Tous les TP se basent sur la configuration par défaut de PostgreSQL,
sauf précision contraire.
Manipuler explain
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 | | 112 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..933.87 rows=28722 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.25 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..70812.96 rows=24751 width=30)
Workers Planned: 2
-> Parallel Seq Scan on donnees (cost=0.00..67337.86 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..933.75 rows=28716 width=30) (actual time=0.060..9.405 rows=19600 loops=1)
Index Cond: ((horodatage >= '2023-01-01'::date) AND (horodatage <= '2023-01-31'::date))
Buffers: shared hit=6 read=191
Planning:
Buffers: shared hit=8
Planning Time: 0.072 ms
Execution Time: 10.472 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.25 rows=24751 width=30) (actual time=0.013..1503.631 rows=19600 loops=1)
Filter: (to_char(horodatage, 'YYYYMM'::text) = '202301'::text)
Rows Removed by Filter: 4930625
Buffers: shared hit=16063 read=20336
Planning Time: 0.025 ms
Execution Time: 1504.379 ms
La dernière ligne indique 10 ms pour la variante avec
BETWEEN
contre 1,5 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.25 rows=4184350 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.25 rows=4184350 width=30) (actual time=160.385..1255.020 rows=4182160 loops=1)
Filter: ((horodatage >= '2023-03-01'::date) AND (horodatage <= '2023-04-30'::date))
Rows Removed by Filter: 768065
Planning Time: 0.470 ms
Execution Time: 1378.383 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..5033.11 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..4124.77 rows=90004 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..8380.56 rows=138788 width=47)
Hash Cond: (donnees.id_machine = machines.id_machine)
-> Index Scan using donnees_horodatage_idx on donnees (cost=0.43..7671.54 rows=257492 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 :
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
Toutes les données sont dans deux schémas nommés
magasin et facturation .
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 | 8248 kB |
magasin | commandes | table | postgres | permanent | 79 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..4693.07 rows=1 width=298)
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 : 34,091 ms
La requête envoie bien une ligne, et l’obtenir a pris 34 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..4693.07 rows=1 width=297)
(actual time=3.328..16.789 rows=1 loops=1)
Filter: (((nom)::text = 'Russel'::text) AND ((prenom)::text = 'Moris'::text))
Rows Removed by Filter: 110004
Buffers: shared hit=3043
Planning Time: 0.052 ms
Execution Time: 16.848 ms
PostgreSQL a à nouveau récupéré une ligne. Ici, cela n’a pris que
17 ms.
La table a été parcourue entièrement, et 110 004 lignes ont été
rejetées. La ligne shared hit indique que 3043 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.057..0.058 rows=1 loops=1)
Index Cond: (contact_id = 26452)
Buffers: shared hit=4 read=3
Planning:
Buffers: shared hit=6 read=3
Planning Time: 0.137 ms
Execution Time: 0.081 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 : 137 µs pour planifier,
81 µ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.82..1469.89 rows=31 width=14)
(actual time=5.079..11.063 rows=32 loops=1)
Merge Cond: (c.contact_id = f.contact_id)
Buffers: shared hit=7 read=464
-> Index Scan using contacts_pkey on contacts c
(cost=0.42..6191.54 rows=346 width=22)
(actual time=0.029..4.842 rows=33 loops=1)
Filter: ((ville)::text = 'Hollywood'::text)
Rows Removed by Filter: 11971
Buffers: shared hit=7 read=364
-> Sort (cost=864.39..889.39 rows=10000 width=8)
(actual time=5.044..5.559 rows=10000 loops=1)
Sort Key: f.contact_id
Sort Method: quicksort Memory: 853kB
Buffers: shared read=100
-> Seq Scan on fournisseurs f (cost=0.00..200.00 rows=10000 width=8)
(actual time=0.490..2.960 rows=10000 loops=1)
Buffers: shared read=100
Planning:
Buffers: shared hit=4
Planning Time: 0.150 ms
Execution Time: 11.174 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 2,96 ms. Le
nœud Sort trie les contact_id
et consomme 853 ko
en mémoire. Il renvoie bien sûr aussi 10 000 lignes, et il commence à le
faire au bout de 5,04 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 (6191).
En haut, on peut lire qu’au total 464 blocs ont été lus hors du
cache, et 7 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 11,17 ms. La majorité de ce temps s’est
déroulé pendant le Merge Join (11,0-5,0 = 6 ms), dont
l’essentiel est constitué par le parcours de l’index.