Pour cet exercice, nous allons étudier deux tables dans la base
hr modélisant un service du personnel.
Le script (de 31 ko) de la base hr peut être
récupéré et installé ainsi :
curl -kL https://dali.bo/tp_hr -o hr.sql
createdb hr # Création de la base
psql -X1 hr < hr.sql
Les statistiques ont-elles été calculées ?
(utiliser la vue pg_stat_user_tables
)
La commande \d+ pg_stat_user_tables
dans psql nous
permet d’identifier les colonnes que nous devons chercher :
View "pg_catalog.pg_stat_user_tables"
Column | Type | Collation | Nullable | Default | Storage | ...
---------------------+--------------------------+-----------+----------+---------+---------+----
relid | oid | | | | plain |
schemaname | name | | | | plain |
relname | name | | | | plain |
seq_scan | bigint | | | | plain |
seq_tup_read | bigint | | | | plain |
idx_scan | bigint | | | | plain |
idx_tup_fetch | bigint | | | | plain |
n_tup_ins | bigint | | | | plain |
n_tup_upd | bigint | | | | plain |
n_tup_del | bigint | | | | plain |
n_tup_hot_upd | bigint | | | | plain |
n_live_tup | bigint | | | | plain |
n_dead_tup | bigint | | | | plain |
n_mod_since_analyze | bigint | | | | plain |
n_ins_since_vacuum | bigint | | | | plain |
last_vacuum | timestamp with time zone | | | | plain |
last_autovacuum | timestamp with time zone | | | | plain |
last_analyze | timestamp with time zone | | | | plain |
last_autoanalyze | timestamp with time zone | | | | plain |
vacuum_count | bigint | | | | plain |
autovacuum_count | bigint | | | | plain |
analyze_count | bigint | | | | plain |
autoanalyze_count | bigint | | | | plain |
(...)
L’information se trouve dans les colonnes last_analyze
et last_autoanalyze
. La requête suivante nous permet de
vérifier le calcul des statistiques pour chaque table :
SELECT relname, last_analyze, last_autoanalyze
FROM pg_stat_user_tables;
relname | last_analyze | last_autoanalyze
-------------+--------------+------------------
job_history | |
countries | |
departments | |
locations | |
employees | |
regions | |
jobs | |
(7 rows )
Dans notre cas, les statistiques n’ont pas encore été calculées.
Rafraîchir les statistiques sur les tables
employees
et locations
.
Lançons la commande ANALYZE
sur les tables
employees
et locations
:
ANALYZE employees, locations;
ANALYZE
La vue pg_stat_user_tables
nous permet de vérifier
l’opération :
SELECT relname, last_analyze, last_autoanalyze
FROM pg_stat_user_tables;
relname | last_analyze | last_autoanalyze
-------------+-------------------------------+-------------------------------
job_history | |
countries | |
departments | |
locations | 2024 - 12 - 17 08 :42:00 .108691 + 01 |
employees | 2024 - 12 - 17 08 :42:00 .107698 + 01 | 2024 - 12 - 17 08 :39:04 .767175 + 01
regions | |
jobs | |
(7 rows )
Dans notre cas, nous remarquons qu’autoanalyze est passé sur
employees
peu avant la collecte manuelle.
Losrqu’on consulte la vue pg_stats
on remarque que les
statistiques sont présentes pour employees, mais pas encore pour
departments :
SELECT attname FROM pg_stats WHERE tablename= 'employees' ;
attname
----------------
employee_id
first_name
last_name
email
phone_number
hire_date
job_id
salary
commission_pct
manager_id
department_id
(11 rows )
SELECT attname FROM pg_stats WHERE tablename= 'departments' ;
attname
---------
(0 row )
Retrouvons les informations calculées par ANALYZE
pour
la table employees
.
Quel est le taux de valeurs nulles pour la colonne
commission_pct
?
SELECT null_frac
FROM pg_stats
WHERE tablename= 'employees'
AND attname= 'commission_pct' ;
null_frac
-----------
0.6728972
(1 ligne)
Comparer le nombre réel de lignes dans la table
employees
avec l’estimation faite par ANALYZE
.
(utiliser la vue pg_class
)
SELECT reltuples FROM pg_class WHERE relname= 'employees' ;
reltuples
-----------
107
(1 row )
Le calcul exact se fait avec la fonction count() de
SELECT
:
SELECT count (* ) FROM employees;
count
-------
107
(1 row )
La table étant petite, l’échantillon permet d’avoir une estimation
exact de la quantité de lignes.
Quel est le taux réel de valeurs
nulles pour la colonne commission_pct
?
SELECT
COUNT (* ) AS count_total,
COUNT (* ) - COUNT (commission_pct) AS count_null,
(COUNT (* ) - COUNT (commission_pct))::float / COUNT (* )::float AS null_frac
FROM employees;
count_total | count_null | null_frac
-------------+------------+--------------------
107 | 72 | 0.6728971962616822
(1 ligne)
Le taux réel de valeurs nulles pour la colonne est égal à
l’estimation. Ce qui est normal car la table ne comporte que 107 lignes,
soit moins que l’échantillon minimal par défaut de 30000.
Quelles sont les valeurs les plus fréquentes pour
la colonne salary
?
SELECT
unnest(most_common_vals::text ::int []) AS most_common_val,
unnest(most_common_freqs) AS most_common_freq
FROM pg_stats
WHERE tablename = 'employees'
AND attname = 'salary' ;
most_common_val | most_common_freq
-----------------+------------------
2500 | 0.056074765
2600 | 0.037383176
2800 | 0.037383176
3100 | 0.037383176
3200 | 0.037383176
9000 | 0.037383176
10000 | 0.037383176
2900 | 0.028037382
7000 | 0.028037382
8000 | 0.028037382
9500 | 0.028037382
11000 | 0.028037382
12000 | 0.028037382
2200 | 0.018691588
2400 | 0.018691588
2700 | 0.018691588
3000 | 0.018691588
3300 | 0.018691588
3600 | 0.018691588
4200 | 0.018691588
4800 | 0.018691588
6000 | 0.018691588
6200 | 0.018691588
6500 | 0.018691588
7500 | 0.018691588
8200 | 0.018691588
10500 | 0.018691588
17000 | 0.018691588
(28 rows )
Le nombre de valeurs recensées est variable en fonction de la taille
de l’échantillon et de la répartition des valeurs, la colonne des noms
last_name
recense moins de valeurs fréquentes dans
pg_stats
.
SELECT
unnest(most_common_vals::text ::text []) AS most_common_val,
unnest(most_common_freqs) AS most_common_freq
FROM pg_stats
WHERE tablename = 'employees'
AND attname = 'last_name' ;
most_common_val | most_common_freq
-----------------+------------------
Cambrault | 0.018691588
Grant | 0.018691588
King | 0.018691588
Smith | 0.018691588
Taylor | 0.018691588
(5 rows )
Afficher l’histogramme pour la colonne
salary
.
WITH bounds AS (
SELECT
generate_series(1 , array_length(histogram_bounds, 1 ) - 1 ) AS bucket_idx,
histogram_bounds::text ::numeric [] AS bounds
FROM pg_stats
WHERE tablename = 'employees' AND attname = 'salary'
)
SELECT
bounds[bucket_idx] AS lower_bound,
bounds[bucket_idx + 1 ] AS upper_bound,
(SELECT COUNT (* )
FROM employees
WHERE salary >= bounds[bucket_idx]
AND salary < bounds[bucket_idx + 1 ]) AS count
FROM bounds;
Quelle est l’estimation du nombre de valeurs
distinctes de commission_pct
?
SELECT attname, n_distinct
FROM pg_stats
WHERE tablename= 'employees'
AND attname= 'commission_pct' ;
attname | n_distinct
----------------+------------
commission_pct | 7
(1 row )
La valeur est supérieure à 0, ceci signifie que PostgreSQL estime que
le nombre de valeurs distinctes pour commission_pct
est
décorrélé du nombre total de lignes dans la table.
Quelle est l’estimation du nombre de valeurs
distinctes de last_name
?
SELECT attname, n_distinct
FROM pg_stats
WHERE tablename= 'employees'
AND attname= 'last_name' ;
attname | n_distinct
-----------+-------------
last_name | - 0.95327103
(1 row )
Ici, la valeur est négative, ceci signifie que PostgreSQL estime que
le nombre de valeurs distinctes de last_name
est
proportionnel au nombre total de lignes de la table. Ici le coefficient
de proportionnalité est proche de 1.
Quelle est l’estimation du nombre de valeurs
distinctes de job_id
? Cette estimation est-elle juste ?
Comment l’améliorer ?
SELECT attname, n_distinct
FROM pg_stats
WHERE tablename= 'employees'
AND attname= 'job_id' ;
attname | n_distinct
-----------+-------------
job_id | - 0.17757009
(1 row )
Ici, PostgreSQL estime aussi que le nombre de valeurs distinctes de
job_id
est proportionnel au nombre total de lignes. Il
faudrait augmenter l’échantillon pour qu’il comprenne que ce n’est pas
le cas.
Selon quelle colonne les données
d’employees
sont-elles le mieux ordonnées sur disque ?
SELECT attname, correlation FROM pg_stats WHERE tablename= 'employees' ;
attname | correlation
----------------+--------------
department_id | 0.09441016
commission_pct | - 0.4140056
employee_id | 1
first_name | 0.061756697
last_name | - 0.10506672
email | 0.052459884
phone_number | 0.094104506
hire_date | 0.15537138
job_id | 0.12764749
salary | - 0.030016262
manager_id | 0.5118422
(11 rows )
L’ordre des données sur disque correspond au tri par clé primaire
employee_id
.
La base hr dispose de nombreux commentaires sur les
objets qui la composent. On peut les voir avec les commandes
\d+
et \dt+
de psql. Ces commentaires sont de
tailles variables et stockés dans la table pg_description
du catalogue système.
Quelle est la largeur moyenne de la colonne
description
de pg_description
?
SELECT attname, avg_width FROM pg_stats WHERE tablename= 'pg_description' AND attname= 'description' ;
attname | avg_width
-------------+-----------
description | 25
(1 row )
Les descriptions (commentaires) présents dans l’instance ont une
largeur moyenne de 25 octets.
Conclusion
Cet exercice nous a fait explorer les vues pg_stats
et
pg_class
, qui révèlent les données essentielles collectées
par PostgreSQL, comme la distribution des valeurs, le nombre de lignes
ou la taille des tables. Ces informations sont au cœur des décisions de
l’optimiseur de requêtes et indispensables pour diagnostiquer les
performances et optimiser les requêtes.
Charger le dump. Ne pas oublier les opérations
habituelles après un chargement.
Si la base cible s’appelle par exemple
correlations :
$ pg_restore -d correlations correlations.dump
$ vacuumdb --analyze correlations
Activer la mesure des durées des I/O dans la
session, désactiver le JIT et le parallélisme.
SET track_io_timing TO on ;
SET jit TO off ;
SET max_parallel_workers_per_gather TO 0 ;
Dans la requête suivante, quelle est la stratégie principale ?
Est-elle efficace ?
-- Cette requête liste les colis d'une liste de villes précisées
EXPLAIN (ANALYZE ,BUFFERS)
SELECT *
FROM colis
WHERE id_ville IN (
SELECT id_ville
FROM villes
WHERE localite = 'PARIS'
AND codepostal LIKE '75%'
);
Le plan est :
QUERY PLAN
--------------------------------------------------------------------------------
Nested Loop (cost=5.85..12897.76 rows=3093 width=16)
(actual time=27.220..820.321 rows=170802 loops=1)
Buffers: shared hit=52994 read=121189
I/O Timings: read=303.505
-> Seq Scan on villes (cost=0.00..1209.32 rows=17 width=8)
(actual time=27.078..29.278 rows=940 loops=1)
Filter: ((codepostal ~~ '75%'::text) AND (localite = 'PARIS'::text))
Rows Removed by Filter: 54015
Buffers: shared read=385
I/O Timings: read=2.686
-> Bitmap Heap Scan on colis (cost=5.85..685.73 rows=182 width=16)
(actual time=0.040..0.816 rows=182 loops=940)
Recheck Cond: (id_ville = villes.id_ville)
Heap Blocks: exact=170515
Buffers: shared hit=52994 read=120804
I/O Timings: read=300.819
-> Bitmap Index Scan on idx_colis_ville
(cost=0.00..5.80 rows=182 width=0)
(actual time=0.018..0.018 rows=182 loops=940)
Index Cond: (id_ville = villes.id_ville)
Buffers: shared hit=2805 read=478
I/O Timings: read=1.903
Planning Time: 1.389 ms
Execution Time: 828.882 ms
Le plan est un Nested Loop . Pour chacune des lignes dans
villes
(obtenues par un Seq Scan ), une lecture de
colis
a lieu (par Bitmap Heap Scan ). C’est une
boucle extrêmement coûteuse : 940 parcours de colis
(1 par
id_ville
).
De plus les tables et index sont volumineux par rapport au cache, il
y a des appels au disque (ou plutôt au cache de l’OS) (indicateurs
read
). Ce problème peut se mitiger avec le temps, mais même
de longs accès en mémoire cache sont à éviter.
Quelles sont les volumétries attendues et
obtenues ?
Comparer avec un filtre uniquement sur la ville ou
le département.
Quel est le problème fondamental ?
Le nombre de lignes obtenues (170 802) est plus de 55 fois supérieur
à celui attendu (3093). Le problème se propage depuis l’estimation
fausse sur villes
. PostgreSQL fait ce choix parce qu’il
estime que la condition
localite = 'PARIS' AND codepostal LIKE '75%'
va ramener 17 enregistrements. En réalité, elle en ramène 940, soit
50 fois plus. Pourquoi PostgreSQL fait-il cette erreur ?
Les volumétries impliquées sont :
SELECT
COUNT (* ) AS nb_villes,
COUNT (* ) FILTER (WHERE localite= 'PARIS' ) AS nb_paris,
COUNT (* ) FILTER (WHERE codepostal LIKE '75%' ) AS nb_75,
COUNT (* ) FILTER (WHERE localite= 'PARIS'
AND codepostal LIKE '75%' ) AS nb_paris_75
FROM villes;
nb_villes | nb_paris | nb_75 | nb_paris_75
-----------+----------+-------+-------------
54955 | 940 | 998 | 940
Les statistiques reproduisent à peu près cela (les chiffres peuvent
varier légèrement entre des installations à cause du choix de
l’échantillon statistique) :
EXPLAIN SELECT * FROM villes ;
QUERY PLAN
-------------------------------------------------------------
Seq Scan on villes (cost=0.00..934.55 rows=54955 width=27)
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)
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)
L’estimation de la combinaison des deux critères est bien
fausse :
EXPLAIN SELECT * FROM villes WHERE localite= 'PARIS'
AND codepostal LIKE '75%' ;
QUERY PLAN
------------------------------------------------------------------------
Seq Scan on villes (cost=0.00..1209.32 rows=18 width=27)
Filter: ((codepostal ~~ '75%'::text) AND (localite = 'PARIS'::text))
D’après les statistiques, villes
contient 54 955
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, ignorant qu’il n’y a
que Paris dans le département 75, 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, PostgreSQL croit donc avoir
(995/54955)×(1042/54955)×54955
= 18,8 enregistrements qui
vérifient le critère complet, ce qui est évidemment faux.
Et un plan portant uniquement sur Paris (ou le département 75) a une
estimation de volumétrie exacte :
EXPLAIN
SELECT *
FROM colis
WHERE id_ville IN (
SELECT id_ville
FROM villes
WHERE localite = 'PARIS'
);
QUERY PLAN
-----------------------------------------------------------------------
Hash Join (cost=1083.94..181388.84 rows=174687 width=16)
Hash Cond: (colis.id_ville = villes.id_ville)
-> Seq Scan on colis (cost=0.00..154053.11 rows=9999911 width=16)
-> Hash (cost=1071.94..1071.94 rows=960 width=8)
-> Seq Scan on villes (cost=0.00..1071.94 rows=960 width=8)
Filter: (localite = 'PARIS'::text)
Tenter d’améliorer l’estimation avec
CREATE STATISTICS
.
Cette fonctionnalité est apparue dans la version 10. Pour calculer
les corrélations entre les deux colonnes en question, la syntaxe
est :
CREATE STATISTICS villes_localite_codepostal ON localite,codepostal FROM villes ;
Le rafraîchissement n’est pas automatique :
Le résultat est-il concluant ?
EXPLAIN
SELECT *
FROM colis
WHERE id_ville IN (
SELECT id_ville
FROM villes
WHERE localite = 'PARIS'
AND codepostal LIKE '75%'
);
La réponse est non :
Nested Loop (cost=5.85..13653.22 rows=3275 width=16)
-> Seq Scan on villes (cost=0.00..1209.32 rows=18 width=8)
Filter: ((codepostal ~~ '75%'::text) AND (localite = 'PARIS'::text))
-> Bitmap Heap Scan on colis (cost=5.85..689.50 rows=183 width=16)
Recheck Cond: (id_ville = villes.id_ville)
-> Bitmap Index Scan on idx_colis_ville (cost=0.00..5.81 rows=183 width=0)
Index Cond: (id_ville = villes.id_ville)
Dans notre cas les statistiques étendues n’aident pas. Par contre,
cela aurait fonctionné avec des départements au lieu des codes postaux,
ce qui est un contournement possible.
Cette colonne supplémentaire peut être alimentée par trigger ou avec
GENERATED ALWAYS AS (left(codepostal,2) STORED
à partir de
la v12.
Créer une fonction SQL comportant les deux
critères : les statistiques associées sont-elles justes ?
On peut indexer sur une fonction des deux critères. C’est un
pis-aller mais la seule solution sûre. PostgreSQL calculera des
statistiques sur le résultat de cette fonction à partir de l’échantillon
au lieu de les calculer indirectement.
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
SELECT * FROM colis WHERE id_ville IN (
SELECT id_ville
FROM villes
WHERE test_ville(localite,codepostal) LIKE 'PARIS-75%'
);
QUERY PLAN
--------------------------------------------------------------------------------
Hash Join (cost=1360.59..181664.68 rows=201980 width=16)
Hash Cond: (colis.id_ville = villes.id_ville)
-> Seq Scan on colis (cost=0.00..154052.48 rows=9999848 width=16)
-> Hash (cost=1346.71..1346.71 rows=1110 width=8)
-> Seq Scan on villes (cost=0.00..1346.71 rows=1110 width=8)
Filter: (((localite || '-'::text) || codepostal)
~~ 'PARIS-75%'::text)
On constate qu’avec cette méthode il n’y a plus d’erreur d’estimation
(1110 est proche du réel 960). Cette méthode est bien sûr pénible à
utiliser, et ne doit donc être réservée qu’aux quelques rares requêtes
au comportement pathologique. Quitte à modifier le code, la colonne
departement
évoquée plus haut est peut-être plus simple et
claire.
Les statistiques améliorées mènent-elles à un
résultat plus rapide ?
De manière générale, des statistiques à jour aident à avoir un
meilleur plan. Mais cela va aussi dépendre de la machine et de son
paramétrage ! Tout ce TP a été effectué avec les paramètres par défaut,
destinés à une machine très modeste :
shared_buffers = 128MB
work_mem = 4MB
random_page_cost = 4
seq_page_cost = 1
effective_cache_size = 4GB
Avec cette configuration, un Hash Join , assez consommateur,
sera choisi. Sur une machine avec un SSD (voire juste de bons disques,
ou si l’OS joue le rôle de cache), ceci peut être moins rapide que le
Nested Loop de la requête d’origine, car l’accès à un bloc de
table isolé n’est guère plus coûteux qu’au sein d’un parcours de table.
Pour un SSD, random_page_cost
peut être passé à 1, et le
Nested Loop a plus de chance de se produire.
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 :
transtypages implicites suspects ;
jointures externes inutiles ;
sous-requêtes imbriquées ;
jointures inutiles (données constantes).