Tous les TP se basent sur la configuration par défaut de PostgreSQL,
sauf précision contraire.
Manipuler explain
Si vous ne l’avez pas déjà, créer une base machines
et y générer les données comme indiqué ci-dessous. L’exécution peut
durer une minute ou deux suivant la machine.
curl -kL https://dali.bo/tp_machines_donnees -o machines_donnees.sql
createdb machines
psql machines < machines_donnees.sql
La base machines contiendra alors deux tables :
machines est une liste de machines ;
donnees contient des données horodatées de quelques
capteurs de ces machines, entre janvier et août 2023.
Tables machines et donnees
Nettoyage et mise à jour des statistiques :
VACUUM ANALYZE machines,donnees;
Cette opération est à faire systématiquement sur des tables récentes,
ou au moindre doute. L’autovacuum n’est parfois pas assez rapide pour
effectuer ces opérations.
Quelles sont les tailles des tables ?
Sous psql :
=# \dt+
Liste des relations
Schéma | Nom | Type | Propriétaire | … | Taille | …
--------+-----------------+-------+--------------+----+---------+--
public | donnees | table | postgres | | 284 MB |
public | machines | table | postgres | | 120 kB |
Quant aux nombres de lignes :
SELECT count (* ) FROM machines ;
SELECT count (* ) FROM donnees ;
Tout plan d’exécution dépend de la configuration de PostgreSQL. Sauf
précision contraire, nous partons toujours de la configuration par
défaut.
Pour simplifier certains plans, désactivons le parallélisme et la
compilation à la volée :
SET max_parallel_workers_per_gather TO 0 ;
SET jit TO off ;
Requêtes sur les périodes :
Quel est le plan prévu pour récupérer les données du 31 janvier dans
la table donnees ?
EXPLAIN
SELECT * FROM donnees
WHERE horodatage = '2023-01-31' ::date ;
Le plan prévu est :
QUERY PLAN
-------------------------------------------------------------------------------
Index Scan using donnees_horodatage_idx on donnees (cost=0.43..8.64 rows=12 width=30)
Index Cond: (horodatage = '2023-01-31'::date)
Il existe un index sur le critère, il est naturel qu’il soit
utilisé.
Quel est le plan prévu pour récupérer les données du mois de janvier
dans la table donnees ?
EXPLAIN
SELECT * FROM donnees
WHERE horodatage BETWEEN '2023-01-01' ::date AND '2023-01-31' ::date ;
Le plan prévu est le même, au critère près :
QUERY PLAN
-------------------------------------------------------------------------------
Index Scan using donnees_horodatage_idx on donnees (cost=0.43..934.33 rows=28745 width=30)
Index Cond: ((horodatage >= '2023-01-01'::date) AND (horodatage <= '2023-01-31'::date))
Noter la réécriture du BETWEEN sous forme
d’inégalités.
Quel est le plan prévu pour cette variante de la requête sur
janvier ?
EXPLAIN
SELECT * FROM donnees
WHERE to_char (horodatage, 'YYYYMM' ) = '202301' ;
Pourquoi est-il différent ? Comparer avec le précédant en utilisant
EXPLAIN ANALYZE.
Le plan cette fois est un parcours de table. L’index est ignoré,
toute la table est lue :
QUERY PLAN
------------------------------------------------------------------
Seq Scan on donnees (cost=0.00..110652.96 rows=24751 width=30)
Filter: (to_char(horodatage, 'YYYYMM'::text) = '202301'::text)
Si le parallélisme est activé, il existe une variante parallélisée de
ce plan :
QUERY PLAN
-------------------------------------------------------------------------------
Gather (cost=1000.00..70813.25 rows=24751 width=30)
Workers Planned: 2
-> Parallel Seq Scan on donnees (cost=0.00..67338.15 rows=10313 width=30)
Filter: (to_char(horodatage, 'YYYYMM'::text) = '202301'::text)
La raison du changement de plan est le changement du critère. C’est
évident pour un humain, mais PostgreSQL ne fait pas l’équivalence entre
les deux formulations du critère sur le mois de janvier. Or il n’y a pas
d’index sur la fonction to_char(horodatage, 'YYYYMM') (il
serait possible d’en créer un).
Si l’on compare les deux plans en les exécutant réellement, avec
EXPLAIN (ANALYZE), on obtient pour la variante avec
BETWEEN :
EXPLAIN (ANALYZE ,BUFFERS)
SELECT * FROM donnees
WHERE horodatage BETWEEN '2023-01-01 ' :: date AND '2023-01-31 ' :: date ;
QUERY PLAN
-------------------------------------------------------------------------------
Index Scan using donnees_horodatage_idx on donnees (cost=0.43..934.33 rows=28745 width=30) (actual time=0.234..26.853 rows=19600.00 loops=1)
Index Cond: ((horodatage >= '2023-01-01'::date) AND (horodatage <= '2023-01-31'::date))
Index Searches: 1
Buffers: shared hit=6 read=191
Planning:
Buffers: shared hit=8
Planning Time: 0.292 ms
Execution Time: 44.079 ms
et pour la variante avec to_char :
EXPLAIN (ANALYZE ,BUFFERS)
SELECT * FROM donnees
WHERE to_char (horodatage, 'YYYYMM ' ) = '202301 ' ;
QUERY PLAN
-------------------------------------------------------------------------------
Seq Scan on donnees (cost=0.00..110652.96 rows=24751 width=30) (actual time=0.323..4391.222 rows=19600.00 loops=1)
Filter: (to_char(horodatage, 'YYYYMM'::text) = '202301'::text)
Rows Removed by Filter: 4930625
Buffers: shared hit=145 read=36254
Planning Time: 0.167 ms
Execution Time: 4406.425 ms
La dernière ligne indique 44 ms pour la variante avec
BETWEEN contre 4,4 s pour la variante avec
to_char : l’utilisation de l’index est nettement plus
intéressante que le parcours complet de la table. Le plan indique aussi
que beaucoup plus de blocs (buffers ) ont été lus.
Quel est le plan pour la même requête, cette fois sur deux mois ?
EXPLAIN
SELECT * FROM donnees
WHERE horodatage BETWEEN '2023-03-01' ::date AND '2023-04-30' ::date ;
Relancer avec EXPLAIN (ANALYZE).
On s’attend au même plan que pour la recherche sur janvier, mais
PostgreSQL prévoit cette fois un parcours complet :
QUERY PLAN
-------------------------------------------------------------------------------
Seq Scan on donnees (cost=0.00..110652.96 rows=4187102 width=30)
Filter: ((horodatage >= '2023-03-01'::date) AND (horodatage <= '2023-04-30'::date))
En effet, il y a beaucoup plus de lignes à récupérer sur mars-avril
qu’en janvier. La mention rows indique l’estimation des
lignes ramenées et indique 4,2 millions de lignes sur les 4,9 de la
table ! Le plus efficace est donc de lire directement la table. Les
statistiques permettent donc à PostgreSQL de changer de stratégie
suivant les volumétries attendues.
Une exécution réelle indique que cette estimation est bonne, et dure
logiquement à peu près aussi longtemps que le parcours complet
ci-dessus :
EXPLAIN (ANALYZE )
SELECT * FROM donnees
WHERE horodatage BETWEEN '2023-03-01 ' :: date AND '2023-04-30 ' :: date ;
QUERY PLAN
-------------------------------------------------------------------------------
Seq Scan on donnees (cost=0.00..110652.96 rows=4187102 width=30) (actual time=119.337..5087.984 rows=4182160.00 loops=1)
Filter: ((horodatage >= '2023-03-01'::date) AND (horodatage <= '2023-04-30'::date))
Rows Removed by Filter: 768065
Buffers: shared hit=239 read=36160
Planning Time: 0.246 ms
Execution Time: 8343.413 ms
Jointure :
Quel est le plan prévu pour cette jointure sur toutes les données
d’une machine ?
EXPLAIN
SELECT *
FROM donnees INNER JOIN machines USING (id_machine)
WHERE machines.code = 'E4DA3B' AND type = 5 ;
Le plan est :
QUERY PLAN
-------------------------------------------------------------------------------
Nested Loop (cost=0.71..5038.34 rows=4950 width=75)
-> Index Scan using machines_type_code_key on machines (cost=0.28..8.29 rows=1 width=49)
Index Cond: ((type = 5) AND ((code)::text = 'E4DA3B'::text))
-> Index Scan using donnees_id_machine_idx on donnees (cost=0.43..4130.00 rows=90005 width=30)
Index Cond: (id_machine = machines.id_machine)
Il s’agit :
d’un accès à machines par l’index sur
machines (type, code) (cet index marque l’unicité) ;
suivi d’un accès à donnees, toujours par l’index sur le
champ indexé id_machine.
Quel est le plan prévu pour la requête suivante, qui récupère toutes
les données d’après juillet pour un type de machines donné ? Quelles en
sont les 3 étapes ?
EXPLAIN
SELECT description, horodatage, valeur1
FROM donnees INNER JOIN machines USING (id_machine)
WHERE machines.type = 1
AND donnees.horodatage > '2023-07-01' ;
QUERY PLAN
-------------------------------------------------------------------------------
Hash Join (cost=30.67..6806.96 rows=112647 width=47)
Hash Cond: (donnees.id_machine = machines.id_machine)
-> Index Scan using donnees_horodatage_idx on donnees (cost=0.43..6225.79 rows=208992 width=16)
Index Cond: (horodatage > '2023-07-01 00:00:00+02'::timestamp with time zone)
-> Hash (cost=23.50..23.50 rows=539 width=39)
-> Seq Scan on machines (cost=0.00..23.50 rows=539 width=39)
Filter: (type = 1)
Il s’agit ici d’une jointure en hash join , courante dans les
jointures brassant beaucoup de lignes.
PostgreSQL commence par un parcours complet de machines
(type = 1 concerne la plupart des machines ). Puis il crée
une « table de hachage » à partir des id_machine des lignes
résultantes. Il parcoure donnees en se basant sur l’index
sur la date. Les lignes résultantes seront comparées au contenu de la
table de hachage pour savoir s’il faut garder les valeurs.
Manipuler explain (base
magasin)
Créer une base de données nommée
magasin.
Si l’on est connecté à la base, en tant que superutilisateur
postgres :
Alternativement, depuis le shell, en tant qu’utilisateur système
postgres :
postgres$ createdb --echo magasin
SELECT pg_catalog.set_config ('search_path ' , ' ' , false );
CREATE DATABASE magasin;
Importer le jeu de données d’exemple :
Si vous ne l’avez pas déjà, la base magasin (dump de
96 Mo, pour 667 Mo sur le disque au final) peut être téléchargée et
restaurée comme suit dans une nouvelle base
magasin :
createdb magasin
curl -kL https://dali.bo/tp_magasin -o /tmp/magasin.dump
pg_restore -d magasin /tmp/magasin.dump
# le message sur public préexistant est normal
rm -- /tmp/magasin.dump
Les données sont dans deux schémas, magasin et
facturation . Penser au search_path.
Pour ce TP, figer les paramètres suivants :
SET max_parallel_workers_per_gather to 0 ;
SET seq_page_cost TO 1 ;
SET random_page_cost TO 4 ;
Le schéma à utiliser se nomme également
magasin.
Consulter les tables.
Le schéma par défaut public ne contient effectivement
aucune table intéressante.
\dn
Liste des schémas
Nom | Propriétaire
-------------+--------------
facturation | postgres
magasin | postgres
public | postgres
SET search_path to magasin ;
\dt+
Liste des relations
Schéma | Nom | Type | Propriétaire | Persistence | Taille | D…
---------+----------------------+-------+--------------+-------------+------------+---
magasin | clients | table | postgres | permanent | 8424 kB |
magasin | commandes | table | postgres | permanent | 80 MB |
magasin | conditions_reglement | table | postgres | permanent | 16 kB |
magasin | contacts | table | postgres | permanent | 24 MB |
magasin | etats_retour | table | postgres | permanent | 16 kB |
magasin | fournisseurs | table | postgres | permanent | 840 kB |
magasin | lignes_commandes | table | postgres | permanent | 330 MB |
magasin | lots | table | postgres | permanent | 74 MB |
magasin | modes_expedition | table | postgres | permanent | 16 kB |
magasin | modes_reglement | table | postgres | permanent | 16 kB |
magasin | numeros_sequence | table | postgres | permanent | 16 kB |
magasin | pays | table | postgres | permanent | 16 kB |
magasin | pays_transporteurs | table | postgres | permanent | 8192 bytes |
magasin | produit_fournisseurs | table | postgres | permanent | 216 kB |
magasin | produits | table | postgres | permanent | 488 kB |
magasin | regions | table | postgres | permanent | 16 kB |
magasin | transporteurs | table | postgres | permanent | 16 kB |
magasin | types_clients | table | postgres | permanent | 16 kB |
Conseils pour la suite :
Préciser \timing on dans psql pour
afficher les temps d’exécution de la recherche.
Pour rendre les plans plus lisibles, désactiver le JIT et le
parallélisme :
SET jit TO off ;
SET max_parallel_workers_per_gather TO 0 ;
Lancer un ANALYZE sur la base.
Le but est de chercher une personne nommée Moris Russel dans la table
contacts par les champs prenom et
nom.
Quel est le plan qu’utilisera PostgreSQL pour le trouver ?
À combien de résultats le planificateur s’attend-il ?
EXPLAIN SELECT * FROM contacts WHERE nom = 'Russel ' AND prenom = 'Moris ' ;
QUERY PLAN
---------------------------------------------------------------------------------
Seq Scan on contacts (cost=0.00..4754.07 rows=1 width=297)
Filter: (((nom)::text = 'Russel'::text) AND ((prenom)::text = 'Moris'::text))
La table sera entièrement parcourue (Seq Scan ). PostgreSQL
pense qu’il trouvera une ligne.
SELECT * FROM contacts WHERE nom = 'Russel ' AND prenom = 'Moris ' ;
-[ RECORD 1 ]----------------------------------------
contact_id | 26452
login | Russel_Moris
passwd | 9f81a90c36dd3c60ff06f3c800ae4c1b
email | ubaldo@hagenes-kulas-and-oberbrunner.mo
nom | Russel
prenom | Moris
adresse1 | 02868 Norris Greens
adresse2 | ¤
code_postal | 62151
ville | Laguna Beach
code_pays | CA
telephone | {"+(05) 4.45.08.11.03"}
Temps : 31,246 ms
La requête envoie bien une ligne, et l’obtenir a pris 31 ms sur cette
machine avec SSD.
Quel est le plan réellement exécuté ?
Il faut relancer la requête :
EXPLAIN (ANALYZE , BUFFERS) SELECT * FROM contacts
WHERE nom = 'Russel ' AND prenom = 'Moris ' ;
QUERY PLAN
-------------------------------------------------------------------------------
Seq Scan on contacts (cost=0.00..4754.07 rows=1 width=297) (actual time=6.058..22.734 rows=1.00 loops=1)
Filter: (((nom)::text = 'Russel'::text) AND ((prenom)::text = 'Moris'::text))
Rows Removed by Filter: 110004
Buffers: shared hit=3104
Planning Time: 0.154 ms
Execution Time: 22.783 ms
PostgreSQL a à nouveau récupéré une ligne. Ici, cela n’a pris que
23 ms.
La table a été parcourue entièrement, et 110 004 lignes ont été
rejetées. La ligne shared hit indique que 3104 blocs de 8 ko
ont été lus dans le cache de PostgreSQL. La requête précédente a
apparemment suffi à charger la table entière en cache (il n’y a pas de
shared read ).
Rechercher la même personne par son
contact_id.
Quel est le plan ?
EXPLAIN (ANALYZE , BUFFERS) SELECT * FROM contacts WHERE contact_id = 26452 ;
QUERY PLAN
-------------------------------------------------------------------------------
Index Scan using contacts_pkey on contacts (cost=0.42..8.44 rows=1 width=297) (actual time=0.095..0.098 rows=1.00 loops=1)
Index Cond: (contact_id = 26452)
Index Searches: 1
Buffers: shared hit=4 read=3
Planning:
Buffers: shared hit=6 read=3
Planning Time: 0.359 ms
Execution Time: 0.137 ms
PostgreSQL estime correctement trouver une ligne. Cette fois, il
s’agit d’un Index Scan , en l’occurrence sur l’index de la clé
primaire. Le résultat est bien plus rapide : 359 µs pour planifier,
137 µs pour exécuter.
Les blocs lus se répartissent entre read et hit :
une partie était en mémoire, notamment ceux liés à la table, puisque la
table aussi a été interrogée (l’index ne contient que les données de
contact_id) ; mais l’index n’était pas en mémoire.
La requête suivante recherche tous les fournisseurs résidents
d’Hollywood.
SELECT c.nom, c.prenom FROM contacts c
INNER JOIN fournisseurs f
ON (f.contact_id = c.contact_id)
WHERE c.ville = 'Hollywood' ;
Quel est le plan prévu ?
Que donne-t-il à l’exécution ?
Le plan simplifié est :
EXPLAIN (COSTS OFF )
SELECT c .nom, c .prenom
FROM contacts c INNER JOIN fournisseurs f ON (f.contact_id = c .contact_id)
WHERE c .ville = 'Hollywood ' ;
QUERY PLAN
-----------------------------------------------------
Merge Join
Merge Cond: (c.contact_id = f.contact_id)
-> Index Scan using contacts_pkey on contacts c
Filter: ((ville)::text = 'Hollywood'::text)
-> Sort
Sort Key: f.contact_id
-> Seq Scan on fournisseurs f
Il consiste à parcourir intégralement la table
fournisseurs (Seq Scan ), à trier sa colonne
contact_id, et à effectuer une jointure de type Merge
Join avec la clé primaire de la table contacts. En
effet, un Merge Join s’effectue entre deux ensembles triés :
l’index l’est déjà, mais fournisseurs.contact_id ne l’est
pas.
Noter qu’aucune donnée n’est récupérée de fournisseurs.
Il est pourtant nécessaire de la joindre à contacts car de
nombreux contacts ne sont pas des fournisseurs.
Exécutée, cette requête renvoie le plan suivant :
EXPLAIN (ANALYZE ,BUFFERS)
SELECT c .nom, c .prenom FROM contacts c
INNER JOIN fournisseurs f ON (f.contact_id = c .contact_id)
WHERE c .ville = 'Hollywood ' ;
QUERY PLAN
-------------------------------------------------------------------------------
Merge Join (cost=864.81..1493.43 rows=31 width=14) (actual time=22.544..51.659 rows=32.00 loops=1)
Merge Cond: (c.contact_id = f.contact_id)
Buffers: shared hit=339 read=132
-> Index Scan using contacts_pkey on contacts c (cost=0.42..6252.56 rows=346 width=22) (actual time=0.036..8.599 rows=33.00 loops=1)
Filter: ((ville)::text = 'Hollywood'::text)
Rows Removed by Filter: 11971
Index Searches: 1
Buffers: shared hit=339 read=32
-> Sort (cost=864.39..889.39 rows=10000 width=8) (actual time=22.460..32.543 rows=10000.00 loops=1)
Sort Key: f.contact_id
Sort Method: quicksort Memory: 385kB
Buffers: shared read=100
-> Seq Scan on fournisseurs f (cost=0.00..200.00 rows=10000 width=8) (actual time=0.855..12.073 rows=10000.00 loops=1)
Buffers: shared read=100
Planning:
Buffers: shared hit=4
Planning Time: 0.493 ms
Execution Time: 52.011 ms
Ce plan est visible graphiquement sur https://explain.dalibo.com/plan/dum :
Plan d’exécution
Le Seq Scan sur fournisseurs lit 10 000 lignes
(100 blocs, hors du cache), ce qui était prévu. Cela prend 12 ms. Le
nœud Sort trie les contact_id et consomme 385 ko
en mémoire. Il renvoie bien sûr aussi 10 000 lignes, et il commence à le
faire au bout de 22,5 ms.
La jointure peut commencer. Il s’agit de parcourir simultanément
l’ensemble que l’on vient de trier d’une part, et l’index
contacts_pkey d’autre part. À cette occasion, le nœud
Index Scan va filtrer les lignes récupérées en comparant à la
valeur de ville, et en exclue 11 971. Au final, le parcours
de l’index sur contacts renvoie 33 lignes, et non les 346
estimées au départ (valeur dérivée de l’estimation du nombre de lignes
où la ville est « Hollywood »). Si l’on regarde les coûts calculés,
c’est cette étape qui est la plus lourde (6252).
En haut, on peut lire qu’au total 132 blocs ont été lus hors du
cache, et 339 dedans. Ces valeurs varient bien sûr en fonction de
l’activité précédente sur la base. Au final, 32 lignes sont retournées,
ce qui était attendu.
Le temps écoulé est de 52,0 ms. La majorité de ce temps s’est déroulé
pendant le Merge Join (51,7-22,5 = 29 ms), dont l’essentiel est
constitué par le parcours de l’index.