Je suis Charlie

Autres trucs

Accueil

Seulement les RFC

Seulement les fiches de lecture

Mon livre « Cyberstructure »

Ève

Améliorer les temps de réponse des requêtes PostgreSQL avec EXPLAIN

Première rédaction de cet article le 2 juillet 2009


Un des pièges de SQL est que c'est un langage de haut niveau. Le rapport entre la commande qu'on tape et le travail que doit faire la machine est beaucoup moins direct et beaucoup plus dur à saisir qu'avec l'assembleur. Il est donc fréquent qu'une requête SQL qui n'aie pas l'air bien méchante prenne des heures à s'exécuter. Heureusement, PostgreSQL dispose d'une excellent commande EXPLAIN qui explique, avant même qu'on exécute la commande, ce qu'elle va faire et ce qui va prendre du temps.

Un excellent exposé d'introduction est Explaining EXPLAIN mais, uniquement avec les transparents, c'est un peu court. Voyons des exemples d'utilisation d'EXPLAIN sur la base de données de StackOverflow.

Je commence par une requête triviale, SELECT * FROM Votes et je la fais précéder de EXPLAIN pour demander à PostgreSQL de m'expliquer :

so=> EXPLAIN SELECT * FROM Votes;
                           QUERY PLAN                           
----------------------------------------------------------------
 Seq Scan on votes  (cost=0.00..36658.37 rows=2379537 width=16)
(1 row)

On apprend que PostgreSQL va faire une recherche séquentielle (Seq Scan), ce qui est normal, puisqu'il faudra tout trouver, que 2379537 tuples (lignes de la table SQL) sont à examiner. Et que le coût sera entre... zéro et 36658,37. Pourquoi zéro ? Parce que PostgreSQL affiche le coût de récupérer le premier tuple (important si on traite les données au fur et à mesure, ce qui est souvent le cas en OLTP) et celui de récupérer tous les tuples. (Utiliser LIMIT ne change rien, PostgreSQL calcule les coûts comme si LIMIT n'était pas là.)

Et le 36658,37 ? C'est 36658,37 quoi ? Pommes, bananes, euros, bons points ? Ce coût est exprimé dans une unité arbitraire qui dépend notamment de la puissance de la machine. Avant de lire toute la documentation sur le calcul des coûts, faisons une autre requête :

so=> EXPLAIN SELECT count(*) FROM Votes;
                             QUERY PLAN                              
---------------------------------------------------------------------
 Aggregate  (cost=42607.21..42607.22 rows=1 width=0)
   ->  Seq Scan on votes  (cost=0.00..36658.37 rows=2379537 width=0)
(2 rows)

Cette fois, le coût du premier tuple est le même que le coût total, ce qui est logique, puisque les fonctions d'agrégation comme count() nécessitent de récupérer toutes les données.

Les coûts qu'affiche EXPLAIN ne sont que des coûts estimés, sans faire effectivement tourner la requête (donc, EXPLAIN est en général très rapide). Si on veut les temps réels, on peut ajouter le mot-clé ANALYZE :

so=> EXPLAIN ANALYZE SELECT * FROM Votes;
                                                    QUERY PLAN                                                     
-------------------------------------------------------------------------------------------------------------------
 Seq Scan on votes  (cost=0.00..36658.37 rows=2379537 width=16) (actual time=0.042..3036.302 rows=2379537 loops=1)
 Total runtime: 5973.786 ms
(2 rows)

Cette fois, on a le temps effectif, trois secondes. On peut en déduire que, sur cette machine, l'unité de coût de PostgreSQL vaut environ 0,08 milli-secondes (3036,302 / 36658,37). Sur un serveur identique, mais avec des disques de modèle différent et un autre système de fichiers, on arrive à une valeur quasiment identique. Mais sur un simple portable, je mesure un facteur de 0,5 milli-secondes par unité de coût.

De toute façon, il faut faire attention : ce facteur d'échelle dépend de beaucoup de choses, à commencer par les réglages du SGBD. Est-ce que ce ratio de 0,08 milli-secondes peut nous aider à prévoir le temps d'exécution d'une requête ? Voyons une requête plus compliquée qui compte le nombre de votes ayant eu lieu dans les 24 heures ayant suivi le message :

so=> EXPLAIN SELECT count(votes.id) FROM votes, posts WHERE votes.post = posts.id AND 
so->                             votes.creation <= posts.creation + interval '1 day';
                                   QUERY PLAN                                    
---------------------------------------------------------------------------------
 Aggregate  (cost=147911.94..147911.95 rows=1 width=4)
   ->  Hash Join  (cost=31161.46..145928.99 rows=793179 width=4)
         Hash Cond: (votes.post = posts.id)
         Join Filter: (votes.creation <= (posts.creation + '1 day'::interval))
         ->  Seq Scan on votes  (cost=0.00..36658.37 rows=2379537 width=12)
         ->  Hash  (cost=15834.65..15834.65 rows=881665 width=12)
               ->  Seq Scan on posts  (cost=0.00..15834.65 rows=881665 width=12)
(7 rows)

PostgreSQL affiche un coût de 147911,95 soit 11,8 secondes. Et le résultat effectif est :

so=> EXPLAIN ANALYZE SELECT count(votes.id) FROM votes, posts WHERE votes.post = posts.id AND 
                            votes.creation <= posts.creation + interval '1 day';
                                                            QUERY PLAN                                                             
-----------------------------------------------------------------------------------------------------------------------------------
 Aggregate  (cost=147911.94..147911.95 rows=1 width=4) (actual time=15125.455..15125.456 rows=1 loops=1)
...

soit 15 secondes au lieu des 11 prévues, ce qui n'est pas trop mal, pour une fonction de coût qui agrège beaucoup de choses différentes.

Si on trouve le résultat de EXPLAIN ANALYZE un peu dur à lire, on peut le copier-coller dans http://explain-analyze.info/ et on a alors une présentation plus jolie.

Que voit-on d'autre sur la sortie de EXPLAIN pour cette requête ? Que les coûts s'additionnent (regardez bien l'indentation des lignes commençant par -> pour voir comment faire l'addition). La recherche séquentielle la plus interne (sur la table Posts) coûte 15834,65, le hachage coûte le même prix (il n'a rien ajouté), l'autre recherche séquentielle (sur la table Votes) a un coût de 36658,37 et ces deux coûts vont s'ajouter, ainsi que le filtrage et le test de jointure, pour donner 145928,99, le coût de la jointure. Ensuite, un petit coût supplémentaire restera à payer pour l'agrégat (count()) nous emmenant à 147911,95.

L'addition des coûts se voit encore mieux sur cette requête :

so=> EXPLAIN SELECT count(votes.id)*100/(SELECT count(votes.id) FROM Posts, Votes 
so(>                                 WHERE Votes.post = Posts.id) AS percent
so->        FROM votes, posts WHERE 
so->                    votes.post = posts.id AND 
so->                    votes.creation > (posts.creation + interval '1 day')::date;
                                             QUERY PLAN                                             
----------------------------------------------------------------------------------------------------
 Aggregate  (cost=287472.95..287472.97 rows=1 width=4)
   InitPlan
     ->  Aggregate  (cost=133612.15..133612.16 rows=1 width=4)
           ->  Hash Join  (cost=30300.46..127663.31 rows=2379537 width=4)
                 Hash Cond: (public.votes.post = public.posts.id)
                 ->  Seq Scan on votes  (cost=0.00..36658.37 rows=2379537 width=8)
                 ->  Hash  (cost=15834.65..15834.65 rows=881665 width=4)
                       ->  Seq Scan on posts  (cost=0.00..15834.65 rows=881665 width=4)
   ->  Hash Join  (cost=31161.46..151877.84 rows=793179 width=4)
         Hash Cond: (public.votes.post = public.posts.id)
         Join Filter: (public.votes.creation > ((public.posts.creation + '1 day'::interval))::date)
         ->  Seq Scan on votes  (cost=0.00..36658.37 rows=2379537 width=12)
         ->  Hash  (cost=15834.65..15834.65 rows=881665 width=12)
               ->  Seq Scan on posts  (cost=0.00..15834.65 rows=881665 width=12)
(14 rows)

dont le temps d'exécution effectif est de 30 secondes, très proche des 23 secondes calculées en multipliant le coût total de 287472,97 par le facteur d'échelle de 0,08 millisecondes.

Plusieurs essais avec des bases très différentes montrent que le facteur d'échelle (la valeur en milli-secondes d'une unité de coût) est plutôt stable. Voici un résultat avec la base de DNSmezzo :

dnsmezzo2=> EXPLAIN ANALYZE SELECT DISTINCT substr(registered_domain,1,43) AS domain,count(registered_domain) AS num FROM DNS_packets WHERE file=3 AND NOT query AND rcode= 3 GROUP BY registered_domain ORDER BY count(registered_domain) DESC;
                                                                            QUERY PLAN                                                                            
------------------------------------------------------------------------------------------------------------------------------------------------------------------
 Unique  (cost=496460.23..496460.31 rows=10 width=12) (actual time=41233.482..42472.861 rows=234779 loops=1)
   ->  Sort  (cost=496460.23..496460.26 rows=10 width=12) (actual time=41233.478..41867.259 rows=234780 loops=1)
         Sort Key: (count(registered_domain)), (substr(registered_domain, 1, 43))
         Sort Method:  external merge  Disk: 11352kB
         ->  HashAggregate  (cost=496459.91..496460.06 rows=10 width=12) (actual time=38954.395..39462.324 rows=234780 loops=1)
               ->  Bitmap Heap Scan on dns_packets  (cost=197782.55..496245.34 rows=42914 width=12) (actual time=34594.268..38306.833 rows=250006 loops=1)
                     Recheck Cond: ((rcode = 3) AND (file = 3))
                     Filter: (NOT query)
                     ->  BitmapAnd  (cost=197782.55..197782.55 rows=87519 width=0) (actual time=34590.468..34590.468 rows=0 loops=1)
                           ->  Bitmap Index Scan on rcode_idx  (cost=0.00..74228.75 rows=1250275 width=0) (actual time=28144.654..28144.654 rows=1290694 loops=1)
                                 Index Cond: (rcode = 3)
                           ->  Bitmap Index Scan on pcap_idx  (cost=0.00..123532.10 rows=2083791 width=0) (actual time=6393.187..6393.187 rows=2233781 loops=1)
                                 Index Cond: (file = 3)
 Total runtime: 42788.863 ms
(14 rows)

Quelques ressources intéressantes si on veut accelérer ses requêtes PostgreSQL :

Version PDF de cette page (mais vous pouvez aussi imprimer depuis votre navigateur, il y a une feuille de style prévue pour cela)

Source XML de cette page (cette page est distribuée sous les termes de la licence GFDL)