Trouver les requêtes lentes Postgres avec pg_stat_statements et EXPLAIN ANALYZE.
Activez pg_stat_statements et triez par temps d’exécution total : les premières lignes montrent où la base passe son temps, souvent une requête rapide appelée des milliers de fois. Lancez ensuite EXPLAIN (ANALYZE, BUFFERS) sur l’une d’elles avec de vraies valeurs, cherchez un parcours séquentiel qui rejette presque tout, et ajoutez l’index qui manque.
Faits vérifiés le
Activer pg_stat_statements
pg_stat_statements est livré avec Postgres, mais reste éteint tant qu’on ne le charge pas. Il a besoin de mémoire partagée : il doit figurer dans shared_preload_libraries, ce qui exige un redémarrage du serveur. Ensuite, un CREATE EXTENSION dans chaque base où vous voulez lire la vue. Il enregistre chaque instruction avec ses constantes remplacées par $1, $2 : WHERE id = 7 et WHERE id = 8 comptent pour une seule requête.
# postgresql.conf (needs a restart)
shared_preload_libraries = 'pg_stat_statements'
compute_query_id = on
-- then, in each database where you want the view
CREATE EXTENSION pg_stat_statements;Les requêtes à lancer
Sur un serveur où vous pouvez lire la vue vous-même, commencez par celle-ci. Triez d’abord par temps total. Une requête de 2 ms appelée 400 000 fois par jour coûte plus cher qu’un rapport de 3 secondes lancé deux fois.
-- Where the time goes: total time, with calls and the average
SELECT round(total_exec_time) AS total_ms,
calls,
round(mean_exec_time::numeric, 2) AS mean_ms,
rows / nullif(calls, 0) AS rows_per_call,
round(100.0 * shared_blks_hit / nullif(shared_blks_hit + shared_blks_read, 0), 1) AS cache_hit_pct,
left(query, 120) AS query
FROM pg_stat_statements
WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
ORDER BY total_exec_time DESC
LIMIT 15;
-- Start a fresh measurement after a fix
SELECT pg_stat_statements_reset();| Ce que vous voyez | Ce que ça veut dire, d’habitude | Quoi regarder ensuite |
|---|---|---|
total_exec_time élevé, mean_exec_time élevé † | Une requête lente à chaque fois | EXPLAIN (ANALYZE, BUFFERS) avec de vraies valeurs |
total_exec_time élevé, moyenne minuscule, calls énorme † | La même recherche lancée dans une boucle : un N+1 | Le chemin de code, pas le plan |
Beaucoup de rows par appel † | On va chercher bien plus que ce que la page affiche | Un LIMIT, moins de colonnes, ou une pagination par clé |
Faible taux de cache (shared_blks_read gros à côté de shared_blks_hit) † | Lecture sur disque, souvent le parcours d’une grosse table | Un index qui réduit le parcours, ou de la mémoire |
temp_blks_written au-dessus de 0 † | Un tri ou un hachage trop gros pour work_mem, qui déborde sur disque | Un index qui rend les lignes déjà triées |
† Les détails sur les concurrents proviennent des pages publiées par chaque fournisseur aux dates indiquées; les prix et forfaits changent, vérifiez auprès du fournisseur avant de décider. `total_exec_time` élevé, `mean_exec_time` élevé (2026-10-29) · `total_exec_time` élevé, moyenne minuscule, `calls` énorme (2026-10-29) · Beaucoup de `rows` par appel (2026-10-29) · Faible taux de cache (`shared_blks_read` gros à côté de `shared_blks_hit`) (2026-10-29) · `temp_blks_written` au-dessus de 0 (2026-10-29)
Lancer EXPLAIN ANALYZE sur la requête du haut
pg_stat_statements affiche $1 à la place des valeurs. Remettez de vraies valeurs : le plan pour un client qui a 40 commandes peut différer de celui d’un client qui en a 400 000. Demandez ensuite à Postgres comment il a exécuté la requête.
-- Postgres 18 adds buffer counts to ANALYZE by itself; on 17, ask for them
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE customer_id = 4821 ORDER BY created_at DESC LIMIT 20;
-- ANALYZE really runs the statement. For writes, roll it back:
BEGIN;
EXPLAIN (ANALYZE, BUFFERS) UPDATE orders SET status = 'paid' WHERE id = 991;
ROLLBACK;Lire le plan
Un plan est un arbre. Chaque ligne est une étape, en retrait sous l’étape qui consomme ses lignes; les lignes les plus profondes s’exécutent en premier. Avec ANALYZE, chaque étape montre deux séries de chiffres : l’estimation du planificateur (cost, rows) et ce qui s’est réellement passé (actual time, rows, loops).
-- Before (Postgres 17 output, trimmed)
Limit (actual time=88.420..88.426 rows=20 loops=1)
Buffers: shared hit=2113 read=6221
-> Sort (actual time=88.418..88.421 rows=20 loops=1)
Sort Key: created_at DESC
-> Seq Scan on orders (cost=0.00..20834.00 rows=51 width=97)
(actual time=0.381..88.301 rows=48 loops=1)
Filter: (customer_id = 4821)
Rows Removed by Filter: 999952
Execution Time: 88.471 ms
-- The fix, as its own migration (CONCURRENTLY cannot run in a transaction)
CREATE INDEX CONCURRENTLY orders_customer_created_idx
ON orders (customer_id, created_at DESC);
-- After
Limit (actual time=0.032..0.061 rows=20 loops=1)
Buffers: shared hit=24
-> Index Scan using orders_customer_created_idx on orders
(actual time=0.031..0.057 rows=20 loops=1)
Index Cond: (customer_id = 4821)
Execution Time: 0.083 ms| À chercher | Ce que ça vous dit |
|---|---|
Seq Scan avec un gros Rows Removed by Filter † | Postgres a lu toute la table pour garder quelques lignes. L’index manquant classique. |
rows estimé très loin du rows réel † | Statistiques périmées ou trop minces : le planificateur a choisi un plan pour la mauvaise taille. Lancez ANALYZE sur la table. |
loops au-dessus de 1 sur une étape interne † | Cette étape a roulé une fois par ligne externe. Multipliez son temps par loops pour son vrai coût. |
Sort Method qui indique le disque plutôt que la mémoire † | Le tri n’entrait pas dans work_mem. Un index dans le même ordre peut éviter le tri. |
Buffers: shared read bien plus gros que hit † | Les données venaient du disque, pas du cache. Lire moins de pages, c’est le but de la plupart des correctifs. |
† Les détails sur les concurrents proviennent des pages publiées par chaque fournisseur aux dates indiquées; les prix et forfaits changent, vérifiez auprès du fournisseur avant de décider. `Seq Scan` avec un gros `Rows Removed by Filter` (2026-10-29) · `rows` estimé très loin du `rows` réel (2026-10-29) · `loops` au-dessus de 1 sur une étape interne (2026-10-29) · `Sort Method` qui indique le disque plutôt que la mémoire (2026-10-29) · `Buffers: shared read` bien plus gros que `hit` (2026-10-29)
Les correctifs courants
Construisez les index avec CREATE INDEX CONCURRENTLY sur une table en service pour que les écritures continuent, dans une migration à part, hors transaction. Où exécuter les migrations explique comment Rails et Django s’en occupent.
- Index manquant sur une colonne de filtre ou de jointure. Les clés étrangères sont l’oubli habituel : Postgres indexe la clé primaire, pas la colonne qui pointe vers elle. Chez Canner,
canner db checkliste les clés étrangères sans index et les index en double, sur tous les forfaits, à partir du catalogue seulement. - Requêtes N+1. Une requête pour la liste, puis une par ligne. Le correctif est dans l’ORM :
includesoupreloaddans Rails,select_related/prefetch_relateddans Django,includedans Prisma, ou une jointure écrite à la main. - Pagination par `OFFSET` profond.
OFFSET 50000lit puis jette 50 000 lignes. Paginez plutôt par clé :WHERE id < $dernier_vu ORDER BY id DESC LIMIT 20. - Une fonction sur la colonne.
WHERE lower(email) = $1ne peut pas utiliser un index suremail. Indexez l’expression :CREATE INDEX ON users (lower(email)). - Trop de données par ligne. Un
SELECT *sur une table aux grosses colonnes JSON ou texte transporte tout. Sélectionnez les colonnes dont la page a besoin. - Statistiques périmées après un chargement massif. Lancez
ANALYZE nom_de_tableet refaites l’EXPLAIN.
Ce que cette configuration ne vous donne pas
Chez Canner, les compteurs partent du moment où vous avez activé la supervision ou de la dernière remise à zéro, et le journal garde sept jours. Il n’y a pas d’historique des requêtes à long terme, ni de traçage d’une requête HTTP lente jusqu’à la requête SQL derrière. Si vous en avez besoin, un outil d’APM dans votre appli le fait mieux.
Sur une base sans instance dédiée, pg_stat_statements n’est pas offert. EXPLAIN ANALYZE fonctionne partout où vous pouvez lancer du SQL, par exemple psql par l’accès externe, et canner db check trouve quand même les index de clés étrangères manquants.
Questions
Pourquoi pg_stat_statements dit-il que la relation n’existe pas?
La bibliothèque est chargée pour tout le serveur, mais la vue n’existe que dans les bases où quelqu’un a lancé CREATE EXTENSION pg_stat_statements. La charger exige aussi shared_preload_libraries et un redémarrage.
EXPLAIN ANALYZE exécute-t-il la requête?
Oui. Il exécute l’instruction et jette le résultat : un UPDATE ou un DELETE modifie donc les données. Entourez les écritures de BEGIN et ROLLBACK. Un EXPLAIN simple, sans ANALYZE, ne fait que planifier.
Faut-il trier par temps total ou par temps moyen?
Par temps total d’abord : c’est ce que la base a réellement dépensé. Le temps moyen trouve les requêtes lentes pour chaque utilisateur. Une requête à la moyenne minuscule et au nombre d’appels énorme est généralement un N+1.
Comment trouver les requêtes N+1 dans Postgres?
Dans pg_stat_statements, cherchez une recherche simple, souvent par clé primaire, avec un nombre d’appels très élevé et un petit temps moyen. Trouvez ensuite la boucle de votre code qui la lance une fois par ligne.
Peut-on utiliser les requêtes lentes avec le forfait gratuit de Canner?
Les requêtes lentes et le journal Postgres exigent l’instance dédiée, à 9 $ par mois par projet. Sur tous les forfaits, vous pouvez lancer EXPLAIN ANALYZE et canner db check.
Voyez vos requêtes les plus lentes chez Canner.
Ajoutez l’instance dédiée à un projet, activez les requêtes lentes et triez par temps total.