Associations polymorphiques en PostgreSQL : comment éviter l'explosion de vos coûts d'infrastructure
Vous avez remarqué que votre boutique en ligne ralentit progressivement, que vos factures d'hébergement grimpent sans raison apparente, et que vos requêtes SQL prennent de plus en plus de temps ? La cause est peut-être cachée dans un coin souvent ignoré de votre base de données : les associations polymorphiques.
Un article récent d'Andrei Lepikhov (publié sur postgr.es) met en lumière un problème de performance bien réel que les équipes techniques de nombreuses applications web subissent sans toujours en identifier l'origine.
Qu'est-ce qu'une association polymorphique ?
Une association polymorphique, c'est une relation en base de données où une colonne peut référencer plusieurs tables différentes selon un discriminant de type. En pratique, cela ressemble à une table order_lines avec deux colonnes : type (qui vaut 'A', 'B' ou 'C') et item_id (l'identifiant de l'entité correspondante).
Les ORMs populaires génèrent ce schéma automatiquement :
- Rails avec
belongs_to :polymorphic - Doctrine (Symfony/PHP) avec l'héritage de table
- Hibernate (Java) avec
@Any - Les plateformes CRM comme Salesforce ou les ERP comme 1C
En Symfony avec Doctrine, vous avez probablement déjà rencontré ce type de mapping sans y prêter attention. C'est pratique à l'écriture, mais c'est un piège à performance à l'exécution.
Le problème : PostgreSQL travaille en aveugle
Voici un exemple typique de requête générée pour la page d'accueil d'une boutique ou le fil d'activité d'un CRM :
SELECT ol.id, COALESCE(p.name, g.name, s.name) AS item_name
FROM order_lines ol
LEFT JOIN products p ON ol.type = 'A' AND ol.item_id = p.id
LEFT JOIN gift_cards g ON ol.type = 'B' AND ol.item_id = g.id
LEFT JOIN subscriptions s ON ol.type = 'C' AND ol.item_id = s.id
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.id = ol.order_id
AND o.placed_at >= DATE '2024-01-01'
)
ORDER BY ol.popularity
LIMIT 100;
Le problème est simple à comprendre : pour chaque ligne de order_lines, PostgreSQL va sonder les trois tables (products, gift_cards, subscriptions), même si une seule peut correspondre. Si type = 'A', les jointures sur gift_cards et subscriptions sont inutiles — mais le moteur ne le sait pas à l'avance.
Résultat : avec N tables de sous-types, vous effectuez N fois plus de travail que nécessaire. Sur une table de plusieurs millions de lignes, cela se traduit directement par :
- 📈 Une consommation CPU démultipliée
- 📈 Une pression accrue sur le cache disque
- 📈 Des temps de réponse qui s'allongent
- 📈 Des coûts d'infrastructure qui explosent
L'optimiseur de PostgreSQL applique ici une logique assez primitive : il ne déduit pas qu'une jointure est systématiquement inutile lorsque la condition de type ne peut jamais être vraie pour un ensemble de lignes donné.
Trois axes d'amélioration en cours dans PostgreSQL
La bonne nouvelle, c'est que la communauté PostgreSQL travaille activement sur ce problème. Trois patches discutés entre 2024 et 2026 sur la liste pgsql-hackers ciblent chacun une source de régression différente.
Sans entrer dans les détails techniques de chaque patch (l'article original les couvre en profondeur), les directions explorées sont :
- Propagation des contraintes de type : permettre à l'optimiseur de déduire, à partir de la valeur de la colonne discriminante, que certaines jointures produiront toujours zéro résultat.
- Élagage des jointures redondantes : supprimer automatiquement du plan d'exécution les
LEFT JOINdont on peut prouver qu'ils ne retourneront jamais de lignes. - Amélioration de l'estimation de cardinalité : mieux estimer le nombre de lignes résultantes quand des conditions de type sont présentes, pour choisir un meilleur ordre de jointure.
Ces améliorations ne sont pas encore disponibles en production dans les versions stables de PostgreSQL, mais elles montrent que le moteur évoluera dans la bonne direction.
Ce que vous pouvez faire dès aujourd'hui
En attendant ces évolutions du moteur, voici les bonnes pratiques à mettre en place sur vos projets Symfony/PHP :
Repenser le schéma si possible. L'héritage de table concrète (une table par sous-type, sans table commune) évite entièrement le problème. Doctrine le supporte avec @InheritanceType("TABLE_PER_CLASS").
Utiliser des requêtes séparées. Plutôt qu'un unique LEFT JOIN sur N tables, exécutez une requête par type et assemblez les résultats en PHP. Moins élégant, mais souvent bien plus rapide.
Analyser vos plans d'exécution. Activez EXPLAIN (ANALYZE, BUFFERS) sur vos requêtes critiques et regardez le nombre de nœuds Hash Join ou Nested Loop inutiles.
Partitionner par type. Si votre table est volumineuse et que les types sont stables, le partitionnement natif de PostgreSQL peut permettre à l'optimiseur d'éliminer des partitions entières.
Mettre en cache les résultats. Pour des pages à fort trafic comme la page d'accueil d'une boutique, un cache Redis bien configuré reste la solution la plus efficace à court terme.
Conclusion
Les associations polymorphiques sont un pattern séduisant pour les développeurs — elles simplifient le code applicatif et s'intègrent naturellement dans les ORMs. Mais elles ont un coût caché qui se manifeste à l'échelle : des requêtes qui sondent inutilement des tables entières, un CPU surchargé, et des coûts d'infrastructure en hausse.
Comme le souligne Andrei Lepikhov dans son analyse, l'optimiseur de PostgreSQL n'est pas encore capable de gérer ce pattern efficacement de manière automatique. La vigilance côté développeur reste donc indispensable : analyser ses plans d'exécution, questionner ses choix de modélisation, et ne pas laisser l'ORM décider seul de la structure de données.
Chez MulerTech, nous intégrons systématiquement l'audit de performance SQL dans nos projets Symfony. Un schéma bien pensé dès le départ, c'est une infrastructure maîtrisée sur le long terme.
Source originale : Optimising Polymorphic Associations in PostgreSQL par Andrei Lepikhov (juin 2026)