Comment lire la sortie de PostgreSQL EXPLAIN ANALYZE
EXPLAIN montre ce que le planificateur a l'intention de faire ; EXPLAIN ANALYZE exécute la requête et montre ce qui s'est réellement passé. Bien lire cette sortie est la compétence au plus fort levier dans le travail de performance PostgreSQL — et elle comporte quelques pièges qui trompent même les développeurs expérimentés.
L'anatomie d'un nœud de plan
Chaque ligne d'un plan est un nœud dans un arbre. Les lignes remontent depuis les nœuds les plus internes (les plus indentés) vers le haut. Un nœud typique ressemble à ceci :
Index Scan using orders_customer_idx on orders
(cost=0.43..152.80 rows=42 width=98)
(actual time=0.031..0.512 rows=38 loops=1)
Deux groupes de nombres, et ils signifient des choses différentes :
cost=0.43..152.80— l'estimation du planificateur, en unités de coût arbitraires (pas des millisecondes). Le premier nombre est le coût de démarrage avant que la première ligne puisse être produite, le second le coût total pour toutes les lignes.rows=42(dans le groupe cost) — combien de lignes le planificateur attendait.actual time=0.031..0.512— les millisecondes mesurées, là encore démarrage..total, par boucle.rows=38 loops=1(dans le groupe actual) — les lignes réellement renvoyées, en moyenne par boucle.
Piège n° 1 : tout est par boucle
Lorsqu'un nœud se trouve du côté interne d'une boucle imbriquée (nested loop), il peut s'exécuter des milliers de fois. PostgreSQL rapporte son actual time et ses rows comme une moyenne par exécution, avec loops qui vous indique combien d'exécutions ont eu lieu :
Index Scan using items_order_idx on items
(actual time=0.005..0.021 rows=3 loops=12000)
Ce nœud n'a pas renvoyé 3 lignes en 0,021 ms. Il a renvoyé environ 36 000 lignes et a consommé environ 250 ms au total (0,021 × 12 000). Multipliez toujours par loops avant de décider si un nœud est bon marché.
Piège n° 2 : les temps sont cumulatifs
Le actual time d'un nœud inclut celui de tous ses enfants. Si un Sort rapporte 900 ms et que le Seq Scan en dessous rapporte 850 ms, le tri lui-même n'a coûté que ~50 ms. Pour trouver où le temps est réellement passé, il vous faut le temps exclusif de chaque nœud : son total moins les totaux de ses enfants. C'est exactement le calcul qui devient fastidieux dans un plan de 40 nœuds — et la principale raison d'être des visualiseurs de plan.
Piège n° 3 : lignes estimées vs réelles
Le diagnostic le plus précieux de toute la sortie est l'écart entre les lignes estimées et réelles. Le plan a été choisi sur la base de l'estimation — si le planificateur attendait 40 lignes et en a obtenu 400 000, chaque décision en aval de ce nœud (stratégie de jointure, dimensionnement de la mémoire, index vs parcours séquentiel) a été prise sur de fausses hypothèses.
Lorsque vous constatez un écart important :
- Exécutez
ANALYZEsur les tables concernées — les statistiques sont peut-être simplement obsolètes. - Si la mauvaise estimation persiste sur une colonne précise, augmentez son niveau de détail d'échantillonnage :
ALTER TABLE t ALTER COLUMN c SET STATISTICS 1000; ANALYZE t; - Si la condition combine des colonnes corrélées (ville + code postal, catégorie + marque), le planificateur multiplie leurs sélectivités comme si elles étaient indépendantes.
CREATE STATISTICSavec le typedependenciesexiste précisément pour ce cas.
Utilisez BUFFERS. Toujours.
EXPLAIN (ANALYZE, BUFFERS) ajoute une ligne comme :
Buffers: shared hit=1520 read=8943 dirtied=12
hit— blocs de 8 Ko trouvés dans le cache des shared buffers de PostgreSQL.read— blocs qui ont dû provenir de l'extérieur des shared buffers (cache de l'OS ou disque).dirtied/written— blocs modifiés ou évincés pendant l'exécution de la requête.
Les compteurs de buffers expliquent la différence entre « rapide dans ma session, lent en production » : le même plan est plus lent de plusieurs ordres de grandeur quand ses blocs ne sont pas en cache. Ils rendent aussi le bloat visible — une requête qui lit 50 000 blocs pour renvoyer 100 lignes étroites lit surtout de l'espace mort ou parcourt bien plus qu'elle ne le devrait.
Autres éléments de sortie à connaître
Rows Removed by Filter— lignes récupérées puis jetées. Un nombre élevé signifie que le chemin d'accès n'est pas sélectif : un index (ou un meilleur index) pourrait éviter la plupart de ces lectures.Sort Method: external merge Disk: 210400kB— le tri ne tenait pas danswork_memet a débordé sur le disque. Même histoire pour les jointures par hachage qui rapportentBatches: 4(plus d'un batch = débordement).(never executed)— le nœud ne s'est jamais exécuté (par exemple, l'autre côté de la jointure a produit zéro ligne). Ses chiffres n'ont aucun sens ; ne l'optimisez pas.loopsavec des workers parallèles — un nœud parallèle affiche une boucle par worker ; les chiffres par boucle sont des moyennes par worker.
Une checklist de lecture
- Trouvez l'
Execution Timetotal en bas — c'est votre budget. - Parcourez l'arbre à la recherche des nœuds dont le temps exclusif (total moins enfants, multiplié par loops) représente une grande part de ce budget. En général, 1 à 3 nœuds dominent.
- Pour chaque nœud critique, comparez les lignes estimées et réelles. Un écart de 10× est un problème de planificateur avant d'être un problème de matériel.
- Vérifiez
BUFFERS: lit-il beaucoup plus de données que le résultat ne le justifie ? - Ce n'est qu'ensuite que vous devez penser aux corrections : statistiques, index, forme de la requête,
work_mem— à peu près dans cet ordre de probabilité.
