Fait partie de Postgres géréPostgres · Performance

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 voyezCe que ça veut dire, d’habitudeQuoi regarder ensuite
total_exec_time élevé, mean_exec_time élevé †Une requête lente à chaque foisEXPLAIN (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+1Le chemin de code, pas le plan
Beaucoup de rows par appel †On va chercher bien plus que ce que la page afficheUn 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 tableUn 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 disqueUn 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
À chercherCe 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 check liste 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 : includes ou preload dans Rails, select_related/prefetch_related dans Django, include dans Prisma, ou une jointure écrite à la main.
  • Pagination par `OFFSET` profond. OFFSET 50000 lit 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) = $1 ne peut pas utiliser un index sur email. 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_table et 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.