Solutions
Affichage de plans de requêtes simples
Recherche de motif texte
Affichez le plan de cette requête (sur la base cave
).
SELECT * FROM appellation WHERE libelle LIKE 'Brouilly%' ;
cave=# explain SELECT * FROM appellation WHERE libelle LIKE 'Brouilly%' ;
QUERY PLAN
------------------------------------------------------------
Seq Scan on appellation (cost=0.00 ..6.99 rows=1 width=24 )
Filter: (libelle ~~ 'Brouilly%' ::text )
(2 lignes)
Que constatez-vous ?
Affichez maintenant le nombre de blocs accédés par cette requête.
cave=# explain (analyze ,buffers) SELECT * FROM appellation
cave=# WHERE libelle LIKE 'Brouilly%' ;
QUERY PLAN
--------------------------------------------------------------------------------
Seq Scan on appellation (cost=0.00 ..6.99 rows=1 width=24 )
(actual time=0.066 ..0.169 rows=1 loops=1 )
Filter: (libelle ~~ 'Brouilly%' ::text )
Rows Removed by Filter: 318
Buffers: shared hit=3
Total runtime: 0.202 ms
(5 lignes)
Cette requête ne passe pas par un index. Essayez de lui forcer la main.
cave=# set enable_seqscan TO off ;
SET
cave=# explain (analyze ,buffers) SELECT * FROM appellation
cave=# WHERE libelle LIKE 'Brouilly%' ;
QUERY PLAN
--------------------------------------------------------------------------------
Seq Scan on appellation (cost=10000000000.00 ..10000000006.99 rows=1 width=24 )
(actual time=0.073 ..0.197 rows=1 loops=1 )
Filter: (libelle ~~ 'Brouilly%' ::text )
Rows Removed by Filter: 318
Buffers: shared hit=3
Total runtime: 0.238 ms
(5 lignes)
Passer enable_seqscan
à « off » n'interdit pas l'utilisation des scans séquentiels. Il ne fait que les défavoriser fortement : regardez le coût estimé du scan séquentiel.
L'index n'est toujours pas utilisé. L'index « par défaut » n'est pas capable de répondre à des questions sur motif.
En effet, l'index par défaut trie les données par la collation de la colonne de la table. Il lui est impossible de savoir que libelle LIKE 'Brouilly%'
est équivalent à libelle >= 'Brouilly' AND libelle < 'Brouillz'
. Ce genre de transformation n'est d'ailleurs pas forcément trivial, ni même possible. Il existe dans certaines langues des équivalences (ß et ss en allemand par exemple) qui rendent ce genre de transformation au mieux hasardeuse.
Créez un index capable de ces opérations. Testez à nouveau le plan.
Pour pouvoir répondre à cette question, on doit donc avoir un index spécialisé, qui compare les chaînes non plus par rapport à leur collation, mais à leur valeur binaire (octale en fait).
CREATE INDEX appellation_libelle_key_search
ON appellation (libelle text_pattern_ops);
On indique par cette commande à PostgreSQL de ne plus utiliser la classe d'opérateurs habituelle de comparaison de texte, mais la classe text_pattern_ops
, qui est spécialement faite pour les recherches LIKE 'xxxx%'
: cette classe ne trie plus les chaînes par leur ordre alphabétique, mais par leur valeur octale.
Si on redemande le plan :
cave=# EXPLAIN (ANALYZE ,BUFFERS) SELECT * FROM appellation
cave=# WHERE libelle LIKE 'Brouilly%' ;
QUERY PLAN
--------------------------------------------------------------------------------
Index Scan using appellation_libelle_key_search on appellation
(cost=0.27 ..8.29 rows=1 width=24 )
(actual time=0.057 ..0.059 rows=1 loops=1 )
Index Cond: ((libelle ~>=~ 'Brouilly' ::text )
AND (libelle ~<~ 'Brouillz' ::text ))
Filter: (libelle ~~ 'Brouilly%' ::text )
Buffers: shared hit=1 read=2
Total runtime: 0.108 ms
(5 lignes)
On utilise enfin un index.
Réactivez enable_seqscan
. Testez à nouveau le plan.
cave=# reset enable_seqscan ;
RESET
cave=# explain (analyze ,buffers) SELECT * FROM appellation
cave=# WHERE libelle LIKE 'Brouilly%' ;
QUERY PLAN
--------------------------------------------------------------------------------
Seq Scan on appellation (cost=0.00 ..6.99 rows=1 width=24 )
(actual time=0.063 ..0.172 rows=1 loops=1 )
Filter: (libelle ~~ 'Brouilly%' ::text )
Rows Removed by Filter: 318
Buffers: shared hit=3
Total runtime: 0.211 ms
(5 lignes)
Quelle est la conclusion ?
PostgreSQL choisit de ne pas utiliser cet index. Le temps d'exécution est pourtant un peu meilleur avec l'index (60 microsecondes contre 172 microsecondes). Néanmoins, cela n'est vrai que parce que les données sont en cache. En cas de données hors du cache, le plan par parcours séquentiel (seq scan ) est probablement meilleur. Certes il prend plus de temps CPU puisqu'il doit consulter 318 enregistrements inutiles. Par contre, il ne fait qu'un accès à 3 blocs séquentiels (les 3 blocs de la table), ce qui est le plus sûr.
La table est trop petite pour que PostgreSQL considère l'utilisation d'un index.
Recherche de motif texte avancé
La base cave
ne contient pas de données textuelles appropriées, nous allons en utiliser une autre.
Lancez textes.sql
ou textes_10pct.sql
(préférable sur une machine peu puissante, ou une instance PostgreSQL non paramétrée).
Ce script crée une table textes
, contenant le texte intégral d'un grand nombre de livres en français du projet Gutenberg, soit 10 millions de lignes pour 85 millions de mots.
Nous allons rechercher toutes les références à « Fantine » dans les textes. On devrait trouver beaucoup d'enregistrements provenant des « Misérables ».
La méthode SQL standard pour écrire cela est :
SELECT * FROM textes WHERE contenu ILIKE '%fantine%' ;
Exécutez cette requête, et regardez son plan d'exécution.
textes=# explain (analyze ,buffers) SELECT * FROM textes
textes=# WHERE contenu ILIKE '%fantine%' ;
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------
Seq Scan on textes (cost=0.00 ..325809.40 rows=874 width=102 )
(actual time=224.634 ..22567.231 rows=921 loops=1 )
Filter: (contenu ~~* '%fantine%' ::text )
Rows Removed by Filter: 11421523
Buffers: shared hit=130459 read=58323
Total runtime: 22567.679 ms
(5 lignes)
Cette requête ne peut pas être optimisée avec les index standard (btree
) : c'est une recherche insensible à la casse et avec plusieurs % dont un au début.
Avec GiST
Créez un index trigramme:
textes=# CREATE EXTENSION pg_trgm;
textes=# CREATE INDEX idx_trgm ON textes USING gist (contenu gist_trgm_ops);
CREATE INDEX
Temps : 962794 ,399 ms
Quelle est la taille de l'index ?
L'index fait cette taille (pour une table de 1,5Go) :
textes=# select pg_size_pretty(pg_relation_size('idx_trgm' ));
pg_size_pretty
----------------
2483 MB
(1 ligne)
Réexécutez la requête. Que constatez-vous ?
textes=# explain (analyze ,buffers) SELECT * FROM textes
textes=# WHERE contenu ILIKE '%fantine%' ;
QUERY PLAN
--------------------------------------------------------------------------------
Bitmap Heap Scan on textes (cost=111.49 ..3573.39 rows=912 width=102 )
(actual time=1942.872 ..1949.393 rows=922 loops=1 )
Recheck Cond: (contenu ~~* '%fantine%' ::text )
Rows Removed by Index Recheck: 75
Buffers: shared hit=16030 read=144183 written=14741
-> Bitmap Index Scan on idx_trgm (cost=0.00 ..111.26 rows=912 width=0 )
(actual time=1942.671 ..1942.671 rows=997 loops=1 )
Index Cond: (contenu ~~* '%fantine%' ::text )
Buffers: shared hit=16029 read=143344 written=14662
Total runtime: 1949.565 ms
(8 lignes)
Temps : 1951 ,175 ms
PostgreSQL dispose de mécanismes spécifiques avancés pour certains types de données. Ils ne sont pas toujours installés en standard, mais leur connaissance peut avoir un impact énorme sur les performances.
Le mécanisme GiST est assez efficace pour répondre à ce genre de questions. Il nécessite quand même un accès à un grand nombre de blocs, d'après le plan : 160 000 blocs lus, 15 000 écrits (dans un fichier temporaire, on pourrait s'en débarrasser en augmentant le work_mem
). Le gain est donc conséquent, mais pas gigantesque : le plan initial lisait 190 000 blocs. On gagne surtout en temps de calcul, car on accède directement aux bons enregistrements. Le parcours de l'index, par contre, est coûteux.
Avec Gin
Créez un index trigramme:
textes=# CREATE EXTENSION pg_trgm;
textes=# CREATE INDEX idx_trgm ON textes USING gin (contenu gin_trgm_ops);
CREATE INDEX
Temps : 591534 ,917 ms
L'index fait cette taille (pour une table de 1,5Go) :
textes=# select pg_size_pretty(pg_total_relation_size('textes' ));
pg_size_pretty
----------------
4346 MB
(1 ligne)
L'index est très volumineux.
Réexécutez la requête. Que constatez-vous ?
textes=# explain (analyze ,buffers) SELECT * FROM textes
textes=# WHERE contenu ILIKE '%fantine%' ;
QUERY PLAN
--------------------------------------------------------------------------------
Bitmap Heap Scan on textes (cost=103.06 ..3561.22 rows=911 width=102 )
(actual time=777.469 ..780.834 rows=921 loops=1 )
Recheck Cond: (contenu ~~* '%fantine%' ::text )
Rows Removed by Index Recheck: 75
Buffers: shared hit=2666
-> Bitmap Index Scan on idx_trgm (cost=0.00 ..102.83 rows=911 width=0 )
(actual time=777.283 ..777.283 rows=996 loops=1 )
Index Cond: (contenu ~~* '%fantine%' ::text )
Buffers: shared hit=1827
Total runtime: 780.954 ms
(8 lignes)
PostgreSQL dispose de mécanismes spécifiques avancés pour certains types de données. Ils ne sont pas toujours installés en standard, mais leur connaissance peut avoir un impact énorme sur les performances. Le mécanisme Gin est vraiment très efficace pour répondre à ce genre de questions. Il s'agit de répondre en moins d'une seconde à « quelles lignes contiennent la chaîne "fantine" ? » sur 12 millions de lignes de texte. Les Index Gin sont par contre très coûteux à maintenir. Ici, on n'accède qu'à 2 666 blocs, ce qui est vraiment excellent. Mais l'index est bien plus volumineux que l'index GiST.
Avec le Full Text Search
Le résultat sera bien sûr différent, et le FTS est moins souple.
Version GiST :
textes=# create index idx_fts
on textes
using gist (to_tsvector('french' ,contenu));
CREATE INDEX
Temps : 1807467 ,811 ms
textes=# EXPLAIN (analyze ,buffers) SELECT * FROM textes
textes=# WHERE to_tsvector('french' ,contenu) @@ to_tsquery('french' ,'fantine' );
QUERY PLAN
------------------------------------------------------------------------
Bitmap Heap Scan on textes (cost=2209.51 ..137275.87 rows=63109 width=97 )
(actual time=648.596 ..659.733 rows=311 loops=1 )
Recheck Cond: (to_tsvector('french' ::regconfig , contenu) @@
' '' fantin '' ' ::tsquery )
Buffers: shared hit=37165
-> Bitmap Index Scan on idx_fts (cost=0.00 ..2193.74 rows=63109 width=0 )
(actual time=648.493 ..648.493 rows=311 loops=1 )
Index Cond: (to_tsvector('french' ::regconfig , contenu) @@
' '' fantin '' ' ::tsquery )
Buffers: shared hit=37016
Total runtime: 659.820 ms
(7 lignes)
Temps : 660 ,364 ms
Et la taille de l'index :
textes=# select pg_size_pretty(pg_relation_size('idx_fts' ));
pg_size_pretty
----------------
671 MB
(1 ligne)
Version Gin :
textes=# CREATE INDEX idx_fts ON textes
textes=# USING gin (to_tsvector('french' ,contenu));
CREATE INDEX
Temps : 491499 ,599 ms
textes=# EXPLAIN (analyze ,buffers) SELECT * FROM textes
textes=# WHERE to_tsvector('french' ,contenu) @@ to_tsquery('french' ,'fantine' );
QUERY PLAN
------------------------------------------------------------------------------
Bitmap Heap Scan on textes (cost=693.10 ..135759.45 rows=63109 width=97 )
(actual time=0.278 ..0.699 rows=311 loops=1 )
Recheck Cond: (to_tsvector('french' ::regconfig , contenu) @@
' '' fantin '' ' ::tsquery )
Buffers: shared hit=153
-> Bitmap Index Scan on idx_fts (cost=0.00 ..677.32 rows=63109 width=0 )
(actual time=0.222 ..0.222 rows=311 loops=1 )
Index Cond: (to_tsvector('french' ::regconfig , contenu) @@
' '' fantin '' ' ::tsquery )
Buffers: shared hit=4
Total runtime: 0.793 ms
(7 lignes)
Temps : 1 ,534 ms
Taille de l'index :
textes=# select pg_size_pretty(pg_relation_size('idx_fts' ));
pg_size_pretty
----------------
593 MB
(1 ligne)
On constate donc que le Full Text Search est bien plus efficace que le trigramme, du moins pour le Full Text Search + Gin : trouver 1 mot parmi plus de cent millions, dans 300 endroits différents dure 1,5 ms.
Par contre, le trigramme permet des recherches floues (orthographe approximative), et des recherches sur autre chose que des mots, même si ces points ne sont pas abordés ici.
Optimisation d'une requête
Optimisation 1
Nous travaillerons sur la requête contenue dans le fichier requete1.sql
pour cet exercice:
-- \timing
-- explain analyze
select
m.annee||' - ' ||a.libelle as millesime_region,
sum (s.nombre) as contenants,
sum (s.nombre*c.contenance) as litres
from
contenant c
join stock s
on s.contenant_id = c.id
join (select round (random ()*50 )+1950 as annee) m
on s.annee = m.annee
join vin v
on s.vin_id = v.id
left join appellation a
on v.appellation_id = a.id
group by m.annee||' - ' ||a.libelle;
L'exécution de la requête donne le plan suivant, avec un temps qui peut varier en fonction de la machine utilisée et de son activité:
HashAggregate (cost=12763.56..12773.13 rows=319 width=32)
(actual time=1542.472..1542.879 rows=319 loops=1)
-> Hash Left Join (cost=184.59..12741.89 rows=2889 width=32)
(actual time=180.263..1520.812 rows=11334 loops=1)
Hash Cond: (v.appellation_id = a.id)
-> Hash Join (cost=174.42..12663.10 rows=2889 width=20)
(actual time=179.426..1473.270 rows=11334 loops=1)
Hash Cond: (s.contenant_id = c.id)
-> Hash Join (cost=173.37..12622.33 rows=2889 width=20)
(actual time=179.401..1446.687 rows=11334 loops=1)
Hash Cond: (s.vin_id = v.id)
-> Hash Join (cost=0.04..12391.22 rows=2889 width=20)
(actual time=164.388..1398.643 rows=11334 loops=1)
Hash Cond: ((s.annee)::double precision =
((round((random() * 50::double precision)) +
1950::double precision)))
-> Seq Scan on stock s
(cost=0.00..9472.86 rows=577886 width=16)
(actual time=0.003..684.039 rows=577886 loops=1)
-> Hash (cost=0.03..0.03 rows=1 width=8)
(actual time=0.009..0.009 rows=1 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 1kB
-> Result (cost=0.00..0.02 rows=1 width=0)
(actual time=0.005..0.006 rows=1 loops=1)
-> Hash (cost=97.59..97.59 rows=6059 width=8)
(actual time=14.987..14.987 rows=6059 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 237kB
-> Seq Scan on vin v
(cost=0.00..97.59 rows=6059 width=8)
(actual time=0.009..7.413 rows=6059 loops=1)
-> Hash (cost=1.02..1.02 rows=2 width=8)
(actual time=0.013..0.013 rows=2 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 1kB
-> Seq Scan on contenant c
(cost=0.00..1.02 rows=2 width=8)
(actual time=0.003..0.005 rows=2 loops=1)
-> Hash (cost=6.19..6.19 rows=319 width=20)
(actual time=0.806..0.806 rows=319 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 17kB
-> Seq Scan on appellation a
(cost=0.00..6.19 rows=319 width=20)
(actual time=0.004..0.379 rows=319 loops=1)
Total runtime: 1543.242 ms
(23 rows)
Nous créons à présent un index sur stock.annee
comme suit :
create index stock_annee on stock (annee) ;
Et exécutons à nouveau la requête. Hélas nous constatons que rien ne change, ni le plan, ni le temps pris par la requête.
Nous n'avons pas lancé ANALYZE
, cela explique que l'optimiseur n'utilise pas l'index : il n'en a pas encore la connaissance.
Le plan n'a toujours pas changé ! Ni le temps d'exécution ?!
Interdisons donc de faire les seq scans à l'optimiseur :
SET ENABLE_SEQSCAN TO OFF ;
Nous remarquons que le plan d'exécution est encore pire :
HashAggregate (cost=40763.39..40772.96 rows=319 width=32)
(actual time=2022.971..2023.390 rows=319 loops=1)
-> Hash Left Join (cost=313.94..40741.72 rows=2889 width=32)
(actual time=18.149..1995.889 rows=11299 loops=1)
Hash Cond: (v.appellation_id = a.id)
-> Hash Join (cost=290.92..40650.09 rows=2889 width=20)
(actual time=17.172..1937.644 rows=11299 loops=1)
Hash Cond: (s.vin_id = v.id)
-> Nested Loop (cost=0.04..40301.43 rows=2889 width=20)
(actual time=0.456..1882.531 rows=11299 loops=1)
Join Filter: (s.contenant_id = c.id)
-> Hash Join (cost=0.04..40202.48 rows=2889 width=20)
(actual time=0.444..1778.149 rows=11299 loops=1)
Hash Cond: ((s.annee)::double precision =
((round((random() * 50::double precision)) +
1950::double precision)))
-> Index Scan using stock_pkey on stock s
(cost=0.00..37284.12 rows=577886 width=16)
(actual time=0.009..1044.061 rows=577886 loops=1)
-> Hash (cost=0.03..0.03 rows=1 width=8)
(actual time=0.011..0.011 rows=1 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 1kB
-> Result (cost=0.00..0.02 rows=1 width=0)
(actual time=0.005..0.006 rows=1 loops=1)
-> Materialize (cost=0.00..12.29 rows=2 width=8)
(actual time=0.001..0.003 rows=2 loops=11299)
-> Index Scan using contenant_pkey on contenant c
(cost=0.00..12.28 rows=2 width=8)
(actual time=0.004..0.010 rows=2 loops=1)
-> Hash (cost=215.14..215.14 rows=6059 width=8)
(actual time=16.699..16.699 rows=6059 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 237kB
-> Index Scan using vin_pkey on vin v
(cost=0.00..215.14 rows=6059 width=8)
(actual time=0.010..8.871 rows=6059 loops=1)
-> Hash (cost=19.04..19.04 rows=319 width=20)
(actual time=0.936..0.936 rows=319 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 17kB
-> Index Scan using appellation_pkey on appellation a
(cost=0.00..19.04 rows=319 width=20)
(actual time=0.016..0.461 rows=319 loops=1)
Total runtime: 2023.742 ms
(22 rows)
Que faire alors ?
Il convient d'autoriser à nouveau les seq scan , puis, peut-être, de réécrire la requête.
Nous réécrivons la requête comme suit (fichier requete2.sql
) :
explain analyze
select
s.annee||' - ' ||a.libelle as millesime_region,
sum (s.nombre) as contenants,
sum (s.nombre*c.contenance) as litres
from
contenant c
join stock s
on s.contenant_id = c.id
join vin v
on s.vin_id = v.id
left join appellation a
on v.appellation_id = a.id
where s.annee = (select round (random ()*50 )+1950 as annee)
group by s.annee||' - ' ||a.libelle;
Il y a une jointure en moins, ce qui est toujours appréciable. Nous pouvons faire cette réécriture parce que la requête select round(random()*50)+1950 as annee
ne ramène qu'un seul enregistrement.
Voici le résultat :
HashAggregate (cost=12734.64..12737.10 rows=82 width=28)
(actual time=265.899..266.317 rows=319 loops=1)
InitPlan 1 (returns $0)
-> Result (cost=0.00..0.02 rows=1 width=0)
(actual time=0.005..0.006 rows=1 loops=1)
-> Hash Left Join (cost=184.55..12712.96 rows=2889 width=28)
(actual time=127.787..245.314 rows=11287 loops=1)
Hash Cond: (v.appellation_id = a.id)
-> Hash Join (cost=174.37..12634.17 rows=2889 width=16)
(actual time=126.950..208.077 rows=11287 loops=1)
Hash Cond: (s.contenant_id = c.id)
-> Hash Join (cost=173.33..12593.40 rows=2889 width=16)
(actual time=126.925..181.867 rows=11287 loops=1)
Hash Cond: (s.vin_id = v.id)
-> Seq Scan on stock s
(cost=0.00..12362.29 rows=2889 width=16)
(actual time=112.101..135.932 rows=11287 loops=1)
Filter: ((annee)::double precision = $0)
-> Hash (cost=97.59..97.59 rows=6059 width=8)
(actual time=14.794..14.794 rows=6059 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 237kB
-> Seq Scan on vin v
(cost=0.00..97.59 rows=6059 width=8)
(actual time=0.010..7.321 rows=6059 loops=1)
-> Hash (cost=1.02..1.02 rows=2 width=8)
(actual time=0.013..0.013 rows=2 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 1kB
-> Seq Scan on contenant c
(cost=0.00..1.02 rows=2 width=8)
(actual time=0.004..0.006 rows=2 loops=1)
-> Hash (cost=6.19..6.19 rows=319 width=20)
(actual time=0.815..0.815 rows=319 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 17kB
-> Seq Scan on appellation a
(cost=0.00..6.19 rows=319 width=20)
(actual time=0.004..0.387 rows=319 loops=1)
Total runtime: 266.663 ms
(21 rows)
Nous sommes ainsi passés de 2 s à 250 ms : la requête est donc environ 10 fois plus rapide.
Que peut-on conclure de cet exercice ?
que la création d'un index est une bonne idée ; cependant l'optimiseur peut ne pas l'utiliser, pour de bonnes raisons ;
qu'interdire les seq scan est toujours une mauvaise idée (ne présumez pas de votre supériorité sur l'optimiseur !)
Optimisation 2
Voici la requête 2 telle que nous l'avons trouvé dans l'exercice précédent :
explain analyze
select
s.annee||' - ' ||a.libelle as millesime_region,
sum (s.nombre) as contenants,
sum (s.nombre*c.contenance) as litres
from
contenant c
join stock s
on s.contenant_id = c.id
join vin v
on s.vin_id = v.id
left join appellation a
on v.appellation_id = a.id
where s.annee = (select round (random ()*50 )+1950 as annee)
group by s.annee||' - ' ||a.libelle;
On peut se demander si la jointure externe (LEFT JOIN) est fondée... On va donc vérifier l'utilité de la ligne suivante :
vin v left join appellation a on v.appellation_id = a.id
Cela se traduit par « récupérer tous les tuples de la table vin, et pour chaque correspondance dans appellation, la récupérer, si elle existe ».
En regardant la description de la table vin
(\d vin
dans psql
), on remarque la contrainte de clé étrangère suivante :
« vin_appellation_id_fkey »
FOREIGN KEY (appellation_id)
REFERENCES appellation(id)
Cela veut dire qu'on a la certitude que pour chaque vin, si une référence à la table appellation est présente, elle est nécessairement vérifiable.
De plus, on remarque :
appellation_id | integer | not null
Ce qui veut dire que la valeur de ce champ ne peut être nulle. Elle contient donc obligatoirement une valeur qui est présente dans la table appellation
.
On peut vérifier au niveau des tuples en faisant un COUNT(*)
du résultat, une fois en INNER JOIN
et une fois en LEFT JOIN
. Si le résultat est identique, la jointure externe ne sert à rien :
select count (*)
from vin v
inner join appellation a on (v.appellation_id = a.id);
count
-------
6057
select count (*)
from vin v
left join appellation a on (v.appellation_id = a.id);
count
-------
6057
On peut donc réécrire la requête 2 sans la jointure externe inutile, comme on vient de le démontrer :
explain analyze
select
s.annee||' - ' ||a.libelle as millesime_region,
sum (s.nombre) as contenants,
sum (s.nombre*c.contenance) as litres
from
contenant c
join stock s
on s.contenant_id = c.id
join vin v
on s.vin_id = v.id
join appellation a
on v.appellation_id = a.id
where s.annee = (select round (random ()*50 )+1950 as annee)
group by s.annee||' - ' ||a.libelle;
Voici le résultat :
HashAggregate (cost=12734.64..12737.10 rows=82 width=28)
(actual time=266.916..267.343 rows=319 loops=1)
InitPlan 1 (returns $0)
-> Result (cost=0.00..0.02 rows=1 width=0)
(actual time=0.005..0.006 rows=1 loops=1)
-> Hash Join (cost=184.55..12712.96 rows=2889 width=28)
(actual time=118.759..246.391 rows=11299 loops=1)
Hash Cond: (v.appellation_id = a.id)
-> Hash Join (cost=174.37..12634.17 rows=2889 width=16)
(actual time=117.933..208.503 rows=11299 loops=1)
Hash Cond: (s.contenant_id = c.id)
-> Hash Join (cost=173.33..12593.40 rows=2889 width=16)
(actual time=117.914..182.501 rows=11299 loops=1)
Hash Cond: (s.vin_id = v.id)
-> Seq Scan on stock s
(cost=0.00..12362.29 rows=2889 width=16)
(actual time=102.903..135.451 rows=11299 loops=1)
Filter: ((annee)::double precision = $0)
-> Hash (cost=97.59..97.59 rows=6059 width=8)
(actual time=14.979..14.979 rows=6059 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 237kB
-> Seq Scan on vin v
(cost=0.00..97.59 rows=6059 width=8)
(actual time=0.010..7.387 rows=6059 loops=1)
-> Hash (cost=1.02..1.02 rows=2 width=8)
(actual time=0.009..0.009 rows=2 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 1kB
-> Seq Scan on contenant c
(cost=0.00..1.02 rows=2 width=8)
(actual time=0.002..0.004 rows=2 loops=1)
-> Hash (cost=6.19..6.19 rows=319 width=20)
(actual time=0.802..0.802 rows=319 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 17kB
-> Seq Scan on appellation a
(cost=0.00..6.19 rows=319 width=20)
(actual time=0.004..0.397 rows=319 loops=1)
Total runtime: 267.688 ms
(21 rows)
Cette réécriture n'a pas d'effet sur le temps d'exécution de la requête dans notre cas. Mais il est probable qu'avec des cardinalités différentes dans la base, cette réécriture aurait eu un impact. Remplacer un LEFT JOIN
par un JOIN
est le plus souvent intéressant, car il laisse davantage de liberté au moteur sur le sens de planification des requêtes.
Optimisation 3
Si on observe attentivement le plan, on constate qu'on a toujours le parcours séquentiel de la table stock
, qui est notre plus grosse table. Pourquoi a-t-il lieu ?
Si on regarde le filtre (ligne Filter
) du parcours de la table stock
, on constate qu'il est écrit :
Filter: ((annee)::double precision = $0)
Ceci signifie que pour tous les enregistrements de la table, l'année est convertie en nombre en double précision (un nombre à virgule flottante), afin d'être comparée à $0, une valeur filtre appliquée à la table. Cette valeur est le résultat du calcul :
select round (random ()*50 )+1950 as annee
comme indiquée par le début du plan (les lignes de l'initplan 1).
Pourquoi compare-t-il l'année, déclarée comme un entier (integer
), en la convertissant en un nombre à virgule flottante ?
Parce que la fonction round()
retourne un nombre à virgule flottante. La somme d'un nombre à virgule flottante et d'un entier est évidemment un nombre à virgule flottante. Si on veut que la fonction round()
retourne un entier, il faut forcer explicitement sa conversion, via CAST(xxx as int)
ou ::int
.
Réécrivons encore une fois cette requête :
explain analyze
select
s.annee||' - ' ||a.libelle as millesime_region,
sum (s.nombre) as contenants,
sum (s.nombre*c.contenance) as litres
from
contenant c
join stock s
on s.contenant_id = c.id
join vin v
on s.vin_id = v.id
join appellation a
on v.appellation_id = a.id
where s.annee = (select cast (round (random ()*50 ) as int )+1950 as annee)
group by s.annee||' - ' ||a.libelle;
Voici son plan :
HashAggregate (cost=1251.12..1260.69 rows=319 width=28)
(actual time=138.418..138.825 rows=319 loops=1)
InitPlan 1 (returns $0)
-> Result (cost=0.00..0.02 rows=1 width=0)
(actual time=0.005..0.006 rows=1 loops=1)
-> Hash Join (cost=267.86..1166.13 rows=11329 width=28)
(actual time=31.108..118.193 rows=11389 loops=1)
Hash Cond: (s.contenant_id = c.id)
-> Hash Join (cost=266.82..896.02 rows=11329 width=28)
(actual time=31.071..80.980 rows=11389 loops=1)
Hash Cond: (s.vin_id = v.id)
-> Index Scan using stock_annee on stock s
(cost=0.00..402.61 rows=11331 width=16)
(actual time=0.049..17.191 rows=11389 loops=1)
Index Cond: (annee = $0)
-> Hash (cost=191.08..191.08 rows=6059 width=20)
(actual time=31.006..31.006 rows=6059 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 313kB
-> Hash Join (cost=10.18..191.08 rows=6059 width=20)
(actual time=0.814..22.856 rows=6059 loops=1)
Hash Cond: (v.appellation_id = a.id)
-> Seq Scan on vin v
(cost=0.00..97.59 rows=6059 width=8)
(actual time=0.005..7.197 rows=6059 loops=1)
-> Hash (cost=6.19..6.19 rows=319 width=20)
(actual time=0.800..0.800 rows=319 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 17kB
-> Seq Scan on appellation a
(cost=0.00..6.19 rows=319 width=20)
(actual time=0.002..0.363 rows=319 loops=1)
-> Hash (cost=1.02..1.02 rows=2 width=8)
(actual time=0.013..0.013 rows=2 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 1kB
-> Seq Scan on contenant c (cost=0.00..1.02 rows=2 width=8)
(actual time=0.003..0.006 rows=2 loops=1)
Total runtime: 139.252 ms
(21 rows)
On constate qu'on utilise enfin l'index de stock
. Le temps d'exécution a encore été divisé par deux.
NB : ce problème d'incohérence de type était la cause du plus gros ralentissement de la requête. En reprenant la requête initiale, et en ajoutant directement le cast, la requête s'exécute déjà en 160 millisecondes.
Corrélation entre colonnes
Importez le fichier correlations.sql
.
createdb correlations
psql correlations < correlations.sql
Exécutez cette requête, et regardez son plan d'exécution. Où est le problème ?
Cette requête a été exécutée dans un environnement où le cache a été intégralement vidé, pour être dans la situation la plus défavorable possible. Vous obtiendrez probablement des performances meilleures, surtout si vous réexécutez cette requête.
explain (analyze ,buffers)
SELECT * FROM colis WHERE id_ville IN (
SELECT id_ville
FROM villes
WHERE localite ='PARIS'
AND codepostal LIKE '75%'
);
QUERY PLAN
--------------------------------------------------------------------------------
Nested Loop (cost=6.75 ..13533.81 rows=3265 width=16 )
(actual time=38.020 ..364383.516 rows=170802 loops=1 )
Buffers: shared hit=91539 read=82652
I/O Timings: read=359812.828
-> Seq Scan on villes (cost=0.00 ..1209.32 rows=19 width=
(actual time=23.979 ..45.383 rows=940 loops=1 )
Filter: ((codepostal ~~ '75%' ::text ) AND (localite = 'PARIS' ::text ))
Rows Removed by Filter: 54015
Buffers: shared hit=1 read=384
I/O Timings: read=22.326
-> Bitmap Heap Scan on colis (cost=6.75 ..682.88 rows=181 width=16 )
(actual time=1.305 ..387.239 rows=182 loops=940 )
Recheck Cond: (id_ville = villes.id_ville)
Buffers: shared hit=91538 read=82268
I/O Timings: read=359790.502
-> Bitmap Index Scan on idx_colis_ville
(cost=0.00 ..6.70 rows=181 width=0 )
(actual time=0.115 ..0.115 rows=182 loops=940 )
Index Cond: (id_ville = villes.id_ville)
Buffers: shared hit=2815 read=476
I/O Timings: read=22.862
Total runtime: 364466.458 ms
(17 lignes)
On constate que l'optimiseur part sur une boucle extrêmement coûteuse : 940 parcours sur colis
, par id_ville
. En moyenne, ces parcours durent environ 400 ms. Le résultat est vraiment très mauvais.
Il fait ce choix parce qu'il estime que la condition
localite ='PARIS' AND codepostal LIKE '75%'
va ramener 19 enregistrements. En réalité, elle en ramène 940, soit 50 fois plus, d'où un très mauvais choix. Pourquoi PostgreSQL fait-il cette erreur ?
marc=# EXPLAIN SELECT * FROM villes;
QUERY PLAN
-------------------------------------------------------------
Seq Scan on villes (cost=0.00..934.55 rows=54955 width=27)
(1 ligne)
marc=# EXPLAIN SELECT * FROM villes WHERE localite='PARIS';
QUERY PLAN
------------------------------------------------------------
Seq Scan on villes (cost=0.00..1071.94 rows=995 width=27)
Filter: (localite = 'PARIS'::text)
(2 lignes)
marc=# EXPLAIN SELECT * FROM villes WHERE codepostal LIKE '75%';
QUERY PLAN
-------------------------------------------------------------
Seq Scan on villes (cost=0.00..1071.94 rows=1042 width=27)
Filter: (codepostal ~~ '75%'::text)
(2 lignes)
marc=# EXPLAIN SELECT * FROM villes WHERE localite='PARIS'
marc=# AND codepostal LIKE '75%';
QUERY PLAN
------------------------------------------------------------------------
Seq Scan on villes (cost=0.00..1209.32 rows=19 width=27)
Filter: ((codepostal ~~ '75%'::text) AND (localite = 'PARIS'::text))
(2 lignes)
D'après les statistiques, villes contient 54955 enregistrements, 995 contenant PARIS (presque 2%), 1042 commençant par 75 (presque 2%).
Il y a donc 2% d'enregistrements vérifiant chaque critère (c'est normal, ils sont presque équivalents). PostgreSQL, n'ayant aucune autre information, part de l'hypothèse que les colonnes ne sont pas liées, et qu'il y a donc 2% de 2% (soit environ 0,04%) des enregistrements qui vérifient les deux.
Si on fait le calcul exact, on a donc :
(995/54955)*(1042/54955)*54955
soit 18,8 enregistrements (arrondi à 19) qui vérifient le critère. Ce qui est évidemment faux.
Exécutez cette requête sans la dernière clause AND codepostal LIKE '75%'
. Que constatez-vous ?
explain (analyze ,buffers) select * from colis where id_ville in (
select id_ville from villes where localite ='PARIS'
);
QUERY PLAN
--------------------------------------------------------------------------------
Hash Semi Join (cost=1083.86 ..183312.59 rows=173060 width=16 )
(actual time=48.975 ..4362.348 rows=170802 loops=1 )
Hash Cond: (colis.id_ville = villes.id_ville)
Buffers: shared hit=7 read=54435
I/O Timings: read=1219.212
-> Seq Scan on colis (cost=0.00 ..154053.55 rows=9999955 width=16 )
(actual time=6.178 ..2228.259 rows=9999911 loops=1 )
Buffers: shared hit=2 read=54052
I/O Timings: read=1199.307
-> Hash (cost=1071.94 ..1071.94 rows=954 width=
(actual time=42.676 ..42.676 rows=940 loops=1 )
Buckets: 1024 Batches: 1 Memory Usage: 37kB
Buffers: shared hit=2 read=383
I/O Timings: read=19.905
-> Seq Scan on villes (cost=0.00 ..1071.94 rows=954 width=
(actual time=35.900 ..41.957 rows=940 loops=1 )
Filter: (localite = 'PARIS' ::text )
Rows Removed by Filter: 54015
Buffers: shared hit=2 read=383
I/O Timings: read=19.905
Total runtime: 4375.105 ms
(17 lignes)
Cette fois-ci le plan est bon, et les estimations aussi.
Quelle solution pourrait-on adopter, si on doit réellement spécifier ces deux conditions ?
On pourrait indexer sur une fonction des deux. C'est maladroit, mais malheureusement la seule solution sûre :
CREATE FUNCTION test_ville (ville text,codepostal text) RETURNS text
IMMUTABLE LANGUAGE SQL as $$
SELECT ville || '-' || codepostal
$$ ;
CREATE INDEX idx_test_ville ON villes (test_ville(localite , codepostal));
ANALYZE villes;
EXPLAIN (analyze ,buffers) SELECT * FROM colis WHERE id_ville IN (
SELECT id_ville
FROM villes
WHERE test_ville(localite,codepostal) LIKE 'PARIS-75%'
);
QUERY PLAN
--------------------------------------------------------------------------------
Hash Semi Join (cost=1360.59 ..183924.46 rows=203146 width=16 )
(actual time=46.127 ..3530.348 rows=170802 loops=1 )
Hash Cond: (colis.id_ville = villes.id_ville)
Buffers: shared hit=454 read=53989
-> Seq Scan on colis (cost=0.00 ..154054.11 rows=9999911 width=16 )
(actual time=0.025 ..1297.520 rows=9999911 loops=1 )
Buffers: shared hit=66 read=53989
-> Hash (cost=1346.71 ..1346.71 rows=1110 width=8 )
(actual time=46.024 ..46.024 rows=940 loops=1 )
Buckets: 1024 Batches: 1 Memory Usage: 37kB
Buffers: shared hit=385
-> Seq Scan on villes (cost=0.00 ..1346.71 rows=1110 width=8 )
(actual time=37.257 ..45.610 rows=940 loops=1 )
Filter: (((localite || '-' ::text ) || codepostal) ~~
'PARIS-75%' ::text )
Rows Removed by Filter: 54015
Buffers: shared hit=385
Total runtime: 3543.838 ms
On constate qu'avec cette méthode il n'y a plus d'erreur d'estimation. Elle est bien sûr très pénible à utiliser, et ne doit donc être réservée qu'aux quelques rares requêtes ayant été identifiées comme ayant un comportement pathologique.
On peut aussi créer une colonne supplémentaire maintenue par un trigger, plutôt qu'un index : cela sera moins coûteux à maintenir, et permettra d'avoir la même statistique.
Conclusion
Que peut-on conclure de cet exercice ?
que la ré-écriture est souvent la meilleure des solutions : interrogez-vous toujours sur la façon dont vous écrivez vos requêtes, plutôt que de mettre en doute PostgreSQL a priori ;
que la ré-écriture de requête est souvent complexe - néanmoins, surveillez un certain nombre de choses :
casts implicites suspects ;
jointures externes inutiles ;
sous-requêtes imbriquées ;
jointures inutiles (données constantes)