Formation PERF1
Dalibo SCOP
25.09
5 septembre 2025
Formation | Formation PERF1 |
Titre | PostgreSQL Performances |
Révision | 25.09 |
ISBN | N/A |
https://dali.bo/perf1_pdf | |
EPUB | https://dali.bo/perf1_epub |
HTML | https://dali.bo/perf1_html |
Slides | https://dali.bo/perf1_slides |
Cette formation est sous licence CC-BY-NC-SA. Vous êtes libre de la redistribuer et/ou modifier aux conditions suivantes :
PostgreSQL® Postgres® et le logo Slonik sont des marques déposées par PostgreSQL Community Association of Canada.
Ce document ne couvre que les versions supportées de PostgreSQL au moment de sa rédaction, soit les versions 13 à 17.
postgresql.conf
Technologie | Temps d’accès | Débit en lecture |
---|---|---|
RAM | ~ 1 ns | ~ 25 Go/s |
NVMe | ~ 10 µs | ~ 10 Go/s |
SSD (SATA) | ~ 100 µs | ~ 550 Mo/s |
HDD SAS 15ktpm | ~ 1 ms | ~ 100 Mo/s |
HDD SATA | ~ 5 ms | ~ 100 Mo/s |
*_flush_after
vm.dirty_bytes
= 1 Govm.dirty_background_bytes
= 512 Movm.dirty_ratio
/
vm.dirty_background_ratio
vm.zone_reclaim_mode
: passer à 0kernel.sched_migration_cost_ns = 5000000
(×10) (si
kernel < 5.13)echo 5000000 > /sys/kernel/debug/sched/migration_cost_ns
(kernel > 5.13)kernel.sched_autogroup_enabled = 0
sysctl
/etc/sysctl.conf
/etc/sysctl.d/*conf
noatime
, nodiratime
dir_index
data=writeback
nobarrier
Ni antivirus, ni IDS
shared_buffers = ...
wal_buffers
work_mem
× hash_mem_multiplier
maintenance_work_mem
io_combine_limit
(v17)
effective_cache_size
random_page_cost
max_parallel_workers_per_gather
(défaut : 2)max_parallel_workers
(8)max_worker_processes
(8)min_parallel_table_scan_size
(8 Mo)min_parallel_index_scan_size
(512 ko)VACUUM
:
max_parallel_maintenance_workers
(2)COMMIT
), intégrité,
durabilitéfsync
(on
!)min_wal_size
(80 Mo) / max_wal_size
(1
Go)checkpoint_timeout
(5 min, ou plus)checkpoint_completion_target
(passer à 0.9)log_*
)track_*
)autovacuum_vacuum/analyze_scale_factor
(10 / 20 %),
etc.pg_tblspc
-- déclaration
CREATE TABLESPACE ssd LOCATION '/mnt/ssd/pg';
-- droit pour un utilisateur
GRANT CREATE ON TABLESPACE ssd TO un_utilisateur ;
-- pour toute une base
CREATE DATABASE nomdb TABLESPACE ssd;
ALTER DATABASE nomdb SET default_tablespace TO ssd ;
-- pour une table
CREATE TABLE une_table (…) TABLESPACE ssd ;
ALTER TABLE une_table SET TABLESPACE ssd ; -- verrou !
-- pour un index (pas automatique)
ALTER INDEX une_table_i_idx SET TABLESPACE ssd ;
CREATE TABLESPACE ssd LOCATION '/mnt/data_ssd/' ;
CREATE TABLESPACE ssd_tmp1 LOCATION '/mnt/temp1' ;
CREATE TABLESPACE ssd_tmp2 LOCATION '/mnt/temp2' ;
GRANT CREATE ON TABLESPACE ssd TO dupont ;
GRANT CREATE ON TABLESPACE ssd_tmp1,ssd_tmp2 TO dupont ;
default_tablespace
temp_tablespaces
:
--wal-dir
de l’outil initdb
stats_temp_directory
pg_stat_statements
SELECT only
,
UPDATE only
ou TPC-B
PostgreSQL propose de nombreuses voies d’optimisation.
Cela passe en priorité par un bon choix des composants matériels et par une configuration pointilleuse.
Mais ceci ne peut se faire qu’en connaissance de l’ensemble du système, et notamment des applications utilisant les bases de l’instance.
N’hésitez pas, c’est le moment !
Ce TP étant complexe, allez directement suivre la partie Solution.
EXPLAIN
Le modèle vise à minimiser un coût :
ANALYZE
EXPLAIN
QUERY PLAN
---------------------------------------------------------------
Sort (cost=21.64..21.67 rows=9 width=8)
(actual time=0.493..0.498 rows=9 loops=1)
Sort Key: c1
Sort Method: quicksort Memory: 25kB
-> Seq Scan on t1 (cost=0.00..21.50 rows=9 width=8)
(actual time=0.061..0.469 rows=9 loops=1)
Filter: (c2 < 10)
Rows Removed by Filter: 991
Planning Time: 0.239 ms
Execution Time: 0.606 ms
QUERY PLAN
---------------------------------------------------------
Sort (cost=17.64..17.67 rows=9 width=8)
(actual time=0.126..0.127 rows=9 loops=1)
Sort Key: c1
Sort Method: quicksort Memory: 25kB
Buffers: shared hit=3 read=5
-> Seq Scan on t1 (cost=0.00..17.50 rows=9 width=8)
(actual time=0.017..0.106 rows=9 loops=1)
Filter: (c2 < 10)
Rows Removed by Filter: 991
Buffers: shared read=5
QUERY PLAN
----------------------------------------------------
Insert on t1 (cost=0.00..10.00 rows=1000 width=8)
(actual time=8.078..8.079 rows=0 loops=1)
WAL: records=2017 fpi=3 bytes=162673
-> Function Scan on generate_series i
(cost=0.00..10.00 rows=1000 width=8)
(actual time=0.222..0.522 rows=1000 loops=1)
Planning Time: 0.076 ms
Execution Time: 8.141 ms
QUERY PLAN
-----------------------------------------------------------------------
Seq Scan on public.demotoast (cost=0.00..177.00 rows=1000 width=1196) (actual time=0.012..0.182 rows=1000 loops=1)
Output: i, t, png, j
Buffers: shared hit=167
Query Identifier: 698565989149231362
Planning Time: 0.055 ms
Serialization: time=397.480 ms output=307084kB format=text
Buffers: shared hit=14500
Execution Time: 397.776 ms
VERBOSE
MEMORY
COSTS OFF
TIMING OFF
SUMMARY
FORMAT
QUERY PLAN
---------------------------------------------------------
Sort (cost=52.14..52.21 rows=27 width=8) (actual time=1.359..1.366 rows=27 loops=1)
…
Buffers: shared hit=3 read=14
I/O Timings: read=0.388
-> Seq Scan on t1 (cost=0.00..51.50 rows=27 width=8) (actual time=0.086..1.233 rows=27 loops=1)
Filter: (c2 < 10)
Rows Removed by Filter: 2973
Buffers: shared read=14
I/O Timings: read=0.388
Planning:
Buffers: shared hit=43 read=14
I/O Timings: read=0.469
Planning Time: 1.387 ms
Execution Time: 1.470 ms
pg_test_timing
BUFFERS
)track_io_timing
)EXISTS
, IN
et certaines jointures
externes
DISTINCT
)UNION ALL
), Except,
IntersectEXPLAIN
EXPLAIN ANALYZE
EXPLAIN [ANALYZE]
Tous les TP se basent sur la configuration par défaut de PostgreSQL, sauf précision contraire.
UNIQUE
(préférer la contrainte)<
, <=
,
=
, >=
, >
SELECT name FROM ma_table WHERE id = 22
C’est souvent tout à fait normal
VACUUM
fréquentCREATE INDEX … CONCURRENTLY
peut échouerDe nombreuses possibilités d’indexation avancée :
IMMUTABLE
!
ANALYZE
après création d’un index
fonctionnel
VACUUM
fréquent nécessaire !varchar_pattern_ops
:
ANALYZE
et autovacuum
WHERE
ou
JOIN
pg_statistic
(par colonne),
pg_statistic_ext
…pg_stats
(par colonne),
pg_stats_ext
et pg_stats_ext_exprs
ANALYZE
-[ RECORD 1 ]----------+-----------------------------------------------
schemaname | public
tablename | employes
attname | date_embauche
n_distinct | -0.5
-[ RECORD 1 ]-----+----------------------------------------------------
schemaname | public
tablename | employes
attname | date_embauche
most_common_vals | {2006-03-01,2006-09-01,2000-06-01,2005-03-06,2006-01-01}
most_common_freqs | {0.214286,0.214286,0.142857,0.142857,0.142857}
-[ RECORD 1 ]-------+------------------------------------------------------
schemaname | public
tablename | produits
attname | prix
histogram_bounds | {4.00,6.12,9.14,23.55,99.57}
NULL
)ANALYZE
si toutes les valeurs
sont dans les MCV-[ RECORD 1 ]----------+-----------------------------------------------
schemaname | public
tablename | clients
attname | date_inscription
correlation | 1
-[ RECORD 1 ]----------+-----------------------------------------------
schemaname | public
tablename | employes
attname | date_embauche
avg_width | 4
NULL
de taille variable
(text
, json
, jsonb
, binaires,
etc.)integer
,
boolean
, char
, etc.)default_statistics_target
= 100-- Configurable pour chaque colonne
ALTER TABLE t ALTER COLUMN c SET STATISTICS 300 ;
-- Configurable pour chaque statistique étendue
ALTER STATISTICS nom SET STATISTICS valeur ;
ANALYZE
pour rafraîchir les statistiquesCREATE STATISTICS
ANALYZE
après la créationpg_stats_ext
Vues disponibles :
pg_stats_ext
pg_stats_ext_exprs
(pour les expressions, v14)ANALYZE
VACUUM ANALYZE
vacuumdb --analyze / --analyze-only
autovacuum
autovacuum_analyze_*
pg_stat_user_tables
autovacuum
fait l’ANALYZE
mais…
cron
psql -c "ANALYZE"
vacuumdb --analyze-only
Les statistiques sont-elles à jour ?
ANALYZE
N’hésitez pas, c’est le moment !
Face à un problème de performances, l’administrateur se retrouve assez rapidement face à une (ou plusieurs) requête(s). Une requête en soi représente très peu d’informations. Suivant la requête, des dizaines de plans peuvent être sélectionnés pour l’exécuter. Il est donc nécessaire de pouvoir trouver le plan d’exécution et de comprendre ce plan. Cela permet de mieux appréhender la requête et de mieux comprendre les pistes envisageables pour la corriger.
Ce qui suit se concentrera sur les plans d’exécution.
EXPLAIN
Nous ferons quelques rappels et approfondissements sur la façon dont une requête s’exécute globalement, et sur le planificateur : en quoi est-il utile, comment fonctionne-t-il, et comment le configurer.
Nous ferons un tour sur le fonctionnement de la commande
EXPLAIN
et les informations qu’elle fournit. Nous verrons
aussi plus en détail l’ensemble des opérations utilisables par le
planificateur, et comment celui-ci choisit un plan.
L’exécution d’une requête peut se voir sur deux niveaux :
Une lenteur dans une requête peut se trouver dans l’un ou l’autre de ces niveaux.
PostgreSQL est un système client-serveur. L’utilisateur se connecte via un outil (le client) à une base d’une instance PostgreSQL (le serveur). L’outil peut envoyer une requête au serveur, celui-ci l’exécute et finit par renvoyer les données résultant de la requête ou le statut de la requête.
Généralement, l’envoi de la requête est rapide. Par contre, la récupération des données peut poser problème si une grosse volumétrie est demandée sur un réseau à faible débit. L’affichage peut aussi être un problème (afficher une ligne sera plus rapide qu’afficher un million de lignes, afficher un entier est plus rapide qu’afficher un document texte de 1 Mo, etc.).
Lorsque le serveur récupère la requête, un ensemble de traitements est réalisé.
Tout d’abord, le parser va réaliser une analyse syntaxique de la requête.
Puis le rewriter va réécrire, si nécessaire, la requête. Pour cela, il prend en compte les règles, les vues non matérialisées et les fonctions SQL.
Si une règle demande de changer la requête, la requête envoyée est remplacée par la nouvelle.
Si une vue non matérialisée est utilisée, la requête qu’elle contient est intégrée dans la requête envoyée. Il en est de même pour une fonction SQL intégrable.
Ensuite, le planner va générer l’ensemble des plans d’exécutions. Il calcule le coût de chaque plan, puis il choisit le plan le moins coûteux, donc le plus intéressant.
Enfin, l’executer exécute la requête.
Pour cela, il doit commencer par récupérer les verrous nécessaires sur les objets ciblés. Une fois les verrous récupérés, il exécute la requête.
Une fois la requête exécutée, il envoie les résultats à l’utilisateur.
Plusieurs goulets d’étranglement sont visibles ici. Les plus importants sont :
Il est possible de tracer l’exécution des différentes étapes grâce
aux options log_parser_stats
,
log_planner_stats
et log_executor_stats
. Voici
un exemple complet :
SET log_parser_stats TO on;
SET log_planner_stats TO on;
SET log_executor_stats TO on;
SET client_min_messages TO log;
LOG: PARSER STATISTICS
DÉTAIL : ! system usage stats:
! 0.000026 s user, 0.000017 s system, 0.000042 s elapsed
! [0.013275 s user, 0.008850 s system total]
! 17152 kB max resident size
! 0/0 [0/368] filesystem blocks in/out
! 0/3 [0/575] page faults/reclaims, 0 [0] swaps
! 0 [0] signals rcvd, 0/0 [0/0] messages rcvd/sent
! 0/0 [5/0] voluntary/involuntary context switches
LOG: PARSE ANALYSIS STATISTICS
DÉTAIL : ! system usage stats:
! 0.000396 s user, 0.000263 s system, 0.000660 s elapsed
! [0.013714 s user, 0.009142 s system total]
! 19476 kB max resident size
! 0/0 [0/368] filesystem blocks in/out
! 0/32 [0/607] page faults/reclaims, 0 [0] swaps
! 0 [0] signals rcvd, 0/0 [0/0] messages rcvd/sent
! 0/0 [5/0] voluntary/involuntary context switches
LOG: REWRITER STATISTICS
DÉTAIL : ! system usage stats:
! 0.000010 s user, 0.000007 s system, 0.000016 s elapsed
! [0.013747 s user, 0.009165 s system total]
! 19476 kB max resident size
! 0/0 [0/368] filesystem blocks in/out
! 0/1 [0/608] page faults/reclaims, 0 [0] swaps
! 0 [0] signals rcvd, 0/0 [0/0] messages rcvd/sent
! 0/0 [5/0] voluntary/involuntary context switches
DÉTAIL : ! system usage stats:
! 0.000255 s user, 0.000170 s system, 0.000426 s elapsed
! [0.014021 s user, 0.009347 s system total]
! 19476 kB max resident size
! 0/0 [0/368] filesystem blocks in/out
! 0/25 [0/633] page faults/reclaims, 0 [0] swaps
! 0 [0] signals rcvd, 0/0 [0/0] messages rcvd/sent
! 0/0 [5/0] voluntary/involuntary context switches
LOG: EXECUTOR STATISTICS
DÉTAIL : ! system usage stats:
! 0.044788 s user, 0.004177 s system, 0.131354 s elapsed
! [0.058917 s user, 0.013596 s system total]
! 46268 kB max resident size
! 0/0 [0/368] filesystem blocks in/out
! 0/468 [0/1124] page faults/reclaims, 0 [0] swaps
! 0 [0] signals rcvd, 0/0 [0/0] messages rcvd/sent
! 4/16 [9/16] voluntary/involuntary context switches
CALL
)TRUNCATE
et COPY
Il existe quelques requêtes qui échappent à la séquence d’opérations
présentées précédemment. Toutes les opérations DDL (modification de la
structure de la base), les instructions TRUNCATE
et
COPY
(en partie) sont vérifiées syntaxiquement, puis
directement exécutées. Les étapes de réécriture et de planification ne
sont pas réalisées.
Le principal souci pour les performances sur ce type d’instructions est donc l’obtention des verrous et l’exécution réelle.
WHERE
Un prédicat est une condition de filtrage présente dans la clause
WHERE
d’une requête. Par exemple
colonne = valeur
. On parle aussi de prédicats de jointure
pour les conditions de jointures présentes dans la clause
WHERE
ou suivant la clause ON
d’une
jointure.
La sélectivité est liée à l’application d’un prédicat sur une table. Elle détermine le nombre de lignes remontées par la lecture d’une relation suite à l’application d’une clause de filtrage, ou prédicat. Elle peut être vue comme un coefficient de filtrage d’un prédicat. La sélectivité est exprimée sous la forme d’un pourcentage. Pour une table de 1000 lignes, si la sélectivité d’un prédicat est de 10 %, la lecture de la table en appliquant le prédicat devrait retourner 10 % des lignes, soit 100 lignes.
La cardinalité représente le nombre de lignes d’une relation. En d’autres termes, la cardinalité représente le nombre de lignes d’une table ou de la sortie d’un nœud. Elle représente aussi le nombre de lignes retournées par la lecture d’une table après application d’un ou plusieurs prédicats.
services
: 4 lignesservices_big
: 40 000 lignesemployes
: 14 lignesemployes_big
: ~500 000 lignesservice*
.num_service
(clés primaires)employes*
.matricule
(clés primaires)employes*
.date_embauche
employes_big
.num_service
(clé
étrangère)Les deux volumétries différentes vont permettre de mettre en évidence certains effets.
Les tables suivantes nous serviront d’exemple par la suite. Le script de création se télécharge et s’installe ainsi dans une nouvelle base employes :
curl -kL https://dali.bo/tp_employes_services -o employes_services.sql
createdb employes
psql employes < employes_services.sql
Les quelques tables occupent environ 80 Mo sur le disque.
-- suppression des tables si elles existent
DROP TABLE IF EXISTS services CASCADE;
DROP TABLE IF EXISTS services_big CASCADE;
DROP TABLE IF EXISTS employes CASCADE;
DROP TABLE IF EXISTS employes_big CASCADE;
-- définition des tables
CREATE TABLE services (
num_service serial PRIMARY KEY,
nom_service character varying(20),
localisation character varying(20),
departement integer,
date_creation date
);
CREATE TABLE services_big (
num_service serial PRIMARY KEY,
nom_service character varying(20),
localisation character varying(20),
departement integer,
date_creation date
);
CREATE TABLE employes (
matricule serial primary key,
nom varchar(15) not null,
prenom varchar(15) not null,
fonction varchar(20) not null,
manager integer,
date_embauche date,
num_service integer not null references services (num_service)
);
CREATE TABLE employes_big (
matricule serial primary key,
nom varchar(15) not null,
prenom varchar(15) not null,
fonction varchar(20) not null,
manager integer,
date_embauche date,
num_service integer not null references services (num_service)
);
-- ajout des données
INSERT INTO services
VALUES
(1, 'Comptabilité', 'Paris', 75, '2006-09-03'),
(2, 'R&D', 'Rennes', 40, '2009-08-03'),
(3, 'Commerciaux', 'Limoges', 52, '2006-09-03'),
(4, 'Consultants', 'Nantes', 44, '2009-08-03');
INSERT INTO services_big (nom_service, localisation, departement, date_creation)
VALUES
('Comptabilité', 'Paris', 75, '2006-09-03'),
('R&D', 'Rennes', 40, '2009-08-03'),
('Commerciaux', 'Limoges', 52, '2006-09-03'),
('Consultants', 'Nantes', 44, '2009-08-03');
INSERT INTO services_big (nom_service, localisation, departement, date_creation)
SELECT s.nom_service, s.localisation, s.departement, s.date_creation
FROM services s, generate_series(1, 10000);
INSERT INTO employes VALUES
(33, 'Roy', 'Arthur', 'Consultant', 105, '2000-06-01', 4),
(81, 'Prunelle', 'Léon', 'Commercial', 97, '2000-06-01', 3),
(97, 'Lebowski', 'Dude', 'Responsable', 104, '2003-01-01', 3),
(104, 'Cruchot', 'Ludovic', 'Directeur Général', NULL, '2005-03-06', 3),
(105, 'Vacuum', 'Anne-Lise', 'Responsable', 104, '2005-03-06', 4),
(119, 'Thierrie', 'Armand', 'Consultant', 105, '2006-01-01', 4),
(120, 'Tricard', 'Gaston', 'Développeur', 125, '2006-01-01', 2),
(125, 'Berlicot', 'Jules', 'Responsable', 104, '2006-03-01', 2),
(126, 'Fougasse', 'Lucien', 'Comptable', 128, '2006-03-01', 1),
(128, 'Cruchot', 'Josépha', 'Responsable', 105, '2006-03-01', 1),
(131, 'Lareine-Leroy', 'Émilie', 'Développeur', 125, '2006-06-01', 2),
(135, 'Brisebard', 'Sylvie', 'Commercial', 97, '2006-09-01', 3),
(136, 'Barnier', 'Germaine', 'Consultant', 105, '2006-09-01', 4),
(137, 'Pivert', 'Victor', 'Consultant', 105, '2006-09-01', 4);
-- on copie la table employes
INSERT INTO employes_big SELECT * FROM employes;
-- duplication volontaire des lignes d'un des employés
INSERT INTO employes_big
SELECT i, nom,prenom,fonction,manager,date_embauche,num_service
FROM employes_big,
LATERAL generate_series(1000, 500000) i
WHERE matricule=137;
-- création des index
CREATE INDEX ON employes(date_embauche);
CREATE INDEX ON employes_big(date_embauche);
CREATE INDEX ON employes_big(num_service);
-- calcul des statistiques sur les nouvelles données
VACUUM ANALYZE;
Cette requête nous servira d’exemple. Elle permet de déterminer les employés basés à Nantes et pour résultat :
matricule | nom | prenom | nom_service | fonction | localisation
-----------+----------+-----------+-------------+-------------+--------------
33 | Roy | Arthur | Consultants | Consultant | Nantes
105 | Vacuum | Anne-Lise | Consultants | Responsable | Nantes
119 | Thierrie | Armand | Consultants | Consultant | Nantes
136 | Barnier | Germaine | Consultants | Consultant | Nantes
137 | Pivert | Victor | Consultants | Consultant | Nantes
En fonction du cache, elle dure de 1 à quelques millisecondes.
L’objet de ce module est de comprendre son plan d’exécution :
Hash Join (cost=1.06..2.28 rows=4 width=48)
Hash Cond: (emp.num_service = ser.num_service)
-> Seq Scan on employes emp (cost=0.00..1.14 rows=14 width=35)
-> Hash (cost=1.05..1.05 rows=1 width=21)
-> Seq Scan on services ser (cost=0.00..1.05 rows=1 width=21)
Filter: ((localisation)::text = 'Nantes'::text)
La directive EXPLAIN
permet de connaître le plan
d’exécution d’une requête. Elle permet de savoir par quelles étapes va
passer le SGBD pour répondre à la requête.
Ce plan montre une jointure par hachage. La table
services
est parcourue intégralement (Seq Scan),
mais elle est filtrée sur le critère sur « Nantes ».
Un hash de la colonne num_service
des lignes
résultantes de ce filtrage est effectué, et comparé aux valeurs
rencontrées lors d’un parcours complet de employes
.
S’affichent également les coûts estimés des opérations et le nombre de lignes que PostgreSQL s’attend à trouver à chaque étape.
Rappels :
Le but du planificateur est assez simple. Pour une requête, il existe de nombreux plans d’exécution possibles. Il va donc tenter d’énumérer tous les plans d’exécution possibles ; même si leur nombre devient vite colossal dans une requête complexe : chaque table peut être accédée selon différents plans, selon l’un ou l’autre critère ou une combinaison, les algorithmes de jointure possibles sont multiples, etc.
Lors de cette énumération des différents plans, il calcule leur coût. Cela lui permet d’en ignorer certains alors qu’ils sont incomplets si leur plan d’exécution est déjà plus coûteux que les autres. Pour calculer le coût, il dispose d’informations sur les données (des statistiques), d’une configuration (réalisée par l’administrateur de bases de données) et d’un ensemble de règles inscrites en dur.
À la fin de l’énumération et du calcul de coût, il ne lui reste plus qu’à sélectionner le plan qui a le plus petit coût, à priori celui qui sera le plus rapide pour la requête demandée. (En toute rigueur, pour réduire le nombre de plans très voisins à étudier, des plans de coûts différents de 1% près peuvent être considérés équivalents, et le coût de démarrage peut alors les départager.)
Le coût d’un plan est une valeur calculée sans unité ni signification physique.
Règle 1 : récupérer le bon résultat
Règle 2 : le plus rapidement possible
Le planificateur suit deux règles :
Cette deuxième règle lui impose de minimiser l’utilisation des ressources : en tout premier lieu les opérations disques vu qu’elles sont les plus coûteuses, mais aussi la charge CPU (charge des CPU utilisés et nombre de CPU utilisés) et l’utilisation de la mémoire.
Dans le cas des opérations disques, s’il doit en faire, il doit souvent privilégier les opérations séquentielles aux dépens des opérations aléatoires (qui demandent un déplacement de la tête de disque, opération la plus coûteuse sur les disques magnétiques).
Pour déterminer le chemin d’exécution le moins coûteux, l’optimiseur devrait connaître précisément les données mises en œuvre dans la requête, les particularités du matériel et la charge en cours sur ce matériel. Cela est impossible. Ce problème est contourné en utilisant deux mécanismes liés l’un à l’autre :
Pour quantifier la charge nécessaire pour répondre à une requête,
PostgreSQL utilise un mécanisme de coût. Il part du principe que chaque
opération a un coût plus ou moins important. Les statistiques sur les
données permettent à l’optimiseur de requêtes de déterminer assez
précisément la répartition des valeurs d’une colonne d’une table, sous
la forme d’histogramme. Il dispose encore d’autres informations comme la
répartition des valeurs les plus fréquentes, le pourcentage de
NULL
, le nombre de valeurs distinctes, etc.
Toutes ces informations aideront l’optimiseur à déterminer la
sélectivité d’un filtre (prédicat de la clause WHERE
,
condition de jointure) et donc la quantité de données récupérées par la
lecture d’une table en utilisant le filtre évalué. Enfin, l’optimiseur
s’appuie sur le schéma de la base de données afin de déterminer
différents paramètres qui entrent dans le calcul du plan d’exécution :
contrainte d’unicité sur une colonne, présence d’une contrainte
NOT NULL
, etc.
critere IN (SELECT ...)
Suppression des jointures externes inutiles
À partir du modèle de données et de la requête soumise, l’optimiseur de PostgreSQL va pouvoir déterminer si une jointure externe n’est pas utile à la production du résultat.
Sous certaines conditions, PostgreSQL peut supprimer des jointures
externes, à condition que le résultat ne soit pas modifié. Dans
l’exemple suivant, il ne sert à rien d’aller consulter la table
services
(ni données à récupérer, ni filtrage à faire, et
même si la table est vide, le LEFT JOIN
ne provoquera la
disparition d’aucune ligne) :
EXPLAIN
SELECT e.matricule, e.nom, e.prenom
FROM employes e
LEFT JOIN services s
ON (e.num_service = s.num_service)
WHERE e.num_service = 4 ;
QUERY PLAN
-----------------------------------------------------------
Seq Scan on employes e (cost=0.00..1.18 rows=5 width=19)
Filter: (num_service = 4)
Toutefois, si le prédicat de la requête est modifié pour s’appliquer
sur la table services
, la jointure est tout de même
réalisée, puisqu’on réalise un test d’existence sur cette table
services
:
EXPLAIN
SELECT e.matricule, e.nom, e.prenom
FROM employes e
LEFT JOIN services s
ON (e.num_service = s.num_service)
WHERE s.num_service = 4;
QUERY PLAN
-----------------------------------------------------------------
Nested Loop (cost=0.00..2.27 rows=5 width=19)
-> Seq Scan on services s (cost=0.00..1.05 rows=1 width=4)
Filter: (num_service = 4)
-> Seq Scan on employes e (cost=0.00..1.18 rows=5 width=23)
Filter: (num_service = 4)
Transformation des sous-requêtes
Certaines sous-requêtes sont transformées en jointure :
EXPLAIN
SELECT *
FROM employes emp
JOIN (SELECT * FROM services WHERE num_service = 1) ser
ON (emp.num_service = ser.num_service) ;
QUERY PLAN
-------------------------------------------------------------------
Nested Loop (cost=0.00..2.25 rows=2 width=64)
-> Seq Scan on services (cost=0.00..1.05 rows=1 width=21)
Filter: (num_service = 1)
-> Seq Scan on employes emp (cost=0.00..1.18 rows=2 width=43)
Filter: (num_service = 1)
La sous-requête ser
a été remontée dans l’arbre de
requête pour être intégrée en jointure.
Application des prédicats au plus tôt
Lorsque cela est possible, PostgreSQL essaye d’appliquer les prédicats au plus tôt :
EXPLAIN
SELECT MAX(date_embauche)
FROM (SELECT * FROM employes WHERE num_service = 4) e
WHERE e.date_embauche < '2006-01-01' ;
QUERY PLAN
------------------------------------------------------------------------------
Aggregate (cost=1.21..1.22 rows=1 width=4)
-> Seq Scan on employes (cost=0.00..1.21 rows=2 width=4)
Filter: ((date_embauche < '2006-01-01'::date) AND (num_service = 4))
Les deux prédicats num_service = 4
et
date_embauche < '2006-01-01'
ont été appliqués en même
temps, réduisant ainsi le jeu de données à considérer dès le départ.
C’est généralement une bonne chose.
Mais en cas de problème, il est possible d’utiliser une CTE
matérialisée (Common Table Expression, clause
WITH … AS MATERIALIZED (…)
) pour bloquer cette optimisation
et forcer PostgreSQL à
exécuter le contenu de la requête en premier. En versions 12 et
ultérieures, une CTE est par défaut non matérialisée et donc intégrée
avec le reste de la requête (du moins dans les cas simples comme
ci-dessus), comme une sous-requête. On retombe exactement sur le plan
précédent :
-- v12 : CTE sans MATERIALIZED (comportement par défaut)
EXPLAIN
WITH e AS ( SELECT * FROM employes WHERE num_service = 4 )
SELECT MAX(date_embauche)
FROM e
WHERE e.date_embauche < '2006-01-01';
QUERY PLAN
------------------------------------------------------------------------------
Aggregate (cost=1.21..1.22 rows=1 width=4)
-> Seq Scan on employes (cost=0.00..1.21 rows=2 width=4)
Filter: ((date_embauche < '2006-01-01'::date) AND (num_service = 4))
Pour recréer la « barrière d’optimisation », il est nécessaire
d’ajouter le mot-clé MATERIALIZED
:
-- v12 : CTE avec MATERIALIZED
EXPLAIN
WITH e AS MATERIALIZED ( SELECT * FROM employes WHERE num_service = 4 )
SELECT MAX(date_embauche)
FROM e
WHERE e.date_embauche < '2006-01-01';
QUERY PLAN
-----------------------------------------------------------------
Aggregate (cost=1.29..1.30 rows=1 width=4)
CTE e
-> Seq Scan on employes (cost=0.00..1.18 rows=5 width=43)
Filter: (num_service = 4)
-> CTE Scan on e (cost=0.00..0.11 rows=2 width=4)
Filter: (date_embauche < '2006-01-01'::date)
La CTE est alors intégralement exécutée avec son filtre propre, avant que le deuxième filtre soit appliqué dans un autre nœud. Jusqu’en version 11 incluse, ce dernier comportement était celui par défaut, et les CTE étaient une source fréquente de problèmes de performances.
Function inlining
Voici deux fonctions, la première écrite en SQL, la seconde en PL/pgSQL :
CREATE OR REPLACE FUNCTION add_months_sql(mydate date, nbrmonth integer)
RETURNS date AS
$BODY$
SELECT ( mydate + interval '1 month' * nbrmonth )::date;
$BODY$
LANGUAGE SQL;
CREATE OR REPLACE FUNCTION add_months_plpgsql(mydate date, nbrmonth integer)
RETURNS date AS
$BODY$
BEGIN RETURN ( mydate + interval '1 month' * nbrmonth ); END;
$BODY$
LANGUAGE plpgsql;
Si l’on utilise la fonction écrite en PL/pgSQL, on retrouve l’appel
de la fonction dans la clause Filter
du plan d’exécution de
la requête :
EXPLAIN (ANALYZE, BUFFERS, COSTS off)
SELECT *
FROM employes
WHERE date_embauche = add_months_plpgsql(now()::date, -1);
QUERY PLAN
------------------------------------------------------------------------------
Seq Scan on employes (actual time=0.354..0.354 rows=0 loops=1)
Filter: (date_embauche = add_months_plpgsql((now())::date, '-1'::integer))
Rows Removed by Filter: 14
Buffers: shared hit=1
Planning Time: 0.199 ms
Execution Time: 0.509 ms
Effectivement, PostgreSQL ne sait pas intégrer le code des fonctions PL/pgSQL dans ses plans d’exécution.
En revanche, en utilisant la fonction écrite en langage SQL, la définition de la fonction est directement intégrée dans la clause de filtrage de la requête :
EXPLAIN (ANALYZE, BUFFERS, COSTS off)
SELECT *
FROM employes
WHERE date_embauche = add_months_sql(now()::date, -1);
QUERY PLAN
---------------------------------------------------------------------------
Seq Scan on employes (actual time=0.014..0.014 rows=0 loops=1)
Filter: (date_embauche = (((now())::date + '-1 mons'::interval))::date)
Rows Removed by Filter: 14
Buffers: shared hit=1
Planning Time: 0.111 ms
Execution Time: 0.027 ms
Le temps d’exécution a été divisé presque par 20 sur ce jeu de données très réduit, montrant l’impact de l’appel d’une fonction dans une clause de filtrage.
Dans les deux cas ci-dessus, PostgreSQL a négligé l’index sur
date_embauche
: la table ne faisait de toute façon qu’un
bloc ! Mais pour de plus grosses tables, l’index sera nécessaire, et la
différence entre fonctions PL/pgSQL et SQL devient alors encore plus
flagrante. Avec la même requête sur la table employes_big
,
beaucoup plus grosse, on obtient ceci :
EXPLAIN (ANALYZE, BUFFERS, COSTS off)
SELECT *
FROM employes_big
WHERE date_embauche = add_months_plpgsql(now()::date, -1);
QUERY PLAN
------------------------------------------------------------------------------
Seq Scan on employes_big (actual time=464.531..464.531 rows=0 loops=1)
Filter: (date_embauche = add_months_plpgsql((now())::date, '-1'::integer))
Rows Removed by Filter: 499015
Buffers: shared hit=4664
Planning:
Buffers: shared hit=61
Planning Time: 0.176 ms
Execution Time: 465.848 ms
La fonction portant sur une « boîte noire », l’optimiseur n’a comme possibilité que le parcours complet de la table.
EXPLAIN (ANALYZE, BUFFERS, COSTS off)
SELECT *
FROM employes_big
WHERE date_embauche = add_months_sql(now()::date, -1);
QUERY PLAN
-------------------------------------------------------------------------------
Index Scan using employes_big_date_embauche_idx on employes_big
(actual time=0.016..0.016 rows=0 loops=1)
Index Cond: (date_embauche = (((now())::date + '-1 mons'::interval))::date)
Buffers: shared hit=3
Planning Time: 0.143 ms
Execution Time: 0.032 ms
La fonction SQL est intégrée, l’optimiseur voit le critère dans
date_embauche
et peut donc se poser la question de
l’utiliser (et ici, la réponse est oui : 3 blocs contre 4664, tous
présents dans le cache dans cet exemple).
D’où une exécution beaucoup plus rapide.
L’optimiseur doit choisir :
Pour exécuter une requête, le planificateur va utiliser des opérations. Pour lire des lignes, il peut :
Il existe encore d’autres types de parcours. Les accès aux tables et index sont généralement les premières opérations utilisées.
Pour joindre les tables, l’ordre est très important pour essayer de réduire la masse des données manipulées. Les jointures se font toujours entre deux des tables impliquées, pas plus ; ou entre une table et le résultat d’un nœud, ou entre les résultats de deux nœuds.
Pour la jointure elle-même, il existe plusieurs méthodes différentes : boucles imbriquées (Nested Loops), hachage (Hash Join), tri-fusion (Merge Join)…
Il existe également plusieurs algorithmes d’agrégation des lignes. Un
tri peut être nécessaire pour une jointure, une agrégation, ou pour un
ORDER BY
, et là encore il y a plusieurs algorithmes
possibles. L’optimiseur peut aussi décider d’utiliser un index (déjà
trié) pour éviter ce tri.
Certaines des opérations ci-dessus sont parallélisables. Certaines sont aussi susceptibles de consommer beaucoup de mémoire, l’optimiseur doit en tenir compte.
DISTINCT
(v15)Principe :
À partir d’une certaine quantité de données à traiter par un nœud, un ou plusieurs processus auxiliaires (parallel workers) apparaissent pour répartir la charge sur d’autres processeurs. Sans cela, une requête n’est traitée que par un seul processus sur un seul processeur.
Il ne s’agit pas de lire une table avec plusieurs processus mais de
répartir le traitement des lignes. La parallélisation n’est donc utile
que si le CPU est le facteur limitant. Par exemple, un simple
SELECT
sur une grosse table sans WHERE
ne
mènera pas à un parcours parallélisé.
La parallélisation concerne en premier lieu les parcours de tables
(Seq Scan), les jointures (Nested Loop, Hash
Join, Merge Join), ainsi que certaines fonctions d’agrégat
(comme min
, max
, avg
,
sum
, etc.) ; mais encore les parcours d’index B-Tree
(Index Scan, Index Only Scan et Bitmap Scan)
La parallélisation est en principe disponible pour les autres types
d’index, mais ils n’en font pas usage pour l’instant.
La parallélisation ne concerne encore que les opérations en lecture.
Il y a des exceptions, comme la création des index B-Tree de façon
parallélisée. Certaines créations de table avec
CREATE TABLE … AS
, SELECT … INTO
sont aussi
parallélisables, ainsi que CREATE MATERIALIZED VIEW
.
En version 15, il devient possible de paralléliser des clauses
DISTINCT
.
Paramétrage :
Le paramétrage s’est affiné au fil des versions.
Le paramètre max_parallel_workers_per_gather
(2 par
défaut) désigne le nombre de processus auxiliaires maximum d’un nœud
d’une requête. max_parallel_maintenance_workers
(2 par
défaut) est l’équivalent dans les opérations de maintenance
(réindexation notamment). Trop de processus parallèles peuvent mener à
une saturation de CPU ; l’exécuteur de PostgreSQL ne lancera donc pas
plus de max_parallel_workers
processus auxiliaires
simultanés (8 par défaut), lui-même limité par
max_worker_processes
(8 par défaut). En pratique, on
ajustera le nombre de parallel workers en fonction des CPU de
la machine et de la charge attendue.
La mise en place de l’infrastructure de parallélisation a un coût,
défini par parallel_setup_cost
(1000 par défaut), et des
tailles de table ou index minimales, en-dessous desquels la
parallélisation n’est pas envisagée.
La plupart de ces paramètres peuvent être modifiés dans une sessions
par SET
.
Même si cette fonctionnalité évolue au fil des versions majeures, des limitations assez fortes restent présentes, notamment :
INSERT
, UPDATE
,DELETE
,
etc.),ALTER TABLE
ne peut pas être parallélisé)Il y a des cas particuliers, notamment CREATE TABLE AS
ou CREATE MATERIALIZED VIEW
, parallélisable à partir de la
v11 ; ou le niveau d’isolation serializable: avant la v12, il
ne permet aucune parallélisation.
L’optimiseur statistique de PostgreSQL utilise un modèle de calcul de coût. Les coûts calculés sont des indications arbitraires sur la charge nécessaire pour répondre à une requête. Chaque facteur de coût représente une unité de travail : lecture d’un bloc, manipulation des lignes en mémoire, application d’un opérateur sur des données.
SET
Pour quantifier la charge nécessaire pour répondre à une requête, PostgreSQL utilise un mécanisme de coût. Il part du principe que chaque opération a un coût plus ou moins important.
Divers paramètres permettent d’ajuster les coûts relatifs. Ces coûts sont arbitraires, à ne comparer qu’entre eux, et ne sont pas liés directement à des caractéristiques physiques du serveur.
seq_page_cost
(1 par défaut) représente le coût relatif
d’un accès séquentiel à un bloc sur le disque, c’est-à-dire à un bloc lu
en même temps que ses voisins dans la table ;random_page_cost
(4 par défaut) représente le coût
relatif d’un accès aléatoire (isolé) à un bloc : 4 signifie que le temps
d’accès de déplacement de la tête de lecture de façon aléatoire est
estimé 4 fois plus important que le temps d’accès en séquentiel — ce
sera moins avec un bon disque, voire 1 pour un SSD ;cpu_tuple_cost
(0,01 par défaut) représente le coût
relatif de la manipulation d’une ligne en mémoire ;cpu_index_tuple_cost
(0,005 par défaut) répercute le
coût de traitement d’une donnée issue d’un index ;cpu_operator_cost
(défaut 0,0025) indique le coût
d’application d’un opérateur sur une donnée ;parallel_tuple_cost
(0,1 par défaut) indique le coût
estimé du transfert d’une ligne d’un processus à un autre ;parallel_setup_cost
(1000 par défaut) indique le coût
de mise en place d’un parcours parallélisé, une procédure assez lourde
qui ne se rentabilise pas pour les petites requêtes ;jit_above_cost
(100 000 par défaut),
jit_inline_above_cost
(500 000 par défaut),
jit_optimize_above_cost
(500 000 par défaut) représentent
les seuils d’activation de divers niveaux du JIT (Just In Time
ou compilation à la volée des requêtes), outil qui ne se rentabilise que
sur les gros volumes.En général, on ne modifie pas ces paramètres sans justification
sérieuse. Le plus fréquemment, on peut être amené à diminuer
random_page_cost
si le serveur dispose de disques rapides,
d’une carte RAID équipée d’un cache important ou de SSD. Mais en faisant
cela, il faut veiller à ne pas déstabiliser des plans optimaux qui
obtiennent des temps de réponse constants. À trop diminuer
random_page_cost
, on peut obtenir de meilleurs temps de
réponse si les données sont en cache, mais aussi des temps de réponse
dégradés si les données ne sont pas en cache.
Pour des besoins particuliers, ces paramètres sont modifiables dans
une session. Ils peuvent être modifiés dynamiquement par l’application
avec l’ordre SET
pour des requêtes bien particulières, pour
éviter de toucher au paramétrage général.
Un plan d’exécution se lit en partant du nœud se trouvant le plus à droite et en remontant jusqu’au nœud final. Quand le plan contient plusieurs nœuds, le premier nœud exécuté est celui qui se trouve le plus à droite. Celui qui est le plus à gauche (la première ligne) est le dernier nœud exécuté. Tous les nœuds sont exécutés simultanément, et traitent les données dès qu’elles sont transmises par le nœud parent (le ou les nœuds justes en dessous, à droite).
Chaque nœud montre les coûts estimés dans le premier groupe de
parenthèses. cost
est un couple de deux coûts : la première
valeur correspond au coût pour récupérer la première ligne (souvent nul
dans le cas d’un parcours séquentiel) ; la deuxième valeur correspond au
coût pour récupérer toutes les lignes (elle dépend essentiellement de la
taille de la table lue, mais aussi d’opération de filtrage).
rows
correspond au nombre de lignes que le planificateur
pense récupérer à la sortie de ce nœud. width
est la
largeur en octets de la ligne.
Cet exemple simple permet de voir le travail de l’optimiseur :
SET enable_nestloop TO off;
EXPLAIN
SELECT matricule, nom, prenom, nom_service, fonction, localisation
FROM employes emp
JOIN services ser ON (emp.num_service = ser.num_service)
WHERE ser.localisation = 'Nantes';
QUERY PLAN
-------------------------------------------------------------------------
Hash Join (cost=1.06..2.34 rows=4 width=48)
Hash Cond: (emp.num_service = ser.num_service)
-> Seq Scan on employes emp (cost=0.00..1.14 rows=14 width=35)
-> Hash (cost=1.05..1.05 rows=1 width=21)
-> Seq Scan on services ser (cost=0.00..1.05 rows=1 width=21)
Filter: ((localisation)::text = 'Nantes'::text)
Ce plan débute en bas par la lecture de la table
services
. L’optimiseur estime que cette lecture ramènera
une seule ligne (rows=1
), que cette ligne occupera 21
octets en mémoire (width=21
). Il s’agit de la sélectivité
du filtre WHERE localisation = 'Nantes'
. Le coût de départ
de cette lecture est de 0 (cost=0.00
). Le coût total de
cette lecture est de 1,05, qui correspond à la lecture séquentielle d’un
seul bloc (paramètre seq_page_cost
) et à la manipulation
des 4 lignes de la table services
(donc 4 *
cpu_tuple_cost
+ 4 * cpu_operator_cost
). Le
résultat de cette lecture est ensuite haché par le nœud Hash,
qui précède la jointure de type Hash Join.
La jointure peut maintenant commencer, avec le nœud Hash
Join. Il est particulier, car il prend 2 entrées : la donnée hachée
initialement, et les données issues de la lecture d’une seconde table
(peu importe le type d’accès). Le nœud a un coût de démarrage de 1,06,
soit le coût du hachage additionné au coût de manipulation du tuple de
départ. Il s’agit du coût de production du premier tuple de résultat. Le
coût total de production du résultat est de 2,34. La jointure par
hachage démarre réellement lorsque la lecture de la table
employes
commence. Cette lecture remontera 14 lignes, sans
application de filtre. La totalité de la table est donc remontée et elle
est très petite donc tient sur un seul bloc de 8 ko. Le coût d’accès
total est donc facilement déduit à partir de cette information. À partir
des sélectivités précédentes, l’optimiseur estime que la jointure
ramènera 4 lignes au total.
ANALYZE
: exécution (danger !)
BUFFERS
: blocs
read/hit/written/dirtied,
shared/local/tempWAL
: écritures dans les journauxSERIALIZE
: coût de sérialisation (v17)SETTINGS
: paramètrage en coursVERBOSE
: champs manipulés, workersMEMORY
: mémoire consommée par le planificateur
(v17)GENERIC_PLAN
: plan générique (requête préparée,
v16)OFF
:
COSTS
, TIMING
, SUMMARY
(dont
planification)FORMAT
: sortie en texte, XML, JSON, YAMLAu fil des versions, EXPLAIN
a gagné en options. L’une
d’entre elles permet de sélectionner le format en sortie. Toutes les
autres permettent d’obtenir des informations supplémentaires, ou au
contraire de masquer des informations affichées par défaut.
Option ANALYZE
Le but de cette option est d’obtenir les informations sur l’exécution
réelle de la requête. EXPLAIN
sans ANALYZE
renvoie juste le plan prévu.
Cette option n’a rien à voir avec l’ordre
ANALYZE nom_table
, qui met à jour les statistiques !
Avec ANALYZE
, la requête est réellement exécutée !
Attention donc aux
INSERT
/UPDATE
/DELETE
. N’oubliez
pas non plus qu’un SELECT
peut appeler des fonctions qui
écrivent dans la base. Dans le doute, englober l’appel dans une
transaction que vous annulerez après coup.
Voici un exemple utilisant cette option :
QUERY PLAN
---------------------------------------------------------
Seq Scan on employes (cost=0.00..1.18 rows=3 width=43)
(actual time=0.004..0.005 rows=3 loops=1)
Filter: (matricule < 100)
Rows Removed by Filter: 11
Planning time: 0.027 ms
Execution time: 0.013 ms
Quatre nouvelles informations apparaissent, toutes liées à l’exécution réelle de la requête :
actual time
:
rows
est le nombre de lignes réellement
récupérées : comparer au nombre de la première parenthèse permet d’avoir
une idée de la justesse des statistiques et de l’estimation ;loops
est le nombre d’exécutions de ce nœud, car
certains peuvent être répétés de nombreuses fois.Multiplier la durée par le nombre de boucles pour obtenir la durée réelle d’exécution du nœud !
L’intérêt de cette option est donc de trouver l’opération qui prend du temps dans l’exécution de la requête, mais aussi de voir les différences entre les estimations et la réalité (notamment au niveau du nombre de lignes).
Option BUFFERS
Elle indique le nombre de blocs impactés par chaque nœud du plan
d’exécution, en lecture comme en écriture. Cette option n’est en
pratique utilisable qu’avec l’option ANALYZE
. Elle est
désactivée par défaut.
Cette option est à activer systématiquement. La quantité de blocs manipulés par une requête est souvent étonnante.
Voici un exemple de son utilisation :
QUERY PLAN
---------------------------------------------------------
Seq Scan on employes (cost=0.00..1.18 rows=3 width=43)
(actual time=0.002..0.004 rows=3 loops=1)
Filter: (matricule < 100)
Rows Removed by Filter: 11
Buffers: shared hit=1
Planning time: 0.024 ms
Execution time: 0.011 ms
La nouvelle ligne est la ligne Buffers
.
shared hit
indique un accès à une table ou index dans les
shared buffers de PostgreSQL. Ces autres indications peuvent se
rencontrer :
Informations | Type d’objet concerné | Explications |
---|---|---|
Shared hit | Table ou index permanent | Lecture d’un bloc dans le cache |
Shared read | Table ou index permanent | Lecture d’un bloc hors du cache |
Shared written | Table ou index permanent | Écriture d’un bloc |
Local hit | Table ou index temporaire | Lecture d’un bloc dans le cache |
Local read | Table ou index temporaire | Lecture d’un bloc hors du cache |
Local written | Table ou index temporaire | Écriture d’un bloc |
Temp read | Tris et hachages | Lecture d’un bloc |
Temp written | Tris et hachages | Écriture d’un bloc |
EXPLAIN (BUFFERS)
peut fonctionner sans
ANALYZE
, mais n’affiche alors que les blocs consommés par
la seule planification. C’est surtout intéressant pour mettre en
évidence le coût d’une première planification, avec le chargement des
tables de statistique et l’effet de cache dans la session par exemple.
Cet exemple est extrême (10 000 partitions avec leurs statistiques à
consulter) :
QUERY PLAN
------------------------------------------
Append
-> Seq Scan on pgbench_accounts_1
Filter: (bid = 1)
-> Seq Scan on pgbench_accounts_2
Filter: (bid = 1)
…
…
-> Seq Scan on pgbench_accounts_9998
Filter: (bid = 1)
-> Seq Scan on pgbench_accounts_9999
Filter: (bid = 1)
-> Seq Scan on pgbench_accounts_10000
Filter: (bid = 1)
Planning:
Buffers: shared hit=431473
Temps : 876,843 ms
Option SETTINGS
Désactivée par défaut, cette option affiche les valeurs des paramètres qui ne sont pas à la valeur par défaut dans la session de la requête. Il est conseillé de l’activer, surtout pour communiquer le plan à des personnes extérieures, ou pour comparer l’effet de différents paramétrages.
QUERY PLAN
-------------------------------------------------------------------------------
Index Scan using employes_big_pkey on employes_big (cost=0.42..8.44 rows=1 width=41)
Index Cond: (matricule = 33)
QUERY PLAN
--------------------------------------------------------------------------------
Bitmap Heap Scan on employes_big (cost=4.43..8.44 rows=1 width=41)
Recheck Cond: (matricule = 33)
-> Bitmap Index Scan on employes_big_pkey (cost=0.00..4.43 rows=1 width=0)
Index Cond: (matricule = 33)
Settings: enable_indexscan = 'off'
Option WAL
Cette option permet d’obtenir le nombre d’enregistrements et le
nombre d’octets écrits dans les journaux de transactions. Elle est
désactivée par défaut, et là encore il est conseillé de l’activer au
moindre doute, et pas juste lors des écritures. Même un
SELECT
peut générer des journaux (maintenance des hint
bits, appel de fonctions…).
CREATE TABLE t1 (id integer);
EXPLAIN (ANALYZE, WAL) INSERT INTO t1 SELECT generate_series(1, 1000) ;
QUERY PLAN
-------------------------------------------------------------
Insert on t1 (cost=0.00..15.02 rows=1000 width=12)
(actual time=1.457..1.458 rows=0 loops=1)
WAL: records=2009 bytes=123824
-> Subquery Scan on "*SELECT*"
(cost=0.00..15.02 rows=1000 width=12)
(actual time=0.003..0.146 rows=1000 loops=1)
-> ProjectSet (cost=0.00..5.02 rows=1000 width=4)
(actual time=0.002..0.068 rows=1000 loops=1)
-> Result (cost=0.00..0.01 rows=1 width=0)
(actual time=0.001..0.001 rows=1 loops=1)
Planning Time: 0.033 ms
Execution Time: 1.479 ms
Option SERIALIZE
À partir de PostgreSQL 17, il est conseillé d’activer cette option
lors d’un EXPLAIN (ANALYZE)
. Elle force la lecture des
données et leur conversion en texte ou binaire (« sérialisation »),
comme le fait une exécution normale. Un EXPLAIN (ANALYZE)
sans cette option ne lit pas forcément toutes les données, ne les
convertit pas, et peut donc afficher un temps inférieur à la réalité en
production. Cet exemple simple montre bien la différence en temps et
volumétrie :
QUERY PLAN
-------------------------------------------------------------
Seq Scan on employes_big (cost=0.00..9654.15 rows=499015 width=41) (actual time=0.013..21.489 rows=499015 loops=1)
Buffers: shared hit=4664
Planning Time: 0.069 ms
Execution Time: 34.783 ms
QUERY PLAN
-------------------------------------------------------------
Seq Scan on employes_big (cost=0.00..9654.15 rows=499015 width=41) (actual time=0.013..17.622 rows=499015 loops=1)
Buffers: shared hit=4664
Planning Time: 0.047 ms
Serialization: time=104.727 ms output=34982kB format=text
Execution Time: 142.996 ms
Cet autre exemple met en évidence le gain en temps et volume si l’on
n’a besoin que d’un seul champ, par rapport au SELECT *
qui
ramène tout (une mauvaise pratique) :
QUERY PLAN
-------------------------------------------------------------
Seq Scan on employes_big (cost=0.00..9654.15 rows=499015 width=4) (actual time=0.015..24.392 rows=499015 loops=1)
Buffers: shared hit=4664
Planning Time: 0.079 ms
Serialization: time=27.199 ms output=5743kB format=text
Execution Time: 73.697 ms
Le temps perdu peut être encore plus important si une table TOAST est inutilement lue.
Le temps supplémentaire perdu lors d’un transfert sur le réseau n’est hélas pas pris en compte, puisque l’ordre s’éxécute intégralement sur le serveur.
Option MEMORY
À partir de PostgreSQL 17, le planificateur peut afficher la mémoire qu’il consomme le temps de la planification, pour chaque requête. En général, il ne s’agira que de quelques kilo-octets. Ce peut être beaucoup plus haut, par exemple avec de nombreuses partitions :
QUERY PLAN
-------------------------------------------------------------------------------
Append (cost=0.00..35000.00 rows=1000000 width=97)
-> Seq Scan on pgbench_accounts_1 (cost=0.00..3.00 rows=100 width=97)
…
…
-> Seq Scan on pgbench_accounts_10000 (cost=0.00..3.00 rows=100 width=97)
Planning:
Memory: used=80587kB allocated=84769kB
Option GENERIC_PLAN
Cette option (à partir de PostgreSQL 16) sert quand on cherche le plan générique planifié pour une requête préparée (c’est-à-dire dont les paramètres seront fournis séparément).
QUERY PLAN
-----------------------------------------------------------------------
Index Scan using t1_c1_idx on t1 (cost=0.15..14.98 rows=333 width=8)
Index Cond: (c1 < $1)
Planning Time: 0.195 ms
Option COSTS
Activée par défaut, l’option COSTS
indique les
estimations du planificateur. La désactiver avec OFF
permet
de gagner en lisibilité.
QUERY PLAN
---------------------------------------------------------
Seq Scan on employes (cost=0.00..1.18 rows=3 width=43)
Filter: (matricule < 100)
Option TIMING
Cette option n’est utilisable qu’avec l’option ANALYZE
et est activée par défaut. Elle ajoute les informations sur les durées
en milliseconde. Sa désactivation peut être utile sur certains systèmes
où le chronométrage prend beaucoup de temps et allonge inutilement la
durée d’exécution de la requête. Voici un exemple :
QUERY PLAN
---------------------------------------------------------
Seq Scan on employes (cost=0.00..1.18 rows=3 width=43)
(actual time=0.003..0.004 rows=3 loops=1)
Filter: (matricule < 100)
Rows Removed by Filter: 11
Planning time: 0.022 ms
Execution time: 0.010 ms
QUERY PLAN
---------------------------------------------------------
Seq Scan on employes (cost=0.00..1.18 rows=3 width=43)
(actual rows=3 loops=1)
Filter: (matricule < 100)
Rows Removed by Filter: 11
Planning time: 0.025 ms
Execution time: 0.010 ms
Option VERBOSE
L’option VERBOSE
permet d’afficher des informations
supplémentaires comme la liste des colonnes en sortie, le nom de la
table qualifié du nom du schéma, le nom de la fonction qualifié du nom
du schéma, le nom du déclencheur (trigger), le détail des temps de
chaque worker parallèle, le SQL envoyé à une table distante,
etc. Elle est désactivée par défaut. Il vaut mieux l’activer autant que
possible si un affichage chargé n’est pas un souci.
QUERY PLAN
---------------------------------------------------------------------
Seq Scan on public.employes (cost=0.00..1.18 rows=3 width=43)
Output: matricule, nom, prenom, fonction, manager, date_embauche,
num_service
Filter: (employes.matricule < 100)
On voit dans cet exemple le nom du schéma ajouté au nom de la table,
et la nouvelle section Output
indique la liste des colonnes
en sortie du nœud.
Option SUMMARY
Cette option permet d’afficher ou non le résumé final indiquant la
durée de la planification et de l’exécution. Par défaut, un
EXPLAIN
simple n’affiche pas le résumé, mais un
EXPLAIN ANALYZE
le fait.
QUERY PLAN
----------------------------------------------------------
Seq Scan on employes (cost=0.00..1.14 rows=14 width=43)
QUERY PLAN
----------------------------------------------------------
Seq Scan on employes (cost=0.00..1.14 rows=14 width=43)
Planning time: 0.014 ms
QUERY PLAN
----------------------------------------------------------
Seq Scan on employes (cost=0.00..1.14 rows=14 width=43)
(actual time=0.002..0.003 rows=14 loops=1)
Planning time: 0.013 ms
Execution time: 0.009 ms
QUERY PLAN
----------------------------------------------------------
Seq Scan on employes (cost=0.00..1.14 rows=14 width=43)
(actual time=0.002..0.003 rows=14 loops=1)
Option FORMAT
Par défaut, la sortie est sous forme d’un texte destiné à être lu par
un humain, mais il est possible de choisir un format balisé parmi XML,
JSON et YAML. Voici ce que donne la commande EXPLAIN
avec
le format XML :
QUERY PLAN
----------------------------------------------------------
<explain xmlns="http://www.postgresql.org/2009/explain">+
<Query> +
<Plan> +
<Node-Type>Seq Scan</Node-Type> +
<Parallel-Aware>false</Parallel-Aware> +
<Relation-Name>employes</Relation-Name> +
<Alias>employes</Alias> +
<Startup-Cost>0.00</Startup-Cost> +
<Total-Cost>1.18</Total-Cost> +
<Plan-Rows>3</Plan-Rows> +
<Plan-Width>43</Plan-Width> +
<Filter>(matricule < 100)</Filter> +
</Plan> +
</Query> +
</explain>
(1 row)
Les signes +
en fin de ligne indiquent un retour à la
ligne lors de l’utilisation de l’outil psql
. Il est
possible de ne pas les afficher en configurant l’option
format
de psql
à unaligned
. Cela
se fait ainsi :
Ces formats semi-structurés sont utilisés principalement par des outils, car le contenu est plus facile à analyser, voire un peu plus complet.
Quelles options utiliser ?
Avec EXPLAIN (ANALYZE)
, n’hésitez pas à utiliser
systématiquement BUFFERS
, SETTINGS
,
SERIALIZE
et WAL
. VERBOSE
peut
surcharger l’affichage, mais est conseillé avec des outils externes
comme explain.dalibo.com. Les
autres options sont plus ciblées.
Afin de comparer les différents plans d’exécution possibles pour une requête et choisir le meilleur, l’optimiseur a besoin d’estimer un coût pour chaque nœud du plan.
L’estimation la plus cruciale est celle liée aux nœuds de parcours de données, car c’est d’eux que découlera la suite du plan. Pour estimer le coût de ces nœuds, l’optimiseur s’appuie sur les informations statistiques collectées, ainsi que sur la valeur de paramètres de configuration.
Les deux notions principales de ce calcul sont la cardinalité (nombre de lignes estimées en sortie d’un nœud) et la sélectivité (fraction des lignes conservées après l’application d’un filtre).
Voici ci-dessous un exemple de calcul de cardinalité et de détermination du coût associé.
Calcul de cardinalité
Pour chaque prédicat et chaque jointure, PostgreSQL va calculer sa sélectivité et sa cardinalité. Pour un prédicat, cela permet de déterminer le nombre de lignes retournées par le prédicat par rapport au nombre total de lignes de la table. Pour une jointure, cela permet de déterminer le nombre de lignes retournées par la jointure entre deux tables.
L’optimiseur dispose de plusieurs façons de calculer la cardinalité
d’un filtre ou d’une jointure selon que la valeur recherchée est une
valeur unique, que la valeur se trouve dans le tableau des valeurs les
plus fréquentes ou dans l’histogramme. Cherchons comment calculer la
cardinalité d’un filtre simple sur une table employes
de 14
lignes, par exemple WHERE num_service = 1
.
Ici, la valeur recherchée se trouve directement dans le tableau des
valeurs les plus fréquentes (dans les champs
most_common_vals
et most_common_freqs
) la
cardinalité peut être calculée directement.
-[ RECORD 1 ]----------+---------------------------------------------
schemaname | public
tablename | employes
attname | num_service
inherited | f
null_frac | 0
avg_width | 4
n_distinct | -0.2857143
most_common_vals | {4,3,2,1}
most_common_freqs | {0.35714287,0.2857143,0.21428572,0.14285715}
histogram_bounds | ¤
correlation | 0.10769231
…
La requête suivante permet de récupérer la fréquence d’apparition de la valeur recherchée :
SELECT tablename, attname, value, freq
FROM (SELECT tablename, attname, mcv.value, mcv.freq FROM pg_stats,
LATERAL ROWS FROM (unnest(most_common_vals::text::int[]),
unnest(most_common_freqs)) AS mcv(value, freq)
WHERE tablename = 'employes'
AND attname = 'num_service') get_mcv
WHERE value = 1;
tablename | attname | value | freq
-----------+-------------+-------+----------
employes | num_service | 1 | 0.142857
Si l’on n’avait pas eu affaire à une des valeurs les plus fréquentes,
il aurait fallu passer par l’histogramme des valeurs
(histogram_bounds
, ici vide car il y a trop peu de
valeurs), pour calculer d’abord la sélectivité du filtre pour en déduire
ensuite la cardinalité.
Une fois cette fréquence obtenue, l’optimiseur calcule la cardinalité
du prédicat WHERE num_service = 1
en la multipliant avec le
nombre total de lignes de la table :
Le calcul est cohérent avec le plan d’exécution de la requête
impliquant la lecture de employes
sur laquelle on applique
le prédicat évoqué plus haut :
QUERY PLAN
---------------------------------------------------------
Seq Scan on employes (cost=0.00..1.18 rows=2 width=43)
Filter: (num_service = 1)
Calcul de coût
Notre table employes
peuplée de 14 lignes va permettre
de montrer le calcul des coûts réalisés par l’optimiseur. L’exemple
présenté ci-dessous est simplifié. En réalité, les calculs sont plus
complexes, car ils tiennent également compte de la volumétrie réelle de
la table.
Le coût de la lecture séquentielle de la table employes
est calculé à partir de deux composantes. Tout d’abord, le nombre de
pages (ou blocs) de la table permet de déduire le nombre de blocs à
accéder pour lire la table intégralement. Le paramètre
seq_page_cost
(coût d’accès à un bloc dans un parcours
complet) sera appliqué ensuite pour obtenir le coût de l’opération :
SELECT relname, relpages * current_setting('seq_page_cost')::float AS cout_acces
FROM pg_class
WHERE relname = 'employes';
Cependant, le coût d’accès seul ne représente pas le coût de la
lecture des données. Une fois que le bloc est monté en mémoire,
PostgreSQL doit décoder chaque ligne individuellement. L’optimiseur
multiplie donc par cpu_tuple_cost
(0,01 par défaut) pour
estimer le coût de manipulation des lignes :
SELECT relname,
relpages * current_setting('seq_page_cost')::float
+ reltuples * current_setting('cpu_tuple_cost')::float AS cout
FROM pg_class
WHERE relname = 'employes';
Le calcul est bon :
QUERY PLAN
----------------------------------------------------------
Seq Scan on employes (cost=0.00..1.14 rows=14 width=43)
Avec un filtre dans la requête, les traitements seront plus lourds.
Par exemple, en ajoutant le prédicat
WHERE date_embauche='2006-01-01'
, il faut non seulement
extraire les lignes les unes après les autres, mais également appliquer
l’opérateur de comparaison utilisé. L’optimiseur utilise le paramètre
cpu_operator_cost
pour déterminer le coût d’application
d’un filtre :
SELECT relname,
relpages * current_setting('seq_page_cost')::float
+ reltuples * current_setting('cpu_tuple_cost')::float
+ reltuples * current_setting('cpu_operator_cost')::float AS cost
FROM pg_class
WHERE relname = 'employes';
Ce nombre se retrouve dans le plan, à l’arrondi près :
QUERY PLAN
---------------------------------------------------------
Seq Scan on employes (cost=0.00..1.18 rows=2 width=43)
Filter: (date_embauche = '2006-01-01'::date)
Pour aller plus loin dans le calcul de sélectivité, de cardinalité et de coût, la documentation de PostgreSQL contient un exemple complet de calcul de sélectivité et indique les références des fichiers sources dans lesquels fouiller pour en savoir plus.
Les plans sont extrêmement sensibles aux données elles-mêmes bien
sûr, aux paramètres, aux tailles réelles des objets, à la version de
PostgreSQL, à l’ordre des données dans la table, voire au moment du
passage d’un VACUUM
. Il n’est donc pas étonnant de trouver
parfois des plans différents de ceux reproduits ici pour une même
requête.
Par parcours, on entend le renvoi d’un ensemble de lignes provenant soit d’un fichier, soit d’un traitement. Le fichier peut correspondre à une table ou à une vue matérialisée, et on parle dans ces deux cas d’un parcours de table. Le fichier peut aussi correspondre à un index, auquel cas on parle de parcours d’index. Un parcours peut être un traitement dans différents cas, principalement celui d’une procédure stockée.
Seq Scan :
Les parcours de tables sont les principales opérations qui lisent les données des tables (normales, temporaires ou non journalisées) et des vues matérialisées. Elles ne prennent donc pas d’autre nœud en entrée et fournissent un ensemble de données en sortie. Cet ensemble peut être trié ou non, filtré ou non.
L’opération Seq Scan correspond à une lecture séquentielle d’une table, aussi appelée Full table scan sur d’autres SGBD. Il consiste à lire l’intégralité de la table, du premier bloc au dernier bloc. Une clause de filtrage peut être appliquée.
Ce nœud apparaît lorsque la requête nécessite de lire l’intégralité ou la majorité de la table :
QUERY PLAN
----------------------------------------------------------
Seq Scan on employes (cost=0.00..1.14 rows=14 width=43)
Ce nœud peut également filtrer directement les données, la présence de la clause Filter montre le filtre appliqué par le nœud à la lecture des données :
seq_page_cost
(défaut : 1)cpu_tuple_cost
&
cpu_operator_cost
enable_seqscan
parallel_tuple_cost
,
min_parallel_table_scan_size
Seq Scan :
Le coût d’un Seq Scan sera fonction du nombre de blocs à
parcourir, et donc du paramètre seq_page_cost
, ainsi que du
nombre de lignes à décoder et, optionnellement, à filtrer. La valeur de
seq_page_cost
, par défaut à 1, est rarement modifiée ; elle
est surtout importante comparée à random_page_cost
.
Parallel Seq Scan :
Il est possible d’avoir un parcours parallélisé d’une table (Parallel Seq Scan). La parallélisation doit être activée comme décrit plus haut, notamment :
max_parallel_workers_per_gather
et
max_parallel_workers
doivent être tous deux supérieurs à 0,
ce qui est le cas par défaut ;min_parallel_table_scan_size
est à 8 Mo) ; et plus elle
sera grosse plus il y aura de workers ;Pour que ce type de parcours soit valable, il faut que l’optimiseur
soit persuadé que le problème sera le temps CPU et non la bande passante
disque. Il y a cependant un coût pour chaque ligne (paramètre
parallel_tuple_cost
). Autrement dit, dans la majorité des
cas, il faut un filtre sur une table importante pour que la
parallélisation se déclenche.
Dans les exemples suivants, la parallélisation est activée :
QUERY PLAN
-------------------------------------------------------------------------------
Gather (cost=1000.00..8263.14 rows=1 width=41)
Workers Planned: 2
-> Parallel Seq Scan on employes_big (cost=0.00..7263.04 rows=1 width=41)
Filter: (num_service <> 4)
Ici, deux processus supplémentaires seront exécutés pour réaliser la requête. Dans le cas de ce type de parcours, chaque processus prend un bloc et traite toutes les lignes de ce bloc. Quand un processus a terminé de traiter son bloc, il regarde quel est le prochain bloc à traiter et le traite. (À partir de la version 14, il prend même un groupe de blocs pour profiter de la fonctionnalité de read ahead du noyau.)
PostgreSQL dispose de trois moyens d’accéder aux données à travers les index. Le plus connu est l’Index Scan, qui possède plusieurs variantes.
Le nœud Index Scan consiste à parcourir les blocs d’index jusqu’à trouver les pointeurs vers les blocs contenant les données. À chaque pointeur trouvé, PostgreSQL lit le bloc de la table pointée pour retrouver l’enregistrement et s’assurer notamment de sa visibilité pour la transaction en cours. De ce fait, il y a beaucoup d’accès non séquentiels pour lire l’index et la table.
QUERY PLAN
----------------------------------------------------
Index Scan using employes_big_pkey on employes_big
(cost=0.42..8.44 rows=1 width=41)
Index Cond: (matricule = 132)
Ce type de nœud ne permet pas d’extraire directement les données à retourner depuis l’index, sans passer par la lecture des blocs correspondants de la table.
VACUUM
récent
Utilité :
Le nœud Index Only Scan est une version plus performante de l’Index Scan. Il est choisi si toutes les colonnes de la table concernées par la requête font partie de l’index :
QUERY PLAN
---------------------------------------------------------
Index Only Scan using employes_big_pkey on employes_big (actual time=0.019..8.195 rows=99014 loops=1)
Index Cond: (matricule < 100000)
Heap Fetches: 0
Buffers: shared hit=274
Planning:
Buffers: shared hit=1
Planning Time: 0.083 ms
Execution Time: 11.123 ms
Il n’y a donc plus besoin d’accéder à la table et l’on économise de nombreux accès disque. C’est d’autant plus appréciable que les lignes sont nombreuses.
Par contre, si l’on ajoute le champ nom
dans la requête,
l’optimiseur se rabat sur un Index Scan. En effet,
nom
ne peut être lu que dans la table puisqu’il ne fait pas
partie de l’index. Dans notre exemple avec de petites tables, le temps
reste correct mais il est moins bon :
EXPLAIN (COSTS OFF, ANALYZE,BUFFERS)
SELECT matricule, nom
FROM employes_big
WHERE matricule < 100000 ;
QUERY PLAN
---------------------------------------------------------
Index Scan using employes_big_pkey on employes_big (actual time=0.009..14.562 rows=99014 loops=1)
Index Cond: (matricule < 100000)
Buffers: shared hit=1199
Planning:
Buffers: shared hit=60
Planning Time: 0.181 ms
Execution Time: 18.170 ms
Noter le nombre de blocs lus beaucoup plus important.
Index couvrants :
Il existe des index dits « couvrants », destinés à favoriser des Index Only Scans. Ils peuvent contenir des données en plus des champs indexés, même avec un index unique comme ici.
Cet index contient aussi nom
et permet de revenir à de
très bonnes performances :
EXPLAIN (COSTS OFF, ANALYZE,BUFFERS)
SELECT matricule, nom
FROM employes_big
WHERE matricule < 100000 ;
QUERY PLAN
---------------------------------------------------------------------
Index Only Scan using employes_big_matricule_nom_idx on employes_big (actual time=0.010..5.769 rows=99014 loops=1)
Index Cond: (matricule < 100000)
Heap Fetches: 0
Buffers: shared hit=383
Planning:
Buffers: shared hit=1
Planning Time: 0.064 ms
Execution Time: 7.747 ms
L’expérience montre qu’un index classique multicolonne (sur
(matricule,nom)
) sera aussi efficace, voire plus, car il
peut être plus petit (la clause INCLUDE
inhibe la
déduplication). La clause INCLUDE
reste utile pour faire
d’une clé primaire, unique ou étrangère, un index couvrant et économiser
un peu de place disque.
Conditions pour obtenir un Index Only Scan :
Toutes les colonnes de la table utilisées par la requête doivent figurer dans l’index, aussi bien celles retournées que celles servant aux calculs ou au filtrage. En pratique, cela réserve l’Index Only Scan au cas où peu de colonnes de la table sont concernées par la requête.
Il est courant d’ajouter des index dédiés aux requêtes critiques n’utilisant que quelques colonnes. Mais plus le nombre de colonnes et la taille de l’index augmentent, moins PostgreSQL sera tenté par l’Index Only Scan.
Pour que l’Index Only Scan soit réellement efficace, il
faut que la table soit fréquemment traitée par des opérations
VACUUM
. En effet, les informations de visibilité des lignes
ne sont pas stockées dans l’index. Pour savoir si la ligne trouvée dans
l’index est visible ou pas par la session, il faut soit aller vérifier
dans la table (et on en revient à un Index Scan), soit aller
voir dans la visibility map de la table que le bloc ne contient
pas de lignes potentiellement mortes, ce qui est beaucoup plus rapide.
Le choix se fait à l’éxécution pour chaque ligne. Dans l’idéal, le plan
indique qu’il n’y a jamais eu à aller dans la table (heap)
ainsi :
S’il y a un trop grand nombre de Heap Fetches, l’Index
Only Scan perd tout son intérêt. Il peut suffire de rendre
l’autovacuum
beaucoup plus agressif sur la table.
Limite pour les index fonctionnels :
Le planificateur a du mal à définir un Index Only Scan pour un index fonctionnel. Il échoue par exemple ici :
CREATE INDEX employes_big_upper_idx ON employes_big (upper (nom)) ;
-- Créer les statistiques pour l'index fonctionnel
ANALYZE employes_big ;
EXPLAIN (COSTS OFF)
SELECT upper(nom) FROM employes_big WHERE upper(nom) = 'CRUCHOT' ;
QUERY PLAN
---------------------------------------------------------------------
Index Scan using employes_big_upper_idx on employes_big
Index Cond: (upper((nom)::text) = 'CRUCHOT'::text)
L’index fonctionnel est bien utilisé mais ce n’est pas un Index Only Scan.
Dans certains cas critiques, il peut être intéressant de créer une colonne générée reprenant la fonction et de créer un index dessus, qui permettra un Index Only Scan :
-- Attention : réécriture de la table
ALTER TABLE employes_big
ADD COLUMN nom_maj text GENERATED ALWAYS AS ( upper(nom) ) STORED ;
CREATE INDEX ON employes_big (nom_maj) ;
-- Créer les statistiques pour la nouvelle colonne
-- et éviter les Heap Fetches
VACUUM ANALYZE employes_big ;
EXPLAIN (COSTS OFF) SELECT nom_maj FROM employes_big WHERE nom_maj = 'CRUCHOT' ;
QUERY PLAN
---------------------------------------------------------------------
Index Only Scan using employes_big_nom_maj_idx on employes_big
Index Cond: (nom_maj = 'CRUCHOT'::text)
La nouvelle colonne générée est bien sûr maintenue automatiquement si
nom
change et n’est pas modifiable directement. Mais sa
création entraîne la réécriture de la table, qui d’ailleurs grossit un
peu, et il faut adapter le code.
Ce dernier parcours est particulièrement efficace pour des opérations de type Range Scan, c’est-à-dire où PostgreSQL doit retourner une plage de valeurs, ou pour combiner le résultat de la lecture de plusieurs index.
Contrairement à d’autres SGBD, un index bitmap de PostgreSQL n’a aucune existence sur disque : il est créé en mémoire lorsque son utilisation a un intérêt. Le but est de diminuer les déplacements de la tête de lecture en découplant le parcours de l’index du parcours de la table :
SET enable_indexscan TO off ;
EXPLAIN
SELECT * FROM employes_big WHERE matricule BETWEEN 200000 AND 300000;
QUERY PLAN
-----------------------------------------------------------------------
Bitmap Heap Scan on employes_big
(cost=2108.46..8259.35 rows=99126 width=41)
Recheck Cond: ((matricule >= 200000) AND (matricule <= 300000))
-> Bitmap Index Scan on employes_big_pkey*
(cost=0.00..2083.68 rows=99126 width=0)
Index Cond: ((matricule >= 200000) AND (matricule <= 300000))
Exemple de combinaison du résultat de la lecture de plusieurs index :
EXPLAIN
SELECT * FROM employes_big
WHERE matricule BETWEEN 1000 AND 100000
OR matricule BETWEEN 200000 AND 300000;
QUERY PLAN
-----------------------------------------------------------------------------
Bitmap Heap Scan on employes_big
(cost=4265.09..12902.67 rows=178904 width=41)
Recheck Cond: (((matricule >= 1000) AND (matricule <= 100000))
OR ((matricule >= 200000) AND (matricule <= 300000)))
-> BitmapOr (cost=4265.09..4265.09 rows=198679 width=0)
-> Bitmap Index Scan on employes_big_pkey
(cost=0.00..2091.95 rows=99553 width=0)
Index Cond: ((matricule >= 1000) AND (matricule <= 100000))
-> Bitmap Index Scan on employes_big_pkey
(cost=0.00..2083.68 rows=99126 width=0)
Index Cond: ((matricule >= 200000) AND (matricule <= 300000))
random_page_cost
(4 ou moins ?)cpu_index_tuple_cost
effective_cache_size
(⅔ de la RAM ?)effective_io_concurrency
maintenance_io_concurrency
min_parallel_index_scan_size
enable_indexscan
, enable_indexonlyscan
,
enable_bitmapscan
Index Scan :
L’Index Scan n’a d’intérêt que s’il y a très peu de lignes à récupérer, surtout si les disques sont mécaniques. Il faut donc que le filtre soit très sélectif.
Le rapport entre les paramètres seq_page_cost
et
random_page_cost
est d’importance majeure dans le choix
face à un Seq Scan. Plus il est proche de 1, plus les parcours
d’index seront favorisés par rapport aux parcours de table.
Index Only Scan :
Il n’y a pas de paramètre dédié à ce parcours. S’il est possible, l’optimiseur le préfère à un Index Scan.
Bitmap Index Scan :
effective_io_concurrency
a pour but d’indiquer le nombre
d’opérations disques possibles en même temps pour un client
(prefetch). Il n’a d’intérêt que si un nœud Bitmap
Scan a été choisi. Cela n’arrive qu’avec un certain nombre de
lignes à récupérer, et est favorisé par une valeur importante de
effective_cache_size
et un peu de corrélation physique dans
la table.
La valeur d’effective_io_concurrency
n’influe pas sur le
choix du plan, mais sa valeur peut notablement accélérer l’exécution du
Bitmap Heap Scan. Le temps de lecture peut fréquemment être
divisé par 3 ou plus.
Les valeurs possibles d’effective_io_concurrency
vont de
0 à 1000. En principe, sur un disque magnétique seul, la valeur 1 ou 0
peut convenir. Avec du SSD, et encore plus du NVMe, il est possible de
monter à plusieurs centaines, étant donné la rapidité de ce type de
disque. Trouver la bonne valeur dépend de divers paramètres liés aux
caractérisques exactes des disques et de leur paramétrage noyau. Le
read ahead du noyau intervient également. Le comportement de
PostgreSQL sur ce point change aussi avec les versions. De plus, à
partir d’un certain nombre de blocs, les I/O peuvent simplement saturer.
La valeur par défaut de 1 (jusque PostgreSQL 17) a été choisie de manière très conservatrice. Les développeurs ont décidé que la valeur 16 est plus intéressante, même sur de vieux disques, et que ce sera le défaut à partir de PostgreSQL 18, qui inclut d’autres améliorations.
À l’inverse, la mauvaise latence de certains systèmes aux disques en
principe performants (baies surchargées, certains stockages cloud…) peut
parfois être compensée par un effective_io_concurrency
plus
élevé.
Il faut tester avec vos requêtes qui utilisent des Bitmap Heap
Scan, et tenir compte du nombre de requêtes simultanées, mais
effective_io_concurrency = 16
semble un bon point de
départ, même sur une configuration modeste. Montez si vous avez de bons
disques.
Une valeur excessive de effective_io_concurrency
mène à
un gaspillage de CPU : ne pas monter trop haut sans preuve de l’utilité,
surtout sur un système très chargé.
Pour les systèmes RAID matériels, selon la documentation, configurer ce paramètre en fonction du nombre n de disques utiles dans le RAID (n/2 s’il s’agit d’un RAID 1, n-1 s’il s’agit d’un RAID 5 ou 6, n/2 s’il s’agit d’un RAID 10).
(Avant la version 13, le principe
d’effective_io_concurrency
était le même, mais la valeur
exacte de ce paramètre devait être 2 à 5 fois plus basse comme le
précise la formule des notes de version de la
version 13.)
Le paramètre maintenance_io_concurrency
a le même but
que effective_io_concurrency
, mais pour les opérations de
maintenance, non les requêtes. Celles-ci peuvent ainsi se voir accorder
plus de ressources qu’une simple requête. Sa valeur par défaut est de
10, et il faut penser à le monter aussi si on adapte
effective_io_concurrency
.
Enfin, le paramètre effective_cache_size
indique à
PostgreSQL une estimation de la taille du cache disque du système (total
du shared buffers et du cache du système). Une bonne pratique
est de positionner ce paramètre à ⅔ de la quantité totale de RAM du
serveur. Sur un système Linux, il est possible de donner une estimation
plus précise en s’appuyant sur la valeur de colonne cached
de la commande free
. Mais si le cache n’est que peu
utilisé, la valeur trouvée peut être trop basse pour pleinement
favoriser l’utilisation des Bitmap Index Scan.
Parallélisation
Il est possible de paralléliser les parcours d’index. Cela donne donc
les nœuds Parallel Index Scan, Parallel Index Only
Scan et Parallel Bitmap Heap Scan. Cette infrastructure
est actuellement uniquement utilisée pour les index B-Tree. Par contre,
pour le Bitmap Scan, seul le parcours de la table est
parallélisé. Un parcours parallélisé d’un index n’est considéré qu’à
partir du moment où l’index a une taille supérieure à la valeur du
paramètre min_parallel_index_scan_size
(512 ko par
défaut).
On retrouve le nœud Function Scan lorsqu’une requête utilise directement le résultat d’une fonction, comme par exemple, dans des fonctions d’informations système de PostgreSQL :
QUERY PLAN
-----------------------------------------------------------------------
Function Scan on pg_get_keywords (cost=0.03..4.03 rows=400 width=65)
Il existe d’autres types de parcours, rarement rencontrés. Ils sont néanmoins détaillés en annexe.
EXISTS
, IN
et certaines jointures
externes
work_mem
( et hash_mem_multiplier
)seq_page_cost
& random_page_cost
.enable_nestloop
, enable_hashjoin
,
enable_mergejoin
Le choix du type de jointure dépend non seulement des données mises
en œuvre, mais elle dépend également beaucoup du paramétrage de
PostgreSQL, notamment des paramètres work_mem
,
hash_mem_multiplier
, seq_page_cost
et
random_page_cost
.
Nested Loop :
La Nested Loop se retrouve principalement dans les jointures de petits ensembles de données. Pour chaque ligne de l’ensemble « externe » (à gauche, le plus grand des deux ensembles normalement), l’autre ensemble (« interne ») est lu, par un Seq Scan, Index Scan, etc.
Dans l’exemple suivant, le critère sur services
ramène
très peu de lignes, il ne coûte pas grand-chose d’aller piocher à chaque
fois dans l’index de employes_big
.
EXPLAIN SELECT matricule, nom, prenom, nom_service, fonction, localisation
FROM employes_big emp
JOIN services ser ON (emp.num_service = ser.num_service)
WHERE ser.localisation = 'Nantes';
QUERY PLAN
--------------------------------------------------------------------
Nested Loop (cost=0.42..10053.94 rows=124754 width=46)
-> Seq Scan on services ser (cost=0.00..1.05 rows=1 width=21)
Filter: ((localisation)::text = 'Nantes'::text)
-> Index Scan using employes_big_num_service_idx on employes_big emp
(cost=0.42..7557.81 rows=249508 width=33)
Index Cond: (num_service = ser.num_service)
Hash Join :
Le Hash Join se retrouve lorsque l’ensemble de la table interne est petit. L’optimiseur construit alors une table de hachage avec les valeurs de la ou les colonne(s) de jointure de la table interne. Il réalise ensuite un parcours de la table externe, et, pour chaque ligne de celle-ci, recherche des lignes correspondantes dans la table de hachage, toujours en utilisant la ou les colonne(s) de jointure comme clé. C’est très efficace mais le coût de démarrage est lent.
EXPLAIN SELECT matricule, nom, prenom, nom_service, fonction, localisation
FROM employes_big emp
JOIN services ser ON (emp.num_service = ser.num_service);
QUERY PLAN
-------------------------------------------------------------------------------
Hash Join (cost=0.19..8154.54 rows=499015 width=45)
Hash Cond: (emp.num_service = ser.num_service)
-> Seq Scan on employes_big emp (cost=0.00..5456.55 rows=499015 width=32)
-> Hash (cost=0.14..0.14 rows=4 width=21)
-> Seq Scan on services ser (cost=0.00..0.14 rows=4 width=21)
Cette opération réclame de la mémoire de tri, visible avec
EXPLAIN (ANALYZE)
(dans le pire des cas, ce sera un fichier
temporaire).
Merge Join :
La jointure par tri-fusion, ou Merge Join, prend deux ensembles de données triés en entrée, les partcourt parallèlement, et restitue l’ensemble de données après jointure. Cette jointure est assez lourde à initialiser si PostgreSQL ne peut pas utiliser d’index, sinon elle est très efficace. Elle a l’avantage de retourner les données triées directement :
EXPLAIN
SELECT matricule, nom, prenom, nom_service, fonction, localisation
FROM employes_big emp
JOIN services_big ser ON (emp.num_service = ser.num_service)
ORDER BY ser.num_service;
QUERY PLAN
-------------------------------------------------------------------------
Merge Join (cost=0.82..20094.77 rows=499015 width=49)
Merge Cond: (emp.num_service = ser.num_service)
-> Index Scan using employes_big_num_service_idx on employes_big emp
(cost=0.42..13856.65 rows=499015 width=33)
-> Index Scan using services_big_pkey on services_big ser
(cost=0.29..1337.35 rows=40004 width=20)
Il s’agit d’un algorithme de jointure particulièrement efficace pour traiter les volumes de données importants, surtout si les données sont pré-triées grâce à l’existence d’un index.
Hash Anti/Semi Join :
Les clauses EXISTS
et NOT EXISTS
mettent
également en œuvre des algorithmes dérivés de semi et anti-jointures. En
voici un exemple avec la clause EXISTS
:
EXPLAIN
SELECT *
FROM services s
WHERE EXISTS (SELECT 1
FROM employes_big e
WHERE e.date_embauche>s.date_creation
AND s.num_service = e.num_service) ;
QUERY PLAN
-----------------------------------------------------------------
Hash Semi Join (cost=17841.84..19794.91 rows=1 width=25)
Hash Cond: (s.num_service = e.num_service)
Join Filter: (e.date_embauche > s.date_creation)
-> Seq Scan on services s (cost=0.00..1.04 rows=4 width=25)
-> Hash (cost=9654.15..9654.15 rows=499015 width=8)
-> Seq Scan on employes_big e
(cost=0.00..9654.15 rows=499015 width=8)
Un plan sensiblement identique s’obtient avec
NOT EXISTS
. Le nœud Hash Semi Join est remplacé
par Hash Anti Join :
EXPLAIN
SELECT *
FROM services s
WHERE NOT EXISTS (SELECT 1
FROM employes_big e
WHERE e.date_embauche>s.date_creation
AND s.num_service = e.num_service);
QUERY PLAN
-----------------------------------------------------------------
Hash Anti Join (cost=17841.84..19794.93 rows=3 width=25)
Hash Cond: (s.num_service = e.num_service)
Join Filter: (e.date_embauche > s.date_creation)
-> Seq Scan on services s (cost=0.00..1.04 rows=4 width=25)
-> Hash (cost=9654.15..9654.15 rows=499015 width=8)
-> Seq Scan on employes_big e
(cost=0.00..9654.15 rows=499015 width=8)
Hash Join parallélisé :
Ces nœuds sont parallélisables. Pour les Parallel Hash Join, la table hachée est même commune pour les différents workers , et le calcul de celle-ci est réparti sur ceux-ci. Par contraste, pour les nœuds Merge Join, Nested Loop et Hash Join (non complètement parallélisé), seul le parcours de la table externe peut être parallélisé, tandis que la table interne est parcourue (voire hachée) entièrement par chaque worker.
Exemple (testé en version 16.1) :
CREATE TABLE foo(id serial, a int);
CREATE TABLE bar(id serial, foo_a int, b int);
INSERT INTO foo(a) SELECT i*2 FROM generate_series(1,1000000) i;
INSERT INTO bar(foo_a, b) SELECT i*2, i%7 FROM generate_series(1,100) i;
VACUUM ANALYZE foo, bar;
EXPLAIN (ANALYZE, VERBOSE, COSTS OFF)
SELECT foo.a, bar.b FROM foo JOIN bar ON (foo.a = bar.foo_a) WHERE a % 3 = 0;
QUERY PLAN
-----------------------------------------------------------------------------------
Gather (actual time=0.192..21.305 rows=33 loops=1)
Output: foo.a, bar.b
Workers Planned: 2
Workers Launched: 2
-> Hash Join (actual time=10.757..16.903 rows=11 loops=3)
Output: foo.a, bar.b
Hash Cond: (foo.a = bar.foo_a)
Worker 0: actual time=16.118..16.120 rows=0 loops=1
Worker 1: actual time=16.132..16.134 rows=0 loops=1
-> Parallel Seq Scan on public.foo (actual time=0.009..12.961 rows=111111 loops=3)
Output: foo.id, foo.a
Filter: ((foo.a % 3) = 0)
Rows Removed by Filter: 222222
Worker 0: actual time=0.011..12.373 rows=102953 loops=1
Worker 1: actual time=0.011..12.440 rows=102152 loops=1
-> Hash (actual time=0.022..0.023 rows=100 loops=3)
Output: bar.b, bar.foo_a
Buckets: 1024 Batches: 1 Memory Usage: 12kB
Worker 0: actual time=0.027..0.027 rows=100 loops=1
Worker 1: actual time=0.026..0.026 rows=100 loops=1
-> Seq Scan on public.bar (actual time=0.008..0.013 rows=100 loops=3)
Output: bar.b, bar.foo_a
Worker 0: actual time=0.012..0.017 rows=100 loops=1
Worker 1: actual time=0.011..0.016 rows=100 loops=1
Planning Time: 0.116 ms
Execution Time: 21.321 ms
Dans ce plan, la table externe foo
est lue de manière
parallélisée, les trois processus se partageant son contenu. Chacun a sa
version de la toute petite table interne bar
, qui est
hachée trois fois (les deux workers et le processus principal
lisent les 100 lignes).
Si bar
est beaucoup plus grosse que foo
, le
plan bascule sur un Parallel Hash Join dont bar
est cette fois la table externe :
INSERT INTO bar(foo_a, b) SELECT i*2, i%7 FROM generate_series(1,300000) i;
VACUUM ANALYZE bar;
EXPLAIN (ANALYZE, VERBOSE, COSTS OFF)
SELECT foo.a, bar.b FROM foo JOIN bar ON (foo.a = bar.foo_a) WHERE a % 3 = 0;
QUERY PLAN
-----------------------------------------------------------------------------------
Gather (actual time=69.490..95.741 rows=100033 loops=1)
Output: foo.a, bar.b
Workers Planned: 1
Workers Launched: 1
-> Parallel Hash Join (actual time=66.408..84.866 rows=50016 loops=2)
Output: foo.a, bar.b
Hash Cond: (bar.foo_a = foo.a)
Worker 0: actual time=63.450..83.008 rows=52081 loops=1
-> Parallel Seq Scan on public.bar (actual time=0.002..5.332 rows=150050 loops=2)
Output: bar.id, bar.foo_a, bar.b
Worker 0: actual time=0.002..5.448 rows=148400 loops=1
-> Parallel Hash (actual time=49.467..49.468 rows=166666 loops=2)
Output: foo.a
Buckets: 262144 (originally 8192) Batches: 4 (originally 1) Memory Usage: 5344kB
Worker 0: actual time=48.381..48.382 rows=158431 loops=1
-> Parallel Seq Scan on public.foo (actual time=0.007..21.265 rows=166666 loops=2)
Output: foo.a
Filter: ((foo.a % 3) = 0)
Rows Removed by Filter: 333334
Worker 0: actual time=0.009..20.909 rows=158431 loops=1
Planning Time: 0.083 ms
Execution Time: 97.567 ms
Le Hash de foo
se fait par deux processus qui
ne traitent cette fois que la moitié du million de lignes, en en
filtrant les ⅔ (la dernière ligne indique quasiment le tiers de
500 000). Le coût de démarrage de ce hash parallélisé est
cependant assez lourd (la jointure ne commence qu’après 66 ms). Pour la
même requête, PostgreSQL 10, qui ne connaît pas le Parallel Hash
Join, procédait à un Hash Join classique et prenait 50 %
plus longtemps. Précisons encore qu’augmenter work_mem
ne
change pas le plan, mais permet cas d’accélérer un peu les hachages
(réduction du nombre de batches).
enable_hashagg
work_mem
& hash_mem_multiplier
Pour réaliser un tri, PostgreSQL dispose de deux nœuds :
Sort et Incremental Sort. Leur efficacité va dépendre
du paramètre work_mem
qui va définir la quantité de mémoire
que PostgreSQL pourra utiliser pour un tri.
Sort :
QUERY PLAN
----------------------------------------------------------------
Sort (cost=1.41..1.44 rows=14 width=43)
(actual time=0.013..0.014 rows=14 loops=1)
Sort Key: fonction
Sort Method: quicksort Memory: 26kB
-> Seq Scan on employes (cost=0.00..1.14 rows=14 width=43)
(actual time=0.003..0.004 rows=14 loops=1)
Planning time: 0.021 ms
Execution time: 0.021 ms
Si le tri ne tient pas en mémoire, l’algorithme de tri gère automatiquement le débordement sur disque (26 Mo ici) :
QUERY PLAN
---------------------------------------------------------------------------
Sort (cost=70529.24..71776.77 rows=499015 width=40)
(actual time=252.827..298.948 rows=499015 loops=1)
Sort Key: fonction
Sort Method: external sort Disk: 26368kB
-> Seq Scan on employes_big (cost=0.00..9654.15 rows=499015 width=40)
(actual time=0.003..29.012 rows=499015 loops=1)
Planning time: 0.021 ms
Execution time: 319.283 ms
Cependant, un tri est coûteux. Donc si un index existe sur le champ de tri, PostgreSQL aura tendance à l’utiliser. Les données sont déjà triées dans l’index, il suffit de le parcourir pour lire les lignes dans l’ordre :
QUERY PLAN
----------------------------------------------
Index Scan using employes_pkey on employes
(cost=0.42..17636.65 rows=499015 width=41)
Et ce, dans n’importe quel ordre de tri :
QUERY PLAN
-----------------------------------------------------
Index Scan Backward using employes_pkey on employes
(cost=0.42..17636.65 rows=499015 width=41)
Le choix du type d’opération de regroupement dépend non seulement des
données mises en œuvres, mais elle dépend également beaucoup du
paramétrage de PostgreSQL, notamment du paramètre
work_mem
.
Comme vu précédemment, PostgreSQL sait utiliser un index pour trier les données. Cependant, dans certains cas, il ne sait pas utiliser l’index alors qu’il pourrait le faire. Prenons un exemple.
Voici un jeu de données contenant une table à trois colonnes, et un index sur une colonne :
DROP TABLE IF exists t1;
CREATE TABLE t1 (c1 integer, c2 integer, c3 integer);
INSERT INTO t1 SELECT i, i+1, i+2 FROM generate_series(1, 10000000) AS i;
CREATE INDEX t1_c2_idx ON t1(c2);
VACUUM ANALYZE t1;
PostgreSQL sait utiliser l’index pour trier les données. Par exemple,
voici le plan d’exécution pour un tri sur la colonne c2
(colonne indexée au niveau de l’index t1_c2_idx
) :
QUERY PLAN
---------------------------------------------------------------------------------
Index Scan using t1_c2_idx on t1 (cost=0.43..313749.06 rows=10000175 width=12)
(actual time=0.016..1271.115 rows=10000000 loops=1)
Buffers: shared hit=81380
Planning Time: 0.173 ms
Execution Time: 1611.868 ms
Par contre, si on essaie de trier par rapport aux colonnes
c2
et c3
, les versions 12 et antérieures ne
savent pas utiliser l’index, comme le montre ce plan d’exécution :
QUERY PLAN
-------------------------------------------------------------------------------
Gather Merge (cost=697287.64..1669594.86 rows=8333480 width=12)
(actual time=1331.307..3262.511 rows=10000000 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=54149, temp read=55068 written=55246
-> Sort (cost=696287.62..706704.47 rows=4166740 width=12)
(actual time=1326.112..1766.809 rows=3333333 loops=3)
Sort Key: c2, c3
Sort Method: external merge Disk: 61888kB
Worker 0: Sort Method: external merge Disk: 61392kB
Worker 1: Sort Method: external merge Disk: 92168kB
Buffers: shared hit=54149, temp read=55068 written=55246
-> Parallel Seq Scan on t1 (cost=0.00..95722.40 rows=4166740 width=12)
(actual time=0.015..337.901 rows=3333333 loops=3)
Buffers: shared hit=54055
Planning Time: 0.068 ms
Execution Time: 3716.541 ms
Comme PostgreSQL ne sait pas utiliser un index pour réaliser ce tri, il passe par un parcours de table (parallélisé dans le cas présent), puis effectue le tri, ce qui prend beaucoup de temps, encore plus s’il faut déborder sur disque. La durée d’exécution a plus que doublé.
Incremental Sort :
La version 13 est beaucoup plus maline à cet égard. Elle est capable
d’utiliser l’index pour faire un premier tri des données (sur la colonne
c2
d’après notre exemple), puis elle trie les données du
résultat par rapport à la colonne c3
:
QUERY PLAN
-------------------------------------------------------------------------------
Incremental Sort (cost=0.48..763746.44 rows=10000000 width=12)
(actual time=0.082..2427.099 rows=10000000 loops=1)
Sort Key: c2, c3
Presorted Key: c2
Full-sort Groups: 312500 Sort Method: quicksort Average Memory: 26kB Peak Memory: 26kB
Buffers: shared hit=81387
-> Index Scan using t1_c2_idx on t1 (cost=0.43..313746.43 rows=10000000 width=12)
(actual time=0.007..1263.517 rows=10000000 loops=1)
Buffers: shared hit=81380
Planning Time: 0.059 ms
Execution Time: 2766.530 ms
La requête en version 12 prenait 3,7 secondes. La version 13 n’en
prend que 2,7 secondes. On remarque un nouveau type de nœud, le
Incremental Sort, qui s’occupe de re-trier les données après un
renvoi de données triées, grâce au parcours d’index. Le plan distingue
bien la clé déjà triée (c2
) et celles à trier
(c2, c3
).
L’apport en performance est déjà très intéressant, mais il devient
remarquable si on utilise une clause LIMIT
. Voici le
résultat en version 12 :
QUERY PLAN
-------------------------------------------------------------------------------
Limit (cost=186764.17..186765.34 rows=10 width=12)
(actual time=718.576..724.791 rows=10 loops=1)
Buffers: shared hit=54149
-> Gather Merge (cost=186764.17..1159071.39 rows=8333480 width=12)
(actual time=718.575..724.788 rows=10 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=54149
-> Sort (cost=185764.15..196181.00 rows=4166740 width=12)
(actual time=716.606..716.608 rows=10 loops=3)
Sort Key: c2, c3
Sort Method: top-N heapsort Memory: 25kB
Worker 0: Sort Method: top-N heapsort Memory: 25kB
Worker 1: Sort Method: top-N heapsort Memory: 25kB
Buffers: shared hit=54149
-> Parallel Seq Scan on t1 (cost=0.00..95722.40 rows=4166740 width=12)
(actual time=0.010..347.085 rows=3333333 loops=3)
Buffers: shared hit=54055
Planning Time: 0.044 ms
Execution Time: 724.818 ms
Et celui en version 13 :
QUERY PLAN
-------------------------------------------------------------------------------
Limit (cost=0.48..1.24 rows=10 width=12) (actual time=0.027..0.029 rows=10 loops=1)
Buffers: shared hit=4
-> Incremental Sort (cost=0.48..763746.44 rows=10000000 width=12)
(actual time=0.027..0.027 rows=10 loops=1)
Sort Key: c2, c3
Presorted Key: c2
Full-sort Groups: 1 Sort Method: quicksort Average Memory: 25kB Peak Memory: 25kB
Buffers: shared hit=4
-> Index Scan using t1_c2_idx on t1 (cost=0.43..313746.43 rows=10000000 width=12)
(actual time=0.012..0.014 rows=11 loops=1)
Buffers: shared hit=4
Planning Time: 0.052 ms
Execution Time: 0.038 ms
La requête passe donc de 724 ms à 0,029 ms.
En version 16, l’Incremental Sort peut aussi servir à
accélérer les DISTINCT
:
QUERY PLAN
---------------------------------------------------------------
Unique (actual time=39.208..2479.229 rows=10000000 loops=1)
Buffers: shared read=81380
-> Incremental Sort (actual time=39.206..1841.165 rows=10000000 loops=1)
Sort Key: c2, c1, c3
Presorted Key: c2
Full-sort Groups: 312500 Sort Method: quicksort Average Memory: 26kB Peak Memory: 26kB
Buffers: shared read=81380
-> Index Scan using t1_c2_idx on t1 (actual time=39.182..949.921 rows=10000000 loops=1)
Buffers: shared read=81380
Planning:
Buffers: shared read=3
Planning Time: 0.274 ms
Execution Time: 2679.447 ms
Cela devrait accélérer de nombreuses requêtes qui possèdent
abusivement une clause DISTINCT
ajoutée par un ETL, si le
premier champ du DISTINCT
est par chance indexé, comme
ici :
QUERY PLAN
---------------------------------------------------------------
Unique
-> Incremental Sort
Sort Key: date_commande, numero_commande, client_id, mode_expedition, commentaire
Presorted Key: date_commande
-> Index Scan using commandes_date_commande_idx on commandes
Aggregate :
Concernant les opérations d’agrégations, on retrouve un nœud de type Aggregate lorsque la requête réalise une opération d’agrégation simple, sans regroupement :
QUERY PLAN
---------------------------------------------------------------
Aggregate (cost=1.18..1.19 rows=1 width=8)
-> Seq Scan on employes (cost=0.00..1.14 rows=14 width=0)
Hash Aggregate :
Si l’optimiseur estime que l’opération d’agrégation tient en mémoire
(paramètre work_mem
), il va utiliser un nœud de type
HashAggregate :
QUERY PLAN
----------------------------------------------------------------
HashAggregate (cost=1.21..1.27 rows=6 width=20)
Group Key: fonction
-> Seq Scan on employes (cost=0.00..1.14 rows=14 width=12)
Avant la version 13, l’inconvénient de ce nœud était que sa
consommation mémoire n’était pas limitée par work_mem
, il
continuait malgré tout à allouer de la mémoire. Dans certains cas,
heureusement très rares, l’optimiseur pouvait se tromper suffisamment
pour qu’un nœud HashAggregate consomme plusieurs gigaoctets de
mémoire et sature ainsi la mémoire du serveur. La version 13 corrige
cela en utilisant le disque à partir du moment où la mémoire nécessaire
dépasse la multiplication de la valeur du paramètre
work_mem
et celle du paramètre
hash_mem_multiplier
(2 par défaut à partir de la version
15, 1 auparavant). La requête sera plus lente, mais la mémoire ne sera
pas saturée.
Group Aggregate :
Lorsque l’optimiseur estime que le volume de données à traiter ne
tient pas dans work_mem
ou quand il peut accéder aux
données pré-triées, il utilise plutôt l’algorithme
GroupAggregate :
QUERY PLAN
---------------------------------------------------------------
GroupAggregate (cost=0.42..20454.87 rows=499015 width=12)
Group Key: matricule
Planned Partitions: 16
-> Index Only Scan using employes_big_pkey on employes_big
(cost=0.42..12969.65 rows=499015 width=4)
Mixed Aggregate :
Le Mixed Aggregate est très efficace pour les clauses
GROUP BY GROUPING SETS
ou GROUP BY CUBE
grâce
à l’utilisation de hashs :
EXPLAIN (ANALYZE,BUFFERS)
SELECT manager, fonction, num_service, COUNT(*)
FROM employes_big
GROUP BY CUBE(manager,fonction,num_service) ;
QUERY PLAN
---------------------------------------------------------------
MixedAggregate (cost=0.00..34605.17 rows=27 width=27)
(actual time=581.562..581.573 rows=51 loops=1)
Hash Key: manager, fonction, num_service
Hash Key: manager, fonction
Hash Key: manager
Hash Key: fonction, num_service
Hash Key: fonction
Hash Key: num_service, manager
Hash Key: num_service
Group Key: ()
Batches: 1 Memory Usage: 96kB
Buffers: shared hit=4664
-> Seq Scan on employes_big (cost=0.00..9654.15 rows=499015 width=19)
(actual time=0.015..35.840 rows=499015 loops=1)
Buffers: shared hit=4664
Planning time: 0.223 ms
Execution time: 581.671 ms
(Comparer avec le plan et le temps obtenus auparavant, que l’on peut
retrouver avec SET enable_hashagg TO off;
).
Le calcul d’un agrégat peut être parallélisé. Dans ce cas, deux nœuds sont utilisés : un pour le calcul partiel de chaque processus (Partial Aggregate), et un pour le calcul final (Finalize Aggregate). Voici un exemple de plan :
EXPLAIN (ANALYZE,COSTS OFF)
SELECT date_embauche, count(*), min(date_embauche), max(date_embauche)
FROM employes_big
GROUP BY date_embauche;
QUERY PLAN
-------------------------------------------------------------------------
Finalize GroupAggregate (actual time=92.736..92.740 rows=7 loops=1)
Group Key: date_embauche
-> Sort (actual time=92.732..92.732 rows=9 loops=1)
Sort Key: date_embauche
Sort Method: quicksort Memory: 25kB
-> Gather (actual time=92.664..92.673 rows=9 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Partial HashAggregate
(actual time=89.531..89.532 rows=3 loops=3)
Group Key: date_embauche
-> Parallel Seq Scan on employes_big
(actual time=0.011..35.801 rows=166338 loops=3)
Planning time: 0.127 ms
Execution time: 95.601 ms
DISTINCT
)UNION ALL
), Except,
IntersectLimit :
On rencontre le nœud Limit
lorsqu’on limite le résultat
avec l’ordre LIMIT
:
QUERY PLAN
---------------------------------------------------------------------------
Limit (cost=0.00..0.02 rows=1 width=40)
-> Seq Scan on employes_big (cost=0.00..9654.15 rows=499015 width=40)
Le nœud Sort utilisera dans ce cas une méthode de tri appelée top-N heapsort qui permet d’optimiser le tri pour retourner les n premières lignes :
QUERY PLAN
-------------------------------------------------------------
Limit (cost=17942.61..17942.62 rows=5 width=40)
(actual time=80.359..80.360 rows=5 loops=1)
-> Sort (cost=17942.61..19190.15 rows=499015 width=40)
(actual time=80.358..80.359 rows=5 loops=1)
Sort Key: fonction
Sort Method: top-N heapsort Memory: 25kB
-> Seq Scan on employes_big
(cost=0.00..9654.15 rows=499015 width=40)
(actual time=0.005..27.506 rows=499015 loops=1)
Planning time: 0.035 ms
Execution time: 80.375 ms
Unique :
On retrouve le nœud Unique lorsque l’on utilise
DISTINCT
pour dédoublonner le résultat d’une requête :
QUERY PLAN
---------------------------------------------------------------
Unique (cost=0.42..14217.19 rows=499015 width=4)
-> Index Only Scan using employes_big_pkey on employes_big
(cost=0.42..12969.65 rows=499015 width=4)
On le verra plus loin, il est souvent plus efficace d’utiliser
GROUP BY
pour dédoublonner les résultats d’une requête.
Append, Except, Intersect :
Les nœuds Append, Except et Intersect se
rencontrent avec les opérateurs ensemblistes UNION
,
EXCEPT
et INTERSECT
. Par exemple, avec
UNION ALL
:
EXPLAIN
SELECT * FROM employes
WHERE num_service = 2
UNION ALL
SELECT * FROM employes
WHERE num_service = 4;
QUERY PLAN
--------------------------------------------------------------------------
Append (cost=0.00..2.43 rows=8 width=43)
-> Seq Scan on employes (cost=0.00..1.18 rows=3 width=43)
Filter: (num_service = 2)
-> Seq Scan on employes employes_1 (cost=0.00..1.18 rows=5 width=43)
Filter: (num_service = 4)
InitPlan :
Le nœud InitPlan apparaît lorsque PostgreSQL a besoin d’exécuter une première sous-requête pour ensuite exécuter le reste de la requête. Il est assez rare :
EXPLAIN
SELECT *,
(SELECT nom_service FROM services WHERE num_service=1)
FROM employes WHERE num_service = 1;
QUERY PLAN
----------------------------------------------------------------
Seq Scan on employes (cost=1.05..2.23 rows=2 width=101)
Filter: (num_service = 1)
InitPlan 1 (returns $0)
-> Seq Scan on services (cost=0.00..1.05 rows=1 width=10)
Filter: (num_service = 1)
SubPlan :
Le nœud SubPlan est utilisé lorsque PostgreSQL a besoin d’exécuter une sous-requête pour filtrer les données :
EXPLAIN
SELECT * FROM employes
WHERE num_service NOT IN (SELECT num_service FROM services
WHERE nom_service = 'Consultants');
QUERY PLAN
----------------------------------------------------------------
Seq Scan on employes (cost=1.05..2.23 rows=7 width=43)
Filter: (NOT (hashed SubPlan 1))
SubPlan 1
-> Seq Scan on services (cost=0.00..1.05 rows=1 width=4)
Filter: ((nom_service)::text = 'Consultants'::text)
Gather :
Le nœud Gather n’apparaît que s’il y a du parallélisme. Il est utilisé comme nœud de rassemblement des données.
Memoize :
Apparu avec PostgreSQL 14, le nœud Memoize est un cache de résultat qui permet d’optimiser les performances d’autres nœuds en mémorisant des données qui risquent d’être accédées plusieurs fois de suite. Pour le moment, ce nœud n’est utilisable que pour les données de l’ensemble interne d’un Nested Loop.
D’autres types de nœuds peuvent également être trouvés dans les plans d’exécution. L’annexe décrit tous ces nœuds en détail.
L’optimiseur de PostgreSQL est sans doute la partie la plus complexe de PostgreSQL. Il se trompe rarement, mais certains facteurs peuvent entraîner des temps d’exécution très lents, voire catastrophiques de certaines requêtes.
Les statistiques sur les données doivent être suffisamment récentes. Celles calculées par défaut portent sur un échantillon de chaque colonne séparément et c’est parfois insuffisant. Sur ce sujet, voir le module M6 - Statistiques pour le planificateur.
join_collapse_limit
(défaut : 8)join_collapse_limit
si nécessaire (12-15)
from_collapse_limit
Les paramètres join_collapse_limit
et
from_collapse_limit
sont trop peu connus, mais peuvent
améliorer radicalement les performances si vous joignez souvent plus de
huit tables.
Voici un exemple complet de ce problème. Disons que
join_collapse_limit
est configuré à 2 (le défaut est en
réalité 8).
Nous allons déjà créer deux tables et les peupler avec 1 million de lignes chacune :
CREATE TABLE t1 (id integer);
INSERT INTO t1 SELECT generate_series(1, 1000000);
CREATE TABLE t2 (id integer);
INSERT INTO t2 SELECT generate_series(1, 1000000);
ANALYZE;
Maintenant, nous allons demander le plan d’exécution pour une jointure entre les deux tables :
QUERY PLAN
--------------------------------------------------------------------------------
Hash Join (cost=30832.00..70728.00 rows=1000000 width=8)
(actual time=2355.012..6141.672 rows=1000000 loops=1)
Hash Cond: (t1.id = t2.id)
-> Seq Scan on t1 (cost=0.00..14425.00 rows=1000000 width=4)
(actual time=0.012..1137.629 rows=1000000 loops=1)
-> Hash (cost=14425.00..14425.00 rows=1000000 width=4)
(actual time=2354.750..2354.753 rows=1000000 loops=1)
Buckets: 131072 Batches: 16 Memory Usage: 3227kB
-> Seq Scan on t2 (cost=0.00..14425.00 rows=1000000 width=4)
(actual time=0.008..1144.492 rows=1000000 loops=1)
Planning Time: 0.095 ms
Execution Time: 7246.491 ms
PostgreSQL choisit de lire la table t2
, de remplir une
table de hachage avec le résultat de cette lecture, puis de parcourir la
table t1
, et enfin de tester la condition de jointure grâce
à la table de hachage.
Ajoutons maintenant une troisième table, sans données cette fois :
Et ajoutons une jointure à la requête précédente. Cela nous donne cette requête :
Son plan d’exécution, avec la configuration par défaut de PostgreSQL,
sauf le join_collapse_limit
à 2, est :
QUERY PLAN
-------------------------------------------------------------------------------
Gather (cost=77972.88..80334.59 rows=2550 width=12)
(actual time=2902.385..2913.956 rows=0 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Merge Join (cost=76972.88..79079.59 rows=1062 width=12)
(actual time=2894.440..2894.615 rows=0 loops=3)
Merge Cond: (t1.id = t3.id)
-> Sort (cost=76793.10..77834.76 rows=416667 width=8)
(actual time=2894.405..2894.572 rows=1 loops=3)
Sort Key: t1.id
Sort Method: external merge Disk: 5912kB
Worker 0: Sort Method: external merge Disk: 5960kB
Worker 1: Sort Method: external merge Disk: 5848kB
-> Parallel Hash Join (cost=15428.00..32202.28 rows=416667 width=8)
(actual time=1892.071..2400.515 rows=333333 loops=3)
Hash Cond: (t1.id = t2.id)
-> Parallel Seq Scan on t1 (cost=0.00..8591.67 rows=416667 width=4)
(actual time=0.007..465.746 rows=333333 loops=3)
-> Parallel Hash (cost=8591.67..8591.67 rows=416667 width=4)
(actual time=950.509..950.514 rows=333333 loops=3)
Buckets: 131072 Batches: 16 Memory Usage: 3520kB
-> Parallel Seq Scan on t2 (cost=0.00..8591.67 rows=416667 width=4)
(actual time=0.017..471.653 rows=333333 loops=3)
-> Sort (cost=179.78..186.16 rows=2550 width=4)
(actual time=0.028..0.032 rows=0 loops=3)
Sort Key: t3.id
Sort Method: quicksort Memory: 25kB
Worker 0: Sort Method: quicksort Memory: 25kB
Worker 1: Sort Method: quicksort Memory: 25kB
-> Seq Scan on t3 (cost=0.00..35.50 rows=2550 width=4)
(actual time=0.019..0.020 rows=0 loops=3)
Planning Time: 0.120 ms
Execution Time: 2914.661 ms
En effet, dans ce cas, PostgreSQL va trier les jointures sur les 2
premières tables (soit t1
et t2
), et il
ajoutera ensuite les autres jointures dans l’ordre indiqué par la
requête. Donc, ici, il joint t1
et t2
, puis le
résultat avec t3
, ce qui nous donne une requête exécutée en
un peu moins de 3 secondes. C’est beaucoup quand on considère que la
table t3
est vide et que le résultat sera forcément vide
lui aussi (l’optimiseur a certes estimé trouver 2550 lignes dans
t3
, mais cela reste très faible par rapport aux autres
tables).
Maintenant, voici le plan d’exécution pour la même requête avec un
join_collapse_limit
à 3 :
QUERY PLAN
-------------------------------------------------------------------------------
Gather (cost=35861.44..46281.24 rows=2550 width=12)
(actual time=14.943..15.617 rows=0 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Hash Join (cost=34861.44..45026.24 rows=1062 width=12)
(actual time=0.119..0.134 rows=0 loops=3)
Hash Cond: (t2.id = t1.id)
-> Parallel Seq Scan on t2 (cost=0.00..8591.67 rows=416667 width=4)
(actual time=0.010..0.011 rows=1 loops=3)
-> Hash (cost=34829.56..34829.56 rows=2550 width=8)
(actual time=0.011..0.018 rows=0 loops=3)
Buckets: 4096 Batches: 1 Memory Usage: 32kB
-> Hash Join (cost=30832.00..34829.56 rows=2550 width=8)
(actual time=0.008..0.013 rows=0 loops=3)
Hash Cond: (t3.id = t1.id)
-> Seq Scan on t3 (cost=0.00..35.50 rows=2550 width=4)
(actual time=0.006..0.007 rows=0 loops=3)
-> Hash (cost=14425.00..14425.00 rows=1000000 width=4)
(never executed)
-> Seq Scan on t1 (cost=0.00..14425.00 rows=1000000 width=4)
(never executed)
Planning Time: 0.331 ms
Execution Time: 15.662 ms
Déjà, on voit que la planification a pris plus de temps. La durée
reste très basse (0,3 milliseconde) ceci dit. Cette fois, PostgreSQL
commence par joindre t3
à t1
. Comme
t3
ne contient aucune ligne, t1
n’est même pas
parcourue (texte never executed
) et le résultat de cette
première jointure renvoie 0 lignes. De ce fait, la création de la table
de hachage est très rapide. La table de hachage étant vide, le parcours
de t2
est abandonné après la première ligne lue. Cela nous
donne une requête exécutée en 15 millisecondes.
join_collapse_limit
est donc
essentielle pour de bonnes performances, notamment sur les requêtes
réalisant un grand nombre de jointures.
Il est courant de monter join_collapse_limit
à 12 si
l’on a des requêtes avec autant de tables (incluant les vues).
Il existe un paramètre très voisin, from_collapse_limit
,
qui définit à quelle profondeur « aplatir » les sous-requêtes. On le
monte à la même valeur que join_collapse_limit
.
Des valeurs plus élevées (jusque 20 ou plus) sont plus dangereuses,
car le temps de planification peut monter très haut (centaines de
millisecondes, voire pire). Mais elles se justifient dans certains
domaines. Il est alors préférable de positionner ces valeurs élevées
uniquement au niveau de la session, de l’écran, ou de l’utilisateur avec
SET
ou ALTER ROLE … SET …
.
À l’inverse, descendre join_collapse_limit
à 1 permet de
dicter
l’ordre d’exécution au planificateur, une mesure de dernier
recours.
Comme le temps de planification et la consommation de RAM et CPU
augmente très vite avec le nombre de tables, il vaut mieux ne pas monter
join_collapse_limit
beaucoup plus haut sans tester que ce
n’est pas contre-productif. Dans la session concernée, il reste possible
de définir :
À l’inverse, la valeur 1 permet de forcer les jointures dans l’ordre
de la clause FROM
, ce qui est à réserver aux cas
désespérés.
GEQO :
Au-delà de douze tables (valeur du paramètre
geqo_threshold
) intervient encore un autre mécanisme,
l’optimiseur génétique GEQO
(GEnetic Query Optimizer). Comme tout algorithme génétique, il
fonctionne par introduction de mutations aléatoires sur un état initial
donné. Il permet de planifier rapidement une requête complexe, pour
fournir un plan d’exécution pas forcément optimal, mais assez proche
pour être acceptable, dans un délai raisonnable. Cette technique très
complexe dispose d’un grand nombre de paramètres, que très peu savent
réellement configurer.
Malgré l’introduction de ces mutations aléatoires, le moteur arrive
tout de même à conserver un fonctionnement
déterministe. Tant que la « graine » du générateur aléatoire
(geqo_seed
) et les autres paramètres contrôlant GEQO
restent inchangés, le plan obtenu sera toujours le même, toutes choses
égales par ailleurs bien sûr. Pour explorer d’autres plans, faire varier
la valeur de geqo_seed
(voir la documentation
officielle).
Il est déconseillé de désactiver GEQO ou de modifier ses paramètres, sous peine d’explosion du temps et de la consommation mémoire lors de la planification.
extract
CREATE STATISTIC
(v14)Lorsque les valeurs des colonnes sont transformées par un calcul ou par une fonction, l’optimiseur n’a aucun moyen de connaître la sélectivité d’un prédicat. Il utilise donc une estimation codée en dur dans le code de l’optimiseur : 0,5 % du nombre de lignes de la table. Dans la requête suivante, l’optimiseur estime alors que la requête va ramener 2495 lignes :
QUERY PLAN
---------------------------------------------------------------
Gather (cost=1000.00..9552.15 rows=2495 width=40)
Workers Planned: 2
-> Parallel Seq Scan on employes_big
(cost=0.00..8302.65 rows=1040 width=40)
Filter: (date_part('year'::text,
(date_embauche)::timestamp without time zone)
= '2006'::double precision)
Ces 2495 lignes correspondent à 0,5 % de la table
employes_big
.
Nous avons vu qu’il est préférable de réécrire la requête de manière à pouvoir utiliser les statistiques existantes sur la colonne, mais ce n’est pas toujours aisé ou même possible.
Dans ce cas, on peut se rabattre sur l’ordre
CREATE STATISTICS
. Nous avons vu plus haut qu’il permet de
calculer des statistiques sur des résultats d’expressions (ne pas
oublier ANALYZE
).
CREATE STATISTICS employe_big_extract
ON extract('year' from date_embauche) FROM employes_big;
ANALYZE employes_big;
Les estimations du plan sont désormais correctes :
QUERY PLAN
----------------------------------------------------------------------
Seq Scan on employes_big (cost=0.00..12149.22 rows=498998 width=40)
Filter: (EXTRACT(year FROM date_embauche) = '2006'::numeric)
Avant PostgreSQL 14, il est nécessaire de créer un index fonctionnel sur l’expression pour que des statistiques soient calculées.
varchar_pattern_ops
/ text_pattern_ops
,
etc.LIKE '%mot%'
:
pg_trgm
,Il existe cependant une spécificité à PostgreSQL : dans le cas d’une
recherche avec préfixe, il peut utiliser directement un index sur la
colonne si l’encodage est « C ». Or le collationnement par défaut d’une
base est presque toujours en_US.UTF-8
ou
fr_FR.UTF-8
, selon les choix à l’installation de l’OS ou de
PostgreSQL :
\l
Liste des bases de données
Nom | Propriétaire | Encodage | Collationnement | Type caract. | …
-----------+--------------+----------+-----------------+--------------+---
pgbench | postgres | UTF8 | en_US.UTF-8 | en_US.UTF-8 |
postgres | postgres | UTF8 | en_US.UTF-8 | en_US.UTF-8 |
template0 | postgres | UTF8 | en_US.UTF-8 | en_US.UTF-8 | …
template1 | postgres | UTF8 | en_US.UTF-8 | en_US.UTF-8 | …
textes_10 | postgres | UTF8 | en_US.UTF-8 | en_US.UTF-8 |
Il faut alors utiliser une classe d’opérateur lors de la création de l’index. Cela donnera par exemple :
Ce n’est qu’à cette condition qu’un LIKE 'mot%'
pourra
utiliser l’index. Par contre, l’opérateur
varchar_pattern_ops
ne permet pas de trier
(ORDER BY
notamment), faute de collation, il faudra donc
peut-être indexer deux fois la colonne.
Un encodage C (purement anglophone) ne nécessite pas l’ajout d’une
classe d’opérateurs varchar_pattern_ops
.
Pour les recherches à l’intérieur d’un texte
(LIKE '%mot%'
), il existe deux autres options :
pg_trgm
est une extension permettant de faire des
recherches de type par trigramme et un index GIN ou GiST ;DELETE
lentDelete (actual time=111.251..111.251 rows=0 loops=1)
-> Hash Join (actual time=1.094..21.402 rows=9347 loops=1)
-> Seq Scan on lot_a30_descr_lot
(actual time=0.007..11.248 rows=34934 loops=1)
-> Hash (actual time=0.501..0.501 rows=561 loops=1)
-> Bitmap Heap Scan on lot_a10_pdl
(actual time=0.121..0.326 rows=561 loops=1)
Recheck Cond: (id_fantoir_commune = 320013)
-> Bitmap Index Scan on...
(actual time=0.101..0.101 rows=561 loops=1)
Index Cond: (id_fantoir_commune = 320013)
Trigger for constraint fk_lotlocal_lota30descrlot:
time=1010.358 calls=9347
Trigger for constraint fk_nonbatia21descrsuf_lota30descrlot:
time=2311695.025 calls=9347
Total runtime: 2312835.032 ms
Parfois, un DELETE
peut prendre beaucoup de temps à
s’exécuter. Cela peut être dû à un grand nombre de lignes à supprimer.
Cela peut aussi être dû à la vérification des contraintes
étrangères.
Dans l’exemple ci-dessus, le DELETE
met 38 minutes à
s’exécuter (2 312 835 ms), pour ne supprimer aucune ligne. En fait,
c’est la vérification de la contrainte
fk_nonbatia21descrsuf_lota30descrlot
qui prend pratiquement
tout le temps. C’est d’ailleurs pour cette raison qu’il est recommandé
de positionner des index sur les clés étrangères, car cet index permet
d’accélérer la recherche liée à la contrainte.
Attention donc aux contraintes de clés étrangères pour les instructions DML !
DISTINCT
est souvent utilisé pour dédoublonner les
lignes
DISTINCT ON
GROUP BY
Un DISTINCT
est une opération coûteuse à cause du tri
nécessaire. Il est fréquent de le voir ajouté abusivement, « par
prudence » ou pour compenser un bug de jointure. De plus il constitue
une « barrière à l’optimisation » s’il s’agit d’une partie de
requête.
Si le résultat contient telles quelles les clés primaires de toutes
les tables jointes, le DISTINCT
est mathématiquement
inutile ! PostgreSQL ne sait malheureusement pas repérer tout seul ce
genre de cas.
Quand le dédoublonnage est justifié, il faut savoir qu’il y a deux
alternatives principales au DISTINCT
. Leurs efficacités
relatives sont très dépendantes du paramétrage mémoire
(work_mem
) ou des volumétries, ou encore de la présence
d’index permettant d’éviter le tri.
Un GROUP BY
des colonnes retournées est fastidieux à
coder, mais donne parfois un plan efficace. Cette astuce est plus
fréquemment utile avant PostgreSQL 13.
Une autre possibilité est d’utiliser la syntaxe
DISTINCT ON (champs)
, qui renvoie la première ligne
rencontrée sur une clé fournie (documentation).
Exemples (sous PostgreSQL 15.2, configuration par défaut sur une petite configuration, cache chaud) :
Il s’agit ici d’afficher la liste des membres des différents services.
DISTINCT
: notez le tri sur disque.EXPLAIN (COSTS OFF, ANALYZE)
SELECT DISTINCT
matricule,
nom, prenom, fonction, manager, date_embauche,
num_service, nom_service, localisation, departement
FROM employes_big
JOIN services USING (num_service) ;
QUERY PLAN
--------------------------------------------------------------------------------
Unique (actual time=2930.441..4765.048 rows=499015 loops=1)
-> Sort (actual time=2930.435..3351.819 rows=499015 loops=1)
Sort Key: employes_big.matricule, employes_big.nom, employes_big.prenom, employes_big.fonction, employes_big.manager, employes_big.date_embauche, employes_big.num_service, services.nom_service, services.localisation, services.departement
Sort Method: external merge Disk: 38112kB
-> Hash Join (actual time=0.085..1263.867 rows=499015 loops=1)
Hash Cond: (employes_big.num_service = services.num_service)
-> Seq Scan on employes_big (actual time=0.030..273.710 rows=499015 loops=1)
-> Hash (actual time=0.032..0.035 rows=4 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 9kB
-> Seq Scan on services (actual time=0.014..0.020 rows=4 loops=1)
Planning Time: 0.973 ms
Execution Time: 4938.634 ms
GROUP BY
: il n’y a pas de gain en
temps dans ce cas précis, mais il n’y a plus de tri sur disque, car
l’index sur la clé primaire est utilisé. Noter que PostgreSQL est assez
malin pour repérer les clés primaire (ici matricule
et
num_service
). Il évite alors d’inclure dans les données à
regrouper ces clés, et tous les champs de la première table.EXPLAIN (COSTS OFF, ANALYZE)
SELECT
matricule,
nom, prenom, fonction, manager, date_embauche,
num_service, nom_service, localisation, departement
FROM employes_big
JOIN services USING (num_service)
GROUP BY
matricule,
nom, prenom, fonction, manager, date_embauche,
num_service, nom_service, localisation, departement ;
QUERY PLAN
--------------------------------------------------------------------------------
Group (actual time=0.409..5067.075 rows=499015 loops=1)
Group Key: employes_big.matricule, services.nom_service, services.localisation, services.departement
-> Incremental Sort (actual time=0.405..3925.924 rows=499015 loops=1)
Sort Key: employes_big.matricule, services.nom_service, services.localisation, services.departement
Presorted Key: employes_big.matricule
Full-sort Groups: 15595 Sort Method: quicksort Average Memory: 28kB Peak Memory: 28kB
-> Nested Loop (actual time=0.092..2762.395 rows=499015 loops=1)
-> Index Scan using employes_big_pkey on employes_big (actual time=0.050..861.828 rows=499015 loops=1)
-> Memoize (actual time=0.001..0.001 rows=1 loops=499015)
Cache Key: employes_big.num_service
Cache Mode: logical
Hits: 499011 Misses: 4 Evictions: 0 Overflows: 0 Memory Usage: 1kB
-> Index Scan using services_pkey on services (actual time=0.012..0.012 rows=1 loops=4)
Index Cond: (num_service = employes_big.num_service)
Planning Time: 0.900 ms
Execution Time: 5190.287 ms
work_mem
de 4 à 100 Mo, les deux versions
basculent sur ce plan, ici plus efficace, qui n’utilise plus l’index,
mais ne trie qu’en mémoire, avec la même astuce que ci-dessus. QUERY PLAN
--------------------------------------------------------------------------------
HashAggregate (actual time=3122.612..3849.449 rows=499015 loops=1)
Group Key: employes_big.matricule, services.nom_service, services.localisation, services.departement
Batches: 1 Memory Usage: 98321kB
-> Hash Join (actual time=0.136..1354.195 rows=499015 loops=1)
Hash Cond: (employes_big.num_service = services.num_service)
-> Seq Scan on employes_big (actual time=0.050..322.423 rows=499015 loops=1)
-> Hash (actual time=0.042..0.046 rows=4 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 9kB
-> Seq Scan on services (actual time=0.020..0.026 rows=4 loops=1)
Planning Time: 0.967 ms
Execution Time: 3970.353 ms
service
pour chacune d’employes_big
.RESET work_mem ;
EXPLAIN (COSTS OFF, ANALYZE)
SELECT DISTINCT ON (matricule)
matricule,
nom, prenom, fonction, manager, date_embauche,
num_service, nom_service, localisation, departement
FROM employes_big
JOIN services USING (num_service) ;
QUERY PLAN
--------------------------------------------------------------------------------
Unique (actual time=0.093..3812.414 rows=499015 loops=1)
-> Nested Loop (actual time=0.090..2741.919 rows=499015 loops=1)
-> Index Scan using employes_big_pkey on employes_big (actual time=0.049..847.356 rows=499015 loops=1)
-> Memoize (actual time=0.001..0.001 rows=1 loops=499015)
Cache Key: employes_big.num_service
Cache Mode: logical
Hits: 499011 Misses: 4 Evictions: 0 Overflows: 0 Memory Usage: 1kB
-> Index Scan using services_pkey on services (actual time=0.012..0.012 rows=1 loops=4)
Index Cond: (num_service = employes_big.num_service)
Planning Time: 0.711 ms
Execution Time: 3982.201 ms
DISTINCT
est inutile. La jointure peut se faire de manière
plus classique.EXPLAIN (COSTS OFF, ANALYZE)
SELECT
matricule,
nom, prenom, fonction, manager, date_embauche,
num_service, nom_service, localisation, departement
FROM employes_big
JOIN services USING (num_service) ;
QUERY PLAN
--------------------------------------------------------------------------------
Hash Join (actual time=0.083..1014.796 rows=499015 loops=1)
Hash Cond: (employes_big.num_service = services.num_service)
-> Seq Scan on employes_big (actual time=0.027..214.360 rows=499015 loops=1)
-> Hash (actual time=0.032..0.036 rows=4 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 9kB
-> Seq Scan on services (actual time=0.013..0.019 rows=4 loops=1)
Planning Time: 0.719 ms
Execution Time: 1117.126 ms
Si les DISTINCT
sont courants et critiques dans votre
application, notez que le nœud est parallélisable depuis
PostgreSQL 15.
random_page_cost
effective_cache_size
Il est relativement fréquent de créer soigneusement un index, et que PostgreSQL ne daigne pas l’utiliser. Il peut y avoir plusieurs raisons à cela.
Problème de statistiques :
Les statistiques de la table peuvent être périmées ou imprécises, pour les causes vues plus haut.
Un index fonctionnel possède ses propres statistiques : il faut donc
penser à lancer ANALYZE
après sa création. De même après un
CREATE STATISTICS
.
Nombre de lignes trouvées dans l’index :
Il faut se rappeler que PostgreSQL aura tendance à ne pas utiliser un index s’il doit chercher trop de lignes (ou plutôt de blocs), dans l’index comme dans la table ensuite. Il sera par contre tenté si cet index permet d’éviter des tris ou s’il est couvrant, et pas trop gros. La dispersion des lignes rencontrées dans la table est un facteur également pris en compte.
Colonnes d’un index B-tree :
Un index B-tree multicolonne est inutilisable, en tout cas beaucoup moins performant, si les premiers champs ne sont pas fournis. L’ordre des colonnes a son importance.
Taille d’un index :
PostgreSQL tient compte de la taille des index. Un petit index peut être préféré à un index multicolonne auquel on a ajouté trop de champs pour qu’il soit couvrant.
Problèmes de prédicats :
Dans d’autres cas, les prédicats d’une requête ne permettent pas à l’optimiseur de choisir un index pour répondre à une requête. C’est le cas lorsque le prédicat inclut une transformation de la valeur d’une colonne. L’exemple suivant est assez naïf, mais assez représentatif et démontre bien le problème :
Avec une telle construction, l’optimiseur ne saura pas tirer partie
d’un quelconque index, à moins d’avoir créé un index fonctionnel sur
date_embauche + interval '1 month'
, mais cet index est
largement contre-productif par rapport à une réécriture de la
requête.
Ce genre de problème se rencontre plus souvent avec des prédicats sur des dates :
ou encore plus fréquemment rencontré :
SELECT * FROM employes WHERE extract('year' from date_embauche) = 2006;
SELECT * FROM employes WHERE upper(prenom) = 'GASTON';
Opérateurs non-supportés :
Les index B-tree supportent la plupart des opérateurs généraux sur
les variables scalaires (entiers, chaînes, dates, mais pas les types
composés comme les géométries, hstore…), mais pas la différence
(<>
ou !=
). Par nature, il n’est pas
possible d’utiliser un index pour déterminer toutes les valeurs sauf
une. Mais ce type de construction est parfois utilisé pour exclure
les valeurs les plus fréquentes d’une colonne. Dans ce cas, il est
possible d’utiliser un index partiel qui, en plus, sera très petit car
il n’indexera qu’une faible quantité de données par rapport à la
totalité de la table :
QUERY PLAN
-------------------------------------------------------------------------------
Gather (cost=1000.00..8264.74 rows=17 width=41)
Workers Planned: 2
-> Parallel Seq Scan on employes_big (cost=0.00..7263.04 rows=7 width=41)
Filter: (num_service <> 4)
La création d’un index partiel permet d’en tirer partie :
CREATE INDEX ON employes_big(num_service) WHERE num_service<>4;
EXPLAIN SELECT * FROM employes_big WHERE num_service<>4;
QUERY PLAN
----------------------------------------------------------------
Index Scan using employes_big_num_service_idx1 on employes_big
(cost=0.14..12.35 rows=17 width=40)
Paramétrage de PostgreSQL
Plusieurs paramètres de PostgreSQL influencent l’optimiseur sur l’utilisation ou non d’un index :
random_page_cost
: indique à PostgreSQL la vitesse d’un
accès aléatoire par rapport à un accès séquentiel
(seq_page_cost
) ;effective_cache_size
: indique à PostgreSQL une
estimation de la taille du cache disque du système.Le paramètre random_page_cost
a une grande influence sur
l’utilisation des index en général. Il indique à PostgreSQL le coût d’un
accès disque aléatoire. Il est à comparer au paramètre
seq_page_cost
qui indique à PostgreSQL le coût d’un accès
disque séquentiel. Ces coûts d’accès sont purement arbitraires et n’ont
aucune réalité physique.
Dans sa configuration par défaut, PostgreSQL estime qu’un accès aléatoire est 4 fois plus coûteux qu’un accès séquentiel. Les accès aux index étant par nature aléatoires alors que les parcours de table sont par nature séquentiels, modifier ce paramètre revient à favoriser l’un par rapport à l’autre. Cette valeur est bonne dans la plupart des cas. Mais si le serveur de bases de données dispose d’un système disque rapide, c’est-à-dire une bonne carte RAID et plusieurs disques SAS rapides en RAID10, ou du SSD, il est possible de baisser ce paramètre à 3 voire 2 ou 1.
Pour aller plus loin, n’hésitez pas à consulter cet article de blog
NOT IN
avec une sous-requête
NOT EXISTS
UNION
entraîne un tri systématique
UNION ALL
SELECT
LATERAL
La façon dont une requête SQL est écrite peut aussi avoir un effet négatif sur les performances. Il n’est pas possible d’écrire tous les cas possibles, mais certaines formes d’écritures reviennent souvent.
La clause NOT IN
n’est pas performante lorsqu’elle est
utilisée avec une sous-requête. L’optimiseur ne parvient pas à exécuter
ce type de requête efficacement.
Il est nécessaire de la réécrire avec la clause
NOT EXISTS
, par exemple :
L’absence de la notion de hints, qui permettent au DBA de forcer l’optimiseur à choisir des plans d’exécution jugés pourtant trop coûteux, est voulue. Elle a même été intégrée dans la liste des fonctionnalités dont la communauté ne voulait pas (« Features We Do Not Want »).
L’absence des hints est très bien expliquée dans un billet de Josh Berkus, ancien membre de la Core Team de PostgreSQL :
Le fait que certains DBA demandent cette fonctionnalité ne veut pas dire qu’ils ont réellement besoin de cette fonctionnalité. Parfois ce sont de mauvaises habitudes d’une époque révolue, où les optimiseurs étaient parfaitement stupides. Ajoutons à cela que les SGBD courants étant des projets commerciaux, ils sont forcément plus poussés à accéder aux demandes des clients, même si ces demandes ne se justifient pas, ou sont le résultat de pressions de pur court terme. Le fait que PostgreSQL soit un projet libre permet justement aux développeurs du projet de choisir les fonctionnalités implémentées suivant leurs idées, et non pas la pression du marché.
Selon le wiki sur le sujet, l’avis de la communauté PostgreSQL est que les hints, du moins tels qu’ils sont implémentés ailleurs, mènent à une plus grande complexité du code applicatif, donc à des problèmes de maintenabilité, interfèrent avec les mises à jour, risquent d’être contre-productifs au fur et à mesure que vos tables grossissent, et sont généralement inutiles. Sur le long terme, il vaut mieux rapporter un problème rencontré avec l’optimiseur pour qu’il soit définitivement corrigé. L’absence de hints permet d’être plus facilement et rapidement mis au courant des problèmes de l’optimiseur. Sur le long terme, cela est meilleur pour le projet comme pour les utilisateurs. Cela a notamment mené à améliorer l’optimiseur et le recueil des statistiques.
L’accumulation de hints dans un système a tendance à poser problème lors de l’évolution des besoins, de la volumétrie ou après des mises à jour. Si le plan d’exécution généré n’est pas optimal, il est préférable de chercher à comprendre d’où vient l’erreur. Il est rare que l’optimiseur se trompe : en général c’est lui qui a raison. Mais il ne peut faire qu’avec les statistiques à sa disposition, le modèle qu’il voit, les index que vous avez créés. Nous avons vu dans ce module quelles pouvaient être les causes entraînant des erreurs de plan :
Ajoutons qu’il existe des outils comme PoWA pour vous aider à optimiser des requêtes.
ALTER DATABASE erp SET auto_explain.log_min_duration = '3s' ;
Principe :
L’outil auto_explain
est habituellement activé quand on
a le sentiment qu’une requête devient subitement lente à certains
moments, et qu’on suspecte que son plan diffère entre deux exécutions.
Elle permet de tracer dans les journaux applicatifs, voire dans la
console, le plan de la requête dès qu’elle dépasse une durée
configurée.
C’est une « contrib » officielle de PostgreSQL (et non une
extension). Tracer systématiquement le plan d’exécution d’une requête
fréquente prend de la place, et est assez coûteux. C’est donc un outil à
utiliser parcimonieusement, qui par défaut ne trace rien (paramètre
auto_explain.log_min_duration
à -1).
Activation et utilisation dans une session :
Pour utiliser auto_explain
dans la session en cours, il
faut activer quelques options similaires à celles
d’EXPLAIN
:
-- Chargement du module
LOAD 'auto_explain' ;
-- Durée minimale pour tracer (ms), le défaut est -1
SET auto_explain.log_min_duration = '0';
-- Options à activer
SET auto_explain.log_analyze TO on ;
SET auto_explain.log_buffers TO on ;
SET auto_explain.log_verbose TO on ;
Les plans se retrouvent dans les traces de l’instance. Pour les
afficher aussi dans la session en cours, il faut descendre le niveau des
messages affichés à LOG
(le défaut de
auto_explain
) :
Ensuite, lancer la requête à analyser : le plan apparaît suivi du résultat de la requête.
LOG: duration: 0.652 ms plan:
Query Text: SELECT count(*)
FROM pg_class, pg_index
WHERE oid = indrelid AND indisunique;
Aggregate (cost=29.75..29.76 rows=1 width=8) (actual time=0.630..0.632 rows=1 loops=1)
Output: count(*)
Buffers: shared hit=18
-> Hash Join (cost=23.34..29.37 rows=150 width=0) (actual time=0.532..0.612 rows=156 loops=1)
Inner Unique: true
Hash Cond: (pg_index.indrelid = pg_class.oid)
Buffers: shared hit=18
-> Seq Scan on pg_catalog.pg_index (cost=0.00..5.64 rows=150 width=4) (actual time=0.014..0.051 rows=156 loops=1)
Output: pg_index.indexrelid, pg_index.indrelid, pg_index.indnatts, pg_index.indnkeyatts, pg_index.indisunique, pg_index.indnullsnotdistinct, pg_index.indisprimary, pg_index.indisexclusion, pg_index.indimmediate, pg_index.indisclustered, pg_index.indisvalid, pg_index.indcheckxmin, pg_index.indisready, pg_index.indislive, pg_index.indisreplident, pg_index.indkey, pg_index.indcollation, pg_index.indclass, pg_index.indoption, pg_index.indexprs, pg_index.indpred
Filter: pg_index.indisunique
Rows Removed by Filter: 14
Buffers: shared hit=4
-> Hash (cost=18.15..18.15 rows=415 width=4) (actual time=0.310..0.311 rows=436 loops=1)
Output: pg_class.oid
Buckets: 1024 Batches: 1 Memory Usage: 24kB
Buffers: shared hit=14
-> Seq Scan on pg_catalog.pg_class (cost=0.00..18.15 rows=415 width=4) (actual time=0.018..0.191 rows=436 loops=1)
Output: pg_class.oid
Buffers: shared hit=14
Query Identifier: 7458316119861708727
LOG: duration: 3.398 ms statement: SELECT count(*)
FROM pg_class, pg_index
WHERE oid = indrelid AND indisunique;
count
-------
156
(1 row)
Autres paramètres :
L’outil a d’autres options, qui reprennent souvent les paramètres
habituels d’EXPLAIN
. Leurs valeurs par défaut sont les
suivantes :
name | setting
---------------------------------------+---------
auto_explain.log_analyze | on
auto_explain.log_buffers | off
auto_explain.log_format | text
auto_explain.log_level | log
auto_explain.log_min_duration | 0
auto_explain.log_nested_statements | off
auto_explain.log_parameter_max_length | -1
auto_explain.log_settings | off
auto_explain.log_timing | on
auto_explain.log_triggers | off
auto_explain.log_verbose | off
auto_explain.log_wal | off
auto_explain.sample_rate | 1
Voir la documentation pour ce qui n’est pas traité ici.
Utilisation au niveau global et précautions :
Pour utiliser auto_explain
globalement, il faut charger
la bibliothèque au démarrage dans le fichier
postgresql.conf
via le paramètre
shared_preload_libraries
.
Après un redémarrage de l’instance, il est possible de configurer les
paramètres de capture des plans d’exécution par base de données. Dans
l’exemple ci-dessous, l’ensemble des requêtes sont tracées sur la base
de données bench
, qui est utilisée par
pgbench
.
ALTER DATABASE bench SET auto_explain.log_min_duration = '0';
ALTER DATABASE bench SET auto_explain.log_analyze = on ;
Attention ! L’activation des traces complètes sur une base de
données avec un fort volume de requêtes, et/ou des requêtes aux plans
complexes, peut faire gonfler énormément les traces
(postgresql.log
). N’activez donc auto_explain
que de manière limitée.
Pour ne tracer qu’un échantillonnage des requêtes, déclarer par exemple :
Un autre problème, rare mais déjà rencontré, est l’activation
simultanée du paramètre track_io_timing
, alors que le test
avec pg_test_timing
indique que c’est problématique.
L’impact sur les performances peut être sévère.
pg_test_timing
est livré avec PostgreSQL et mesure les performances de l’horloge
système. Si le temps de mesure renvoyé sur la deuxième ligne n’est que
de quelques dizaines de nanosecondes, la machine est suffisamment rapide
pour que track_io_timing
renvoie des résultats précis et
sans ralentir la requête. C’est le cas sur presque toutes les machines
et systèmes d’exploitation actuels, mais il y a parfois des surprises,
par exemple dans certaines machines virtuelles ou selon la source de
l’horloge système. Sinon, éviter d’activer track_io_timing
sur un serveur de production. Sur une machine de test ou de formation,
ce n’est pas un problème.
Exemple de résultat :
Un petit benchmark pgbench
est lancé :
Les plans d’exécution de l’ensemble des requêtes exécutées apparaissent alors dans les traces de l’instance.
2025-04-30 09:52:05.360 UTC [78782] LOG: duration: 0.038 ms plan:
Query Text: SELECT abalance FROM pgbench_accounts WHERE aid = 445977;
Index Scan using pgbench_accounts_3_pkey on pgbench_accounts_3 pgbench_accounts (cost=0.42..8.44 rows=1 width=4) (actual time=0.032..0.033 rows=1 loops=1)
Index Cond: (aid = 445977)
2025-04-30 09:52:05.360 UTC [78782] LOG: duration: 0.237 ms statement: SELECT abalance FROM pgbench_accounts WHERE aid = 445977;
2025-04-30 09:52:05.361 UTC [78782] LOG: duration: 0.028 ms plan:
Query Text: UPDATE pgbench_tellers SET tbalance = tbalance + 4529 WHERE tid = 33;
Update on pgbench_tellers (cost=0.00..1.63 rows=0 width=0) (actual time=0.027..0.027 rows=0 loops=1)
-> Seq Scan on pgbench_tellers (cost=0.00..1.63 rows=1 width=10) (actual time=0.015..0.017 rows=1 loops=1)
Filter: (tid = 33)
Rows Removed by Filter: 49
2025-04-30 09:52:05.361 UTC [78782] LOG: duration: 0.154 ms statement: UPDATE pgbench_tellers SET tbalance = tbalance + 4529 WHERE tid = 33;
2025-04-30 09:52:05.361 UTC [78782] LOG: duration: 0.012 ms plan:
Query Text: UPDATE pgbench_branches SET bbalance = bbalance + 4529 WHERE bid = 3;
Update on pgbench_branches (cost=0.00..1.06 rows=0 width=0) (actual time=0.011..0.011 rows=0 loops=1)
-> Seq Scan on pgbench_branches (cost=0.00..1.06 rows=1 width=10) (actual time=0.005..0.006 rows=1 loops=1)
Filter: (bid = 3)
Rows Removed by Filter: 4
2025-04-30 09:52:05.361 UTC [78782] LOG: duration: 0.101 ms statement: UPDATE pgbench_branches SET bbalance = bbalance + 4529 WHERE bid = 3;
2025-04-30 09:52:05.361 UTC [78782] LOG: duration: 0.011 ms plan:
Query Text: INSERT INTO pgbench_history (tid, bid, aid, delta, mtime) VALUES (33, 3, 445977, 4529, CURRENT_TIMESTAMP);
Insert on pgbench_history (cost=0.00..0.01 rows=0 width=0) (actual time=0.011..0.011 rows=0 loops=1)
-> Result (cost=0.00..0.01 rows=1 width=116) (actual time=0.003..0.004 rows=1 loops=1)
2025-04-30 09:52:05.361 UTC [78782] LOG: duration: 0.096 ms statement: INSERT INTO pgbench_history (tid, bid, aid, delta, mtime) VALUES (33, 3, 445977, 4529, CURRENT_TIMESTAMP);
…
Utilisation pour des fonctions :
auto_explain
est aussi un moyen de suivre les plans au
sein de fonctions. Par défaut, un plan n’indique que les compteurs de
blocs hit, read, temp… de l’appel global à la
fonction.
Par exemple, on veut le plan à l’exécution de cette fonction en
PL/pgSQL, qui récupère le solde le plus élevé dans la table
pgbench_accounts
:
CREATE OR REPLACE function f_max_balance() RETURNS int AS $$
DECLARE
acct_balance int;
BEGIN
SELECT max(abalance)
INTO acct_balance
FROM pgbench_accounts;
RETURN acct_balance;
END;
$$ LANGUAGE plpgsql ;
Un EXPLAIN ANALYZE
ne permet pas d’obtenir le plan de la
requête de la fonction, car il est masqué au sein d’un nœud
Result
:
QUERY PLAN
--------------------------------------------------------------------------------------
Result (cost=0.00..0.26 rows=1 width=4) (actual time=37.047..37.048 rows=1 loops=1)
Output: f_max_balance()
Query Identifier: -5308051721018796792
Planning Time: 0.059 ms
Execution Time: 37.701 ms
(Exception : certaines fonctions très simples en SQL, et non PL/pgSQL, peuvent être intégrées dans la requête appelante et n’ont pas de plan propre.)
Par défaut, auto_explain
ne va pas capturer plus
d’information que la commande EXPLAIN ANALYZE
. Le fichier
log de l’instance capture le même plan lorsque la fonction est
exécutée.
2025-04-30 09:49:17.829 UTC [78757] LOG: duration: 30.579 ms plan:
Query Text: EXPLAIN (ANALYZE,VERBOSE) SELECT f_max_balance();
Result (cost=0.00..0.26 rows=1 width=4) (actual time=30.576..30.577 rows=1 loops=1)
Output: f_max_balance()
Buffers: shared hit=8199
Query Identifier: -5308051721018796792
auto_explain
permet de tracer les plans dans la
fonction en activant le paramètre
auto_explain.log_nested_statements
, de préférence
uniquement dans la ou les sessions concernées :
Les deux plans (celui de la requête de la fonction, puis de la fonction) sont alors visibles dans les traces de l’instance :
2025-04-30 09:54:27.301 UTC [78790] LOG: duration: 85.188 ms plan:
Query Text: SELECT max(abalance)
FROM pgbench_accounts
Finalize Aggregate (cost=13702.88..13702.89 rows=1 width=4) (actual time=85.182..85.184 rows=1 loops=1)
-> Gather (cost=13702.67..13702.88 rows=2 width=4) (actual time=85.175..85.178 rows=1 loops=1)
Workers Planned: 2
Workers Launched: 0
-> Partial Aggregate (cost=12702.67..12702.68 rows=1 width=4) (actual time=85.173..85.175 rows=1 loops=1)
-> Parallel Append (cost=0.00..12181.84 rows=208332 width=4) (actual time=0.029..63.662 rows=500000 loops=1)
-> Parallel Seq Scan on pgbench_accounts_1 (cost=0.00..3713.39 rows=98039 width=4) (actual time=0.028..14.006 rows=166667 loops=1)
-> Parallel Seq Scan on pgbench_accounts_2 (cost=0.00..3713.39 rows=98039 width=4) (actual time=0.026..9.567 rows=166667 loops=1)
-> Parallel Seq Scan on pgbench_accounts_3 (cost=0.00..3713.39 rows=98039 width=4) (actual time=0.007..9.687 rows=166666 loops=1)
2025-04-30 09:54:27.301 UTC [78790] CONTEXT: SQL statement "SELECT max(abalance)
FROM pgbench_accounts"
PL/pgSQL function f_max_balance() line 5 at SQL statement
2025-04-30 09:54:27.302 UTC [78790] LOG: duration: 85.520 ms plan:
Query Text: SELECT f_max_balance();
Result (cost=0.00..0.26 rows=1 width=4) (actual time=85.511..85.511 rows=1 loops=1)
2025-04-30 09:54:27.302 UTC [78790] LOG: duration: 85.795 ms statement: SELECT f_max_balance();
Utilisation avec pgBadger :
pgBadger est capable de lire les plans tracés par
auto_explain
, de les intégrer à son rapport et d’inclure un
lien vers explain.depesz.com
pour une version plus lisible.
Cette extension est disponible à cette adresse (le miroir GitHub ne semble pas maintenu). Oleg Bartunov, l’un de ses auteurs, a publié en 2018 un article intéressant sur son utilisation.
Il faudra récupérer le source et le compiler. La configuration est basée sur trois paramètres :
plantuner.enable_index
pour préciser les index à
activer ;plantuner.disable_index
pour préciser les index à
désactiver ;plantuner.fix_empty_table
pour forcer à zéro les
statistiques des tables de 0 bloc.Ils sont configurables à chaud, comme le montre l’exemple suivant :
QUERY PLAN
-----------------------------------------------------------------
Index Scan using employes_big_date_embauche_idx on employes_big
Index Cond: (date_embauche = '1000-01-01'::date)
SET plantuner.disable_index='employes_big_date_embauche_idx';
EXPLAIN (COSTS OFF)
SELECT * FROM employes_big WHERE date_embauche='1000-01-01';
QUERY PLAN
------------------------------------------------------
Gather
Workers Planned: 2
-> Parallel Seq Scan on employes_big
Filter: (date_embauche = '1000-01-01'::date)
Un des intérêts de cette extension est de pouvoir interdire l’utilisation d’un index, afin de pouvoir ensuite le supprimer de manière transparente, c’est-à-dire sans bloquer aucune requête applicative.
Cependant, généralement, cette extension a sa place sur un serveur de développement pour bien comprendre les choix de planification, pas sur un serveur de production. En tout cas, pas dans le but de tromper le planificateur.Comme avec toute extension en C, un bug est susceptible de provoquer un plantage complet du serveur.
Cette extension existe depuis longtemps.
Elle est à présent présente dans les paquets du PGDG, par exemple :
# paquets Debian/Ubuntu
sudo apt install postgresql-17-pg-hint-plan
# paquets RPM
sudo dnf install pg_hint_plan_17 pg_hint_plan_17-llvmjit
Le code est sur le dépôt Github. La documentation en anglais peut être complétée par la version japonaise plus à jour, ou cet article.
Comme avec toute extension en C, un bug est susceptible de provoquer un plantage complet du serveur !
Cette extension est disponible sur GitHub et dans les paquets du PGDG. Il existe trois fonctions principales et une vue :
hypopg_create_index()
pour créer un index
hypothétique ;hypopg_drop_index()
pour supprimer un index
hypothétique particulier ou hypopg_reset()
pour tous les
supprimer ;hypopg_list_indexes
pour les lister.Un index hypothétique n’existe que dans la session, ni en mémoire ni
sur le disque, mais le planificateur le prendra en compte dans un
EXPLAIN
simple (évidemment pas un
EXPLAIN ANALYZE
). En quittant la session, tous les index
hypothétiques restants et créés sur cette session sont supprimés.
L’exemple suivant est basé sur la base dont le script peut être téléchargé sur https://dali.bo/tp_employes_services.
QUERY PLAN
-------------------------------------------------------------------------------
Gather (cost=1000.00..8263.14 rows=1 width=41)
Workers Planned: 2
-> Parallel Seq Scan on employes_big (cost=0.00..7263.04 rows=1 width=41)
Filter: ((prenom)::text = 'Gaston'::text)
indexrelid | indexname
------------+----------------------------------
24591 | <24591>btree_employes_big_prenom
QUERY PLAN
-------------------------------------------------------------------
Index Scan using <24591>btree_employes_big_prenom on employes_big
(cost=0.05..4.07 rows=1 width=41)
Index Cond: ((prenom)::text = 'Gaston'::text)
indexrelid | indexname | nspname | relname | amname
------------+----------------------------------+---------+--------------+--------
24591 | <24591>btree_employes_big_prenom | public | employes_big | btree
QUERY PLAN
----------------------------------------------------------
Index Scan using employes_big_prenom_idx on employes_big
(cost=0.42..4.44 rows=1 width=41)
Index Cond: ((prenom)::text = 'Gaston'::text)
Le cas idéal d’utilisation est l’index B-Tree sur une colonne. Un index fonctionnel est possible, mais, faute de statistiques disponibles avant la création réelle de l’index, les estimations peuvent être fausses. Les autres types d’index sont moins bien ou non supportées.
La version en ligne des solutions de ces TP est disponible sur https://dali.bo/j2_solutions.
Tous les TP se basent sur la configuration par défaut de PostgreSQL, sauf précision contraire.
\timing
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 :
random()
).psql
, il est possible de
les rappeler avec \g
, ou la touche flèche
haut du clavier.Ce TP utilise notamment la base cave. Son schéma est le suivant :
La base cave (dump de 2,6 Mo, pour 71 Mo sur le disque au final) peut être téléchargée et restaurée ainsi :
curl -kL https://dali.bo/tp_cave -o cave.dump
psql -c "CREATE ROLE caviste LOGIN PASSWORD 'caviste'"
psql -c "CREATE DATABASE cave OWNER caviste"
pg_restore -d cave cave.dump
# NB : une erreur sur un schéma 'public' existant est normale
Les valeurs (taille, temps d’exécution) varieront à cause de plusieurs critères :
But : Optimisation de requête
La requête suivante vise à récupérer un état des stocks pour une année prise au hasard :
SET jit TO off ;
SET max_parallel_workers_per_gather TO 0;
EXPLAIN (ANALYZE, COSTS OFF)
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;
stock.annee
.stock
.enable_seqscan
dans la session.WHERE
.But : Optimisation de requête
L’exercice précédent nous a amené à 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
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;
Cette écriture n’est pas optimale.
appellation
.stock.annee
est-il
utilisé ?But : Optimiser une requête avec beaucoup de tables
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 search_path TO magasin,facturation ;
SET max_parallel_workers_per_gather TO 0; -- paramétrage pour simplifier les plans
SET jit TO off ; --
SELECT SUM (reglements.montant) AS somme_reglements
FROM factures
INNER JOIN reglements USING (numero_facture)
INNER JOIN commandes USING (numero_commande)
INNER JOIN clients cl USING (client_id)
INNER JOIN types_clients USING (type_client)
INNER JOIN lignes_commandes lc USING (numero_commande)
INNER JOIN lots l ON (l.numero_lot = lc.numero_lot_expedition)
INNER JOIN transporteurs USING (transporteur_id)
INNER JOIN contacts ct ON (ct.contact_id = cl.contact_id)
WHERE transporteurs.nom = 'Royal Air Drone'
AND login = 'Beatty_Brahem' ;
Tous les TP se basent sur la configuration par défaut de PostgreSQL, sauf précision contraire.
\timing
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 :
random()
).psql
, il est possible de
les rappeler avec \g
, ou la touche flèche
haut du clavier.Ce TP utilise notamment la base cave. Son schéma est le suivant :
La base cave (dump de 2,6 Mo, pour 71 Mo sur le disque au final) peut être téléchargée et restaurée ainsi :
curl -kL https://dali.bo/tp_cave -o cave.dump
psql -c "CREATE ROLE caviste LOGIN PASSWORD 'caviste'"
psql -c "CREATE DATABASE cave OWNER caviste"
pg_restore -d cave cave.dump
# NB : une erreur sur un schéma 'public' existant est normale
Les valeurs (taille, temps d’exécution) varieront à cause de plusieurs critères :
SET jit TO off ;
SET max_parallel_workers_per_gather TO 0;
EXPLAIN (ANALYZE, COSTS OFF)
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. Le temps comme le plan peuvent varier en fonction de la version exacte de PostgreSQL, de la machine utilisée, de son activité :
QUERY PLAN
-------------------------------------------------------------------------------
HashAggregate (actual time=199.630..199.684 rows=319 loops=1)
Group Key: (((((round((random() * '50'::double precision))
+ '1950'::double precision)))::text || ' - '::text)
|| a.libelle)
-> Hash Left Join (actual time=61.631..195.614 rows=16892 loops=1)
Hash Cond: (v.appellation_id = a.id)
-> Hash Join (actual time=61.531..190.045 rows=16892 loops=1)
Hash Cond: (s.contenant_id = c.id)
-> Hash Join (actual time=61.482..186.976 rows=16892 loops=1)
Hash Cond: (s.vin_id = v.id)
-> Hash Join (actual time=60.049..182.135 rows=16892 loops=1)
Hash Cond: ((s.annee)::double precision
= ((round((random() * '50'::double precision))
+ '1950'::double precision)))
-> Seq Scan on stock s (… rows=860588 loops=1)
-> Hash (actual time=0.010..0.011 rows=1 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 9kB
-> Result (… rows=1 loops=1)
-> Hash (actual time=1.420..1.421 rows=6062 loops=1)
Buckets: 8192 Batches: 1 Memory Usage: 301kB
-> Seq Scan on vin v (… rows=6062 loops=1)
-> Hash (actual time=0.036..0.036 rows=3 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 9kB
-> Seq Scan on contenant c (… rows=3 loops=1)
-> Hash (actual time=0.090..0.090 rows=319 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 25kB
-> Seq Scan on appellation a (… rows=319 loops=1)
Planning Time: 2.673 ms
Execution Time: 199.871 ms
stock.annee
.Instinctivement, on s’attend à ce qu’un index sur
stock.annee
soit utile, puisque l’on sélectionne uniquement
là-dessus :
Cependant, le plan ne change pas si l’on relance la requête ci-dessus.
La raison est simple : au moment de la construction du plan, la valeur de l’année est inconnue. L’index est donc inutilisable.
stock
.Peut-être ANALYZE
a-t-il été oublié ? Dans l’idéal, un
VACUUM ANALYZE
est même préférable pour favoriser les
Index Only Scan
.
Mais cela n’a pas d’influence sur le plan. En fait, le premier plan ci-dessus montre que les statistiques sont déjà correctement estimées.
enable_seqscan
dans la session.Nous remarquons que le temps d’exécution explose :
GroupAggregate (actual time=1279.990..1283.367 rows=319 loops=1)
Group Key: ((((((round((random() * '50'::double precision))
+ '1950'::double precision)))::text || ' - '::text)
|| a.libelle))
-> Sort (actual time=1279.965..1280.895 rows=16854 loops=1)
Sort Key: ((((((round((random() * '50'::double precision))
+ '1950'::double precision)))::text || ' - '::text)
|| a.libelle))
Sort Method: quicksort Memory: 2109kB
-> Hash Left Join (actual time=11.163..1258.628 rows=16854 loops=1)
Hash Cond: (v.appellation_id = a.id)
-> Hash Join (actual time=10.911..1247.542 rows=16854 loops=1)
Hash Cond: (s.vin_id = v.id)
-> Nested Loop (actual time=0.070..1230.297
rows=16854 loops=1)
Join Filter: (s.contenant_id = c.id)
Rows Removed by Join Filter: 17139
-> Hash Join (actual time=0.056..1220.730
rows=16854 loops=1)
Hash Cond: ((s.annee)::double precision =
((round((random() *
'50'::double precision))
+ '1950'::double precision)))
-> Index Scan using stock_pkey on stock s
(actual time=0.011..1098.671 rows=860588 loops=1)
-> Hash (actual time=0.007..0.007 rows=1 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 9kB
-> Result (…rows=1 loops=1)
-> Materialize (… rows=2 loops=16854)
-> Index Scan using contenant_pkey on contenant c
(actual time=0.007..0.009 rows=3 loops=1)
-> Hash (actual time=10.826..10.826 rows=6062 loops=1)
Buckets: 8192 Batches: 1 Memory Usage: 301kB
-> Index Scan using vin_pkey on vin v
(actual time=0.010..8.436 rows=6062 loops=1)
-> Hash (actual time=0.233..0.233 rows=319 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 25kB
-> Index Scan using appellation_pkey on appellation a
(actual time=0.015..0.128 rows=319 loops=1)
Planning Time: 1.337 ms
Execution Time: 1283.467 ms
Le plan renvoyé peut être analysé graphiquement avec explain.dalibo.com.
Avec COSTS ON
(qui est activé par défaut), les
estimations attendues sont affichées, où l’on peut comparer ligne par
ligne aux nombres réellement ramenés.
HashAggregate (cost=17931.35..17937.73 rows=319 width=48)
(actual time=195.338..195.388 rows=319 loops=1)
Group Key: (…)
…
L’estimation du nombre de lignes renvoyé par la requête est parfaite. Est-ce le cas pour tous les nœuds en-dessous ?
D’abord on note que les lignes à regrouper étaient 4 fois plus nombreuses que prévues :
-> Hash Left Join (cost=180.68..17877.56 rows=4303 width=40)
(actual time=136.012..191.468 rows=16834 loops=1)
Hash Cond: (v.appellation_id = a.id)
Cela ne veut pas dire que les statistiques sur les tables
v
ou a
sont fausses, car les nœuds précédents
ont déjà opéré des jointures et filtrages. Si on tente de descendre au
nœud le plus profond qui montre un problème d’estimation, on trouve
ceci :
-> Hash Join
(cost=0.04..17603.89 rows=4303 width=20)
(actual time=134.406..177.861 rows=16834 loops=1)
Hash Cond: ((s.annee)::double precision =
((round((random() * '50'::double precision))
+ '1950'::double precision)))
Il s’agit de la jointure hash entre stock
et
annee
sur une sélection aléatoire de l’année. PostgreSQL
s’attend à 4303 lignes, et en retrouve 16 834, 4 fois plus.
Il ne s’agit pas d’un problème dans l’estimation de
stock
même, car il s’attend correctement à y balayer
860 588 lignes (il s’agit bien du nombre de lignes vivantes total de la
table qui vont alimenter la jointure avec annee
) :
-> Seq Scan on stock s
(cost=0.00..13257.88 rows=860588 width=16)
(actual time=0.012..66.563 rows=860588 loops=1)
La seconde partie du hash (le SELECT
sur
annee
) est constitué d’un bucket correctement
estimé à 1 ligne depuis le résultat de la sous-requête :
-> Hash (cost=0.03..0.03 rows=1 width=8)
(actual time=0.053..0.053 rows=1 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 9kB
-> Result (cost=0.00..0.02 rows=1 width=8)
(actual time=0.049..0.050 rows=1 loops=1)
Il y a donc un problème dans l’estimation du nombre de lignes ramenées par la jointure sur l’année choisie au hasard.
WHERE
.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.
Le nouveau plan est :
HashAggregate (cost=17888.29..17974.35 rows=4303 width=48)
(actual time=123.606..123.685 rows=319 loops=1)
Group Key: (((s.annee)::text || ' - '::text) || a.libelle)
InitPlan 1 (returns $0)
-> Result (cost=0.00..0.02 rows=1 width=8)
(… rows=1 loops=1)
-> Hash Left Join (cost=180.64..17834.49 rows=4303 width=40)
(actual time=8.329..114.481 rows=17527 loops=1)
Hash Cond: (v.appellation_id = a.id)
-> Hash Join (cost=170.46..17769.84 rows=4303 width=16)
(actual time=7.847..101.390 rows=17527 loops=1)
Hash Cond: (s.contenant_id = c.id)
-> Hash Join (cost=169.40..17741.52 rows=4303 width=16)
(actual time=7.789..94.117 rows=17527 loops=1)
Hash Cond: (s.vin_id = v.id)
-> Seq Scan on stock s
(cost=0.00..17560.82 rows=4303 width=16)
(actual time=0.031..77.158 rows=17527 loops=1)
Filter: ((annee)::double precision = $0)
Rows Removed by Filter: 843061
-> Hash (cost=93.62..93.62 rows=6062 width=8)
(actual time=7.726..7.726 rows=6062 loops=1)
Buckets: 8192 Batches: 1 Memory Usage: 301kB
-> Seq Scan on vin v
(cost=0.00..93.62 rows=6062 width=8)
(actual time=0.016..3.563 rows=6062 loops=1)
-> Hash (cost=1.03..1.03 rows=3 width=8)
(actual time=0.040..0.040 rows=3 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 9kB
-> Seq Scan on contenant c
(cost=0.00..1.03 rows=3 width=8)
(actual time=0.026..0.030 rows=3 loops=1)
-> Hash (cost=6.19..6.19 rows=319 width=20)
(actual time=0.453..0.453 rows=319 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 25kB
-> Seq Scan on appellation a
(cost=0.00..6.19 rows=319 width=20)
(actual time=0.019..0.200 rows=319 loops=1)
Planning Time: 2.227 ms
Execution Time: 123.909 ms
Sur la machine testée, le temps d’exécution est réduit d’un tiers. Pourtant, le plan n’est que très peu différent, et les estimations ne sont pas meilleures (ce qui semble logique, PostgreSQL n’ayant pas plus d’informations sur la valeur exacte de l’année qui sera calculée).
La différence avec l’ancien plan est cette partie :
-> Seq Scan on stock s
(cost=0.00..17560.82 rows=4303 width=16)
(actual time=0.031..77.158 rows=17527 loops=1)
Filter: ((annee)::double precision = $0)
Rows Removed by Filter: 843061
Le nouveau plan comprend le calcul de la varable $0
(tout en haut) puis un parcours complet de stock
et un
filtrage au fur et à mesure des lignes sur annee=$0
.
Il ne s’agit plus là d’une jointure par hash. Toute la
construction d’une table de hachage sur la table stock
est
supprimée. PostgreSQL sait de manière absolue qu’il n’y aura qu’une
seule valeur ramenée par sous-requête, gràce à =
. Ce
n’était pas évident pour lui car le résultat des fonctions forme un peu
une « boîte noire ». Si on remplace le =
par
IN
, on retombe sur le plan original.
Noter toutefois que la différence totale de coût au niveau de la requête est faible.
Que peut-on conclure de cet exercice ?
l’optimiseur n’est pas tenu d’utiliser un index ;
se croire plus malin que l’optimiseur est souvent
contre-productif (SET enable_seqscan TO off
n’a pas mené au
résultat espéré) ;
il vaut toujours mieux être explicite dans ce qu’on demande dans une requête ;
il vaut mieux séparer jointure et filtrage.
Il reste un mystère qui sera couvert par un exercice suivant :
pourquoi l’index sur stock.annee
n’est-il pas utilisé ?
appellation
.On peut se demander si la jointure externe (LEFT JOIN
)
est fondée :
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 ».
La description de la table vin
est :
\d vin
Table « public.vin »
Colonne | Type | ... | NULL-able | Par défaut
----------------+---------+-----+-----------+---------------------------------
id | integer | | not null | nextval('vin_id_seq'::regclass)
recoltant_id | integer | | |
appellation_id | integer | | not null |
type_vin_id | integer | | not null |
Index :
"vin_pkey" PRIMARY KEY, btree (id)
Contraintes de clés étrangères :
"vin_appellation_id_fkey" FOREIGN KEY (appellation_id)
REFERENCES appellation(id) ON DELETE CASCADE
"vin_recoltant_id_fkey" FOREIGN KEY (recoltant_id)
REFERENCES recoltant(id) ON DELETE CASCADE
"vin_type_vin_id_fkey" FOREIGN KEY (type_vin_id)
REFERENCES type_vin(id) ON DELETE CASCADE
Référencé par :
TABLE "stock" CONSTRAINT "stock_vin_id_fkey"
FOREIGN KEY (vin_id) REFERENCES vin(id) ON DELETE CASCADE
appellation_id
est NOT NULL
: il y a
forcément une valeur, qui est forcément dans appellation
.
De plus, la contrainte vin_appellation_id_fkey
signifie
qu’on a la certitude que pour chaque vin.appellation.id
, il
existe une ligne correspondante dans appellation
.
À titre de vérification, deux COUNT(*)
du résultat, une
fois en INNER JOIN
et une fois en LEFT JOIN
montrent un résultat identique :
On peut donc réécrire la requête sans la jointure externe, qui n’est pas fausse mais est généralement bien moins efficace qu’une jointure interne :
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;
Quant au plan, il est identique au plan précédent. Cela n’est pas
étonnant : il n’y a aucun filtrage sur appellation
et c’est
une petite table, donc intuitivement on peut se dire que PostgreSQL fera
la jointure une fois les autres opérations effectuées, sur le minimum de
lignes. D’autre part, PostgreSQL est depuis longtemps capable de
transformer un LEFT JOIN
inutile en INNER JOIN
quand la contrainte est là.
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 ?
stock.annee
est-il
utilisé ?Si on fige l’année, on constate que l’index sur
stock.annee
est bien utilisé, avec un temps d’exécution
bien plus réduit :
EXPLAIN (ANALYSE, COSTS OFF)
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 = 1950
GROUP BY s.annee||' - '||a.libelle;
QUERY PLAN
----------------------------------------------------------------------------
HashAggregate (actual time=48.827..48.971 rows=319 loops=1)
Group Key: (((s.annee)::text || ' - '::text) || a.libelle)
-> Hash Join (actual time=8.889..40.737 rows=17527 loops=1)
Hash Cond: (v.appellation_id = a.id)
-> Hash Join (actual time=8.398..29.828 rows=17527 loops=1)
Hash Cond: (s.vin_id = v.id)
-> Hash Join (actual time=0.138..14.374 rows=17527 loops=1)
Hash Cond: (s.contenant_id = c.id)
-> Index Scan using stock_annee_idx on stock s
(actual time=0.066..6.587 rows=17527 loops=1)
Index Cond: (annee = 1950)
-> Hash (actual time=0.017..0.018 rows=3 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 9kB
-> Seq Scan on contenant c (… rows=3 loops=1)
-> Hash (actual time=8.228..8.228 rows=6062 loops=1)
Buckets: 8192 Batches: 1 Memory Usage: 301kB
-> Seq Scan on vin v (…)
-> Hash (actual time=0.465..0.465 rows=319 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 25kB
-> Seq Scan on appellation a (…)
Planning Time: 2.144 ms
Execution Time: 49.317 ms
La partie qui diffère de l’ancien plan est celle-ci :
-> Index Scan using stock_annee_idx on stock s
(actual time=0.066..6.587 rows=17527 loops=1)
Index Cond: (annee = 1950)
Quand précédemment on avait un parcours et un filtrage :
-> Seq Scan on stock s
(cost=0.00..17560.82 rows=4303 width=16)
(actual time=0.031..77.158 rows=17527 loops=1)
Filter: ((annee)::double precision = $0)
Rows Removed by Filter: 843061
Le nombre de lignes estimées et obtenues sont pourtant les mêmes.
Pourquoi PostgreSQL utilise-t-il l’index pour filtrer sur
1950
et par pour $0
? Le filtre en fait
diffère, le premier est (annee = 1950)
(compatible avec un
index), l’autre est ((annee)::double precision = $0)
, qui
contient une conversion de int
en
double precision
! Et dans ce cas, l’index est inutilisable
(comme à chaque fois qu’il y a une opération sur la colonne
indexée).
La conversion a lieu 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
.
Le phénomène peut s’observer sur la requête avec 1950 en comparant
annee = 1950 + 1.0
: l’index ne sera plus utilisé.
Réécrivons encore une fois cette requête en homogénéisant les types :
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))::int + 1950 AS annee)
GROUP BY s.annee||' - '||a.libelle;
Voici son plan :
HashAggregate (actual time=28.208..28.365 rows=319 loops=1)
Group Key: (((s.annee)::text || ' - '::text) || a.libelle)
InitPlan 1 (returns $0)
-> Result (actual time=0.003..0.003 rows=1 loops=1)
-> Hash Join (actual time=2.085..23.194 rows=16891 loops=1)
Hash Cond: (v.appellation_id = a.id)
-> Hash Join (actual time=1.894..16.358 rows=16891 loops=1)
Hash Cond: (s.vin_id = v.id)
-> Hash Join (actual time=0.091..9.865 rows=16891 loops=1)
Hash Cond: (s.contenant_id = c.id)
-> Index Scan using stock_annee_idx on stock s
(actual time=0.068..4.774 rows=16891 loops=1)
Index Cond: (annee = $0)
-> Hash (actual time=0.013..0.013 rows=3 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 9kB
-> Seq Scan on contenant c (…)
-> Hash (actual time=1.792..1.792 rows=6062 loops=1)
Buckets: 8192 Batches: 1 Memory Usage: 301kB
-> Seq Scan on vin v (…)
-> Hash (actual time=0.183..0.183 rows=319 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 25kB
-> Seq Scan on appellation a (…)
Planning Time: 0.574 ms
Execution Time: 28.516 ms
On constate qu’on utilise enfin l’index de stock
. Le
temps d’exécution est bien meilleur. Ce problème d’incohérence de type
était la cause fondamentale du ralentissement de la requête.
Noter au passage que le critère suivant ne fonctionnera pas, non à cause du type, mais parce qu’il est faux :
En effet, la comparaison entre annee
et la valeur
aléatoire se ferait à chaque ligne séparément, avec un résultat
complètement faux. Pour choisir une année au hasard, il faut
donc encapsuler le calcul dans une sous-requête, dont le résultat
ramènera une seule ligne de manière garantie.
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 search_path TO magasin,facturation ;
SET max_parallel_workers_per_gather TO 0; -- paramétrage pour simplifier les plans
SET jit TO off ; --
SELECT SUM (reglements.montant) AS somme_reglements
FROM factures
INNER JOIN reglements USING (numero_facture)
INNER JOIN commandes USING (numero_commande)
INNER JOIN clients cl USING (client_id)
INNER JOIN types_clients USING (type_client)
INNER JOIN lignes_commandes lc USING (numero_commande)
INNER JOIN lots l ON (l.numero_lot = lc.numero_lot_expedition)
INNER JOIN transporteurs USING (transporteur_id)
INNER JOIN contacts ct ON (ct.contact_id = cl.contact_id)
WHERE transporteurs.nom = 'Royal Air Drone'
AND login = 'Beatty_Brahem' ;
Cette requête s’exécute très lentement. Son plan simplifié est le suivant (la version complète est sur https://explain.dalibo.com/plan/D0U) :
QUERY PLAN
-------------------------------------------------------------------------------
Aggregate (actual time=3050.969..3050.978 rows=1 loops=1)
-> Hash Join (actual time=2742.616..3050.966 rows=4 loops=1)
Hash Cond: (cl.contact_id = ct.contact_id)
-> Hash Join (actual time=2192.741..2992.578 rows=422709 loops=1)
Hash Cond: (factures.numero_commande = commandes.numero_commande)
-> Hash Join (actual time=375.112..914.517 rows=1055812 loops=1)
Hash Cond: ((reglements.numero_facture)::text = (factures.numero_facture)::text)
-> Seq Scan on reglements (actual time=0.007..96.963 rows=1055812 loops=1)
-> Hash (actual time=371.347..371.348 rows=1000000 loops=1)
Buckets: 1048576 Batches: 1 Memory Usage: 62880kB
-> Seq Scan on factures (actual time=0.018..113.699 rows=1000000 loops=1)
-> Hash (actual time=1813.741..1813.746 rows=393841 loops=1)
Buckets: 1048576 Batches: 1 Memory Usage: 29731kB
-> Hash Join (actual time=558.943..1731.833 rows=393841 loops=1)
Hash Cond: (cl.type_client = types_clients.type_client)
-> Hash Join (actual time=558.912..1654.443 rows=393841 loops=1)
Hash Cond: (commandes.client_id = cl.client_id)
-> Hash Join (actual time=533.279..1522.611 rows=393841 loops=1)
Hash Cond: (lc.numero_commande = commandes.numero_commande)
-> Hash Join (actual time=190.050..1073.358 rows=393841 loops=1)
Hash Cond: (lc.numero_lot_expedition = l.numero_lot)
-> Seq Scan on lignes_commandes lc (actual time=0.024..330.462 rows=3141967 loops=1)
-> Hash (actual time=189.059..189.061 rows=125889 loops=1)
Buckets: 262144 Batches: 1 Memory Usage: 6966kB
-> Hash Join (actual time=0.032..163.622 rows=125889 loops=1)
Hash Cond: (l.transporteur_id = transporteurs.transporteur_id)
-> Seq Scan on lots l (actual time=0.016..68.766 rows=1006704 loops=1)
-> Hash (actual time=0.010..0.011 rows=1 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 9kB
-> Seq Scan on transporteurs (actual time=0.006..0.007 rows=1 loops=1)
Filter: ((nom)::text = 'Royal Air Drone'::text)
Rows Removed by Filter: 4
-> Hash (actual time=339.432..339.432 rows=1000000 loops=1)
Buckets: 1048576 Batches: 1 Memory Usage: 55067kB
-> Seq Scan on commandes (actual time=0.028..118.268 rows=1000000 loops=1)
-> Hash (actual time=25.156..25.156 rows=100000 loops=1)
Buckets: 131072 Batches: 1 Memory Usage: 6493kB
-> Seq Scan on clients cl (actual time=0.006..9.926 rows=100000 loops=1)
-> Hash (actual time=0.018..0.018 rows=3 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 9kB
-> Seq Scan on types_clients (actual time=0.010..0.011 rows=3 loops=1)
-> Hash (actual time=29.722..29.723 rows=1 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 9kB
-> Seq Scan on contacts ct (actual time=17.172..29.716 rows=1 loops=1)
Filter: ((login)::text = 'Beatty_Brahem'::text)
Rows Removed by Filter: 110004
Planning Time: 1.390 ms
Execution Time: 3059.442 m
Le plan se résume ainsi : un premier filtre se fait sur le
transporteur demandé (1 ligne sur 4). Puis toutes les jointures
s’enchaînent, de manière certes peu efficace : toutes les tables sont
parcourues intégralement. Enfin, les 422 709 lignes obtenues sont
jointes à la table contacts
, laquelle a été filtrée sur la
personne demandée (1 ligne sur 110 005).
Le critère sur contact
est de loin le plus
discriminant : on s’attend à ce qu’il soit le premier pris en compte. Le
plan complet montre que les estimations de volumétrie sont pourtant
correctes.
Il y a 9 tables. Avec autant de tables, il faut se rappeler de
l’existence du paramètre join_collapse_limit
. Vérifions que
la valeur est celle par défaut, et testons une autre valeur :
EXPLAIN (ANALYZE, COSTS OFF)
SELECT SUM (reglements.montant) AS somme_reglements
FROM factures
INNER JOIN reglements USING (numero_facture)
INNER JOIN commandes USING (numero_commande)
INNER JOIN clients cl USING (client_id)
INNER JOIN types_clients USING (type_client)
INNER JOIN lignes_commandes lc USING (numero_commande)
INNER JOIN lots l ON (l.numero_lot = lc.numero_lot_expedition)
INNER JOIN transporteurs USING (transporteur_id)
INNER JOIN contacts ct ON (ct.contact_id = cl.contact_id)
WHERE transporteurs.nom = 'Royal Air Drone'
AND login = 'Beatty_Brahem' ;
QUERY PLAN
-------------------------------------------------------------------------------
Aggregate (actual time=533.593..533.601 rows=1 loops=1)
-> Hash Join (actual time=464.437..533.589 rows=4 loops=1)
Hash Cond: ((reglements.numero_facture)::text = (factures.numero_facture)::text)
-> Seq Scan on reglements (actual time=0.011..83.493 rows=1055812 loops=1)
-> Hash (actual time=354.413..354.420 rows=4 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 9kB
-> Hash Join (actual time=326.786..354.414 rows=4 loops=1)
Hash Cond: (factures.numero_commande = commandes.numero_commande)
-> Seq Scan on factures (actual time=0.012..78.213 rows=1000000 loops=1)
-> Hash (actual time=197.837..197.843 rows=4 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 9kB
-> Hash Join (actual time=118.525..197.838 rows=4 loops=1)
Hash Cond: (l.transporteur_id = transporteurs.transporteur_id)
-> Nested Loop (actual time=49.407..197.816 rows=35 loops=1)
-> Nested Loop (actual time=49.400..197.701 rows=35 loops=1)
-> Hash Join (actual time=49.377..197.463 rows=10 loops=1)
Hash Cond: (commandes.client_id = cl.client_id)
-> Seq Scan on commandes (actual time=0.003..88.021 rows=1000000 loops=1)
-> Hash (actual time=30.975..30.978 rows=1 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 9kB
-> Nested Loop (actual time=20.840..30.976 rows=1 loops=1)
-> Hash Join (actual time=20.823..30.957 rows=1 loops=1)
Hash Cond: (cl.contact_id = ct.contact_id)
-> Seq Scan on clients cl (actual time=0.003..6.206 rows=100000 loops=1)
-> Hash (actual time=16.168..16.169 rows=1 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 9kB
-> Seq Scan on contacts ct (actual time=6.660..16.143 rows=1 loops=1)
Filter: ((login)::text = 'Beatty_Brahem'::text)
Rows Removed by Filter: 110004
-> Index Only Scan using types_clients_pkey on types_clients (actual time=0.013..0.013 rows=1 loops=1)
Index Cond: (type_client = cl.type_client)
Heap Fetches: 1
-> Index Scan using lignes_commandes_pkey on lignes_commandes lc (actual time=0.019..0.020 rows=4 loops=10)
Index Cond: (numero_commande = commandes.numero_commande)
-> Index Scan using lots_pkey on lots l (actual time=0.003..0.003 rows=1 loops=35)
Index Cond: (numero_lot = lc.numero_lot_expedition)
-> Hash (actual time=0.009..0.009 rows=1 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 9kB
-> Seq Scan on transporteurs (actual time=0.006..0.007 rows=1 loops=1)
Filter: ((nom)::text = 'Royal Air Drone'::text)
Rows Removed by Filter: 4
Planning Time: 3.168 ms
Execution Time: 533.689 ms
(Le plan complet est sur https://explain.dalibo.com/plan/EQN).
Ce plan est 6 fois plus rapide. La différence essentielle tient dans
le filtre effectué en premier : cette fois, c’est sur
contacts
. Puis toute la chaîne des jointures est à nouveau
remontée, avec beaucoup moins de lignes qu’auparavant. C’est donc plus
rapide, et les Nested Loops et Index Scans deviennent
rentables. L’agrégat ne se fait plus que sur 4 lignes.
Avec le join_collapse_limit
par défaut à 8, PostgreSQL
joignait les 8 premières tables, sans critère de filtrage vraiment
discriminant, puis joignait le résultat à contacts
. En
augmentant join_collapse_limit
, PostgreSQL s’est permis
d’étudier les plans incluants contacts
, sur lesquels porte
le filtre le plus intéressant.
Noter que le temps de planification a plus que doublé, mais il est intéressant de perdre 1 ou 2 ms de planification pour gagner plusieurs secondes à l’exécution.
Si l’on a accès au code de la requête, il est possible de la modifier afin que la table la plus discriminante figure dans les 8 premières tables.
EXPLAIN (ANALYZE, COSTS OFF)
SELECT SUM (reglements.montant) AS somme_reglements
FROM factures
INNER JOIN reglements USING (numero_facture)
INNER JOIN commandes USING (numero_commande)
INNER JOIN clients cl USING (client_id)
INNER JOIN contacts ct ON (ct.contact_id = cl.contact_id) --- jointure déplacée
INNER JOIN types_clients USING (type_client)
INNER JOIN lignes_commandes lc USING (numero_commande)
INNER JOIN lots l ON (l.numero_lot = lc.numero_lot_expedition)
INNER JOIN transporteurs USING (transporteur_id)
WHERE magasin.transporteurs.nom = 'Royal Air Drone'
AND login = 'Beatty_Brahem' ;
QUERY PLAN
-------------------------------------------------------------------------------
Aggregate (actual time=573.108..573.115 rows=1 loops=1)
-> Hash Join (actual time=498.176..573.103 rows=4 loops=1)
Hash Cond: (l.transporteur_id = transporteurs.transporteur_id)
-> Hash Join (actual time=415.225..573.077 rows=35 loops=1)
Hash Cond: ((reglements.numero_facture)::text = (factures.numero_facture)::text)
-> Seq Scan on reglements (actual time=0.003..92.461 rows=1055812 loops=1)
-> Hash (actual time=376.019..376.025 rows=35 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 10kB
-> Nested Loop (actual time=309.851..376.006 rows=35 loops=1)
-> Nested Loop (actual time=309.845..375.889 rows=35 loops=1)
-> Hash Join (actual time=309.809..375.767 rows=10 loops=1)
Hash Cond: (factures.numero_commande = commandes.numero_commande)
-> Seq Scan on factures (actual time=0.011..85.450 rows=1000000 loops=1)
-> Hash (actual time=205.640..205.644 rows=10 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 9kB
-> Hash Join (actual time=48.891..205.625 rows=10 loops=1)
Hash Cond: (commandes.client_id = cl.client_id)
-> Seq Scan on commandes (actual time=0.003..92.731 rows=1000000 loops=1)
-> Hash (actual time=27.823..27.826 rows=1 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 9kB
-> Nested Loop (actual time=16.526..27.823 rows=1 loops=1)
-> Hash Join (actual time=16.509..27.804 rows=1 loops=1)
Hash Cond: (cl.contact_id = ct.contact_id)
-> Seq Scan on clients cl (actual time=0.002..6.978 rows=100000 loops=1)
-> Hash (actual time=11.785..11.786 rows=1 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 9kB
-> Seq Scan on contacts ct (actual time=4.188..11.781 rows=1 loops=1)
Filter: ((login)::text = 'Beatty_Brahem'::text)
Rows Removed by Filter: 110004
-> Index Only Scan using types_clients_pkey on types_clients (actual time=0.013..0.013 rows=1 loops=1)
Index Cond: (type_client = cl.type_client)
Heap Fetches: 1
-> Index Scan using lignes_commandes_pkey on lignes_commandes lc (actual time=0.008..0.009 rows=4 loops=10)
Index Cond: (numero_commande = factures.numero_commande)
-> Index Scan using lots_pkey on lots l (actual time=0.002..0.002 rows=1 loops=35)
Index Cond: (numero_lot = lc.numero_lot_expedition)
-> Hash (actual time=0.008..0.008 rows=1 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 9kB
-> Seq Scan on transporteurs (actual time=0.006..0.007 rows=1 loops=1)
Filter: ((nom)::text = 'Royal Air Drone'::text)
Rows Removed by Filter: 4
Planning Time: 1.543 ms
Execution Time: 573.169 ms
(Plan complet sur https://explain.dalibo.com/plan/suz)
Le plan redevient très voisin du précédent, sans forcément être aussi optimal que celui ci-dessus. Mais l’inefficacité majeure est corrigée.
La conclusion de cette exercice est que, lorsque c’est possible, il
vaut mieux mettre en première jointure les tables portant les critères
les plus discriminants. Dans le cas où des requêtes contenant de
nombreuses jointures sont générées dynamiquement, qu’elles sont
fréquentes, et si le temps de planification est ridicule par rapport au
gain de l’exécution, alors il est envisageable de monter globalement
join_collapse_limit
(NB: il est aussi possible de
positionner ce paramètre sur le rôle de l’utilisateur ou encore sur les
paramètres de la base).
Un plan d’exécution est un arbre. Chaque nœud de l’arbre est une opération à effectuer par l’exécuteur. Le planificateur arrange les nœuds pour que le résultat final soit le bon, et qu’il soit récupéré le plus rapidement possible.
Il y a plusieurs types de nœuds :
Cet annexe a pour but d’entrer dans le détail de chaque type de nœuds, ses avantages et inconvénients.
Pour chacun il existe plusieurs variantes, par exemple les variantes parallélisées des parcours ou agrégats.
Les parcours sont les seules opérations qui lisent les données des tables, qu’elles soient standards, temporaires, non journalisées, une partition ou en fait une vue matérialisée. Des parcours sont dédiés aux lectures via un index, voire seulement dans l’index.
Les parcours ne prennent donc rien en entrée, et fournissent un ensemble de données en sortie. Cet ensemble peut être trié ou non, filtré ou non.
Il existe trois types de parcours que nous allons détailler :
Tous les trois pouvant recevoir des filtres supplémentaires en sortie.
LIMIT
sans ORDER BY
Le parcours le plus simple est le parcours séquentiel. La table est lue complètement, de façon séquentielle, par bloc de 8 ko (par défaut et en général). Les données sont lues dans l’ordre physique sur disque, donc les données ne sont pas envoyées triées au nœud supérieur. Cela fonctionne dans tous les cas, car il n’y a besoin de rien de plus pour le faire : un parcours de table ne nécessite rien de plus que la table, même sans index.
D’autres SGBD connaissent le Seq Scan sous le nom de Full Table Scan.
synchronize_seqscans
Le parcours de table est intéressant pour les performances dans deux cas :
Très petites tables :
Les très petites tables qui tiennent dans une poignée de blocs sont parcourues très vite. L’utilisation d’un index ne ferait pas gagner de temps.
Nombreuses lignes retournées :
Quand une partie significative d’une grosse table doit être retournée, les allers-retours entre la table et un index, et l’accès à l’index lui-même ne sont plus rentables. PostgreSQL peut alors juger qu’il vaut mieux lire toute la table et ignorer certaines lignes.
La ratio de lignes ramenées à partir duquel PostgreSQL bascule entre
un appel d’index et un Seq Scan dépend en bonne partie des
paramètres random_page_cost
et seq_page_cost
.
Sur un disque rotatif, les accès de blocs isolés sont beaucoup plus
coûteux que sur un SSD. Il dépend aussi d’autres choses comme la
corrélation physique, c’est-à-dire la relation entre la valeur de la
ligne dans la table et son emplacement sur disque. En effet, récupérer
une ligne implique de récupérer tout le bloc où elle se trouve. Si les
statistiques montrent que la corrélation est mauvaise, les lignes
satisfaisant un critère sont dispersées, et la chance de devoir lire
tous les blocs plus élevée.
Évidemment, si toute la table doit être lue sans filtrage, le Seq Scan demeure incontournable.
Exemples :
Voici quelques exemples à partir de ce jeu de tests :
CREATE TABLE t1 (c1 integer);
INSERT INTO t1 (c1) SELECT generate_series(1, 100000);
VACUUM ANALYZE t1;
SET jit TO off ; -- pour simplifier les plans suivants
Ici, nous faisons une lecture complète de la table. De ce fait, un parcours séquentiel sera plus rapide du fait de la rapidité de la lecture séquentielle des blocs :
QUERY PLAN
----------------------------------------------------------
Seq Scan on t1 (cost=0.00..1443.00 rows=100000 width=4)
Le coût est relatif au nombre de blocs lus, au nombre de lignes
décodées et à la valeur des paramètres seq_page_cost
et
cpu_tuple_cost
. Si un filtre est ajouté, cela aura un coût
supplémentaire dû à l’application du filtre sur toutes les lignes de la
table (pour trouver celles qui correspondent à ce filtre) :
QUERY PLAN
-----------------------------------------------------
Seq Scan on t1 (cost=0.00..1693.00 rows=1 width=4)
Filter: (c1 = 1000)
Ce coût supplémentaire dépend du nombre de lignes dans la table et de
la valeur du paramètre cpu_operator_cost
(défaut 0,0025) ou
de la valeur du paramètre COST
de la fonction appelée.
L’exemple ci-dessus montre le coût (1693) en utilisant l’opérateur
standard d’égalité. Maintenant, si on crée une fonction qui utilise cet
opérateur (écrite en PL/pgSQL, elle masque l’opérateur à l’optimiseur),
avec un coût forcé à 10 000, cela donne :
CREATE FUNCTION egal(integer,integer) RETURNS boolean
LANGUAGE plpgsql AS $$
BEGIN
RETURN $1 = $2;
END $$ COST 10000 ;
QUERY PLAN
------------------------------------------------------------
Seq Scan on t1 (cost=0.00..2501443.00 rows=33333 width=4)
Filter: egal(c1, 1000)
La ligne Filter indique le filtre réalisé. Le nombre de
lignes indiqué par rows=
est le nombre de lignes après
filtrage. Pour savoir combien de lignes ne satisfont pas le prédicat de
la clause WHERE
, il faut exécuter la requête et donc
utiliser l’option EXPLAIN
:
QUERY PLAN
-----------------------------------------------------
Seq Scan on t1 (cost=0.00..1693.00 rows=1 width=4) (actual time=0.093..6.032 rows=1 loops=1)
Filter: (c1 = 1000)
Rows Removed by Filter: 99999
Buffers: shared hit=443
Planning Time: 0.062 ms
Execution Time: 6.052 ms
La ligne Rows Removed by Filter
indique que 99 999
lignes ont été rencontrées et ignorées. La vision graphique du plan sur
explain.dalibo.com
affiche donc un avertissement sur ce taux élevé de rejet. Cela doit
faire envisager la création d’un index.
L’option BUFFERS
permet en plus de savoir le nombre de
blocs lus dans le cache (hit) et hors du cache,
(read). Les 443 blocs (3,5 Mo) de la table sont ici dans le
cache.
Le calcul réalisé pour le coût final est le suivant :
SELECT
round((
current_setting('seq_page_cost')::numeric*relpages +
current_setting('cpu_tuple_cost')::numeric*reltuples +
current_setting('cpu_operator_cost')::numeric*reltuples
)::numeric, 2)
AS cout_final
FROM pg_class
WHERE relname='t1';
Synchronisation des Seq Scan :
Si le paramètre synchronize_seqscans
est à
on
(et il l’est par défaut), le processus qui entame une
lecture séquentielle cherche en premier lieu si un autre processus ne
ferait pas une lecture séquentielle de la même table. Si c’est le cas,
le second processus démarre son parcours de table à l’endroit où le
premier processus est en train de lire, ce qui lui permet de profiter
des données mises en cache par ce processus. L’accès au disque étant
bien plus lent que l’accès mémoire, les processus restent naturellement
synchronisés pour le reste du parcours de la table, et les lectures ne
sont donc réalisées qu’une seule fois. Le début de la table restera à
être lu indépendamment. Cette optimisation permet de diminuer le nombre
de blocs lus par chaque processus en cas de lectures parallèles de la
même table.
Sans autre tri, l’ordre des lignes retournées peut donc différer
alors que les requêtes sont identiques et simultanées. Mais aucune
application ne doit supposer que les lignes sont dans un certain ordre
sans ORDER BY
explicite.
Parcours parallélisé :
Une nouvelle optimisation vient de la parallélisation apparue avec PostgreSQL 9.6. Le nœud devient un Parallel Seq Scan. Le processus responsable de la requête demande l’exécution de plusieurs processus, appelés workers, qui ont tous pour charge de lire une partie de la table et d’appliquer le filtre. Les nouveaux blocs lus depuis le disque sont enregistrés en mémoire partagée. Chaque worker travaille sur des blocs différents. Quand il a terminé de travailler sur un bloc, il consulte la mémoire partagée pour s’attribuer d’autres blocs à traiter. Il n’y a aucune assurance que chaque worker travaillera sur le même nombre de blocs au final. Voici un exemple de plan parallélisé pour un parcours de table :
CREATE TABLE tp AS
SELECT i AS c1, i AS c2 FROM generate_series (1,1_000_000) i ;
VACUUM ANALYZE tp ;
EXPLAIN (ANALYZE,BUFFERS)
SELECT sum(c1) FROM tp WHERE c1 BETWEEN 100000 AND 600000 ;
QUERY PLAN
-------------------------------------------------------------------------
Finalize Aggregate (cost=12246.29..12246.30 rows=1 width=8) (actual time=32.636..34.167 rows=1 loops=1)
Buffers: shared hit=2304 read=2176
I/O Timings: shared read=2.891
-> Gather (cost=12246.08..12246.29 rows=2 width=8) (actual time=32.580..34.162 rows=3 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=2304 read=2176
I/O Timings: shared read=2.891
-> Partial Aggregate (cost=11246.08..11246.09 rows=1 width=8) (actual time=22.147..22.147 rows=1 loops=3)
Buffers: shared hit=2304 read=2176
I/O Timings: shared read=2.891
-> Parallel Seq Scan on tp (cost=0.00..10730.00 rows=206431 width=4) (actual time=2.225..15.961 rows=166667 loops=3)
Filter: ((c1 >= 100000) AND (c1 <= 600000))
Rows Removed by Filter: 166666
Buffers: shared hit=2304 read=2176
I/O Timings: shared read=2.891
Planning:
Buffers: shared hit=8
Planning Time: 0.169 ms
Execution Time: 34.199 ms
La vision graphique est sur https://explain.dalibo.com/plan/c92a611h5g4b33cg.
Dans ce cas, le planificateur a prévu l’exécution de deux
workers, et deux ont bien été lancés lors de l’exécution de la
requête. Deux Parallel Scan apparaissent, mais le processus
principal participe aussi (par défaut), d’où la mention
loops=3
. Chacun des nœuds a en moyenne lu 2304 blocs dans
le cache, 2176 hors du cache, ignoré 166 666 lignes et conservé 206 431
lignes. Une vision plus étoffée avec le détail des workers peut
s’obtenir avec EXPLAIN (ANALYZE,BUFFERS,VERBOSE)
.
Le résultat de chaque Parallel Scan est récupéré par un Partial Aggregate, qui récupère les lignes et agrège au niveau d’un worker. Chaque Partial Aggregate renvoie 1 ligne de décompte (là encore 3 fois). Au-dessus, le nœud Gather est chargé de récupérer les 3 lignes des workers. La somme des agrégats partiels est réalisée tout en haut dans un nœud Finalize Aggregate qui renvoie l’unique ligne finale de résultat.
Partitionnement :
Un des intérêts du partitionnement est de favoriser les parcours complets sur un ensemble de données. Par exemple, un partitionnement par mois rendra optimales une requête agrégeant toutes les lignes d’un mois entier sans filtrage : il suffit de parcourir la partition (une table, en fait) du mois, sans index ni filtrage, et uniquement cette partition. Bien sûr, il faut choisir ce partitionnement en fonction des requêtes les plus sensibles.
Parcourir une table prend du temps, surtout quand on cherche à ne récupérer que quelques lignes de cette table. Le but d’un index est donc d’utiliser une structure de données optimisée pour satisfaire une recherche particulière (on parle de prédicat).
Sur PostgreSQL, l’index est un fichier séparé de la table. Il ne contient que les données indexées, triées, et l’emplacement des lignes où se trouvent ces données dans la table (bloc et ligne).
La donnée indexée peut être :
Dans le cas de loin le plus courant, l’index est de type B-tree. Cette structure est un arbre. La recherche consiste à suivre la structure de l’arbre pour trouver le premier enregistrement correspondant au prédicat, puis suivre les feuilles de l’arbre jusqu’au dernier enregistrement vérifiant le prédicat. Étant donné la façon dont l’arbre est stocké sur disque, les accès concernent des blocs éparpillés, et, sur un disque rotatif, cela peut provoquer de nombreux déplacements de la tête de lecture. De plus, les enregistrements sont habituellement éparpillés dans la table, et retournés dans un ordre totalement différent de leur ordre physique par le parcours sur l’index. Cet accès à la table génère donc énormément d’accès aléatoires. Or, ce type d’activité est généralement le plus lent sur un disque magnétique. C’est pourquoi le parcours d’une large portion d’un index est très lent. PostgreSQL ne cherchera à utiliser un index que s’il suppose qu’il aura peu de lignes à récupérer.
Un autre problème des performances sur les index, spécifique à PostgreSQL, est que les informations de visibilité des lignes sont uniquement stockées dans la table. Quand une ligne est marquée comme effacée dans la table, l’index conserve un temps le pointeur vers cette ligne (il peut être nettoyé bien plus tard). Cela veut dire que, pour chaque élément de l’index correspondant au filtre, il faut lire la ligne dans la table pour vérifier qu’elle est bien visible pour la transaction en cours. Ce n’est pas vraiment un problème dans le cas général, où le but de la requête est justement de récupérer ces lignes, et les autres colonnes demandées, généralement absentes de l’index. Mais dans les cas où l’index contient déjà toute l’information pour satisfaire le filtre, l’accès à la table semble inutile. Nous verrons que l’Index Only Scan existe pour traiter ce cas précis.
Voici l’algorithme permettant un parcours d’index avec PostgreSQL :
Un Index Scan ne consiste donc pas qu’à consulter l’index, mais aussi la table, et à opérer des filtres supplémentaires sur les données récupérées dans la table.
Les autres types d’index connus de PostgreSQL (GiST, hash, GIN…) reposent généralement sur une variante de ce principe, chacun avec sa spécificité.
ORDER BY
…)Un parcours d’index est donc très coûteux, principalement à cause des déplacements de la tête de lecture. Un parcours d’index n’est préférable à un parcours de table que si la recherche ne va ramener qu’un faible pourcentage de la table. Comme PostgreSQL calcule le nombre de blocs, la corrélation physique a aussi une influence.
Le gain possible est très important par rapport à un parcours
séquentiel de table. Par contre, un parcours d’index se révèle très lent
pour lire un gros pourcentage de la table. Les accès aléatoires
diminuent spectaculairement les performances sur les disques rotatifs
(par défaut, le paramètre random_page_cost
, lié au coût de
lecture aléatoire d’une page est 4 fois supérieur à celui de la lecture
séquentielle d’une page, seq_page_cost
). La situation est
bien meilleure sur les disques SSD et NVMe récents mais le principe
reste le même.
Il est à noter que, contrairement au parcours de table, le parcours
d’index renvoie toujours les données triées. C’est le seul parcours à le
faire. Il permet donc d’optimiser la clause ORDER BY
d’une
requête, et d’éviter un tri des résultats, parfois très lourd. L’index
est aussi utilisable dans le cas des tris descendants, et dans ce cas,
le nœud est nommé Index Scan Backward (sens inverse).
Attention, l’ORDER BY
doit être dans le même ordre que les
champs de l’index pour être optimal.
Cet index peut aussi favoriser l’utilisation d’autres nœuds qui savent utiliser des données triées, en premier lieu les jointures par Merge Join, ou des agrégations.
Il ne faut pas oublier aussi le coût de mise à jour de l’index. Si un index n’est pas utilisé, il coûte cher en maintenance (ajout des nouvelles entrées, suppression des entrées obsolètes, place sur le disque et le cache, etc.). En général, ajouter un index reste « rentable» puisqu’il accélère les lectures, voire les écritures (sur l’index). Mais il faut vérifier que l’on n’a pas des index inutiles, en double (PostgreSQL le permet) ou redondants, et l’on n’indexera pas « à tout hasard » des champs.
Enfin, il est à noter que ce type de parcours est aussi consommateur en CPU.
Voici un exemple montrant les deux types de parcours et ce que cela occasionne comme lecture disque. Commençons par créer une toute petite table avec quelques données et un index :
DROP TABLE IF EXISTS t1 ;
CREATE TABLE t1 (c1 integer, c2 integer);
INSERT INTO t1 VALUES (1,2), (2,4), (3,6);
CREATE INDEX i1 ON t1(c1);
VACUUM ANALYZE t1;
Essayons maintenant de lire la table avec un simple parcours séquentiel :
QUERY PLAN
--------------------------------------------------
Seq Scan on t1 (cost=0.00..1.04 rows=1 width=8) (actual time=0.012..0.013 rows=1 loops=1)
Filter: (c1 = 2)
Rows Removed by Filter: 2
Buffers: shared hit=1
Planning:
Buffers: shared hit=13 read=1
I/O Timings: shared read=0.013
Planning Time: 0.199 ms
Execution Time: 0.031 ms
Nous obtenons un parcours séquentiel (Seq Scan). En effet,
la table est tellement petite (elle tient dans un bloc de 8 ko)
qu’utiliser l’index coûterait forcément plus cher. Grâce à l’option
BUFFERS
, nous savons d’ailleurs que seul un bloc a été lu.
À titre purement expérimental, pour forcer un parcours d’index, nous allons désactiver les parcours séquentiels et réinitialiser ses statistiques :
Il existe aussi un paramètre, appelé enable_indexscan
,
pour désactiver les parcours d’index, à titre expérimental.
Maintenant relançons la requête :
QUERY PLAN
-------------------------------------------------------------
Index Scan using i1 on t1 (cost=0.13..8.15 rows=1 width=8) (actual time=0.055..0.057 rows=1 loops=1)
Index Cond: (c1 = 2)
Buffers: shared hit=1 read=1
I/O Timings: shared read=0.010
Planning Time: 0.091 ms
Execution Time: 0.077 ms
Nous avons bien un parcours d’index. Vérifions les statistiques sur l’activité :
SELECT relname, heap_blks_read, heap_blks_hit,
idx_blks_read, idx_blks_hit
FROM pg_statio_user_tables
WHERE relname='t1';
relname | heap_blks_read | heap_blks_hit | idx_blks_read | idx_blks_hit
---------+----------------+---------------+---------------+--------------
t1 | 0 | 1 | 0 | 2
Deux blocs ont été lus dans l’index (colonnes
idx_blks_*
) et un autre a été lu dans la table (colonnes
heap_blks_*
à 1). Le plus impactant est l’accès aléatoire
sur l’index et la table. Ce n’est pas un souci avec peu de lignes, mais
avec de gros volumes il serait bon d’avoir une lecture de l’index, puis
une lecture séquentielle de la table. C’est le but du Bitmap Index
Scan.
D’autres SGBD connaissent des index de type « bitmap », mais pas PostgreSQL. Celui-ci crée des structures bitmap en mémoire lorsque son utilisation a un intérêt. Cela se manifeste par le couple de nœuds Bitmap Index Scan et Bitmap Heap Scan.
Le principe est de diminuer les déplacements de la tête de lecture en découplant le parcours de l’index du parcours de la table. Même avec un SSD, cette méthode évite d’aller chercher trop souvent les mêmes blocs et améliore l’utilisation du cache.
Son principe est le suivant :
Les parcours d’index bitmap sont utilisables sur les B-tree bien sûr, mais aussi sur d’autres types d’index comme GIN, GiST, BRIN… C’est parfois le seul parcours disponible, certains types ayant de toute façon besoin d’une phase de recheck des lignes trouvées dans la table.
Un bitmap est souvent utilisé quand il y a un grand nombre de valeurs
à filtrer, notamment pour les clauses IN
et
ANY
.
Ce type d’index présente un autre gros intérêt : pouvoir combiner
plusieurs index en mémoire. Les bitmaps de TID obtenus se combinent
facilement avec des opérations booléennes AND
et
OR
.
Exemple :
Cet exemple utilise PostgreSQL 17 dans sa configuration par défaut. La table suivante possède trois champs indexés susceptibles de servir de critères de recherche :
DROP TABLE IF EXISTS tbt ;
CREATE UNLOGGED TABLE tbt
(i int GENERATED ALWAYS AS IDENTITY PRIMARY KEY, j int, k int, t text) ;
INSERT INTO tbt (j,k,t)
SELECT (i / 1000) , i / 777, chr (64+ (i % 58))
FROM generate_series(1,10000000) i ;
CREATE INDEX tbt_j_idx ON tbt (j) ;
CREATE INDEX tbt_k_idx ON tbt (k) ;
CREATE INDEX tbt_t_idx ON tbt (t) ;
VACUUM ANALYZE tbt ;
Lors de la recherche sur plusieurs critères, les lignes renvoyées par les Bitmap Index Scan peuvent être combinées :
-- pour la lisibilité des plans
SET max_parallel_workers_per_gather TO 0 ;
SET jit TO off ;
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS)
SELECT i, j, k, t FROM tbt
WHERE j = 8
AND k = 10
AND t = 'a';
QUERY PLAN
-------------------------------------------------------------------------------
Bitmap Heap Scan on public.tbt (cost=22.97..26.99 rows=1 width=14) (actual time=0.096..0.163 rows=9 loops=1)
Output: i, j, k, t
Recheck Cond: ((tbt.k = 10) AND (tbt.j = 8))
Filter: (tbt.t = 'a'::text)
Rows Removed by Filter: 538
Heap Blocks: exact=4
Buffers: shared read=11
I/O Timings: shared read=0.028
-> BitmapAnd (cost=22.97..22.97 rows=1 width=0) (actual time=0.075..0.075 rows=0 loops=1)
Buffers: shared read=7
I/O Timings: shared read=0.019
-> Bitmap Index Scan on tbt_k_idx (cost=0.00..10.60 rows=822 width=0) (actual time=0.039..0.039 rows=777 loops=1)
Index Cond: (tbt.k = 10)
Buffers: shared read=4
I/O Timings: shared read=0.011
-> Bitmap Index Scan on tbt_j_idx (cost=0.00..12.12 rows=1025 width=0) (actual time=0.034..0.034 rows=1000 loops=1)
Index Cond: (tbt.j = 8)
Buffers: shared read=3
I/O Timings: shared read=0.008
Settings: search_path = '"$user", public, topology', max_parallel_workers_per_gather = '0', jit = 'off'
Query Identifier: 6107944285316481754
Planning:
Buffers: shared hit=48 read=1
I/O Timings: shared read=0.004
Planning Time: 0.224 ms
Execution Time: 0.181 ms
Dans le plan précédent :
k
vaut 10), l’autre 1000 lignes (où j
vaut
8) ;t
est ignoré : il y a trop de
lignes avec cette valeur (un décompte en trouverait 172 414), et surtout
dispersées dans toute la table ;t = 'a'
: c’est le rôle de la clause Filter,
qui écarte 538 lignes et n’en garde que 9.Clause OR :
Les index sont également utiles avec une clause OR
:
QUERY PLAN
-------------------------------------------------------------------------------
Bitmap Heap Scan on tbt (cost=1997.64..59048.55 rows=171163 width=14) (actual time=26.325..166.924 rows=173623 loops=1)
Recheck Cond: ((j = 8) OR (k = 10) OR (t = 'a'::text))
Heap Blocks: exact=54054
Buffers: shared hit=11 read=54198 written=10709
I/O Timings: shared read=60.286 write=19.675
-> BitmapOr (cost=1997.64..1997.64 rows=171195 width=0) (actual time=18.383..18.384 rows=0 loops=1)
Buffers: shared hit=7 read=148
I/O Timings: shared read=0.398
-> Bitmap Index Scan on tbt_j_idx (cost=0.00..12.17 rows=1032 width=0) (actual time=0.040..0.040 rows=1000 loops=1)
Index Cond: (j = 8)
Buffers: shared hit=3
-> Bitmap Index Scan on tbt_k_idx (cost=0.00..10.68 rows=832 width=0) (actual time=0.030..0.030 rows=777 loops=1)
Index Cond: (k = 10)
Buffers: shared hit=4
-> Bitmap Index Scan on tbt_t_idx (cost=0.00..1846.42 rows=169331 width=0) (actual time=18.312..18.312 rows=172414 loops=1)
Index Cond: (t = 'a'::text)
Buffers: shared read=148
I/O Timings: shared read=0.398
Planning:
Buffers: shared hit=107 read=4
I/O Timings: shared read=0.038
Planning Time: 0.526 ms
Execution Time: 170.909 ms
Ce plan utilise cette fois les trois index.
Au final, le Bitmap Heap Scan lit quand même toute la table,
qui fait 54 054 blocs ! En effet, il y a des t='a'
dans
tous les blocs (c’est le cas le plus défavorable).
Cependant, 98 % des comparaisons de critères sont tout de même évitées. Répéter le plan après avoir tapé ceci :
mènerait à un parcours de table séquentiel trois fois plus long. Réactiver la parallélisation donnerait un plan parallélisé encore un peu moins efficace. Ne pas oublier d’annuler ce paramétrage ensuite :
IN
, ANY
AND
/OR
LIMIT
work_mem
(sinon lossy)effective_cache_size
effective_io_concurrency
Avec des B-tree, l’intérêt peut être énorme s’il y a une certaine corrélation des données avec l’emplacement physique. Il permet de réduire les accès aléatoires, ce qui est intéressant avec des disques qui ont un bon débit mais une mauvaise latence (disques mécaniques, baie surchargée).
Le coût de démarrage est généralement important à cause de la lecture
préalable de l’index et du tri des TID. Ce type de parcours est donc
moins intéressant quand on recherche un coût de démarrage faible (clause
LIMIT
, curseur…). Un parcours d’index simple sera
généralement choisi dans ce cas.
Le paramètre enable_bitmapscan
permet d’activer ou de
désactiver l’utilisation des parcours d’index bitmap, à titre
expérimental.
work_mem :
Si le paramètre work_mem
est trop bas, PostgreSQL n’a
plus la place de stocker un bit par ligne dans son tableau, mais utilise
un bit par page. La mention lossy apparaît alors sur la ligne
Heap Blocks, et toutes les lignes de la page doivent être
vérifiées lors de la phase Recheck. Avec la requête précédente,
la performance est cette fois bien pire qu’un parcours complet :
SET work_mem TO '256kB' ;
EXPLAIN (ANALYZE,BUFFERS, COSTS)
SELECT i, j, k, t FROM tbt
WHERE j = 8
OR k = 10
OR t = 'a';
QUERY PLAN
-------------------------------------------------------------------------------
Bitmap Heap Scan on tbt (cost=1997.64..224533.50 rows=171163 width=14) (actual time=11.596..756.488 rows=173623 loops=1)
Recheck Cond: ((j = 8) OR (k = 10) OR (t = 'a'::text))
Rows Removed by Index Recheck: 9350021
Heap Blocks: exact=2620 lossy=51434
Buffers: shared read=54209
I/O Timings: shared read=59.028
-> BitmapOr (cost=1997.64..1997.64 rows=171195 width=0) (actual time=10.811..10.813 rows=0 loops=1)
Buffers: shared read=155
I/O Timings: shared read=0.360
-> Bitmap Index Scan on tbt_j_idx (cost=0.00..12.17 rows=1032 width=0) (actual time=0.622..0.622 rows=1000 loops=1)
Index Cond: (j = 8)
Buffers: shared read=3
I/O Timings: shared read=0.015
-> Bitmap Index Scan on tbt_k_idx (cost=0.00..10.68 rows=832 width=0) (actual time=0.037..0.038 rows=777 loops=1)
Index Cond: (k = 10)
Buffers: shared read=4
I/O Timings: shared read=0.012
-> Bitmap Index Scan on tbt_t_idx (cost=0.00..1846.42 rows=169331 width=0) (actual time=10.150..10.150 rows=172414 loops=1)
Index Cond: (t = 'a'::text)
Buffers: shared read=148
I/O Timings: shared read=0.334
Planning Time: 0.108 ms
Execution Time: 760.607 ms
effective_cache_size :
Les nœuds Bitmap Scan sont favorisés par une valeur
importante du paramètre effective_cache_size
, qui estime la
quantité totale de cache disponible.
effective_io_concurrency :
Ce paramètre influe notablement sur la vitesse d’exécution des
Bitmap Scan, entre autres.
effective_io_concurrency
a pour but d’indiquer le nombre
d’opérations disques possibles en même temps pour un client
(prefetch). Il n’a d’intérêt que si un nœud Bitmap
Scan a été choisi. Cela n’arrive qu’avec un certain nombre de
lignes à récupérer, et est favorisé par une valeur importante de
effective_cache_size
et un peu de corrélation physique dans
la table.
La valeur d’effective_io_concurrency
n’influe pas sur le
choix du plan, mais sa valeur peut notablement accélérer l’exécution du
Bitmap Heap Scan. Le temps de lecture peut fréquemment être
divisé par 3 ou plus.
Les valeurs possibles d’effective_io_concurrency
vont de
0 à 1000. En principe, sur un disque magnétique seul, la valeur 1 ou 0
peut convenir. Avec du SSD, et encore plus du NVMe, il est possible de
monter à plusieurs centaines, étant donné la rapidité de ce type de
disque. Trouver la bonne valeur dépend de divers paramètres liés aux
caractérisques exactes des disques et de leur paramétrage noyau. Le
read ahead du noyau intervient également. Le comportement de
PostgreSQL sur ce point change aussi avec les versions. De plus, à
partir d’un certain nombre de blocs, les I/O peuvent simplement saturer.
La valeur par défaut de 1 (jusque PostgreSQL 17) a été choisie de manière très conservatrice. Les développeurs ont décidé que la valeur 16 est plus intéressante, même sur de vieux disques, et que ce sera le défaut à partir de PostgreSQL 18, qui inclut d’autres améliorations.
À l’inverse, la mauvaise latence de certains systèmes aux disques en
principe performants (baies surchargées, certains stockages cloud…) peut
parfois être compensée par un effective_io_concurrency
plus
élevé.
Il faut tester avec vos requêtes qui utilisent des Bitmap Heap
Scan, et tenir compte du nombre de requêtes simultanées, mais
effective_io_concurrency = 16
semble un bon point de
départ, même sur une configuration modeste. Montez si vous avez de bons
disques.
Une valeur excessive de effective_io_concurrency
mène à
un gaspillage de CPU : ne pas monter trop haut sans preuve de l’utilité,
surtout sur un système très chargé.
Pour les systèmes RAID matériels, selon la documentation, configurer ce paramètre en fonction du nombre n de disques utiles dans le RAID (n/2 s’il s’agit d’un RAID 1, n-1 s’il s’agit d’un RAID 5 ou 6, n/2 s’il s’agit d’un RAID 10).
(Avant la version 13, le principe
d’effective_io_concurrency
était le même, mais la valeur
exacte de ce paramètre devait être 2 à 5 fois plus basse comme le
précise la formule des notes de version de la
version 13.)
Répétons que les informations de visibilité d’une ligne sont stockées dans la table, pas dans l’index. C’est dommage pour la requête précédente, qui affiche juste le champ de la colonne indexée : ce qui est trouvé dans l’index suffit à répondre à la requête (il n’y a que le champ qui sert au filtrage). Il y a donc potentiellement des allers-retours inutiles vers la table.
Or, PostgreSQL maintient une visibility map des blocs totalement visibles, où le contrôle de visibilité est superflu. Un nœud spécifique, Index Only Scan, sait en tirer parti.
Voici un exemple sous PostgreSQL 9.1 (sorti en 2010), qui n’avait pas encore d’optimisation sur ce point. Un index est créé sur deux colonnes de la table, et la requête ne porte que sur ces deux colonnes :
DROP TABLE IF EXISTS demo_i_o_scan ;
-- Table d'un million de lignes
CREATE TABLE demo_i_o_scan (a int, b text, c text, d text) ;
INSERT INTO demo_i_o_scan
SELECT random()*10000000, i, i, i
FROM generate_series(1,10000000) i ;
-- Index sur deux champs
CREATE INDEX demo_idx ON demo_i_o_scan (a,b);
VACUUM ANALYZE demo_i_o_scan ;
-- Sélection sur un de ces champs, affichage du deuxième
EXPLAIN (ANALYZE,BUFFERS) SELECT b,a FROM demo_i_o_scan
WHERE a BETWEEN 10000 AND 100000 ;
QUERY PLAN
--------------------------------------------------------------------------------
Bitmap Heap Scan on demo_i_o_scan (cost=1205.00..56486.01 rows=85401 width=11) (actual time=10.058..436.445 rows=90118 loops=1)
Recheck Cond: ((a >= 10000) AND (a <= 100000))
Buffers: shared hit=2 read=52307 written=3964
-> Bitmap Index Scan on demo_idx (cost=0.00..1183.65 rows=85401 width=0) (actual time=8.688..8.688 rows=90118 loops=1)
Index Cond: ((a >= 10000) AND (a <= 100000))
Buffers: shared hit=2 read=346 written=318
Total runtime: 438.209 ms
Si l’on reproduit la même requête en 9.2 :
DROP TABLE IF EXISTS demo_i_o_scan ;
-- Table d'un million de lignes
CREATE TABLE demo_i_o_scan (a int, b text, c text, d text) ;
INSERT INTO demo_i_o_scan
SELECT random()*10000000, i, i, i
FROM generate_series(1,10000000) i ;
-- Index sur deux champs
CREATE INDEX demo_idx ON demo_i_o_scan (a,b);
VACUUM ANALYZE demo_i_o_scan ;
-- Sélection sur un de ces champs, affichage du deuxième
EXPLAIN (ANALYZE,BUFFERS) SELECT b,a FROM demo_i_o_scan
WHERE a BETWEEN 10000 AND 100000 ;
QUERY PLAN
--------------------------------------------------------------------------------
Index Only Scan using demo_idx on demo_i_o_scan (cost=0.00..2121.46 rows=88891 width=11) (actual time=0.013..8.847 rows=90167 loops=1)
Index Cond: ((a >= 10000) AND (a <= 100000))
Heap Fetches: 0
Buffers: shared hit=17591 read=346 written=264
Total runtime: 10.754 ms
On a donc un facteur 40 d’accélération (ici sur une machine moderne avec SSD, mais c’est au moins aussi intéressant sur des disques magnétiques avec une latence bien supérieure). La réduction du nombre de blocs lus (44 108 contre 347) explique en bonne partie le gain en temps. En effet, seul l’index est lu à présent. PostgreSQL 17 ne fait pas mieux.
Passer le enable_indexonlyscan
à off
désactive le nœud Index Only Scan, et revient au comportement
de la 9.1.
VACUUM
fréquentLes nœuds Index Only Scan sont donc très intéressants pour toutes les requêtes avec peu de colonnes qui pourraient tenir dans un index.
Des index dits « couvrants », contenant d’abord les colonnes de filtrage, puis celles à retourner, peuvent être créés à destination de requêtes critiques afin de favoriser l’Index Only Scan.
Par contre, l’Index Only Scan est très sensible au nombre de lignes « mortes ». Si l’on modifie 10 % de la table et que l’on relance la requête avant qu’un autovacuum ait le temps de passer, le temps d’exécution devient bien moins bon :
UPDATE demo_i_o_scan SET c = 42 WHERE a > 900000;
EXPLAIN (ANALYZE,BUFFERS) SELECT b,a FROM demo_i_o_scan
WHERE a BETWEEN 10000 AND 100000 ;
QUERY PLAN
--------------------------------------------------------------------------------
Index Only Scan using demo_idx on demo_i_o_scan (cost=0.44..180640.78 rows=159025 width=11) (actual time=0.022..580.623 rows=89710 loops=1)
Index Cond: ((a >= 10000) AND (a <= 100000))
Heap Fetches: 89710
Buffers: shared hit=70014 read=72409 dirtied=51877 written=60718
I/O Timings: shared read=101.754 write=141.870
Planning:
Buffers: shared hit=6 read=4 dirtied=1 written=4
I/O Timings: shared read=0.010 write=0.017
Planning Time: 0.116 ms
Execution Time: 583.106 ms
La version graphique montre bien la présence de 89 710 heap fetches, c’est-à-dire de contrôles de visibility de la ligne dans la table. Cela concerne la moitié des lignes récupérées.
Certes, les lignes retournées ne sont pas celles modifiées. Mais
elles sont mélangées (la corrélation entre a
et
l’emplacement physique est ici nulle) et de nombreux blocs contiennent
les unes et les autres, et ne sont donc pas intégralement visibles, d’où
les rechecks.
Si une requête critique dépend d’un Index Only Scan, il faut
s’assurer que l’autovacuum passe très souvent, et/ou planifier des
VACUUM
très fréquents sur la table.
Références :
Il existe d’autres parcours, bien moins fréquents ceci dit.
TID Scan :
TID est l’acronyme de tuple ID. C’est en quelque sorte un
pointeur vers une ligne (ou ctid
). Ce type de parcours est
exceptionnel en applicatif (et généralement une très mauvaise idée
puisqu’il généralement utilisé en interne par PostgreSQL.
QUERY PLAN
----------------------------------------------------------
Tid Scan on pg_class (cost=0.00..4.01 rows=1 width=674)
TID Cond: (ctid = '(1,1)'::tid)
Il est possible de le désactiver via le paramètre
enable_tidscan
.
Function Scan :
Un Function Scan est utilisé par les fonctions renvoyant des
ensembles de lignes (appelées SRF pour Set Returning
Functions). Vous pouvez écrire les vôtres mais PostgreSQL en
fournit de nombreuses, par exemple generate_series()
:
QUERY PLAN
------------------------------------------------------------------------
Function Scan on pg_catalog.generate_series (cost=0.00..10000.00 rows=1000000 width=4) (actual time=61.888..103.660 rows=1000000 loops=1)
Output: generate_series
Function Call: generate_series(1, 1000000)
Buffers: temp read=1709 written=1709
I/O Timings: temp read=1.972 write=6.779
Query Identifier: -3923767323822397746
Planning Time: 0.051 ms
Execution Time: 125.887 ms
Noter que la génération de la fonction a donné lieu à l’utilisation
d’un fichier temporaire. Pour l’éviter, il faut monter
work_mem
assez haut.
Values :
VALUES
est une clause de l’instruction
INSERT
, mais VALUES
peut aussi être utilisé
comme une table dont on spécifie les valeurs. Par exemple :
Un alias permet de nommer les champs :
Le planificateur utilise un nœud spécial appelé Values Scan pour indiquer un parcours sur cette clause :
QUERY PLAN
--------------------------------------------------------------
Values Scan on "*VALUES*" (cost=0.00..0.04 rows=3 width=36)
Result :
Enfin, le nœud Result n’est pas à proprement parler un nœud de type parcours. Il y ressemble dans le fait qu’il ne prend aucun ensemble de données en entrée et en renvoie un en sortie. Son but est de renvoyer un ensemble de données suite à un calcul. Par exemple :
Le but d’une jointure est de grouper deux ensembles de données pour n’en produire qu’un seul. Une jointure se fait toujours entre deux ensembles de données, jamais plus. Dans le cas le plus simple, elle joint deux ensembles issus de parcours de table, d’index… mais aussi des ensembles issus eux-mêmes de jointures, agrégats, etc.
L’un des ensembles est appelé ensemble interne (inner set), l’autre est appelé ensemble externe (outer set).
Le planificateur de PostgreSQL est capable de traiter les jointures grâce à trois nœuds :
Ils possèdent des variantes, notamment leurs versions parallélisées.
« Boucles imbriquées »
Les boucles imbriquées, ou nested loops, sont la manière la plus simple d’opérer une jointure entre deux ensembles issus de parcours de tables, de parcours d’index, ou de n’importe quels autres nœuds.
Un des deux ensembles, souvent le plus gros, est pris comme « relation externe », Il est parcouru une seule fois. Pour chacune de ses lignes, PostgreSQL recherche les lignes correspondantes à la condition de jointure dans l’autre ensemble. Cela peut être rapide (toute petite table, index pertinent…), ou très lent (parcours complet de la table sans index utilisable).
Le paramètre enable_nestloop
permet d’activer ou de
désactiver ce type de nœud à titre expérimental.
Prenons un exemple avec trois tables, dont une table de commandes, pointant vers des clients et une toute petite table de statuts de commandes. Il n’y a pas d’index, à part ceux automatiquement ajoutés sur les clés primaires des trois tables.
DROP TABLE IF EXISTS commandes ;
DROP TABLE IF EXISTS statuts ;
DROP TABLE IF EXISTS clients ;
CREATE TABLE statuts ( sid int PRIMARY KEY,
statut text) ;
INSERT INTO statuts VALUES (0,'à faire'),
(1,'en cours'),
(2,'archivé');
CREATE TABLE clients (clid int PRIMARY KEY, nom text) ;
INSERT INTO clients SELECT i, 'Client '||i
FROM generate_series (0,100) i ;
CREATE TABLE commandes (cid int PRIMARY KEY,
sid int REFERENCES statuts,
clid int REFERENCES clients,
montant float
) ;
INSERT INTO commandes
SELECT i, mod (i,2), mod (i,99), 3.14*i
FROM generate_series (1,100000) i ;
VACUUM ANALYZE statuts, clients, commandes ;
Exemple 1 :
Cette première requête sélectionne une partie des commandes, puis opère une jointure pour obtenir le statut :
EXPLAIN (ANALYZE) SELECT cid, montant, statut
FROM commandes INNER JOIN statuts USING (sid)
WHERE montant > 313000 ;
QUERY PLAN
-------------------------------------------------------------------------------
Nested Loop (cost=0.00..1900.23 rows=325 width=21) (actual time=7.942..8.113 rows=319 loops=1)
Join Filter: (statuts.sid = commandes.sid)
Rows Removed by Join Filter: 159
-> Seq Scan on commandes (cost=0.00..1887.00 rows=325 width=16) (actual time=7.924..7.968 rows=319 loops=1)
Filter: (montant > '313000'::double precision)
Rows Removed by Filter: 99681
-> Materialize (cost=0.00..1.04 rows=3 width=13) (actual time=0.000..0.000 rows=1 loops=319)
-> Seq Scan on statuts (cost=0.00..1.03 rows=3 width=13) (actual time=0.007..0.008 rows=2 loops=1)
Planning Time: 0.390 ms
Execution Time: 8.158 ms
La vision graphique est la suivante :
Le Seq Scan sur commandes
parcourt la table une
fois et trouve 319 lignes. La jointure Nested Loops prend ce
résultat comme ensemble externe et appelle donc 319 fois l’ensemble
interne (loops=319
), ramenant une ligne en moyenne à chaque
fois. L’ensemble interne n’est pas directement la petite table
statuts
, mais une version en mémoire préalablement obtenue
avec un nœud Materialize.
Exemple 2 :
Pour les commandes de client d’identifiant 1, afficher les informations sur les clients :
QUERY PLAN
-------------------------------------------------------------------------------
Nested Loop (cost=0.00..1898.59 rows=933 width=29) (actual time=0.028..8.762 rows=1011 loops=1)
-> Seq Scan on clients (cost=0.00..2.26 rows=1 width=13) (actual time=0.018..0.025 rows=1 loops=1)
Filter: (clid = 1)
Rows Removed by Filter: 100
-> Seq Scan on commandes (cost=0.00..1887.00 rows=933 width=20) (actual time=0.007..8.564 rows=1011 loops=1)
Filter: (clid = 1)
Rows Removed by Filter: 98989
Planning Time: 0.380 ms
Execution Time: 8.870 ms
Noter que le filtre clid = 1
porte sur la condition de
jointure, donc PostgreSQL peut l’appliquer aux deux Seq Scan.
L’ensemble externe porte sur clients
, et chaque ligne
trouvée est cherchée dans commandes
. C’est à première vue
un peu étonnant, on s’attendrait à avoir la plus grosse table en table
externe. Cependant, PostgreSQL a correctement estimé qu’il ne ramène
qu’une ligne de clients
, et la table commandes
n’est donc parcourue qu’une fois (loops=1
). Dans ce cas
précis, commencer par l’une ou l’autre table est jugé équivalent par
PostgreSQL.
Exemple 3 :
Il est généralement conseillé d’indexer les clés étrangères, on ajoute donc :
La requête suivante récupère les commandes d’un client identifié par son nom :
EXPLAIN (ANALYZE) SELECT *
FROM commandes INNER JOIN clients USING (clid)
WHERE clients.nom = 'Client 1' ;
QUERY PLAN
-------------------------------------------------------------------------------
Nested Loop (cost=12.12..701.01 rows=990 width=29) (actual time=0.344..1.553 rows=1011 loops=1)
-> Seq Scan on clients (cost=0.00..2.26 rows=1 width=13) (actual time=0.014..0.025 rows=1 loops=1)
Filter: (nom = 'Client 1'::text)
Rows Removed by Filter: 100
-> Bitmap Heap Scan on commandes (cost=12.12..688.65 rows=1010 width=20) (actual time=0.325..1.365 rows=1011 loops=1)
Recheck Cond: (clid = clients.clid)
Heap Blocks: exact=637
-> Bitmap Index Scan on commandes_clid_idx (cost=0.00..11.87 rows=1010 width=0) (actual time=0.145..0.145 rows=1011 loops=1)
Index Cond: (clid = clients.clid)
Planning Time: 0.435 ms
Execution Time: 1.637 ms
La vision graphique est sur explain.dalibo.com.
L’ensemble externe provient de clients
, après récupération
d’une seule ligne. L’ensemble interne est la table
commandes
. Pour y trouver les lignes avec le
clid
nécessaire, PostgreSQL utilise un Bitmap Scan
sur l’index sur clid
, qu’il estime plus efficace que 1011
appels dans le même index.
Le coût d’un nœud Nested Loop est proportionnel à la taille des ensembles. Il est donc intéressant pour les petits ensembles, et encore plus lorsque l’ensemble interne dispose d’un index satisfaisant la condition de jointure.
En théorie, il s’agit du type de jointure le plus lent. Mais il a un
gros intérêt : il n’est pas nécessaire de trier les données (comme pour
un Merge Join) ou de les hacher (comme pour un Hash
Join) avant de commencer à traiter les données. Le coût de
démarrage est donc très faible. Il est ainsi favorisé pour un petit
ensemble externe, ou une clause LIMIT
. Dès que le nombre de
lignes augmente, PostgreSQL bascule sur d’autres types de nœuds.
Nested Loop est aussi le seul nœud capable de traiter des
jointures sur des conditions différentes de l’égalité ainsi que des
jointures de type CROSS JOIN
.
Jointure d’ensembles triés
L’algorithme est assez simple. Le prérequis est que les deux ensembles de données doivent être triés. Ils sont parcourus ensemble. Lorsque la condition de jointure est vraie, la ligne résultante est envoyée dans l’ensemble de données en sortie.
Voici un exemple basé sur une jointure entre une table des billets et une table des commentaires d’une plate-forme de blog.
DROP TABLE IF EXISTS commentaires ;
DROP TABLE IF EXISTS billets ;
CREATE TABLE billets (bid bigint PRIMARY KEY,
d timestamptz,
t text) ;
CREATE TABLE commentaires (cid bigint PRIMARY KEY,
bid bigint REFERENCES billets,
d timestamptz,
t text) ;
INSERT INTO billets (bid, d, t)
SELECT i,
'2010-01-01'::date + i*interval '1h',
lpad('lorem ipsum',4000,'x')
FROM generate_series (1,10_000) i ;
INSERT INTO commentaires (cid, bid, d, t)
SELECT i,
1+i/3,
'2010-01-01'::date + (i/3)*interval '1h',
lpad('lorem ipsum',300,'x')
FROM generate_series (4,30_000-3) i ;
-- Index sur clé étrangère
CREATE INDEX ON commentaires (bid) ;
VACUUM ANALYZE billets,commentaires;
EXPLAIN (ANALYZE)
SELECT *
FROM billets INNER JOIN commentaires USING (bid)
WHERE billets.d > '2010-06-30'::date
ORDER BY bid ;
QUERY PLAN
-------------------------------------------------------------------------------
Merge Join (cost=0.62..2600.41 rows=14996 width=397) (actual time=2.115..5.705 rows=14998 loops=1)
Merge Cond: (billets.bid = commentaires.bid)
-> Index Scan using billets_pkey on billets (cost=0.29..222.78 rows=5000 width=81) (actual time=0.010..0.409 rows=5000 loops=1)
Index Cond: (bid > 5000)
-> Index Scan using commentaires_bid_idx on commentaires (cost=0.29..2140.18 rows=29992 width=324) (actual time=0.005..3.404 rows=29994 loops=1)
Filter: (d < '2012-12-31'::date)
Planning Time: 0.234 ms
Execution Time: 5.993 ms
Comme la clé primaire et la clé étrangère sont indexées, il n’y a
rien à trier, le temps de démarrage est ici très rapide.
L’ORDER BY
encourage l’usage du Merge Join. (Sans
cela, comme la volumétrie reste modeste, PostgreSQL a souvent tendance à
préférer un Hash Join.)
Le paramètre enable_mergejoin
permet d’ activer ou de
désactiver ce type de nœud.
Contrairement au Nested Loop, le Merge Join ne lit qu’une fois chaque ligne. Il n’a pas besoin de mémoire et peut démarrer rapidement une fois les données triées. Sur de gros volumes, il est optimal.
Les données étant triées en entrées, elles reviennent triées. Cela
peut avoir son avantage dans certains cas, comme l’économie d’un tri
pour un ORDER BY
.
Son gros inconvénient est justement que les données en entrée doivent
être triées. PostgreSQL peut alors décider de précéder le Merge
Join d’un nœud de tri Sort. Or, trier beaucoup de données
est coûteux et peut prendre du temps, consommer de la RAM ou du disque.
Le coût de démarrage peut devenir alors rédhibitoire pour des requêtes
avec un LIMIT
par exemple.
L’idéal est que le Merge Join s’appuie sur un index pour accélérer l’opération de tri (on verra alors forcément un Index Scan).
Un autre inconvénient est que le Merge Join ne fonctionne que pour des conditions d’égalité, ce qui représente tout de même l’essentiel des clés de jointures.
Jointure par hachage
À partir d’une certaine volumétrie, les Nested Loops et les Index Scan peuvent devenir trop lents. Le Hash Join cherche à supprimer ce problème en créant une table de hachage de la table interne, à priori la plus petite.
Sa première étape est le hachage de chaque clé de jointure de la table interne, ce qui consomme du CPU, puis le stockage de cette table de hachage, ce qui consomme de la mémoire, voire du disque.
Ensuite, en simplifiant, le nœud Hash Join parcourt la table externe, hache chaque clé de jointure l’une après l’autre et va trouver le ou les enregistrements de la table interne correspondant à la valeur hachée. PostgreSQL vérifie enfin d’éventuels prédicats supplémentaires à vérifier.
Voici un exemple de Hash Join sur des tables système d’une base de données vide de toute donnée utilisateur :
EXPLAIN (ANALYZE,BUFFERS) SELECT *
FROM pg_class, pg_namespace
WHERE pg_class.relnamespace=pg_namespace.oid ;
QUERY PLAN
--------------------------------------------------------------------------
Hash Join (cost=1.09..21.49 rows=415 width=398) (actual time=0.041..0.405 rows=415 loops=1)
Hash Cond: (pg_class.relnamespace = pg_namespace.oid)
Buffers: shared hit=15
-> Seq Scan on pg_class (cost=0.00..18.15 rows=415 width=273) (actual time=0.011..0.066 rows=415 loops=1)
Buffers: shared hit=14
-> Hash (cost=1.04..1.04 rows=4 width=125) (actual time=0.010..0.011 rows=4 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 9kB
Buffers: shared hit=1
-> Seq Scan on pg_namespace (cost=0.00..1.04 rows=4 width=125) (actual time=0.004..0.005 rows=4 loops=1)
Buffers: shared hit=1
Planning:
Buffers: shared hit=4
Planning Time: 0.297 ms
Execution Time: 0.477 ms
Graphiquement, cela donne ceci :
La table pg_namespace
(la plus petite des deux) est
parcourue et hachée, ce qui occupe 9 ko. Puis la table
pg_class
est parcourue, et le hachage de chacune de ses
lignes est comparé au contenu de la table interne hachée.
Le paramètre enable_hashjoin
permet d’ activer ou de
désactiver ce type de nœud.
work_mem
× hash_mem_multiplier
Ce type de nœud est très rapide à condition d’avoir suffisamment de mémoire pour stocker le résultat du hachage de l’ensemble interne. Si une des tables est relativement petite, ce hash tient en mémoire, c’est optimal.
Il y a cependant plusieurs limites.
D’abord, le coût de démarrage peut se révéler important à cause du
hachage de la table interne. Ce nœud a des chances de ne pas être
utilisé s’il y a une clause LIMIT
après la jointure.
Un Hash Join consomme CPU et mémoire pour créer la table de hachage. Au-delà il peut utiliser des fichiers temporaires sur disque, ce qui ralentit la jointure. Le paramétrage de la mémoire utilisable est donc primordial :
work_mem
est le premier paramètre qui limite cette
mémoire, et le défaut est généralement augmenté ;hash_mem_multiplier
est un facteur multiplicateur de
work_mem
pour les nœuds utilisant un hachage, par défaut 2
depuis PostgreSQL 15 (1 avant), qui permet de ne pas gonfler
work_mem
de manière plus générale. Ces deux paramètres peuvent bien sûr être modifiés dans une session ou une transaction.
Pour réduire la mémoire de hachage, il est conseillé de diminuer le
nombre de colonnes récupérées. C’est une raison supplémentaire d’éviter
les SELECT *
aveugles.
Si la mémoire est insuffisante et qu’il faut utiliser des fichiers temporaires, l’algorithme travaille par groupe de lignes (batch). Cette version améliorée de l’algorithme partitionne la table interne. Pour des détails, voir la page Wikipédia de l’algorithme Grace hash join et celle de la variante qu’utilise PostgreSQL, l’Hybrid hash Join.
Un Hash Join peut être très lent si l’estimation de la
taille des ensembles est mauvaise (ensemble interne beaucoup plus gros
que prévu, notamment). De plus, avant PostgreSQL 14, un autre bug était
lié à ces mauvaises estimations : la mémoire réellement consommée
n’était pas limitée, work_mem
ne jouant qu’à la
planification, ce qui pouvait aller jusqu’à la saturation mémoire.
Le hachage de l’ensemble interne d’un Hash Join peut être parallélisé.
Accessoirement, les données retournées par un Hash Join ne sont pas triées.
Exemple :
Deux classiques tables d’entête et de détails, de 0,9 et 1,5 Go, sont jointes entre elles :
CREATE TABLE entetes ( eid bigint PRIMARY KEY,
a bigint,
filler char(40) default 'x') ;
CREATE TABLE details ( did bigint PRIMARY KEY,
eid bigint REFERENCES entetes,
b bigint,
filler char(40) default 'y') ;
INSERT INTO entetes
SELECT i, i FROM generate_series (0,10_000_000) i
ORDER BY random() ;
INSERT INTO details SELECT i, i/3, i
FROM generate_series (1,10_000_000 * 3) i
WHERE random() > 0.5 ;
VACUUM ANALYZE entetes, details;
-- Affichage du temps passé hors du cache
SET track_io_timing TO on ;
-- Simplification des plans affichés
SET jit TO off ;
SET max_parallel_workers_per_gather TO 0 ;
-- Work_mem raisonnable
SET hash_mem_multiplier TO 2 ; -- défaut
SET work_mem TO '100MB';
EXPLAIN (ANALYZE,BUFFERS)
SELECT *
FROM entetes INNER JOIN details USING (eid)
WHERE a > 100 and b IS NOT NULL ;
QUERY PLAN
-----------------------------------------------------------------------------
Hash Join (cost=471037.90..1304805.07 rows=15003264 width=114) (actual time=1539.278..10372.297 rows=15004553 loops=1)
Hash Cond: (details.eid = entetes.eid)
Buffers: shared hit=15956 read=282925 written=8, temp read=229697 written=229697
I/O Timings: shared read=260.851 write=0.023, temp read=325.657 write=804.411
-> Seq Scan on details (cost=0.00..335291.64 rows=15004764 width=65) (actual time=0.008..1154.374 rows=15004703 loops=1)
Filter: (b IS NOT NULL)
Buffers: shared hit=1002 read=184242 written=8
I/O Timings: shared read=173.003 write=0.023
-> Hash (cost=238637.70..238637.70 rows=9999056 width=57) (actual time=1529.753..1529.754 rows=9999900 loops=1)
Buckets: 2097152 Batches: 8 Memory Usage: 125027kB
Buffers: shared hit=14954 read=98683, temp written=82241
I/O Timings: shared read=87.848, temp write=270.981
-> Seq Scan on entetes (cost=0.00..238637.70 rows=9999056 width=57) (actual time=0.018..583.625 rows=9999900 loops=1)
Filter: (a > 100)
Rows Removed by Filter: 101
Buffers: shared hit=14954 read=98683
I/O Timings: shared read=87.848
Planning:
Buffers: shared hit=98 read=7 dirtied=1
I/O Timings: shared read=0.019
Planning Time: 0.202 ms
Execution Time: 10727.840 ms
Le plan
précédent montre un Hash Join, où la table des entêtes, la
plus petite, est la table interne. 643 Mo de fichiers temporaires
(temp written=82241
) sont utilisés. L’algorithme a utilisé
8 batchs consommant chacun 125 Mo de mémoire (ligne
Buckets
). Cette dernière information donne une idée
approximative de la mémoire totale nécessaire (environ 1 Go).
Avec un work_mem
× hash_mem_multiplier
moitié plus bas, on aurait 16 batchs
moitié plus petits.
Si le serveur a assez de mémoire à ce moment, on peut augmenter la mémoire autorisée un peu au-delà de 1 Go. Ce peut être ainsi :
ou ainsi :
La différence peut être importante si le paramétrage n’est pas limité à la session.
Le hash peut alors s’effectuer complètement en RAM et est un peu plus rapide :
QUERY PLAN
-------------------------------------------------------------------------------
Hash Join (cost=363625.90..738205.96 rows=14999295 width=114) (actual time=2631.882..8422.888 rows=15000582 loops=1)
Hash Cond: (details.eid = entetes.eid)
Buffers: shared hit=16054 read=282778
I/O Timings: shared read=287.729
-> Seq Scan on details (cost=0.00..335202.95 rows=15000795 width=65) (actual time=0.020..1027.317 rows=15000737 loops=1)
Filter: (b IS NOT NULL)
Buffers: shared hit=1100 read=184095
I/O Timings: shared read=172.991
-> Hash (cost=238637.70..238637.70 rows=9999056 width=57) (actual time=2623.124..2623.125 rows=9999900 loops=1)
Buckets: 16777216 Batches: 1 Memory Usage: 1000204kB
Buffers: shared hit=14954 read=98683
I/O Timings: shared read=114.738
-> Seq Scan on entetes (cost=0.00..238637.70 rows=9999056 width=57) (actual time=0.033..682.189 rows=9999900 loops=1)
Filter: (a > 100)
Rows Removed by Filter: 101
Buffers: shared hit=14954 read=98683
I/O Timings: shared read=114.738
Planning:
Buffers: shared hit=8
Planning Time: 0.119 ms
Execution Time: 8810.939 ms
Avec moins de champs, la consommation mémoire peut grandement se réduire :
EXPLAIN (ANALYZE,BUFFERS)
SELECT b
FROM entetes INNER JOIN details USING (eid)
WHERE a > 100 and b IS NOT NULL ;
QUERY PLAN
-------------------------------------------------------------------------------
…
-> Hash (cost=238637.70..238637.70 rows=9999056 width=8) (actual time=2707.913..2707.915 rows=9999900 loops=1)
Buckets: 16777216 Batches: 1 Memory Usage: 521694kB
Buffers: shared hit=15010 read=98627
…
Ce type de nœuds prend un ou plusieurs ensembles de données en entrée et renvoie un seul ensemble de données. Cela concerne surtout les requêtes visant des tables partitionnées ou héritées.
UNION ALL
et des UNION
UNION
sans ALL
élimine les doublons
(lourd !)Un nœud Append
a pour but de concaténer plusieurs
ensembles de données pour n’en faire qu’un, non trié. Ce type de nœud
est utilisé dans les requêtes concaténant explicitement des tables
(clause UNION
) ou implicitement (requêtes sur une table
mère d’un héritage ou une table partitionnée).
Dans une base de données créée avec
pgbench -i -s 10 --partitions=10
, la table
pgbench_accounts
est partitionnée (voir au besoin le module
V0 - Partitionnement déclaratif
(introduction)) Accéder à cette table donne ce plan :
QUERY PLAN
---------------------------------------
Append
-> Seq Scan on pgbench_accounts_1
-> Seq Scan on pgbench_accounts_2
-> Seq Scan on pgbench_accounts_3
-> Seq Scan on pgbench_accounts_4
-> Seq Scan on pgbench_accounts_5
-> Seq Scan on pgbench_accounts_6
-> Seq Scan on pgbench_accounts_7
-> Seq Scan on pgbench_accounts_8
-> Seq Scan on pgbench_accounts_9
-> Seq Scan on pgbench_accounts_10
Les nœuds sont exécutés les uns après les autres, et les résultats renvoyés les uns à la suite des autres, sans tri ni déduplication.
Les nœuds ne sont pas forcément identiques. Dans la requête suivante
qui utilise UNION ALL
, le planificateur récupère des
valeurs de deux tables, dont la deuxième est partitionnée. Pour celle-ci
il utilise un autre noeud Append, qui se limite aux deux
partitions utiles pour la requête, avec un nœud différent sur
chacune :
QUERY PLAN
-------------------------------------------------------------------------------
Finalize Aggregate
-> Gather
Workers Planned: 2
-> Partial Aggregate
-> Parallel Append
-> Parallel Index Only Scan using pgbench_accounts_3_pkey on pgbench_accounts_3 pgbench_accounts_2
Index Cond: ((aid >= 1000001) AND (aid <= 2000001))
-> Parallel Seq Scan on pgbench_accounts_2 pgbench_accounts_1
Filter: ((aid >= 1000001) AND (aid <= 2000001))
UNION ALL
récupère toutes les lignes des deux ensembles
de données, même en cas de doublon. Pour n’avoir que des lignes
distinctes, il est possible d’utiliser UNION
sans
la clause ALL
mais cela entraîne une déduplication des
données, ce qui est souvent coûteux :
EXPLAIN (COSTS OFF)
SELECT bid FROM pgbench_branches
UNION
SELECT bid FROM pgbench_accounts WHERE aid < 100000;
QUERY PLAN
-------------------------------------------------------------------------------
HashAggregate
Group Key: pgbench_branches.bid
-> Append
-> Seq Scan on pgbench_branches
-> Index Scan using pgbench_accounts_1_pkey on pgbench_accounts_1 pgbench_accounts
Index Cond: (aid < 100000)
L’utilisation involontaire de UNION
au lieu de
UNION ALL
est un problème de performance très
fréquent !
Les nœuds enfants d’un nœud Append sont parallélisables.
Paramètres dans le cas des tables partitionnées ou héritées :
Le paramètre enable_partition_pruning
permet d’activer
l’élagage des partitions. Il peut prendre deux valeurs on
(le défaut) ou off
.
Avec le partitionnement par héritage (qui n’est plus guère utilisé,
voir le
chapitre de formation ici), le paramètre
constraint_exclusion
permet d’éviter de parcourir les
tables filles qui ne peuvent pas accueillir les données qui nous
intéressent.
UNION ALL
, partitionnement/héritageLIMIT
Le nœud MergeAppend est une optimisation de Append
apparue avec PostgreSQL 9.1, spécifiquement conçue pour le
partitionnement. Elle optimise les requêtes effectuant un tri sur un
UNION ALL
, soit explicite, soit induit par partitionnement
ou héritage.
Considérons la requête suivante qui concatène deux champs dans deux tables et trie sur un champ indexé :
CREATE TABLE liste1 (d timestamptz, v float) ;
CREATE TABLE liste2 (d timestamptz, v float) ;
-- Les dates des deux table sont entremêlées
INSERT INTO liste1
SELECT '2020-01-01'::timestamptz + i*interval '6min', random()
FROM generate_series(1,100000) i;
INSERT INTO liste2
SELECT '2020-01-01'::timestamptz + i*interval '10min', random()
FROM generate_series(1,100000) i;
CREATE INDEX ON liste1 (d);
CREATE INDEX ON liste2 (d);
EXPLAIN (COSTS OFF)
SELECT d, v FROM liste1
UNION ALL
SELECT d, v FROM liste2
ORDER BY d ;
QUERY PLAN
-----------------------------------------------
Merge Append
Sort Key: liste1.d
-> Index Scan using liste1_d_idx on liste1
-> Index Scan using liste2_d_idx on liste2
Il n’y a aucun tri, PostgreSQL déroule les deux index en parallèle bien que les dates soient entremêlées.
Pour voir d’où provient chaque ligne, utiliser le champ système
tableoid
:
SELECT tableoid::regclass, d, v FROM liste1
UNION ALL
SELECT tableoid::regclass, d, v FROM liste2
ORDER BY d
LIMIT 5;
tableoid | d | v
----------+------------------------+---------------------
liste1 | 2020-01-01 00:06:00+01 | 0.3217132313176123
liste2 | 2020-01-01 00:10:00+01 | 0.4255801913539963
liste1 | 2020-01-01 00:12:00+01 | 0.29396898755013123
liste1 | 2020-01-01 00:18:00+01 | 0.3339045666755862
liste2 | 2020-01-01 00:20:00+01 | 0.1439201886655812
MergeAppend est encore plus intéressant avec une clause
LIMIT
.
Évidemment, sans les index, on aurait un Merge suivi d’un Sort global.
EXCEPT
et EXCEPT ALL
INTERSECT
et INTERSECT ALL
La clause UNION
permet de concaténer deux ensembles de
données. À l’inverse, les clauses EXCEPT
et
INTERSECT
permettent de supprimer une partie de deux
ensembles de données.
Voici un exemple basé sur EXCEPT
, pour trouver les
espaces de noms (schémas) non utilisés par des relations :
QUERY PLAN
-------------------------------------------------------------------------------
HashSetOp Except (cost=0.00..30.29 rows=31 width=8)
-> Append (cost=0.00..28.90 rows=556 width=8)
-> Subquery Scan on "*SELECT* 1" (cost=0.00..1.62 rows=31 width=8)
-> Seq Scan on pg_namespace (cost=0.00..1.31 rows=31 width=4)
-> Subquery Scan on "*SELECT* 2" (cost=0.00..24.50 rows=525 width=8)
-> Seq Scan on pg_class (cost=0.00..19.25 rows=525 width=4)
Le résultat peut donner par exemple ceci, suivant l’historique de la base :
oid
------------------
pg_toast_temp_40
pg_temp_47
pg_toast_temp_43
pg_toast_temp_5
…
Avec INTERSECT
, on peut trouver les schémas utilisés
dans les deux tables :
SELECT oid::regnamespace FROM pg_namespace
INTERSECT
SELECT relnamespace::regnamespace FROM pg_class ;
QUERY PLAN
--------------------------------------------
HashSetOp Intersect
-> Append
-> Subquery Scan on "*SELECT* 2"
-> Seq Scan on pg_class
-> Subquery Scan on "*SELECT* 1"
-> Seq Scan on pg_namespace
Et vous obtiendrez au moins ceci :
oid
--------------------
pg_toast
public
information_schema
pg_catalog
Comme pour UNION
, préférer les variantes
EXCEPT ALL
et INTERSECT ALL
si des doublons
dans le résultat ne sont pas gênants ou s’ils sont logiquement
impossibles.
ORDER BY
DISTINCT
, GROUP BY
,
UNION
LIMIT
)Le nœud Sort peut servir aux ORDER BY
, mais
aussi à d’autres nœuds, parfois en préalable à leur exécution, comme des
GROUP BY
des DISTINCT
, ou des jointures
Merge Join…
PostgreSQL peut faire un tri de plusieurs façons. Le choix dépend
principalement de work_mem
. S’il faut tout trier et qu’il
calcule que la quantité de mémoire sera inférieure à ce que le paramètre
work_mem
indique, alors il trie en mémoire
(quicksort). S’il ne faut pas tout trier car il y a un
LIMIT
, et que la mémoire suffit toujours, top
N-heapsort sera utilisé, toujours en mémoire. Si PostgreSQL estime
que work_mem
est insuffisant, il procède à un tri sur
disque (merge disk), ce qui est généralement beaucoup plus
lent.
Voici quelques exemples dans une base pgbench de taille 100 :
Tri en mémoire :
QUERY PLAN
-------------------------------------------------------------------------------
Sort (cost=5.32..5.57 rows=100 width=8) (actual time=0.024..0.028 rows=100 loops=1)
Sort Key: bbalance
Sort Method: quicksort Memory: 27kB
-> Seq Scan on pgbench_branches (cost=0.00..2.00 rows=100 width=8) (actual time=0.007..0.013 rows=100 loops=1)
Planning Time: 0.037 ms
Execution Time: 0.046 ms
Il n’y a ici que 100 petites lignes à trier, 27 ko en RAM ont suffit.
Tri en mémoire avec LIMIT :
On bascule sur un nœud top N heapsort un peu moins consommateur :
QUERY PLAN
------------------------------------------------------------------------
Limit (cost=3.29..3.30 rows=3 width=8) (actual time=0.047..0.048 rows=3 loops=1)
-> Sort (cost=3.29..3.54 rows=100 width=8) (actual time=0.046..0.047 rows=3 loops=1)
Sort Key: bbalance
Sort Method: top-N heapsort Memory: 25kB
-> Seq Scan on pgbench_branches (cost=0.00..2.00 rows=100 width=8) (actual time=0.006..0.024 rows=100 loops=1)
Planning Time: 0.040 ms
Execution Time: 0.065 ms
Tri sur disque :
SET max_parallel_workers_per_gather TO 0;
SET jit TO off ;
SET work_mem TO '4MB' ;
EXPLAIN (ANALYZE) SELECT aid FROM pgbench_accounts ORDER BY abalance ;
QUERY PLAN
-------------------------------------------------------------------------------
Sort (cost=1586743.21..1611742.82 rows=9999844 width=8) (actual time=3895.278..5098.186 rows=10000000 loops=1)
Sort Key: abalance
Sort Method: external merge Disk: 176160kB
Buffers: shared hit=417 read=163519, temp read=44038 written=44159
-> Seq Scan on pgbench_accounts (cost=0.00..263933.44 rows=9999844 width=8) (actual time=0.028..1413.596 rows=10000000 loops=1)
Buffers: shared hit=416 read=163519
Planning Time: 0.043 ms
Execution Time: 5810.752 ms
Cette fois, trier 10 millions de lignes n’a pas tenu dans le petit
work_mem
, PostgreSQL a créé un fichier temporaire de 176 Mo
sur le disque.
Noter que le nœud Sort peut se paralléliser (ici sur 5 processus), le gain de temps est appréciable :
SET max_parallel_workers_per_gather TO 4;
EXPLAIN (ANALYZE)
SELECT aid FROM pgbench_accounts ORDER BY abalance ;
QUERY PLAN
-------------------------------------------------------------------------------
Gather Merge (cost=523960.95..1721288.68 rows=9999844 width=8) (actual time=529.038..1352.855 rows=10000000 loops=1)
Workers Planned: 4
Workers Launched: 4
-> Sort (cost=522960.89..529210.80 rows=2499961 width=8) (actual time=480.241..582.130 rows=2000000 loops=5)
Sort Key: abalance
Sort Method: external merge Disk: 40000kB
Worker 0: Sort Method: external merge Disk: 22712kB
Worker 1: Sort Method: external merge Disk: 37856kB
Worker 2: Sort Method: external merge Disk: 38136kB
Worker 3: Sort Method: external merge Disk: 37896kB
-> Parallel Seq Scan on pgbench_accounts (cost=0.00..188934.61 rows=2499961 width=8) (actual time=0.043..158.185 rows=2000000 loops=5)
Planning Time: 0.148 ms
Execution Time: 1573.879 ms
Tri sur disque évité grâce à une clause LIMIT :
Un LIMIT 5
à la requête précédente refait basculer vers
un top-N heapsort en RAM, car il n’y a pas besoin de beaucoup
de RAM pour garder en mémoire les 5 premières pendant que la table est
lue :
QUERY PLAN
-------------------------------------------------------------------------------
Limit (cost=430027.25..430027.27 rows=5 width=8) (actual time=2121.489..2121.491 rows=5 loops=1)
Buffers: shared hit=800 read=163135
-> Sort (cost=430027.25..455026.86 rows=9999844 width=8) (actual time=2121.488..2121.489 rows=5 loops=1)
Sort Key: abalance
Sort Method: top-N heapsort Memory: 25kB
Buffers: shared hit=800 read=163135
-> Seq Scan on pgbench_accounts (cost=0.00..263933.44 rows=9999844 width=8) (actual time=0.027..1103.317 rows=10000000 loops=1)
Buffers: shared hit=800 read=163135
Planning Time: 0.038 ms
Execution Time: 2121.512 ms
Par contre, une valeur excessive de LIMIT
, ou une clause
OFFSET
élevée fera rebasculer le plan vers un tri sur
disque.
Tri par index :
Le tri le plus rapide est celui qu’on ne fait pas. Si un index existe sur les champs concernés (dans le bon ordre), le travail est déjà fait, et PostgreSQL sait utiliser cet index. Au lieu du Sort, apparaît alors un Index Scan, (voire un Index Only Scan dans le cas les plus favorable) :
QUERY PLAN
-------------------------------------------------------------------------------
Index Scan using pgbench_accounts_pkey on pgbench_accounts (cost=0.43..423624.09 rows=9999844 width=97) (actual time=0.022..1746.740 rows=10000000 loops=1)
Planning Time: 65.105 ms
Execution Time: 1982.572 ms
Si une requête doit renvoyer de nombreuses lignes triées, peut-être vaut-il la peine de rajouter un index pour accélérer ce tri.
Paramètres :
À titre expérimental, le paramètre enable_sort
sert à
décourager fortement l’utilisation d’un tri. Dans ce cas, le
planificateur tend encore plus à utiliser un index, qui retourne les
données déjà triées, même si cela implique de passer par un nœud moins
optimal par ailleurs.
Augmenter la valeur du paramètre work_mem
aura l’effet
inverse : favoriser un tri plutôt que l’utilisation d’un index.
ORDER BY
, GROUP BY
,
UNION
DISTINCT
(v16)Lorsqu’un tri est réalisé sur plusieurs champs, et que la ou les premiers champs sont déjà triées (par un index ou un autre nœud), PostgreSQL évite de faire un tri complet et ne trie que les derniers, ce qui peut notablement accélérer et réduire la consommation mémoire.
CREATE TABLE demo_is (i int PRIMARY KEY,
j int,
k int,
filler char(40) default ' ');
INSERT INTO demo_is (i,j,k) SELECT i, random()*10000, mod(i,3)
FROM generate_series(1,100000) i;
CREATE INDEX ON demo_is (j);
CREATE INDEX ON demo_is (k);
VACUUM ANALYZE demo_is ;
Ce tri utilise l’index existant sur j
pour un
Incremental Sort, avec une consommation mémoire maximale de
27 ko :
QUERY PLAN
----------------------------------------------------------------------
Incremental Sort (cost=1.09..9206.46 rows=100000 width=53) (actual time=0.108..46.284 rows=100000 loops=1)
Sort Key: j, k
Presorted Key: j
Full-sort Groups: 2704 Sort Method: quicksort Average Memory: 27kB Peak Memory: 27kB
-> Index Scan using demo_is_j_idx on demo_is (cost=0.29..6084.28 rows=100000 width=53) (actual time=0.046..30.360 rows=100000 loops=1)
Planning Time: 0.208 ms
Execution Time: 48.692 ms
Si l’on inverse les champs à trier, PostgreSQL repère que
k
possède très peu de valeurs différentes et est dispersé
dans la table, et donc que trier sur j
sera lourd. Il
revient alors au classique Sort sur toute la table.
QUERY PLAN
----------------------------------------------------------------------
Sort (cost=13755.32..14005.32 rows=100000 width=53) (actual time=43.834..50.645 rows=100000 loops=1)
Sort Key: k, j
Sort Method: external merge Disk: 6176kB
-> Seq Scan on demo_is (cost=0.00..2031.00 rows=100000 width=53) (actual time=0.003..5.306 rows=100000 loops=1)
Planning Time: 0.173 ms
Execution Time: 53.170 ms
Le tri se fait sur disque, monter work_mem
pourrait se
révéler utile.
PostgreSQL ne sait malheureusement pas repérer qu’un tri supplémentaire est inutile quand le premier champ est unique (ici la clé primaire) :
QUERY PLAN
----------------------------------------------------------------------
Incremental Sort (cost=0.34..8138.29 rows=100000 width=53)
Sort Key: i, j
Presorted Key: i
-> Index Scan using demo_is_pkey on demo_is (cost=0.29..3638.29 rows=100000 width=53)
Le DISTINCT
utilise parfois un tri pour dédupliquer, et
il peut profiter de l’Incremental Sort depuis PostgreSQL
16 :
QUERY PLAN
----------------------------------------------------------------------
Unique (cost=1.08..9703.76 rows=100000 width=8)
-> Incremental Sort (cost=1.08..9203.76 rows=100000 width=8)
Sort Key: j, i
Presorted Key: j
-> Index Scan using demo_is_j_idx on demo_is (cost=0.29..6084.10 rows=100000 width=8)
Passer le paramètre enable_incremental_sort
à
off
permet de décourager fortement le tri incrémental,
auquel cas il sera sans doute remplacé par un tri classique.
SELECT max(…), count(*), sum(…)…
count(*)
max/min
Il existe plusieurs façons de réaliser un agrégat :
Les deux derniers sont utilisés quand la clause SELECT
contient des colonnes en plus de la fonction d’agrégat.
Les exemples suivants utilisent une base pgbench.
Pour un seul résultat COUNT(*)
, nous aurons ce plan
d’exécution :
QUERY PLAN
------------------------------------------------------------------------
Aggregate (cost=2.25..2.26 rows=1 width=8)
-> Seq Scan on pgbench_branches (cost=0.00..2.00 rows=100 width=0)
La table est toute petite, donc PostgreSQL a considéré que le plus simple était de la parcourir entièrement.
Souvent le nœud Aggregate se reposera sur l’index de la clé
primaire, généralement beaucoup plus petit, et il peut même utiliser un
Index Only Scan dans le cas d’un simple
count(*)
.
SET max_parallel_workers_per_gather TO 0 ;
SET jit TO off ;
EXPLAIN SELECT count(*) FROM pgbench_accounts ;
QUERY PLAN
------------------------------------------------------------------------
Aggregate (cost=28480.42..28480.43 rows=1 width=8)
-> Index Only Scan using pgbench_accounts_pkey on pgbench_accounts (cost=0.42..25980.42 rows=1000000 width=0)
L’Aggregate se parallélise, ici toujours pour parcourir un index :
QUERY PLAN
------------------------------------------------------------------------
Finalize Aggregate
-> Gather
Workers Planned: 2
-> Partial Aggregate
-> Parallel Index Only Scan using pgbench_accounts_pkey on pgbench_accounts
Pour une somme, l’utilisation d’un index est en principe possible de la même manière, mais on indexe plus rarement des indicateurs qui servent aux sommes, et un Seq Scan ou Parallel Seq Scan sera plus fréquent :
QUERY PLAN
---------------------------------------------------------
Finalize Aggregate
-> Gather
Workers Planned: 2
-> Partial Aggregate
-> Parallel Seq Scan on pgbench_accounts
Il faut savoir que plusieurs agrégats peuvent être calculés dans un seul nœud Aggregate :
QUERY PLAN
---------------------------------------------------------
Finalize Aggregate
-> Gather
Workers Planned: 2
-> Partial Aggregate
-> Parallel Seq Scan on pgbench_accounts
Pour les max
et les min
, il est également
possible de passer par un index existant. Comme il est trié, il suffit
d’aller chercher ses valeurs extrêmes, ce qui est très rapide. Si, là
encore, un indicateur est rarement indexé, c’est assez fréquent pour des
dates d’événements.
Le plan suivant récupère les deux extrémités de la clé primaire par son index :
QUERY PLAN
----------------------------------------------------------------------
Result (actual time=0.101..0.104 rows=1 loops=1)
Buffers: shared hit=8
InitPlan 1
-> Limit (actual time=0.074..0.076 rows=1 loops=1)
Buffers: shared hit=4
-> Index Only Scan using pgbench_accounts_pkey on pgbench_accounts (actual time=0.071..0.072 rows=1 loops=1)
Heap Fetches: 0
Buffers: shared hit=4
InitPlan 2
-> Limit (actual time=0.017..0.017 rows=1 loops=1)
Buffers: shared hit=4
-> Index Only Scan Backward using pgbench_accounts_pkey on pgbench_accounts pgbench_accounts_1 (actual time=0.016..0.016 rows=1 loops=1)
Heap Fetches: 0
Buffers: shared hit=4
Planning Time: 0.303 ms
Execution Time: 0.161 ms
Enfin, un décompte de valeurs distinctes est notoirement beaucoup plus lourd. Il implique souvent un tri :
QUERY PLAN
-------------------------------------------------------------------------------------------
Aggregate (actual time=191.564..191.565 rows=1 loops=1)
Buffers: shared hit=2566 read=13828, temp read=1471 written=1476
-> Sort (actual time=117.658..160.773 rows=1000000 loops=1)
Sort Key: bid
Sort Method: external merge Disk: 11768kB
Buffers: shared hit=2566 read=13828, temp read=1471 written=1476
-> Seq Scan on pgbench_accounts (actual time=0.062..65.713 rows=1000000 loops=1)
Buffers: shared hit=2566 read=13828
Planning:
Buffers: shared hit=3
Planning Time: 0.106 ms
Execution Time: 192.717 ms
Là encore, un index peut accélérer le plan, car il contient toutes les valeurs triées, il suffit de parcourir l’index intégralement et de compter :
Aggregate (cost=20900.42..20900.43 rows=1 width=8) (actual time=64.464..64.465 rows=1 loops=1)
-> Index Only Scan using pgbench_accounts_bid_idx on pgbench_accounts (cost=0.42..18400.42 rows=1000000 width=4) (actual time=0.011..37.865 rows=1000000 loops=1)
Heap Fetches: 0
Planning Time: 0.050 ms
Execution Time: 64.487 ms
GROUP BY
work_mem
×hash_mem_multiplier
Le HashAggregate apparaît quand le regroupement se fait avec
d’autres champs, donc en premier lieu avec un
GROUP BY
:
SET max_parallel_workers_per_gather TO 0 ;
SET jit TO off;
EXPLAIN (ANALYZE)
SELECT bid, sum(abalance)
FROM pgbench_accounts
GROUP BY bid ;
QUERY PLAN
----------------------------------------------------------------------
HashAggregate (cost=31394.00..31394.10 rows=10 width=12) (actual time=129.595..129.597 rows=10 loops=1)
Group Key: bid
Batches: 1 Memory Usage: 24kB
-> Seq Scan on pgbench_accounts (cost=0.00..26394.00 rows=1000000 width=8) (actual time=0.045..50.752 rows=1000000 loops=1)
Planning Time: 0.089 ms
Execution Time: 129.635 ms
Le hachage occupe de la place en mémoire. Le plan n’est choisi que si
PostgreSQL estime que si la table de hachage générée tient dans la
mémoire autorisée, c’est-à-dire
work_mem
×hash_mem_multiplier
, comme pour les
Hash Join (voir cette partie plus haut pour ce paramètres). En
cas de souci d’estimation, au pire, PostgreSQL écrit sur disque.
Par exemple, une erreur d’estimation a lieu à cause d’un regroupement sur un champ calculé, notoirement impossible à estimer :
QUERY PLAN
----------------------------------------------------------------------
HashAggregate (cost=85144.00..105456.50 rows=1000000 width=12) (actual time=130.830..132.095 rows=10001 loops=1)
Group Key: (aid / 100)
Planned Partitions: 16 Batches: 1 Memory Usage: 3857kB
Buffers: shared hit=13716 read=2678
I/O Timings: shared read=2.706
-> Seq Scan on pgbench_accounts (cost=0.00..28894.00 rows=1000000 width=8) (actual time=0.065..58.410 rows=1000000 loops=1)
Buffers: shared hit=13716 read=2678
I/O Timings: shared read=2.706
Planning Time: 0.095 ms
Execution Time: 133.805 ms
Ici, elle est dans le bon sens (moins de lignes que prévu). Si l’on veut améliorer ces statistiques, et que le critère de regroupement est assez simple (plus exactement une fonction immutable) il y a deux moyens :
CREATE STATISTICS pgbench_accounts_aid_sur_100 ON (aid/100) FROM pgbench_accounts ;
ANALYZE pgbench_accounts ;
On retrouve alors une bonne estimation (ici avec l’index), qui ne change cependant pas le plan :
QUERY PLAN
--------------------------------------------------------------------------------
HashAggregate (cost=33894.00..34019.05 rows=10004 width=12)
Group Key: (aid / 100)
-> Seq Scan on pgbench_accounts (cost=0.00..28894.00 rows=1000000 width=8)
Le HashAggregate peut se retrouver aussi avec des clauses de
regroupement avancé comme les GROUPING SETS
:
EXPLAIN (COSTS OFF)
SELECT piece,region,sum(quantite)
FROM stock
GROUP BY GROUPING SETS (piece,region);
QUERY PLAN
-------------------------
HashAggregate
Hash Key: piece
Hash Key: region
-> Seq Scan on stock
Le paramètre enable_hashagg
permet d’activer et de
désactiver l’utilisation des HashAggregate.
GROUPING SETS
, ROLLUP
,
CUBE
…Un GroupAggregate a besoin en entrée de données triées selon le regroupement demandé.
Son principe est de faire le regroupement par partie, une fois terminé le balayage.
Avec cette table d’exemple :
CREATE TABLE stats (id serial PRIMARY KEY,
annee int NOT NULL,
dateachat date NOT NULL,
codeproduit char(4) NOT NULL,
typeclient int,
typeproduit int,
qte int
) ;
INSERT INTO stats (annee, dateachat, codeproduit,
typeclient, typeproduit, qte)
SELECT 2000+a,
'2000-01-01'::date+a*interval'1 year'+d*interval '1 day',
c, p, p+c, p*c*a/10000+j+d
FROM
generate_series(14,24) a,
generate_series(3000,9000,1000) c,
generate_series(1,365,5) d,
generate_series (1,200) j,
generate_series (3,8) p
WHERE a-p > 16 ;
VACUUM ANALYZE stats;
La requête suivante utilise un GROUP BY
sur deux
champs :
SET max_parallel_workers_per_gather TO 0 ;
SET jit TO off ;
EXPLAIN
SELECT annee, typeclient,
count (*), count(distinct dateachat), min (dateachat), max(dateachat), sum(qte)
FROM stats
WHERE annee >2022
GROUP BY annee, typeclient ;
QUERY PLAN
----------------------------------------------------------------------
GroupAggregate (cost=137581.65..156029.00 rows=25 width=40)
Group Key: annee, typeclient
-> Sort (cost=137581.65..139887.54 rows=922355 width=16)
Sort Key: annee, typeclient, dateachat
-> Seq Scan on stats (cost=0.00..30435.50 rows=922355 width=16)
Filter: (annee > 2022)
Le tri est bien sûr une opération lourde. En fait il est nécessaire
au count(distinct)
, on le voit d’ailleurs dans
Sort Key
. Sans ce count distinct
, le jeu de
données est assez petit pour qu’un HashAggregate en mémoire
suffise.
Un nouvel index permet au GroupAggregate d’éviter le tri :
CREATE INDEX stats2 ON stats (annee, typeclient, dateachat) ;
SELECT annee, typeclient,
count (*), count(distinct dateachat), min (dateachat), max(dateachat), sum(qte)
FROM stats
WHERE annee >2022
GROUP BY annee, typeclient ;
QUERY PLAN
----------------------------------------------------------------------
GroupAggregate (cost=0.43..79149.94 rows=25 width=40) (actual time=30.400..204.004 rows=9 loops=1)
Group Key: annee, typeclient
Buffers: shared hit=218359 read=806 written=17
I/O Timings: shared read=1.288 write=0.354
-> Index Scan using stats2 on stats (cost=0.43..63008.47 rows=922355 width=16) (actual time=0.063..125.070 rows=919800 loops=1)
Index Cond: (annee > 2022)
Buffers: shared hit=218359 read=806 written=17
I/O Timings: shared read=1.288 write=0.354
Planning:
Buffers: shared hit=3
Planning Time: 0.216 ms
Execution Time: 204.052 ms
ROLLUP
, CUBE
Un MixedAggregate se rencontre quand il y a plusieurs niveau
d’agrégation, par exemple quand on utilise des fonctionnalités OLAP
comme ROLLUP
ou CUBE
(voir au besoin le module
S70 - Analyse de
données). Ce nœud utilise des tables de hachage.
Exemple :
EXPLAIN
SELECT typeclient, typeproduit, sum(qte)
FROM stats
WHERE annee = 2021
GROUP BY CUBE (typeclient, typeproduit) ;
QUERY PLAN
---------------------------------------------------------------------
MixedAggregate (cost=0.00..34479.66 rows=216 width=16)
Hash Key: typeclient, typeproduit
Hash Key: typeclient
Hash Key: typeproduit
Group Key: ()
-> Seq Scan on stats (cost=0.00..30435.50 rows=202100 width=12)
Filter: (annee = 2021)
DISTINCT
Le nœud Unique
permet de ne conserver que les lignes
différentes d’un résultat. Les données doivent être préalablement
triées, et c’est souvent cette opération qui est trop lourde.
Rappelons que l’ajout de clauses DISTINCT
inutiles est
un souci de performance fréquent.
En voici un exemple, où la table est parcourue puis triée :
QUERY PLAN
---------------------------------------------------------------
Unique
-> Gather Merge
Workers Planned: 2
-> Sort
Sort Key: bid
-> HashAggregate
Group Key: bid
-> Parallel Seq Scan on pgbench_accounts
Là aussi, un index aide à accélérer ce type de nœud :
CREATE INDEX ON pgbench_accounts (bid) ;
EXPLAIN (COSTS OFF)
SELECT DISTINCT bid FROM pgbench_accounts ;
QUERY PLAN
---------------------------------------------------------------
Unique
-> Gather Merge
Workers Planned: 2
-> Unique
-> Parallel Index Only Scan using pgbench_accounts_bid_idx on pgbench_accounts
Pour opérer un DISTINCT
, l’optimiseur préfère souvent un
agrégat à un nœud Unique
:
QUERY PLAN
----------------------------------------------------------------------
HashAggregate
Group Key: (aid / 100)
-> Index Only Scan using pgbench_accounts_pkey on pgbench_accounts
L’opérateur ne sait pas non plus identifier un champ dont l’unicité
est garantie dans la requête. Dans l’exemple suivant, aid
est la clé primaire, il n’y a pas de jointure, le nœud
Unique
est ici :
QUERY PLAN
-----------------------------------------------------------------------
Unique
-> Index Only Scan using pgbench_accounts_pkey on pgbench_accounts
LIMIT
et OFFSET
Un nœud Limit arrête l’émission des lignes une fois le seuil
demandé. L’ajout de LIMIT
modifie profondément le plan, car
PostgreSQL cherche alors à obtenir le plus vite possible les premières
lignes demandées, et non à optimiser la récupération d’un grand nombre
de lignes. PostgreSQL favorise alors les nœuds à faible coût de
démarrage comme Seq Scan ou Nested Loops,
OFFSET
permet de parcourir un certain nombre de valeurs
sans les renvoyer avant de commencer l’affichage. L’utilisation
habituelle est la présentation de résultats page par page. Cependant,
ces résultats seront quand même lus. Attention, l’utilisation d’une
valeur élevée pour OFFSET
est une mauvaise pratique.
Ce plan ne contient pas de LIMIT
. Comme il y a 10
millions de lignes à lire et à joindre, PostgreSQL décide d’un Hash
Join :
QUERY PLAN
---------------------------------------------------------------
Hash Join (cost=3.25..291298.76 rows=9999844 width=457)
Hash Cond: (pgbench_accounts.bid = pgbench_branches.bid)
-> Seq Scan on pgbench_accounts (cost=0.00..263933.44 rows=9999844 width=97)
-> Hash (cost=2.00..2.00 rows=100 width=364)
-> Seq Scan on pgbench_branches (cost=0.00..2.00 rows=100 width=364)
L’ajout de LIMIT 5
fait basculer le plan vers un
Nested Loop dont le coût de démarrage est nul :
QUERY PLAN
---------------------------------------------------------------
Limit (cost=0.15..0.41 rows=5 width=457)
-> Nested Loop (cost=0.15..512475.41 rows=9999844 width=457)
-> Seq Scan on pgbench_accounts (cost=0.00..263933.44 rows=9999844 width=97)
-> Memoize (cost=0.15..0.17 rows=1 width=364)
Cache Key: pgbench_accounts.bid
Cache Mode: logical
-> Index Scan using pgbench_branches_pkey on pgbench_branches (cost=0.14..0.16 rows=1 width=364)
Index Cond: (bid = pgbench_accounts.bid)
Attention, avec LIMIT
, la lecture des plan se complique.
Dans le plan suivant, les valeurs estimées peuvent porter sur
l’intégralité de la table et non sur ce qui doit être réellement
récupérées :
EXPLAIN (ANALYZE)
SELECT * FROM pgbench_accounts
INNER JOIN pgbench_branches USING (bid)
LIMIT 5 OFFSET 20 ;
QUERY PLAN
---------------------------------------------------------------
Limit (cost=1.18..1.43 rows=5 width=457) (actual time=0.046..0.051 rows=5 loops=1)
-> Nested Loop (cost=0.15..512475.41 rows=9999844 width=457) (actual time=0.027..0.047 rows=25 loops=1)
-> Seq Scan on pgbench_accounts (cost=0.00..263933.44 rows=9999844 width=97) (actual time=0.010..0.012 rows=25 loops=1)
-> Memoize (cost=0.15..0.17 rows=1 width=364) (actual time=0.001..0.001 rows=1 loops=25)
Cache Key: pgbench_accounts.bid
Cache Mode: logical
Hits: 24 Misses: 1 Evictions: 0 Overflows: 0 Memory Usage: 1kB
-> Index Scan using pgbench_branches_pkey on pgbench_branches (cost=0.14..0.16 rows=1 width=364) (actual time=0.007..0.007 rows=1 loops=1)
Index Cond: (bid = pgbench_accounts.bid)
Planning Time: 0.240 ms
Execution Time: 0.084 ms
Le Seq Scan et le Nested Loops affichent 9 999 844 lignes dans leur estimation alors que 25 lignes (20 d’offset et 5 affichées) sont récupérées par l’un et l’autre.
work_mem
×hash_mem_multiplier
ndistinct
Apparu avec PostgreSQL 14, le nœud Memoize est un cache de
résultat qui permet d’optimiser les performances d’autres nœuds en
mémorisant des données qui risquent d’être accédées plusieurs fois de
suite. Pour le moment, ce nœud n’est utilisable que pour les données de
l’ensemble interne d’un Nested Loop généré par une jointure
classique ou LATERAL
.
Le cas idéal pour cette optimisation concerne des jointures où de large portions des lignes de l’ensemble interne de la jointure n’ont pas de correspondance dans l’ensemble externe. Dans ce genre de cas, un Hash Join serait moins efficace car il devrait calculer la clé de hachage de valeurs qui ne seront jamais utilisées ; et le Merge Join devrait ignorer un grand nombre de lignes dans son parcours de la table interne.
L’intérêt du cache de résultat augmente lorsqu’il y a peu de valeurs
distinctes dans l’ensemble interne et que le nombre de valeurs dans
l’ensemble externe est grand, ce qui provoque beaucoup de boucles. Ce
nœud est donc très sensible aux statistiques sur le nombre de valeurs
distinctes (ndistinct
).
Cette fonctionnalité utilise une table de hachage pour stocker les
résultats. Cette table est dimensionnée grâce aux paramètres
work_mem
et hash_mem_multiplier
. Si le cache
se remplit, les valeurs les plus anciennes sont exclues du cache.
Exemple :
DROP TABLE IF EXISTS t1;
DROP TABLE IF EXISTS t2;
CREATE TABLE t1(i int, j int);
CREATE TABLE t2(k int, l int);
INSERT INTO t1 SELECT x,x FROM generate_series(1, 400000) AS F(x) ;
INSERT INTO t2 SELECT x % 20,x FROM generate_series(1, 3000000) AS F(x);
CREATE INDEX ON t1(j) ;
VACUUM ANALYZE t1,t2 ;
EXPLAIN (TIMING off, COSTS off, SUMMARY off, ANALYZE)
SELECT * FROM t1 INNER JOIN t2 ON (t1.j = t2.k) ;
QUERY PLAN
------------------------------------------------------------------------------
Nested Loop (actual rows=2850000 loops=1)
-> Seq Scan on t2 (actual rows=3000000 loops=1)
-> Memoize (actual rows=1 loops=3000000)
Cache Key: t2.k
Cache Mode: logical
Hits: 2999980 Misses: 20 Evictions: 0 Overflows: 0 Memory Usage: 3kB
-> Index Scan using t1_j_idx on t1 (actual rows=1 loops=20)
Index Cond: (j = t2.k)
Il n’y a que 20 valeurs différentes pour t2.k
, le cache
ne fait donc que 3 ko. Il a permis 2 999 980 accès au cache, et
seulement 20 valeurs ont dû être cherchées dans l’index
(loops=20
). Aucune valeur n’a été exclue du cache
(Evictions: 0
).
En désactivant ce nœud, on bascule sur un Hash Join:
SET enable_memoize TO off;
EXPLAIN (TIMING off, COSTS off, SUMMARY off, ANALYZE)
SELECT * FROM t1 INNER JOIN t2 ON t1.j = t2.k;
QUERY PLAN
-------------------------------------------------------
Hash Join (actual rows=2850000 loops=1)
Hash Cond: (t2.k = t1.j)
-> Seq Scan on t2 (actual rows=3000000 loops=1)
-> Hash (actual rows=400000 loops=1)
Buckets: 262144 Batches: 4 Memory Usage: 5956kB
-> Seq Scan on t1 (actual rows=400000 loops=1)
Le nombre de nœuds est important mais il faut apprendre à les distinguer, connaître leur points forts et faibles, et ce qui les (dé)favorise.
La théorie ne remplace pas l’expérience acquise en essayant de comprendre des plans de requêtes, et en testant des modifications.
Superviser un serveur de bases de données consiste à superviser le moteur lui-même, mais aussi le système d’exploitation et le matériel. Ces deux derniers sont importants pour connaître la charge système, l’utilisation des disques ou du réseau, qui pourraient expliquer des lenteurs au niveau du moteur. PostgreSQL propose lui aussi des informations qu’il est important de surveiller pour détecter des problèmes au niveau de l’utilisation du SGBD ou de sa configuration.
Ce module a pour but de montrer comment effectuer une supervision occasionnelle (au cas où un problème surviendrait, savoir comment interpréter les informations fournies par le système et par PostgreSQL).
Il existe de nombreux outils sous Unix permettant de superviser de
temps en temps le système. Cela passe par des outils comme
ps
ou top
pour surveiller les processus à
iotop
ou vmstat
pour les disques. Il est
nécessaire de les tester, de comprendre les indicateurs et de se
familiariser avec tout ou partie de ces outils afin d’être capable
d’identifier rapidement un problème matériel ou logiciel.
ps
est l’outil de base pour les processusps aux
ps f -f -u postgres
ps
est l’outil le plus connu sous Unix. Il permet de
récupérer la liste des processus en cours d’exécution. Les différentes
options de ps
peuvent avoir des définitions différentes en
fonction du système d’exploitation (GNU/Linux, UNIX ou BSD)
Par exemple, l’option f
active la présentation sous
forme d’arborescence des processus. Cela nous donne ceci :
$ ps -u postgres f
10149 pts/5 S 0:00 \_ postmaster
10165 ? Ss 0:00 | \_ postgres: checkpointer
10166 ? Ss 0:00 | \_ postgres: background writer
10168 ? Ss 0:00 | \_ postgres: wal writer
10169 ? Ss 0:00 | \_ postgres: autovacuum launcher
10171 ? Ss 0:00 | \_ postgres: logical replication launcher
Les options aux
permettent d’avoir une idée de la
consommation processeur (colonne %CPU
de l’exemple suivant)
et mémoire (colonne %MEM
) de chaque processus :
$ ps aux
USER PID %CPU %MEM VSZ RSS STAT COMMAND
500 10149 0.0 0.0 294624 18776 S postmaster
500 10165 0.0 0.0 294624 5120 Ss postgres: checkpointer
500 10166 0.0 0.0 294624 5120 Ss postgres: background writer
500 10168 0.0 0.0 294624 8680 Ss postgres: wal writer
500 10169 0.0 0.0 295056 5976 Ss postgres: autovacuum launcher
500 10171 0.0 0.0 294916 4004 Ss postgres: logical replication launcher
[...]
Attention à la colonne RSS
. Elle indique la quantité de
mémoire utilisée par chaque processus, en prenant aussi en compte la
mémoire partagée lue par le processus. Il peut donc arriver qu’en
additionnant les valeurs de cette colonne, on arrive à une valeur bien
plus importante que la mémoire physique, ce qui est donc normal. La
valeur de la colonne VSZ
comprend toujours l’intrégralité
de la mémoire partagée allouée initialement par le processus
postmaster.
Dernier exemple :
$ ps uf -C postgres
USER PID %CPU %MEM VSZ RSS STAT COMMAND
500 9131 0.0 0.0 194156 7964 S postmaster
500 9136 0.0 0.0 194156 1104 Ss \_ postgres: checkpointer
500 9137 0.0 0.0 194156 1372 Ss \_ postgres: background writer
500 9138 0.0 0.0 194156 1104 Ss \_ postgres: wal writer
500 9139 0.0 0.0 194992 2360 Ss \_ postgres: autovacuum launcher
500 9141 0.0 0.0 194156 1372 Ss \_ postgres: logical replication launcher
Il est à noter que la commande ps
affiche un grand
nombre d’informations sur le processus seulement si le paramètre
update_process_title
est activé. Un processus d’une session
affiche ainsi la base, l’utilisateur et, le cas échéant, l’adresse IP de
la connexion. Il affiche aussi la commande en cours d’exécution et si
cette commande est bloquée en attente d’un verrou ou non.
$ ps -u postgres f
4563 pts/0 S 0:00 \_ postmaster
4569 ? Ss 0:00 | \_ postgres: checkpointer
4570 ? Ss 0:00 | \_ postgres: background writer
4571 ? Ds 0:00 | \_ postgres: wal writer
4572 ? Ss 0:00 | \_ postgres: autovacuum launcher
4574 ? Ss 0:00 | \_ postgres: logical replication launcher
4610 ? Ss 0:00 | \_ postgres: u1 b2 [local] idle in transaction
4614 ? Ss 0:00 | \_ postgres: u2 b2 [local] DROP TABLE waiting
4617 ? Ss 0:00 | \_ postgres: u3 b1 [local] INSERT
4792 ? Ss 0:00 | \_ postgres: u1 b2 [local] idle
Dans cet exemple, quatre sessions sont ouvertes. La session 4610
n’exécute aucune requête mais est dans une transaction ouverte (c’est
potentiellement un problème, à cause des verrous tenus pendant
l’entièreté de la transaction et de la moindre efficacité des VACUUM).
La session 4614 affiche le mot-clé waiting
: elle est en
attente d’un verrou, certainement détenu par une session en cours
d’exécution d’une requête ou d’une transaction. Le
DROP TABLE
a son exécution mise en pause à cause de ce
verrou non acquis. La session 4617 est en train d’exécuter un
INSERT
(la requête réelle peut être obtenue avec la vue
pg_stat_activity
qui sera abordée plus loin dans ce
chapitre). Enfin, la session 4792 n’exécute pas de requête et ne se
trouve pas dans une transaction ouverte. u1
,
u2
et u3
sont les utilisateurs pris en compte
pour la connexion, alors que b1
et b2
sont les
noms des bases de données de connexion. De ce fait, la session 4614 est
connectée à la base de données b2
avec l’utilisateur
u2
.
Les processus des sessions ne sont pas les seuls à fournir quantité d’informations. Les processus de réplication et le processus d’archivage indiquent le statut et la progression de leur activité.
%CPU
et %MEM
CPU
atop
, htop
top
est un outil utilisant ncurses
pour
afficher un bandeau d’informations sur le système, la charge système,
l’utilisation de la mémoire et enfin la liste des processus. Les
informations affichées ressemblent beaucoup à ce que fournit la commande
ps
avec les options « aux ». Cependant, top
rafraichit son affichage toutes les trois secondes (par défaut), ce qui
permet de vérifier si le comportement détecté reste présent.
top
est intéressant pour connaître rapidement le processus
qui consomme le plus en termes de processeur (touche P) ou de mémoire
(touche M). Ces touches permettent de changer l’ordre de tri des
processus. Il existe beaucoup plus de tris possibles, la sélection
complète étant disponible en appuyant sur la touche F.
Parmi les autres options intéressantes, la touche c permet de basculer l’affichage du processus entre son nom seulement ou la ligne de commande complète. La touche u permet de filtrer les processus par utilisateur. Enfin, la touche 1 permet de basculer entre un affichage de la charge moyenne sur tous les processeurs et un affichage détaillé de la charge par processeur.
Exemple :
top - 11:45:02 up 3:40, 5 users, load average: 0.09, 0.07, 0.10
Tasks: 183 total, 2 running, 181 sleeping, 0 stopped, 0 zombie
Cpu0 : 6.7%us, 3.7%sy, 0.0%ni, 88.3%id, 1.0%wa, 0.3%hi, 0.0%si, 0.0%st
Cpu1 : 3.3%us, 2.0%sy, 0.0%ni, 94.0%id, 0.0%wa, 0.3%hi, 0.3%si, 0.0%st
Cpu2 : 5.6%us, 3.0%sy, 0.0%ni, 91.0%id, 0.0%wa, 0.3%hi, 0.0%si, 0.0%st
Cpu3 : 2.7%us, 0.7%sy, 0.0%ni, 96.3%id, 0.0%wa, 0.3%hi, 0.0%si, 0.0%st
Mem: 3908580k total, 3755244k used, 153336k free, 50412k buffers
Swap: 2102264k total, 88236k used, 2014028k free, 1436804k cached
PID PR NI VIRT RES SHR S %CPU %MEM COMMAND
8642 20 0 178m 29m 27m D 53.3 0.8 postgres: gui formation [local] INSERT
7885 20 0 176m 7660 7064 S 0.0 0.2 /opt/postgresql-10/bin/postgres
7892 20 0 176m 1928 1320 S 0.8 0.0 postgres: wal writer
7893 20 0 178m 3356 1220 S 0.0 0.1 postgres: autovacuum launcher
Attention à la valeur de la colonne free
. La mémoire
réellement disponible correspond plutôt à la soustraction
total - (used + buffers + cached)
(cached
étant le cache disque mémoire du noyau). En réalité, c’est un peu moins,
car tout ce qu’il y a dans cache
ne peut être libéré sans
recourir au swapping. Les versions plus modernes de
top
affichent une colonne avail Mem
,
équivalent de la colonne available
de la commande
free
, et qui correspond beaucoup mieux à l’idée de
« mémoire disponible ».
top
n’existe pas directement sur Solaris. L’outil par
défaut sur ce système est prstat
.
%IO
iotop
est l’équivalent de top
pour la
partie disque. Il affiche le nombre d’octets lus et écrits par
processus, avec la commande complète. Cela permet de trouver rapidement
le processus à l’origine de l’activité disque :
Total DISK READ: 19.79 K/s | Total DISK WRITE: 5.06 M/s
TID PRIO USER DISK READ DISK WRITE SWAPIN IO> COMMAND
1007 be/3 root 0.00 B/s 810.43 B/s 0.00 % 2.41 % [jbd2/sda3-8]
7892 be/4 guill 14.25 K/s 229.52 K/s 0.00 % 1.93 % postgres:
wal writer
445 be/3 root 0.00 B/s 3.17 K/s 0.00 % 1.91 % [jbd2/sda2-8]
8642 be/4 guill 0.00 B/s 7.08 M/s 0.00 % 0.76 % postgres: gui formation
[local] INSERT
7891 be/4 guill 0.00 B/s 588.83 K/s 0.00 % 0.00 % postgres:
background writer
1 be/4 root 0.00 B/s 0.00 B/s 0.00 % 0.00 % init
Comme top
, il s’agit d’un programme ncurses dont
l’affichage est rafraîchi fréquemment (toutes les secondes par défaut).
Cet outil ne peut être utilisé qu’en tant qu’administrateur
(root).
iowait
vmstat
est certainement l’outil système de supervision
le plus fréquemment utilisé parmi les administrateurs de bases de
données PostgreSQL. Il donne un condensé d’informations système qui
permet de cibler très rapidement le problème.
Contrairement à top
ou iotop
, il envoie
l’information directement sur la sortie standard, sans utiliser une
interface particulière.
Cette commande accepte plusieurs options en ligne de commande, mais
il faut fournir au minimum un argument indiquant la fréquence de
rafraichissement. En l’absence du deuxième argument count
,
la commande s’exécute en permanence jusqu’à son arrêt avec un Ctrl-C. Ce
comportement est identique pour les commandes iostat
et
sar
notamment.
procs-----------memory---------- ---swap-- -----io---- --system-- -----cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
2 0 145004 123464 51684 1272840 0 2 24 57 17 351 7 2 90 1 0
0 0 145004 119640 51684 1276368 0 0 256 384 1603 2843 3 3 86 9 0
0 0 145004 118696 51692 1276452 0 0 0 44 2214 3644 11 2 87 1 0
0 0 145004 118796 51692 1276460 0 0 0 0 1674 2904 3 2 95 0 0
1 0 145004 116596 51692 1277784 0 0 4 384 2096 3470 4 2 92 2 0
0 0 145004 109364 51708 1285608 0 0 0 84 1890 3306 5 2 90 3 0
0 0 145004 109068 51708 1285608 0 0 0 0 1658 3028 3 2 95 0 0
0 0 145004 117784 51716 1277132 0 0 0 400 1862 3138 3 2 91 4 0
1 0 145004 121016 51716 1273292 0 0 0 0 1657 2886 3 2 95 0 0
0 0 145004 121080 51716 1273292 0 0 0 0 1598 2824 3 1 96 0 0
0 0 145004 121320 51732 1273144 0 0 0 444 1779 3050 3 2 90 5 0
0 1 145004 114168 51732 1280840 0 0 0 25928 2255 3358 17 3 79 2 0
0 1 146612 106568 51296 1286520 0 1608 24 25512 2527 3767 16 5 75 5 0
0 1 146904 119364 50196 1277060 0 292 40 26748 2441 3350 16 4 78 2 0
1 0 146904 109744 50196 1286556 0 0 0 20744 3464 5883 23 4 71 3 0
1 0 146904 110836 50204 1286416 0 0 0 23448 2143 2811 16 3 78 3 0
1 0 148364 126236 46432 1273168 0 1460 0 17088 1626 3303 9 3 86 2 0
0 0 148364 126344 46432 1273164 0 0 0 0 1384 2609 3 2 95 0 0
1 0 148364 125556 46432 1273320 0 0 56 1040 1259 2465 3 2 95 0 0
0 0 148364 124676 46440 1273244 0 0 4 114720 1774 2982 4 2 84 9 0
0 0 148364 125004 46440 1273232 0 0 0 0 1715 2817 3 2 95 0 0
0 0 148364 124888 46464 1273256 0 0 4 552 2306 4014 3 2 79 16 0
0 0 148364 125060 46464 1273232 0 0 0 0 1888 3508 3 2 95 0 0
0 0 148364 124936 46464 1273220 0 0 0 4 2205 4014 4 2 94 0 0
0 0 148364 125168 46464 1273332 0 0 12 384 2151 3639 4 2 94 0 0
1 0 148364 123192 46464 1274316 0 0 0 0 2019 3662 4 2 94 0 0
^C
Parmi les colonnes intéressantes :
Les informations à propos des blocs manipulés (si/so et bi/bo) sont
indiquées du point de vue de la mémoire. Ainsi, un bloc écrit vers le
swap sort de la mémoire, d’où le so
, comme swap
out.
iostat
fournit des informations plus détaillées que
vmstat
. Il est généralement utilisé quand il est
intéressant de connaître le disque sur lequel sont fait les lectures
et/ou écritures. Cet outil affiche des statistiques sur l’utilisation
CPU et les I/O.
-d
permet de n’afficher que les informations
disque, l’option -c
permettant de n’avoir que celles
concernant le CPU.-k
affiche des valeurs en ko/s au lieu de
blocs/s. De même, -m
pour des Mo/s.-x
permet d’afficher le mode étendu. Ce mode
est le plus intéressant.vmstat
.Comme la majorité de ces types d’outils, la première mesure retournée est une moyenne depuis le démarrage du système. Il ne faut pas la prendre en compte.
Exemple d’affichage de la commande en mode étendu compact :
Device tps kB/s rqm/s await areq-sz aqu-sz %util
sdb 76.0 324.0 4.0 0.8 4.3 0.1 1.2
Device tps kB/s rqm/s await areq-sz aqu-sz %util
sdb 192.0 139228.0 49.0 8.1 725.1 1.5 28.0
Device tps kB/s rqm/s await areq-sz aqu-sz %util
sdb 523.0 364236.0 86.0 9.0 696.4 4.7 70.4
Les colonnes ont les significations suivantes :
Device
: le périphériquerrqm/s
et wrqm/s
:
read request merged per second
et
write request merged per second
, c’est-à-dire fusions
d’entrées/sorties en lecture et en écriture. Cela se produit dans la
file d’attente des entrées/sorties, quand des opérations sur des blocs
consécutifs sont demandées… par exemple un programme qui demande
l’écriture de 1 Mo de données, par bloc de 4 ko. Le système fusionnera
ces demandes d’écritures en opérations plus grosses pour le disque, afin
d’être plus efficace. Un chiffre faible dans ces colonnes
(comparativement à w/s et r/s) indique que le système ne peut fusionner
les entrées/sorties, ce qui est signe de beaucoup d’entrées/sorties non
contiguës (aléatoires). La récupération de données depuis un parcours
d’index est un bon exemple.r/s
et w/s
: nombre de lectures et
d’écritures par seconde. Il ne s’agit pas d’une taille en blocs, mais
bien d’un nombre d’entrées/sorties par seconde. Ce nombre est le plus
proche d’une limite physique, sur un disque (plus que son débit en
fait) : le nombre d’entrées/sorties par seconde faisable est directement
lié à la vitesse de rotation et à la performance des actuateurs des
bras. Il est plus facile d’effectuer des entrées/sorties sur des
cylindres proches que sur des cylindres éloignés, donc même cette valeur
n’est pas parfaitement fiable. La somme de r/s
et
w/s
devrait être assez proche des capacités du disque. De
l’ordre de 150 entrées/sorties par seconde pour un disque 7200 RPMS
(SATA), 200 pour un 10 000 RPMS, 300 pour un 15 000 RPMS, et 10000 pour
un SSD.rkB/s
et wkB/s
: les débits en lecture et
écriture. Ils peuvent être faibles, avec un disque pourtant à
100 %.areq-sz
(avgrq-sz
dans les anciennes
versions) : taille moyenne d’une requête. Plus elle est proche de 1
(1 ko), plus les opérations sont aléatoires. Sur un SGBD, c’est un
mauvais signe : dans l’idéal, soit les opérations sont séquentielles,
soit elles se font en cache.aqu-sz
: taille moyenne de la file d’attente des
entrées/sorties. Si ce chiffre est élevé, cela signifie que les
entrées/sorties s’accumulent. Ce n’est pas forcément anormal, mais cela
entrainera des latences. Si une grosse écriture est en cours, ce n’est
pas choquant (voir le second exemple).await
: temps moyen attendu par une entrée/sortie avant
d’être totalement traitée. C’est le temps moyen écoulé, vu d’un
programme, entre la soumission d’une entrée/sortie et la récupération
des données. C’est un bon indicateur du ressenti des utilisateurs :
c’est le temps moyen qu’ils ressentiront pour qu’une entrée/sortie se
fasse (donc vraisemblablement une lecture, vu que les écritures sont
asynchrones, vues par un utilisateur de PostgreSQL).%util
: le pourcentage d’utilisation. Une valeur proche
de 100 indique une saturation pour les disques rotatifs classiques, mais
pas forcément pour les système RAID ou les disques SSD qui peuvent
traiter plusieurs requêtes en parallèle.Exemple d’affichage de la commande lors d’une copie de 700 Mo :
Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await svctm %util
sda 60,7 1341,3 156,7 24,0 17534,7 2100,0 217,4 34,4 124,5 5,5 99,9
Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await svctm %util
sda 20,7 3095,3 38,7 117,3 4357,3 12590,7 217,3 126,8 762,4 6,4 100,0
Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await svctm %util
sda 30,7 803,3 63,3 73,3 8028,0 6082,7 206,5 104,9 624,1 7,3 100,0
Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await svctm %util
sda 55,3 4203,0 106,0 29,7 12857,3 6477,3 285,0 59,1 504,0 7,4 100,0
Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await svctm %util
sda 28,3 2692,3 56,0 32,7 7046,7 14286,7 481,2 54,6 761,7 11,3 100,0
sysstat
est un paquet logiciel comprenant de nombreux
outils permettant de récupérer un grand nombre d’informations système,
notamment pour le système disque. Il est capable d’enregistrer ces
informations dans des fichiers binaires, qu’il est possible de décoder
par la suite.
Sur les distributions Linux modernes intégrant systemd, une fois
sysstat
installé, il faut configurer son exécution
automatique pour récupérer des statistiques périodiques avec :
Il est par ailleurs recommandé de positionner la variable
SADC_OPTIONS
à "-S XALL"
dans le fichier de
configuration (/etc/sysstat/sysstat
pour Debian).
Le paquet sysstat dispose notamment de l’outil pidstat
.
Ce dernier récupère les informations système spécifiques à un processus
(et en option à ses fils).
Pour plus d’information, consultez le readme.
Cette commande indique la mémoire totale, la mémoire disponible, celle utilisée pour le cache, etc.
Ce serveur dispose de 251 Go de mémoire d’après la colonne
total
. Les applications utilisent 9 Go de mémoire. Seuls
15 Go ne sont pas utilisés. Le système utilise 226 Go de cette mémoire
pour son cache disque (et un peu de bufferisation au niveau noyau),
comme le montre la colonne buff/cache
. La colonne
available
correspond à la quantité de mémoire libre, plus
celle que le noyau est capable de libérer sans recourir au
swapping. On voit ici que c’est un peu inférieur à la somme de
free
et buff/cache
.
Bien qu’il y ait moins d’outils en ligne de commande, il existe plus d’outils graphiques, directement utilisables. Un outil très intéressant est même livré avec le système : les outils performances.
ps
et grep
en une commandetasklist est le seul outil en ligne de commande discuté ici.
Il permet de récupérer la liste des processus en cours d’exécution.
Les colonnes affichées sont modifiables par des options en ligne de
commande et les processus sont filtrables (option /fi
).
Le format de sortie est sélectionnable avec l’option
/fo
.
La commande suivante permet de ne récupérer que les processus
postgres.exe
:
Voir le site officiel pour plus de détails.
Process Monitor permet de lister les appels système des processus, comme le montre la copie d’écran ci-dessous :
Il affiche en temps réel l’utilisation du système de fichiers, de la base de registre et de l’activité des processus. Il combine les fonctionnalités de deux anciens outils, FileMon et Regmon, tout en ajoutant un grand nombre de fonctionnalités (filtrage, propriétés des événements et des processus, etc.). Process Monitor permet d’afficher les accès aux fichiers (DLL et autres) par processus.
Ce logiciel est un outil de supervision avancée sur l’activité du
système et plus précisément des processus. Il permet de filtrer les
processus affichés, de les trier, le tout avec une interface graphique
facile à utiliser.
La copie d’écran ci-dessus montre un système Windows avec deux instances PostgreSQL démarrées. L’utilisation des disques et de la mémoire est visible directement. Quand on demande les propriétés d’un processus, on dispose d’un dialogue avec plusieurs onglets, dont trois essentiels :
Il existe aussi sur cet outil un bouton System Information. Ce dernier affiche une fenêtre à quatre onglets, avec des graphes basiques mais intéressants sur les performances du système.
sysstat
Cet outil permet d’aller plus loin en termes de graphes. Il crée des graphes sur toutes les données disponibles, fournies par le système. Cela rend la recherche des performances plus simples dans un premier temps sur un système Windows.
Superviser une instance PostgreSQL consiste à surveiller à la fois ce qui s’y passe, depuis quelles sources, vers quelles tables, selon quelles requêtes et comment sont gérées les écritures.
PostgreSQL offre de nombreuses vues internes pour suivre cela.
Certains champs des vues ne sont remplis que si
track_io_timing
est à on
, off
étant sa valeur par défaut. Avant de l’activer, vérifiez les capacités
et la configuration de la machine avec l’outil
pg_test_timing
.
pg_test_timing
est livré avec PostgreSQL et mesure les performances de l’horloge
système. Si le temps de mesure renvoyé sur la deuxième ligne n’est que
de quelques dizaines de nanosecondes, la machine est suffisamment rapide
pour que track_io_timing
renvoie des résultats précis et
sans ralentir la requête. C’est le cas sur presque toutes les machines
et systèmes d’exploitation actuels, mais il y a parfois des surprises,
par exemple dans certaines machines virtuelles ou selon la source de
l’horloge système. Sinon, éviter d’activer track_io_timing
sur un serveur de production. Sur une machine de test ou de formation,
ce n’est pas un problème.
Vue « pg_catalog.pg_stat_database »
Colonne | Type | …
--------------------------+--------------------------+---
datid | oid |
datname | name |
numbackends | integer |
xact_commit | bigint |
xact_rollback | bigint |
blks_read | bigint |
blks_hit | bigint |
tup_returned | bigint |
tup_fetched | bigint |
tup_inserted | bigint |
tup_updated | bigint |
tup_deleted | bigint |
conflicts | bigint |
temp_files | bigint |
temp_bytes | bigint |
deadlocks | bigint |
checksum_failures | bigint |
checksum_last_failure | timestamp with time zone |
blk_read_time | double precision |
blk_write_time | double precision |
session_time | double precision |
active_time | double precision |
idle_in_transaction_time | double precision |
sessions | bigint |
sessions_abandoned | bigint |
sessions_fatal | bigint |
sessions_killed | bigint |
stats_reset | timestamp with time zone |
Voici la signification des différentes colonnes :
datid
/datname
: l’OID
et le
nom de la base de données ;numbackends
: le nombre de sessions en cours ;xact_commit
: le nombre de transactions ayant terminé
avec commit sur cette base ;xact_rollback
: le nombre de transactions ayant terminé
avec rollback sur cette base ;blks_read
: le nombre de blocs demandés au système
d’exploitation ;blks_hit
: le nombre de blocs trouvés dans la cache de
PostgreSQL ;tup_returned
: le nombre d’enregistrements réellement
retournés par les accès aux tables ;tup_fetched
: le nombre d’enregistrements interrogés
par les accès aux tables (ces deux compteurs seront explicités dans la
vue sur les index) ;tup_inserted
: le nombre d’enregistrements insérés en
base ;tup_updated
: le nombre d’enregistrements mis à jour en
base ;tup_deleted
: le nombre d’enregistrements supprimés en
base ;conflicts
: le nombre de conflits de réplication (sur
un serveur secondaire) ;temp_files
: le nombre de fichiers temporaires
(utilisés pour le tri) créés par cette base depuis son démarrage ;temp_bytes
: le nombre d’octets correspondant à ces
fichiers temporaires : permet de trouver les bases effectuant beaucoup
de tris sur disque ;deadlocks
: le nombre de deadlocks
(interblocages) ;checksum_failures
: le nombre d’échecs lors de la
vérification d’une somme de contrôle ;checksum_last_failure
: l’horodatage du dernier échec
;blk_read_time
et blk_write_time
: le temps
passé à faire des lectures et des écritures sur le disque (si
track_io_timing = on
) ;session_time
: temps passé par les sessions sur cette
base, en millisecondes ;active_time
: temps passé par les sessions à exécuter
des requêtes SQL dans cette base ;idle_in_transaction_time
: temps passé par les sessions
dans une transaction mais sans exécuter de requête ;sessions
: nombre total de sessions établies sur cette
base ;sessions_abandoned
: nombre total de sessions sur cette
base abandonnées par le client ;sessions_fatal
: nombre total de sessions terminées par
des erreurs fatales sur cette base ;sessions_killed
: nombre total de sessions terminées
par l’administrateur ;stats_reset
: la date de dernière remise à zéro des
compteurs de cette vue.query_id
pg_stat_activity
est une des vues les plus utilisées et
est souvent le point de départ d’une recherche. Elle donne la liste des
processus en cours sur l’instance, en incluant entre autres :
pid
) ;application_name
;SELECT datname, pid, usename, application_name,
backend_start, state, backend_type, query
FROM pg_stat_activity \gx
-[ RECORD 1 ]----+-------------------------------------------------------------
datname | ¤
pid | 26378
usename | ¤
application_name |
backend_start | 2019-10-24 18:25:28.236776+02
state | ¤
backend_type | autovacuum launcher
query |
-[ RECORD 2 ]----+-------------------------------------------------------------
datname | ¤
pid | 26380
usename | postgres
application_name |
backend_start | 2019-10-24 18:25:28.238157+02
state | ¤
backend_type | logical replication launcher
query |
-[ RECORD 3 ]----+-------------------------------------------------------------
datname | pgbench
pid | 22324
usename | test_performance
application_name | pgbench
backend_start | 2019-10-28 10:26:51.167611+01
state | active
backend_type | client backend
query | UPDATE pgbench_accounts SET abalance = abalance + -3810 WHERE…
-[ RECORD 4 ]----+-------------------------------------------------------------
datname | postgres
pid | 22429
usename | postgres
application_name | psql
backend_start | 2019-10-28 10:27:09.599426+01
state | active
backend_type | client backend
query | select datname, pid, usename, application_name, backend_start…
-[ RECORD 5 ]----+-------------------------------------------------------------
datname | pgbench
pid | 22325
usename | test_performance
application_name | pgbench
backend_start | 2019-10-28 10:26:51.172585+01
state | active
backend_type | client backend
query | UPDATE pgbench_accounts SET abalance = abalance + 4360 WHERE…
-[ RECORD 6 ]----+-------------------------------------------------------------
datname | pgbench
pid | 22326
usename | test_performance
application_name | pgbench
backend_start | 2019-10-28 10:26:51.178514+01
state | active
backend_type | client backend
query | UPDATE pgbench_accounts SET abalance = abalance + 2865 WHERE…
-[ RECORD 7 ]----+-------------------------------------------------------------
datname | ¤
pid | 26376
usename | ¤
application_name |
backend_start | 2019-10-24 18:25:28.235574+02
state | ¤
backend_type | background writer
query |
-[ RECORD 8 ]----+-------------------------------------------------------------
datname | ¤
pid | 26375
usename | ¤
application_name |
backend_start | 2019-10-24 18:25:28.235064+02
state | ¤
backend_type | checkpointer
query |
-[ RECORD 9 ]----+-------------------------------------------------------------
datname | ¤
pid | 26377
usename | ¤
application_name |
backend_start | 2019-10-24 18:25:28.236239+02
state | ¤
backend_type | walwriter
query |
Les textes des requêtes sont tronqués à 1024 caractères : c’est un
problème courant. Il est conseillé de monter le paramètre
track_activity_query_size
à plusieurs kilooctets.
Cette vue fournit aussi les wait events, qui indiquent ce
qu’une session est en train d’attendre. Cela peut être très divers et
inclut la levée d’un verrou sur un objet, celle d’un verrou interne, la
fin d’une entrée-sortie… L’absence de wait event indique que la
requête s’exécute. À noter qu’une session avec un wait event
peut rester en statut active
.
Les détails sur les champs wait_event_type
(type
d’événement en attente) et wait_event
(nom de l’événement
en attente) sont disponibles dans le tableau des événements
d’attente. de la documentation.
À partir de PostgreSQL 17, la vue pg_wait_events
peut
être directement jointe à pg_stat_activity
, et son champ
description
évite d’aller voir la documentation :
SELECT datname, application_name, pid,
wait_event_type, wait_event, query, w.description
FROM pg_stat_activity a
LEFT OUTER JOIN pg_wait_events w
ON (a.wait_event_type = w.type AND a.wait_event = w.name)
WHERE backend_type='client backend'
AND wait_event IS NOT NULL
ORDER BY wait_event DESC LIMIT 4 \gx
-[ RECORD 1 ]----+-------------------------------------------------------------
datname | pgbench_20000_hdd
application_name | pgbench
pid | 786146
wait_event_type | LWLock
wait_event | WALWrite
query | UPDATE pgbench_accounts SET abalance = abalance + 4055 WHERE…
description | Waiting for WAL buffers to be written to disk
-[ RECORD 2 ]----+-------------------------------------------------------------
datname | pgbench_20000_hdd
application_name | pgbench
pid | 786190
wait_event_type | IO
wait_event | WalSync
query | UPDATE pgbench_accounts SET abalance = abalance + -1859 WHERE…
description | Waiting for a WAL file to reach durable storage
-[ RECORD 3 ]----+-------------------------------------------------------------
datname | pgbench_20000_hdd
application_name | pgbench
pid | 786145
wait_event_type | IO
wait_event | DataFileRead
query | UPDATE pgbench_accounts SET abalance = abalance + 3553 WHERE…
description | Waiting for a read from a relation data file
-[ RECORD 4 ]----+-------------------------------------------------------------
datname | pgbench_20000_hdd
application_name | pgbench
pid | 786143
wait_event_type | IO
wait_event | DataFileRead
query | UPDATE pgbench_accounts SET abalance = abalance + 1929 WHERE…
description | Waiting for a read from a relation data file
Le processus de la ligne 2 attend une synchronisation sur disque du journal de transaction (WAL), et les deux suivants une lecture d’un fichier de données.
Pour entrer dans le détail des champs liés aux connexions :
backend_type
est le type de processus : on filtrera
généralement sur client backend
, mais on y trouvera aussi
des processus de tâche de fond comme checkpointer
,
walwriter
, autovacuum launcher
et autres
processus de PostgreSQL, ou encore des workers lancés par des
extensions ;datname
est le nom de la base à laquelle la session est
connectée, et datid
est son identifiant (OID) ;pid
est le processus du backend, c’est-à-dire
du processus PostgreSQL chargé de discuter avec le client, qui durera le
temps de la session (sauf parallélisation) ;usename
est le nom de l’utilisateur connecté, et
usesysid
est son OID dans pg_roles
;application_name
est un nom facultatif, et il est
recommandé que l’application cliente le renseigne autant que possible
avec SET application_name TO 'nom_outil_client'
;client_addr
est l’adresse IP du client connecté
(NULL
si connexion sur socket Unix), et
client_hostname
est le nom associé à cette IP, renseigné
uniquement si log_hostname
a été passé à on
(cela peut ralentir les connexions à cause de la résolution DNS) ;client_port
est le numéro de port sur lequel le client
est connecté, toujours s’il s’agit d’une connexion IP.Une requête parallélisée occupe plusieurs processus, et apparaîtra
sur plusieurs lignes de pid
différents. Le champ
leader_pid
indique le processus principal. Les autres
processus disparaîtront dès la requête terminée.
Pour les champs liés aux durées de session, transactions et requêtes :
backend_start
est le timestamp de l’établissement de la
session ;xact_start
est le timestamp de début de la
transaction ;query_start
est le timestamp de début de la requête en
cours, ou de la dernière requête exécutée ;status
vaut soit active
, soit
idle
(la session ne fait rien) soit
idle in transaction
(en attente pendant une transaction) ;
backend_xid
est l’identifiant de la transaction en
cours, s’il y en a une ;backend_xmin
est l’horizon des transactions visibles,
et dépend aussi des autres transactions en cours. Rappelons qu’une session durablement en statut
idle in transaction
bloque le fonctionnement de
l’autovacuum car backend_xmin
est bloqué. Cela peut mener à
des tables fragmentées et du gaspillage de place disque.
Depuis PostgreSQL 14, pg_stat_activity
peut afficher un
champ query_id
, c’est-à-dire un identifiant de requête
normalisée (dépouillée des valeurs de paramètres). Il faut que le
paramètre compute_query_id
soit à on
ou
auto
(le défaut, et alors une extension peut l’activer). Ce
champ est utile pour retrouver une requête dans la vue de l’extension
pg_stat_statements
, par exemple.
Certains champs de cette vue ne sont renseignés que si le paramètre
track_activities
est à on
(valeur par défaut,
qu’il est conseillé de laisser ainsi).
À noter qu’il ne faut pas interroger pg_stat_activity
au
sein d’une transaction, son contenu pourrait sembler figé.
pg_cancel_backend (pid int)
pg_ctl kill INT pid
(éviter)kill -SIGINT pid
, kill -2 pid
(éviter)pg_terminate_backend(pid int, timeout bigint)
pg_ctl kill TERM pid
(éviter)kill -SIGTERM pid
, kill -15 pid
(éviter)kill -9
ou kill -SIGKILL
!!Les fonctions pg_cancel_backend
et
pg_terminate_backend
sont le plus souvent utilisées. Le
paramètre est le numéro du processus auprès de l’OS.
La première permet d’annuler une requête en cours d’exécution. Elle
requiert un argument, à savoir le numéro du PID du processus
postgres
exécutant cette requête. Généralement,
l’annulation est immédiate. Voici un exemple de son utilisation.
L’utilisateur, connecté au processus de PID 10901 comme l’indique la
fonction pg_backend_pid
, exécute une très grosse
insertion :
Supposons qu’on veuille annuler l’exécution de cette requête. Voici comment faire à partir d’une autre connexion :
L’utilisateur qui a lancé la requête d’insertion verra ce message apparaître :
Si la requête du INSERT
faisait partie d’une
transaction, la transaction elle-même devra se conclure par un
ROLLBACK
à cause de l’erreur. À noter cependant qu’il n’est
pas possible d’annuler une transaction qui n’exécute rien à ce moment.
En conséquence, pg_cancel_backend
ne suffit pas pour parer
à une session en statut idle in transaction
.
Il est possible d’aller plus loin en supprimant la connexion d’un
utilisateur. Cela se fait avec la fonction
pg_terminate_backend
qui se manie de la même manière :
SELECT pid, datname, usename, application_name,state
FROM pg_stat_activity WHERE backend_type = 'client backend' ;
procpid | datname | usename | application_name | state
---------+---------+-----------+------------------+--------
13267 | b1 | u1 | psql | idle
10901 | b1 | guillaume | psql | active
SELECT pid, datname, usename, application_name, state
FROM pg_stat_activity WHERE backend_type='client backend';
procpid | datname | usename | application_name | state
---------+---------+-----------+------------------+--------
10901 | b1 | guillaume | psql | active
L’utilisateur de la session supprimée verra un message d’erreur au
prochain ordre qu’il enverra. psql
se reconnecte
automatiquement mais cela n’est pas forcément le cas d’autres outils
client.
FATAL: terminating connection due to administrator command
la connexion au serveur a été coupée de façon inattendue
Le serveur s'est peut-être arrêté anormalement avant ou durant le
traitement de la requête.
La connexion au serveur a été perdue. Tentative de réinitialisation : Succès.
Temps : 7,309 ms
Par défaut, pg_terminate_backend
renvoie
true
dès qu’il a pu envoyer le signal, sans tester son
effet. À partir de la version 14, il est possible de préciser une durée
comme deuxième argument de pg_terminate_backend
. Dans
l’exemple suivant, on attend 2 s (2000 ms) avant de constater, ici, que
le processus visé n’est toujours pas arrêté, et de renvoyer
false
et un avertissement :
WARNING: backend with PID 178896 did not terminate within 2000 milliseconds
pg_terminate_backend
----------------------
f
Ce message ne veut pas dire que le processus ne s’arrêtera pas finalement, plus tard.
Depuis la ligne de commande du serveur, un
kill <pid>
(c’est-à-dire kill -SIGTERM
ou kill -15
) a le même effet qu’un
SELECT pg_terminate_backend (<pid>)
. Cette méthode
n’est pas recommandée car il n’y a pas de vérification que vous tuez
bien un processus postgres. pg_ctl
dispose
d’une action kill
pour envoyer un signal à un processus.
Malheureusement, là-aussi, pg_ctl
ne fait pas de différence
entre les processus postgres et les autres processus.
N’utilisez jamais kill -9 <pid>
(ou
kill -SIGKILL
), ou (sous Windows)
taskkill /f /pid <pid>
pour tuer une connexion :
l’arrêt est alors brutal, et le processus principal n’a aucun moyen de
savoir pourquoi. Pour éviter une corruption de la mémoire partagée, il
va arrêter et redémarrer immédiatement tous les processus, déconnectant
tous les utilisateurs au passage !
L’utilisation de pg_terminate_backend()
et
pg_cancel_backend()
n’est disponible que pour les
utilisateurs appartenant au même rôle que l’utilisateur à déconnecter,
les utilisateurs membres du rôle pg_signal_backend
et bien
sûr les superutilisateurs.
Quand le SSL est activé sur le serveur, cette vue indique pour chaque connexion cliente les informations suivantes :
La définition de la vue est celle-ci :
Vue « pg_catalog.pg_stat_ssl »
Colonne | Type | Collationnement | NULL-able | Par défaut
---------------+---------+-----------------+-----------+------------
pid | integer | | |
ssl | boolean | | |
version | text | | |
cipher | text | | |
bits | integer | | |
compression | boolean | | |
client_dn | text | | |
client_serial | numeric | | |
issuer_dn | text | | |
pid
: numéro du processus du backend,
c’est-à-dire du processus PostgreSQL chargé de discuter avec le
client ;ssl
: ssl activé ou non ;version
: version ssl utilisée, null si ssl
n’est pas utilisé ;cipher
: suite de chiffrement utilisée, null
si ssl n’est pas utilisé ;bits
: nombre de bits de la suite de chiffrement,
null si ssl n’est pas utilisé ;compression
: compression activée ou non, null
si ssl n’est pas utilisé ;client_dn
: champ Distinguished Name (DN) du
certificat client, null si aucun certificat client n’est
utilisé ou si ssl n’est pas utilisé ;client_serial
: numéro de série du certificat client,
null si aucun certificat client n’est utilisé ou si ssl n’est
pas utilisé ;issuer_dn
: champ Distinguished Name (DN) du
constructeur du certificat client, null si aucun certificat
client n’est utilisé ou si ssl n’est pas utilisé ;La vue pg_locks
est une vue globale à l’instance. Voici
la signification de ses colonnes :
locktype
: type de verrou, les plus fréquents étant
relation
(table ou index), transactionid
(transaction), virtualxid
(transaction virtuelle, utilisée
tant qu’une transaction n’a pas eu à modifier de données, donc à stocker
des identifiants de transaction dans des enregistrements).database
: la base dans laquelle ce verrou est
pris.relation
: si locktype vaut relation
(ou
page
ou tuple
), l’OID
de la
relation cible.page
: le numéro de la page dans une relation cible
(quand verrou de type page
ou tuple
).tuple
: le numéro de l’enregistrement cible (quand
verrou de type tuple
).virtualxid
: le numéro de la transaction virtuelle
cible (quand verrou de type virtualxid
).transactionid
: le numéro de la transaction cible.classid
: le numéro d’OID
de la classe de
l’objet verrouillé (autre que relation) dans pg_class
.
Indique le catalogue système, donc le type d’objet, concerné. Aussi
utilisé pour les advisory locks.objid
: l’OID
de l’objet dans le catalogue
système pointé par classid.objsubid
: l’ID de la colonne de l’objet objid concerné
par le verrou.virtualtransaction
: le numéro de transaction virtuelle
possédant le verrou (ou tentant de l’acquérir si granted est à
f
).pid
: le pid de la session possédant le verrou.mode
: le niveau de verrouillage demandé.granted
: acquis ou non (donc en attente).fastpath
: information utilisée pour le débuggage
surtout. Fastpath est le mécanisme d’acquisition des verrous les plus
faibles.La plupart des verrous sont de type relation, transactionid ou
virtualxid. Une transaction qui démarre prend un verrou virtualxid sur
son propre virtualxid. Elle acquiert des verrous faibles
(ACCESS SHARE
) sur tous les objets sur lesquels elle fait
des SELECT
, afin de garantir que leur structure n’est pas
modifiée sur la durée de la transaction. Dès qu’une modification doit
être faite, la transaction acquiert un verrou exclusif sur le numéro de
transaction qui vient de lui être affecté. Tout objet modifié (table)
sera verrouillé avec ROW EXCLUSIVE
, afin d’éviter les
CREATE INDEX
non concurrents, et empêcher aussi les
verrouillage manuels de la table en entier
(SHARE ROW EXCLUSIVE
).
log_lock_waits
à on
Le paramètre log_lock_waits
permet d’activer la trace
des attentes de verrous. Toutes les attentes ne sont pas tracées, seules
les attentes qui dépassent le seuil indiqué par le paramètre
deadlock_timeout
. Ce paramètre indique à partir de quand
PostgreSQL doit résoudre les deadlocks potentiels entre plusieurs
transactions.
Comme il s’agit d’une opération assez lourde, elle n’est pas déclenchée lorsqu’une session est mise en attente, mais lorsque l’attente dure plus d’une seconde, si l’on reste sur la valeur par défaut du paramètre. En complément de cela, PostgreSQL peut tracer les verrous qui nécessitent une attente et qui ont déclenché le lancement du gestionnaire de deadlock. Une nouvelle trace est émise lorsque la session a obtenu son verrou.
À chaque fois qu’une requête est mise en attente parce qu’une autre transaction détient un verrou, un message tel que le suivant apparaît dans les logs de PostgreSQL :
LOG: process 2103 still waiting for ShareLock on transaction 29481
after 1039.503 ms
DETAIL: Process holding the lock: 2127. Wait queue: 2103.
CONTEXT: while locking tuple (1,3) in relation "clients"
STATEMENT: SELECT * FROM clients WHERE client_id = 100 FOR UPDATE;
Lorsque le client obtient le verrou qu’il attendait, le message suivant apparaît dans les logs :
LOG: process 2103 acquired ShareLock on transaction 29481 after 8899.556 ms
CONTEXT: while locking tuple (1,3) in relation "clients"
STATEMENT: SELECT * FROM clients WHERE client_id = 100 FOR UPDATE;
L’inconvénient de cette méthode est qu’il n’y a aucune trace de la session qui a mis une ou plusieurs autres sessions en attente. Si l’on veut obtenir le détail de ce que réalise cette session, il est nécessaire d’activer la trace des requêtes SQL.
log_connections
et
log_disconnections
Les paramètres log_connections
et
log_disconnections
permettent d’activer les traces de
toutes les connexions réalisées sur l’instance.
La connexion d’un client, lorsque sa connexion est acceptée, entraîne la trace suivante :
LOG: connection received: host=::1 port=45837
LOG: connection authorized: user=workshop database=workshop
Si la connexion est rejetée, l’événement est également tracé :
LOG: connection received: host=[local]
FATAL: pg_hba.conf rejects connection for host "[local]", user "postgres",
database "postgres", SSL off
Une déconnexion entraîne la production d’une trace de la forme suivante :
Ces traces peuvent être exploitées par des outils comme pgBadger. Toutefois, pgBadger n’ayant pas accès à l’instance observée, il ne sera pas possible de déterminer quels sont les utilisateurs qui sont connectés de manière permanente à la base de données. Cela permet néanmoins de déterminer le volume de connexions réalisées sur la base de données, par exemple pour évaluer si un pooler de connexion serait intéressant.
Pour une table :
pg_relation_size
: heappg_table_size
: + TOAST + diversIndex : pg_indexes_size
Table + index : pg_total_relation_size
Plus lisibles avec pg_size_pretty
Une table comprend différents éléments : la partie principale ou main (ou heap) ; pas toujours la plus grosse ; des objets techniques comme la visibility map ou la Free Space Map ou l’init ; parfois des données dans une table TOAST associée ; et les éventuels index. La « taille » de la table dépend donc de ce que l’on entend précisément.
pg_relation_size
donne la taille de la relation, par
défaut de la partie main, mais on peut demander aussi les
parties techniques. Elle fonctionne aussi pour la table TOAST si l’on a
son nom ou son OID.
pg_total_relation_size
fournit la taille totale de tous
les éléments, dont les index et la partie TOAST.
pg_table_size
renvoie la taille de la table avec le
TOAST et les parties techniques, mais sans les index (donc
essentiellement les données).
pg_indexes_size
calcule la taille totale des index d’une
table.
Toutes ces fonctions acceptent en paramètre soit un OID soit le nom en texte.
Voici un exemple d’une table avec deux index avec les quatre fonctions :
CREATE UNLOGGED TABLE donnees_aleatoires (
i int PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
a text);
-- 6000 lignes de blancs
INSERT INTO donnees_aleatoires (a)
SELECT repeat (' ',2000) FROM generate_series (1,6000);
-- Pour la Visibility Map
VACUUM donnees_aleatoires ;
SELECT pg_relation_size('donnees_aleatoires'), -- partie 'main'
pg_relation_size('donnees_aleatoires', 'vm') AS "pg_relation_size (,vm)",
pg_relation_size('donnees_aleatoires', 'fsm') AS "pg_relation_size (,fsm)",
pg_relation_size('donnees_aleatoires', 'init') AS "pg_relation_size (,init)",
pg_table_size ('donnees_aleatoires'),
pg_indexes_size ('donnees_aleatoires'),
pg_total_relation_size('donnees_aleatoires')
\gx
-[ RECORD 1 ]------------+---------
pg_relation_size | 12288000
pg_relation_size (,vm) | 8192
pg_relation_size (,fsm) | 24576
pg_relation_size (,init) | 0
pg_table_size | 12337152
pg_indexes_size | 163840
pg_total_relation_size | 12500992
La fonction pg_size_pretty
est souvent utilisée pour
renvoyer un texte plus lisible :
SELECT pg_size_pretty(pg_relation_size('donnees_aleatoires'))
AS pg_relation_size,
pg_size_pretty(pg_relation_size('donnees_aleatoires', 'vm'))
AS "pg_relation_size (,vm)",
pg_size_pretty(pg_relation_size('donnees_aleatoires', 'fsm'))
AS "pg_relation_size (,fsm)",
pg_size_pretty(pg_relation_size('donnees_aleatoires', 'init'))
AS "pg_relation_size (,init)",
pg_size_pretty(pg_table_size('donnees_aleatoires'))
AS pg_table_size,
pg_size_pretty(pg_indexes_size('donnees_aleatoires'))
AS pg_indexes_size,
pg_size_pretty(pg_total_relation_size('donnees_aleatoires'))
AS pg_total_relation_size
\gx
-[ RECORD 1 ]------------+-----------
pg_relation_size | 12 MB
pg_relation_size (,vm) | 8192 bytes
pg_relation_size (,fsm) | 24 kB
pg_relation_size (,init) | 0 bytes
pg_table_size | 12 MB
pg_indexes_size | 160 kB
pg_total_relation_size | 12 MB
Ajoutons des données peu compressibles pour la partie TOAST :
\COPY donnees_aleatoires(a) FROM PROGRAM 'cat /dev/urandom|tr -dc A-Z|fold -bw 5000|head -n 5000' ;
VACUUM ANALYZE donnees_aleatoires ;
SELECT
oid AS table_oid,
c.relnamespace::regnamespace || '.' || relname AS TABLE,
reltoastrelid,
reltoastrelid::regclass::text AS toast_table,
reltuples AS nb_lignes_estimees,
pg_size_pretty(pg_table_size(c.oid)) AS " Table",
pg_size_pretty(pg_relation_size(c.oid, 'main')) AS " Heap",
pg_size_pretty(pg_relation_size(c.oid, 'vm')) AS " VM",
pg_size_pretty(pg_relation_size(c.oid, 'fsm')) AS " FSM",
pg_size_pretty(pg_relation_size(c.oid, 'init')) AS " Init",
pg_size_pretty(pg_total_relation_size(reltoastrelid)) AS " Toast",
pg_size_pretty(pg_indexes_size(c.oid)) AS " Index",
pg_size_pretty(pg_total_relation_size(c.oid)) AS "Total"
FROM pg_class c
WHERE relkind = 'r'
AND relname = 'donnees_aleatoires'
\gx
-[ RECORD 1 ]------+--------------------------
table_oid | 4200073
table | public.donnees_aleatoires
reltoastrelid | 4200076
toast_table | pg_toast.pg_toast_4200073
nb_lignes_estimees | 6000
Table | 40 MB
Heap | 12 MB
VM | 8192 bytes
FSM | 24 kB
Init | 0 bytes
Toast | 28 MB
Index | 264 kB
Total | 41 MB
Le wiki contient d’autres exemples, notamment sur le calcul de la taille totale d’une table partitionnée.
pgstattuple
pgstattuple_approx()
(tables)check_pgactivity
La fragmentation des tables et index est inhérente à l’implémentation
de MVCC de PostgreSQL. Elle est contenue grâce à VACUUM
et
surtout à autovacuum. Cependant, certaines utilisations de la base de
données peuvent entraîner une fragmentation plus importante que prévue
(transaction ouverte pendant plusieurs jours, purge massive, etc.), puis
des ralentissements de la base de données. Il est donc nécessaire de
pouvoir détecter les cas où la base présente une fragmentation trop
élevée.
La fragmentation recouvre deux types d’espaces : les lignes mortes à nettoyer, et l’espace libre et utilisable, parfois excessif.
Estimation rapide :
pg_stat_user_tables.n_dead_tup
à une valeur élevée est
déjà un indicateur qu’un VACUUM
est nécessaire.
De manière plus complète, les requêtes de Jehan-Guillaume de Rorthais
dans le dépôt indiqué ci-dessus permettent d’évaluer indépendamment la
fragmentation des tables et des index. Elles sont utilisées dans la
sonde check_pgactivity
, qui permet d’être alerté
automatiquement dès lors qu’une ou plusieurs tables/index présentent une
fragmentation trop forte, c’est-à-dire un espace (mort ou réutilisable)
excessif
Attention : il s’agit seulement d’une estimation de la fragmentation
d’une table. Les statistiques (ANALYZE
) doivent être
fraîches. Dans certains cas, l’estimation n’est pas très précise. Par
contre elle est très rapide.
Calcul précis :
Pour mesurer très précisément la fragmentation d’une table ou d’un index, il faut installer l’extension pgstattuple. Celle-ci par contre est susceptible de lire toute la table, ce qui est donc long.
Il existe une fonction pgstattuple()
pour les tables et
index, et une fonction pgstatindex()
plus précise pour les
index.
Une autre fonction, pgstattuple_approx()
, se base sur la
visibility map et la Free Space Map. Elle ne
fonctionne que pour les tables. Elle est moins précise mais plus rapide
que pgstattuple()
, mais reste plus lente que l’estimation
basée sur les statistiques.
Exemple :
Les ordres ci-dessous génèrent de la fragmentation dans une table de 42 Mo dont on efface 90 % des lignes :
CREATE EXTENSION IF NOT EXISTS pgstattuple;
DROP TABLE IF EXISTS demo_bloat ;
CREATE TABLE demo_bloat (i integer, filler char(10) default ' ');
-- désactivation de l'autovacuum pour l'exemple
ALTER TABLE demo_bloat SET (autovacuum_enabled=false);
-- insertion puis suppression de 90% des lignes
INSERT INTO demo_bloat SELECT i FROM generate_series(1, 1000000) i ;
DELETE FROM demo_bloat WHERE i < 900000 ;
-[ RECORD 1 ]-------+-----------
relid | 10837034
schemaname | public
relname | demo_bloat
seq_scan | 1
seq_tup_read | 1000000
idx_scan |
idx_tup_fetch |
n_tup_ins | 1000000
n_tup_upd | 0
n_tup_del | 899999
n_tup_hot_upd | 0
n_live_tup | 100001
n_dead_tup | 899999
n_mod_since_analyze | 1899999
n_ins_since_vacuum | 1000000
last_vacuum |
last_autovacuum |
last_analyze |
last_autoanalyze |
vacuum_count | 0
autovacuum_count | 0
analyze_count | 0
autoanalyze_count | 0
n_dead_tup
(lignes mortes) est ici très élevé.
L’estimation retournée par la requête d’estimation proposée plus haut est ici très proche de la réalité car les statistiques sont fraîches :
(…)
-[ RECORD 41 ]---+------------------------
current_database | postgres
schemaname | public
tblname | demo_bloat
real_size | 44285952
extra_size | 39870464
extra_pct | 90.02959674435812
fillfactor | 100
bloat_size | 39870464
bloat_pct | 90.02959674435812
(…)
Le bloat et l’espace « en trop » (extra) sont tous les deux à 90 % car le fillfactor est de 100 %.
Avec pgstattuple()
, les colonnes free_space
et free_percent
donnent la taille et le pourcentage
d’espace libre :
-[ RECORD 1 ]------+-------
table_len | 44285952
tuple_count | 100001
tuple_len | 3900039
tuple_percent | 8.81
dead_tuple_count | 899999
dead_tuple_len | 35099961
dead_tuple_percent | 79.26
free_space | 134584
free_percent | 0.3
Il n’y a presque pas d’espace libre (free) car beaucoup de
lignes sont encore mortes (dead_tuple_percent
indique 79 %
de lignes mortes).
La fonction d’approximation est ici plus rapide (deux fois moins de blocs lus dans ce cas précis) pour le même résultat :
-[ RECORD 1 ]--------+-------------------
table_len | 44285952
scanned_percent | 100
approx_tuple_count | 100001
approx_tuple_len | 3900039
approx_tuple_percent | 8.80649240644076
dead_tuple_count | 899999
dead_tuple_len | 35099961
dead_tuple_percent | 79.2575510175326
approx_free_space | 134584
approx_free_percent | 0.3038977235941546
Si on nettoie la table, on retrouve 90 % d’espace réellement libre :
-[ RECORD 1 ]------+-------
table_len | 44285952
tuple_count | 100001
tuple_len | 3900039
tuple_percent | 8.81
dead_tuple_count | 0
dead_tuple_len | 0
dead_tuple_percent | 0
free_space | 39714448
free_percent | 89.68
(La fonction approximative renverra presque les mêmes chiffres :
-[ RECORD 1 ]--------+-------------------
table_len | 44285952
scanned_percent | 0
approx_tuple_count | 100001
approx_tuple_len | 4584480
approx_tuple_percent | 10.351996046059934
dead_tuple_count | 0
dead_tuple_len | 0
dead_tuple_percent | 0
approx_free_space | 39701472
approx_free_percent | 89.64800395394006
Le résultat de la requête d’estimation ne changera pas, indiquant toujours 90 % de bloat.
Le choix de la bonne requête dépendra de ce que l’on veut. Si l’on
cherche juste à savoir si un VACUUM FULL
est nécessaire,
l’estimation suffit généralement et est très rapide. Si l’on suspecte
que l’estimation est fausse et que l’on a plus de temps, les deux
fonctions de pgstattuple
sont plus précises.
Contrairement aux vues précédentes, cette vue est locale à chaque base.
Voici la définition de ses colonnes :
relid
, relname
: OID
et nom
de la table concernée ;schemaname
: le schéma contenant cette table ;seq_scan
: nombre de parcours séquentiels sur cette
table ;last_seq_scan
: timestamp du dernier parcours
séquentiel sur cette table ;seq_tup_read
: nombre d’enregistrements accédés par ces
parcours séquentiels ;idx_scan
: nombre de parcours d’index sur cette
table ;last_idx_scan
: timestamp du dernier parcours d’index
sur cette table ;idx_tup_fetch
: nombre d’enregistrements accédés par
ces parcours séquentiels ;n_tup_ins
, n_tup_upd
,
n_tup_del
: nombre d’enregistrements insérés, mis à jour (y
compris ceux comptés dans n_tup_hot_upd
et
n_tup_newpage_upd
) ou supprimés ;n_tup_hot_upd
: nombre d’enregistrements mis à jour par
mécanisme HOT (c’est-à-dire chaînés au sein d’un même bloc) ;n_tup_newpage_upd
: nombre de mises à jour ayant
nécessité d’aller écrire la nouvelle ligne dans un autre bloc, faute de
place dans le bloc d’origine (à partir de PostgreSQL 16) ;n_live_tup
: estimation du nombre d’enregistrements
« vivants » ;n_dead_tup
: estimation du nombre d’enregistrements
« morts » (supprimés mais non nettoyés) depuis le dernier
VACUUM
;n_mod_since_analyze
: nombre d’enregistrements modifiés
depuis le dernier ANALYZE
;n_ins_since_vacuum
: estimation du nombre
d’enregistrements insérés depuis le dernier VACUUM
;last_vacuum
: timestamp du dernier
VACUUM
;last_autovacuum
: timestamp du dernier
VACUUM
automatique ;last_analyze
: timestamp du dernier
ANALYZE
;last_autoanalyze
: timestamp du dernier
ANALYZE
automatique ;vacuum_count
: nombre de VACUUM
manuels ;autovacuum_count
: nombre de VACUUM
automatiques ;analyze_count
: nombre d’ANALYZE
manuels ;autoanalyze_count
: nombre d’ANALYZE
automatiques.Contrairement aux autres colonnes, les colonnes
n_live_tup
, n_dead_tup
et
n_mod_since_analyze
sont des estimations. Leur valeurs
changent au fur et à mesure de l’exécution de commandes
INSERT
, UPDATE
, DELETE
. Elles
sont aussi recalculées complètement lors de l’exécution d’un
VACUUM
et d’un ANALYZE
. De ce fait, leur
valeur peut changer entre deux VACUUM
même si aucune
écriture de ligne n’a eu lieu.
Voici la liste des colonnes de cette vue :
relid
, relname
: OID
et nom
de la table qui possède l’indexindexrelid
, indexrelname
:
OID
et nom de l’index en questionschemaname
: schéma contenant l’indexidx_scan
: nombre de parcours de cet indexidx_tup_read
: nombre d’enregistrements retournés par
cet indexidx_tup_fetch
: nombre d’enregistrements accédés sur la
table associée à cet indexidx_tup_read
et idx_tup_fetch
retournent
des valeurs différentes pour plusieurs raisons :
idx_tup_read
sera
supérieure à celle de idx_tup_fetch
.Dans tous les cas, ce qu’on surveille le plus souvent dans cette vue,
c’est tout d’abord les index ayant idx_scan
à 0. Ils sont
le signe d’un index qui ne sert probablement à rien. La seule exception
éventuelle étant un index associé à une contrainte d’unicité (et donc
aussi les clés primaires), les parcours de l’index réalisés pour
vérifier l’unicité n’étant pas comptabilisés dans cette vue.
Les autres indicateurs intéressants sont un nombre de
tup_read
très grand par rapport aux parcours d’index, qui
peuvent suggérer un index trop peu sélectif, et une grosse différence
entre les colonnes idx_tup_read
et
idx_tup_fetch
. Ces indicateurs ne permettent cependant pas
de conclure quoi que ce soit par eux-même, ils peuvent seulement donner
des pistes d’amélioration.
Voici la description des différentes colonnes de
pg_statio_user_tables
:
Vue « pg_catalog.pg_statio_user_tables »
Colonne | Type | Collationnement | NULL-able | Par défaut
-----------------+--------+-----------------+-----------+------------
relid | oid | | |
schemaname | name | | |
relname | name | | |
heap_blks_read | bigint | | |
heap_blks_hit | bigint | | |
idx_blks_read | bigint | | |
idx_blks_hit | bigint | | |
toast_blks_read | bigint | | |
toast_blks_hit | bigint | | |
tidx_blks_read | bigint | | |
tidx_blks_hit | bigint | | |
relid
,relname
: OID
et nom de
la table ;schemaname
: nom du schéma contenant la table ;heap_blks_read
: nombre de blocs accédés de la table
demandés au système d’exploitation. Heap
signifie
tas, et ici données non triées, par opposition aux
index ;heap_blks_hit
: nombre de blocs accédés de la table
trouvés dans le cache de PostgreSQL ;idx_blks_read
: nombre de blocs accédés de l’index
demandés au système d’exploitation ;idx_blks_hit
: nombre de blocs accédés de l’index
trouvés dans le cache de PostgreSQL ;toast_blks_read
, toast_blks_hit
,
tidx_blks_read
, tidx_blks_hit
: idem que
précédemment, mais pour la partie TOAST des tables et index.Et voici la description des différentes colonnes de
pg_statio_user_indexes
:
Vue « pg_catalog.pg_statio_user_indexes »
Colonne | Type | Collationnement | NULL-able | Par défaut
---------------+--------+-----------------+-----------+------------
relid | oid | | |
indexrelid | oid | | |
schemaname | name | | |
relname | name | | |
indexrelname | name | | |
idx_blks_read | bigint | | |
idx_blks_hit | bigint | | |
indexrelid
, indexrelname
:
OID
et nom de l’index ;idx_blks_read
: nombre de blocs accédés de l’index
demandés au système d’exploitation ;idx_blks_hit
: nombre de blocs accédés de l’index
trouvés dans le cache de PostgreSQL.Pour calculer un hit ratio, qui est un indicateur fréquemment utilisé, on utilise la formule suivante (cet exemple cible uniquement les index) :
SELECT schemaname,
indexrelname,
relname,
idx_blks_hit::float/CASE idx_blks_read+idx_blks_hit
WHEN 0 THEN 1 ELSE idx_blks_read+idx_blks_hit END
FROM pg_statio_user_indexes;
Notez que idx_blks_hit::float
convertit le numérateur en
type float
, ce qui entraîne que la division est à virgule
flottante (pour ne pas faire une division entière qui renverrait souvent
0), et que le CASE
est destiné à éviter une division par
zéro.
Vue synthétique des opérations disques selon :
Penser à activer track_io_timing
La nouvelle vue pg_stat_io
permet d’obtenir des
informations sur les opérations faites sur disques. Il y a différents
compteurs : reads (lectures), writes (écritures),
read_time et write_time (durées associées aux
précédents), extends (extensions de fichiers), hits
(lecture en cache de PostgreSQL), evictions (éviction du
cache), etc. Ils sont calculés pour chaque combinaison de type de
backend, objet I/O cible et contexte I/O. Les définitions des colonnes
et des compteurs peuvent être trouvées dans la la
documentation officielle.
Comme la plupart des vues statistiques, les données sont cumulatives. Une remise à zéro s’effectue avec :
Les champs *_time
ne sont alimentés que si le paramètre
track_io_timing
a été activé. Ne sont pas tracées certaines
opérations qui ne passent pas par le cache disque, comme les
déplacements de table entre tablespace.
Exemples :
Si nous voulons connaître les opérations qui ont les durées de lectures hors du cache les plus longues :
SELECT backend_type, object, context, reads, read_time
FROM pg_stat_io
ORDER BY read_time DESC NULLS LAST LIMIT 3 ;
backend_type | object | context | reads | read_time
-------------------+----------+----------+-----------+---------------
client backend | relation | normal | 640840357 | 738717779.603
autovacuum worker | relation | vacuum | 117320999 | 16634388.118
background worker | relation | bulkread | 44481246 | 9749622.473
Le résultat indique que ce temps est essentiellement dépensé par des backends client, sur des tables non temporaires, dans un contexte « normal » (via les shared buffers). La présence de reads massifs indique peut-être des shared buffers trop petits (si les requêtes sont optimisées).
Une requête similaire pour les écritures est :
SELECT backend_type, object, context, writes, round(write_time) AS write_time
FROM pg_stat_io ORDER BY write_time DESC NULLS LAST LIMIT 3 ;
backend_type | object | context | writes | write_time
-------------------+----------+---------+--------+------------
checkpointer | relation | normal | 435117 | 14370
background writer | relation | normal | 74684 | 1049
client backend | relation | vacuum | 25941 | 123
Ici, les écritures sont faites essentiellement par les checkpoints,
accessoirement le background writer
, ce qui est idéal.
Par contre, si la même requête renvoie ceci :
SELECT backend_type, object, context, writes, round(write_time) AS write_time
FROM pg_stat_io ORDER BY write_time DESC NULLS LAST LIMIT 5 ;
backend_type | object | context | writes | write_time
-------------------+----------+-----------+----------+------------
client backend | relation | normal | 82556667 | 3770829
autovacuum worker | relation | vacuum | 94262005 | 1847367
checkpointer | relation | normal | 74210966 | 632146
client backend | relation | bulkwrite | 47901524 | 206759
background writer | relation | normal | 10315801 | 147621
on en déduit que les backends écrivent beaucoup par eux-mêmes, un peu
plus en nombre d’écritures que le checkpointer
. Cela
suggère que le background writer
n’est pas assez agressif.
Noter que les autovacuum workers procèdent aussi eux-mêmes à
leurs écritures. Enfin le contexte bulkwrite indique
l’utilisation de modes d’écritures en masse (par exemple des
CREATE TABLE … AS …
).
log_min_duration_statements
= <temps minimal
d’exécution>
0
permet de tracer toutes les requêteslog_min_duration_sample
= <temps minimal
d’exécution>
log_statement_sample_rate
et/ou
log_transaction_sample_rate
Le paramètre log_min_duration_statements
permet
d’activer une trace sélective des requêtes lentes. Le paramètre accepte
plusieurs valeurs :
-1
pour désactiver la trace,0
pour tracer systématiquement toutes les requêtes
exécutées,Si le temps d’exécution d’une requête dépasse le seuil défini par le
paramètre log_min_duration_statements
, PostgreSQL va alors
tracer le temps d’exécution de la requête, ainsi que ces paramètres
éventuels. Par exemple :
LOG: duration: 43.670 ms statement:
SELECT DISTINCT c.numero_commande,
c.date_commande, lots.numero_lot, lots.numero_suivi FROM commandes c
JOIN lignes_commandes l ON (c.numero_commande = l.numero_commande)
JOIN lots ON (l.numero_lot_expedition = lots.numero_lot)
WHERE c.numero_commande = 72199;
Ces traces peuvent ensuite être exploitées par l’outil pgBadger qui pourra établir un rapport des requêtes les plus fréquentes, des requêtes les plus lentes, etc.
Cependant, tracer toutes les requêtes peut poser problème. Le contournement habituel est de ne tracer que les requêtes dont l’exécution est supérieure à une certaine durée, mais cela cache tout le restant du trafic qui peut être conséquent et avoir un impact sur les performances globales du système. En version 13, une nouvelle fonctionnalité a été ajoutée : tracer un certain ratio de requêtes ou de transactions.
Si log_statement_sample_rate
est configuré à une valeur
strictement supérieure à zéro, la valeur correspondra au pourcentage de
requêtes à tracer. Par exemple, en le configuration à 0,5, une requête
sur deux sera tracée. Les requêtes réellement tracées dépendent de leur
durée d’exécution. Cette durée doit être supérieure ou égale à la valeur
du paramètre log_min_duration_sample
.
Ce comportement est aussi disponible pour les transactions. Pour
cela, il faut configurer le paramètre
log_transaction_sample_rate
.
log_temp_files = <taille minimale>
0
trace tous les fichiers temporairesLe paramètre log_temp_files
permet de tracer les
fichiers temporaires générés par les requêtes SQL. Il est généralement
positionné à 0 pour tracer l’ensemble des fichiers temporaires, et donc
de s’assurer que l’instance n’en génère que rarement.
Par exemple, la trace suivante est produite lorsqu’une requête génère un fichier temporaire :
LOG: temporary file: path "base/pgsql_tmp/pgsql_tmp2181.0", size 276496384
STATEMENT: select * from lignes_commandes order by produit_id;
Si une requête nécessite de générer plusieurs fichiers temporaires, chaque fichier temporaire sera tracé individuellement. pgBadger permet de réaliser une synthèse des fichiers temporaires générés et propose un rapport sur les requêtes générant le plus de fichiers temporaires et permet donc de cibler l’optimisation.
pg_stat_statements
Contrairement à pgBadger, pg_stat_statements
ne
nécessite pas de tracer les requêtes exécutées. Il est connecté
directement à l’exécuteur de requêtes qui fait appel à lui à chaque fois
qu’il a exécuté une requête. pg_stat_statements
a ainsi
accès à beaucoup d’informations. Certaines sont placées en mémoire
partagée et accessible via une vue statistique appelée
pg_stat_statements
. Les requêtes sont normalisées
(reconnues comme identiques même avec des paramètres différents), et
identifiables grâce à un queryid
. Une même requête peut
apparaître sur plusieurs lignes de pg_stat_statements
pour
des bases et utilisateurs différents. Par contre, l’utilisation de
schémas, implicitement ou pas, force un queryid
différent.
L’installation et quelques exemples de requêtes sont proposés dans https://dali.bo/x2_html#pg_stat_statements.
Voici un exemple de requête sur la vue
pg_stat_statements
:
-[ RECORD 1 ]---+--------------------------------------------------------
userid | 10
dbid | 63781
toplevel | t
queryid | -1739183385080879393
query | UPDATE branches SET bbalance = bbalance + $1 WHERE bid = $2;
plans | 0
[...]
calls | 3000
total_exec_time | 20.716706
[...]
rows | 3000
[...]
-[ RECORD 2 ]---+--------------------------------------------------------
userid | 10
dbid | 63781
toplevel | t
queryid | -1737296385080879394
query | UPDATE tellers SET tbalance = tbalance + $1 WHERE tid = $2;
plans | 0
[...]
calls | 3000
total_exec_time | 17.1107649999999
[...]
rows | 3000
[...]
pg_stat_statements
possède des paramètres de
configuration pour indiquer le nombre maximum d’instructions tracées, la
sauvegarde des statistiques entre chaque démarrage du serveur, etc.
Métriques intéressantes :
total_exec_time
min_exec_time
/max_exec_time
stddev_exec_time
mean_exec_time
_exec
dans leur nomrows
pg_stat_statements
apporte des statistiques sur les
durées d’exécutions des requêtes normalisées. Notamment :
total_exec_time
: temps d’exécution total ;min_exec_time
et max_exec_time
: durées
d’exécution minimale et maximale d’une requête normalisée ;mean_exec_time
: durée moyenne d’exécution ;stddev_exec_time
: écart-type de la durée d’exécution,
une métrique intéressante pour identifier une requête dont le temps
d’exécution varie fortement ;rows
: nombre total de lignes retournées.total_plan_time
min_plan_time
/max_plan_time
stddev_plan_time
mean_plan_time
pg_stat_statements
apporte des statistiques sur les
durées d’optimisation des requêtes normalisées. Ainsi,
total_plan_time
indique le cumul d’optimisation total.
min_plan_time
et max_plan_time
représentent
respectivement la durée d’optimisation minimale et maximale d’une
requête normalisée. La colonne mean_plan_time
donne la
durée moyenne d’optimisation alors que la colonne
stddev_plan_time
donne l’écart-type de la durée
d’optimisation. Cette métrique peut être intéressante pour identifier
une requête dont le temps d’optimisation varie fortement.
Toutes ces colonnes ne sont disponibles qu’à partir de la version 13.
shared_blks_hit/read/dirtied/written
local_blks_hit/read/dirtied/written
temp_blks_read/written
blk_read_time/blk_write_time
pg_stat_statements
fournit également des métriques sur
les accès aux blocs.
Lors des accès à la mémoire partagée (shared buffers), les compteurs suivants peuvent être incrémentés :
shared_blks_hit
: nombre de blocs lus directement dans
le cache de PostgreSQL ;shared_blks_read
: blocs lus demandés au système
d’exploitation (donc lus sur le disque ou dans le cache du
système) ;shared_blks_dirtied
: nouveaux blocs « sales » générés
par la requête par des mises à jour, insertions, suppressions,
VACUUM
…, et sans compter ceux qui l’étaient déjà
auparavant ; ces blocs seront écrits sur disque ultérieurement ;shared_blks_written
: blocs directements écrits sur
disque, ce qui peut arriver s’il n’y a plus de place en mémoire partagée
(un processus backend peut nettoyer des pages dirty
sur disque pour libérer des pages en mémoire partagée, certaines
commandes peuvent être plus agressives).Des métriques similaires sont local_blks_*
pour les
accès à la mémoire du backend, pour les objets temporaires
(tables temporaires, index sur tables temporaires…). Ces derniers ne
nécessitent pas d’être partagés avec les autres sessions.
Les métriques temp_blks_read
et
temp_blks_written
correspondent au nombre de blocs lus et
écris depuis le disque dans des fichiers temporaires. Cela survient par
exemple lorsqu’un tri ou le retour d’une fonction multiligne ne rentre
pas dans le work_mem
.
Les métriques finissant par _time
sont des cumuls des
durées de lectures et écritures des accès sur disques. Il faut activer
track_io_timing
pour qu’elles soient remplies.
wal_records
wal_fpi
wal_bytes
pg_stat_statements
apporte des statistiques sur les
écritures dans les journaux de transactions. wal_records
,
wal_fpi
, wal_bytes
correspondent
respectivement au nombre d’enregistrements, au nombre de Full Page
Images (blocs entiers, de 8 ko généralement, écrits intégralement
quand un bloc est écrit pour la première fois après un checkpoint), et
au nombre d’octets écrits dans les journaux de transactions lors de
l’exécution de cette requête.
On peut ainsi suivre les requêtes créant de nombreux journaux.
jit_functions
jit_generation_time
pg_stat_statements
apporte des statistiques sur les
durées d’optimisation via JIT. Toutes les informations fournies par un
EXPLAIN ANALYZE
sont disponibles dans cette vue. Cette
métrique peut être intéressante pour comprendre si JIT améliore bien la
durée d’exécution des requêtes.
Liste des colonnes disponibles :
jit_functions
jit_generation_time
jit_inlining_count
jit_inlining_time
jit_optimization_count
jit_optimization_time
jit_emission_count
jit_emission_time
Toutes ces colonnes ne sont disponibles qu’à partir de la version 15.
pg_stat_activity
wait_event
et
wait_event_type
pg_locks
granted
waitstart
(v14+)pg_blocking_pids
Lors de l’exécution d’une requête, le processus chargé de cette exécution va tout d’abord récupérer les verrous dont il a besoin. En cas de conflit, la requête est mise en attente. Cette attente est visible à deux niveaux :
wait_event
et
wait_event_type
de la vue
pg_stat_activity
;granted
de la vue
pg_locks
.C’est une vue globale à l’instance :
Vue « pg_catalog.pg_locks »
Colonne | Type | … | NULL-able | Par défaut
--------------------+--------------------------+---+-----------+------------
locktype | text | | |
database | oid | | |
relation | oid | | |
page | integer | | |
tuple | smallint | | |
virtualxid | text | | |
transactionid | xid | | |
classid | oid | | |
objid | oid | | |
objsubid | smallint | | |
virtualtransaction | text | | |
pid | integer | | |
mode | text | | |
granted | boolean | | |
fastpath | boolean | | |
waitstart | timestamp with time zone | | |
Il est ensuite assez simple de trouver qui bloque qui. Prenons par exemple deux sessions, une dans une transaction qui a lu une table :
La deuxième session cherche à supprimer cette table :
Elle se trouve bloquée. La première session ayant lu cette table,
elle a posé pendant la lecture un verrou d’accès partagé
(AccessShareLock
) pour éviter une suppression ou une
redéfinition de la table pendant la lecture. Les verrous étant conservés
pendant toute la durée d’une transaction, la transaction restant
ouverte, le verrou reste. La deuxième session veut supprimer la table.
Pour réaliser cette opération, elle doit obtenir un verrou exclusif sur
cette table, verrou qu’elle ne peut pas obtenir vu qu’il y a déjà un
autre verrou sur cette table. L’opération de suppression est donc
bloquée, en attente de la fin de la transaction de la première session.
Comment peut-on le voir ? tout simplement en interrogeant les tables
pg_stat_activity
et pg_locks
.
Avec pg_stat_activity
, nous pouvons savoir quelle
session est bloquée :
SELECT pid, query FROM pg_stat_activity
WHERE wait_event_type = 'Lock' AND backend_type='client backend' ;
Pour savoir de quel verrou a besoin le processus 17396, il faut
interroger la vue pg_locks
:
locktype | relation | pid | mode | granted
----------+----------+-------+---------------------+---------
relation | 24581 | 17396 | AccessExclusiveLock | f
Le processus 17396 attend un verrou sur la relation 24581. Reste à savoir qui dispose d’un verrou sur cet objet :
locktype | relation | pid | mode | granted
----------+----------+-------+-----------------+---------
relation | 24581 | 17276 | AccessShareLock | t
Il s’agit du processus 17276. Et que fait ce processus ?
usename | datname | state | query
----------+----------+---------------------+---------------------------
postgres | postgres | idle in transaction | select * from t2 limit 1;
Nous retrouvons bien notre session en transaction.
Depuis PostgreSQL 9.6, on peut aller plus vite, avec la fonction
pg_blocking_pids()
, qui renvoie les PID des sessions
bloquant une session particulière.
Le processus 17276 bloque bien le processus 17396.
Depuis la version 14, la colonne waitstart
de la vue
pg_locks
indique depuis combien de temps la session est en
attente du verrou.
Opération | Vue de suivi | PostgreSQL |
---|---|---|
VACUUM |
pg_stat_progress_vacuum |
9.6 |
ANALYZE |
pg_stat_progress_analyze |
13 |
CLUSTER |
pg_stat_progress_cluster |
12 |
VACUUM FULL |
pg_stat_progress_cluster |
12 |
CREATE INDEX |
pg_stat_progress_create_index |
12 |
BASE BACKUP |
pg_stat_progress_basebackup |
13 |
COPY |
pg_stat_progress_copy |
14 |
Il est possible de suivre l’exécution d’un VACUUM
par
l’intermédiaire de la vue pg_stat_progress_vacuum
. Elle
contient une ligne par VACUUM
en cours d’exécution. Voici
un exemple de son contenu :
-[ RECORD 1 ]------+--------------
pid | 2603780
datid | 1308955
datname | pgbench_100
relid | 1308962
phase | scanning heap
heap_blks_total | 163935
heap_blks_scanned | 3631
heap_blks_vacuumed | 0
index_vacuum_count | 0
max_dead_tuple_bytes | 67108864
dead_tuple_bytes | 0
num_dead_item_ids | 0
indexes_total | 0
indexes_processed | 0
Dans cet exemple, le VACUUM
exécuté par le PID 4299 a
parcouru 86 665 blocs (soit 68 % de la table), et en a traité
86 664.
Noter que le suivi du VACUUM
dans les index (les deux
derniers champs) nécessite au moins PostgreSQL 17. C’est souvent la
partie la plus longue d’un VACUUM
.
Au fil des versions de PostgreSQL, sont apparues des vues similaires
pour suivre les ordres ANALYZE
, VACUUM FULL
,
CLUSTER
, les (ré)indexations, les base backups
(par exemple avec pg_basebackup
), ou des insertions avec
COPY
.
Ces vues n’affichent que les opérations en cours, elles n’historisent rien. Si aucun de ces ordres n’est en cours, elles n’afficheront rien.
Hélas, PostgreSQL ne permet pas suivre le déroulement d’une requête.
Même avec auto_explain
, il faut attendre sa fin pour avoir
le plan d’exécution.
Cette lacune est effectivement gênante. Il existe des extensions ou projets plus ou moins expérimentaux, ou avec un impact notable en performance.
log_checkpoints = on
Le paramètre log_checkpoints
, lorsqu’il est actif,
permet de tracer les informations liées à chaque checkpoint
déclenché.
PostgreSQL va produire une trace de ce type pour un checkpoint
déclenché par checkpoint_timeout
:
LOG: checkpoint starting: time
LOG: checkpoint complete: wrote 56 buffers (0.3%); 0 transaction log file(s)
added, 0 removed, 0 recycled; write=5.553 s, sync=0.013 s, total=5.573 s;
sync files=9, longest=0.004 s, average=0.001 s; distance=464 kB,
estimate=2153 kB
Un outil comme pgBadger peut exploiter ces informations.
pg_stat_bgwriter
pg_stat_checkpointer
(v17)
Activité des écritures dans les fichiers de données
Visualisation du volume d’allocations et d’écritures
Cette vue ne comporte qu’une seule ligne.
pg_stat_bgwriter
stocke les statistiques d’écriture des
buffers des background writer, et du checkpointer
(jusqu’en version 16 incluse) et des sessions elles-mêmes. On peut ainsi
voir si les backends écrivent beaucoup ou peu. À partir de
PostgreSQL 17, apparaît pg_stat_checkpointer
qui reprend
les champs sur les checkpoints et en ajoute quelques-uns. Cette
vue permet de vérifier que les checkpoints sont réguliers, donc
peu gênants.
Exemple (version 17) :
-[ RECORD 1 ]----+------------------------------
buffers_clean | 3004
maxwritten_clean | 26
buffers_alloc | 24399160
stats_reset | 2024-11-05 15:12:27.556173+01
-[ RECORD 1 ]-------+------------------------------
num_timed | 282
num_requested | 2
restartpoints_timed | 0
restartpoints_req | 0
restartpoints_done | 0
write_time | 605908
sync_time | 3846
buffers_written | 20656
stats_reset | 2024-11-05 15:12:27.556173+01
Certaines colonnes indiquent l’activité du checkpointer
,
afin de vérifier que celui-ci effectue surtout des écritures
périodiques, donc bien lissées dans le temps. Les deux premières
colonnes notamment permettent de vérifier que la configuration de
max_wal_size
n’est pas trop basse par rapport au volume
d’écriture que subit la base.
checkpoints_timed
: nombre de checkpoints déclenchés
par checkpoint_timeout
(périodiques) ;checkpoints_req
: nombre de checkpoints déclenchés par
atteinte de max_wal_size
, donc sous forte charge ;checkpoint_write_time
: temps passé par le
checkpointer
à écrire des données ;checkpoint_sync_time
: temps passé à s’assurer que les
écritures ont été synchronisées sur disque lors des checkpoints.Le background writer
est destiné à nettoyer le cache de
PostgreSQL en complément du checkpointer
, pour éviter que
les backends (processus clients) écrivent eux-mêmes, faute de bloc
libérable dans le cache. Il allège aussi la charge du
checkpointer
. Il a des champs dédiés :
buffers_checkpoint
: nombre de blocs écrits par
checkpointer
;buffers_clean
: nombre de blocs écrits par le
background writer
;maxwritten_clean
: nombre de fois où le
background writer
s’est arrêté pour avoir atteint la limite
configurée par bgwriter_lru_maxpages
;buffers_backend
: nombre de blocs écrits par les
processus backends (faute de buffer disponible en cache) ;buffers_backend_fsync
: nombre de blocs synchronisés
par les backends ;buffers_alloc
: nombre de blocs alloués dans les
shared buffers.Les colonnes buffers_clean
(à comparer à
buffers_checkpoint
et buffers_backend
) et
maxwritten_clean
permettent de vérifier que la
configuration est adéquate : si maxwritten_clean
augmente
fortement en fonctionnement normal, c’est que le paramètre
bgwriter_lru_maxpages
l’empêche de libérer autant de
buffers qu’il l’estime nécessaire (ce paramètre sert de garde-fou). Dans
ce cas, les backends vont se mettre à écrire eux-mêmes sur le disque et
buffers_backend
va augmenter. Ce dernier cas n’est pas
inquiétant s’il est ponctuel (gros import), mais ne doit pas être
fréquent en temps normal, toujours dans le but de lisser les écritures
sur le disque.
Il faut toutefois prendre tout cela avec prudence : une session qui
modifie énormément de blocs n’aura pas le droit de modifier tout le
contenu du cache disque, elle sera cantonnée à une toute petite partie.
Elle sera donc obligée de vider elle-même ses buffers. C’est le cas par
exemple d’une session chargeant un volume conséquent de données avec
COPY
.
Toutes ces statistiques sont cumulatives. Le champs
stats_reset
indique la date de remise à zéro de cette vue.
Pour demander la réinitialisation, utiliser :
pg_stat_archiver
pg_stat_replication
pg_stat_database_conflicts
Cette vue ne comporte qu’une seule ligne.
archived_count
: nombre de WAL archivés ;last_archived_wal
: nom du dernier fichier WAL dont
l’archivage a réussi ;last_archived_time
: date du dernier archivage
réussi ;failed_count
: nombre de tentatives d’archivages
échouées ;last_failed_wal
: nom du dernier fichier WAL qui a
rencontré des problèmes d’archivage ;last_failed_time
: date de la dernière tentative
d’archivage échouée ;stats_reset
: date de remise à zéro de cette vue
statistique.Cette vue peut être spécifiquement remise à zéro par l’appel à la
fonction pg_stat_reset_shared('archiver')
.
On peut facilement s’en servir pour déterminer si l’archivage fonctionne bien :
pg_stat_replication
:
pg_stat_database_conflicts
:
pg_stat_replication
permet de suivre les différentes
étapes de la réplication.
-[ RECORD 1 ]----+------------------------------
pid | 16028
usesysid | 10
usename | postgres
application_name | secondaire
client_addr | 192.168.74.16
client_hostname | *NULL*
client_port | 52016
backend_start | 2019-10-28 19:00:16.612565+01
backend_xmin | *NULL*
state | streaming
sent_lsn | 0/35417438
write_lsn | 0/35417438
flush_lsn | 0/35417438
replay_lsn | 0/354160F0
write_lag | 00:00:00.002626
flush_lag | 00:00:00.005243
replay_lag | 00:00:38.09978
sync_priority | 1
sync_state | sync
reply_time | 2019-10-28 19:04:48.286642+0
pid
: numéro de processus du backend discutant avec le
serveur secondaire ;usesysid
, usename
: OID et nom de
l’utilisateur utilisé pour se connecter en streaming replication ;application_name
: application_name de la
chaîne de connexion du serveur secondaire ; Peut être paramétré dans le
paramètre primary_conninfo
du serveur secondaire, surtout
utilisé dans le cas de la réplication synchrone ;client_addr
: adresse IP du secondaire (s’il n’est pas
sur la même machine, ce qui est vraisemblable) ;client_hostname
: nom d’hôte du secondaire (si
log_hostname
à on
) ;client_port
: numéro de port TCP auquel est connecté le
serveur secondaire ;backend_start
: timestamp de connexion du serveur
secondairebackend_xmin
: l’horizon xmin
renvoyé par
le standby ;state
: startup
(en cours
d’initialisation), backup
(utilisé par
pg_basebackup
), catchup
(étape avant
streaming, rattrape son retard), streaming
(on est dans le
mode streaming, les nouvelles entrées de journalisation sont envoyées au
fil de l’eau) ;sent_lsn
: l’adresse jusqu’à laquelle on a envoyé le
contenu du WAL à ce secondaire ;write_lsn
l’adresse jusqu’à laquelle ce serveur
secondaire a écrit le WAL sur disque ;flush_lsn
: l’adresse jusqu’à laquelle ce serveur
secondaire a synchronisé le WAL sur disque (l’écriture est alors
garantie) ;replay_lsn
: l’adresse jusqu’à laquelle le serveur
secondaire a rejoué les informations du WAL (les données sont donc
visibles jusqu’à ce point, par requêtes, sur le secondaire) ;write_lag
: durée écoulée entre la synchronisation
locale sur disque et la réception de la notification indiquant que le
standby l’a écrit (mais ni synchronisé ni appliqué) ;flush_lag
: durée écoulée entre la synchronisation
locale sur disque et la réception de la notification indiquant que le
standby l’a écrit et synchronisé (mais pas appliqué) ;replay_lag
: durée écoulée entre la synchronisation
locale sur disque et la réception de la notification indiquant que le
standby l’a écrit, synchronisé et appliqué ;sync_priority
: dans le cas d’une réplication
synchrone, la priorité de ce serveur (un seul est synchrone, si celui-ci
tombe, un autre est promu). Les 3 valeurs 0 (asynchrone), 1 (synchrone)
et 2 (candidat) sont traduites dans sync_state
;reply_time
: date et heure d’envoi du dernier message
de réponse du standby.pg_stat_database_conflicts
suit les conflits entre les
données provenant du serveur principal et les sessions en cours sur le
secondaire :
Vue « pg_catalog.pg_stat_database_conflicts »
Colonne | Type | Collationnement | NULL-able | Par défaut
------------------+--------+-----------------+-----------+------------
datid | oid | | |
datname | name | | |
confl_tablespace | bigint | | |
confl_lock | bigint | | |
confl_snapshot | bigint | | |
confl_bufferpin | bigint | | |
confl_deadlock | bigint | | |
datid
, datname
: l’OID et le nom de la
base ;confl_tablespace
: requêtes annulées pour rejouer un
DROP TABLESPACE
;confl_lock
: requêtes annulées à cause de
lock_timeout
;confl_snapshot
: requêtes annulées à cause d’un
snapshot (instantané) trop vieux ; dû à des données supprimées
sur le primaire par un VACUUM, rejouées sur le secondaire et y
supprimant des données encore nécessaires pour des requêtes (on peut
faire disparaître totalement ce cas en activant
hot_standby_feedback
) ;confl_bufferpin
: requêtes annulées à cause d’un
buffer pin
, c’est-à-dire d’un bloc de cache mémoire en
cours d’utilisation dont avait besoin la réplication. Ce cas est
extrêmement rare : il faudrait un buffer pin d’une durée comparable à
max_standby_archive_delay
ou
max_standby_streaming_delay
. Or ceux-ci sont par défaut à
30 s, alors qu’un buffer pin dure quelques microsecondes ;confl_deadlock
: requêtes annulées à cause d’un
deadlock entre une session et le rejeu des transactions (toujours au
niveau des buffers). Hautement improbable aussi.Il est à noter que la version 14 permet de tracer toute attente due à
un conflit de réplication. Il suffit pour cela d’activer le paramètre
log_recovery_conflict_waits
.
pg_stat_statements
, PoWADifférents outils d’analyse sont apparus pour superviser les performances d’un serveur PostgreSQL. Ce sont généralement des outils développés par la communauté, mais qui ne sont pas intégrés au moteur. Par contre, ils utilisent les fonctionnalités du moteur.
top
pour PostgreSQLpg_activity
est un projet libre qui apporte une
fonctionnalité équivalent à top
, mais appliqué à
PostgreSQL. Il affiche trois écrans qui affichent chacun les requêtes en
cours, les sessions bloquées et les sessions bloquantes, avec
possibilité de tris, de changer le délai de rafraîchissement, de mettre
en pause, d’exporter les requêtes affichées en CSV, etc…
Pour afficher toutes les informations, y compris au niveau système, l’idéal est de se connecter en root et superutilisateur postgres :
pgBadger est un projet sous licence BSD très actif. Le site officiel se trouve sur https://pgbadger.darold.net/.
Voici une liste des options les plus utiles :
--top
--extension
--dbname
--prefix
pg_stat_statements
Aucune historisation n’est en effet réalisée par
pg_stat_statements
. PoWA a été développé pour combler ce
manque et ainsi fournir un outil équivalent à AWR d’Oracle, permettant
de connaître l’activité du serveur sur une période donnée.
Sur l’instance de production de Dalibo, la base de données PoWA occupe moins de 300 Mo sur disque, avec les caractéristiques suivantes :
COPY
, ~11 000 LOCK
Une bonne politique de supervision est la clef de voûte d’un système pérenne. Pour cela, il faut tout d’abord s’assurer que les traces et les statistiques soient bien configurées. Ensuite, s’intéresser à la métrologie et compléter ou installer un système de supervision avec des indicateurs compréhensibles.
N’hésitez pas, c’est le moment !
But : Installation & utilisation de pgBadger
On peut installer pgBadger soit depuis les dépôts du PGDG, soit depuis le site de l’auteur https://pgbadger.darold.net/.
Le plus simple reste le dépôt du PGDG associé à la distribution :
Comme Gilles Darold fait évoluer le produit régulièrement, il n’est pas rare que le dépôt Github soit plus à jour, et l’on peut préférer cette source. La release 11.8 est la dernière au moment où ceci est écrit.
Dans le répertoire pgbadger-11.8
, il n’y a guère que le
script pgbadger
dont on ait besoin, et que l’on placera par
exemple dans /usr/local/bin
.
On peut même utiliser un simple git clone
du dépôt. Il
n’y a pas de phase de compilation.
Elles sont disponibles sur : https://public.dalibo.com/workshop/workshop_supervision/logs_postgresql.tgz.
L’archive contient 9 fichiers de traces de 135 Mo chacun :
$ tar xzf logs_postgresql.tgz
$ cd logs_postgresql
$ du -sh *
135M postgresql-11-main.1.log
135M postgresql-11-main.2.log
135M postgresql-11-main.3.log
135M postgresql-11-main.4.log
135M postgresql-11-main.5.log
135M postgresql-11-main.6.log
135M postgresql-11-main.7.log
135M postgresql-11-main.8.log
135M postgresql-11-main.9.log
But : Apprendre à générer et analyser des rapports pgBadger.
Créer un premier rapport sur le premier fichier de traces : \
pgbadger -j 4 postgresql-11-main.1.log
.
Lancer tout de suite en arrière-plan la création du rapport complet :
pgbadger -j 4 --outfile rapport_complet.html postgresql-11-main.*.log
Pendant ce temps, ouvrir le fichier out.html dans votre navigateur. Parcourir les différents onglets et graphiques. \ Que montrent les onglets Connections et Sessions ?
Que montre l’onglet Checkpoints ?
Que montre l’onglet Temp Files ?
Que montre l’onglet Vacuums ?
Que montre l’onglet Locks ?
Que montre l’onglet Queries ?
Que montre l’onglet Top dans Time consuming queries et Normalized slowest queries ? \ Quelle est la différence entre les différents ensemble de requêtes présentés ?
Une fois la génération de
rapport_complet.html
terminée, l’ouvrir. \ Chercher à quel moment et sur quelle base sont apparus principalement des problèmes d’attente de verrous.
Créer un rapport
rapport_bank.html
ciblé sur les 5 minutes avant et après 16h50, pour cette base de données. \ Retrouver les locks et identifier la cause du verrou dans les requêtes les plus lentes.
Nous voulons connaître plus précisément les requêtes venant de l’IP 192.168.0.89 et avoir une vue plus fine des graphiques. \ Créer un rapport
rapport_host_89.html
sur cette IP avec une moyenne par minute.
Créer un rapport incrémental (sans HTML) dans
/tmp/incr_report
à partir du premier fichier avec :pgbadger -j 4 -I --noreport -O /tmp/incr_report/ postgresql-11-main.1.log
\ Que contient le répertoire ?
Quelle est la taille de ce rapport incrémental ?
Ajouter les rapports incrémentaux avec le rapport HTML sur les 2 premiers fichiers de traces. \ Quel rapport obtient-on ?
Voir l’énoncé plus haut.
Créer un premier rapport sur le premier fichier de traces : \
pgbadger -j 4 postgresql-11-main.1.log
.
Nous allons commencer par créer un premier rapport à partir du
premier fichier de logs. L’option -j
est à fixer à votre
nombre de processeurs :
Le fichier de rapport out.html est créé dans le répertoire courant. Avant de l’ouvrir dans le navigateur, lançons la création du rapport complet :
Lancer tout de suite en arrière-plan la création du rapport complet :
pgbadger -j 4 --outfile rapport_complet.html postgresql-11-main.*.log
La ligne de commande suivante génère un rapport sur tous les fichiers disponibles :
Pendant ce temps, ouvrir le fichier out.html dans votre navigateur. Parcourir les différents onglets et graphiques. \ Que montrent les onglets Connections et Sessions ?
On peut observer dans les sections Connections et Sessions un nombre de sessions et de connexions proches. Chaque session doit ouvrir une nouvelle connexion. Ceci est assez coûteux, un processus et de la mémoire devant être alloués.
Que montre l’onglet Checkpoints ?
La section Checkpoints indique les écritures des checkpointers et background writer. Ils ne s’apprécient que sur une durée assez longue.
Que montre l’onglet Temp Files ?
La section Temp Files permet, grâce au graphique temporel,
de vérifier si un ralentissement de l’instance est corrélé à un volume
important d’écriture de fichiers temporaires. Le rapport permet
également de lister les requêtes ayant généré des fichiers temporaires.
Suivant les cas, on pourra tenter une optimisation de la requête ou bien
un ajustement de la mémoire de travail, work_mem
.
Que montre l’onglet Vacuums ?
La section Vacuums liste les différentes tables ayant fait
l’objet d’un VACUUM
.
Que montre l’onglet Locks ?
Le section Locks permet d’obtenir les requêtes normalisées ayant le plus fait l’objet d’attente sur verrou. Le rapport pgBadger ne permet pas toujours de connaître la raison de ces attentes.
Que montre l’onglet Queries ?
La section Queries fournit une connaissance du type
d’activité sur chaque base de données : application web, OLTP,
data warehouse. Elle permet également, si le paramètre
log_line_prefix
le précise bien, de connaître la
répartition des requêtes selon la base de données, l’utilisateur, l’hôte
ou l’application.
Que montre l’onglet Top dans Time consuming queries et Normalized slowest queries ? \ Quelle est la différence entre les différents ensemble de requêtes présentés ?
La section Top est très intéressante. Elle permet de lister les requêtes les plus lentes unitairement, mais surtout celles ayant pris le plus de temps, en cumulé et en moyenne par requête.
Avoir fixé le paramètre log_min_duration_statement
à 0
permet de lister toutes les requêtes exécutées. Une requête peut ne
mettre que quelques dizaines de millisecondes à s’exécuter et sembler
unitairement très rapide. Mais si elle est lancée des millions de fois
par heure, elle peut représenter une charge très conséquente. Elle est
donc la première requête à optimiser.
Par comparaison, une grosse requête lente passant une fois par jour participera moins à la charge de la machine, et sa durée n’est pas toujours réellement un problème.
Une fois la génération de
rapport_complet.html
terminée, l’ouvrir. \ Chercher à quel moment et sur quelle base sont apparus principalement des problèmes d’attente de verrous.
La vue des verrous nous informe d’un problème sur la base de données bank vers 16h50.
Créer un rapport
rapport_bank.html
ciblé sur les 5 minutes avant et après 16h50, pour cette base de données. \ Retrouver les locks et identifier la cause du verrou dans les requêtes les plus lentes.
Nous allons réaliser un rapport spécifique sur cette base de données et cette période :
$ pgbadger -j 4 --outfile rapport_bank.html --dbname bank \
--begin "2018-11-12 16:45:00" --end "2018-11-12 16:55:00" \
postgresql-11-main.*.log
L’onglet Top affiche moins de requête, et la requête responsable du verrou de 16h50 saute plus rapidement aux yeux que dans le rapport complet :
Nous voulons connaître plus précisément les requêtes venant de l’IP 192.168.0.89 et avoir une vue plus fine des graphiques. \ Créer un rapport
rapport_host_89.html
sur cette IP avec une moyenne par minute.
Nous allons créer un rapport en filtrant par client et en calculant les moyennes par minute (le défaut est de 5) :
$ pgbadger -j 4 --outfile rapport_host_89.html --dbclient 192.168.0.89 \
--average 1 postgresql-11-main.*.log
Il est également possible de filtrer par application avec l’option
--appname
.
Les fichiers de logs sont volumineux. On ne peut pas toujours conserver un historique assez important. pgBadger peut parser les fichiers de log et stocker les informations dans des fichiers binaires. Un rapport peut être construit à tout moment en précisant les fichiers binaires à utiliser.
Créer un rapport incrémental (sans HTML) dans
/tmp/incr_report
à partir du premier fichier avec :pgbadger -j 4 -I --noreport -O /tmp/incr_report/ postgresql-11-main.1.log
\ Que contient le répertoire ?
Le résultat est le suivant :
$ mkdir /tmp/incr_report
$ pgbadger -j 4 -I --noreport -O /tmp/incr_report/ postgresql-11-main.1.log
$ tree /tmp/incr_report
/tmp/incr_report
├── 2018
│ └── 11
│ └── 12
│ ├── 2018-11-12-25869.bin
│ ├── 2018-11-12-25871.bin
│ ├── 2018-11-12-25872.bin
│ └── 2018-11-12-25873.bin
└── LAST_PARSED
3 directories, 5 files
Le fichier LAST_PARSE
stocke la dernière ligne
analysée :
$ cat /tmp/incr_report/LAST_PARSED
2018-11-12 16:36:39 141351476 2018-11-12 16:36:39 CET [17303]: user=banquier,
db=bank,app=gestion,client=192.168.0.84 LOG: duration: 0.2
Dans le cas d’un fichier de log en cours d’écriture, pgBadger commencera son analyse suivante à partir de cette date.
Quelle est la taille de ce rapport incrémental ?
Le fichier postgresql-11-main.1.log
occupe 135 Mo. On
peut le compresser pour le réduire à 7 Mo. Voyons l’espace occupé par
les fichiers incrémentaux de pgBadger :
$ mkdir /tmp/incr_report
$ pgbadger -j 4 -I --noreport -O /tmp/incr_report/ postgresql-11-main.1.log
$ du -sh /tmp/incr_report/
340K /tmp/incr_report/
On pourra reconstruire à tout moment les rapports avec la commande :
Ce mode permet de construire des rapports réguliers, journaliers et hebdomadaires. Vous pouvez vous référer à la documentation pour en savoir plus sur ce mode incrémental.
Ajouter les rapports incrémentaux avec le rapport HTML sur les 2 premiers fichiers de traces. \ Quel rapport obtient-on ?
Il suffit d’enlever l’option --noreport
:
$ pgbadger -j 4 -I -O /tmp/incr_report/ postgresql-11-main.1.log postgresql-11-main.2.log
[========================>] Parsed 282702952 bytes of 282702952 (100.00%),
queries: 7738842, events: 33
LOG: Ok, generating HTML daily report into /tmp/incr_report//2018/11/12/...
LOG: Ok, generating HTML weekly report into /tmp/incr_report//2018/week-46/...
LOG: Ok, generating global index to access incremental reports...
Les rapports obtenus sont ici quotidiens et hebdomadaires :
$ tree /tmp/incr_report
/tmp/incr_report
├── 2018
│ ├── 11
│ │ └── 12
│ │ ├── 2018-11-12-14967.bin
│ │ ├── 2018-11-12-17227.bin
│ │ ├── 2018-11-12-18754.bin
│ │ ├── 2018-11-12-18987.bin
│ │ ├── 2018-11-12-18993.bin
│ │ ├── 2018-11-12-18996.bin
│ │ ├── 2018-11-12-19002.bin
│ │ ├── 2018-11-12-22821.bin
│ │ ├── 2018-11-12-3633.bin
│ │ ├── 2018-11-12-3634.bin
│ │ ├── 2018-11-12-3635.bin
│ │ ├── 2018-11-12-3636.bin
│ │ └── index.html
│ └── week-46
│ └── index.html
├── index.html
└── LAST_PARSED