Dalibo SCOP
Formation | Formation DEV42 |
Titre | Développement avancé |
Révision | 24.09 |
ISBN | N/A |
https://dali.bo/dev42_pdf | |
EPUB | https://dali.bo/dev42_epub |
HTML | https://dali.bo/dev42_html |
Slides | https://dali.bo/dev42_slides |
Vous trouverez en ligne les différentes versions complètes de ce document. La version imprimée ne contient pas les travaux pratiques. Ils sont présents dans la version numérique (PDF ou HTML).
Cette formation est sous licence CC-BY-NC-SA. Vous êtes libre de la redistribuer et/ou modifier aux conditions suivantes :
Vous n’avez pas le droit d’utiliser cette création à des fins commerciales.
Si vous modifiez, transformez ou adaptez cette création, vous n’avez le droit de distribuer la création qui en résulte que sous un contrat identique à celui-ci.
Vous devez citer le nom de l’auteur original de la manière indiquée par l’auteur de l’œuvre ou le titulaire des droits qui vous confère cette autorisation (mais pas d’une manière qui suggérerait qu’ils vous soutiennent ou approuvent votre utilisation de l’œuvre). À chaque réutilisation ou distribution de cette création, vous devez faire apparaître clairement au public les conditions contractuelles de sa mise à disposition. La meilleure manière de les indiquer est un lien vers cette page web. Chacune de ces conditions peut être levée si vous obtenez l’autorisation du titulaire des droits sur cette œuvre. Rien dans ce contrat ne diminue ou ne restreint le droit moral de l’auteur ou des auteurs.
Le texte complet de la licence est disponible sur http://creativecommons.org/licenses/by-nc-sa/2.0/fr/legalcode
Cela inclut les diapositives, les manuels eux-mêmes et les travaux pratiques. Cette formation peut également contenir quelques images et schémas dont la redistribution est soumise à des licences différentes qui sont alors précisées.
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 12 à 16.
Sur les versions précédentes susceptibles d’être encore rencontrées en production, seuls quelques points très importants sont évoqués, en plus éventuellement de quelques éléments historiques.
Sauf précision contraire, le système d’exploitation utilisé est Linux.
À l’aide des fonctions de calcul d’agrégats, on peut réaliser un certain nombre de calculs permettant d’analyser les données d’une table.
La plupart des exemples utilisent une table employes
définie telle que :
CREATE TABLE employes (
char(8) primary key,
matricule not null,
nom text
service text,numeric(7,2)
salaire
);
INSERT INTO employes (matricule, nom, service, salaire)
VALUES ('00000001', 'Dupuis', 'Direction', 10000.00);
INSERT INTO employes (matricule, nom, service, salaire)
VALUES ('00000004', 'Fantasio', 'Courrier', 4500.00);
INSERT INTO employes (matricule, nom, service, salaire)
VALUES ('00000006', 'Prunelle', 'Publication', 4000.00);
INSERT INTO employes (matricule, nom, service, salaire)
VALUES ('00000020', 'Lagaffe', 'Courrier', 3000.00);
INSERT INTO employes (matricule, nom, service, salaire)
VALUES ('00000040', 'Lebrac', 'Publication', 3000.00);
SELECT * FROM employes ;
matricule | nom | service | salaire
-----------+----------+-------------+----------
00000001 | Dupuis | Direction | 10000.00
00000004 | Fantasio | Courrier | 4500.00
00000006 | Prunelle | Publication | 4000.00
00000020 | Lagaffe | Courrier | 3000.00
00000040 | Lebrac | Publication | 3000.00
(5 lignes)
Ainsi, on peut déduire le salaire moyen avec la fonction
avg()
, les salaires maximum et minimum versés par la
société avec les fonctions max()
et min()
,
ainsi que la somme totale des salaires versés avec la fonction
sum()
:
SELECT avg(salaire) AS salaire_moyen,
max(salaire) AS salaire_maximum,
min(salaire) AS salaire_minimum,
sum(salaire) AS somme_salaires
FROM employes;
salaire_moyen | salaire_maximum | salaire_minimum | somme_salaires
-----------------------+-----------------+-----------------+----------------
4900.0000000000000000 | 10000.00 | 3000.00 | 24500.00
La base de données réalise les calculs sur l’ensemble des données de la table et n’affiche que le résultat du calcul.
Si l’on applique un filtre sur les données, par exemple pour ne prendre en compte que le service Courrier, alors PostgreSQL réalise le calcul uniquement sur les données issues de la lecture :
SELECT avg(salaire) AS salaire_moyen,
max(salaire) AS salaire_maximum,
min(salaire) AS salaire_minimum,
sum(salaire) AS somme_salaires
FROM employes
WHERE service = 'Courrier';
salaire_moyen | salaire_maximum | salaire_minimum | somme_salaires
-----------------------+-----------------+-----------------+----------------
3750.0000000000000000 | 4500.00 | 3000.00 | 7500.00
(1 ligne)
En revanche, il n’est pas possible de référencer d’autres colonnes
pour les afficher à côté du résultat d’un calcul d’agrégation à moins de
les utiliser comme critère de regroupement avec
GROUP BY
:
SELECT avg(salaire), nom FROM employes;
ERROR: column "employes.nom" must appear in the GROUP BY clause or be used in
an aggregate function
LIGNE 1 : SELECT avg(salaire), nom FROM employes;
^
L’opérateur d’agrégat GROUP BY
indique à la base de
données que l’on souhaite regrouper les données selon les mêmes valeurs
d’une colonne.
Des calculs pourront être réalisés sur les données agrégées selon le
critère de regroupement donné. Le résultat sera alors représenté en
n’affichant que les colonnes de regroupement puis les valeurs calculées
par les fonctions d’agrégation :
L’agrégation est ici réalisée sur la colonne service
. En
guise de calcul d’agrégation, une somme est réalisée sur les salaires
payés dans chaque service.
SQL permet depuis le début de réaliser des calculs d’agrégation. Pour
cela, la base de données observe les critères de regroupement définis
dans la clause GROUP BY
de la requête et effectue
l’opération sur l’ensemble des lignes qui correspondent au critère de
regroupement.
On peut bien entendu combiner plusieurs opérations d’agrégations :
SELECT service,
sum(salaire) salaires_par_service,
avg(salaire) AS salaire_moyen_service
FROM employes
GROUP BY service;
service | salaires_par_service | salaire_moyen_service
-------------+----------------------+------------------------
Courrier | 7500.00 | 3750.0000000000000000
Direction | 10000.00 | 10000.0000000000000000
Publication | 7000.00 | 3500.0000000000000000
(3 lignes)
On peut combiner le résultat de deux requêtes d’agrégation avec
UNION ALL
, si les ensembles retournées sont de même
type :
SELECT service,
sum(salaire) AS salaires_par_service
FROM employes GROUP BY service
UNION ALL
SELECT 'Total' AS service,
sum(salaire) AS salaires_par_service
FROM employes;
service | salaires_par_service
-------------+----------------------
Courrier | 7500.00
Direction | 10000.00
Publication | 7000.00
Total | 24500.00
(4 lignes)
On le verra plus loin, cette dernière requête peut être écrite plus
simplement avec les GROUPING SETS
.
Les fonctions array_agg
, string_agg
et
xmlagg
permettent d’agréger des éléments dans un tableau,
dans une chaîne ou dans une arborescence XML. Autant l’ordre dans lequel
les données sont utilisées n’a pas d’importance lorsque l’on réalise un
calcul d’agrégat classique, autant cet ordre va influencer la façon dont
les données seront produites par les trois fonctions citées plus haut.
En effet, le tableau généré par array_agg
est composé
d’éléments ordonnés, de même que la chaîne de caractères ou
l’arborescence XML.
La requête suivante permet d’obtenir, pour chaque service, la liste des employés dans un tableau, trié par ordre alphabétique :
SELECT service,
', ' ORDER BY nom) AS liste_employes
string_agg(nom, FROM employes
GROUP BY service;
service | liste_employes
-------------+-------------------
Courrier | Fantasio, Lagaffe
Direction | Dupuis
Publication | Lebrac, Prunelle
(3 lignes)
Il est possible de réaliser la même chose mais pour obtenir un tableau plutôt qu’une chaîne de caractère :
SELECT service,
ORDER BY nom) AS liste_employes
array_agg(nom FROM employes
GROUP BY service;
service | liste_employes
-------------+--------------------
Courrier | {Fantasio,Lagaffe}
Direction | {Dupuis}
Publication | {Lebrac,Prunelle}
La clause FILTER
permet de remplacer des expressions
complexes écrites avec CASE
et donc de simplifier
l’écriture de requêtes réalisant un filtrage dans une fonction
d’agrégat.
CASE
Avec cette syntaxe, dès que l’on a besoin d’avoir de multiples filtres ou de filtres plus complexes, la requête devient très rapidement peu lisible et difficile à maintenir. Le risque d’erreur est également élevé.
L’exemple suivant montre l’utilisation de la clause
FILTER
et son équivalent écrit avec une expression
CASE
:
SELECT count(*) AS compte_pays,
count(*) FILTER (WHERE r.nom_region='Europe') AS compte_pays_europeens,
count(CASE WHEN r.nom_region='Europe' THEN 1 END)
AS oldschool_compte_pays_europeens
FROM pays p
JOIN regions r
ON (p.region_id = r.region_id);
compte_pays | compte_pays_europeens | oldschool_compte_pays_europeens
-------------+-----------------------+---------------------------------
25 | 5 | 5
(1 ligne)
PostgreSQL supporte les fonctions de fenêtrage. Elles apportent des fonctionnalités analytiques à PostgreSQL, et permettent d’écrire beaucoup plus simplement certaines requêtes.
Prenons un exemple.
SELECT service, AVG(salaire)
FROM employe
GROUP BY service
SELECT service, id_employe, salaire,
AVG(salaire) OVER (
PARTITION BY service
ORDER BY age
ROWS BETWEEN 2 PRECEEDING AND 2 FOLLOWING
)FROM employes
La clause OVER
permet de définir la façon dont les
données sont regroupées - uniquement pour la colonne définie - avec la
clause PARTITION BY
.
Les exemples vont utiliser cette table employes
:
SELECT * FROM employes ;
matricule | nom | service | salaire
-----------+----------+-------------+----------
00000001 | Dupuis | Direction | 10000.00
00000004 | Fantasio | Courrier | 4500.00
00000006 | Prunelle | Publication | 4000.00
00000020 | Lagaffe | Courrier | 3000.00
00000040 | Lebrac | Publication | 3000.00
(5 lignes)
Les calculs réalisés par cette requête sont identiques à ceux
réalisés avec une agrégation utilisant GROUP BY
. La
principale différence est que l’on évite ici de perdre le détail des
données tout en disposant des données agrégées dans le résultat de la
requête.
Entouré de noir, le critère de regroupement et entouré de rouge, les données sur lesquelles sont appliqués le calcul d’agrégat.
Le terme PARTITION BY
permet d’indiquer les critères de
regroupement de la fenêtre sur laquelle on souhaite travailler.
La fonction row_number()
permet de numéroter les lignes
selon un critère de tri défini dans la clause OVER
.
L’ordre de tri de la clause OVER
n’influence pas l’ordre
de tri explicite d’une requête :
SELECT row_number() OVER (ORDER BY matricule),
matricule, nomFROM employes
ORDER BY nom;
row_number | matricule | nom
------------+-----------+----------
1 | 00000001 | Dupuis
2 | 00000004 | Fantasio
4 | 00000020 | Lagaffe
5 | 00000040 | Lebrac
3 | 00000006 | Prunelle
(5 lignes)
On dispose aussi de fonctions de classement, pour déterminer par exemple les employés les moins bien payés :
SELECT matricule, nom, salaire, service,
rank() OVER (ORDER BY salaire),
dense_rank() OVER (ORDER BY salaire)
FROM employes ;
matricule | nom | salaire | service | rank | dense_rank
-----------+----------+----------+-------------+------+------------
00000020 | Lagaffe | 3000.00 | Courrier | 1 | 1
00000040 | Lebrac | 3000.00 | Publication | 1 | 1
00000006 | Prunelle | 4000.00 | Publication | 3 | 2
00000004 | Fantasio | 4500.00 | Courrier | 4 | 3
00000001 | Dupuis | 10000.00 | Direction | 5 | 4
(5 lignes)
La fonction de fenêtrage rank()
renvoie le classement en
autorisant des trous dans la numérotation, et dense_rank()
le classement sans trous.
Lorsque l’on utilise une clause de tri, la portion de données visible
par l’opérateur d’agrégat correspond aux données comprises entre la
première ligne examinée et la ligne courante. La fenêtre est définie
selon le critère
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
.
Nous verrons plus loin que nous pouvons modifier ce comportement.
Le terme ORDER BY
permet d’indiquer les critères de tri
de la fenêtre sur laquelle on souhaite travailler.
Il est possible de combiner les clauses de fenêtrage
PARTITION BY
et ORDER BY
. Cela permet d’isoler
des jeux de données entre eux avec la clause PARTITION BY
,
tout en appliquant un critère de tri avec la clause
ORDER BY
. Beaucoup d’applications sont possibles si l’on
associe à cela les nombreuses fonctions analytiques disponibles.
Si l’on applique les deux clauses PARTITION BY
et
ORDER BY
à une fonction de fenêtrage, alors le critère de
tri est appliqué dans la partition et chaque partition est indépendante
l’une de l’autre.
Voici un extrait plus complet du résultat de la requête présentée ci-dessus :
continent | pays | population | rang_pop
---------------------------+--------------------+------------+-----------
Afrique | Nigéria | 173.6 | 1
Afrique | Éthiopie | 94.1 | 2
Afrique | Égypte | 82.1 | 3
Afrique | Rép. dém. du Congo | 67.5 | 4
Afrique | Afrique du Sud | 52.8 | 5
Afrique | Tanzanie | 49.3 | 6
Afrique | Kenya | 44.4 | 7
Afrique | Algérie | 39.2 | 8
Afrique | Ouganda | 37.6 | 9
Afrique | Maroc | 33.0 | 10
Afrique | Ghana | 25.9 | 11
Afrique | Mozambique | 25.8 | 12
Afrique | Madagascar | 22.9 | 13
Afrique | Côte-d'Ivoire | 20.3 | 14
Afrique | Niger | 17.8 | 15
Afrique | Burkina Faso | 16.9 | 16
Afrique | Zimbabwe | 14.1 | 17
Afrique | Soudan | 14.1 | 17
Afrique | Tunisie | 11.0 | 19
Amérique du Nord | États-Unis | 320.1 | 1
Amérique du Nord | Canada | 35.2 | 2
Amérique latine. Caraïbes | Brésil | 200.4 | 1
Amérique latine. Caraïbes | Mexique | 122.3 | 2
Amérique latine. Caraïbes | Colombie | 48.3 | 3
Amérique latine. Caraïbes | Argentine | 41.4 | 4
Amérique latine. Caraïbes | Pérou | 30.4 | 5
Amérique latine. Caraïbes | Venezuela | 30.4 | 5
Amérique latine. Caraïbes | Chili | 17.6 | 7
Amérique latine. Caraïbes | Équateur | 15.7 | 8
Amérique latine. Caraïbes | Guatemala | 15.5 | 9
Amérique latine. Caraïbes | Cuba | 11.3 | 10
(…)
Cette construction ne pose aucune difficulté syntaxique. La norme
impose de placer la clause PARTITION BY
avant la clause
ORDER BY
, c’est la seule chose à retenir au niveau de la
syntaxe.
Sans les fonctions analytiques, il était difficile en SQL d’écrire des requêtes nécessitant de faire appel à des données provenant d’autres lignes que la ligne courante.
Par exemple, pour renvoyer la liste détaillée de tous les employés ET
le salaire le plus élevé du service auquel il appartient, on peut
utiliser la fonction first_value()
:
SELECT matricule, nom, salaire, service,
first_value(salaire) OVER (PARTITION BY service ORDER BY salaire DESC)
AS salaire_maximum_service
FROM employes ;
matricule | nom | salaire | service | salaire_maximum_service
-----------+----------+----------+-------------+-------------------------
00000004 | Fantasio | 4500.00 | Courrier | 4500.00
00000020 | Lagaffe | 3000.00 | Courrier | 4500.00
00000001 | Dupuis | 10000.00 | Direction | 10000.00
00000006 | Prunelle | 4000.00 | Publication | 4000.00
00000040 | Lebrac | 3000.00 | Publication | 4000.00
(5 lignes)
Il existe également les fonctions suivantes :
last_value(colonne)
: renvoie la dernière valeur pour
la colonne ;nth(colonne, n)
: renvoie la n-ème valeur (en comptant
à partir de 1) pour la colonne ;lag(colonne, n)
: renvoie la valeur située en n-ème
position avant la ligne en cours pour la colonne ;lead(colonne, n)
: renvoie la valeur située en n-ème
position après la ligne en cours pour la colonne ;
NULL
sera renvoyé.lead()
et lag()
La construction lead(colonne)
est équivalente à
lead(colonne, 1)
. De même, la construction
lag(colonne)
est équivalente à
lag(colonne, 1)
. Il s’agit d’un raccourci pour utiliser la
valeur précédente ou la valeur suivante d’une colonne dans la fenêtre
définie.
lead()
et lag()
:
exempleLa requête présentée en exemple ne s’appuie que sur un jeu réduit de données afin de montrer un résultat compréhensible.
lead()
et
lag()
: principeNULL
est renvoyé lorsque la valeur n’est pas accessible
dans la fenêtre de données, comme par exemple si l’on souhaite utiliser
la valeur d’une colonne appartenant à la ligne précédant la première
ligne de la partition.
Utilisé avec ORDER BY
et PARTITION BY
, la
fonction first_value()
permet par exemple d’obtenir le
salaire le plus élevé d’un service :
SELECT matricule, nom, salaire, service,
first_value(salaire) OVER (PARTITION BY service ORDER BY salaire DESC)
AS salaire_maximum_service
FROM employes ;
matricule | nom | salaire | service | salaire_maximum_service
-----------+----------+----------+-------------+-------------------------
00000004 | Fantasio | 4500.00 | Courrier | 4500.00
00000020 | Lagaffe | 3000.00 | Courrier | 4500.00
00000001 | Dupuis | 10000.00 | Direction | 10000.00
00000006 | Prunelle | 4000.00 | Publication | 4000.00
00000040 | Lebrac | 3000.00 | Publication | 4000.00
(5 lignes)
first
/last
/nth_value
:
exempleLorsque que la clause ORDER BY
est utilisée pour définir
une fenêtre, la fenêtre visible depuis la ligne courante commence par
défaut à la première ligne de résultat et s’arrête à la ligne
courante.
Par exemple, si l’on exécute la même requête en utilisant
last_value()
plutôt que first_value()
, on
récupère à chaque fois la valeur de la colonne sur la ligne
courante :
SELECT pays, continent, population,
last_value(population) OVER (PARTITION BY continent
ORDER BY population DESC)
FROM population;
pays | continent | population | last_value
-----------------------+-----------+------------+------------
Chine | Asie | 1385.6 | 1385.6
Iraq | Asie | 33.8 | 33.8
Ouzbékistan | Asie | 28.9 | 28.9
Arabie Saoudite | Asie | 28.8 | 28.8
France métropolitaine | Europe | 64.3 | 64.3
Finlande | Europe | 5.4 | 5.4
Lettonie | Europe | 2.1 | 2.1
(7 rows)
Il est alors nécessaire de redéfinir le comportement de la fenêtre
visible pour que la fonction se comporte comme attendu, en utilisant
RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
-
cet aspect sera décrit dans la section sur les possibilités de
modification de la définition de la fenêtre.
WINDOW
Il arrive que l’on ait besoin d’utiliser plusieurs fonctions de
fenêtrage au sein d’une même requête qui utilisent la même définition de
fenêtre (même clause PARTITION BY
et/ou
ORDER BY
). Afin d’éviter de dupliquer cette clause, il est
possible de définir une fenêtre nommée et de l’utiliser à plusieurs
endroits de la requête. Par exemple, l’exemple précédent des fonctions
de classement pourrait s’écrire :
SELECT matricule, nom, salaire, service,
rank() OVER w,
dense_rank() OVER w
FROM employes
AS (ORDER BY salaire); WINDOW w
matricule | nom | salaire | service | rank | dense_rank
-----------+----------+----------+-------------+------+------------
00000020 | Lagaffe | 3000.00 | Courrier | 1 | 1
00000040 | Lebrac | 3000.00 | Publication | 1 | 1
00000006 | Prunelle | 4000.00 | Publication | 3 | 2
00000004 | Fantasio | 4500.00 | Courrier | 4 | 3
00000001 | Dupuis | 10000.00 | Direction | 5 | 4
(5 lignes)
À noter qu’il est possible de définir de multiples définitions de
fenêtres au sein d’une même requête, et qu’une définition de fenêtre
peut surcharger la clause ORDER BY
si la définition parente
ne l’a pas définie. Par exemple, la requête SQL suivante est
correcte :
SELECT matricule, nom, salaire, service,
rank() OVER w_asc,
dense_rank() OVER w_desc
FROM employes
AS (PARTITION BY service),
WINDOW w AS (w ORDER BY salaire),
w_asc AS (w ORDER BY salaire DESC); w_desc
WINDOW
: syntaxeGROUPS
Ceci n’est disponible que depuis la version 11.
EXCLUDE
Ceci n’est disponible que depuis la version 11.
La clause WITHIN GROUP
est une clause pour les agrégats
utilisant des fonctions dont les données doivent être triées. Quelques
fonctions ont été ajoutées pour profiter au mieux de cette nouvelle
clause.
WITHIN GROUP
: exempleCet exemple permet d’afficher le continent, la médiane de la population par continent et la population du pays le moins peuplé parmi les 5% de pays les plus peuplés de chaque continent.
Pour rappel, la table contient les données suivantes :
SELECT * FROM population ORDER BY continent, population;
pays | population | superficie | densite | continent
-----------------------+------------+------------+---------+----------
Tunisie | 11.0 | 164 | 67 | Afrique
Zimbabwe | 14.1 | 391 | 36 | Afrique
Soudan | 14.1 | 197 | 72 | Afrique
Burkina Faso | 16.9 | 274 | 62 | Afrique
(…)
Les GROUPING SETS
permettent de définir plusieurs
clauses d’agrégation GROUP BY
. Les résultats seront
présentés comme si plusieurs requêtes d’agrégation avec les clauses
GROUP BY
mentionnées étaient assemblées avec
UNION ALL
.
CREATE TABLE stock AS SELECT * FROM (
VALUES ('ecrous', 'est', 50),
'ecrous', 'ouest', 0),
('ecrous', 'sud', 40),
('clous', 'est', 70),
('clous', 'nord', 0),
('vis', 'ouest', 50),
('vis', 'sud', 50),
('vis', 'nord', 60)
(AS VALUES(piece, region, quantite); )
Le comportement de la clause GROUPING SETS
peut être
émulée avec deux requêtes utilisant chacune une clause
GROUP BY
sur les colonnes de regroupement souhaitées.
Cependant, le plan d’exécution de la requête équivalente conduit à deux lectures et peut être particulièrement coûteux si le jeu de données sur lequel on souhaite réaliser les agrégations est important :
EXPLAIN SELECT piece,NULL as region,sum(quantite)
FROM stock
GROUP BY piece
UNION ALL
SELECT NULL, region,sum(quantite)
FROM STOCK
GROUP BY region;
QUERY PLAN
-------------------------------------------------------------------------
Append (cost=1.12..2.38 rows=7 width=44)
-> HashAggregate (cost=1.12..1.15 rows=3 width=45)
Group Key: stock.piece
-> Seq Scan on stock (cost=0.00..1.08 rows=8 width=9)
-> HashAggregate (cost=1.12..1.16 rows=4 width=44)
Group Key: stock_1.region
-> Seq Scan on stock stock_1 (cost=0.00..1.08 rows=8 width=8)
La requête utilisant la clause GROUPING SETS
propose un
plan bien plus efficace :
EXPLAIN SELECT piece,region,sum(quantite)
FROM stock GROUP BY GROUPING SETS (piece,region);
QUERY PLAN
------------------------------------------------------------------
GroupAggregate (cost=1.20..1.58 rows=14 width=17)
Group Key: piece
Sort Key: region
Group Key: region
-> Sort (cost=1.20..1.22 rows=8 width=13)
Sort Key: piece
-> Seq Scan on stock (cost=0.00..1.08 rows=8 width=13)
La clause ROLLUP
est une fonctionnalité d’analyse type
OLAP du langage SQL. Elle s’utilise dans la clause
GROUP BY
, tout comme GROUPING SETS
Sur une requête un peu plus intéressante, effectuant des statistiques sur des ventes :
SELECT type_client, code_pays, SUM(quantite*prix_unitaire) AS montant
FROM commandes c
JOIN lignes_commandes l
ON (c.numero_commande = l.numero_commande)
JOIN clients cl
ON (c.client_id = cl.client_id)
JOIN contacts co
ON (cl.contact_id = co.contact_id)
WHERE date_commande BETWEEN '2014-01-01' AND '2014-12-31'
GROUP BY ROLLUP (type_client, code_pays);
Elle produit le résultat suivant :
type_client | code_pays | montant
-------------+-----------+---------------
A | CA | 6273168.32
A | CN | 7928641.50
A | DE | 6642061.57
A | DZ | 6404425.16
A | FR | 55261295.52
A | IN | 7224008.95
A | PE | 7356239.93
A | RU | 6766644.98
A | US | 7700691.07
A | | 111557177.00
(…)
P | RU | 287605812.99
P | US | 296424154.49
P | | 4692152751.08
| | 5217862160.65
Une fonction GROUPING
, associée à ROLLUP
,
permet de déterminer si la ligne courante correspond à un regroupement
donné. Elle est de la forme d’un masque de bit converti au format
décimal :
SELECT row_number()
OVER ( ORDER BY grouping(piece,region)) AS ligne,
grouping(piece,region)::bit(2) AS g,
piece,
region,sum(quantite)
FROM stock
GROUP BY CUBE (piece,region)
ORDER BY g ;
ligne | g | piece | region | sum
-------+----+--------+--------+-----
1 | 00 | clous | est | 150
2 | 00 | clous | nord | 10
3 | 00 | ecrous | est | 110
4 | 00 | ecrous | ouest | 10
5 | 00 | ecrous | sud | 90
6 | 00 | vis | nord | 130
7 | 00 | vis | ouest | 110
8 | 00 | vis | sud | 110
9 | 01 | vis | | 350
10 | 01 | ecrous | | 210
11 | 01 | clous | | 160
12 | 10 | | ouest | 120
13 | 10 | | sud | 200
14 | 10 | | est | 260
15 | 10 | | nord | 140
16 | 11 | | | 720
Voici un autre exemple :
SELECT COALESCE(service,
CASE
WHEN GROUPING(service) = 0 THEN 'Unknown' ELSE 'Total'
END) AS service,
sum(salaire) AS salaires_service, count(*) AS nb_employes
FROM employes
GROUP BY ROLLUP (service);
service | salaires_service | nb_employes
-------------+------------------+-------------
Courrier | 7500.00 | 2
Direction | 50000.00 | 1
Publication | 7000.00 | 2
Total | 64500.00 | 5
(4 rows)
Ou appliqué à l’exemple un peu plus complexe :
SELECT COALESCE(type_client,
CASE
WHEN GROUPING(type_client) = 0 THEN 'Unknown' ELSE 'Total'
END) AS type_client,
COALESCE(code_pays,
CASE
WHEN GROUPING(code_pays) = 0 THEN 'Unknown' ELSE 'Total'
END) AS code_pays,
SUM(quantite*prix_unitaire) AS montant
FROM commandes c
JOIN lignes_commandes l
ON (c.numero_commande = l.numero_commande)
JOIN clients cl
ON (c.client_id = cl.client_id)
JOIN contacts co
ON (cl.contact_id = co.contact_id)
WHERE date_commande BETWEEN '2014-01-01' AND '2014-12-31'
GROUP BY ROLLUP (type_client, code_pays);
type_client | code_pays | montant
-------------+-----------+---------------
A | CA | 6273168.32
A | CN | 7928641.50
A | DE | 6642061.57
A | DZ | 6404425.16
A | FR | 55261295.52
A | IN | 7224008.95
A | PE | 7356239.93
A | RU | 6766644.98
A | US | 7700691.07
A | Total | 111557177.00
(…)
P | US | 296424154.49
P | Total | 4692152751.08
Total | Total | 5217862160.65
La clause CUBE
est une autre fonctionnalité d’analyse
type OLAP du langage SQL. Tout comme ROLLUP
, elle s’utilise
dans la clause GROUP BY
.
Elle permet de réaliser des regroupements sur l’ensemble des combinaisons possibles des clauses de regroupement indiquées. Pour de plus amples détails, se référer à cet article Wikipédia.
En reprenant la requête de l’exemple précédent :
SELECT type_client,
code_pays,SUM(quantite*prix_unitaire) AS montant
FROM commandes c
JOIN lignes_commandes l
ON (c.numero_commande = l.numero_commande)
JOIN clients cl
ON (c.client_id = cl.client_id)
JOIN contacts co
ON (cl.contact_id = co.contact_id)
WHERE date_commande BETWEEN '2014-01-01' AND '2014-12-31'
GROUP BY CUBE (type_client, code_pays);
Elle retournera le résultat suivant :
type_client | code_pays | montant
-------------+-----------+---------------
A | CA | 6273168.32
A | CN | 7928641.50
A | DE | 6642061.57
A | DZ | 6404425.16
A | FR | 55261295.52
A | IN | 7224008.95
A | PE | 7356239.93
A | RU | 6766644.98
A | US | 7700691.07
A | | 111557177.00
E | CA | 28457655.81
E | CN | 25537539.68
E | DE | 25508815.68
E | DZ | 24821750.17
E | FR | 209402443.24
E | IN | 26788642.27
E | PE | 24541974.54
E | RU | 25397116.39
E | US | 23696294.79
E | | 414152232.57
P | CA | 292975985.52
P | CN | 287795272.87
P | DE | 287337725.21
P | DZ | 302501132.54
P | FR | 2341977444.49
P | IN | 295256262.73
P | PE | 300278960.24
P | RU | 287605812.99
P | US | 296424154.49
P | | 4692152751.08
| | 5217862160.65
| CA | 327706809.65
| CN | 321261454.05
| DE | 319488602.46
| DZ | 333727307.87
| FR | 2606641183.25
| IN | 329268913.95
| PE | 332177174.71
| RU | 319769574.36
| US | 327821140.35
Dans ce genre de contexte, lorsque le regroupement est réalisé sur
l’ensemble des valeurs d’un critère de regroupement, alors la valeur qui
apparaît est NULL
pour la colonne correspondante. Si la
colonne possède des valeurs NULL
légitimes, il est alors
difficile de les distinguer. On utilise alors la fonction
GROUPING()
qui permet de déterminer si le regroupement
porte sur l’ensemble des valeurs de la colonne. L’exemple suivant montre
une requête qui exploite cette fonction :
SELECT GROUPING(type_client,code_pays)::bit(2),
GROUPING(type_client)::boolean g_type_cli,
GROUPING(code_pays)::boolean g_code_pays,
type_client,
code_pays,SUM(quantite*prix_unitaire) AS montant
FROM commandes c
JOIN lignes_commandes l
ON (c.numero_commande = l.numero_commande)
JOIN clients cl
ON (c.client_id = cl.client_id)
JOIN contacts co
ON (cl.contact_id = co.contact_id)
WHERE date_commande BETWEEN '2014-01-01' AND '2014-12-31'
GROUP BY CUBE (type_client, code_pays);
Elle produit le résultat suivant :
grouping | g_type_cli | g_code_pays | type_client | code_pays | montant
----------+------------+-------------+-------------+-----------+---------------
00 | f | f | A | CA | 6273168.32
00 | f | f | A | CN | 7928641.50
00 | f | f | A | DE | 6642061.57
00 | f | f | A | DZ | 6404425.16
00 | f | f | A | FR | 55261295.52
00 | f | f | A | IN | 7224008.95
00 | f | f | A | PE | 7356239.93
00 | f | f | A | RU | 6766644.98
00 | f | f | A | US | 7700691.07
01 | f | t | A | | 111557177.00
(…)
01 | f | t | P | | 4692152751.08
11 | t | t | | | 5217862160.65
10 | t | f | | CA | 327706809.65
10 | t | f | | CN | 321261454.05
10 | t | f | | DE | 319488602.46
10 | t | f | | DZ | 333727307.87
10 | t | f | | FR | 2606641183.25
10 | t | f | | IN | 329268913.95
10 | t | f | | PE | 332177174.71
10 | t | f | | RU | 319769574.36
10 | t | f | | US | 327821140.35
(40 rows)
L’application sera alors à même de gérer la présentation des
résultats en fonction des valeurs de grouping
ou
g_type_client
et g_code_pays
.
La table brno2015
peut être téléchargée et restaurée
ainsi :
curl -kL https://dali.bo/tp_brno2015 -o /tmp/brno2015.dump
createdb brno2015
pg_restore -O -d brno2015 /tmp/brno2015.dump
# une erreur sur l'existence du schéma public est normale
Le schéma brno2015
dispose d’une table pilotes ainsi que
les résultats tour par tour de la course de MotoGP de Brno (CZ) de la
saison 2015.
La table brno2015
indique pour chaque tour, pour chaque
pilote, le temps réalisé dans le tour :
Table "public.brno_2015"
Column | Type | Modifiers
-----------+----------+-----------
no_tour | integer |
no_pilote | integer |
lap_time | interval |
Une table pilotes
permet de connaître les détails d’un
pilote :
Table "public.pilotes"
Column | Type | Modifiers
-------------+---------+-----------
no | integer |
nom | text |
nationalite | text |
ecurie | text |
moto | text |
Précisions sur les données à manipuler : la course est réalisée en plusieurs tours; certains coureurs n’ont pas terminé la course, leur relevé de tours s’arrête donc brutalement.
Agrégation
Quel est le pilote qui a le moins gros écart entre son meilleur tour et son moins bon tour ?
Déterminer quel est le pilote le plus régulier (écart-type).
Window Functions
Afficher la place sur le podium pour chaque coureur.
À partir de la requête précédente, afficher également la différence du temps de chaque coureur par rapport à celui de la première place.
Pour chaque tour, afficher :
- le nom du pilote ;
- son rang dans le tour ;
- son temps depuis le début de la course ;
- dans le tour, la différence de temps par rapport au premier.
Pour chaque coureur, quel est son meilleur tour et quelle place avait-il sur ce tour ?
Déterminer quels sont les coureurs ayant terminé la course qui ont gardé la même position tout au long de la course.
En quelle position a terminé le coureur qui a doublé le plus de personnes ? Combien de personnes a-t-il doublées ?
Grouping Sets
Ce TP nécessite PostgreSQL 9.5 ou supérieur. Il s’appuie sur les
tables présentes dans le schéma magasin
.
En une seule requête, afficher le montant total des commandes par année et pays et le montant total des commandes uniquement par année.
Ajouter également le montant total des commandes depuis le début de l’activité.
Ajouter également le montant total des commandes par pays.
La table brno2015
peut être téléchargée et restaurée
ainsi :
curl -kL https://dali.bo/tp_brno2015 -o /tmp/brno2015.dump
createdb brno2015
pg_restore -O -d brno2015 /tmp/brno2015.dump
# une erreur sur l'existence du schéma public est normale
Le schéma brno2015
dispose d’une table pilotes ainsi que
les résultats tour par tour de la course de MotoGP de Brno (CZ) de la
saison 2015.
La table brno2015
indique pour chaque tour, pour chaque
pilote, le temps réalisé dans le tour :
Table "public.brno_2015"
Column | Type | Modifiers
-----------+----------+-----------
no_tour | integer |
no_pilote | integer |
lap_time | interval |
Une table pilotes
permet de connaître les détails d’un
pilote :
Table "public.pilotes"
Column | Type | Modifiers
-------------+---------+-----------
no | integer |
nom | text |
nationalite | text |
ecurie | text |
moto | text |
Précisions sur les données à manipuler : la course est réalisée en plusieurs tours; certains coureurs n’ont pas terminé la course, leur relevé de tours s’arrête donc brutalement.
Agrégation
Tout d’abord, nous positionnons le search_path
pour
chercher les objets du schéma brno2015
:
SET search_path = brno2015;
Quel est le pilote qui a le moins gros écart entre son meilleur tour et son moins bon tour ?
Le coureur :
SELECT nom, max(lap_time) - min(lap_time) as ecart
FROM brno_2015
JOIN pilotes
ON (no_pilote = no)
GROUP BY 1
ORDER BY 2
LIMIT 1;
La requête donne le résultat suivant :
nom | ecart
-----------------+--------------
Jorge LORENZO | 00:00:04.661
Déterminer quel est le pilote le plus régulier (écart-type).
Nous excluons le premier tour car il s’agit d’une course avec départ arrêté, donc ce tour est plus lent que les autres, ici d’au moins 8 secondes :
SELECT nom, stddev(extract (epoch from lap_time)) as stddev
FROM brno_2015
JOIN pilotes
ON (no_pilote = no)
WHERE no_tour > 1
GROUP BY 1
ORDER BY 2
LIMIT 1;
Le résultat montre le coureur qui a abandonné en premier :
nom | stddev
-----------------+-------------------
Alex DE ANGELIS | 0.130107647741847
On s’aperçoit qu’Alex De Angelis n’a pas terminé la course. Il semble donc plus intéressant de ne prendre en compte que les pilotes qui ont terminé la course et toujours en excluant le premier tour (il y a 22 tours sur cette course, on peut le positionner soit en dur dans la requête, soit avec un sous-select permettant de déterminer le nombre maximum de tours) :
SELECT nom, stddev(extract (epoch from lap_time)) as stddev
FROM brno_2015
JOIN pilotes
ON (no_pilote = no)
WHERE no_tour > 1
AND no_pilote in (SELECT no_pilote FROM brno_2015 WHERE no_tour=22)
GROUP BY 1
ORDER BY 2
LIMIT 1;
Le pilote 19 a donc été le plus régulier :
nom | stddev
-----------------+-------------------
Alvaro BAUTISTA | 0.222825823492654
Window Functions
Si ce n’est pas déjà fait, nous positionnons le search_path pour
chercher les objets du schéma brno2015
:
SET search_path = brno2015;
Afficher la place sur le podium pour chaque coureur.
Les coureurs qui ne franchissent pas la ligne d’arrivée sont dans le classement malgré tout. Il faut donc tenir compte de cela dans l’affichage des résultats.
SELECT rank() OVER (ORDER BY max_lap desc, total_time asc) AS rang,
nom, ecurie, total_timeFROM (SELECT no_pilote,
sum(lap_time) over (PARTITION BY no_pilote) as total_time,
max(no_tour) over (PARTITION BY no_pilote) as max_lap
FROM brno_2015
AS race_data
) JOIN pilotes
ON (race_data.no_pilote = pilotes.no)
GROUP BY nom, ecurie, max_lap, total_time
ORDER BY max_lap desc, total_time asc;
La requête affiche le résultat suivant :
rang | nom | ecurie | total_time
------+------------------+-----------------------------+--------------
1 | Jorge LORENZO | Movistar Yamaha MotoGP | 00:42:53.042
2 | Marc MARQUEZ | Repsol Honda Team | 00:42:57.504
3 | Valentino ROSSI | Movistar Yamaha MotoGP | 00:43:03.439
4 | Andrea IANNONE | Ducati Team | 00:43:06.113
5 | Dani PEDROSA | Repsol Honda Team | 00:43:08.692
6 | Andrea DOVIZIOSO | Ducati Team | 00:43:08.767
7 | Bradley SMITH | Monster Yamaha Tech 3 | 00:43:14.863
8 | Pol ESPARGARO | Monster Yamaha Tech 3 | 00:43:16.282
9 | Aleix ESPARGARO | Team SUZUKI ECSTAR | 00:43:36.826
10 | Danilo PETRUCCI | Octo Pramac Racing | 00:43:38.303
11 | Yonny HERNANDEZ | Octo Pramac Racing | 00:43:43.015
12 | Scott REDDING | EG 0,0 Marc VDS | 00:43:43.216
13 | Alvaro BAUTISTA | Aprilia Racing Team Gresini | 00:43:47.479
14 | Stefan BRADL | Aprilia Racing Team Gresini | 00:43:47.666
15 | Loris BAZ | Forward Racing | 00:43:53.358
16 | Hector BARBERA | Avintia Racing | 00:43:54.637
17 | Nicky HAYDEN | Aspar MotoGP Team | 00:43:55.43
18 | Mike DI MEGLIO | Avintia Racing | 00:43:58.986
19 | Jack MILLER | CWM LCR Honda | 00:44:04.449
20 | Claudio CORTI | Forward Racing | 00:44:43.075
21 | Karel ABRAHAM | AB Motoracing | 00:44:55.697
22 | Maverick VIÑALES | Team SUZUKI ECSTAR | 00:29:31.557
23 | Cal CRUTCHLOW | CWM LCR Honda | 00:27:38.315
24 | Eugene LAVERTY | Aspar MotoGP Team | 00:08:04.096
25 | Alex DE ANGELIS | E-Motion IodaRacing Team | 00:06:05.782
(25 rows)
À partir de la requête précédente, afficher également la différence du temps de chaque coureur par rapport à celui de la première place.
La requête n’est pas beaucoup modifiée, seule la fonction
first_value()
est utilisée pour déterminer le temps du
vainqueur, temps qui sera ensuite retranché au temps du coureur
courant.
SELECT rank() OVER (ORDER BY max_lap desc, total_time asc) AS rang,
nom, ecurie, total_time,- first_value(total_time)
total_time OVER (ORDER BY max_lap desc, total_time asc) AS difference
FROM (SELECT no_pilote,
sum(lap_time) over (PARTITION BY no_pilote) as total_time,
max(no_tour) over (PARTITION BY no_pilote) as max_lap
FROM brno_2015
AS race_data
) JOIN pilotes
ON (race_data.no_pilote = pilotes.no)
GROUP BY nom, ecurie, max_lap, total_time
ORDER BY max_lap desc, total_time asc;
La requête affiche le résultat suivant :
r| nom | ecurie | total_time | difference
--+-----------------+----------------------+-------------+---------------
1| Jorge LORENZO | Movistar Yamaha [...]|00:42:53.042 | 00:00:00
2| Marc MARQUEZ | Repsol Honda Team |00:42:57.504 | 00:00:04.462
3| Valentino ROSSI | Movistar Yamaha [...]|00:43:03.439 | 00:00:10.397
4| Andrea IANNONE | Ducati Team |00:43:06.113 | 00:00:13.071
5| Dani PEDROSA | Repsol Honda Team |00:43:08.692 | 00:00:15.65
6| Andrea DOVIZIOSO| Ducati Team |00:43:08.767 | 00:00:15.725
7| Bradley SMITH | Monster Yamaha Tech 3|00:43:14.863 | 00:00:21.821
8| Pol ESPARGARO | Monster Yamaha Tech 3|00:43:16.282 | 00:00:23.24
9| Aleix ESPARGARO | Team SUZUKI ECSTAR |00:43:36.826 | 00:00:43.784
10| Danilo PETRUCCI | Octo Pramac Racing |00:43:38.303 | 00:00:45.261
11| Yonny HERNANDEZ | Octo Pramac Racing |00:43:43.015 | 00:00:49.973
12| Scott REDDING | EG 0,0 Marc VDS |00:43:43.216 | 00:00:50.174
13| Alvaro BAUTISTA | Aprilia Racing [...] |00:43:47.479 | 00:00:54.437
14| Stefan BRADL | Aprilia Racing [...] |00:43:47.666 | 00:00:54.624
15| Loris BAZ | Forward Racing |00:43:53.358 | 00:01:00.316
16| Hector BARBERA | Avintia Racing |00:43:54.637 | 00:01:01.595
17| Nicky HAYDEN | Aspar MotoGP Team |00:43:55.43 | 00:01:02.388
18| Mike DI MEGLIO | Avintia Racing |00:43:58.986 | 00:01:05.944
19| Jack MILLER | CWM LCR Honda |00:44:04.449 | 00:01:11.407
20| Claudio CORTI | Forward Racing |00:44:43.075 | 00:01:50.033
21| Karel ABRAHAM | AB Motoracing |00:44:55.697 | 00:02:02.655
22| Maverick VIÑALES| Team SUZUKI ECSTAR |00:29:31.557 | -00:13:21.485
23| Cal CRUTCHLOW | CWM LCR Honda |00:27:38.315 | -00:15:14.727
24| Eugene LAVERTY | Aspar MotoGP Team |00:08:04.096 | -00:34:48.946
25| Alex DE ANGELIS | E-Motion Ioda[...] |00:06:05.782 | -00:36:47.26
(25 rows)
Pour chaque tour, afficher :
- le nom du pilote ;
- son rang dans le tour ;
- son temps depuis le début de la course ;
- dans le tour, la différence de temps par rapport au premier.
Pour construire cette requête, nous avons besoin d’obtenir le temps cumulé tour après tour pour chaque coureur. Nous commençons donc par écrire une première requête :
SELECT *,
SUM(lap_time)
OVER (PARTITION BY no_pilote ORDER BY no_tour) AS temps_tour_glissant
FROM brno_2015
Elle retourne le résultat suivant :
no_tour | no_pilote | lap_time | temps_tour_glissant
---------+-----------+--------------+---------------------
1 | 4 | 00:02:02.209 | 00:02:02.209
2 | 4 | 00:01:57.57 | 00:03:59.779
3 | 4 | 00:01:57.021 | 00:05:56.8
4 | 4 | 00:01:56.943 | 00:07:53.743
5 | 4 | 00:01:57.012 | 00:09:50.755
6 | 4 | 00:01:57.011 | 00:11:47.766
7 | 4 | 00:01:57.313 | 00:13:45.079
8 | 4 | 00:01:57.95 | 00:15:43.029
9 | 4 | 00:01:57.296 | 00:17:40.325
10 | 4 | 00:01:57.295 | 00:19:37.62
11 | 4 | 00:01:57.185 | 00:21:34.805
12 | 4 | 00:01:57.45 | 00:23:32.255
13 | 4 | 00:01:57.457 | 00:25:29.712
14 | 4 | 00:01:57.362 | 00:27:27.074
15 | 4 | 00:01:57.482 | 00:29:24.556
16 | 4 | 00:01:57.358 | 00:31:21.914
17 | 4 | 00:01:57.617 | 00:33:19.531
18 | 4 | 00:01:57.594 | 00:35:17.125
19 | 4 | 00:01:57.412 | 00:37:14.537
20 | 4 | 00:01:57.786 | 00:39:12.323
21 | 4 | 00:01:58.087 | 00:41:10.41
22 | 4 | 00:01:58.357 | 00:43:08.767
(…)
Cette requête de base est ensuite utilisée dans une CTE qui sera
utilisée par la requête répondant à la question de départ. La colonne
temps_tour_glissant
est utilisée pour calculer le rang du
pilote dans la course, est affiché et le temps cumulé du meilleur pilote
est récupéré avec la fonction first_value
:
WITH temps_glissant AS (
SELECT no_tour, no_pilote, lap_time,
sum(lap_time)
OVER (PARTITION BY no_pilote
ORDER BY no_tour
as temps_tour_glissant
) FROM brno_2015
ORDER BY no_pilote, no_tour
)
SELECT no_tour, nom,
rank() OVER (PARTITION BY no_tour
ORDER BY temps_tour_glissant ASC
as place_course,
)
temps_tour_glissant,- first_value(temps_tour_glissant)
temps_tour_glissant OVER (PARTITION BY no_tour
ORDER BY temps_tour_glissant asc
AS difference
) FROM temps_glissant t
JOIN pilotes p ON p.no = t.no_pilote;
On pouvait également utiliser une simple sous-requête pour obtenir le même résultat :
SELECT no_tour,
nom,rank()
OVER (PARTITION BY no_tour
ORDER BY temps_tour_glissant ASC
AS place_course,
)
temps_tour_glissant,- first_value(temps_tour_glissant)
temps_tour_glissant OVER (PARTITION BY no_tour
ORDER BY temps_tour_glissant asc
AS difference
) FROM (
SELECT *, SUM(lap_time)
OVER (PARTITION BY no_pilote
ORDER BY no_tour)
AS temps_tour_glissant
FROM brno_2015) course
JOIN pilotes
ON (pilotes.no = course.no_pilote)
ORDER BY no_tour;
La requête fournit le résultat suivant :
no.| nom | place_c. | temps_tour_glissant | difference
---+------------------+----------+---------------------+--------------
1 | Jorge LORENZO | 1 | 00:02:00.83 | 00:00:00
1 | Marc MARQUEZ | 2 | 00:02:01.058 | 00:00:00.228
1 | Andrea DOVIZIOSO | 3 | 00:02:02.209 | 00:00:01.379
1 | Valentino ROSSI | 4 | 00:02:02.329 | 00:00:01.499
1 | Andrea IANNONE | 5 | 00:02:02.597 | 00:00:01.767
1 | Bradley SMITH | 6 | 00:02:02.861 | 00:00:02.031
1 | Pol ESPARGARO | 7 | 00:02:03.239 | 00:00:02.409
( ..)
2 | Jorge LORENZO | 1 | 00:03:57.073 | 00:00:00
2 | Marc MARQUEZ | 2 | 00:03:57.509 | 00:00:00.436
2 | Valentino ROSSI | 3 | 00:03:59.696 | 00:00:02.623
2 | Andrea DOVIZIOSO | 4 | 00:03:59.779 | 00:00:02.706
2 | Andrea IANNONE | 5 | 00:03:59.9 | 00:00:02.827
2 | Bradley SMITH | 6 | 00:04:00.355 | 00:00:03.282
2 | Pol ESPARGARO | 7 | 00:04:00.87 | 00:00:03.797
2 | Maverick VIÑALES | 8 | 00:04:01.187 | 00:00:04.114
(…)
(498 rows)
Pour chaque coureur, quel est son meilleur tour et quelle place avait-il sur ce tour ?
Il est ici nécessaire de sélectionner pour chaque tour le temps du meilleur tour. On peut alors sélectionner les tours pour lequels le temps du tour est égal au meilleur temps :
WITH temps_glissant AS (
SELECT no_tour, no_pilote, lap_time,
sum(lap_time)
OVER (PARTITION BY no_pilote
ORDER BY no_tour
as temps_tour_glissant
) FROM brno_2015
ORDER BY no_pilote, no_tour
),
AS (
classement_tour SELECT no_tour, no_pilote, lap_time,
rank() OVER (
PARTITION BY no_tour
ORDER BY temps_tour_glissant
as place_course,
)
temps_tour_glissant,min(lap_time) OVER (PARTITION BY no_pilote) as meilleur_temps
FROM temps_glissant
)
SELECT no_tour, nom, place_course, lap_time
FROM classement_tour t
JOIN pilotes p ON p.no = t.no_pilote
WHERE lap_time = meilleur_temps;
Ce qui donne le résultat suivant :
no_tour | nom | place_course | lap_time
---------+------------------+--------------+--------------
4 | Jorge LORENZO | 1 | 00:01:56.169
4 | Marc MARQUEZ | 2 | 00:01:56.048
4 | Valentino ROSSI | 3 | 00:01:56.747
6 | Andrea IANNONE | 5 | 00:01:56.86
6 | Dani PEDROSA | 7 | 00:01:56.975
4 | Andrea DOVIZIOSO | 4 | 00:01:56.943
3 | Bradley SMITH | 6 | 00:01:57.25
17 | Pol ESPARGARO | 8 | 00:01:57.454
4 | Aleix ESPARGARO | 12 | 00:01:57.844
4 | Danilo PETRUCCI | 11 | 00:01:58.121
9 | Yonny HERNANDEZ | 14 | 00:01:58.53
2 | Scott REDDING | 14 | 00:01:57.976
3 | Alvaro BAUTISTA | 21 | 00:01:58.71
3 | Stefan BRADL | 16 | 00:01:58.38
3 | Loris BAZ | 19 | 00:01:58.679
2 | Hector BARBERA | 15 | 00:01:58.405
2 | Nicky HAYDEN | 16 | 00:01:58.338
3 | Mike DI MEGLIO | 18 | 00:01:58.943
4 | Jack MILLER | 22 | 00:01:59.007
2 | Claudio CORTI | 24 | 00:02:00.377
14 | Karel ABRAHAM | 23 | 00:02:01.716
3 | Maverick VIÑALES | 8 | 00:01:57.436
3 | Cal CRUTCHLOW | 11 | 00:01:57.652
3 | Eugene LAVERTY | 20 | 00:01:58.977
3 | Alex DE ANGELIS | 23 | 00:01:59.257
(25 rows)
Déterminer quels sont les coureurs ayant terminé la course qui ont gardé la même position tout au long de la course.
WITH nb_tour AS (
SELECT max(no_tour) FROM brno_2015
),AS (
temps_glissant SELECT no_tour, no_pilote, lap_time,
sum(lap_time) OVER (
PARTITION BY no_pilote
ORDER BY no_tour
as temps_tour_glissant,
) max(no_tour) OVER (PARTITION BY no_pilote) as total_tour
FROM brno_2015
),AS (
classement_tour SELECT no_tour, no_pilote, lap_time, total_tour,
rank() OVER (
PARTITION BY no_tour
ORDER BY temps_tour_glissant
as place_course
) FROM temps_glissant
)SELECT no_pilote
FROM classement_tour t
JOIN nb_tour n ON n.max = t.total_tour
GROUP BY no_pilote
HAVING count(DISTINCT place_course) = 1;
Elle retourne le résultat suivant :
no_pilote
-----------
93
99
En quelle position a terminé le coureur qui a doublé le plus de personnes ? Combien de personnes a-t-il doublées ?
WITH temps_glissant AS (
SELECT no_tour, no_pilote, lap_time,
sum(lap_time) OVER (
PARTITION BY no_pilote
ORDER BY no_tour
as temps_tour_glissant
) FROM brno_2015
),AS (
classement_tour SELECT no_tour, no_pilote, lap_time,
rank() OVER (
PARTITION BY no_tour
ORDER BY temps_tour_glissant
as place_course,
)
temps_tour_glissantFROM temps_glissant
),AS (
depassement SELECT no_pilote,
last_value(place_course) OVER (PARTITION BY no_pilote) as rang,
CASE
WHEN lag(place_course) OVER (
PARTITION BY no_pilote
ORDER BY no_tour
- place_course < 0
) THEN 0
ELSE lag(place_course) OVER (
PARTITION BY no_pilote
ORDER BY no_tour
- place_course
) END AS depasse
FROM classement_tour t
)
SELECT no_pilote, rang, sum(depasse)
FROM depassement
GROUP BY no_pilote, rang
ORDER BY sum(depasse) DESC
LIMIT 1;
Grouping Sets
La suite de ce TP est maintenant réalisé avec la base de formation habituelle. Attention, ce TP nécessite l’emploi d’une version 9.5 ou supérieure de PostgreSQL.
Tout d’abord, nous positionnons le search_path pour chercher les
objets du schéma magasin
:
SET search_path = magasin;
En une seule requête, afficher le montant total des commandes par année et pays et le montant total des commandes uniquement par année.
SELECT extract('year' from date_commande) AS annee, code_pays,
SUM(quantite*prix_unitaire) AS montant_total_commande
FROM commandes c
JOIN lignes_commandes l
ON (c.numero_commande = l.numero_commande)
JOIN clients
ON (c.client_id = clients.client_id)
JOIN contacts co
ON (clients.contact_id = co.contact_id)
GROUP BY GROUPING SETS (
extract('year' from date_commande), code_pays),
(extract('year' from date_commande))
( );
Le résultat attendu est :
annee | code_pays | montant_total_commande
-------+-----------+------------------------
2003 | DE | 49634.24
2003 | FR | 10003.98
2003 | | 59638.22
2008 | CA | 1016082.18
2008 | CN | 801662.75
2008 | DE | 694787.87
2008 | DZ | 663045.33
2008 | FR | 5860607.27
2008 | IN | 741850.87
2008 | PE | 1167825.32
2008 | RU | 577164.50
2008 | US | 928661.06
2008 | | 12451687.15
(...)
Ajouter également le montant total des commandes depuis le début de l’activité.
L’opérateur de regroupement ROLL UP
amène le niveau
d’agrégation sans regroupement :
SELECT extract('year' from date_commande) AS annee, code_pays,
SUM(quantite*prix_unitaire) AS montant_total_commande
FROM commandes c
JOIN lignes_commandes l
ON (c.numero_commande = l.numero_commande)
JOIN clients
ON (c.client_id = clients.client_id)
JOIN contacts co
ON (clients.contact_id = co.contact_id)
GROUP BY ROLLUP (extract('year' from date_commande), code_pays);
Ajouter également le montant total des commandes par pays.
Cette fois, l’opérateur CUBE
permet d’obtenir l’ensemble
de ces informations :
SELECT extract('year' from date_commande) AS annee, code_pays,
SUM(quantite*prix_unitaire) AS montant_total_commande
FROM commandes c
JOIN lignes_commandes l
ON (c.numero_commande = l.numero_commande)
JOIN clients
ON (c.client_id = clients.client_id)
JOIN contacts co
ON (clients.contact_id = co.contact_id)
GROUP BY CUBE (extract('year' from date_commande), code_pays);
true
lorsque le regroupement est réalisé sur l’ensemble des
valeurs de la colonne.Ces colonnes booléennes permettent d’indiquer à l’application comment gérer la présentation des résultats.
SELECT grouping(extract('year' from date_commande))::boolean AS g_annee,
grouping(code_pays)::boolean AS g_pays,
extract('year' from date_commande) AS annee,
code_pays,SUM(quantite*prix_unitaire) AS montant_total_commande
FROM commandes c
JOIN lignes_commandes l
ON (c.numero_commande = l.numero_commande)
JOIN clients
ON (c.client_id = clients.client_id)
JOIN contacts co
ON (clients.contact_id = co.contact_id)
GROUP BY CUBE (extract('year' from date_commande), code_pays);
Les UUID (pour Universally Unique IDentifier) sont nés d’un besoin d’avoir des identifiants uniques au niveau mondial pour divers objets, avec un risque de collision théoriquement négligeable. Ce sont des identifiants sur 128 bits.
Le standard propose plusieurs versions à cause d’un historique déjà long depuis les années 1980, et de différents algorithmes de création ou d’utilisation dans des bases de données. Il existe aussi des versions dérivées liées à certains éditeurs.
Dans une base, les clés primaires « techniques » (surrogate), servent à identifier de manière unique une ligne, sans posséder de sens propre : les UUID peuvent donc parfaitement remplacer les numéros de séquence traditionnels. Ce n’est pas toujours une bonne idée.
Références :
Sous PostgreSQL, nous verrons que de simples fonctions comme
gen_random_uuid()
, ou celles de l’extension standard
uuid-ossp
, permettent de générer des UUID aussi facilement
que des numéros de séquences. Il est bien sûr possible que ces UUID
soient fournis par des applications extérieures.
Généralement, les clés primaires des tables proviennent d’entiers
générés successivement (séquences), généralement en partant de 1.
L’unicité des identifiants est ainsi facilement garantie. Cela ne pose
aucun souci jusqu’au jour où les données sont à rapprocher de données
d’une autre base. Il y a de bonnes chances que les deux bases utilisent
les mêmes identifiants pour des choses différentes. Souvent, une clé
fonctionnelle (unique aussi) permet de faire le lien (commande
DALIBO-CRA-1234
, personne de numéro
25502123123
…) mais ce n’est pas toujours le cas et des
erreurs de génération sont possibles. Un UUID arbitraire et unique est
une solution facile pour nommer n’importe quelle entité logique ou
physique sans risque de collision avec les identifiants d’un autre
système.
Les UUID sont parfaits s’il y a des cas où il faut fusionner des bases de données issues de plusieurs bases de même structure. Cela peut arriver dans certains contextes distribués ou multitenants.
Hormis ce cas particulier, ils sont surtout utiles pour identifier un ensemble de données échangés entre deux systèmes (que ce soit en JSON, CSV ou un autre moyen) : l’UUID devient une sorte de clé primaire publique. En interne, les deux bases peuvent continuer à utiliser des séquences classiques pour leurs jointures.
Il est techniquement possible de pousser la logique jusqu’au bout et de décider que chaque clé primaire d’une ligne sera un UUID et non un entier, et de joindre sur ces UUID.
Des numéros de séquence consécutifs peuvent se deviner (dans l’URL d’un site web par exemple), ce qui peut être une faille de sécurité. Des UUID (apparemment) aléatoires ne présentent pas ce problème… si l’on a bien choisi la version d’UUID (voir plus loin).
Lisibilité :
Le premier inconvénient n’est pas technique mais humain : il est plus aisé de lire et retenir des valeurs courtes comme un ticket 10023, une commande 2024-67 ou une immatriculation AT-389-RC que « d67572bf-5d8c-47a7-9457-a9ddce259f05 ». Les UUID sont donc à réserver aux clés techniques. De même, un développeur qui consulte une base retiendra et discernera plus facilement des valeurs entre 1 000 et 100 000 que des UUID à première vue aléatoires, et surtout à rallonge.
Pour la base de données, il y a d’autres inconvénients :
Taille :
Le type uuid
de PostgreSQL prend 128 bits, donc 16
octets. Les types
numériques entiers de PostgreSQL utilisent 2 octets pour un
smallint
(int2
, de -32768 à +32767), 4 pour un
integer
(de -2 à +2 milliards environ), 8 pour un
bigint
(int8
, de -9.10¹⁸ à +9.10¹⁸ environ).
Ces types entiers suffisent généralement à combler les besoins, tout en
permettant de choisir le type le plus petit possible. On a donc une
différence de 8 octets par ligne entre uuid
et
bigint
, à multiplier par autant de lignes, parfois des
milliards.
Cette différence s’amplifie tout le long de l’utilisation de la clé :
Ce n’est pas forcément bloquant si votre utilisation le nécessite.
Le pire est le stockage d’UUID dans un champ varchar
:
la taille passe à 36, les jointures sont bien plus lourdes, et la
garantie d’avoir un véritable UUID disparaît !
Temps de génération :
Selon l’algorithme de génération utilisé, la création d’un UUID peut être plusieurs fois plus lente que celle d’un numéro de séquence. Mais ce n’est pas vraiment un souci avec les processeurs modernes, qui sont capables de générer des dizaines, voire des centaines de milliers d’UUID, aléatoires ou pas, par seconde.
Fragmentation des index :
Le plus gros problème des UUID vient de leur apparence aléatoire. Cela a un impact sur la fragmentation des index et leur utilisation du cache.
Parlons d’abord de l’insertion de nouvelles lignes. Par défaut, les UUID sont générés en utilisant la version 4. Elle repose sur un algorithme générant des nombres aléatoires. Par conséquent, les UUID produits sont imprévisibles. Cela peut entraîner de fréquents splits des pages d’index (division d’une page pleine qui doit accueillir de nouvelles entrées). Les conséquences directes sont la fragmentation de l’index, une augmentation de sa taille (avec un effet négatif sur le cache), et l’augmentation du nombre d’accès disques (en lecture et écriture).
De plus, toujours avec des UUID version 4, comme les mises à jour sont réparties sur toute la longueur des index, ceux-ci tendent à rester entièrement dans le cache de PostgreSQL. Si celui-ci est insuffisant, des accès disques aléatoires fréquents peuvent devenir gênants.
À l’inverse, une séquence génère des valeurs strictement croissantes, donc toujours insérées à la fin de l’index. Non seulement la fragmentation reste basse, mais la partie utile de l’index, en cache, reste petite.
Évidemment, tout cela devient plus complexe quand on modifie ensuite les lignes. Mais beaucoup d’applications ont tendance à modifier surtout les lignes récentes, et délaissent les blocs d’index des lignes anciennes.
Pour un index qui reste petit, donc une table statique ou dont les anciennes lignes sont vite supprimées, ce n’est pas vraiment un problème. Mais un modèle où chaque clé de table et chaque clé étrangère est un index a intérêt à pouvoir garder tous ces index en mémoire.
Récemment, une solution standardisée est apparue avec les UUID version 7 (standardisés dans la RFC 9562 en 2024) : ces UUID utilisent l’heure de génération et sont donc triés. Le souci de pollution du cache disparaît donc.
Le type uuid
est connu de PostgreSQL, c’est un champ
simple de taille fixe. Si l’UUID provient de l’extérieur, le type
garantit qu’il s’agit d’un UUID valide.
gen_random_uuid() :
Générer un UUID depuis le SQL est très simple avec la fonction
gen_random_uuid()
:
SELECT gen_random_uuid() FROM generate_series (1,4) ;
gen_random_uuid
--------------------------------------
d1ac1da0-4c0c-4e56-9302-72362cc5726c
c32fa82d-a2c1-4520-8b70-95919c6cb15f
dd980a9c-05a8-4659-a1e7-ca7836bc7da7 27de59d3-60bc-43b9-8d03-4779a1a01e47
Les UUID générés sont de version 4, c’est-à-dire totalement aléatoires, avec tous les inconvénients vus ci-dessus.
uuid-ossp :
La fonction gen_random_uuid()
n’est disponible
directement que depuis PostgreSQL 13. Auparavant, il fallait forcément
utiliser une extension : soit pgcrypto
, qui
fournissait cette fonction, soit uuid-ossp
,
toutes deux livrées avec PostgreSQL. uuid-ossp
reste utile
car elle fournit plusieurs algorithmes de génération d’UUID avec les
fonctions suivantes.
Avec uuid_generate_v1()
, l’UUID généré est lié à
l’adresse MAC de la machine et à l’heure.
Cette propriété peut faciliter la prédiction de futures valeurs d’UUID. Les UUID v1 peuvent donc être considérés comme une faille de sécurité dans certains contextes.
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" ;
SELECT uuid_generate_v1() from generate_series(1,5) ;
uuid_generate_v1
--------------------------------------
82e94192-45e3-11ef-92e5-04cf4b21f39a
82e94193-45e3-11ef-92e5-04cf4b21f39a
82e94194-45e3-11ef-92e5-04cf4b21f39a 82e94195-45e3-11ef-92e5-04cf4b21f39a
Sur une autre machine :
SELECT uuid_generate_v1(),pg_sleep(5) from generate_series(1,5) ;
uuid_generate_v1 | pg_sleep
--------------------------------------+----------
ef5078b4-45e3-11ef-a2d4-67bc5acec5f2 |
f24c2982-45e3-11ef-a2d4-67bc5acec5f2 |
f547b552-45e3-11ef-a2d4-67bc5acec5f2 |
f84345aa-45e3-11ef-a2d4-67bc5acec5f2 | fb3ed120-45e3-11ef-a2d4-67bc5acec5f2 |
Noter que le problème de fragmentation des index se pose déjà.
Il existe une version uuid_generate_v1mc()
un peu plus
sécurisée.
uuid_generate_v3()
et uuid_generate_v5()
génèrent des valeurs reproductibles en fonction des paramètres. La
version 5 utilise un algorithme plus sûr.
uuid_generate_v4
génère un UUID totalement aléatoire,
comme gen_random_uuid()
.
UUID version 7 :
PostgreSQL ne sait pas encore générer d’UUID en version 7. Il existe
cependant plusieurs extensions dédiées, avec les soucis habituels de
disponibilité de paquets, maintenance des versions, confiance dans le
mainteneur et disponibilité dans un PostgreSQL en SaaS. Par exemple,
Supabase propose pg_idkit
(versions Rust, et PL/pgSQL).
Le plus simple est sans doute d’utiliser la fonction SQL suivante, de Kyle Hubert, modifiée par Daniel Vérité. Elle est sans doute suffisamment rapide pour la plupart des besoins.
CREATE FUNCTION uuidv7() RETURNS uuid
AS $$
-- Replace the first 48 bits of a uuidv4 with the current
-- number of milliseconds since 1970-01-01 UTC
-- and set the "ver" field to 7 by setting additional bits
SELECT encode(
set_bit(
set_bit(
overlay(uuid_send(gen_random_uuid()) placingextract(epoch FROM clock_timestamp())*1000)::bigint)
substring(int8send((FROM 3)
FROM 1 for 6),
52, 1),
53, 1), 'hex')::uuid;
$$ LANGUAGE sql VOLATILE;
Il existe une version plus lente avec une précision inférieure à la milliseconde. Le même billet de blog offre une fonction retrouvant l’heure de création d’un UUID v7 :
CREATE FUNCTION uuidv7_extract_timestamp(uuid) RETURNS timestamptz
AS $$
SELECT to_timestamp(
right(substring(uuid_send($1) FROM 1 for 6)::text, -1)::bit(48)::int8
/1000.0);
$$ LANGUAGE sql IMMUTABLE STRICT;
-- 10 UUID v 7 espacés de 3 secondes
WITH us AS (SELECT uuidv7() AS u, pg_sleep(3)
FROM generate_series (1,10))
SELECT u, uuidv7_extract_timestamp(u)
FROM us ;
u | uuidv7_extract_timestamp
--------------------------------------+----------------------------
0190cbaf-7879-7a4c-9ee3-8d383157b5cc | 2024-07-19 17:49:52.889+02
0190cbaf-8435-7bb8-8417-30376a2e7251 | 2024-07-19 17:49:55.893+02
0190cbaf-8fef-7535-8fd6-ab7316259338 | 2024-07-19 17:49:58.895+02
0190cbaf-9baa-74f3-aa9e-bf2d2fa84e68 | 2024-07-19 17:50:01.898+02
0190cbaf-a766-7ef6-871d-2f25e217a6ea | 2024-07-19 17:50:04.902+02
0190cbaf-b321-717b-8d42-5969de7e7c1e | 2024-07-19 17:50:07.905+02
0190cbaf-bedb-79c1-b67d-0034d51ac1ad | 2024-07-19 17:50:10.907+02
0190cbaf-ca95-7d70-a8c0-f4daa60cbe21 | 2024-07-19 17:50:13.909+02
0190cbaf-d64f-7ffe-89cd-987377b2cc07 | 2024-07-19 17:50:16.911+02 0190cbaf-e20a-7260-95d6-32fec0a7e472 | 2024-07-19 17:50:19.914+02
Ils sont classés à la suite dans l’index, ce qui est tout l’intérêt de la version 7.
Noter que cette fonction économise les 8 octets par ligne d’un champ
creation_date
, que beaucoup de développeurs ajoutent.
Création de table :
Utilisez une clause DEFAULT
pour générer l’UUID à la
volée :
CREATE TABLE test_uuidv4 (id uuid DEFAULT ( gen_random_uuid() ) PRIMARY KEY,
<autres champ>…) ;
CREATE TABLE test_uuidv7 (id uuid DEFAULT ( uuidv7() ) PRIMARY KEY,
<autres champ>…) ;
L’index B-tree classique convient parfaitement pour trier des UUID.
En général on le veut UNIQUE
(plus pour parer à des erreurs
humaines qu’à de très improbables collisions dans l’algorithme de
génération).
Si des données doivent être échangées avec d’autres systèmes, les UUID sont un excellent moyen de garantir l’unicité d’identifiants universels.
Si vous les générez vous-mêmes, préférez les UUID version 7. Des UUID v4 (totalement aléatoires) restent sinon recommandables, avec les soucis potentiels de cache et de fragmentation évoqués ci-dessus.
Pour les jointures internes à l’applicatif, conservez les séquences
habituelles, (notamment avec GENERATED ALWAYS AS IDENTITY
),
ne serait-ce que pour leur simplicité.
Une tableau est un ensemble d’objets d’un même type. Ce type de base est généralement un numérique ou une chaîne, mais ce peut être un type structuré (géométrique, JSON, type personnalisé…), voire un type tableau. Les tableaux peuvent être multidimensionnels.
Un tableau se crée par exemple avec le constructeur
ARRAY
, avec la syntaxe {…}::type[]
, ou en
agrégeant des lignes existantes avec array_agg
. À
l’inverse, on peut transformer un tableau en lignes grâce à la fonction
unnest
. Les syntaxes [numéro]
et
[début:fin]
permettent d’extraire un élément ou une partie
d’un tableau. Deux tableaux se concatènent avec ||
.
Les tableaux sont ordonnés, ce ne sont pas des ensembles. Deux tableaux avec les mêmes données dans un ordre différent ne sont pas identiques.
Références :
array_to_string
,
string_to_array
, unnest
,
array_agg
, array_length
,
array_cat
, array_append
,
array_prepend
, cardinality
,
array_position
/array_positions
,
array_fill
, array_remove
,
array_shuffle
, trim_array
…CREATE TABLE demotab ( id int, liste int[] ) ;
INSERT INTO demotab (id, liste)
SELECT i, array_agg (j)
FROM generate_series (1,5) i,
*10, i*10+5) j
LATERAL generate_series (iGROUP BY i
;
TABLE demotab ;
id | liste
----+---------------------
1 | {10,11,12,13,14,15}
3 | {30,31,32,33,34,35}
5 | {50,51,52,53,54,55}
4 | {40,41,42,43,44,45} 2 | {20,21,22,23,24,25}
Recherchons des lignes contenant certaines valeurs :
-- Ceci échoue car 11 n'est le PREMIER élément sur aucune ligne
SELECT * FROM demotab
WHERE liste[1] = 11 ;
-- Recherche de la ligne qui contient 11
SELECT * FROM demotab
WHERE liste @> ARRAY[11] ;
id | liste
----+--------------------- 1 | {10,11,12,13,14,15}
-- Recherche de la ligne qui contient 11 ET 15 (ordre indifférent)
SELECT * FROM demotab
WHERE liste @> ARRAY[15,11] ;
id | liste
----+--------------------- 1 | {10,11,12,13,14,15}
-- Recherche des deux lignes contenant 11 OU 55 (éléments communs)
SELECT * FROM demotab
WHERE liste && ARRAY[11,55] ;
id | liste
----+---------------------
1 | {10,11,12,13,14,15} 5 | {50,51,52,53,54,55}
Une bonne modélisation aboutit en général à des valeurs uniques, chacune dans son champ sur sa ligne. Les tableaux stockent plusieurs valeurs dans un même champ d’une ligne, et vont donc à l’encontre de cette bonne pratique.
Cependant les tableaux peuvent être très pratiques pour alléger la modélisation sans tomber dans de mauvaises pratiques. Typiquement, on remplacera :
-- Mauvaise pratique : champs identiques séparés à nombre fixe
CREATE TABLE personnes ( …
telephone1 text, telephone2 text … ) ;
par :
CREATE TABLE personnes ( …
telephones text[] ) ;
Une table des numéros de téléphone serait stricto censu plus
propre et flexible, mais induirait des jointures supplémentaires. De
plus, il est impossible de poser des contraintes de validation
(CHECK
) sur des éléments de tableau sans créer un type
intermédiaire. (Dans des cas plus complexes où il faut typer le numéro,
on peut utiliser un tableau d’un type structuré, ou basculer vers un
type JSON, qui peut lui-même contenir des tableaux, mais a un maniement
un peu moins évident. L’intérêt du type structuré sur un champ JSON ou
hstore est qu’il est plus compact, mais évidemment sans aucune
flexibilité.)
Quand il y a beaucoup de lignes et peu de valeurs sur celles-ci, il faut se rappeler que chaque ligne d’une base de données PostgreSQL a un coût d’au moins 24 octets de données « administratives », même si l’on ne stocke qu’un entier par ligne, par exemple. Agréger des valeurs dans un tableau permet de réduire mécaniquement le nombre de lignes et la volumétrie sur le disque et celles des écritures. Par contre, le développeur devra savoir comment utiliser ces tableaux, comment retrouver une valeur donnée à l’intérieur d’un champ multicolonne, et comment le faire efficacement.
De plus, si le champ concaténé est assez gros (typiquement 2 ko), le mécanisme TOAST peut s’activer et procéder à la compression du champ tableau, ou à son déport dans une table système de manière transparente. (Pour les détails sur le mécanisme TOAST, voir cet extrait de la formation DBA2.)
Les tableaux peuvent donc permettre un gros gain de volumétrie. De plus, les données d’un même tableau, souvent utilisées ensemble, restent forcément dans le même bloc. L’effet sur le cache est donc extrêmement intéressant.
Par exemple, ces deux tables contiennent 6,3 millions de valeurs réparties sur 366 jours :
-- Table avec 1 valeur/ligne
CREATE TABLE serieparligne (d timestamptz PRIMARY KEY,
int );
valeur
INSERT INTO serieparligne
SELECT d, extract (hour from d)
FROM generate_series ('2020-01-01'::timestamptz,
'2020-12-31'::timestamptz, interval '5 s') d ;
SET default_toast_compression TO lz4 ; -- pour PG >= 14
-- Table avec les 17280 valeurs du jour sur 366 lignes :
CREATE TABLE serieparjour (d date PRIMARY KEY,
int[]
valeurs
) ;
INSERT INTO serieparjour
SELECT d::date, array_agg ( extract (hour from d) )
FROM generate_series ('2020-01-01'::timestamptz,
'2020-12-31'::timestamptz, interval '5 s') d
GROUP BY d::date ;
La différence de taille est d’un facteur 1000 :
ANALYZE serieparjour,serieparligne ;
SELECT
:regnamespace || '.' || relname AS TABLE,
c.relnamespace:AS nb_lignes_estimees,
reltuples oid)) AS " Table (dont TOAST)",
pg_size_pretty(pg_table_size(c.oid)) AS " Heap",
pg_size_pretty(pg_relation_size(c.AS " Toast",
pg_size_pretty(pg_relation_size(reltoastrelid)) oid)) AS " Index",
pg_size_pretty(pg_indexes_size(c.oid)) AS "Total"
pg_size_pretty(pg_total_relation_size(c.FROM pg_class c
WHERE relkind = 'r'
AND relname like 'seriepar%' ;
-[ RECORD 1 ]-------+---------------------
table | public.serieparjour
nb_lignes_estimees | 366
Table (dont TOAST) | 200 kB
Heap | 168 kB
Toast | 0 bytes
Index | 16 kB
Total | 216 kB
-[ RECORD 2 ]-------+---------------------
table | public.serieparligne
nb_lignes_estimees | 6.3072e+06
Table (dont TOAST) | 266 MB
Heap | 266 MB
Toast | ø
Index | 135 MB Total | 402 MB
Ce cas est certes extrême (beaucoup de valeurs par ligne et peu de valeurs distinctes).
Les cas réels sont plus complexes, avec un horodatage moins régulier. Par exemple, l’outil OPM stocke plutôt des tableaux d’un type composé d’une date et de la valeur relevée, et non la valeur seule.
Dans beaucoup de cas, cette technique assez simple évite de recourir à des extensions spécialisées payantes comme TimescaleDB, voire à des bases de données spécialisées.
Évidemment, le code devient moins simple. Selon les besoins, il peut y avoir besoin de stockage temporaire, de fonctions de compactage périodique…
Il devient plus compliqué de retrouver une valeur précise. Ce n’est pas trop un souci dans les cas pour une recherche ou pré-sélection à partir d’un autre critère (ici la date, indexée). Pour la recherche dans les tableaux, voir plus bas.
Les mises à jour des données à l’intérieur d’un tableau deviennent moins faciles et peuvent être lourdes en CPU avec de trop gros tableaux.
Indexer un champ tableau est techniquement possible avec un index B-tree, le type d’index par défaut. Mais cet index est en pratique peu performant. De toute façon il ne permet que de chercher un tableau entier (ordonné) comme critère.
Dans les exemples précédents, les index B-tree sont plutôt à placer sur un autre champ (la date, l’ID), qui ramène une ligne entière.
D’autres cas nécessitent de chercher une valeur parmi celles des
tableaux (par exemple dans des listes de propriétés). Dans ce cas, un
index GIN sera plus adapté, même si cet index est un peu lourd. Les
opérateurs ||
et @>
sont utilisables. La
valeur recherchée peut être n’importe où dans les tableaux.
TRUNCATE TABLE demotab ;
-- 500 000 lignes
INSERT INTO demotab (id, liste)
SELECT i, array_agg (j)
FROM generate_series (1,500000) i,
mod(i*10,100000), mod(i*10,100000)+5) j
LATERAL generate_series (GROUP BY i ;
CREATE INDEX demotab_gin ON demotab USING gin (liste);
EXPLAIN (ANALYZE,BUFFERS) SELECT * FROM demotab
WHERE liste @> ARRAY[45] ;
QUERY PLAN
-----------------------------------------------------------------------------
Bitmap Heap Scan on demotab (cost=183.17..2377.32 rows=2732 width=49) (actual time=0.958..1.014 rows=50 loops=1)
Recheck Cond: (liste @> '{45}'::integer[])
Heap Blocks: exact=50
Buffers: shared hit=211
-> Bitmap Index Scan on demotab_gin (cost=0.00..182.49 rows=2732 width=0) (actual time=0.945..0.945 rows=50 loops=1)
Index Cond: (liste @> '{45}'::integer[])
Buffers: shared hit=161
Planning:
Buffers: shared hit=4
Planning Time: 0.078 ms Execution Time: 1.042 ms
Là encore, on récupère les tableaux entiers qui contiennent la valeur demandée. Selon le besoin, il faudra peut-être reparcourir les éléments récupérés, ce qui coûtera un peu de CPU :
EXPLAIN (ANALYZE)
SELECT id,
SELECT count(*) FROM unnest (liste) e WHERE e=45) AS nb_occurences_45
(FROM demotab
WHERE liste @> ARRAY[45] ;
QUERY PLAN
-----------------------------------------------------------------------------
Bitmap Heap Scan on demotab (cost=23.37..2417.62 rows=2500 width=12) (actual time=0.067..0.325 rows=50 loops=1)
Recheck Cond: (liste @> '{45}'::integer[])
Heap Blocks: exact=50
-> Bitmap Index Scan on demotab_gin (cost=0.00..22.75 rows=2500 width=0) (actual time=0.024..0.024 rows=50 loops=1)
Index Cond: (liste @> '{45}'::integer[])
SubPlan 1
-> Aggregate (cost=0.13..0.14 rows=1 width=8) (actual time=0.003..0.003 rows=1 loops=50)
-> Function Scan on unnest e (cost=0.00..0.13 rows=1 width=0) (actual time=0.002..0.002 rows=1 loops=50)
Filter: (e = 45)
Rows Removed by Filter: 5
Planning Time: 0.240 ms Execution Time: 0.388 ms
Quant aux recherches sur une plage de valeurs dans les tableaux, elles ne sont pas directement indexables par un index GIN.
Pour les détails sur les index GIN, voir le module J5.
Ces types sont utilisés quand le modèle relationnel n’est pas assez souple, donc s’il est nécessaire d’ajouter dynamiquement des colonnes à la table suivant les besoins du client, ou si le détail des attributs d’une entité n’est pas connu (modélisation géographique par exemple), etc.
La solution traditionnelle est de créer des tables entité/attribut de ce format :
CREATE TABLE attributs_sup (entite int, attribut text, valeur text);
On y stocke dans entite
la clé de l’enregistrement de la
table principale, dans attribut
la colonne supplémentaire,
et dans valeur
la valeur de cet attribut. Ce modèle
présente l’avantage évident de résoudre le problème. Les défauts sont
par contre nombreux :
attributs_sup
: récupérer n’importe quelle
information demandera donc des accès à de nombreux blocs
différents.Toute recherche complexe est très inefficace : une recherche multicritère sur ce schéma va être extrêmement peu performante. Les statistiques sur les valeurs d’un attribut deviennent nettement moins faciles à estimer pour PostgreSQL. Quant aux contraintes d’intégrité entre valeurs, elles deviennent pour le moins complexes à gérer.
Les types hstore
, json
et
jsonb
permettent de résoudre le problème autrement. Ils
permettent de stocker les différentes entités dans un seul champ pour
chaque ligne de l’entité. L’accès aux attributs se fait par une syntaxe
ou des fonctions spécifiques.
Il n’y a même pas besoin de créer une table des attributs séparée :
le mécanisme du « TOAST » permet de déporter les champs volumineux
(texte, JSON, hstore
…) dans une table séparée gérée par
PostgreSQL, éventuellement en les compressant, et cela de manière
totalement transparente. On y gagne donc en simplicité de
développement.
hstore est une extension, fournie en « contrib ». Elle est donc systématiquement disponible. L’installer permet d’utiliser le type de même nom. On peut ainsi stocker un ensemble de clés/valeurs, exclusivement textes, dans un unique champ.
Ces champs sont indexables et peuvent recevoir des contraintes d’intégrité (unicité, non recouvrement…).
Les hstore
ne permettent par contre qu’un modèle
« plat ». Il s’agit d’un pur stockage clé-valeur. Si vous avez besoin de
stocker des informations davantage orientées document, vous devrez vous
tourner vers un type JSON.
Ce type perd donc de son intérêt depuis que PostgreSQL 9.4 a apporté
le type jsonb
. Il lui reste sa simplicité
d’utilisation.
Les ordres précédents installent l’extension, créent une table avec
un champ de type hstore
, insèrent trois lignes, avec des
attributs variant sur chacune, indexent l’ensemble avec un index GiST,
et enfin recherchent les lignes où l’attribut carnivore
possède la valeur t
.
SELECT * FROM animaux ;
nom | caract
--------+-----------------------------------
canari | "vole"=>"oui", "pattes"=>"2"
loup | "pattes"=>"4", "carnivore"=>"oui" carpe | "eau"=>"douce"
Les différentes fonctions disponibles sont bien sûr dans la documentation.
Par exemple :
UPDATE animaux SET caract = caract||'poil=>t'::hstore
WHERE nom = 'loup' ;
SELECT * FROM animaux WHERE caract@>'carnivore=>oui';
nom | caract
------+-------------------------------------------------- loup | "poil"=>"t", "pattes"=>"4", "carnivore"=>"oui"
Il est possible de convertir un hstore
en tableau :
SELECT hstore_to_matrix(caract) FROM animaux
WHERE caract->'vole' = 'oui';
hstore_to_matrix
------------------------- { {vole,oui},{pattes,2} }
ou en JSON :
SELECT caract::jsonb FROM animaux
WHERE (caract->'pattes')::int > 2;
caract
---------------------------------------------------- {"pattes": "4", "poil": "t", "carnivore": "oui"}
L’indexation de ces champs peut se faire avec divers types d’index. Un index unique n’est possible qu’avec un index B-tree classique. Les index GIN ou GiST sont utiles pour rechercher des valeurs d’un attribut. Les index hash ne sont utiles que pour des recherches d’égalité d’un champ entier ; par contre ils sont très compacts.
Le format JSON est devenu extrêmement populaire. Au-delà d’un simple stockage clé/valeur, il permet de stocker des tableaux, ou des hiérarchies, de manière plus simple et lisible qu’en XML. Par exemple, pour décrire une personne, on peut utiliser cette structure :
{
"firstName": "Jean",
"lastName": "Dupont",
"isAlive": true,
"age": 27,
"address": {
"streetAddress": "43 rue du Faubourg Montmartre",
"city": "Paris",
"state": "",
"postalCode": "75002"
},
"phoneNumbers": [
{
"type": "personnel",
"number": "06 12 34 56 78"
},
{
"type": "bureau",
"number": "07 89 10 11 12"
}
],
"children": [],
"spouse": null
}
Historiquement, le JSON est apparu dans PostgreSQL 9.2, mais n’est
vraiment utilisable qu’avec l’arrivée du type jsonb
(binaire) dans PostgreSQL 9.4. Ce dernier est le type à utiliser.
Les opérateurs SQL/JSON path ont été ajoutés dans PostgreSQL 12, suite à l’introduction du JSON dans le standard SQL:2016.
Le type natif json
, dans PostgreSQL, n’est rien d’autre
qu’un habillage autour du type texte. Il valide à chaque
insertion/modification que la donnée fournie est une syntaxe JSON
valide. Le stockage est exactement le même qu’une chaîne de texte, et
utilise le mécanisme du TOAST, qui compresse
les grands champs au besoin, de manière transparente pour l’utilisateur.
Le fait que la donnée soit validée comme du JSON permet d’utiliser des
fonctions de manipulation, comme l’extraction d’un attribut, la
conversion d’un JSON en enregistrement, de façon systématique sur un
champ sans craindre d’erreur.
Mais on préférera généralement le type binaire jsonb
pour les performances, et ses fonctionnalités supplémentaires. Le seul
intérêt du type json
texte est de conserver un objet JSON
sous sa forme originale, y compris l’ordre des clés, les espaces
inutiles compris, et les clés dupliquées (la dernière étant celle prise
en compte) :
SELECT '{"cle2": 0, "cle1": 6, "cle2": 4, "cle3": 17}'::json ;
json
---------------------------------------------------- {"cle2": 0, "cle1": 6, "cle2": 4, "cle3": 17}
SELECT '{"cle2": 0, "cle1": 6, "cle2": 4, "cle3": 17}'::jsonb ;
jsonb
------------------------------------ {"cle1": 6, "cle2": 4, "cle3": 17}
Une partie des exemples suivants avec le type jsonb
est
aussi applicable au json
. Beaucoup de fonctions existent
sous les deux formes (par exemple json_build_object
et
jsonb_build_object
), mais beaucoup d’autres sont propres au
type jsonb
.
Le type jsonb
permet de stocker les données dans un
format binaire optimisé. Ainsi, il n’est plus nécessaire de désérialiser
l’intégralité du document pour accéder à une propriété.
Les gains en performance sont importants. Par exemple une requête simple comme celle-ci :
SELECT personne_nom->'id' FROM json.personnes;
passe de 5 à 1,5 s sur une machine en convertissant le champ de
json
à jsonb
(pour ½ million de champs JSON
totalisant 900 Mo environ pour chaque version, ici sans TOAST notable et
avec une table intégralement en cache).
Encore plus intéressant : jsonb
supporte les index GIN,
et la syntaxe JSONPath pour le requêtage et l’extraction d’attributs.
jsonb
est donc le type le plus intéressant pour stocker du
JSON dans PostgreSQL.
À partir de PostgreSQL 16 existe le prédicat IS JSON
. Il
peut être appliqué sur des champs text
ou
bytea
et évidemment sur des champs json
et
jsonb
. Il permet de repérer notamment une faute de syntaxe
comme dans le deuxième exemple ci-dessus.
Existent aussi :
IS JSON WITH UNIQUE KEYS
pour garantir
l’absence de clé en doublon :SELECT '{"auteur": "JRR", "auteur": "Tolkien", "titre": "Le Hobbit"}'
IS JSON WITH UNIQUE KEYS AS valid ;
valid
---------- f
SELECT '{"prenom": "JRR", "nom": "Tolkien", "titre": "Le Hobbit"}'
IS JSON WITH UNIQUE KEYS AS valid ;
valid
---------- t
l’opérateur IS JSON WITHOUT UNIQUE KEYS
pour
garantir l’absence de clé unique ;
l’opérateur IS JSON ARRAY
pour le bon formatage des
tableaux :
SELECT
"auteur": "JRR Tolkien", "titre": "La confrérie de l'anneau"},
$$[{"auteur": "JRR Tolkien", "titre": "Les deux tours"},
{"auteur": "JRR Tolkien", "titre": "Le retour du roi"}]$$
{IS JSON ARRAY AS valid ;
valid
------- t
IS JSON SCALAR
et
IS JSON OBJECT
pour valider par exemple le contenu de
fragments d’un objet JSON.-- NB : l'opérateur ->> renvoie un texte
SELECT '{"nom": "production", "version":"1.1"}'::json ->> 'version'
IS JSON SCALAR AS est_nombre ;
est_nombre
------------ t
Noter que la RFC impose qu’un JSON soit en UTF-8, qui est l’encodage recommandé, mais pas obligatoire, d’une base PostgreSQL.
Un champ de type jsonb
(ou json
) accepte
tout champ JSON directement.
Pour construire un petit JSON, le transtypage d’une chaîne peut
suffire dans les cas simples. jsonb_build_object
permet de
limiter les erreurs de syntaxe.
Dans un JSON, l’ordre n’a pas d’importance.
Le type json
dispose de nombreuses fonctions et
opérateurs de manipulation et d’extraction.
Attention au type en retour, qui peut être du texte ou du JSON. Les
opérateurs ->>
et ->
renvoient
respectivement une valeur au format texte, et au format JSON :
SELECT datas->>'firstName' AS prenom,
->'address' AS addr
datasFROM personnes \gdesc
Column | Type
--------+-------
prenom | text addr | jsonb
Pour l’affichage, la fonction jsonb_pretty
améliore la
lisibilité :
SELECT datas->>'firstName' AS prenom,
->'address') AS addr
jsonb_pretty (datasFROM personnes ;
prenom | addr
---------+------------------------------------------------------
Jean | { +
| "city": "Paris", +
| "postalCode": "75002", +
| "streetAddress": "43 rue du Faubourg Montmartre"+
| }
Georges | { +
| "city": "Châteauneuf", +
| "postalCode": "45990", +
| "streetAddress": "27 rue des Moulins" +
| }
Jacques | { +
| "city": "Paris", +
| "state": "", +
| "postalCode": "75002", +
| "streetAddress": "43 rue du Faubourg Montmartre"+ | }
L’équivalent existe avec des chemins, avec #>
et
#>>
:
SELECT datas #>> '{address,city}' AS villes FROM personnes ;
villes
-------------
Paris
Châteauneuf Paris
Depuis la version 14, une autre syntaxe plus claire est disponible, plus simple, et qui renvoie du JSON :
SELECT datas['address']['city'] AS villes FROM personnes ;
villes
---------------
"Paris"
"Châteauneuf" "Paris"
Avec cette syntaxe, une petite astuce permet de convertir en texte
sans utiliser ->>['city']
(en toute rigueur,
->>0
renverra le premier élément d’un tableau):
SELECT datas['address']['city']->>0 AS villes FROM personnes ;
villes
---------------
Paris
Châteauneuf paris
PostgreSQL ne contrôle absolument pas que les clés JSON (comme ici
firstName
ou city
) sont valides ou pas. La
moindre faute de frappe (ou de casse !) entraînera une valeur
NULL
en retour. C’est la conséquence de l’absence de schéma
dans un JSON, contrepartie de sa souplesse.
L’opérateur ||
concatène deux jsonb
pour
donner un autre jsonb
:
SELECT '{"nom": "Durand"}'::jsonb ||
'{"address" : {"city": "Paris", "postalcode": "75002"}}'::jsonb ;
{"nom": "Durand", "address": {"city": "Paris", "postalcode": "75002"}}
Comme d’habitude, le résultat est NULL si l’un des JSON est
NULL
. Dans le doute, on peut utiliser {}
comme
élément neutre :
SELECT '{"nom": "Durand"}'::jsonb || coalesce (NULL::jsonb, '{}') ;
{"nom": "Durand"}
Pour supprimer un attribut d’un jsonb
, il suffit de
l’opérateur -
et d’un champ texte indiquant l’attribut à
supprimer. Il existe une variante avec text[]
pour
supprimer plusieurs attributs :
SELECT '{"nom": "Durand", "prenom": "Georges",
"address": {"city": "Paris"}}'::jsonb
- '{nom, prenom}'::text[] ;
{"address": {"city": "Paris"}}
ainsi que l’opérateur pour supprimer un sous-attribut :
SELECT '{"nom": "Durand",
"address": {"city": "Paris", "postalcode": "75002"}}'::jsonb
- '{address,postalcode}' ; #
{"nom": "Durand", "address": {"city": "Paris"}}
La fonction jsonb_set
modifie l’attribut indiqué dans un
jsonb
:
SELECT jsonb_set ('{"nom": "Durand", "address": {"city": "Paris"}}'::jsonb,
'{address}',
'{"ville": "Lyon" }'::jsonb) ;
{"nom": "Durand", "address": {"ville": "Lyon"}}
Attention, le sous-attribut est intégralement remplacé, et non fusionné. Dans cet exemple, le code postal disparaît :
SELECT jsonb_set ('{"nom": "Durand",
"address": {"postalcode": 69001, "city": "Paris"}}'::jsonb,
'{address}',
'{"ville": "Lyon" }'::jsonb) ;
{"nom": "Durand", "address": {"ville": "Lyon"}}
Il vaut mieux indiquer le chemin complet en second paramètre :
SELECT jsonb_set ('{"nom": "Durand",
"address": {"postalcode": 69001, "city": "Paris"}}'::jsonb,
'{address, city}',
'"Lyon"'::jsonb) ;
{"nom": "Durand", "address": {"city": "Lyon", "postalcode": 69001}}
Un JSON peut contenir un tableau de texte, nombre, ou autres JSON. Il est possible de déstructurer ces tableaux, mais il est compliqué de requêter sur leur contenu.
jsonb_array_elements
permet de parcourir ces
tableaux :
SELECT datas->>'firstName' AS prenom,
->'phoneNumbers')->>'number' AS numero
jsonb_array_elements (datasFROM personnes ;
prenom | numero
---------+----------------
Jean | 06 12 34 56 78
Jean | 07 89 10 11 12
Georges | 06 21 34 56 78
Georges | 07 98 10 11 12
Jacques | +33 1 23 45 67 89 Jacques | 07 00 00 01 23
Avec la syntaxe JSONPath, le résultat est le même :
SELECT datas->>'firstName',
'$.phoneNumbers[*].number')->>0 AS numero
jsonb_path_query (datas, FROM personnes ;
Si l’on veut retrouver un type tableau, il faut réagréger, car
jsonb_path_query
et jsonb_array_elements
renvoient un ensemble de lignes. On peut utiliser une clause
LATERAL
, qui sera appelée pour chaque ligne (ce qui est
lent) puis réagréger :
SELECT datas->>'firstName' AS prenom,
AS numeros_en_json -- tableau de JSON
jsonb_agg (n) FROM personnes,
SELECT jsonb_path_query (datas, '$.phoneNumbers[*].number') ) AS nums(n)
LATERAL (GROUP BY prenom ;
prenom | numeros_en_json
---------+--------------------------------------
Jean | ["06 12 34 56 78", "07 89 10 11 12"]
Georges | ["06 21 34 56 78", "07 98 10 11 12"] Jacques | ["+33 1 23 45 67 89", "07 00 00 01 23"]
On voit que la fonction jsonb_agg
envoie un tableau de
JSON.
Si l’on veut un tableau de textes :
SELECT datas->>'firstName' AS prenom,
->>'number' ) AS telephones -- text[]
array_agg ( nFROM personnes,
SELECT jsonb_path_query (datas, '$.phoneNumbers[*]') ) AS nums(n)
LATERAL (GROUP BY prenom ;
prenom | telephones
---------+-------------------------------------
Jean | {"06 12 34 56 78","07 89 10 11 12"}
Georges | {"06 21 34 56 78","07 98 10 11 12"} Jacques | {"+33 1 23 45 67 89","07 00 00 01 23"}
Noter que la clause LATERAL
supprime les personnes sans
téléphone. On peut utiliser LEFT OUTER JOIN LATERAL
, ou
l’exemple suivant. On suppose aussi que le prénom est une clé de
regroupement suffisante ; un identifiant quelconque serait plus
pertinent.
Cette autre variante avec un sous-SELECT
sera plus
performante avec de nombreuses lignes, car elle évite le
regroupement :
SELECT datas->>'firstName' AS prenom,
SELECT array_agg (nt) FROM (
(SELECT (jsonb_path_query (datas, '$.phoneNumbers[*]'))->>'number'
AS nums(nt) ) AS telephones -- text[]
) FROM personnes ;
Il existe une autre fonction d’agrégation des JSON plus pratique,
nommée jsonb_agg_strict()
, qui supprime les valeurs à
null
de l’agrégat (mais pas un attribut à
null
). Pour d’autres cas, il existe aussi
jsonb_strip_nulls()
pour nettoyer un JSON de toutes les
valeurs null
si une clé est associée (pas dans un
tableau) :
SELECT jsonb_agg (usename) AS u1,
AS u1b,
jsonb_strip_nulls (jsonb_agg (usename)) AS u2
jsonb_agg_strict (usename) FROM pg_stat_activity \gx
-[ RECORD 1 ]-----------------------------------------
u1 | ["postgres", null, "postgres", null, null, null]
u1b | ["postgres", null, "postgres", null, null, null] u2 | ["postgres", "postgres"]
SELECT jsonb_agg ( to_jsonb(rq) ) AS a1,
AS a2,
jsonb_agg_strict ( to_jsonb(rq) ) AS a3,
jsonb_agg ( jsonb_strip_nulls (to_jsonb(rq) ) ) AS a4
jsonb_strip_nulls(jsonb_agg ( to_jsonb(rq) ) ) FROM (
SELECT pid, usename FROM pg_stat_activity
AS rq ; )
-[ RECORD 1 ]--------------------------------------------------------------
a1 | [{"pid": 1583585, "usename": "postgres"}, {"pid": 2425, "usename": null}, {"pid": 2426, "usename": "postgres"}, {"pid": 2421, "usename": null}, {"pid": 2422, "usename": null}, {"pid": 2424, "usename": null}]
a2 | [{"pid": 1583585, "usename": "postgres"}, {"pid": 2425, "usename": null}, {"pid": 2426, "usename": "postgres"}, {"pid": 2421, "usename": null}, {"pid": 2422, "usename": null}, {"pid": 2424, "usename": null}]
a3 | [{"pid": 1583585, "usename": "postgres"}, {"pid": 2425}, {"pid": 2426, "usename": "postgres"}, {"pid": 2421}, {"pid": 2422}, {"pid": 2424}] a4 | [{"pid": 1583585, "usename": "postgres"}, {"pid": 2425}, {"pid": 2426, "usename": "postgres"}, {"pid": 2421}, {"pid": 2422}, {"pid": 2424}]
Plusieurs fonctions permettant de construire du jsonb
,
ou de le manipuler de manière ensembliste.
jsonb_each :
jsonb_each
décompose les clés et retourne une ligne par
clé. Là encore, on multiplie le nombre de lignes.
SELECT
key, -- text
j.value -- jsonb
j.FROM personnes p CROSS JOIN jsonb_each(p.datas) j ;
key | value
--------------+------------------------------------------------------------------
address | {"city": "Paris", "postalCode": "75002", "streetAddress":
"43 rue du Faubourg Montmartre"}
children | ["Cosette"]
lastName | "Valjean"
firstName | "Jean"
phoneNumbers | [{"number": "06 12 34 56 78"}, {"type": "bureau", "number":
"07 89 10 11 12"}]
address | {"city": "Châteauneuf", "postalCode": "45990", "streetAddress":
"27 rue des Moulins"}
children | []
lastName | "Durand"
firstName | "Georges"
phoneNumbers | [{"number": "06 21 34 56 78"}, {"type": "bureau", "number":
"07 98 10 11 12"}]
age | 27
spouse | "Martine Durand" …
jsonb_populate_record/jsonb_populate_recordset :
Si les noms des attributs et champs sont bien identiques (casse
comprise !) entre JSON et table cible,
jsonb_populate_record
peut être pratique :
CREATE TABLE nom_prenom_age (
"firstName" text,
"lastName" text,
int,
age present boolean) ;
-- Ceci renvoie un RECORD, peu pratique :
SELECT jsonb_populate_record (null::nom_prenom_age, datas) FROM personnes ;
-- Cette version renvoie des lignes
SELECT np.*
FROM personnes,
null::nom_prenom_age, datas) np ; LATERAL jsonb_populate_record (
firstName | lastName | age | present
-----------+----------+-----+---------
Jean | Valjean | |
Georges | Durand | | Jacques | Dupont | 27 |
Les attributs du JSON non récupérés sont ignorés, les valeurs absentes du JSON sont à NULL. Il existe une possibilité de créer des valeurs par défaut :
SELECT np.*
FROM personnes,
'X','Y',null,true)::nom_prenom_age, datas) np ; LATERAL jsonb_populate_record ((
firstName | lastName | age | present
-----------+----------+-----+---------
Jean | Valjean | | t
Georges | Durand | | t Jacques | Dupont | 27 | t
jsonb_populate_recordset
sert dans le cas des tableaux
de JSON.
Définir un type au lieu d’une table fonctionne aussi.
jsonb_to_record/jsonb_to_recordset :
jsonb_to_record
renvoie un enregistrement avec des
champs correspondant à chaque attribut JSON, donc bien typés. Pour
fonctionner, la fonction exige une clause AS
avec les
attributs voulus et leur bon type. (Là encore, attention à la casse
exacte des noms d’attributs sous peine de se retrouver avec des valeurs
à NULL
.)
SELECT p.*
FROM personnes,
AS p ("firstName" text, "lastName" text); LATERAL jsonb_to_record(datas)
firstName | lastName
-----------+----------
Jean | Valjean
Georges | Durand Jacques | Dupont
Les autres attributs de notre exemple peuvent être extraits
également, ou re-convertis en enregistrements avec une autre clause
LATERAL
. Si ces attributs sont des tableaux, on peut
générer une ligne par élément de tableau avec
json_to_recordset
:
SELECT p.*, t.*
FROM personnes,
AS p ("firstName" text, "lastName" text),
LATERAL jsonb_to_record(datas) ->'phoneNumbers') AS t ("number" json) ; LATERAL jsonb_to_recordset (datas
firstName | lastName | number
-----------+----------+---------------------
Jean | Valjean | "06 12 34 56 78"
Jean | Valjean | "07 89 10 11 12"
Georges | Durand | "06 21 34 56 78"
Georges | Durand | "07 98 10 11 12"
Jacques | Dupont | "+33 1 23 45 67 89" Jacques | Dupont | "07 00 00 01 23"
À l’inverse, transformer le résultat d’une requête en JSON est très
facile avec to_jsonb
:
SELECT to_jsonb(rq) FROM (
SELECT pid, datname, application_name FROM pg_stat_activity
AS rq ; )
to_jsonb
-----------------------------------------------------------------------
{"pid": 2428, "datname": null, "application_name": ""}
{"pid": 2433, "datname": null, "application_name": ""}
{"pid": 1404455, "datname": "postgres", "application_name": "pgbench"}
{"pid": 1404456, "datname": "postgres", "application_name": "pgbench"}
{"pid": 1404457, "datname": "postgres", "application_name": "pgbench"}
{"pid": 1404458, "datname": "postgres", "application_name": "pgbench"}
{"pid": 1404459, "datname": "postgres", "application_name": "pgbench"}
{"pid": 1406914, "datname": "postgres", "application_name": "psql"}
{"pid": 2425, "datname": null, "application_name": ""}
{"pid": 2424, "datname": null, "application_name": ""} {"pid": 2426, "datname": null, "application_name": ""}
jsonb_typeof :
Pour connaître le type d’un attribut JSON :
SELECT jsonb_typeof (datas->'firstName'),
->'age')
jsonb_typeof (datasFROM personnes ;
jsonb_typeof | jsonb_typeof
--------------+--------------
string |
string | string | number
Les attributs JSON sont très pratiques quand le schéma est peu structuré. Mais la complexité supplémentaire de code nuit à la lisibilité des requêtes. En termes de performances, ils sont coûteux, pour les raisons que nous allons voir.
Les contraintes d’intégrité sur les types, les tailles, les clés
étrangères… ne sont pas disponibles. Rien ne vous interdit d’utiliser un
attribut country
au lieu de pays
, avec une
valeur FR
au lieu de France
. Les contraintes
protègent de nombreux bugs, mais elles sont aussi une aide précieuse
pour l’optimiseur.
Chaque JSON récupéré l’est en bloc. Si un seul attribut est récupéré, PostgreSQL devra charger tout le JSON et le décomposer. Cela peut même coûter un accès supplémentaire à une table TOAST pour les gros JSON. Rappelons que le mécanisme du TOAST permet à PostgreSQL de compresser à la volée un grand champ texte, binaire, JSON… et/ou de le déporter dans une table annexe interne, le tout étant totalement transparent pour l’utilisateur. Pour les détails, voir cet extrait de la formation DBA2.
Il n’y a pas de mise à jour partielle : modifier un attribut implique de décomposer tout le JSON pour le réécrire entièrement (et parfois en le détoastant/retoastant). Si le JSON est trop gros, modifier ses sous-attributs par plusieurs processus différents peut poser des problèmes de verrouillage. Pour citer la documentation :« Les données JSON sont sujettes aux mêmes considérations de contrôle de concurrence que pour n’importe quel autre type de données quand elles sont stockées en table. Même si stocker de gros documents est prévisible, il faut garder à l’esprit que chaque mise à jour acquiert un verrou de niveau ligne sur toute la ligne. Il faut envisager de limiter les documents JSON à une taille gérable pour réduire les contentions sur verrou lors des transactions en mise à jour. Idéalement, les documents JSON devraient chacun représenter une donnée atomique, que les règles métiers imposent de ne pas pouvoir subdiviser en données plus petites qui pourraient être modifiées séparément. »
Un gros point noir est l’absence de statistiques propres aux clés du JSON. Le planificateur va avoir beaucoup de mal à estimer les cardinalités des critères. Nous allons voir des contournements possibles.
Suivant le modèle, il peut y avoir une perte de place, puisque les clés sont répétées entre chaque attribut JSON, et non normalisées dans des tables séparées.
Enfin, nous allons voir que l’indexation est possible, mais moins triviale qu’à l’habitude.
Ces inconvénients sont à mettre en balance avec les intérêts du JSON (surtout : éviter des lignes avec trop d’attributs toujours à NULL, si même on les connaît), les fréquences de lecture et mises à jour des JSON, et les modalités d’utilisation des attributs.
Certaines de ces limites peuvent être réduites par les techniques ci-dessous.
Pour chercher les lignes avec un champ JSON possédant un attribut d’une valeur donnée, il existe plusieurs opérateurs (au sens syntaxique). Les comparaisons directes de textes ou de JSON sont possibles, mais nous verrons qu’elles ne sont pas simplement indexables.
L’opérateur @>
(« contient ») est généralement plus
adapté, mais il faut fournir un JSON avec le critère de recherche.
L’opérateur ?
permet de tester l’existence d’un attribut
dans le JSON (même vide). Plusieurs attributs peuvent être testés avec
?|
(« ou » logique) ou ?&
(« et »
logique).
JSONPath est un langage de requêtage permettant de spécifier des parties d’un champ JSON, même complexe. Il a été implémenté dans de nombreux langages, et a donc une syntaxe différente de celle du SQL, mais souvent déjà familière aux développeurs. Il évite de parcourir manuellement les nœuds et tableaux du JSON, ce qui est vite fastidieux en SQL.
Le standard SQL:2016 intègre le SQL/JSON. PostgreSQL 12 contient déjà l’essentiel des fonctionnalités SQL/JSON, y compris JSONPath, mais elles sont complétées dans les versions suivantes.
Par exemple, une recherche peut se faire ainsi, et elle profitera d’un index GIN :
SELECT datas->>'firstName' AS prenom
FROM personnes
WHERE datas @@ '$.lastName == "Durand"' ;
prenom
---------- Georges
Les opérateurs @@
et @?
sont liés à la
recherche et au filtrage. La différence entre les deux est liée à la
syntaxe à utiliser. Ces deux exemples renvoient la même ligne :
SELECT * FROM personnes
WHERE datas @? '$.lastName ? (@ == "Valjean")' ;
SELECT * FROM personnes
WHERE datas @@ '$.lastName == "Valjean"' ;
Il existe des fonctions équivalentes, jsonb_path_exists
et jsonb_path_match
:
SELECT datas->>'lastName' AS nom,
'$.lastName ? (@ == "Valjean")'),
jsonb_path_exists (datas, '$.lastName == "Valjean"')
jsonb_path_match (datas, FROM personnes ;
nom | jsonb_path_exists | jsonb_path_match
---------+-------------------+------------------
Valjean | t | t
Durand | f | f Dupont | f | f
(Pour les détails sur ces opérateurs et fonctions, et des exemples sur des filtres plus complexes (inégalités par exemple), voir par exemple : https://justatheory.com/2023/10/sql-jsonpath-operators/.)
Un autre intérêt est la fonction jsonb_path_query
, qui
permet d’extraire facilement des parties d’un tableau :
SELECT jsonb_path_query (datas, '$.phoneNumbers[*] ? (@.type == "bureau") ')
FROM personnes ;
jsonb_path_query
------------------------------------------------
{"type": "bureau", "number": "07 89 10 11 12"} {"type": "bureau", "number": "07 98 10 11 13"}
Ici, jsonb_path_query
génère une ligne par élément du
tableau phoneNumbers
inclus dans le JSON.
L’appel suivant effectue un filtrage sur la ville :
SELECT jsonb_path_query (datas, '$.address ? (@.city == "Paris")')
FROM personnes ;
Cependant, pour que l’indexation GIN fonctionne, il faudra
l’opérateur @?
:
SELECT datas->>'lastName',
FROM personnes
WHERE personne @? '$.address ? (@.city == "Paris")' ;
Au final, le code JSONPath est souvent plus lisible que celui
utilisant de nombreuses fonctions jsonb
spécifiques à
PostgreSQL. Un développeur le manipule déjà souvent dans un autre
langage.
On trouvera d’autres exemples dans la présentation de Postgres Pro dédié à la fonctionnalité lors la parution de PostgreSQL 12, ou dans un billet de Michael Paquier.
Index fonctionnel :
L’extraction d’une partie d’un JSON est en fait une fonction immutable, donc indexable. Un index fonctionnel permet d’accéder directement à certaines propriétés, par exemple :
CREATE INDEX idx_prs_nom ON personnes ((datas->>'lastName')) ;
Mais il ne fonctionnera que s’il y a une clause WHERE
avec cette expression exacte. Pour un attribut fréquemment utilisé pour
des recherches, c’est le plus efficace.
On n’oubliera pas de lancer un ANALYZE
pour calculer les
statistiques après création de l’index fonctionnel. Même si l’index est
peu discriminant, on obtient ainsi de bonnes statistiques sur son
critère.
Colonne générée :
Une autre possibilité est de dénormaliser l’attribut JSON intéressant dans un champ séparé de la table, et indexable :
ALTER TABLE personnes
ADD COLUMN lastname text
GENERATED ALWAYS AS ((datas->>'lastName')) STORED ;
ANALYZE personnes ;
CREATE INDEX ON personnes (lastname) ;
Cette colonne générée est mise à jour quand le JSON est modifié, et n’est pas modifiable autrement. C’est à part cela un champ simple, indexable avec un B-tree, et avec ses statistiques propres.
Ce champ coûte certes un peu d’espace disque supplémentaire, mais il améliore la lisibilité du code, et facilite l’usage avec certains outils ou pour certains utilisateurs. Dans le cas des gros JSON, il peut aussi éviter quelques allers-retours vers la table TOAST. Même sans utilisation d’un index, un champ normal est beaucoup plus rapide à lire dans la ligne qu’un attribut extrait d’un JSON.
Index GIN :
Les champs jsonb
peuvent tirer parti de fonctionnalités
avancées de PostgreSQL, notamment les index GIN, et ce via deux classes
d’opérateurs.
L’opérateur par défaut de GIN pour jsonb
est
jsonb_ops
. Mais il est souvent plus efficace de choisir
l’opérateur jsonb_path_ops
. Ce dernier donne des index plus
petits et performants sur des clés fréquentes, bien qu’il ne supporte
que certains opérateurs de recherche (@>
,
@?
et @@
) (voir
les détails), ce qui suffit généralement.
CREATE INDEX idx_prs ON personnes USING gin (datas jsonb_path_ops) ;
jsonb_path_ops
supporte notamment l’opérateur
« contient » (@>
) :
EXPLAIN (ANALYZE)
SELECT datas->>'firstName' FROM personnes
WHERE datas @> '{"lastName": "Dupont"}'::jsonb ;
QUERY PLAN
--------------------------------------------------------------------
Bitmap Heap Scan on personnes (cost=2.01..3.02 rows=1 width=32)
(actual time=0.018..0.019 rows=1 loops=1)
Recheck Cond: (datas @> '{"lastName": "Dupont"}'::jsonb)
Heap Blocks: exact=1
-> Bitmap Index Scan on idx_prs (cost=0.00..2.01 rows=1 width=0)
(actual time=0.010..0.010 rows=1 loops=1)
Index Cond: (datas @> '{"lastName": "Dupont"}'::jsonb)
Planning Time: 0.052 ms Execution Time: 0.104 ms
Un index GIN est moins efficace qu’un index fonctionnel B-tree classique, mais il est idéal quand la clé de recherche n’est pas connue, et que n’importe quel attribut du JSON peut être un critère.
Un index GIN ne permet cependant pas d’Index Only Scan.
Surtout, un index GIN ne permet pas de recherches sur des opérateurs
B-tree classiques (<
, <=
,
>
, >=
), ou sur le contenu de tableaux.
On est obligé pour cela de revenir au monde relationnel, ou se rabattre
sur les index fonctionnels ou colonnes générées vus plus haut.
Attention, des fonctions comme jsonb_path_query
ne savent
pas utiliser les index. Il est donc préférable d’utiliser les opérateurs
spécifiques, comme « contient » (@>
) ou « existe » en
JSONPath (@?
).
Les fonctions et opérateurs indiqués ici ne représentent qu’une partie de ce qui existe. Certaines fonctions sont très spécialisées, ou existent en plusieurs variantes voisines. Il est conseillé de lire ces deux chapitres de documentation lors de tout travail avec les JSON. Attention à la version de la page : des fonctionnalités sont ajoutées à chaque version de PostgreSQL.
Le type xml
, inclus de base, vérifie que le XML inséré
est un document « bien formé », ou constitue des fragments de contenu
(« content »). L’encodage UTF-8 est impératif. Il y a quelques
limitations par rapport aux dernières
versions du standard, XPath et XQuery. Le stockage se fait en texte,
donc bénéficie du mécanisme de compression TOAST.
Il existe quelques opérateurs et fonctions de validation et de manipulations, décrites dans la documentation du type xml ou celle des fonctions. Par contre, une simple comparaison est impossible et l’indexation est donc impossible directement. Il faudra passer par une expression XPath.
À titre d’exemple : XMLPARSE
convertit une chaîne en
document XML, XMLSERIALIZE
procède à l’opération
inverse.
CREATE TABLE liste_cd (catalogue xml) ;
\d liste_cd
Table « public.liste_cd »
Colonne | Type | Collationnement | NULL-able | Par défaut
-----------+------+-----------------+-----------+------------
catalogue | xml | | |
INSERT INTO liste_cd
SELECT XMLPARSE ( DOCUMENT
$$<?xml version="1.0" encoding="UTF-8"?>
<CATALOG>
<CD>
<TITLE>The Times They Are a-Changin'</TITLE>
<ARTIST>Bob Dylan</ARTIST>
<COUNTRY>USA</COUNTRY>
<YEAR>1964</YEAR>
</CD>
<CD>
<TITLE>Olympia 1961</TITLE>
<ARTIST>Jacques Brel</ARTIST>
<COUNTRY>France</COUNTRY>
<YEAR>1962</YEAR>
</CD>
</CATALOG> $$ ) ;
--- Noter le $$ pour délimiter une chaîne contenant une apostrophe
SELECT XMLSERIALIZE (DOCUMENT catalogue AS text) FROM liste_cd;
xmlserialize
--------------------------------------------------
<?xml version="1.0" encoding="UTF-8"?> +
<CATALOG> +
<CD> +
<TITLE>The Times They Are a-Changin'</TITLE>+
<ARTIST>Bob Dylan</ARTIST> +
<COUNTRY>USA</COUNTRY> +
<YEAR>1964</YEAR> +
</CD> +
<CD> +
<TITLE>Olympia 1961</TITLE> +
<ARTIST>Jacques Brel</ARTIST> +
<COUNTRY>France</COUNTRY> +
<YEAR>1962</YEAR> +
</CD> +
</CATALOG> (1 ligne)
Il existe aussi query_to_xml
pour convertir un résultat
de requête en XML, xmlagg
pour agréger des champs XML, ou
xpath
pour extraire des nœuds suivant une expression XPath
1.0.
NB : l’extension xml2 est dépréciée et ne doit pas être utilisée dans les nouveaux projets.
PostgreSQL permet de stocker des données au format binaire, potentiellement de n’importe quel type, par exemple des images ou des PDF.
Il faut vraiment se demander si des binaires ont leur place dans une base de données relationnelle. Ils sont généralement beaucoup plus gros que les données classiques. La volumétrie peut donc devenir énorme, et encore plus si les binaires sont modifiés, car le mode de fonctionnement de PostgreSQL aura tendance à les dupliquer. Cela aura un impact sur la fragmentation, la quantité de journaux, la taille des sauvegardes, et toutes les opérations de maintenance. Ce qui est intéressant à conserver dans une base sont des données qu’il faudra rechercher, et l’on recherche rarement au sein d’un gros binaire. En général, l’essentiel des données binaires que l’on voudrait confier à une base peut se contenter d’un stockage classique, PostgreSQL ne contenant qu’un chemin ou une URL vers le fichier réel.
PostgreSQL donne le choix entre deux méthodes pour gérer les données binaires :
bytea
: un type comme un autre ;Voici un exemple :
CREATE TABLE demo_bytea(a bytea);
INSERT INTO demo_bytea VALUES ('bonjour'::bytea);
SELECT * FROM demo_bytea ;
a
------------------ \x626f6e6a6f7572
Nous avons inséré la chaîne de caractère « bonjour » dans le champ
bytea, en fait sa représentation binaire dans l’encodage courant
(UTF-8). Si nous interrogeons la table, nous voyons la représentation
textuelle du champ bytea. Elle commence par \x
pour
indiquer un encodage de type hex
. Ensuite, chaque paire de
valeurs hexadécimales représente un octet.
Un second format d’affichage est disponible :
escape
:
SET bytea_output = escape ;
SELECT * FROM demo_bytea ;
a
--------- bonjour
INSERT INTO demo_bytea VALUES ('journée'::bytea);
SELECT * FROM demo_bytea ;
a
----------------
bonjour journ\303\251e
Le format de sortie escape
ne protège donc que les
valeurs qui ne sont pas représentables en ASCII 7 bits. Ce format peut
être plus compact pour des données textuelles essentiellement en
alphabet latin sans accent, où le plus gros des caractères n’aura pas
besoin d’être protégé.
Cependant, le format hex
est bien plus efficace à
convertir, et est le défaut depuis PostgreSQL 9.0.
Avec les vieilles applications, ou celles restées avec cette
configuration, il faudra peut-être forcer bytea_output
à
escape
, sous peine de corruption.)
Pour charger directement un fichier, on peut notamment utiliser la
fonction pg_read_binary_file
, exécutée par le serveur
PostreSQL :
INSERT INTO demo_bytea (a)
SELECT pg_read_binary_file ('/chemin/fichier');
En théorie, un bytea
peut contenir 1 Go. En pratique, on
se limitera à nettement moins, ne serait-ce que parce
pg_dump
tombe en erreur quand il doit exporter des bytea de
plus de 500 Mo environ (le décodage double le nombre d’octets et dépasse
cette limite de 1 Go).
La
documentation officielle liste les fonctions pour encoder, décoder,
extraire, hacher… les bytea
.
Un large object est un objet totalement décorrélé des tables. Le code doit donc gérer cet objet séparément :
lob
) ;Le large object nécessite donc un plus gros investissement au niveau du code.
En contrepartie, il a les avantages suivant :
Cependant, nous déconseillons son utilisation autant que possible :
bytea
prendront moins de place (penser à
changer default_toast_compression
à lz4
sur
les versions 14 et supérieures) ;--large-objects
de pg_dump
) ;pg_dump
n’est pas optimisé pour sauver de nombreux
large objects : la sauvegarde de la table
pg_largeobject
ne peut être parallélisée et peut consommer
transitoirement énormément de mémoire s’il y a trop d’objets. Il y a plusieurs méthodes pour nettoyer les large objects devenu inutiles :
lo_unlink
dans le code client — au
risque d’oublier ;lo_manage
fournie par le
module contrib lo
: (voir documentation, si
les large objects ne sont jamais référencés plus d’une
fois ;vacuumlo
(là encore
un contrib) :
il liste tous les large objects référencés dans la base, puis
supprime les autres. Ce traitement est bien sûr un peu lourd.Techniquement, un large object est stocké dans la table
système pg_largeobject
sous forme de pages de 2 ko. Voir la
documentation
pour les détails.
Ce TP étant est purement descriptif, allez voir direment la solution.
La base personnes_et_dossiers pèse en version complète 613 Mo, pour 2 Go sur disque au final. Elle peut être installée comme suit :
# Dump complet
curl -kL https://dali.bo/tp_personnes -o /tmp/personnes.dump
# Taille 40%
# curl -kL https://dali.bo/tp_personnes_200k -o /tmp/personnes.dump
# Taille 16%
# curl -kL https://dali.bo/tp_personnes_fr -o /tmp/personnes.dump
createdb --echo personnes
# L'erreur sur un schéma 'public' existant est normale
pg_restore -v -d personnes /tmp/personnes.dump
rm -- /tmp/personnes.dump
La base personnes
contient alors deux schémas
json
et eav
avec les mêmes données sous deux
formes différentes.
Chercher la ville et le numéro de téléphones (sous-attribut
ville
de l’attributadresse
du champ JSONpersonne
) de Gaston Lagaffe, grâce aux attributsprenom
etnom
. Effectuer de préférence la recherche en cherchant un JSON avec@>
(« contient ») (Ne pas chercher encore à utiliser JSONPath).
Créer une requête qui renvoie les attributs
nom
,prenom
,date_naissance
(comme type date) de toutes les personnes avec le nom « Lagaffe ». Utiliser la fonctionjson_to_record()
etLATERAL
. Rajouterville
etpays
ensuite de la même manière.
En supprimant le filtre, comparer le temps d’exécution de la requête précédente avec cette requête plus simple qui récupère les champs plus manuellement :
SELECT personne->>'nom', ->>'prenom', personne->>'date_naissance')::date, (personne>>'{adresse,ville}', personne#>>'{adresse,pays}' personne#FROM json.personnes;
Créer un index GIN ainsi :
CREATE INDEX personnes_gin ON json.personnes USING gin(personne jsonb_path_ops);
Quelle taille fait-il ?
Retenter les requêtes précédentes. Lesquelles utilisent l’index ?
Récupérer les numéros de téléphone de Léon Prunelle avec ces trois syntaxes. Quelles sont les différences ?
Afficher les noms et prénoms de Prunelles, et un tableau de champs texte contenant ses téléphones (utiliser
jsonb_array_elements_text
).
Comparer le résultat et les performances de ces deux requêtes, qui récupèrent aussi les numéros de téléphone de Prunelle :
Chercher qui possède le numéro de téléphone
0650041821
avec la syntaxe JSONPath.
Compter le nombre de personnes habitant à Paris ou Bruxelles avec :
- la syntaxe
@>
et unOR
;- une syntaxe JSONPath
@?
et un « ou logique (||
) ;- une syntaxe JSONPath
@?
et une regex@.ville like_regex "^(Paris|Bruxelles)$"
.
Le compte du nombre de personne par pays doit être optimisé au maximum. Ajouter un index fonctionnel sur l’attribut
pays
. Tester l’efficacité sur une recherche, et un décompte de toute les personnes par pays.
Ajouter un champ généré dans
json.personne
, correspondant à l’attributpays
.
Comparer les temps d’exécution du décompte des pays par l’attribut, et par cette colonne générée.
Créer un index B-tree sur la colonne générée
pays
. Consulter les statistiques danspg_stats
. Cet index est-il utilisable pour des filtres et le décompte parpays
?
(Optionnel) Créer des colonnes générées sur
nom
,prenom
,date_naissance
, etville
(en un seul ordre). Reprendre la requête plus haut qui les affiche tous et comparer les performances.
Ajouter l’attribut
animaux
à Gaston Lagaffe, avec la valeur 18. Vérifier en relisant la ligne.
Ajouter l’attribut
animaux
à 2% des individus au hasard, avec une valeur 1 ou 2.
Compter le nombre de personnes avec des animaux (avec ou sans JSONPath). Proposer un index qui pourait convenir à d’autres futurs nouveaux attributs peu fréquents.
- Créer une table
fichiers
avec un texte et une colonne permettant de référencer des Large Objects.
- Importer un fichier local à l’aide de psql dans un large object.
- Noter l’
oid
retourné.
- Importer un fichier du serveur à l’aide de psql dans un large object.
- Afficher le contenu de ces différents fichiers à l’aide de psql.
- Les sauvegarder dans des fichiers locaux.
Tout ce qui suit dans se dérouler dans la même base, par exemple :
CREATE DATABASE capteurs ;
Ce TP est prévu pour un shared_buffers
de 128 Mo (celui
par défaut). Si le vôtre est plus gros, le TP devra peut-être durer plus
longtemps :
SHOW shared_buffers ;
Utilisez au moins une fenêtre pour les ordres shell et une pour les ordres SQL.
Créer avec le script suivants les deux versions d’un petit modèle avec des capteurs, et les données horodatées qu’ils renvoient ; ainsi que les deux procédures pour remplir ces tables ligne à ligne :
\c capteurs
-- Modèle : une table 'capteurs' et ses 'donnees' horodatées
-- liées par une contrainte
-- Deux versions : avec ID et une séquence, et avec UUID
DROP TABLE IF EXISTS donnees1, donnees2, capteurs1, capteurs2 ;
-- Avec identifiants bigint
CREATE TABLE capteurs1 (id_capteur bigint PRIMARY KEY,
char (50) UNIQUE,
nom char (50) default ''
filler
) ;CREATE TABLE donnees1 (id_donnee bigserial PRIMARY KEY,
int NOT NULL REFERENCES capteurs1,
id_capteur timestamp with time zone,
horodatage int,
valeur1 int,
valeur2 float
valeur3
) ;CREATE INDEX ON donnees1 (horodatage) ;
-- Version avec les UUID
CREATE TABLE capteurs2 (id_capteur uuid PRIMARY KEY,
char (50) UNIQUE,
nom char (50) default ''
filler
) ;CREATE TABLE donnees2 (id_donnee uuid PRIMARY KEY,
NOT NULL REFERENCES capteurs2,
id_capteur uuid timestamp with time zone,
horodatage int,
valeur1 int,
valeur2 float
valeur3
) ;CREATE INDEX ON donnees2 (horodatage) ;
-- 1000 capteurs identiques
INSERT INTO capteurs1 (id_capteur, nom)
SELECT i,
'M-'||md5(i::text)
FROM generate_series (1,1000) i
ORDER BY random() ;
INSERT INTO capteurs2 (id_capteur, nom)
SELECT gen_random_uuid(), nom FROM capteurs1 ;
-- 2 procédures d'insertion de données identiques sur quelques capteurs au hasard
-- insertion dans donnees1 avec une séquence
CREATE OR REPLACE PROCEDURE insere_donnees_1 ()
AS $$
SET synchronous_commit TO off ; -- accélère
INSERT INTO donnees1 (id_donnee, id_capteur, horodatage, valeur1, valeur2, valeur3)
SELECT nextval('donnees1_id_donnee_seq'::regclass), -- clé primaire des données
-- clé étrangère
m.id_capteur, random()*1000)::int,(random()*1000)::int,random()
now(), (FROM capteurs1 m TABLESAMPLE BERNOULLI (1) ; -- 1% des lignes
$$ LANGUAGE sql;-- insertion dans donnees2 avec un UUID v7
CREATE OR REPLACE PROCEDURE insere_donnees_2 ()
AS $$
SET synchronous_commit TO off ; -- accélère
INSERT INTO donnees2 (id_donnee, id_capteur, horodatage, valeur1, valeur2, valeur3)
SELECT gen_random_uuid(), -- clé primaire des données, UUID v4
-- clé étrangère
m.id_capteur, random()*1000)::int,(random()*1000)::int,random()
now(), (FROM capteurs2 m TABLESAMPLE BERNOULLI (1) ; -- 1% des lignes
$$ LANGUAGE sql;
Vous devez obtenir ces tables et une séquence :
capteurs=# \d+
Liste des relations
Schéma | Nom | Type | … | … | … | Taille | Description
--------+------------------------+----------+---+---+---+------------+-------------
public | capteurs1 | table | … | … | … | 168 kB |
public | capteurs2 | table | … | … | … | 176 kB |
public | donnees1 | table | … | … | … | 0 bytes |
public | donnees1_id_donnee_seq | séquence | … | … | | 8192 bytes |
public | donnees2 | table | … | … | … | 0 bytes | (5 lignes)
et ces index :
capteurs=# \di
Liste des relations
Schéma | Nom | Type | Propriétaire | Table
--------+-------------------------+-------+--------------+-----------
public | capteurs1_nom_key | index | postgres | capteurs1
public | capteurs1_pkey | index | postgres | capteurs1
public | capteurs2_nom_key | index | postgres | capteurs2
public | capteurs2_pkey | index | postgres | capteurs2
public | donnees1_horodatage_idx | index | postgres | donnees1
public | donnees1_pkey | index | postgres | donnees1
public | donnees2_horodatage_idx | index | postgres | donnees2 public | donnees2_pkey | index | postgres | donnees2
Créer deux fichiers SQL contenants juste les appels de fonctions, qui serviront pour pgbench :
echo "CALL insere_donnees_1 ()" > /tmp/insere1.sql
echo "CALL insere_donnees_2 ()" > /tmp/insere2.sql
Dans la même base que les table ci-dessus, installer l’extension pg_buffercache qui va nous permettre de voir ce qu’il y a dans le cache de PostgreSQL :
CREATE EXTENSION IF NOT EXISTS pg_buffercache ;
La vue du même nom contient une ligne par bloc. La requête suivante permet de voir lesquelles de nos tables utilisent le cache :
SELECT CASE WHEN datname = current_database()
AND relname NOT LIKE 'pg%'
THEN relname ELSE '*AUTRES*' END AS objet,
count(*),
count(bufferid)*8192) as Taille_Mo
pg_size_pretty(FROM pg_buffercache b
LEFT OUTER JOIN pg_class c ON c.relfilenode = b.relfilenode
LEFT OUTER JOIN pg_database d ON (d.oid = b.reldatabase)
GROUP BY objet
ORDER BY count(bufferid) DESC ;
Cette version semi-graphique est peut-être plus parlante :
SELECT CASE WHEN datname = current_database()
AND relname NOT LIKE 'pg%'
THEN relname ELSE '*AUTRES*' END AS objet,
count(bufferid)*8192) as Taille_Mo,
pg_size_pretty(lpad('',(count(bufferid)/200)::int, '#') AS Taille
FROM pg_buffercache b
LEFT OUTER JOIN pg_class c ON c.relfilenode = b.relfilenode
LEFT OUTER JOIN pg_database d ON (d.oid = b.reldatabase)
GROUP BY objet
ORDER BY objet DESC ;
Dans une fenêtre, lancer l’une de ces requêtes (dans la bonne base !), puis la répéter toutes les secondes ainsi :
-- sous psql
1 \watch
Dans une autre fenêtre, lancer
pgbench
avec deux clients, et le script pour remplir la tabledonnees1
:
# Sous Rocky Linux/Almalinux…
/usr/pgsql-16/bin/pgbench capteurs -c2 -j1 -n -T1800 -P1 \
-f /tmp/insere1.sql# Sous Debian/Ubuntu
pgbench capteurs -c2 -j1 -n -T1800 -P1 \
-f /tmp/insere1.sql
Le nombre de transactions dépend fortement de la machine, mais peut atteindre plusieurs milliers à la seconde.
Les tables peuvent rapidement atteindre plusieurs gigaoctets. N’hésitez pas à les vider ensemble de temps à autre.
TRUNCATE donnees1, donnees2 ;
Quelle est la répartition des données dans le cache ?
Après peu de temps, la répartition doit ressembler à peu près à ceci :
objet | taille_mo | taille
-------------------------+------------+--------------------------------
donnees1_pkey | 28 MB | #########
donnees1_id_donnee_seq | 8192 bytes |
donnees1_horodatage_idx | 12 MB | ####
donnees1 | 86 MB | ############################
capteurs1_pkey | 48 kB |
capteurs1 | 144 kB | *AUTRES* | 2296 kB | #
Et ce, même si la table et ses index ne tiennent plus intégralement dans le cache.
La table donnees1
représente la majorité du cache.
Interrompre
pgbench
et le relancer pour remplirdonnees2
:
# Sous Rocky Linux/Almalinux…
/usr/pgsql-16/bin/pgbench capteurs -c2 -j1 -n -T1800 -P1 \
-f /tmp/insere2.sql# Sous Debian/Ubuntu
pgbench capteurs -c2 -j1 -n -T1800 -P1 \
-f /tmp/insere2.sql
Noter que le débit en transaction est du même ordre de grandeur : les UUID ne sont pas spécialement lourds à générer.
Que devient la répartition des données dans le cache ?
donnees1
et ses index est chassé du cache par les
nouvelles données, ce qui est logique.
Surtout, on constate que la clé primaire de donnnes2
finit par remplir presque tout le cache. Dans ce petit cache, il n’y a
plus de place même pour les données de donnees2
!
objet | taille_mo | taille
-------------------------+-----------+----------------------------------------------
donnees2_pkey | 120 MB | #######################################
donnees2_horodatage_idx | 728 kB |
donnees2 | 6464 kB | ##
capteurs2_pkey | 48 kB |
capteurs2 | 152 kB | *AUTRES* | 408 kB |
Interrompre
pgbench
, purger les tables et lancer les deux scripts d’alimentation en même temps.
TRUNCATE donnees1, donnees2 ;
# Sous Rocky Linux/Almalinux…
/usr/pgsql-16/bin/pgbench capteurs -c2 -j1 -n -T1800 -P1 \
-f /tmp/insere2.sql
-f /tmp/insere1.sql # Sous Debian/Ubuntu
pgbench capteurs -c2 -j1 -n -T1800 -P1 \
-f /tmp/insere2.sql -f /tmp/insere1.sql
On constate le même phénomène de monopolisation du cache par
donnees2
, bien que les deux tables de données aient le même
nombre de lignes :
objet | taille_mo | taille
-------------------------+------------+------------------------------------------
donnees2_pkey | 115 MB | #####################################
donnees2_horodatage_idx | 624 kB |
donnees2 | 5568 kB | ##
donnees1_pkey | 1504 kB |
donnees1_id_donnee_seq | 8192 bytes |
donnees1_horodatage_idx | 632 kB |
donnees1 | 4544 kB | #
capteurs2_pkey | 48 kB |
capteurs2 | 152 kB |
capteurs1_pkey | 48 kB |
capteurs1 | 144 kB |
*AUTRES* | 408 kB | (12 lignes)
Avez-vous remarqué une différence de vitesse entre les deux traitements ?
Ce ne peut être rigoureusement établi ici. Les volumétries sont trop faibles par rapport à la taille des mémoires et il faut tester sur la durée. Le nombre de clients doit être étudié pour utiliser au mieux les capacités de la machine sans monter jusqu’à ce que la contention devienne un problème. Les checkpoints font également varier les débits.
Cependant, si vous laissez le test tourner très longtemps avec des tailles de tables de plusieurs Go, les effets de cache seront très différents :
donnees1
, le débit en insertion devrait rester
correct, car seuls les derniers blocs en cache sont utiles ;donnees2
, le débit en insertion doit
progressivement baisser : chaque insertion a besoin d’un bloc de l’index
de clé primaire différent, qui a de moins en moins de chance de se
trouver dans le cache de PostgreSQL, puis dans le cache de Linux.L’impact sur les I/O augmente donc, et pas seulement à cause de la volumétrie supérieure des tables avec UUID. À titre d’exemple, sur une petite base de formation avec 3 Go de RAM :
# avec ID numériques, pendant des insertions dans donnees1 uniquement,
# qui atteint 1,5 Go
# débit des requêtes : environ 3000 tps
$ iostat -h 1
avg-cpu: %user %nice %system %iowait %steal %idle
88,6% 0,0% 10,7% 0,0% 0,0% 0,7%
tps kB_read/s kB_wrtn/s kB_read kB_wrtn Device
86,00 0,0k 17,0M 0,0k 17,0M vda
0,00 0,0k 0,0k 0,0k 0,0k scd0
# avec UUID v4, pendant des insertions dans donnees2 uniquement,
# qui atteint 1,5 Go
# débit des requêtes : environ 700 tps
$ iostat -h 1
avg-cpu: %user %nice %system %iowait %steal %idle
41,2% 0,0% 17,3% 25,9% 0,7% 15,0%
tps kB_read/s kB_wrtn/s kB_read kB_wrtn Device
2379,00 0,0k 63,0M 0,0k 63,0M vda
0,00 0,0k 0,0k 0,0k 0,0k scd0
Comparer les tailles des tables et index avant et après un
VACUUM FULL
. Où était la fragmentation ?
VACUUM FULL
reconstruit complètement les tables et aussi
les index.
Tables avant le VACUUM FULL
:
capteurs=# \d+
Liste des relations
Schéma | Nom | Type | … | … | … | Taille | …
--------+------------------------+----------+---+---+---+------------+--
public | capteurs1 | table | … | … | … | 168 kB |
public | capteurs2 | table | … | … | … | 176 kB |
public | donnees1 | table | … | … | … | 2180 MB |
public | donnees1_id_donnee_seq | séquence | … | … | | 8192 bytes |
public | donnees2 | table | … | … | … | 2227 MB |
public | pg_buffercache | vue | … | … | | 0 bytes | (6 lignes)
Après :
capteurs=# \d+
Liste des relations
Schéma | Nom | Type | … | … | … | Taille | …
--------+------------------------+----------+---+---+---+------------+--
public | capteurs1 | table | … | … | … | 144 kB |
public | capteurs2 | table | … | … | … | 152 kB |
public | donnees1 | table | … | … | … | 2180 MB |
public | donnees1_id_donnee_seq | séquence | … | … | | 8192 bytes |
public | donnees2 | table | … | … | … | 2227 MB |
public | pg_buffercache | vue | … | … | | 0 bytes | (6 lignes)
Les tailles des tables donnees1
et donnees2
ne bougent pas. C’est normal, il n’y a eu que des insertions à chaque
fois en fin de table, et ni modification ni suppression de données.
Index avant le VACUUM FULL
:
capteurs=# \di+
Liste des relations
Schéma | Nom | Type | … | Table | … | Méth. | Taille | …
--------+-------------------------+-------+---+-----------+---+-------+---------+--
public | capteurs1_nom_key | index | … | capteurs1 | … | btree | 120 kB |
public | capteurs1_pkey | index | … | capteurs1 | … | btree | 56 kB |
public | capteurs2_nom_key | index | … | capteurs2 | … | btree | 120 kB |
public | capteurs2_pkey | index | … | capteurs2 | … | btree | 56 kB |
public | donnees1_horodatage_idx | index | … | donnees1 | … | btree | 298 MB |
public | donnees1_pkey | index | … | donnees1 | … | btree | 717 MB |
public | donnees2_horodatage_idx | index | … | donnees2 | … | btree | 245 MB |
public | donnees2_pkey | index | … | donnees2 | … | btree | 1166 MB | (8 lignes)
Index après :
capteurs=# \di+
Liste des relations
Schéma | Nom | Type | … | Table | … | Méth. | Taille | …
--------+-------------------------+-------+---+-----------+---+-------+--------+--
public | capteurs1_nom_key | index | … | capteurs1 | … | btree | 96 kB |
public | capteurs1_pkey | index | … | capteurs1 | … | btree | 40 kB |
public | capteurs2_nom_key | index | … | capteurs2 | … | btree | 96 kB |
public | capteurs2_pkey | index | … | capteurs2 | … | btree | 48 kB |
public | donnees1_horodatage_idx | index | … | donnees1 | … | btree | 296 MB |
public | donnees1_pkey | index | … | donnees1 | … | btree | 717 MB |
public | donnees2_horodatage_idx | index | … | donnees2 | … | btree | 245 MB |
public | donnees2_pkey | index | … | donnees2 | … | btree | 832 MB | (8 lignes)
Les index d’horodatage gardent la même taille qu’avant (la différence
entre eux est dû à des nombres de lignes différents dans cet exemple).
L’index sur la clé primaire de donnees1
(bigint
) n’était pas fragmenté. Par contre,
donnees2_pkey
se réduit de 29% ! Les index UUID (v4) ont
effectivement tendance à se fragmenter.
Les UUID générés avec
gen_random_uuid
sont de version 4. Créer la fonction suivante pour générer des UUID version 7, l’utiliser dans la fonction d’alimentation dedonnees2
, et relancer les deux alimentations :
-- Source : https://postgresql.verite.pro/blog/2024/07/15/uuid-v7-pure-sql.html
-- Daniel Vérité d'après Kyle Hubert
CREATE OR REPLACE FUNCTION uuidv7() RETURNS uuid
AS $$
-- Replace the first 48 bits of a uuidv4 with the current
-- number of milliseconds since 1970-01-01 UTC
-- and set the "ver" field to 7 by setting additional bits
select encode(
set_bit(
set_bit(
overlay(uuid_send(gen_random_uuid()) placingextract(epoch from clock_timestamp())*1000)::bigint)
substring(int8send((from 3)
from 1 for 6),
52, 1),
53, 1), 'hex')::uuid;
$$ LANGUAGE sql volatile ;
-- insertion dans donnees2 avec un UUID v7
CREATE OR REPLACE PROCEDURE insere_donnees_2 ()
AS $$
SET synchronous_commit TO off ; -- accélère
INSERT INTO donnees2 (id_donnee, id_capteur, horodatage, valeur1, valeur2, valeur3)
SELECT uuidv7(), -- clé primaire des données, UUID v7
-- clé étrangère
m.id_capteur, random()*1000)::int,(random()*1000)::int,random()
now(), (FROM capteurs2 m TABLESAMPLE BERNOULLI (1) ; -- 1% des capteurs
$$ LANGUAGE sql;
# Sous Rocky Linux/Almalinux…
/usr/pgsql-16/bin/pgbench capteurs -c2 -j1 -n -T1800 -P1 \
-f /tmp/insere2.sql
-f /tmp/insere1.sql # Sous Debian/Ubuntu
pgbench capteurs -c2 -j1 -n -T1800 -P1 \
-f /tmp/insere2.sql -f /tmp/insere1.sql
Après quelques dizaines de secondes :
donnees2
occupe une taille un peu supérieure à cause de
la taille double des UUID par rapport aux bigint
de
donnees1
:
objet | taille_mo | taille
-------------------------+------------+------------------------------------------
donnees2_pkey | 18 MB | ######
donnees2_horodatage_idx | 5392 kB | ##
donnees2 | 48 MB | ###############
donnees1_pkey | 13 MB | ####
donnees1_id_donnee_seq | 8192 bytes |
donnees1_horodatage_idx | 5376 kB | ##
donnees1 | 38 MB | ############
capteurs2_pkey | 48 kB |
capteurs2 | 152 kB |
capteurs1_pkey | 48 kB |
capteurs1 | 144 kB |
*AUTRES* | 648 kB | (12 lignes)
Relancez
pgbench
pour chargerdonnees2
, alternez entre les deux versions de la fonctioninsere_donnees_2
.
/usr/pgsql-16/bin/pgbench capteurs -c2 -j1 -n -T1800 -P1 -f /tmp/insere2.sql
… [fonction avec gen_random_uuid (UUID v4) ]
progress: 202.0 s, 781.2 tps, lat 2.546 ms stddev 6.631, 0 failed
progress: 203.0 s, 597.6 tps, lat 3.229 ms stddev 10.497, 0 failed
progress: 204.0 s, 521.7 tps, lat 3.995 ms stddev 20.001, 0 failed
progress: 205.0 s, 837.0 tps, lat 2.307 ms stddev 7.743, 0 failed
progress: 206.0 s, 1112.1 tps, lat 1.856 ms stddev 7.602, 0 failed
progress: 207.0 s, 1722.8 tps, lat 1.097 ms stddev 0.469, 0 failed
progress: 208.0 s, 894.4 tps, lat 2.352 ms stddev 12.725, 0 failed
progress: 209.0 s, 1045.6 tps, lat 1.911 ms stddev 5.631, 0 failed
progress: 210.0 s, 1040.0 tps, lat 1.921 ms stddev 8.009, 0 failed
progress: 211.0 s, 734.6 tps, lat 2.259 ms stddev 9.833, 0 failed
progress: 212.0 s, 0.0 tps, lat 0.000 ms stddev 0.000, 0 failed
progress: 213.0 s, 266.3 tps, lat 16.299 ms stddev 165.541, 0 failed
progress: 214.0 s, 1548.9 tps, lat 1.290 ms stddev 1.970, 0 failed
progress: 215.0 s, 896.0 tps, lat 2.163 ms stddev 5.404, 0 failed
progress: 216.0 s, 1113.0 tps, lat 1.798 ms stddev 4.115, 0 failed
progress: 217.0 s, 886.9 tps, lat 1.990 ms stddev 4.609, 0 failed
progress: 218.0 s, 771.1 tps, lat 2.965 ms stddev 9.767, 0 failed
… [modification avec uuidv7 (UUID v7) ]
progress: 219.0 s, 1952.1 tps, lat 1.022 ms stddev 2.513, 0 failed
progress: 220.0 s, 2241.1 tps, lat 0.890 ms stddev 0.431, 0 failed
progress: 221.0 s, 2184.0 tps, lat 0.914 ms stddev 0.853, 0 failed
progress: 222.0 s, 2191.1 tps, lat 0.911 ms stddev 0.373, 0 failed
progress: 223.0 s, 2355.8 tps, lat 0.847 ms stddev 0.332, 0 failed
progress: 224.0 s, 2267.0 tps, lat 0.880 ms stddev 0.857, 0 failed
progress: 225.0 s, 2308.0 tps, lat 0.864 ms stddev 0.396, 0 failed
progress: 226.0 s, 2230.9 tps, lat 0.894 ms stddev 0.441, 0 failed
progress: 227.0 s, 2225.1 tps, lat 0.897 ms stddev 1.284, 0 failed
progress: 228.0 s, 2250.2 tps, lat 0.886 ms stddev 0.408, 0 failed
progress: 229.0 s, 2325.1 tps, lat 0.858 ms stddev 0.327, 0 failed
progress: 230.0 s, 2172.1 tps, lat 0.919 ms stddev 0.442, 0 failed
progress: 231.0 s, 2209.8 tps, lat 0.903 ms stddev 0.373, 0 failed
progress: 232.0 s, 2379.0 tps, lat 0.839 ms stddev 0.342, 0 failed
progress: 233.0 s, 2349.1 tps, lat 0.849 ms stddev 0.506, 0 failed
progress: 234.0 s, 2274.9 tps, lat 0.877 ms stddev 0.350, 0 failed
progress: 235.0 s, 2245.0 tps, lat 0.889 ms stddev 0.351, 0 failed
progress: 236.0 s, 2155.9 tps, lat 0.925 ms stddev 0.344, 0 failed
progress: 237.0 s, 2299.2 tps, lat 0.869 ms stddev 0.343, 0 failed
… [nouvelle modification, retour à gen_random_uuid ]
progress: 238.0 s, 1296.9 tps, lat 1.540 ms stddev 2.092, 0 failed
progress: 239.0 s, 1370.1 tps, lat 1.457 ms stddev 2.794, 0 failed
progress: 240.0 s, 1089.9 tps, lat 1.832 ms stddev 4.234, 0 failed
progress: 241.0 s, 770.0 tps, lat 2.594 ms stddev 13.761, 0 failed
progress: 242.0 s, 412.0 tps, lat 4.736 ms stddev 28.332, 0 failed
progress: 243.0 s, 0.0 tps, lat 0.000 ms stddev 0.000, 0 failed
progress: 244.0 s, 632.6 tps, lat 6.403 ms stddev 65.839, 0 failed
progress: 245.0 s, 1183.0 tps, lat 1.655 ms stddev 3.732, 0 failed
progress: 246.0 s, 869.0 tps, lat 2.287 ms stddev 5.968, 0 failed
progress: 247.0 s, 967.0 tps, lat 2.118 ms stddev 4.860, 0 failed
progress: 248.0 s, 954.5 tps, lat 2.088 ms stddev 3.967, 0 failed
progress: 249.0 s, 759.3 tps, lat 2.635 ms stddev 10.382, 0 failed
progress: 250.0 s, 787.0 tps, lat 2.395 ms stddev 9.791, 0 failed
progress: 251.0 s, 744.0 tps, lat 2.518 ms stddev 10.636, 0 failed
progress: 252.0 s, 815.1 tps, lat 2.744 ms stddev 11.983, 0 failed
progress: 253.0 s, 931.2 tps, lat 1.998 ms stddev 7.886, 0 failed
progress: 254.0 s, 665.0 tps, lat 2.946 ms stddev 13.315, 0 failed
progress: 255.0 s, 537.1 tps, lat 3.970 ms stddev 19.232, 0 failed progress: 256.0 s, 683.9 tps, lat 2.757 ms stddev 10.356, 0 failed
Le débit en transactions varie ici d’un facteur 2. Noter que la durée
des transactions est aussi beaucoup plus stable
(stddev
).
La base personnes_et_dossiers pèse en version complète 613 Mo, pour 2 Go sur disque au final. Elle peut être installée comme suit :
# Dump complet
curl -kL https://dali.bo/tp_personnes -o /tmp/personnes.dump
# Taille 40%
# curl -kL https://dali.bo/tp_personnes_200k -o /tmp/personnes.dump
# Taille 16%
# curl -kL https://dali.bo/tp_personnes_fr -o /tmp/personnes.dump
createdb --echo personnes
# L'erreur sur un schéma 'public' existant est normale
pg_restore -v -d personnes /tmp/personnes.dump
rm -- /tmp/personnes.dump
La base personnes
contient alors deux schémas
json
et eav
avec les mêmes données sous deux
formes différentes.
La table json.personnes
contient une ligne par personne,
un identifiant et un champ JSON avec de nombreux attributs. Elle n’est
pas encore indexée :
\d json.personnes
Table « json.personnes »
Colonne | Type | Collationnement | NULL-able | Par défaut
-------------+---------+-----------------+-----------+------------
id_personne | integer | | | personne | jsonb | | |
Chercher la ville et le numéro de téléphone (sous-attribut
ville
de l’attributadresse
du champ JSONpersonne
) de Gaston Lagaffe, grâce aux attributsprenom
etnom
. Effectuer de préférence la recherche en cherchant un JSON avec@>
(« contient ») (Ne pas chercher encore à utiliser JSONPath).
La recherche peut s’effectuer en convertissant tous les attributs en texte :
SELECT personne->'adresse'->>'ville'
FROM json.personnes p
WHERE personne->>'nom' = 'Lagaffe'
AND personne->>'prenom' = 'Gaston' ;
On obtient « Bruxelles ».
Avec la syntaxe en version 14 :
SELECT personne['adresse']['ville']->>0 AS ville
FROM json.personnes p
WHERE personne['nom'] = '"Lagaffe"'::jsonb
AND personne['prenom'] = '"Gaston"'::jsonb ;
Il est plus propre de rechercher grâce à une de ces syntaxes, notamment parce qu’elles seront indexables plus tard :
SELECT personne->'adresse'->>'ville'
FROM json.personnes p
WHERE personne @> '{"nom": "Lagaffe", "prenom": "Gaston"}'::jsonb ;
ou :
SELECT personne->'adresse'->>'ville'
FROM json.personnes p
WHERE personne @> jsonb_build_object ('nom', 'Lagaffe', 'prenom', 'Gaston') ;
Créer une requête qui renvoie les attributs
nom
,prenom
,date_naissance
(comme type date) de toutes les personnes avec le nom « Lagaffe ». Utiliser la fonctionjson_to_record()
etLATERAL
. Rajouterville
etpays
ensuite de la même manière.
jsonb_to_record
exige que l’on fournisse le nom de
l’attribut et son type :
SELECT r.*
FROM json.personnes,
AS r (nom text, prenom text, date_naissance date)
LATERAL jsonb_to_record (personne) WHERE personne @> '{"nom": "Lagaffe"}'::jsonb;
nom | prenom | date_naissance
---------+--------+----------------
Lagaffe | Gaston | 1938-09-22 Lagaffe | Jeanne | 1940-02-14
Avec la ville, qui est dans un sous-attribut, il faut rajouter une
clause LATERAL
:
SELECT r1.*, r2.*
FROM json.personnes,
LATERAL jsonb_to_record (personne)AS r1 (nom text, prenom text, date_naissance date),
->'adresse')
LATERAL jsonb_to_record (personneAS r2 (ville text, pays text)
WHERE personne @> '{"nom": "Lagaffe"}'::jsonb;
nom | prenom | date_naissance | ville | pays
---------+--------+----------------+-----------+----------
Lagaffe | Gaston | 1938-09-22 | Bruxelles | Belgique Lagaffe | Jeanne | 1940-02-14 | Bruxelles | Belgique
En supprimant le filtre, comparer le temps d’exécution de la requête précédente avec cette requête plus simple qui récupère les champs plus manuellement :
SELECT personne->>'nom', ->>'prenom', personne->>'date_naissance')::date, (personne>>'{adresse,ville}', personne#>>'{adresse,pays}' personne#FROM json.personnes;
Cette dernière requête est nettement plus lente que l’utilisation de
jsonb_to_record
, même si les I/O sont plus réduites :
EXPLAIN (COSTS OFF,ANALYZE,BUFFERS)
SELECT personne->>'nom',
->>'prenom',
personne->>'date')::date,
(personne>>'{adresse,ville}',
personne#>>'{adresse,pays}'
personne#FROM json.personnes;
QUERY PLAN
---------------------------------------------------------------------
Seq Scan on personnes (cost=0.00..71383.74 rows=532645 width=132) (actual time=0.079..6009.601 rows=532645 loops=1)
Buffers: shared hit=3825357 read=122949
Planning Time: 0.078 ms Execution Time: 6022.738 ms
EXPLAIN (ANALYZE,BUFFERS)
SELECT r1.*, r2.* FROM json.personnes,
LATERAL jsonb_to_record (personne)AS r1 (nom text, prenom text, date_naissance date),
->'adresse')
LATERAL jsonb_to_record (personneAS r2 (ville text, pays text) ;
QUERY PLAN
---------------------------------------------------------------------
Nested Loop (cost=0.01..83368.26 rows=532645 width=132) (actual time=0.064..3820.847 rows=532645 loops=1)
Buffers: shared hit=1490408 read=122956
-> Nested Loop (cost=0.00..72715.35 rows=532645 width=832) (actual time=0.059..2247.303 rows=532645 loops=1)
Buffers: shared hit=712094 read=122956
-> Seq Scan on personnes (cost=0.00..62062.45 rows=532645 width=764) (actual time=0.037..98.138 rows=532645 loops=1)
Buffers: shared read=56736
-> Function Scan on jsonb_to_record r1 (cost=0.00..0.01 rows=1 width=68) (actual time=0.004..0.004 rows=1 loops=532645)
Buffers: shared hit=712094 read=66220
-> Function Scan on jsonb_to_record r2 (cost=0.01..0.01 rows=1 width=64) (actual time=0.003..0.003 rows=1 loops=532645)
Buffers: shared hit=778314
Planning Time: 0.103 ms Execution Time: 3953.137 ms
La maintenabilité plaide pour la seconde version. Quant à la lisibilité entre les deux versions de la requête, c’est un choix personnel.
Créer un index GIN ainsi :
CREATE INDEX personnes_gin ON json.personnes USING gin(personne jsonb_path_ops);
Quelle taille fait-il ?
L’index peut être un peu long à construire (plusieurs dizaines de secondes) et est assez gros :
\di+ json.personnes_gin
Liste des relations
Schéma | Nom | … | Table | … | Méthode d'accès | Taille | …
--------+---------------+---+-----------+---+--+++++----------+--------+- json | personnes_gin | … | personnes | … | gin | 230 MB |
Retenter les requêtes précédentes. Lesquelles utilisent l’index ?
Les requêtes utilisant les égalités (que ce soit sur du texte ou en JSON) n’utilisent pas l’index :
EXPLAIN (COSTS OFF, ANALYZE,BUFFERS)
SELECT personne->'adresse'->>'ville'
FROM json.personnes p
WHERE personne->>'nom' = 'Lagaffe'
AND personne->>'prenom' = 'Gaston' ;
QUERY PLAN
---------------------------------------------------------------------
Gather (actual time=0.427..566.202 rows=1 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=712208 read=122962
-> Parallel Seq Scan on personnes p (actual time=372.989..561.152 rows=0 loops=3)
Filter: (((personne ->> 'nom'::text) = 'Lagaffe'::text) AND ((personne ->> 'prenom'::text) = 'Gaston'::text))
Rows Removed by Filter: 177548
Buffers: shared hit=712208 read=122962
Planning Time: 0.110 ms Execution Time: 566.228 ms
Par contre, la syntaxe @>
(« contient ») utilise
l’index, quelle que soit la manière dont on construit le JSON critère.
Le gain en temps et en I/O (et en CPU) grâce à l’index est assez
foudroyant. Et ceci, quelle que soit la manière dont on récupère les
champs, puisqu’il n’y a plus qu’une poignée de lignes à analyser :
EXPLAIN (COSTS OFF, ANALYZE,BUFFERS)
SELECT personne->'adresse'->>'ville'
FROM json.personnes p
WHERE personne @> '{"nom": "Lagaffe", "prenom": "Gaston"}'::jsonb ;
QUERY PLAN
---------------------------------------------------------------------
Bitmap Heap Scan on personnes p (actual time=0.047..0.049 rows=1 loops=1)
Recheck Cond: (personne @> '{"nom": "Lagaffe", "prenom": "Gaston"}'::jsonb)
Heap Blocks: exact=1
Buffers: shared hit=8
-> Bitmap Index Scan on personnes_gin (actual time=0.026..0.027 rows=1 loops=1)
Index Cond: (personne @> '{"nom": "Lagaffe", "prenom": "Gaston"}'::jsonb)
Buffers: shared hit=7
Planning:
Buffers: shared hit=1
Planning Time: 0.408 ms Execution Time: 0.081 ms
EXPLAIN (ANALYZE, VERBOSE)
SELECT r1.*, r2.*
FROM json.personnes,
LATERAL jsonb_to_record (personne)AS r1 (nom text, prenom text, date_naissance date),
->'adresse')
LATERAL jsonb_to_record (personneAS r2 (ville text, pays text)
WHERE personne @> '{"nom": "Lagaffe"}'::jsonb;
QUERY PLAN
---------------------------------------------------------------------
Nested Loop (cost=25.90..235.79 rows=53 width=132) (actual time=0.051..0.063 rows=2 loops=1)
Output: r1.nom, r1.prenom, r1.date_naissance, r2.ville, r2.pays
-> Nested Loop (cost=25.90..234.73 rows=53 width=821) (actual time=0.047..0.056 rows=2 loops=1)
Output: personnes.personne, r1.nom, r1.prenom, r1.date_naissance
-> Bitmap Heap Scan on json.personnes (cost=25.90..233.66 rows=53 width=753) (actual time=0.029..0.034 rows=2 loops=1)
Output: personnes.id_personne, personnes.personne
Recheck Cond: (personnes.personne @> '{"nom": "Lagaffe"}'::jsonb)
Heap Blocks: exact=2
-> Bitmap Index Scan on personnes_gin (cost=0.00..25.88 rows=53 width=0) (actual time=0.017..0.018 rows=2 loops=1)
Index Cond: (personnes.personne @> '{"nom": "Lagaffe"}'::jsonb)
-> Function Scan on pg_catalog.jsonb_to_record r1 (cost=0.00..0.01 rows=1 width=68) (actual time=0.009..0.009 rows=1 loops=2)
Output: r1.nom, r1.prenom, r1.date_naissance
Function Call: jsonb_to_record(personnes.personne)
-> Function Scan on pg_catalog.jsonb_to_record r2 (cost=0.01..0.01 rows=1 width=64) (actual time=0.002..0.003 rows=1 loops=2)
Output: r2.ville, r2.pays
Function Call: jsonb_to_record((personnes.personne -> 'adresse'::text))
Planning Time: 0.259 ms Execution Time: 0.098 ms
Les requêtes sans filtre n’utilisent pas l’index, bien sûr.
Récupérer les numéros de téléphone de Léon Prunelle avec ces trois syntaxes. Quelles sont les différences ?
--(Syntaxe pour PostgreSQL 14 minimum)
SELECT personne['adresse']['telephones'],
'adresse']->'telephones',
personne['adresse']['telephones']#>'{}',
personne['adresse']['telephones']->0,
personne['adresse']->>'telephones',
personne['adresse']['telephones']#>>'{}',
personne['adresse']['telephones']->>0
personne[FROM json.personnes p
WHERE personne @> '{"nom": "Prunelle", "prenom": "Léon"}'::jsonb ;
Le sous-attribut telephones
est un tableau. La syntaxe
->0
ne renvoie que le premier élément :
-[ RECORD 1 ]--------------------------
personne | ["0129951489", "0678327400"]
?column? | ["0129951489", "0678327400"]
?column? | ["0129951489", "0678327400"]
?column? | "0129951489"
?column? | ["0129951489", "0678327400"]
?column? | ["0129951489", "0678327400"] ?column? | 0129951489
Les 4 premières lignes renvoient un jsonb
, les trois
dernières sa conversion en texte :
\gdesc
Column | Type
----------+-------
personne | jsonb
?column? | jsonb
?column? | jsonb
?column? | jsonb
?column? | text
?column? | text ?column? | text
Afficher les noms et prénoms de Prunelles, et un tableau de champs texte contenant ses numéros de téléphone (utiliser
jsonb_array_elements_text
).
Il vaut mieux ne pas « bricoler » avec des conversions manuelles du
JSON en texte puis en tableau. La fonction dédiée est
jsonb_array_elements_text
.
SELECT personne->>'prenom' AS prenom, personne->>'nom' AS nom,
->'adresse'->'telephones') AS tel
jsonb_array_elements_text (personneFROM json.personnes p
WHERE personne @> '{"nom": "Prunelle", "prenom": "Léon"}'::jsonb ;
prenom | nom | tel
--------+----------+------------
Léon | Prunelle | 0129951489 Léon | Prunelle | 0678327400
Cependant on multiplie les lignes par le nombre de numéros de téléphone, et il faut réagréger :
SELECT personne->>'prenom' AS prenom, personne->>'nom' AS nom,
SELECT array_agg (t) FROM
(->'adresse'->'telephones') tels(t)
jsonb_array_elements_text (personneAS tels
) FROM json.personnes p
WHERE personne @> '{"nom": "Prunelle", "prenom": "Léon"}'::jsonb ;
prenom | nom | tels
--------+----------+------------------------- Léon | Prunelle | {0129951489,0678327400}
\gdesc
Column | Type
--------+--------
prenom | text
nom | text tels | text[]
La version suivante fonctionnerait aussi dans ce cas précis (cependant elle sera moins performante s’il y a beaucoup de lignes, car PostgreSQL voudra faire un agrégat global au lieu d’un simple parcours ; il faudra aussi vérifier que la clé d’agrégation tient compte d’homonymes).
SELECT personne->>'prenom' AS prenom, personne->>'nom' AS nom,
AS tels
array_agg (t) FROM json.personnes p
LEFT OUTER JOIN LATERAL jsonb_array_elements_text (
->'adresse'->'telephones') AS tel(t) ON (true)
personneWHERE personne @> '{"nom": "Prunelle", "prenom": "Léon"}'::jsonb
GROUP BY 1,2 ;
(Noter que la fonction sœur jsonb_array_elements()
renverrait, elle, des JSON.)
Comparer le résultat et les performances de ces deux requêtes, qui récupèrent aussi les numéros de téléphone de Prunelle :
SELECT jsonb_path_query (personne,
'$.adresse.telephones[*] ? ($.nom == "Prunelle" && $.prenom == "Léon")' ) #>>'{}' AS tel
FROM json.personnes ;
SELECT jsonb_path_query (personne, '$.adresse.telephones[*]')#>>'{}'
AS tel
FROM json.personnes
WHERE personne @@ '$.nom == "Prunelle" && $.prenom == "Léon"' ;
Le résultat est le même dans les deux cas :
tel
------------
0129951489 0678327400
Par contre, le plan et les temps d’exécutions sont totalement
différents. La clause jsonb_path_query
unique parcourt
complètement la table :
EXPLAIN (COSTS OFF, ANALYZE, BUFFERS)
SELECT jsonb_path_query (personne,
'$.adresse.telephones[*] ? ($.nom == "Prunelle" && $.prenom == "Léon")'
>>'{}' AS tel
) #FROM json.personnes ;
QUERY PLAN
---------------------------------------------------------------------
Gather (actual time=1290.193..1293.496 rows=2 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=729122 read=123788
-> Result (actual time=1113.807..1269.568 rows=1 loops=3)
Buffers: shared hit=729122 read=123788
-> ProjectSet (actual time=1113.803..1269.564 rows=1 loops=3)
Buffers: shared hit=729122 read=123788
-> Parallel Seq Scan on personnes (actual time=0.240..304.804 rows=177548 loops=3)
Buffers: shared read=55888
Planning Time: 0.134 ms Execution Time: 1293.548 ms
Tandis que la séparation du filtrage et de l’affichage permet à PostgreSQL de sélectionner les lignes, et donc de passer par un index avant de procéder à l’affichage.
EXPLAIN (COSTS OFF, ANALYZE, BUFFERS)
SELECT jsonb_path_query (personne, '$.adresse.telephones[*]')#>>'{}' AS tel
FROM json.personnes
WHERE personne @@ '$.nom == "Prunelle" && $.prenom == "Léon"' ;
QUERY PLAN
---------------------------------------------------------------------
Result (actual time=2.196..2.207 rows=2 loops=1)
Buffers: shared hit=2 read=6
-> ProjectSet (actual time=2.186..2.194 rows=2 loops=1)
Buffers: shared hit=2 read=6
-> Bitmap Heap Scan on personnes (actual time=2.167..2.170 rows=1 loops=1)
Recheck Cond: (personne @@ '($."nom" == "Prunelle" && $."prenom" == "Léon")'::jsonpath)
Heap Blocks: exact=1
Buffers: shared hit=2 read=6
-> Bitmap Index Scan on personnes_gin (actual time=2.113..2.114 rows=1 loops=1)
Index Cond: (personne @@ '($."nom" == "Prunelle" && $."prenom" == "Léon")'::jsonpath)
Buffers: shared hit=2 read=5
Planning:
Buffers: shared read=4
Planning Time: 2.316 ms Execution Time: 2.269 ms
(À la place de @@
, la syntaxe classique
@>
avec un JSON comme critère, est aussi performante
dans ce cas simple.)
Chercher qui possède le numéro de téléphone
0650041821
avec la syntaxe JSONPath.
Ces deux syntaxes sont équivalentes :
SELECT personne->>'nom', personne->>'prenom'
FROM json.personnes
WHERE personne @@ '$.adresse.telephones[*] == "0650041821" ' ;
SELECT personne->>'nom', personne->>'prenom'
FROM json.personnes
WHERE personne @? '$.adresse.telephones[*] ? (@ == "0650041821")' ;
?column? | ?column?
-----------+---------- Delacroix | Justine
Dans les deux cas, EXPLAIN
montre que l’index GIN est
bien utilisé.
Compter le nombre de personnes habitant à Paris ou Bruxelles avec :
- la syntaxe
@>
et unOR
;- une syntaxe JSONPath
@?
et un « ou » logique (||
) ;- une syntaxe JSONPath
@?
et une regex@.ville like_regex "^(Paris|Bruxelles)$"
.
Vous devez trouver 63 personnes avec la version complète de la base.
Cet appel va utiliser l’index GIN :
EXPLAIN SELECT count(*) FROM json.personnes
WHERE personne @> '{"adresse": {"ville": "Paris"}}'::jsonb
OR personne @> '{"adresse": {"ville": "Bruxelles"}}'::jsonb ;
QUERY PLAN
---------------------------------------------------------------------
Aggregate (cost=467.65..467.66 rows=1 width=8)
-> Bitmap Heap Scan on personnes (cost=51.82..467.38 rows=107 width=0)
Recheck Cond: ((personne @> '{"adresse": {"ville": "Paris"}}'::jsonb) OR (personne @> '{"adresse": {"ville": "Bruxelles"}}'::jsonb))
-> BitmapOr (cost=51.82..51.82 rows=107 width=0)
-> Bitmap Index Scan on personnes_gin (cost=0.00..25.88 rows=53 width=0)
Index Cond: (personne @> '{"adresse": {"ville": "Paris"}}'::jsonb)
-> Bitmap Index Scan on personnes_gin (cost=0.00..25.88 rows=53 width=0) Index Cond: (personne @> '{"adresse": {"ville": "Bruxelles"}}'::jsonb)
Cet appel aussi :
EXPLAIN SELECT count(*) FROM json.personnes
WHERE personne @? '$.adresse ? ( @.ville == "Paris" || @.ville == "Bruxelles") ' ;
QUERY PLAN
---------------------------------------------------------------------
Aggregate (cost=2020.86..2020.87 rows=1 width=8)
-> Bitmap Heap Scan on personnes (cost=48.13..2019.53 rows=533 width=0)
Recheck Cond: (personne @? '$."adresse"?(@."ville" == "Paris" || @."ville" == "Bruxelles")'::jsonpath)
-> Bitmap Index Scan on personnes_gin (cost=0.00..47.99 rows=533 width=0) Index Cond: (personne @? '$."adresse"?(@."ville" == "Paris" || @."ville" == "Bruxelles")'::jsonpath)
Par contre, l’index GIN est inutilisable si l’on demande une expression régulière (aussi simple soit-elle) :
EXPLAIN SELECT count(*) FROM json.personnes
WHERE personne @? '$.adresse ? ( @.ville like_regex "^(Paris|Bruxelles)$" ) ' ;
QUERY PLAN
---------------------------------------------------------------------
Finalize Aggregate (cost=56899.96..56899.97 rows=1 width=8)
-> Gather (cost=56899.75..56899.96 rows=2 width=8)
Workers Planned: 2
-> Partial Aggregate (cost=55899.75..55899.76 rows=1 width=8)
-> Parallel Seq Scan on personnes (cost=0.00..55899.19 rows=222 width=0) Filter: (personne @? '$."adresse"?(@."ville" like_regex "^(Paris|Bruxelles)$")'::jsonpath)
Le compte du nombre de personne par pays doit être optimisé au maximum. Ajouter un index fonctionnel sur l’attribut
pays
. Tester l’efficacité sur une recherche, et un décompte de toutes les personnes par pays.
Suivant la syntaxe préférée, l’index peut être par exemple ceci :
CREATE INDEX personnes_pays_idx ON json.personnes
USING btree ( (personne->'adresse'->>'pays'));
ANALYZE json.personnes ; VACUUM
L’index contient peu de valeurs et fait au plus 3 Mo (beaucoup plus sur une version antérieure à PostgreSQL 13).
Cet index est utilisable pour une recherche à condition que la syntaxe de l’expression soit rigoureusement identique, ce qui limite les cas d’usage.
EXPLAIN (ANALYZE,BUFFERS) SELECT count(*) FROM json.personnes
WHERE personne->'adresse'->>'pays' ='Belgique' ;
QUERY PLAN
---------------------------------------------------------------------
Aggregate (cost=8.38..8.39 rows=1 width=8) (actual time=0.045..0.046 rows=1 loops=1)
Buffers: shared hit=6
-> Index Scan using personnes_pays_idx on personnes (cost=0.42..8.38 rows=1 width=0) (actual time=0.032..0.037 rows=3 loops=1)
Index Cond: (((personne -> 'adresse'::text) ->> 'pays'::text) = 'Belgique'::text)
Buffers: shared hit=6
Planning:
Buffers: shared hit=1
Planning Time: 0.154 ms Execution Time: 0.078 ms
Par contre, pour le décompte complet, il n’a aucun intérêt :
EXPLAIN SELECT personne->'adresse'->>'pays', count(*)
FROM json.personnes GROUP BY 1 ;
QUERY PLAN
---------------------------------------------------------------------
Finalize GroupAggregate (cost=61309.88..61312.72 rows=11 width=40)
Group Key: (((personne -> 'adresse'::text) ->> 'pays'::text))
-> Gather Merge (cost=61309.88..61312.45 rows=22 width=40)
Workers Planned: 2
-> Sort (cost=60309.86..60309.88 rows=11 width=40)
Sort Key: (((personne -> 'adresse'::text) ->> 'pays'::text))
-> Partial HashAggregate (cost=60309.50..60309.67 rows=11 width=40)
Group Key: ((personne -> 'adresse'::text) ->> 'pays'::text) -> Parallel Seq Scan on personnes (cost=0.00..59199.38 rows=222025 width=32)
En effet, un index fonctionnel ne permet pas un Index Only Scan. Pourtant, il pourrait être très intéressant ici.
Ajouter un champ généré dans
json.personne
, correspondant à l’attributpays
.
Attention, l’ordre va réécrire la table, ce qui peut être long (de l’ordre de la minute, suivant le matériel) :
ALTER TABLE json.personnes ADD COLUMN pays text
GENERATED ALWAYS AS ( personne->'adresse'->>'pays' ) STORED ;
ANALYZE json.personnes ; VACUUM
Comparer les temps d’exécution du décompte des pays par l’attribut, et par cette colonne générée.
\timing on
SELECT personne->'adresse'->>'pays', count(*) FROM json.personnes GROUP BY 1 ;
?column? | count
--------------------------+-------
België | 39597
Belgique | 3
Denmark | 21818
España | 79899
France | 82936
Italia | 33997
Lietuva | 6606
Poland | 91099
Portugal | 17850
United Kingdom | 64926
United States of America | 93914
Temps : 601,815 ms
Par contre, la lecture directe du champ est nettement plus rapide :
SELECT pays, count(*) FROM json.personnes GROUP BY 1 ;
… Temps : 58,811 ms
Le plan est pourtant le même : un Seq Scan, faute de clause de filtrage et d’index, suivi d’un agrégat parallélisé n’utilisant que quelques kilooctets de mémoire.
QUERY PLAN
---------------------------------------------------------------------
Finalize GroupAggregate (cost=59529.88..59532.67 rows=11 width=19) (actual time=61.211..64.244 rows=11 loops=1)
Group Key: pays
Buffers: shared hit=55219
-> Gather Merge (cost=59529.88..59532.45 rows=22 width=19) (actual time=61.204..64.235 rows=33 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=55219
-> Sort (cost=58529.85..58529.88 rows=11 width=19) (actual time=45.186..45.188 rows=11 loops=3)
Sort Key: pays
Sort Method: quicksort Memory: 25kB
Worker 0: Sort Method: quicksort Memory: 25kB
Worker 1: Sort Method: quicksort Memory: 25kB
Buffers: shared hit=55219
-> Partial HashAggregate (cost=58529.55..58529.66 rows=11 width=19) (actual time=45.159..45.161 rows=11 loops=3)
Group Key: pays
Buffers: shared hit=55203
-> Parallel Seq Scan on personnes (cost=0.00..57420.70 rows=221770 width=11) (actual time=0.005..13.678 rows=177548 loops=3)
Buffers: shared hit=55203
Planning Time: 0.105 ms Execution Time: 64.297 ms
Le champ généré a donc un premier intérêt en terme de rapidité de lecture des champs, surtout avec des JSON importants comme ici.
Créer un index B-tree sur la colonne générée
pays
. Consulter les statistiques danspg_stats
. Cet index est-il utilisable pour des filtres et le décompte parpays
?
CREATE INDEX personnes_g_pays_btree ON json.personnes (pays);
ANALYZE json.personnes ; VACUUM
Ces deux ordres ne durent qu’1 ou 2 secondes.
EXPLAIN (ANALYZE, BUFFERS)
SELECT p.pays, count(*)
FROM json.personnes p
GROUP BY 1 ;
QUERY PLAN
---------------------------------------------------------------------
Finalize GroupAggregate (cost=1000.45..8885.35 rows=10 width=19) (actual time=7.629..49.349 rows=11 loops=1)
Group Key: pays
Buffers: shared hit=477
-> Gather Merge (cost=1000.45..8885.15 rows=20 width=19) (actual time=7.625..49.340 rows=11 loops=1)
Workers Planned: 2
Workers Launched: 0
Buffers: shared hit=477
-> Partial GroupAggregate (cost=0.42..7882.82 rows=10 width=19) (actual time=7.371..49.034 rows=11 loops=1)
Group Key: pays
Buffers: shared hit=477
-> Parallel Index Only Scan using personnes_g_pays_btree on personnes p (cost=0.42..6771.79 rows=222186 width=11) (actual time=0.023..22.578 rows=532645 loops=1)
Heap Fetches: 0
Buffers: shared hit=477
Planning Time: 0.114 ms Execution Time: 49.391 ms
Le gain en temps est appréciable. Mais l’intérêt principal réside ici dans le nombre de blocs lus divisé par 100 ! Le nouvel index ne fait que 3 Mo.
\di+ json.personnes*
Liste des relations
Schéma | Nom | Type | … | Méthode d'accès | Taille | …
--------+------------------------+-------+---+-----------------+---------+---
json | personnes_g_pays_btree | index | … | btree | 3664 kB |
json | personnes_gin | index | … | gin | 230 MB | json | personnes_pays_idx | index | … | btree | 3664 kB |
(Optionnel) Créer des colonnes générées sur
nom
,prenom
,date_naissance
, etville
(en un seul ordre). Reprendre la requête plus haut qui les affiche tous et comparer les performances.
Un champ va poser problème : la date de naissance. En effet, la date
est stockée au format texte, il faudra soi-même faire la conversion. De
plus, un simple opérateur ::date
ne peut être utilisé dans
une expression de GENERATED
car il n’est pas « immutable »
(pour des raisons
techniques).
Un contournement pas très performant est celui-ci :
ALTER TABLE json.personnes
ADD COLUMN nom text GENERATED ALWAYS AS (personne->>'prenom') STORED,
ADD COLUMN prenom text GENERATED ALWAYS AS (personne->>'nom') STORED,
ADD COLUMN date_naissance date
GENERATED ALWAYS AS (
left(personne->>'date_naissance',4)::int,
make_date (->>'date_naissance',6,2)::int,
substring(personneleft(personne->>'date_naissance',2)::int))
STORED,ADD COLUMN ville text GENERATED ALWAYS AS ( personne->'adresse'->>'ville') STORED ;
ANALYZE json.personnes ; VACUUM
Une autre possibilité plus performante est d’enrober
to_date()
dans une fonction immutable, puisqu’il n’y a,
dans ce cas précis, pas d’ambiguïté sur le format ISO :
CREATE OR REPLACE FUNCTION to_date_immutable (text)
date
RETURNS -- Cette fonction requiert que les dates soient bien
-- stockées au format ci-dessous
-- et ne fait aucune gestion d'erreur sinon
LANGUAGE sqlPARALLEL SAFE
IMMUTABLE AS $body$
SELECT to_date($1, 'YYYY-MM-DD');
$body$ ;
et l’ordre devient :
ALTER TABLE json.personnes
…ADD COLUMN date_naissance date
GENERATED ALWAYS AS (to_date_immutable (personne->>'date_naissance')) STORED,
…;
Les conversions de texte vers des dates sont des sources fréquentes
de problèmes. Le conseil habituel est de toujours stocker une date dans
un champ de type date
ou
timestamp
/timestamptz
. Mais si elle provient
d’un JSON, il faudra gérer soi-même la conversion.
Quelle que soit la méthode, la requête suivante :
SELECT nom, prenom, date_naissance, ville, pays FROM json.personnes ;
est beaucoup plus rapide que :
SELECT r1.nom, r1.prenom, r1.date_naissance, r2.ville, r2.pays
FROM json.personnes,
LATERAL jsonb_to_record (personne)AS r1 (nom text, prenom text, date_naissance date),
->'adresse')
LATERAL jsonb_to_record (personneAS r2 (ville text, pays text) ;
elle-même plus rapide que les extractions manuelles des attributs un à un, comme vu plus haut.
Certes, la table est un peu plus grosse, mais le coût d’insertion des colonnes générées est donc souvent rentable pour les champs fréquemment utilisés.
Ajouter l’attribut
animaux
à Gaston Lagaffe, avec la valeur 18. Vérifier en relisant la ligne.
UPDATE json.personnes
SET personne = personne || '{"animaux": 18}'
WHERE personne @> '{"nom": "Lagaffe", "prenom": "Gaston"}'::jsonb;
SELECT personne->>'animaux'
FROM json.personnes WHERE personne @> '{"nom": "Lagaffe", "prenom": "Gaston"}'::jsonb ;
?column?
---------- 18
Ajouter l’attribut
animaux
à 2% des individus au hasard, avec une valeur 1 ou 2.
On utilise ici la fonction jsonb_build_object()
, plus
adaptée à la construction d’un JSON qui n’est pas une constante. Le
choix des individus peut se faire de plusieurs manières, par exemple
avec random()
, mod()
…
UPDATE json.personnes
SET personne = personne ||
'animaux', 1+mod ((personne->>'numgen')::int, 50))
jsonb_build_object (WHERE mod((personne->>'numgen')::int,50) = 0 ;
UPDATE 10653
-- Conseillé après chaque mise à jour importante
ANALYZE json.personnes ; VACUUM
Compter le nombre de personnes avec des animaux (avec ou sans JSONPath). Proposer un index qui pourrait convenir à d’autres futurs nouveaux attributs peu fréquents.
Ces requêtes renvoient 10654, mais effectuent toutes un Seq Scan avec une durée d’exécution aux alentours de la seconde :
SELECT count(*) FROM json.personnes
WHERE (personne->>'animaux')::int > 0 ;
SELECT count(*) FROM json.personnes
WHERE personne ? 'animaux' ;
SELECT count(*) FROM json.personnes
WHERE personne @@ '$.animaux > 0' ;
SELECT count(*) FROM json.personnes
WHERE personne @? '$.animaux ? (@ > 0) ' ;
(Remarquer que les deux dernières requêtes utiliseraient l’index GIN
pour des égalités comme (@ == 0)
ou (@ == 18)
,
et seraient presque instantanées. Là encore, c’est une limite des index
GIN.)
On pourrait indexer (personne->>'animaux')::int
,
ce qui serait excellent pour la première requête, mais ne conviendrait
pas à d’autres critères.
L’opérateur ?
ne sait pas utiliser l’index GIN
jsonb_path_ops
existant. Par contre, il peut profiter de
l’opérateur GIN par défaut :
CREATE INDEX personnes_gin_df ON json.personnes USING gin (personne) ;
EXPLAIN
SELECT count(*) FROM json.personnes
WHERE personne ? 'animaux' ;
QUERY PLAN
---------------------------------------------------------------------
Aggregate (cost=42263.34..42263.35 rows=1 width=8)
-> Bitmap Heap Scan on personnes (cost=167.47..42209.54 rows=21521 width=0)
Recheck Cond: (personne ? 'animaux'::text)
-> Bitmap Index Scan on personnes_gin_df (cost=0.00..162.09 rows=21521 width=0) Index Cond: (personne ? 'animaux'::text)
Il est directement utilisable par tout autre attribut :
SELECT count(*) FROM json.personnes
WHERE personne ? 'voitures' ;
QUERY PLAN
---------------------------------------------------------------------
Aggregate (cost=233.83..233.84 rows=1 width=8)
-> Bitmap Heap Scan on personnes (cost=25.89..233.70 rows=53 width=0)
Recheck Cond: (personne ? 'voitures'::text)
-> Bitmap Index Scan on personnes_gin_df (cost=0.00..25.88 rows=53 width=0) Index Cond: (personne ? 'voitures'::text)
Cet index avec l’opérateur jsonb_ops
a par contre le
gros inconvénient d’être encore plus gros que l’index GIN avec
jsonb_path_ops
(303 Mo contre 235 Mo), et d’alourdir encore
les mises à jour. Il peut cependant remplacer ce dernier, de manière un
peu moins performante. Il faut aviser selon les requêtes, la place, les
écritures…
- Créer une table
fichiers
avec un texte et une colonne permettant de référencer des Large Objects.
CREATE TABLE fichiers (nom text PRIMARY KEY, data OID);
- Importer un fichier local à l’aide de psql dans un large object.
- Noter l’
oid
retourné.
psql -c "\lo_import '/etc/passwd'"
lo_import 6821285
INSERT INTO fichiers VALUES ('/etc/passwd',6821285) ;
- Importer un fichier du serveur à l’aide de psql dans un large object.
INSERT INTO fichiers SELECT 'postgresql.conf',
'/var/lib/pgsql/15/data/postgresql.conf') ; lo_import(
- Afficher le contenu de ces différents fichiers à l’aide de psql.
psql -c "SELECT nom,encode(l.data,'escape') \
FROM fichiers f JOIN pg_largeobject l ON f.data = l.loid;"
- Les sauvegarder dans des fichiers locaux.
psql -c "\lo_export loid_retourné '/home/dalibo/passwd_serveur';"
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 :
GRANT EXECUTE ON FUNCTION nom_fonction TO un_role ;
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).
Les 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.
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.
SELECT addition (1,2);
addition
---------- 3
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)
integer
RETURNS
LANGUAGE sql
IMMUTABLERETURN 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)
boolean
RETURNS
LANGUAGE sqlAS $$
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)
boolean
RETURNS AS $$
LANGUAGE plpgsql 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.
SELECT nb_lignes_table ('public', 'pgbench_accounts');
nb_lignes_table
----------------- 10000000
Voici l’exemple de la fonction :
CREATE OR REPLACE FUNCTION
public.demo_insert_perl(nom_client text, titre_facture text)integer
RETURNS
LANGUAGE plperl
STRICT$function$
AS 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 = '
$nom_client)
. quote_literal(
);# Sinon on le crée :
if ($rv->{processed} == 0)
{$rv = spi_exec_query('INSERT INTO mes_clients (nom_client) VALUES ('
$nom_client) . ') RETURNING id_client'
. quote_literal(
);
}# 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 ('
$titre_facture) . ", $id_client ) RETURNING id_facture"
. quote_literal(
);
$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)
integer
RETURNS
LANGUAGE plpgsql
STRICTAS $function$
DECLARE
int;
v_id_facture int;
v_id_client 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.
CREATE OR REPLACE PROCEDURE vide_tables ()
BEGIN ATOMIC
DELETE FROM pgbench_history ;
DELETE FROM pgbench_accounts ;
DELETE FROM pgbench_tellers ;
DELETE FROM pgbench_branches ;
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).
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 ma_fonction('arg1', 'arg2');
SELECT
, en passant en argument les valeurs
d’une colonne d’une table :SELECT ma_fonction(ma_colonne) FROM ma_table;
FROM
d’un SELECT
, la fonction
renvoit ici généralement plusieurs lignes (SETOF
), et un
résultat de type RECORD
:SELECT result FROM ma_fonction() AS f(result);
INSERT
pour générer la valeur à insérer :INSERT INTO ma_table(ma_colonne) VALUES ( ma_fonction() );
CREATE INDEX ON ma_table ( ma_fonction(ma_colonne) );
ma_fonction()
(qui doit renvoyer une seule ligne) est passé
en argument d’entrée de la procédure ma_procedure()
:CALL ma_procedure( ma_fonction() );
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.).
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 plpgsqlAS $$
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
$$;
CALL transaction_test1();
SELECT * FROM test1;
a | b
---+---
0 |
2 | 4 |
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() ;
transaction termination
ERROR: invalid /pgSQL function transaction_test1() line 6 at COMMIT CONTEXTE : PL
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
'caught division_by_zero';
RAISE NOTICE END;
END;
$$;
commit while a subtransaction is active
ERREUR: cannot /pgSQL inline_code_block, ligne 5 à COMMIT CONTEXTE : fonction PL
Voici la syntaxe complète pour une fonction d’après la documentation :
CREATE [ OR REPLACE ] FUNCTION
DEFAULT | = } default_expr ] [, …] ] )
name ( [ [ argmode ] [ argname ] argtype [ {
[ RETURNS rettypeTABLE ( column_name column_type [, …] ) ]
| RETURNS
{ LANGUAGE lang_nameFOR TYPE type_name } [, … ]
| TRANSFORM {
| WINDOW
| { IMMUTABLE | STABLE | VOLATILE }NOT ] LEAKPROOF
| [ ON NULL INPUT | RETURNS NULL ON NULL INPUT | STRICT }
| { CALLED DEFINER }
| { [ EXTERNAL ] SECURITY INVOKER | [ EXTERNAL ] SECURITY PARALLEL { UNSAFE | RESTRICTED | SAFE }
| COST execution_cost
| ROWS result_rows
|
| SUPPORT support_functionSET 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
DEFAULT | = } default_expr ] [, …] ] )
name ( [ [ argmode ] [ argname ] argtype [ {
{ LANGUAGE lang_nameFOR TYPE type_name } [, … ]
| TRANSFORM { DEFINER
| [ EXTERNAL ] SECURITY INVOKER | [ EXTERNAL ] SECURITY 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 :=
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 :
<<mon_label>>
-- le code (blocs DECLARE, BEGIN-END, et EXCEPTION)
ou bien (pour une boucle)
<<mon_label>> ]
[ LOOP
ordres …END LOOP [ mon_label ];
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 :
WHEN compteur > 1; EXIT [mon_label]
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.
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.
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)
integer
RETURNS
LANGUAGE plpgsql
IMMUTABLEAS $ma_fonction_addition$
DECLARE
integer;
resultat BEGIN
:= entier1 + entier2;
resultat 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. :
:= requete || '' AND vin LIKE ''''bordeaux%'''' AND xyz '' requete
En voilà une simplification grâce aux dollars :
:= requete || $sql$ AND vin LIKE 'bordeaux%' AND xyz $sql$ requete
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.
Si 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.
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).
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;
SELECT * FROM explose_date ('31-12-2020');
jour | mois | annee
------+------+------- 31 | 0 | 2020
(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 :
CREATE OR REPLACE FUNCTION explose_date_table (d date)
TABLE (jour integer, mois integer, annee integer)
RETURNS
LANGUAGE sqlAS $$
SELECT extract (day FROM d)::int,
extract(month FROM d)::int, extract (year FROM d)::int ;
$$ ;
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)
integer
RETURNS SETOF
LANGUAGE plpgsqlAS $$
BEGIN
FOR i IN 1..limite LOOP
RETURN NEXT i;
END LOOP;
END
$$ ;
SELECT * FROM liste_entiers_setof (3) ;
liste_entiers_setof
---------------------
1
2 3
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 plpgsqlAS $$
BEGIN
RETURN QUERY SELECT schemaname::text, tablename::text
FROM pg_tables WHERE tableowner=p_owner
ORDER BY tablename ;
END $$ ;
SELECT * FROM tables_by_owner ('pgbench');
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 sqlAS $$
SELECT * FROM pg_stat_user_tables
WHERE coalesce(last_analyze, last_autoanalyze) IS NULL ;
$$ ;
SELECT * FROM tables_jamais_analyzees() \gx
-[ 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)
TABLE (j int)
RETURNS AS $$
BEGIN
FOR i IN 1..limite LOOP
= i ;
j RETURN NEXT ; -- renvoie la valeur de j en cours
END LOOP;
END $$ LANGUAGE plpgsql;
SELECT * FROM liste_entiers_table (3) ;
j
---
1
2 3
(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)
TABLE (schemaname text, tablename text)
RETURNS
LANGUAGE plpgsqlAS $$
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 :
SELECT * FROM ma_fonction();
Une alternative est d’utiliser :
SELECT ma_fonction();
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 :
SELECT * FROM pg_control_system () ;
pg_control_version | catalog_version_no | system_identifier | pg_control_…
--------------------+--------------------+---------------------+-------------
1201 | 201909212 | 6744959735975969621 | 2021-09-17 … (1 ligne)
SELECT pg_control_system () ;
pg_control_system
---------------------------------------------------------------
(1201,201909212,6744959735975969621,"2021-09-17 18:24:05+02") (1 ligne)
Si 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
.
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) :
integer := 42; answer
sa valeur par défaut, si on veut autre chose que
NULL
:
integer DEFAULT 42; answer
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) :
integer NOT NULL DEFAULT 42; answer
le collationnement à utiliser, pour les variables de type chaîne de caractères :
"en_GB"; question text COLLATE
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.
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.
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
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.
Par expression, on entend par exemple des choses comme :
IF myvar > 0 THEN
:= 1 / myvar;
myvar2 END IF;
Dans ce cas, l’expression myvar > 0
sera préparée par
le moteur de la façon suivante :
PREPARE statement_name(integer, integer) AS SELECT $1 > $2;
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 :
PREPARE statement_name(integer, integer) AS SELECT $1 / $2;
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 :
:= une_colonne FROM ma_table WHERE id = 5; ma_variable
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é.
Ré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 :
ERROR: query returned no rows ERROR: query returned more than one row
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) :
CREATE OR REPLACE FUNCTION compte_tables () RETURNS int LANGUAGE plpgsql AS $$
DECLARE
int ;
n RECORD ;
t BEGIN
SELECT count(*) INTO STRICT n
FROM pg_tables ;
FOR t IN SELECT * FROM pg_tables LOOP
'Table %.%', t.schemaname, t.tablename;
RAISE NOTICE END LOOP ;
RETURN n ;
END ;
$$ ;
SELECT compte_tables (); #
NOTICE: Table pg_catalog.pg_foreign_server
NOTICE: Table pg_catalog.pg_type
…
NOTICE: Table public.pgbench_accounts
NOTICE: Table public.pgbench_branches
NOTICE: Table public.pgbench_tellers
NOTICE: Table public.pgbench_history
compte_tables
---------------
186 (1 ligne)
On peut déterminer qu’aucune ligne n’a été trouvée par la requête en
utilisant la variable FOUND
:
* FROM ma_table WHERE une_colonne>0;
PERFORM IF NOT FOUND THEN
…END IF;
Pour appeler une fonction, il suffit d’utiliser PERFORM
de la manière suivante :
PERFORM mafonction(argument1);
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
:
= ROW_COUNT; GET DIAGNOSTICS variable
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
;EXECUTE
dans un bloc PL/pgSQL permet notamment du SQL
dynamique : l’ordre peut être construit dans une variable.
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
:= $$'Robert' ; DROP TABLE eleves;$$ ;
nom text id int ;
BEGIN
'A exécuter : %','SELECT * FROM eleves WHERE nom = '|| nom ;
RAISE NOTICE 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.
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
RECORD;
mviews BEGIN
'Rafraîchissement de toutes les vues matérialisées…';
RAISE NOTICE
FOR mviews IN
SELECT n.nspname AS mv_schema,
AS mv_name,
c.relname AS owner
pg_catalog.pg_get_userbyid(c.relowner) 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é
'Rafraichissement de la vue matérialisée %.% (owner: %)…',
RAISE NOTICE
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;
'Fin du rafraîchissement';
RAISE NOTICE RETURN 1;
END;
$$ LANGUAGE plpgsql;
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.
La 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 :
SELECT format(
'SELECT %I FROM %I WHERE %1$I=%3$L',
'MaColonne',
'ma_table',
'été$$
$$l);
format
------------------------------------------------------------- SELECT "MaColonne" FROM ma_table WHERE "MaColonne"='l''été'
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 :
DO $$BEGIN
CASE current_setting ('server_version_num')::int/10000
WHEN 8,9,10,11 THEN RAISE NOTICE 'Version non supportée !!' ;
WHEN 12,13,14,15,16 THEN RAISE NOTICE 'Version supportée' ;
ELSE RAISE NOTICE 'Version inconnue (fin 2023)' ;
END CASE ;
END ;
$$ LANGUAGE plpgsql ;
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.
La boucle FOR
n’a pas d’originalité par rapport à
d’autres langages.
L’option BY
permet d’augmenter l’incrémentation :
FOR variable in 1..10 BY 5…
L’option REVERSE
permet de faire défiler les valeurs en
ordre inverse :
FOR variable in REVERSE 10..1 …
Cette 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.
Voici deux exemples permettant d’illustrer l’utilité de
SLICE
:
SLICE
:
DO $$DECLARE a int[] := ARRAY[[1,2],[3,4],[5,6]];
int;
b BEGIN
IN ARRAY a LOOP
FOREACH b 'var: %', b;
RAISE INFO END LOOP;
END $$ ;
INFO: var: 1
INFO: var: 2
INFO: var: 3
INFO: var: 4
INFO: var: 5 INFO: var: 6
SLICE
:
DO $$DECLARE a int[] := ARRAY[[1,2],[3,4],[5,6]];
int[];
b BEGIN
1 IN ARRAY a LOOP
FOREACH b SLICE 'var: %', b;
RAISE INFO END LOOP;
END $$;
INFO: var: {1,2}
INFO: var: {3,4} INFO: var: {5,6}
et avec SLICE 2
, on obtient :
INFO: var: {{1,2},{3,4},{5,6}}
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)
integer
RETURNS
LANGUAGE sqlPARALLEL SAFE
IMMUTABLE DEFINER
SECURITY 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 :
\c pgbench lecteur
SELECT pgbench_util.accounts_balance (5) ;
accounts_balance
------------------ 0
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)
integer
RETURNS
LANGUAGE plpgsqlPARALLEL SAFE
IMMUTABLE DEFINER
SECURITY -- 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 ()
TABLE (bid int, bbalance int)
RETURNS
LANGUAGE plpgsqlAS $$
DECLARE
int ;
n BEGIN
-- affichage de l'utilisateur pgbench
'Entrée dans fonction piégée en tant que %', current_user ;
RAISE NOTICE -- 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
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
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.
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
VOLATILEAS $$
BEGIN
'appel premierjourdumois' ; -- trace des appels
RAISE notice 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 :
ALTER FUNCTION premierjourdumois(timestamp with time zone) 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 :
CREATE INDEX ON heures (premierjourdumois( t )) ;
ERROR: functions in index expression must be marked IMMUTABLE
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
IMMUTABLEAS $$
DECLARE
timestamp ; --sans TZ
jour1 BEGIN
:= date_trunc ('month', (t at time zone 'UTC')::timestamp) ;
jour1 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 :
\x
SET timezone TO 'US/Alaska';
SELECT d,
AT TIME ZONE 'UTC' AS d_en_utc,
d
premierjourdumois_utc (d),AT TIME ZONE 'UTC' as pjm_en_utc
premierjourdumois_utc (d) 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,
AT TIME ZONE 'UTC' AS d_en_utc,
d
premierjourdumois_utc (d),AT TIME ZONE 'UTC' as pjm_en_utc
premierjourdumois_utc (d) 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
IMMUTABLEAS $$
SELECT (date_trunc ('month',
at time zone 'UTC')::timestamp
(t
)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.
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
Toutes les données sont dans deux schémas nommés magasin et facturation.
É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.
É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) ).
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
.
É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>
).
É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.
É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
.
Le calcul de la date de Pâques est complexe. On peut écrire la fonction suivante :
CREATE OR REPLACE FUNCTION paques (annee integer)
date
RETURNS AS $$
DECLARE
integer ;
a integer ;
b date ;
r BEGIN
:= (19*(annee % 19) + 24) % 30 ;
a := (2*(annee % 4) + 4*(annee % 7) + 6*a + 5) % 7 ;
b 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
.
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 textAS $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 textAS $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 :
EXPLAIN SELECT hello();
QUERY PLAN
------------------------------------------ Result (cost=0.00..0.01 rows=1 width=32)
EXPLAIN SELECT hello_pl();
QUERY PLAN
------------------------------------------ Result (cost=0.00..0.26 rows=1 width=32)
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)
numeric
RETURNS AS $BODY$
BEGIN
RETURN arg1::numeric / arg2::numeric;
END
$BODY$ LANGUAGE plpgsql;
SELECT division (3,2) ;
division
-------------------- 1.5000000000000000
Écrire cette même fonction en SQL.
CREATE OR REPLACE FUNCTION division_sql (a integer, b integer)
numeric
RETURNS 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 :
SELECT division(1,0);
ERROR: division by zero CONTEXTE : PL/pgSQL function division(integer,integer) line 3 at RETURN
Pour la version en PL :
CREATE OR REPLACE FUNCTION division(arg1 integer, arg2 integer)
numeric
RETURNS AS $BODY$
BEGIN
IF arg2 = 0 THEN
RETURN 'NaN';
ELSE
RETURN arg1::numeric / arg2::numeric;
END IF;
END $BODY$
LANGUAGE plpgsql;
SELECT division (3,0) ;
division
---------- NaN
Pour la version en SQL :
CREATE OR REPLACE FUNCTION division_sql(a integer, b integer)
numeric
RETURNS 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)
integer
RETURNS AS $BODY$
DECLARE
integer;
nb BEGIN
SELECT count(*)
INTO nb
FROM employes
WHERE extract (year from date_embauche) = v_annee ;
RETURN nb;
END
$BODY$ LANGUAGE plpgsql ;
Test :
SELECT nb_embauches (2006);
nb_embauches
-------------- 9
Utiliser la fonction
generate_series()
pour lister le nombre d’embauches pour chaque année entre 2000 et 2010.
SELECT n, nb_embauches (n)
FROM generate_series (2000,2010) n
ORDER BY n;
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)
TABLE (annee int, nombre_embauches int)
RETURNS 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 :
SELECT * FROM nb_embauches (2006,2010);
annee | nombre_embauches
-------+------------------
2006 | 9
2007 | 0
2008 | 0
2009 | 0 2010 | 0
É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)
integer
RETURNS AS $BODY$
DECLARE
integer;
a1 integer;
a2 BEGIN
IF arg1 = 'zéro' THEN
:= 0;
a1 = 'un' THEN
ELSEIF arg1 := 1;
a1 = 'deux' THEN
ELSEIF arg1 := 2;
a1 = 'trois' THEN
ELSEIF arg1 := 3;
a1 = 'quatre' THEN
ELSEIF arg1 := 4;
a1 = 'cinq' THEN
ELSEIF arg1 := 5;
a1 = 'six' THEN
ELSEIF arg1 := 6;
a1 = 'sept' THEN
ELSEIF arg1 := 7;
a1 = 'huit' THEN
ELSEIF arg1 := 8;
a1 = 'neuf' THEN
ELSEIF arg1 := 9;
a1 END IF;
IF arg2 = 'zéro' THEN
:= 0;
a2 = 'un' THEN
ELSEIF arg2 := 1;
a2 = 'deux' THEN
ELSEIF arg2 := 2;
a2 = 'trois' THEN
ELSEIF arg2 := 3;
a2 = 'quatre' THEN
ELSEIF arg2 := 4;
a2 = 'cinq' THEN
ELSEIF arg2 := 5;
a2 = 'six' THEN
ELSEIF arg2 := 6;
a2 = 'sept' THEN
ELSEIF arg2 := 7;
a2 = 'huit' THEN
ELSEIF arg2 := 8;
a2 = 'neuf' THEN
ELSEIF arg2 := 9;
a2 END IF;
RETURN a1*a2;
END
$BODY$ LANGUAGE plpgsql;
Test :
SELECT multiplication('deux', 'trois');
multiplication
---------------- 6
SELECT multiplication('deux', 'quatre');
multiplication
---------------- 8
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)
integer AS $BODY$
RETURNS DECLARE
integer;
ret BEGIN
IF arg = 'zéro' THEN
:= 0;
ret = 'un' THEN
ELSEIF arg := 1;
ret = 'deux' THEN
ELSEIF arg := 2;
ret = 'trois' THEN
ELSEIF arg := 3;
ret = 'quatre' THEN
ELSEIF arg := 4;
ret = 'cinq' THEN
ELSEIF arg := 5;
ret = 'six' THEN
ELSEIF arg := 6;
ret = 'sept' THEN
ELSEIF arg := 7;
ret = 'huit' THEN
ELSEIF arg := 8;
ret = 'neuf' THEN
ELSEIF arg := 9;
ret END IF;
RETURN ret;
END
$BODY$
LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION multiplication(arg1 text, arg2 text)
integer
RETURNS AS $BODY$
DECLARE
integer;
a1 integer;
a2 BEGIN
:= texte_vers_entier(arg1);
a1 := texte_vers_entier(arg2);
a2 RETURN a1*a2;
END
$BODY$ LANGUAGE plpgsql;
Essayer de multiplier « deux » par 4. Qu’obtient-on et pourquoi ?
SELECT multiplication('deux', 4::text);
multiplication ----------------
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)
integer AS $BODY$
RETURNS DECLARE
integer;
ret BEGIN
IF arg = 'zéro' THEN
:= 0;
ret = 'un' THEN
ELSEIF arg := 1;
ret = 'deux' THEN
ELSEIF arg := 2;
ret = 'trois' THEN
ELSEIF arg := 3;
ret = 'quatre' THEN
ELSEIF arg := 4;
ret = 'cinq' THEN
ELSEIF arg := 5;
ret = 'six' THEN
ELSEIF arg := 6;
ret = 'sept' THEN
ELSEIF arg := 7;
ret = 'huit' THEN
ELSEIF arg := 8;
ret = 'neuf' THEN
ELSEIF arg := 9;
ret ELSE
EXCEPTION 'argument "%" invalide', arg;
RAISE := NULL;
ret END IF;
RETURN ret;
END
$BODY$ LANGUAGE plpgsql;
SELECT multiplication('deux', 4::text);
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 textAS $BODY$
DECLARE
integer;
heure
libelle text;BEGIN
:= to_char(now(), 'HH24');
heure IF heure > 12
THEN
:= 'Bonsoir';
libelle ELSE
:= 'Bonjour';
libelle END IF;
RETURN libelle||' '||utilisateur||' !';
END
$BODY$ LANGUAGE plpgsql;
Test :
SELECT salutation ('Guillaume');
salutation
--------------------- Bonsoir Guillaume !
Écrire la même fonction avec un paramètre
OUT
.
CREATE OR REPLACE FUNCTION salutation(IN utilisateur text, OUT message text)
AS $BODY$
DECLARE
integer;
heure
libelle text;BEGIN
:= to_char(now(), 'HH24');
heure IF heure > 12
THEN
:= 'Bonsoir';
libelle ELSE
:= 'Bonjour';
libelle END IF;
:= libelle||' '||utilisateur||' !';
message END
$BODY$ LANGUAGE plpgsql;
Elle s’utilise de la même manière :
SELECT salutation ('Guillaume');
salutation
--------------------- Bonsoir Guillaume !
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
integer;
heure
libelle text;BEGIN
SELECT INTO heure extract(hour from now())::int;
IF heure > 12
THEN
:= 'Bonsoir';
libelle ELSE
:= 'Bonjour';
libelle END IF;
:= libelle||' '||utilisateur||' !';
message 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 textAS $$
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)
varchar
RETURNS AS $$
DECLARE
varchar ; -- à renvoyer
str_out integer ;
position BEGIN
-- Initialisation de str_out, sinon sa valeur reste à NULL
:= '';
str_out -- Position initialisée ç la longueur de la chaîne
:= char_length(str_in);
position -- 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 || substring(str_in, position, 1);
str_out := position - 1;
position END LOOP;
RETURN str_out;
END;
$$ LANGUAGE plpgsql;
SELECT inverser (' toto ') ;
inverser
---------- otot
La fonction suivante calcule la date de Pâques d’une année :
CREATE OR REPLACE FUNCTION paques (annee integer)
date
RETURNS AS $$
DECLARE
integer ;
a integer ;
b date ;
r BEGIN
:= (19*(annee % 19) + 24) % 30 ;
a := (2*(annee % 4) + 4*(annee % 7) + 6*a + 5) % 7 ;
b 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.
SELECT paques (n) FROM generate_series (2018, 2025) n ;
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)
date
RETURNS AS $$
DECLARE
date;
r 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)
date
RETURNS AS $$
SELECT (paques (annee) + INTERVAL '39 days')::date ;
$$ LANGUAGE sql;
Test :
SELECT paques (n), ascension(n) FROM generate_series (2018, 2025) n ;
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 (
integer,
annee boolean DEFAULT false
alsace_moselle record
) RETURNS SETOF AS $$
DECLARE
integer;
f record;
r 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 :
SELECT *
FROM vacances(2020, true) AS (libelle text, jour date)
ORDER BY jour ;
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(
integer,
annee boolean DEFAULT false,
alsace_moselle OUT libelle text,
OUT jour date)
record
RETURNS SETOF
LANGUAGE plpgsqlAS $function$
DECLARE
integer;
f record;
r 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 :
SELECT *
FROM vacances(2020)
ORDER BY jour ;
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(
integer,alsace_moselle boolean DEFAULT false)
annee TABLE (libelle text, jour date)
RETURNS
LANGUAGE plpgsqlAS $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)
TABLE (libelle text, jour date)
RETURNS
LANGUAGE plpgsqlAS $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. La base magasin (dump de 96 Mo, pour 667 Mo sur le disque au final) peut être téléchargée et restaurée comme suit dans une nouvelle base magasin :
createdb magasin
curl -kL https://dali.bo/tp_magasin -o /tmp/magasin.dump
pg_restore -d magasin /tmp/magasin.dump
# le message sur public préexistant est normal
rm -- /tmp/magasin.dump
Toutes les données sont dans deux schémas nommés magasin et facturation.
É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 :
SELECT * FROM produits WHERE longueur * hauteur * largeur < 1000000 ;
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 :
CREATE INDEX ON produits((longueur * hauteur * largeur));
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)
numeric
RETURNS AS $$
SELECT p.longueur * p.hauteur * p.largeur;
$$ language SQLPARALLEL 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 :
CREATE INDEX ON produits (volume(produits)) ;
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 :
SELECT * FROM produits WHERE largeur * longueur * hauteur < 1000000 ;
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 :
SELECT * FROM produits WHERE produits.volume < 1000000;
L’utilisation du mot clé VARIADIC
dans la déclaration
des routines permet d’utiliser un nombre variable d’arguments dans la
mesure où tous les arguments optionnels sont du même type de données.
Ces arguments sont passés à la fonction sous forme de tableau
d’arguments du même type.
VARIADIC tableau text[]
Il n’est pas possible d’utiliser d’autres arguments en entrée à la
suite d’un paramètre VARIADIC
.
Quelques explications sur cette fonction :
generate_subscript()
prend un tableau en premier
paramètre et la dimension de ce tableau (un tableau peut avoir plusieurs
dimensions), et elle retourne une série d’entiers allant du premier au
dernier indice du tableau dans cette dimensiongenerate_subscripts
est une SRF
(set-returning function
, retourne un SETOF
), g
est donc le nom de l’alias de table, et i le nom de l’alias de
colonne.En PL/pgSQL, il est possible d’utiliser une boucle
FOREACH
pour parcourir directement le tableau des arguments
optionnels.
CREATE OR REPLACE FUNCTION pluspetit(VARIADIC liste numeric[])
numeric
RETURNS
LANGUAGE plpgsqlAS $function$
DECLARE
numeric;
courant numeric;
plus_petit BEGIN
IN ARRAY liste LOOP
FOREACH courant IF plus_petit IS NULL OR courant < plus_petit THEN
:= courant;
plus_petit END IF;
END LOOP;
RETURN plus_petit;
END
$function$;
Auparavant, il fallait développer le tableau avec la fonction
unnest()
pour réaliser la même opération.
CREATE OR REPLACE FUNCTION pluspetit(VARIADIC liste numeric[])
numeric
RETURNS
LANGUAGE plpgsqlAS $function$
DECLARE
numeric;
courant numeric;
plus_petit BEGIN
FOR courant IN SELECT unnest(liste) LOOP
IF plus_petit IS NULL OR courant < plus_petit THEN
:= courant;
plus_petit END IF;
END LOOP;
RETURN plus_petit;
END
$function$;
Pour pouvoir utiliser la même fonction en utilisant des types
différents, il est nécessaire de la redéfinir avec les différents types
autorisés en entrée. Par exemple, pour autoriser l’utilisation de
données de type integer
ou float
en entrée et
retournés par une même fonction, il faut la dupliquer.
CREATE OR REPLACE FUNCTION
integer, var2 integer)
addition(var1 integer
RETURNS AS $$
DECLARE
integer;
somme BEGIN
:= var1 + var2;
somme RETURN somme;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION
float, var2 float)
addition(var1 float
RETURNS AS $$
DECLARE
float;
somme BEGIN
:= var1 + var2;
somme RETURN somme;
END;
$$ LANGUAGE plpgsql;
L’utilisation de types polymorphes permet d’éviter ce genre de duplications fastidieuses.
L’opérateur +
étant défini pour les entiers comme pour
les numeric
, la fonction ne pose aucun problème pour ces
deux types de données, et retourne une donnée du même type que les
données d’entrée.
Le typage n’étant connu qu’à l’exécution, c’est aussi à ce moment que se déclenchent les erreurs.
De même, l’affectation du type unique pour tous les éléments se fait sur la base du premier élément, ainsi :
# SELECT addition(1, 3.5);
ERROR: function addition(integer, numeric) does not exist
LIGNE 1 : SELECT addition(1, 3.5);
^
ASTUCE : No function matches the given name and argument types.
You might need to add explicit type casts.
génère une erreur car du premier argument est déduit le type
integer
, ce qui n’est évidement pas le cas du deuxième. Il
peut donc être nécessaire d’utiliser une conversion explicite pour
résoudre ce genre de problématique.
# SELECT addition(1::numeric, 3.5);
addition
----------
4.5
Un trigger est une spécification précisant que la base de données
doit exécuter une fonction particulière quand un certain type
d’opération est traité. Les fonctions trigger peuvent être définies pour
s’exécuter avant ou après une commande INSERT
,
UPDATE
, DELETE
ou TRUNCATE
.
La fonction trigger doit être définie avant que le trigger lui-même
puisse être créé. La fonction trigger doit être déclarée comme une
fonction ne prenant aucun argument et retournant un type
trigger
.
Une fois qu’une fonction trigger est créée, le trigger est créé avec
CREATE TRIGGER
. La même fonction trigger est utilisable par
plusieurs triggers.
Un trigger TRUNCATE
ne peut utiliser que le mode par
instruction, contrairement aux autres triggers pour lesquels vous avez
le choix entre « par ligne » et « par instruction ».
Enfin, l’instruction COPY
est traitée comme s’il
s’agissait d’une commande INSERT
.
À noter que les problématiques de visibilité et de volatilité depuis un trigger sont assez complexes dès lors que l’on lit ou modifie les données. Voir la documentation pour plus de détails à ce sujet.
Vous pourriez aussi rencontrer dans du code la variable
TG_RELNAME
. C’est aussi le nom de la table qui a déclenché
le trigger. Attention, cette variable est obsolète, il est préférable
d’utiliser maintenant TG_TABLE_NAME
.
La fonction trigger est déclarée sans arguments mais il est possible de lui en passer dans la déclaration du trigger. Dans ce cas, il faut utiliser les deux variables ci-dessus pour y accéder. Attention, tous les arguments sont convertis en texte. Il faut donc se cantonner à des informations simples, sous peine de compliquer le code.
CREATE OR REPLACE FUNCTION verifier_somme()
trigger AS $$
RETURNS DECLARE
integer;
fact_limit varchar;
arg_color BEGIN
:= TG_ARGV[0];
fact_limit
IF NEW.somme > fact_limit THEN
'La facture % necessite une verification. '
RAISE NOTICE 'La somme % depasse la limite autorisee de %.',
NEW.idfact, NEW.somme, fact_limit;
END IF;
NEW.datecreate := current_timestamp;
return NEW;
END;
$$
LANGUAGE plpgsql;
CREATE TRIGGER trig_verifier_debit
BEFORE INSERT OR UPDATE ON test
FOR EACH ROW
EXECUTE PROCEDURE verifier_somme(400);
CREATE TRIGGER trig_verifier_credit
BEFORE INSERT OR UPDATE ON test
FOR EACH ROW
EXECUTE PROCEDURE verifier_somme(800);
Une fonction trigger retourne le type spécial trigger
.
Pour cette raison, ces fonctions ne peuvent être utilisées que dans le
contexte d’un ou plusieurs triggers. Pour pouvoir être utilisée comme
valeur de retour dans la fonction (avec RETURN
), une
variable doit être de structure identique à celle de la table sur
laquelle le trigger a été déclenché. Les variables spéciales
OLD
(ancienne valeur avant application de l’action à
l’origine du déclenchement) et NEW
(nouvelle valeur après
application de l’action) sont également disponibles, utilisables et même
modifiables.
La valeur de retour d’un trigger de type ligne (ROW
)
déclenché avant l’opération (BEFORE
) peut changer
complètement l’effet de la commande ayant déclenché le trigger. Par
exemple, il est possible d’annuler complètement l’action sans erreur (et
d’empêcher également tout déclenchement ultérieur d’autres triggers pour
cette même action) en retournant NULL
. Il est également
possible de changer les valeurs de la nouvelle ligne créée par une
action INSERT
ou UPDATE
en retournant une des
valeurs différentes de NEW
(ou en modifiant
NEW
directement). Attention, dans le cas d’une fonction
trigger BEFORE
déclenchée par une action
DELETE
, in faut prendre en compte que NEW
contient NULL
, en conséquence RETURN NEW;
provoquera l’annulation du DELETE
! Dans ce cas, si on
désire laisser l’action inchangée, la convention est de faire un
RETURN OLD;
.
En revanche, la valeur de retour utilisée n’a pas d’effet dans les
cas des triggers ROW
et AFTER
, et des triggers
STATEMENT
. À noter que bien que la valeur de retour soit
ignorée dans ce cas, il est possible d’annuler l’action d’un trigger de
type ligne intervenant après l’opération ou d’un trigger à l’instruction
en remontant une erreur à l’exécution de la fonction.
UPDATE
.
Encore un moyen de simplifier le code et de gagner en performances en
évitant les déclenchements inutiles.INSTEAD OF
, qui permet de programmer de façon efficace les
INSERT
/UPDATE
/DELETE
/TRUNCATE
sur les vues. Auparavant, il fallait passer par le système de règles
(RULES
), complexe et sujet à erreurs.Dans le cas d’un trigger en mode instruction, il n’est pas possible
d’utiliser les variables OLD
et NEW
car elles
ciblent une seule ligne. Pour cela, le standard SQL parle de tables de
transition.
La version 10 de PostgreSQL permet donc de rattraper le retard à ce sujet par rapport au standard SQL et SQL Server.
Voici un exemple de leur utilisation.
Nous allons créer une table t1 qui aura le trigger et une table archives qui a pour but de récupérer les enregistrements supprimés de la table t1.
CREATE TABLE t1 (c1 integer, c2 text);
CREATE TABLE archives (id integer GENERATED ALWAYS AS IDENTITY,
timestamp DEFAULT now(),
dlog integer, t1_c2 text); t1_c1
Maintenant, il faut créer le code de la procédure stockée :
CREATE OR REPLACE FUNCTION log_delete() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO archives (t1_c1, t1_c2) SELECT c1, c2 FROM oldtable;
RETURN null;
END
$$;
Et ajouter le trigger sur la table t1 :
CREATE TRIGGER tr1
AFTER DELETE ON t1
REFERENCING OLD TABLE AS oldtable
FOR EACH STATEMENT
EXECUTE PROCEDURE log_delete();
Maintenant, insérons un million de ligne dans t1 et supprimons-les :
INSERT INTO t1 SELECT i, 'Ligne '||i FROM generate_series(1, 1000000) i;
DELETE FROM t1;
Time: 2141.871 ms (00:02.142)
La suppression avec le trigger prend 2 secondes. Il est possible de
connaître le temps à supprimer les lignes et le temps à exécuter le
trigger en utilisant l’ordre EXPLAIN ANALYZE
:
TRUNCATE archives;
INSERT INTO t1 SELECT i, 'Ligne '||i FROM generate_series(1, 1000000) i;
EXPLAIN (ANALYZE) DELETE FROM t1;
QUERY PLAN
--------------------------------------------------------------------------
Delete on t1 (cost=0.00..14241.98 rows=796798 width=6)
time=781.612..781.612 rows=0 loops=1)
(actual -> Seq Scan on t1 (cost=0.00..14241.98 rows=796798 width=6)
time=0.113..104.328 rows=1000000 loops=1)
(actual time: 0.079 ms
Planning Trigger tr1: time=1501.688 calls=1
time: 2287.907 ms
Execution 5 rows) (
Donc la suppression des lignes met 0,7 seconde alors que l’exécution du trigger met 1,5 seconde.
Pour comparer, voici l’ancienne façon de faire (configuration d’un trigger en mode ligne) :
CREATE OR REPLACE FUNCTION log_delete() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO archives (t1_c1, t1_c2) VALUES (old.c1, old.c2);
RETURN null;
END
$$;
DROP TRIGGER tr1 ON t1;
CREATE TRIGGER tr1
AFTER DELETE ON t1
FOR EACH ROW
EXECUTE PROCEDURE log_delete();
TRUNCATE archives;
TRUNCATE t1;
INSERT INTO t1 SELECT i, 'Ligne '||i FROM generate_series(1, 1000000) i;
DELETE FROM t1;
Time: 8445.697 ms (00:08.446)
TRUNCATE archives;
INSERT INTO t1 SELECT i, 'Ligne '||i FROM generate_series(1, 1000000) i;
EXPLAIN (ANALYZE) DELETE FROM t1;
QUERY PLAN
--------------------------------------------------------------------------
Delete on t1 (cost=0.00..14241.98 rows=796798 width=6)
time=1049.420..1049.420 rows=0 loops=1)
(actual -> Seq Scan on t1 (cost=0.00..14241.98 rows=796798 width=6)
time=0.061..121.701 rows=1000000 loops=1)
(actual time: 0.096 ms
Planning Trigger tr1: time=7709.725 calls=1000000
time: 8825.958 ms
Execution 5 rows) (
Donc avec un trigger en mode ligne, la suppression du million de lignes met presque 9 secondes à s’exécuter, dont 7,7 pour l’exécution du trigger. Sur le trigger en mode instruction, il faut compter 2,2 secondes, dont 1,5 sur le trigger. Les tables de transition nous permettent de gagner en performance.
Le gros intérêt des tables de transition est le gain en performance que cela apporte.
À noter que la notion de curseur existe aussi en SQL pur, sans passer
par une routine PL/pgSQL. On les crée en utilisant la commande
DECLARE
, et les règles de manipulation sont légèrement
différentes (on peut par exemple créer un curseur
WITH HOLD
, qui persistera après la fin de la transaction).
Voir la documentation pour plus d’informations à ce sujet :
https://docs.postgresql.fr/current/sql-declare.html
La première forme permet la création d’un curseur non lié à une requête.
Voici un exemple de lien entre une requête et un curseur :
OPEN curseur FOR SELECT * FROM ma_table;
Et voici un exemple d’utilisation d’une requête dynamique :
OPEN curseur FOR EXECUTE 'SELECT * FROM ' || quote_ident(TG_TABLE_NAME);
Attention, ces différentes syntaxes ne modifient pas les données dans le curseur en mémoire, mais font réellement la modification dans la table. L’emplacement actuel du curseur est utilisé ici pour identifier la ligne correspondante à mettre à jour.
Voici un exemple d’utilisation d’une référence de curseur retournée par une fonction :
CREATE FUNCTION consult_all_stock(refcursor) RETURNS refcursor AS $$
BEGIN
OPEN $1 FOR SELECT * FROM stock;
RETURN $1;
END;
$$ LANGUAGE plpgsql;
-- doit être dans une transaction pour utiliser les curseurs.
BEGIN;
SELECT * FROM consult_all_stock('cursor_a');
ALL FROM cursor_a;
FETCH COMMIT;
Toutes les erreurs sont référencées dans la documentation
Attention, des codes d’erreurs nouveaux apparaissent à chaque version.
La classe data_exception contient de nombreuses erreurs, comme datetime_field_overflow, invalid_escape_character, invalid_binary_representation… On peut donc, dans la déclaration de l’exception, intercepter toutes les erreurs de type data_exception d’un coup, ou une par une.
L’instruction GET STACKED DIAGNOSTICS
permet d’avoir une
vision plus précise de l’erreur récupéré par le bloc de traitement des
exceptions. La liste de toutes les informations que l’on peut collecter
est disponible dans
la documentation .
La démonstration ci-dessous montre comment elle peut être utilisée.
CREATE TABLE t5(c1 integer PRIMARY KEY);
# CREATE TABLE
INSERT INTO t5 VALUES (1);
# INSERT 0 1
CREATE OR REPLACE FUNCTION test(INT4) RETURNS void AS $$
# DECLARE
v_state TEXT;
v_msg TEXT;
v_detail TEXT;
v_hint TEXT;
v_context TEXT;BEGIN
BEGIN
INSERT INTO t5 (c1) VALUES ($1);
EXCEPTION WHEN others THEN
GET STACKED DIAGNOSTICS= RETURNED_SQLSTATE,
v_state = MESSAGE_TEXT,
v_msg = PG_EXCEPTION_DETAIL,
v_detail = PG_EXCEPTION_HINT,
v_hint = PG_EXCEPTION_CONTEXT;
v_context 'Et une exception :
raise notice E state : %
message: %
detail : %
hint : %
context: %', v_state, v_msg, v_detail, v_hint, v_context;
END;
RETURN;
END;
$$ LANGUAGE plpgsql;SELECT test(2);
#
test------
1 row)
(
SELECT test(2);
# exception :
NOTICE: Et une 23505
state : key value violates unique constraint "t5_pkey"
message: duplicate Key (c1)=(2) already exists.
detail :
hint :context: SQL statement "INSERT INTO t5 (c1) VALUES ($1)"
/pgSQL function test(integer) line 10 at SQL statement
PL
test------
1 row) (
Il convient de noter qu’un message envoyé de cette manière ne fera
pas partie de l’éventuel résultat d’une fonction, et ne sera donc pas
exploitable en SQL. Pour cela, il faut utiliser l’instruction
RETURN
avec un type de retour approprié.
Le traitement des messages de ce type et leur destination d’envoi
sont contrôlés par le serveur à l’aide des paramètres
log_min_messages
et client_min_messages
.
Les autres niveaux pour RAISE
ne sont que des messages,
sans déclenchement d’exception.
Le rôle d’une exception est d’intercepter une erreur pour exécuter un traitement permettant soit de corriger l’erreur, soit de remonter une erreur pertinente. Intercepter un problème pour retourner « erreur interne » n’est pas une bonne idée.
Démonstration en plusieurs étapes :
CREATE TABLE ma_table (
# id integer unique
);CREATE TABLE
CREATE OR REPLACE FUNCTION public.demo_exception()
#
RETURNS void
LANGUAGE plpgsqlAS $function$
DECLARE
BEGIN
INSERT INTO ma_table VALUES (1);
-- Va déclencher une erreur de violation de contrainte d'unicité
INSERT INTO ma_table VALUES (1);
END
$function$;CREATE FUNCTION
SELECT demo_exception();
# key value violates unique constraint "ma_table_id_key"
ERROR: duplicate Key (id)=(1) already exists.
DETAIL: CONTEXT: SQL statement "INSERT INTO ma_table VALUES (1)"
/pgSQL function demo_exception() line 6 at SQL statement PL
Une exception a été remontée avec un message explicite.
SELECT * FROM ma_table ;
#
a---
0 row) (
La fonction a bien été annulée.
CREATE OR REPLACE FUNCTION public.demo_exception()
#
RETURNS void
LANGUAGE plpgsqlAS $function$
DECLARE
BEGIN
INSERT INTO ma_table VALUES (1);
-- Va déclencher une erreur de violation de contrainte d'unicité
INSERT INTO ma_table VALUES (1);
EXCEPTION WHEN unique_violation THEN
'violation d''unicite, mais celle-ci n''est pas grave';
RAISE NOTICE 'erreur: %',sqlerrm;
RAISE NOTICE END
$function$;CREATE FUNCTION
SELECT demo_exception();
# 'unicite, mais celle-ci n'est pas grave
NOTICE: violation dkey value violates unique constraint "ma_table_id_key"
NOTICE: erreur: duplicate
demo_exception----------------
1 row) (
L’erreur est bien devenue un message de niveau
NOTICE
.
SELECT * FROM ma_table ;
#
a---
0 row) (
La table n’en reste pas moins vide pour autant puisque le bloc a été annulé.
Voici une nouvelle version de la fonction :
CREATE OR REPLACE FUNCTION public.demo_exception()
#
RETURNS void
LANGUAGE plpgsqlAS $function$
DECLARE
BEGIN
INSERT INTO ma_table VALUES (1);
-- L'operation suivante pourrait échouer.
-- Il ne faut pas perdre le travail effectué jusqu'à ici
BEGIN
-- Va déclencher une erreur de violation de contrainte d'unicité
INSERT INTO ma_table VALUES (1);
EXCEPTION WHEN unique_violation THEN
-- Cette exception est bien celle du bloc imbriqué
'violation d''unicite, mais celle-ci n''est pas grave';
RAISE NOTICE 'erreur: %',sqlerrm;
RAISE NOTICE END; -- Fin du bloc imbriqué
END
$function$;CREATE FUNCTION
SELECT demo_exception();
# 'unicite, mais celle-ci n'est pas grave
NOTICE: violation dkey value violates unique constraint "ma_table_id_key"
NOTICE: erreur: duplicate
demo_exception----------------
1 row) (
En apparence, le résultat est identique.
SELECT * FROM ma_table ;
#
a---
1
1 row) (
Mais cette fois-ci, le bloc BEGIN
parent n’a pas eu
d’exception, il s’est donc bien terminé.
On commence par ajouter une contrainte sur la colonne pour empêcher les valeurs supérieures ou égales à 10 :
ALTER TABLE ma_table ADD CHECK (id < 10 ) ;
# ALTER TABLE
Puis, on recrée la fonction de façon à ce qu’elle déclenche cette erreur dans le bloc le plus bas, et la gère uniquement dans le bloc parent :
CREATE OR REPLACE FUNCTION public.demo_exception()
RETURNS void
LANGUAGE plpgsqlAS $function$
DECLARE
BEGIN
INSERT INTO ma_table VALUES (1);
-- L'operation suivante pourrait échouer.
-- Il ne faut pas perdre le travail effectué jusqu'à ici
BEGIN
-- Va déclencher une erreur de violation de check (col < 10)
INSERT INTO ma_table VALUES (100);
EXCEPTION WHEN unique_violation THEN
-- Cette exception est bien celle du bloc imbriqué
'violation d''unicite, mais celle-ci n''est pas grave';
RAISE NOTICE 'erreur: %',sqlerrm;
RAISE NOTICE END; -- Fin du bloc imbriqué
EXCEPTION WHEN check_violation THEN
'violation de contrainte check';
RAISE NOTICE EXCEPTION 'mais on va remonter une exception à l''appelant, '
RAISE 'juste pour le montrer';
END
$function$;
Exécutons la fonction :
SELECT demo_exception();
# key value violates unique constraint "ma_table_id_key"
ERROR: duplicate Key (id)=(1) already exists.
DETAIL: CONTEXT: SQL statement "INSERT INTO ma_table VALUES (1)"
/pgSQL function demo_exception() line 4 at SQL statement PL
C’est normal, nous avons toujours l’enregistrement à 1 du test précédent. L’exception se déclenche donc dans le bloc parent, sans espoir d’interception: nous n’avons pas d’exception pour lui.
Nettoyons donc la table, pour reprendre le test :
TRUNCATE ma_table ;
# TRUNCATE TABLE
SELECT demo_exception();
# check
NOTICE: violation de contrainte on va remonter une exception à l'appelant, juste pour le montrer
ERREUR: mais CONTEXT: PL/pgSQL function demo_exception() line 17 at RAISE
Le gestionnaire d’exception qui intercepte l’erreur est bien ici celui de l’appelant. Par ailleurs, comme nous retournons nous-même une exception, la requête ne retourne pas de résultat, mais une erreur : il n’y a plus personne pour récupérer l’exception, c’est donc PostgreSQL lui-même qui s’en charge.
Rappelons qu’une fonction s’exécute par défaut avec les droits
d’accès de l’utilisateur qui l’exécute ; et que pour donner accès à ses
données, un utilisateur peut mettre à disposition des autres des
fonctions en mode SECURITY DEFINER
qui profiteront de ses
droits. Il est impératif de réinitialiser l’environnement et en
particulier le search_path
pour éviter des attaques de la
part d’utilisateurs malveillants, et de restreindre au maximum l’accès à
ces fonctions.
Certains utilisateurs créent des vues pour filtrer des lignes, afin
de restreindre la visibilité sur certaines données. Or, cela peut se
révéler dangereux si un utilisateur malintentionné a la possibilité de
créer une fonction car il peut facilement contourner cette sécurité si
cette option n’est pas utilisée, notamment en jouant sur des paramètres
de fonction comme COST
, qui permet d’indiquer au
planificateur un coût estimé pour la fonction.
En indiquant un coût extrêmement faible, le planificateur aura
tendance à réécrire la requête, et à déplacer l’exécution de la fonction
dans le code même de la vue, avant l’application des filtres
restreignant l’accès aux données : la fonction a donc accès a tout le
contenu de la table, et peut faire fuiter des données normalement
inaccessibles, par exemple à travers l’utilisation de la commande
RAISE
.
L’option security_barrier
des vues dans PostgreSQL
bloque ce comportement du planificateur, mais en conséquence empêche le
choix de plans d’exécutions potentiellement plus performants. Déclarer
une fonction avec l’option LEAKPROOF
permet d’indiquer à
PostgreSQL que celle-ci ne peut pas occasionner de fuite d’informations.
Ainsi, le planificateur de PostgreSQL sait qu’il peut en optimiser
l’exécution. Cette option n’est accessible qu’aux superutilisateurs.
La méta-commande psql \df+ public.addition
permet
également d’obtenir cette information.
Voici un exemple simple :
CREATE TABLE ma_table_secrete1 (b integer, a integer);
INSERT INTO ma_table_secrete1 SELECT i,i from generate_series(1,20) i;
CREATE OR REPLACE FUNCTION demo_injection ( param1 text, value1 text )
RETURNS SETOF ma_table_secrete1
LANGUAGE plpgsqlDEFINER
SECURITY AS $function$
-- Cette fonction prend un nom de colonne variable
-- et l'utilise dans une clause WHERE
-- Il faut donc une requête dynamique
-- Par contre, mon utilisateur 'normal' qui appelle
-- n'a droit qu'aux enregistrements où a<10
DECLARE
ma_requete text;record;
ma_ligne BEGIN
:= 'SELECT * FROM ma_table_secrete1 WHERE ' || param1 || ' = ' ||
ma_requete || ' AND a < 10';
value1 RETURN QUERY EXECUTE ma_requete;
END
$function$;
SELECT * from demo_injection ('b','2');
#
a | b---+---
2 | 2
1 row)
(
SELECT * from demo_injection ('a','20');
#
a | b---+---
0 row) (
Tout va bien, elle effectue ce qui est demandé.
Par contre, elle effectue aussi ce qui n’est pas prévu :
SELECT * from demo_injection ('1=1 --','');
#
a | b-----+-----
1 | 1
2 | 2
3 | 3
4 | 4
5 | 5
6 | 6
7 | 7
8 | 8
9 | 9
10 | 10
11 | 11
12 | 12
13 | 13
14 | 14
15 | 15
16 | 16
17 | 17
18 | 18
19 | 19
20 | 20
20 lignes) (
Cet exemple est évidemment simplifié.
Une règle demeure : ne jamais faire confiance aux paramètres d’une
fonction. Au minimum, un quote_ident
pour param1 et un
quote_literal
pour val1 étaient obligatoires, pour se
protéger de ce genre de problèmes.
Les fonctions de ce type sont susceptibles de renvoyer un résultat
différent à chaque appel, comme par exemple random()
ou
setval()
.
Toute fonction ayant des effets de bords doit être qualifiée
volatile
dans le but d’éviter que PostgreSQL utilise un
résultat intermédiaire déjà calculé et évite ainsi d’exécuter le code de
la fonction.
À noter qu’il est possible de « forcer » le pré-calcul du résultat
d’une fonction volatile dans une requête SQL en utilisant une
sous-requête. Par exemple, dans l’exemple suivant, random()
est exécutée pour chaque ligne de la table ma_table
, et
renverra donc une valeur différente par ligne :
SELECT random() FROM ma_table;
Par contre, en utilisant une sous-requête, l’optimiseur va
pré-calculer le résultat de random()
… l’exécution sera donc
plus rapide, mais le résultat différent, puisque la même valeur sera
affichée pour toutes les lignes !
SELECT ( SELECT random() ) FROM ma_table;
Certaines fonctions que l’on écrit sont déterministes. C’est-à-dire qu’à paramètre(s) identique(s), le résultat est identique.
Le résultat de telles fonctions est alors remplaçable par son résultat avant même de commencer à planifier la requête.
Voici un exemple qui utilise cette particularité :
create function factorielle (a integer) returns bigint as
$$declare
result bigint;begin
if a=1 then
return 1;
else
return a*(factorielle(a-1));
end if;
end;
$$ language plpgsql immutable;
CREATE TABLE test (a bigint UNIQUE);
# CREATE TABLE
INSERT INTO test SELECT generate_series(1,1000000);
# INSERT 0 1000000
ANALYZE test;
# EXPLAIN ANALYZE SELECT * FROM test WHERE a < factorielle(12);
# QUERY PLAN
--------------------------------------------------------------------
Scan on test (cost=0.00..16925.00 rows=1000000 width=8)
Seq time=0.032..130.921 rows=1000000 loops=1)
(actual Filter: (a < '479001600'::bigint)
time: 896.039 ms
Planning time: 169.954 ms
Execution 4 rows) (
La fonction est exécutée une fois, remplacée par sa constante, et la requête est ensuite planifiée.
Si on déclare la fonction comme stable :
EXPLAIN ANALYZE SELECT * FROM test WHERE a < factorielle(12);
# QUERY PLAN
----------------------------------------------------------
Index Only Scan using test_a_key on test
cost=0.68..28480.67 rows=1000000 width=8)
(time=0.137..115.592 rows=1000000 loops=1)
(actual Index Cond: (a < factorielle(12))
Heap Fetches: 0
time: 4.682 ms
Planning time: 153.762 ms
Execution 5 rows) (
La requête est planifiée sans connaître factorielle(12)
,
donc avec une hypothèse très approximative sur la cardinalité.
factorielle(12)
est calculé, et la requête est exécutée.
Grâce au Index Only Scan
, le requête s’effectue
rapidement.
Si on déclare la fonction comme volatile :
EXPLAIN ANALYZE SELECT * FROM test WHERE a < factorielle(12);
# QUERY PLAN
-----------------------------------------------------------------------
Scan on test (cost=0.00..266925.00 rows=333333 width=8)
Seq time=1.005..57519.702 rows=1000000 loops=1)
(actual Filter: (a < factorielle(12))
time: 0.388 ms
Planning time: 57573.508 ms
Execution 4 rows) (
La requête est planifiée, et factorielle(12) est calculé pour chaque enregistrement de la table, car on ne sait pas si elle retourne toujours le même résultat.
Ces fonctions retournent la même valeur pour la même requête SQL, mais peuvent retourner une valeur différente dans la prochaine instruction.
Il s’agit typiquement de fonctions dont le traitement dépend d’autres
valeurs dans la base de données, ou bien de réglages de configuration.
Les fonctions comme to_char()
, to_date()
sont
STABLE
et non IMMUTABLE
car des paramètres de
configuration (locale utilisée pour to_char()
,
timezone pour les fonctions temporelles, etc.) pourraient
influer sur le résultat.
À noter au passage que les fonctions de la famille de
current_timestamp
(et donc le fréquemment utilisé
now()
) renvoient de plus une valeur constante au sein d’une
même transaction.
PostgreSQL refusera de déclarer comme STABLE
toute
fonction modifiant des données : elle ne peut pas être stable si elle
modifie la base.
Les fonctions définies comme STRICT
ou
RETURNS NULL ON NULL INPUT
annule l’exécution de la requête
si l’un des paramètres passés est NULL
. Dans ce cas, la
fonction est considérée comme ayant renvoyé NULL
.
Si l’on reprend l’exemple de la fonction
factorielle()
:
create or replace function factorielle (a integer) returns bigint as
$$declare
result bigint;begin
if a=1 then
return 1;
else
return a*(factorielle(a-1));
end if;
end;
$$ language plpgsql immutable STRICT;
on obtient le résultat suivant si elle est exécutée avec la valeur
NULL
passée en paramètre :
EXPLAIN ANALYZE SELECT * FROM test WHERE a < factorielle(NULL);
# QUERY PLAN
---------------------------------------------------
cost=0.00..0.00 rows=0 width=8)
Result (time=0.002..0.002 rows=0 loops=1)
(actual -Time Filter: false
Onetime: 0.100 ms
Planning time: 0.039 ms
Execution 4 rows) (
Avant la version 9.2, un plan générique (indépendant des paramètres de l’ordre SQL) était systématiquement généré et utilisé. Ce système permet de gagner du temps d’exécution si la requête est réutilisée plusieurs fois, et qu’elle est coûteuse à planifier.
Toutefois, un plan générique n’est pas forcément idéal dans toutes les situations, et peut conduire à des mauvaises performances.
Par exemple :
SELECT * FROM ma_table WHERE col_pk = param_function ;
est un excellent candidat à être écrit statiquement : le plan sera toujours le même : on attaque l’index de la clé primaire pour trouver l’enregistrement.
SELECT * FROM ma_table WHERE col_timestamp > param_function ;
est un moins bon candidat : le plan, idéalement, dépend de param_function : on ne parcourt pas la même fraction de la table suivant la valeur de param_function.
Par défaut, un plan générique ne sera utilisé dès la première exécution d’une requête statique que si celle-ci ne dépend d’aucun paramètre. Dans le cas contraire, cela ne se produira qu’au bout de plusieurs exécutions de la requête, et seulement si le planificateur détermine que les plans spécifiques utilisés n’apportent pas d’avantage par rapport au plan générique.
L’écriture d’une requête dynamique est par contre un peu plus
pénible, puisqu’il faut fabriquer un ordre SQL, puis le passer en
paramètre à EXECUTE
, avec tous les quote_* que cela
implique pour en protéger les paramètres.
Pour se faciliter la vie, on peut utiliser
EXECUTE query USING param1, param2 …,
qui est même
quelquefois plus lisible que la syntaxe en dur : les paramètres de la
requête sont clairement identifiés dans cette syntaxe.
Par contre, la syntaxe USING
n’est utilisable que si le
nombre de paramètres est fixe.
La limite est difficile à placer, il s’agit de faire un compromis entre le temps de planification d’une requête (quelques dizaines de microsecondes pour une requête basique à potentiellement plusieurs secondes si on dépasse la dizaine de jointures) et le temps d’exécution.
Dans le doute, réalisez un test de performance de la fonction sur un jeu de données représentatif.
Tous les outils d’administration PostgreSQL permettent d’écrire des routines stockées en PL/pgSQL, la plupart avec les fonctionnalités habituelles (comme le surlignage des mots clés, l’indentation automatique, etc.).
Par contre, pour aller plus loin, l’offre est restreinte. Il existe tout de même un debugger qui fonctionne avec pgAdmin 4, sous la forme d’une extension.
pldebugger est un outil initialement créé par Dave Page et Korry Douglas au sein d’EnterpriseDB, repris par la communauté. Il est proposé sous license libre (Artistic 2.0).
Il fonctionne grâce à des hooks implémentés dans la version 8.2 de PostgreSQL.
Il est assez peu connu, ce qui explique que peu l’utilisent. Seul l’outil d’installation « one-click installer » l’installe par défaut. Pour tous les autres systèmes, cela réclame une compilation supplémentaire. Cette compilation est d’ailleurs peu aisée étant donné qu’il n’utilise pas le système pgxs.
Voici les étapes à réaliser pour compiler pldebugger en prenant pour
hypothèse que les sources de PostgreSQL sont disponibles dans le
répertoire /usr/src/postgresql-10
et qu’ils ont été
préconfigurés avec la commande ./configure
:
$ cd /usr/src/postgresql-10/contrib
$ git clone git://git.postgresql.org/git/pldebugger.git
Cloning into 'pldebugger'...
remote: Counting objects: 441, done.
remote: Compressing objects: 100% (337/337), done.
remote: Total 441 (delta 282), reused 171 (delta 104)
Receiving objects: 100% (441/441), 170.24 KiB, done.
Resolving deltas: 100% (282/282), done.
pldebugger
:$ cd pldebugger
$ make
# make install
L’installation copie le fichier plugin_debugger.so
dans
le répertoire des bibliothèques partagées de PostgreSQL. L’installation
copie ensuite les fichiers SQL et de contrôle de l’extension
pldbgapi
dans le répertoire extension
du
répertoire share
de PostgreSQL.
La configuration du paramètre shared_preload_libraries
permet au démarrage de PostgreSQL de laisser la bibliothèque
plugin_debugger
s’accrocher aux hooks de l’interpréteur
PL/pgSQL. Du coup, pour que la modification de ce paramètre soit prise
en compte, il faut redémarrer PostgreSQL.
L’interaction avec pldebugger se fait par l’intermédiaire de procédures stockées. Il faut donc au préalable créer ces procédures stockées dans la base contenant les procédures PL/pgSQL à débugguer. Cela se fait en créant l’extension :
$ psql13.0)
psql (Type "help" for help.
create extension pldbgapi;
postgres# CREATE EXTENSION
auto_explain
est une « contrib » officielle de
PostgreSQL (et non une extension). Il permet de tracer le plan d’une
requête. 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 est à -1 pour ne tracer aucun plan.
Comme dans un EXPLAIN
classique, on peut activer toutes
les options (par exemple ANALYZE
ou TIMING
avec, respectivement SET auto_explain.log_analyze TO true;
et 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
et auto_explain.log_settings
(voir la documentation).
L’exemple suivant utilise deux fonctions imbriquées mais cela marche pour une simple requête :
CREATE OR REPLACE FUNCTION table_nb_indexes (tabname IN text, nbi OUT int)
int
RETURNS
LANGUAGE plpgsqlAS $$
BEGIN
SELECT COUNT(*) INTO nbi
FROM pg_index i INNER JOIN pg_class c ON (c.oid=indrelid)
WHERE relname LIKE tabname ;
RETURN ;
END ;
$$
;CREATE OR REPLACE FUNCTION table_nb_col_indexes
IN text, nb_cols OUT int, nb_indexes OUT int)
(tabname record
RETURNS
LANGUAGE plpgsqlAS $$
BEGIN
SELECT COUNT(*) INTO nb_cols
FROM pg_attribute
WHERE attname LIKE tabname ;
SELECT nbi INTO nb_indexes FROM table_nb_indexes (tabname) ;
RETURN ;
END ;
$$ ;
Chargement dans la session d’auto_explain
(si pas déjà
présent dans shared_preload_libraries
) :
'auto_explain' ; LOAD
Activation pour toutes les requêtes, avec les options
ANALYZE
et BUFFERS
, puis affichage dans la
console (si la sortie dans les traces ne suffit pas) :
SET auto_explain.log_min_duration TO 0 ;
SET auto_explain.log_analyze TO on ;
SET auto_explain.log_buffers TO on ;
SET client_min_messages TO log ;
Test de la première fonction : le plan s’affiche, mais les compteurs (ici juste shared hit), ne concernent que la fonction dans son ensemble.
postgres=# SELECT * FROM table_nb_col_indexes ('pg_class') ;
LOG: duration: 2.208 ms plan:
Query Text: SELECT * FROM table_nb_col_indexes ('pg_class') ;
Function Scan on table_nb_col_indexes (cost=0.25..0.26 rows=1 width=8)
(actual time=2.203..2.203 rows=1 loops=1)
Buffers: shared hit=294
nb_cols | nb_indexes
---------+------------
0 | 3
En activant auto_explain.log_nested_statements
, on voit
clairement les plans de chaque requête exécutée :
SET auto_explain.log_nested_statements TO on ;
postgres=# SELECT * FROM table_nb_col_indexes ('pg_class') ;
LOG: duration: 0.235 ms plan:
Query Text: SELECT COUNT(*) FROM pg_attribute
WHERE attname LIKE tabname
Aggregate (cost=65.95..65.96 rows=1 width=8)
(actual time=0.234..0.234 rows=1 loops=1)
Buffers: shared hit=24
-> Index Only Scan using pg_attribute_relid_attnam_index on pg_attribute
(cost=0.28..65.94 rows=1 width=0)
(actual time=0.233..0.233 rows=0 loops=1)
Index Cond: ((attname >= 'pg'::text) AND (attname < 'ph'::text))
Filter: (attname ~~ 'pg_class'::text)
Heap Fetches: 0
Buffers: shared hit=24
LOG: duration: 0.102 ms plan:
Query Text: SELECT COUNT(*) FROM pg_index i
INNER JOIN pg_class c ON (c.oid=indrelid)
WHERE relname LIKE tabname
Aggregate (cost=24.48..24.49 rows=1 width=8)
(actual time=0.100..0.100 rows=1 loops=1)
Buffers: shared hit=18
-> Nested Loop (cost=0.14..24.47 rows=1 width=0)
(actual time=0.096..0.099 rows=3 loops=1)
Buffers: shared hit=18
-> Seq Scan on pg_class c (cost=0.00..23.30 rows=1 width=4)
(actual time=0.091..0.093 rows=1 loops=1)
Filter: (relname ~~ 'pg_class'::text)
Rows Removed by Filter: 580
Buffers: shared hit=16
-> Index Only Scan using pg_index_indrelid_index on pg_index i
(cost=0.14..1.16 rows=1 width=4)
(actual time=0.003..0.004 rows=3 loops=1)
Index Cond: (indrelid = c.oid)
Heap Fetches: 0
Buffers: shared hit=2
LOG: duration: 0.703 ms plan:
Query Text: SELECT nbi FROM table_nb_indexes (tabname)
Function Scan on table_nb_indexes (cost=0.25..0.26 rows=1 width=4)
(actual time=0.702..0.702 rows=1 loops=1)
Buffers: shared hit=26
LOG: duration: 1.524 ms plan:
Query Text: SELECT * FROM table_nb_col_indexes ('pg_class') ;
Function Scan on table_nb_col_indexes (cost=0.25..0.26 rows=1 width=8)
(actual time=1.520..1.520 rows=1 loops=1)
Buffers: shared hit=59
nb_cols | nb_indexes
---------+------------
0 | 3
Cet exemple permet de mettre le doigt sur un petit problème de
performance dans la fonction : le _
est interprété comme
critère de recherche. En modifiant le paramètre on peut constater le
changement de plan au niveau des index :
postgres=# SELECT * FROM table_nb_col_indexes ('pg\_class') ;
LOG: duration: 0.141 ms plan:
Query Text: SELECT COUNT(*) FROM pg_attribute
WHERE attname LIKE tabname
Aggregate (cost=56.28..56.29 rows=1 width=8)
(actual time=0.140..0.140 rows=1 loops=1)
Buffers: shared hit=24
-> Index Only Scan using pg_attribute_relid_attnam_index on pg_attribute
(cost=0.28..56.28 rows=1 width=0)
(actual time=0.138..0.138 rows=0 loops=1)
Index Cond: (attname = 'pg_class'::text)
Filter: (attname ~~ 'pg\_class'::text)
Heap Fetches: 0
Buffers: shared hit=24
LOG: duration: 0.026 ms plan:
Query Text: SELECT COUNT(*) FROM pg_index i
INNER JOIN pg_class c ON (c.oid=indrelid)
WHERE relname LIKE tabname
Aggregate (cost=3.47..3.48 rows=1 width=8) (actual time=0.024..0.024 rows=1 loops=1)
Buffers: shared hit=8
-> Nested Loop (cost=0.42..3.47 rows=1 width=0) (…)
Buffers: shared hit=8
-> Index Scan using pg_class_relname_nsp_index on pg_class c
(cost=0.28..2.29 rows=1 width=4)
(actual time=0.017..0.018 rows=1 loops=1)
Index Cond: (relname = 'pg_class'::text)
Filter: (relname ~~ 'pg\_class'::text)
Buffers: shared hit=6
-> Index Only Scan using pg_index_indrelid_index on pg_index i (…)
Index Cond: (indrelid = c.oid)
Heap Fetches: 0
Buffers: shared hit=2
LOG: duration: 0.414 ms plan:
Query Text: SELECT nbi FROM table_nb_indexes (tabname)
Function Scan on table_nb_indexes (cost=0.25..0.26 rows=1 width=4)
(actual time=0.412..0.412 rows=1 loops=1)
Buffers: shared hit=16
LOG: duration: 1.046 ms plan:
Query Text: SELECT * FROM table_nb_col_indexes ('pg\_class') ;
Function Scan on table_nb_col_indexes (cost=0.25..0.26 rows=1 width=8)
(actual time=1.042..1.043 rows=1 loops=1)
Buffers: shared hit=56
nb_cols | nb_indexes
---------+------------
0 | 3
Pour les procédures, il est possible de mettre en place cette trace
avec
ALTER PROCEDURE … SET auto_explain.log_min_duration = 0
.
Cela ne fonctionne pas pour les fonctions.
pgBadger est capable de lire les plans tracés par
auto_explain
, de les intégrer à son rapport et d’inclure un
lien vers depesz.com pour une
version plus lisible.
Le menu contextuel pour accéder au débuggage d’une fonction :
La fenêtre du débugger :
log_functions
est un outil créé par Guillaume Lelarge au
sein de Dalibo. Il est proposé sous license libre (BSD).
Voici les étapes à réaliser pour compiler log_functions en prenant
pour hypothèse que les sources de PostgreSQL sont disponibles dans le
répertoire /home/guillaume/postgresql-9.1.4
et qu’ils ont
été préconfigurés avec la commande ./configure
:
$ cd /home/guillaume/postgresql-9.1.4/contrib
$ git://github.com/gleu/log_functions.git
Cloning into 'log_functions'...
remote: Counting objects: 24, done.
remote: Compressing objects: 100% (15/15), done.
remote: Total 24 (delta 8), reused 24 (delta 8)
Receiving objects: 100% (24/24), 11.71 KiB, done.
Resolving deltas: 100% (8/8), done.
log_functions
:$ cd log_functions
$ make
$ make install
L’installation copie le fichier log_functions.o
dans le
répertoire des bibliothèques partagées de PostgreSQL.
Si la version de PostgreSQL est supérieure ou égale à la 9.2, alors l’installation est plus simple et les sources de PostgreSQL ne sont plus nécessaires.
Téléchargement de log_functions :
wget http://api.pgxn.org/dist/log_functions/1.0.0/log_functions-1.0.0.zip
puis décompression et installation de l’extension :
unzip log_functions-1.0.0.zip
cd log_functions-1.0.0/
make USE_PGXS=1 && make USE_PGXS=1 install
L’installation copie aussi le fichier log_functions.so
dans le répertoire des bibliothèques partagées de PostgreSQL.
Le module log_functions est activable de deux façons.
La première consiste à demander à PostgreSQL de le charger au
démarrage. Pour cela, il faut configurer la variable
shared_preload_libraries
, puis redémarrer PostgreSQL pour
que le changement soit pris en compte.
La deuxième manière de l’activer est de l’activer seulement au moment
où son utilisation s’avère nécessaire. Il faut utiliser pour cela la
commande LOAD
en précisant le module à charger.
La première méthode a un coût en terme de performances car le module s’exécute à chaque exécution d’une procédure stockée écrite en PL/pgSQL. La deuxième méthode rend l’utilisation du profiler un peu plus complexe. Le choix est donc laissé à l’administrateur.
Les informations de profilage récupérées par log_functions sont envoyées dans les traces de PostgreSQL. Comme cela va générer plus d’écriture, et donc plus de lenteurs, il est possible de configurer chaque trace.
La configuration se fait soit dans le fichier
postgresql.conf
soit avec l’instruction
SET
.
Voici la liste des paramètres et leur utilité :
log_functions.log_declare
, à mettre à true pour tracer
le moment où PL/pgSQL exécute la partie DECLARE
d’une
procédure stockée ;log_functions.log_function_begin
, à mettre à true pour
tracer le moment où PL/pgSQL exécute la partie BEGIN
d’une
procédure stockée ;log_functions.log_function_end
, à mettre à true pour
tracer le moment où PL/pgSQL exécute la partie END
d’une
procédure stockée ;log_functions.log_statement_begin
, à mettre à true pour
tracer le moment où PL/pgSQL commence l’exécution d’une instruction dans
une procédure stockée ;log_functions.log_statement_end
, à mettre à true pour
tracer le moment où PL/pgSQL termine l’exécution d’une instruction dans
une procédure stockée.Par défaut, seuls log_statement_begin
et
log_statement_end
sont à false pour éviter la génération de
traces trop importantes.
Voici un exemple d’utilisation de cet outil :
SELECT incremente(4);
b2#
incremente------------
5
1 row)
(
'log_functions';
b2# LOAD
LOADSET client_min_messages TO log;
b2# LOG: duration: 0.136 ms statement: set client_min_messages to log;
SET
SELECT incremente(4);
b2# LOG: log_functions, DECLARE, incremente
LOG: log_functions, BEGIN, incremente
CONTEXT: PL/pgSQL function "incremente" during function entry
LOG: valeur de b : 5
LOG: log_functions, END, incremente
CONTEXT: PL/pgSQL function "incremente" during function exit
LOG: duration: 118.332 ms statement: select incremente(4);
incremente------------
5
1 row) (
Quelques liens utiles dans la documentation de PostgreSQL :
TP2.1
Ré-écrire la fonction de division pour tracer le problème de division par zéro (vous pouvez aussi utiliser les exceptions).
TP2.2
Tracer dans une table toutes les modifications du champ
nombre
dans stock
. On veut conserver
l’ancienne et la nouvelle valeur. On veut aussi savoir qui a fait la
modification et quand.
Interdire la suppression des lignes dans stock. Afficher un message dans les logs dans ce cas.
Afficher aussi un message NOTICE
quand nombre devient
inférieur à 5, et WARNING
quand il vaut 0.
TP2.3
Interdire à tout le monde, sauf un compte admin, l’accès à la table des logs précédemment créée .
En conséquence, le trigger fonctionne-t-il ? Le cas échéant, le modifier pour qu’il fonctionne.
TP2.4
Lire toute la table stock
avec un curseur.
Afficher dans les journaux applicatifs toutes les paires
(vin_id, contenant_id)
pour chaque nombre supérieur à
l’argument de la fonction.
TP2.5
Ré-écrire la fonction nb_bouteilles
du TP précédent de
façon à ce qu’elle prenne désormais en paramètre d’entrée une liste
variable d’années à traiter.
TP2.1 Solution :
CREATE OR REPLACE FUNCTION division(arg1 integer, arg2 integer)
AS
RETURNS float4
$BODY$BEGIN
RETURN arg1::float4/arg2::float4;
EXCEPTION WHEN OTHERS THEN
-- attention, division par zéro
LOG 'attention, [%]: %', SQLSTATE, SQLERRM;
RAISE RETURN 'NaN';
END $BODY$
'plpgsql' VOLATILE; LANGUAGE
Requêtage :
=# SET client_min_messages TO log;
caveSET
=# SELECT division(1,5);
cave
division----------
0.2
1 ligne)
(
=# SELECT division(1,0);
caveLOG: attention, [22012]: division par zéro
division----------
NaN1 ligne) (
TP2.2 Solution :
La table de log :
CREATE TABLE log_stock (
id serial,
utilisateur text,timestamp,
dateheure char(1),
operation integer,
vin_id integer,
contenant_id integer,
annee integer,
anciennevaleur integer); nouvellevaleur
La fonction trigger :
CREATE OR REPLACE FUNCTION log_stock_nombre()
TRIGGER AS
RETURNS
$BODY$DECLARE
v_requete text;char(1);
v_operation integer;
v_vinid integer;
v_contenantid integer;
v_annee integer;
v_anciennevaleur integer;
v_nouvellevaleur boolean := false;
v_atracer BEGIN
-- ce test a pour but de vérifier que le contenu de nombre a bien changé
-- c'est forcément le cas dans une insertion et dans une suppression
-- mais il faut tester dans le cas d'une mise à jour en se méfiant
-- des valeurs NULL
:= substr(TG_OP, 1, 1);
v_operation IF TG_OP = 'INSERT'
THEN
-- cas de l'insertion
:= true;
v_atracer := NEW.vin_id;
v_vinid := NEW.contenant_id;
v_contenantid := NEW.annee;
v_annee := NULL;
v_anciennevaleur := NEW.nombre;
v_nouvellevaleur = 'UPDATE'
ELSEIF TG_OP THEN
-- cas de la mise à jour
:= OLD.nombre != NEW.nombre;
v_atracer := NEW.vin_id;
v_vinid := NEW.contenant_id;
v_contenantid := NEW.annee;
v_annee := OLD.nombre;
v_anciennevaleur := NEW.nombre;
v_nouvellevaleur = 'DELETE'
ELSEIF TG_OP THEN
-- cas de la suppression
:= true;
v_atracer := OLD.vin_id;
v_vinid := OLD.contenant_id;
v_contenantid := NEW.annee;
v_annee := OLD.nombre;
v_anciennevaleur := NULL;
v_nouvellevaleur END IF;
IF v_atracer
THEN
INSERT INTO log_stock
(utilisateur, dateheure, operation, vin_id, contenant_id,
annee, anciennevaleur, nouvellevaleur)VALUES
(current_user, now(), v_operation, v_vinid, v_contenantid,
v_annee, v_anciennevaleur, v_nouvellevaleur);END IF;
RETURN NEW;
END $BODY$
'plpgsql' VOLATILE; LANGUAGE
Le trigger :
CREATE TRIGGER log_stock_nombre_trig
AFTER INSERT OR UPDATE OR DELETE
ON stock
FOR EACH ROW
EXECUTE PROCEDURE log_stock_nombre();
On commence par supprimer le trigger :
DROP TRIGGER log_stock_nombre_trig ON stock;
La fonction trigger :
CREATE OR REPLACE FUNCTION log_stock_nombre()
TRIGGER AS
RETURNS
$BODY$DECLARE
v_requete text;char(1);
v_operation integer;
v_vinid integer;
v_contenantid integer;
v_annee integer;
v_anciennevaleur integer;
v_nouvellevaleur boolean := false;
v_atracer BEGIN
:= substr(TG_OP, 1, 1);
v_operation IF TG_OP = 'INSERT'
THEN
-- cas de l'insertion
:= true;
v_atracer := NEW.vin_id;
v_vinid := NEW.contenant_id;
v_contenantid := NEW.annee;
v_annee := NULL;
v_anciennevaleur := NEW.nombre;
v_nouvellevaleur = 'UPDATE'
ELSEIF TG_OP THEN
-- cas de la mise à jour
:= OLD.nombre != NEW.nombre;
v_atracer := NEW.vin_id;
v_vinid := NEW.contenant_id;
v_contenantid := NEW.annee;
v_annee := OLD.nombre;
v_anciennevaleur := NEW.nombre;
v_nouvellevaleur END IF;
IF v_atracer
THEN
INSERT INTO log_stock
(utilisateur, dateheure, operation, vin_id, contenant_id,
anciennevaleur, nouvellevaleur)VALUES
(current_user, now(), v_operation, v_vinid, v_contenantid,
v_anciennevaleur, v_nouvellevaleur);END IF;
RETURN NEW;
END $BODY$
'plpgsql' VOLATILE; LANGUAGE
Le trigger :
CREATE TRIGGER trace_nombre_de_stock
AFTER INSERT OR UPDATE
ON stock
FOR EACH ROW
EXECUTE PROCEDURE log_stock_nombre();
La deuxième fonction trigger :
CREATE OR REPLACE FUNCTION empeche_suppr_stock()
TRIGGER AS
RETURNS
$BODY$BEGIN
IF TG_OP = 'DELETE'
THEN
'Tentative de suppression du stock (%, %, %)',
RAISE WARNING OLD.vin_id, OLD.contenant_id, OLD.annee;
RETURN NULL;
ELSE
RETURN NEW;
END IF;
END $BODY$
'plpgsql' VOLATILE; LANGUAGE
Le deuxième trigger :
CREATE TRIGGER empeche_suppr_stock_trig
BEFORE DELETE
ON stock
FOR EACH ROW
EXECUTE PROCEDURE empeche_suppr_stock();
La fonction trigger :
CREATE OR REPLACE FUNCTION log_stock_nombre()
TRIGGER AS
RETURNS
$BODY$DECLARE
v_requete text;char(1);
v_operation integer;
v_vinid integer;
v_contenantid integer;
v_annee integer;
v_anciennevaleur integer;
v_nouvellevaleur boolean := false;
v_atracer BEGIN
:= substr(TG_OP, 1, 1);
v_operation IF TG_OP = 'INSERT'
THEN
-- cas de l'insertion
:= true;
v_atracer := NEW.vin_id;
v_vinid := NEW.contenant_id;
v_contenantid := NEW.annee;
v_annee := NULL;
v_anciennevaleur := NEW.nombre;
v_nouvellevaleur = 'UPDATE'
ELSEIF TG_OP THEN
-- cas de la mise à jour
:= OLD.nombre != NEW.nombre;
v_atracer := NEW.vin_id;
v_vinid := NEW.contenant_id;
v_contenantid := NEW.annee;
v_annee := OLD.nombre;
v_anciennevaleur := NEW.nombre;
v_nouvellevaleur END IF;
IF v_nouvellevaleur < 1
THEN
'Il ne reste plus que % bouteilles dans le stock (%, %, %)',
RAISE WARNING OLD.vin_id, OLD.contenant_id, OLD.annee;
v_nouvellevaleur, < 5
ELSEIF v_nouvellevaleur THEN
LOG 'Il ne reste plus que % bouteilles dans le stock (%, %, %)',
RAISE OLD.vin_id, OLD.contenant_id, OLD.annee;
v_nouvellevaleur, END IF;
IF v_atracer
THEN
INSERT INTO log_stock
(utilisateur, dateheure, operation, vin_id, contenant_id,
annee, anciennevaleur, nouvellevaleur)VALUES
(current_user, now(), v_operation, v_vinid, v_contenantid,
v_annee, v_anciennevaleur, v_nouvellevaleur);END IF;
RETURN NEW;
END $BODY$
'plpgsql' VOLATILE; LANGUAGE
Requêtage :
Faire des INSERT, DELETE, UPDATE pour jouer avec.
TP2.3 Solution :
CREATE ROLE admin;
ALTER TABLE log_stock OWNER TO admin;
ALTER TABLE log_stock_id_seq OWNER TO admin;
REVOKE ALL ON TABLE log_stock FROM public;
=> insert into stock (vin_id, contenant_id, annee, nombre)
cavevalues (3,1,2020,10);
for relation log_stock
ERROR: permission denied CONTEXT: SQL statement "INSERT INTO log_stock
(utilisateur, dateheure, operation, vin_id, contenant_id,
annee, anciennevaleur, nouvellevaleur)VALUES
(current_user, now(), v_operation, v_vinid, v_contenantid,"
v_annee, v_anciennevaleur, v_nouvellevaleur)/pgSQL function log_stock_nombre() line 45 at SQL statement
PL
ALTER FUNCTION log_stock_nombre() OWNER TO admin;
ALTER FUNCTION log_stock_nombre() SECURITY DEFINER;
=> insert into stock (vin_id, contenant_id, annee, nombre)
cavevalues (3,1,2020,10);
INSERT 0 1
Que constatez-vous dans log_stock
? (un petit indice :
regardez l’utilisateur)
TP2.4 Solution :
CREATE OR REPLACE FUNCTION verif_nombre(maxnombre integer)
integer AS
RETURNS
$BODY$DECLARE
v_curseur refcursor;%ROWTYPE;
v_resultat stockinteger;
v_index BEGIN
:= 0;
v_index OPEN v_curseur FOR SELECT * FROM stock WHERE nombre > maxnombre;
LOOP
INTO v_resultat;
FETCH v_curseur IF NOT FOUND THEN
EXIT;END IF;
:= v_index + 1;
v_index 'nombre de (%, %) : % (supérieur à %)',
RAISE NOTICE
v_resultat.vin_id, v_resultat.contenant_id, v_resultat.nombre, maxnombre;END LOOP;
RETURN v_index;
END $BODY$
'plpgsql' VOLATILE; LANGUAGE
Requêtage:
SELECT verif_nombre(16);
6535, 3) : 17 (supérieur à 16)
INFO: nombre de (6538, 3) : 17 (supérieur à 16)
INFO: nombre de (6541, 3) : 17 (supérieur à 16)
INFO: nombre de (...]
[6692, 3) : 18 (supérieur à 16)
INFO: nombre de (6699, 3) : 17 (supérieur à 16)
INFO: nombre de (
verif_nombre--------------
107935
1 ligne) (
TP2.5
CREATE OR REPLACE FUNCTION
integer[])
nb_bouteilles(v_typevin text, VARIADIC v_annees record
RETURNS SETOF AS $BODY$
DECLARE
record;
resultat integer;
i BEGIN
IN ARRAY v_annees
FOREACH i LOOP
SELECT INTO resultat i, nb_bouteilles(v_typevin, i);
RETURN NEXT resultat;
END LOOP;
RETURN;
END
$BODY$ LANGUAGE plpgsql;
Exécution:
-- ancienne fonction
=# SELECT * FROM nb_bouteilles('blanc', 1990, 1995)
caveAS (annee integer, nb integer);
annee | nb-------+------
1990 | 5608
1991 | 5642
1992 | 5621
1993 | 5581
1994 | 5614
1995 | 5599
6 lignes)
(
=# SELECT * FROM nb_bouteilles('blanc', 1990, 1992, 1994)
caveAS (annee integer, nb integer);
annee | nb-------+------
1990 | 5608
1992 | 5621
1994 | 5614
3 lignes)
(
=# SELECT * FROM nb_bouteilles('blanc', 1993, 1991)
caveAS (annee integer, nb integer);
annee | nb-------+------
1993 | 5581
1991 | 5642
2 lignes) (
Les extensions sont un gros point fort de PostgreSQL. Elles permettent de rajouter des fonctionnalités, aussi bien pour les utilisateurs que pour les administrateurs, sur tous les sujets : fonctions utilitaires, types supplémentaires, outils d’administration avancés, voire applications quasi-complètes. Certaines sont intégrées par le projet, mais n’importe qui peut en proposer et en intégrer une.
Une extension est un objet du catalogue, englobant d’autres objets. On peut la comparer à un paquetage Linux.
Une extension peut provenir d’un projet séparé de PostgreSQL (PostGIS, par exemple, ou le Foreign Data Wrapper Oracle).
Les extensions les plus simples peuvent se limiter à quelques objets en SQL, certaines sont en PL/pgSQL, beaucoup sont en C. Dans ce dernier cas, il faut être conscient que la stabilité du serveur est encore plus en jeu !
Au niveau du système d’exploitation, une extension nécessite des objets (binaires, scripts…) dans l’arborescence de PostgreSQL. De nombreuses extensions sont déjà fournies sous forme de paquets dans les distributions courantes ou par le PGDG, ou encore l’outil PGXN. Dans certains cas, il faudra aller sur le site du projet et l’installer soi-même, ce qui peut nécessiter une compilation.
L’extension doit être ensuite déclarée dans chaque base où elle est
jugée nécessaire avec CREATE EXTENSION nom_extension
. Les
scripts fournis avec l’extension vont alors créer les objets nécessaires
(vues, procédures, tables…). En cas de désinstallation avec
DROP EXTENSION
, ils seront supprimés. Une extension peut
avoir besoin d’autres extensions : l’option CASCADE
permet
de les installer automatiquement.
Le mécanisme couvre aussi la mise à jour des extensions :
ALTER EXTENSION UPDATE
permet de mettre à jour une
extension dans PostgreSQL suite à la mise à jour de ses binaires. Cela
peut être nécessaire si elle contient des tables à mettre à jour, par
exemple. Les versions des extensions disponibles sur le système et
celles installées dans la base en cours sont visibles dans la vue
pg_available_extensions
.
Les extensions peuvent être exportées et importées par
pg_dump
/pg_restore
. Un export par
pg_dump
contient un
CREATE EXTENSION nom_extension
, ce qui permettra de recréer
d’éventuelles tables, et le contenu de ces tables. Une mise à
jour de version majeure, par exemple, permettra donc de migrer les
extensions dans leur dernière version installée sur le serveur
(changement de prototypes de fonctions, nouvelles vues, etc.).
Sous psql, les extensions présentes dans la base sont visibles avec
\dx
:
# \dx
Liste des extensions installées
Nom | Version | Schéma | Description
--------------------+---------+------------+--------------------------------------------------
amcheck | 1.2 | public | functions for verifying relation integrity
file_fdw | 1.0 | public | foreign-data wrapper for flat file access
hstore | 1.6 | public | data type for storing sets of (key, value) pairs
pageinspect | 1.9 | public | inspect the contents of database pages at...
pg_buffercache | 1.3 | public | examine the shared buffer cache
pg_prewarm | 1.2 | public | prewarm relation data
pg_rational | 0.0.1 | public | bigint fractions
pg_stat_statements | 1.10 | public | track execution statistics of all SQL statements...
plpgsql | 1.0 | pg_catalog | PL/pgSQL procedural language
plpython3u | 1.0 | pg_catalog | PL/Python3U untrusted procedural language
postgres_fdw | 1.0 | public | foreign-data wrapper for remote PostgreSQL servers
unaccent | 1.1 | public | text search dictionary that removes accents
Une « contrib » est habituellement une extension, sauf quelques
exceptions qui ne créent pas d’objets de catalogue
(auto_explain
par exemple). Elles sont fournies directement
dans l’arborescence de PostgreSQL, et suivent donc strictement son
rythme de révision. Leur compatibilité est ainsi garantie. Les
distributions les proposent parfois dans des paquets séparés
(postgresql-contrib-9.6
,
postgresql14-contrib
…), dont l’installation est fortement
conseillée.
Il s’agit soit de fonctionnalités qui n’intéressent pas tout le monde
(hstore
, uuid
, pg_trgm
,
pgstattuple
…), ou en cours de stabilisation (comme
l’autovacuum avant PostgreSQL 8.1), ou à l’inverse de dépréciation
(xml2
).
La documentation des contribs est dans le chapitre F des annexes, et est donc fréquemment oubliée par les nouveaux utilisateurs.
Fourni avec PostgreSQL, vous permet de chiffrer vos données :
Voici un exemple de code:
CREATE EXTENSION pgcrypto;
UPDATE utilisateurs SET mdp = crypt('mon nouveau mot de passe',gen_salt('md5'));
INSERT INTO table_secrete (encrypted)
VALUES (pgp_sym_encrypt('mon secret','motdepasse'));
L’appel à gen_salt
permet de rajouter une partie
aléatoire à la chaîne à chiffrer, ce qui évite que la même chaîne
chiffrée deux fois retourne le même résultat. Cela limite donc les
attaques par dictionnaire.
La base effectuant le (dé)chiffrement, cela évite certains allers-retours. Il est préférable que la clé de déchiffrement ne soit pas dans l’instance, et soit connue et fournie par l’applicatif. La communication avec cet applicatif doit être sécurisée par SSL pour que les clés et données ne transitent pas en clair.
Un gros inconvénient des données chiffrées dans la table est l’impossibilité complète de les indexer, même avec un index fonctionnel : les données déchiffrées seraient en clair dans cet index ! Une recherche implique donc de parcourir et déchiffrer chaque ligne…
hstore
fournit un type très simple pour stocker des
clés/valeur :
CREATE EXTENSION hstore ;
CREATE TABLE demo_hstore(id serial, meta hstore);
INSERT INTO demo_hstore (meta) VALUES ('river=>t');
INSERT INTO demo_hstore (meta) VALUES ('road=>t,secondary=>t');
INSERT INTO demo_hstore (meta) VALUES ('road=>t,primary=>t');
CREATE INDEX idxhstore ON demo_hstore USING gist (meta);
SELECT * FROM demo_hstore WHERE meta@>'river=>t';
id | meta
----+--------------
15 | "river"=>"t"
Cette extension a rendu, et rend encore, bien des services. Cependant
le type JSON (avec le type binaire jsonb
) est généralement
préféré.
Postgresql Anonymizer est une extension pour masquer ou remplacer les données personnelles dans une base PostgreSQL. Elle est développée par Damien Clochard de Dalibo.
Le projet fonctionne selon une approche déclarative, c’est à dire que les règles de masquage sont déclarées directement dans le modèle de données avec des ordres DDL.
Une fois que les règles de masquage sont définies, on peut accéder aux données masquées de 3 façons différentes :
Par ailleurs, l’extension fournit toute une gamme de fonctions de masquage : randomisation, génération de données factices, destruction partielle, brassage, ajout de bruit, etc. On peut également écrire ses propres fonctions de masquage !
Au-delà du masquage, il est également possible d’utiliser une autre approche appelée généralisation qui est bien adaptée pour les statistiques et l’analyse de données.
Enfin, l’extension offre un panel de fonctions de détection qui tentent de deviner quelles colonnes doivent être anonymisées.
Un module de formation lui est consacré.
Exemple :
=# SELECT * FROM people;
id | firstname | lastname | phone
----+----------+----------+------------
T1 | Sarah | Conor | 0609110911
Étape 1 : activer le masquage dynamique
=# CREATE EXTENSION IF NOT EXISTS anon CASCADE;
=# SELECT anon.start_dynamic_masking();
Étape 2 : déclarer un utilisateur masqué
=# CREATE ROLE skynet LOGIN;
=# SECURITY LABEL FOR anon ON ROLE skynet IS 'MASKED';
Étape 3 : déclarer les règles de masquage
=# SECURITY LABEL FOR anon ON COLUMN people.lastname
-# IS 'MASKED WITH FUNCTION anon.fake_last_name()';
=# SECURITY LABEL FOR anon ON COLUMN people.phone
-# IS 'MASKED WITH FUNCTION anon.partial(phone,2,$$******$$,2)';
Étape 4 : se connecter avec l’utilisateur masqué
=# \c - skynet
=# SELECT * FROM people;
id | firstname | lastname | phone
----+----------+-----------+------------
T1 | Sarah | Stranahan | 06******11
PostGIS ajoute le support d’objets géographiques à PostgreSQL. C’est un projet totalement indépendant développé par la société Refractions Research sous licence GPL, soutenu par une communauté active, utilisée par des spécialistes du domaine géospatial (IGN, BRGM, AirBNB, Mappy, Openstreetmap, Agence de l’eau…), mais qui peut convenir pour des projets plus modestes.
Techniquement, c’est une extension transformant PostgreSQL en serveur de données spatiales, qui sera utilisé par un Système d’Information Géographique (SIG), tout comme le SDE de la société ESRI ou bien l’extension Oracle Spatial. PostGIS se conforme aux directives du consortium OpenGIS et a été certifié par cet organisme comme tel, ce qui est la garantie du respect des standards par PostGIS.
PostGIS permet d’écrire des requêtes de ce type :
SELECT restaurants.geom, restaurants.name FROM restaurants
WHERE EXISTS (SELECT 1 FROM routes
WHERE ST_DWithin(restaurants.geom, routes.geom, 3000)
AND route.name = 'Nationale 12')
PostGIS fournit les fonctions d’indexation qui permettent d’accéder rapidement aux objets géométriques, au moyen d’index GiST. La requête ci-dessus n’a évidemment pas besoin de parcourir tous les restaurants à la recherche de ceux correspondant aux critères de recherche.
La liste des fonctionnalités comprend le support des coordonnées géodésiques ; des projections et reprojections dans divers systèmes de coordonnées locaux (Lambert93 en France par exemple) ; des opérateurs d’analyse géométrique (enveloppe convexe, simplification…)
PostGIS est intégré aux principaux serveurs de carte, ETL, et outils de manipulation.
La version 3.0 apporte la gestion du parallélisme, un meilleur support de l’indexation SP-GiST et GiST, ainsi qu’un meilleur support du type GeoJSON.
Les extensions permettent de diffuser des bibliothèques de fonction pour la compatibilité avec du code d’autres produits : orafce est un exemple bien connu.
Pour éviter de maintenir un fork complet de PostgreSQL, certains éditeurs offrent leur produit sous forme d’extension, souvent avec une version communautaire intégrant les principales fonctionnalités. Par exemple :
Face à des extensions extérieures, on gardera à l’esprit qu’il s’agit d’un produit supplémentaire à maîtriser et administrer, et l’on cherchera d’abord à tirer le maximum du PostgreSQL communautaire.
SQL et PL/pgSQL ne sont pas les seuls langages utilisables au niveau d’un serveur PostgreSQL. PL/pgSQL est installé par défaut en tant qu’extension. Il est possible de rajouter les langages python, perl, R, etc. et de coder des fonctions dans ces langages. Ces langages ne sont pas fournis par l’installation standard de PostgreSQL. Une installation via les paquets du système d’exploitation est sans doute le plus simple.
Les accès distants à d’autres bases de données sont généralement disponibles par des extensions. L’extension dblink permet d’accéder à une autre instance PostgreSQL mais elle est ancienne, et l’on préférera le foreign data wrapper postgresql_fdw, disponible dans les contribs. D’autres FDW sont des projets extérieurs : ora_fdw, mysql_fdw, etc.
Une solution de sharding n’est pas encore intégrée à PostgreSQL mais des outils existent : PL/Proxy fournit des fonctions pour répartir des accès mais implique de refondre le code. Citus est une extension plus récente et plus transparente.
Tous ces modules permettent de manipuler une facette de PostgreSQL à laquelle on n’a normalement pas accès. Leur utilisation est parfois très spécialisée et pointue.
En plus des contribs listés ci-dessus, de nombreux projets externes existent : toastinfo, pg_stat_kcache, pg_qualstats, PoWa, pg_wait_sampling, hypopg…
Le site PGXN fournit une vitrine à de nombreux projets gravitant autour de PostgreSQL.
PGXN a de nombreux avantages, dont celui de demander aux projets
participants de respecter un certain cahier des charges permettant
l’installation automatisée des modules hébergés. Ceci peut par exemple
être réalisé avec le client pgxn
fourni :
> pgxn search --dist fdw
multicdr_fdw 1.2.2
MultiCDR *FDW* =================== Foreign Data Wrapper for representing
CDR files stream as an external SQL table. CDR files from a directory
can be read into a table with a specified field-to-column...
redis_fdw 1.0.0
Redis *FDW* for PostgreSQL 9.1+ ============================== This
PostgreSQL extension implements a Foreign Data Wrapper (*FDW*) for the
Redis key/value database: http://redis.io/ This code is...
jdbc_fdw 1.0.0
Also,since the JVM being used in jdbc *fdw* is created only once for the
entire psql session,therefore,the first query issued that uses jdbc
+fdw* shall set the value of maximum heap size of the JVM(if...
mysql_fdw 2.1.2
... This PostgreSQL extension implements a Foreign Data Wrapper (*FDW*)
for [MySQL][1]. Please note that this version of mysql_fdw only works
with PostgreSQL Version 9.3 and greater, for previous version...
www_fdw 0.1.8
... library contains a PostgreSQL extension, a Foreign Data Wrapper
(*FDW*) handler of PostgreSQL which provides easy way for interacting
with different web-services.
mongo_fdw 2.0.0
MongoDB *FDW* for PostgreSQL 9.2 ============================== This
PostgreSQL extension implements a Foreign Data Wrapper (*FDW*) for
MongoDB.
firebird_fdw 0.1.0
... -
http://www.postgresql.org/docs/current/interactive/postgres-*fdw*.html *
Other FDWs - https://wiki.postgresql.org/wiki/*Fdw* -
http://pgxn.org/tag/*fdw*/
json_fdw 1.0.0
... This PostgreSQL extension implements a Foreign Data Wrapper (*FDW*)
for JSON files. The extension doesn't require any data to be loaded into
the database, and supports analytic queries against array...
postgres_fdw 1.0.0
This port provides a read-only Postgres *FDW* to PostgreSQL servers in
the 9.2 series. It is a port of the official postgres_fdw contrib module
available in PostgreSQL version 9.3 and later.
osm_fdw 3.0.0
... "Openstreetmap pbf foreign data wrapper") (*FDW*) for reading
[Openstreetmap PBF](http://wiki.openstreetmap.org/wiki/PBF_Format
"Openstreetmap PBF") file format (*.osm.pbf) ## Requirements *...
odbc_fdw 0.1.0
ODBC *FDW* (beta) for PostgreSQL 9.1+
=================================== This PostgreSQL extension implements
a Foreign Data Wrapper (*FDW*) for remote databases using Open Database
Connectivity(ODBC)...
couchdb_fdw 0.1.0
CouchDB *FDW* (beta) for PostgreSQL 9.1+
====================================== This PostgreSQL extension
implements a Foreign Data Wrapper (*FDW*) for the CouchDB document-
oriented database...
treasuredata_fdw 1.2.14
## INSERT INTO statement This *FDW* supports `INSERT INTO` statement.
With `atomic_import` is `false`, the *FDW* imports INSERTed rows as
follows.
twitter_fdw 1.1.1
Installation ------------ $ make && make install $ psql -c "CREATE
EXTENSION twitter_fdw" db The CREATE EXTENSION statement creates not
only *FDW* handlers but also Data Wrapper, Foreign Server, User...
ldap_fdw 0.1.1
... is an initial working on a PostgreSQL's Foreign Data Wrapper (*FDW*)
to query LDAP servers. By all means use it, but do so entirely at your
own risk! You have been warned! Do you like to use it in...
git_fdw 1.0.2
# PostgreSQL Git Foreign Data Wrapper [![Build Status](https://travis-
ci.org/franckverrot/git_fdw.svg?branch=master)](https://travis-
ci.org/franckverrot/git_fdw) git\_fdw is a Git Foreign Data...
oracle_fdw 2.0.0
Foreign Data Wrapper for Oracle ===============================
oracle_fdw is a PostgreSQL extension that provides a Foreign Data
Wrapper for easy and efficient access to Oracle databases, including...
foreign_table_exposer 1.0.0
# foreign_table_exposer This PostgreSQL extension exposes foreign tables
like a normal table with rewriting Query tree. Some BI tools can't
detect foreign tables since they don't consider them when...
cstore_fdw 1.6.0
cstore_fdw ========== [![Build Status](https://travis-
ci.org/citusdata/cstore_fdw.svg?branch=master)][status] [![Coverage](htt
p://img.shields.io/coveralls/citusdata/cstore_fdw/master.svg)][coverage]
...
multicorn 1.3.5
[![PGXN version](https://badge.fury.io/pg/multicorn.svg)](https://badge.
fury.io/pg/multicorn) [![Build
Status](https://jenkins.dalibo.info/buildStatus/public/Multicorn)]()
Multicorn =========...
tds_fdw 1.0.7
# TDS Foreign data wrapper * **Author:** Geoff Montee * **Name:**
tds_fdw * **File:** tds_fdw/README.md ## About This is a [PostgreSQL
foreign data...
pmpp 1.2.3
... Having foreign server definitions and user mappings makes for
cleaner function invocations.
file_textarray_fdw 1.0.1
### File Text Array Foreign Data Wrapper for PostgreSQL This *FDW* is
similar to the provided file_fdw, except that instead of the foreign
table having named fields to match the fields in the data...
floatfile 1.3.0
Also I'd need to compare the performance of this vs an *FDW*. If I do
switch to an *FDW*, I'll probably use [Andrew Dunstan's
`file_text_array_fdw`](https://github.com/adunstan/file_text_array_fdw)
as a...
pg_pathman 1.4.13
... event handling; * Non-blocking concurrent table partitioning; *
+FDW* support (foreign partitions); * Various GUC toggles and
configurable settings.
Pour peu que le Instant Client d’Oracle soit installé, on peut par exemple lancer :
> pgxn install oracle_fdw
INFO: best version: oracle_fdw 1.1.0
INFO: saving /tmp/tmpihaor2is/oracle_fdw-1.1.0.zip
INFO: unpacking: /tmp/tmpihaor2is/oracle_fdw-1.1.0.zip
INFO: building extension
gcc -O3 -O0 -Wall -Wmissing-prototypes -Wpointer-arith [...]
[...]
INFO: installing extension
/usr/bin/mkdir -p '/opt/postgres/lib'
/usr/bin/mkdir -p '/opt/postgres/share/extension'
/usr/bin/mkdir -p '/opt/postgres/share/extension'
/usr/bin/mkdir -p '/opt/postgres/share/doc/extension'
/usr/bin/install -c -m 755 oracle_fdw.so '/opt/postgres/lib/oracle_fdw.so'
/usr/bin/install -c -m 644 oracle_fdw.control '/opt/postgres/share/extension/'
/usr/bin/install -c -m 644 oracle_fdw--1.1.sql\oracle_fdw--1.0--1.1.sql
'/opt/postgres/share/extension/'
/usr/bin/install -c -m 644 README.oracle_fdw \
'/opt/postgres/share/doc/extension/'
Attention : le fait qu’un projet soit hébergé sur PGXN n’est absolument pas une validation de la part du projet PostgreSQL. De nombreux projets hébergés sur PGXN sont encore en phase de développement, voire abandonnés. Il faut avoir le même recul que pour n’importe quel autre brique libre.
Il n’est pas très compliqué de créer sa propre extension pour diffuser aisément des outils. Elle peut se limiter à des fonctions en SQL ou PL/pgSQL. Le versionnement des extensions et la facilité de mise à jour peuvent être extrêmement utiles.
Deux exemples de création de fonctions en SQL ou C sont disponibles sur le blog Dalibo. Un autre billet de blog présente une extension utilisable pour l’archivage.
La référence reste évidemment la documentation de PostgreSQL, chapitre Empaqueter des objets dans une extension.
Cette possibilité d’étendre les fonctionnalités de PostgreSQL est vraiment un atout majeur du projet PostgreSQL. Cela permet de tester des fonctionnalités sans avoir à toucher au moteur de PostgreSQL et risquer des états instables.
Une fois l’extension mature, elle peut être intégrée directement dans le code de PostgreSQL si elle est considérée utile au moteur.
N’hésitez pas à créer vos propres extensions et à les diffuser !
Installer l’extension PostgreSQL Anonymizer en suivant la procédure décrite sur la page Installation de la documentation.
Créer une table
customer
:
CREATE TABLE customer (
id SERIAL PRIMARY KEY,
firstname TEXT,
lastname TEXT,
phone TEXT,DATE,
birth
postcode TEXT );
Ajouter des individus dans la table :
INSERT INTO customer
VALUES
107,'Sarah','Conor','060-911-0911', '1965-10-10', '90016'),
(258,'Luke', 'Skywalker', NULL, '1951-09-25', '90120'),
(341,'Don', 'Draper','347-515-3423', '1926-06-01', '04520')
( ;
Lire la documentation sur comment déclarer une règle de masquage et placer une règle pour générer un faux nom de famille sur la colonne
lastname
. Déclarer une règle de masquage statique sur la colonnelastname
et l’appliquer. Vérifier le contenu de la table.
Réappliquer le masquage statique. Qu’observez-vous ?
Parcourir la liste des fonctions de masquage et écrire une règle pour cacher partiellement le numéro de téléphone. Activer le masquage dynamique. Appliquer le masquage dynamique uniquement sur la colonne
phone
pour un nouvel utilisateur nommé soustraitant.
Installer l’extension PostgreSQL Anonymizer en suivant la procédure décrite sur la page Installation de la documentation.
Sur Rocky Linux ou autre dérivé Red Hat, depuis les dépôts du PGDG :
sudo dnf install postgresql_anonymizer_14
Au besoin, remplacer 14 par la version de l’instance PostgreSQL.
La base de travail ici se nomme sensible. Se connecter à l’instance pour initialiser l’extension :
ALTER DATABASE sensible SET session_preload_libraries = 'anon' ;
Après reconnexion à la base sensible :
CREATE EXTENSION anon CASCADE;
SELECT anon.init(); -- ne pas oublier !
Créer une table
customer
:
CREATE TABLE customer (
id SERIAL PRIMARY KEY,
firstname TEXT,
lastname TEXT,
phone TEXT,DATE,
birth
postcode TEXT );
Ajouter des individus dans la table :
INSERT INTO customer
VALUES
107,'Sarah','Conor','060-911-0911', '1965-10-10', '90016'),
(258,'Luke', 'Skywalker', NULL, '1951-09-25', '90120'),
(341,'Don', 'Draper','347-515-3423', '1926-06-01', '04520')
( ;
SELECT * FROM customer ;
id | firstname | lastname | phone | birth | postcode
-----+-----------+-----------+--------------+------------+----------
107 | Sarah | Conor | 060-911-0911 | 1965-10-10 | 90016
258 | Luke | Skywalker | | 1951-09-25 | 90120
341 | Don | Draper | 347-515-3423 | 1926-06-01 | 04520
Lire la documentation sur comment déclarer une règle de masquage et placer une règle pour générer un faux nom de famille sur la colonne
lastname
. Déclarer une règle de masquage statique sur la colonnelastname
et l’appliquer. Vérifier le contenu de la table.
LABEL FOR anon ON COLUMN customer.lastname
SECURITY IS 'MASKED WITH FUNCTION anon.fake_last_name()' ;
Si on consulte la table avec :
SELECT * FROM customer ;
les données ne sont pas encore masquées car la règle n’est pas appliquée. L’application se fait avec :
SELECT anon.anonymize_table('customer') ;
SELECT * FROM customer;
id | firstname | lastname | phone | birth | postcode
-----+-----------+----------+--------------+------------+----------
107 | Sarah | Waelchi | 060-911-0911 | 1965-10-10 | 90016
258 | Luke | Lemke | | 1951-09-25 | 90120
341 | Don | Shanahan | 347-515-3423 | 1926-06-01 | 04520
NB : les données de la table ont ici bien été modifiées sur le disque.
Réappliquer le masquage statique. Qu’observez-vous ?
Si l’on relance l’anonymisation plusieurs fois, les données factices
vont changer car la fonction fake_last_name()
renvoie des
valeurs différentes à chaque appel.
SELECT anon.anonymize_table('customer');
SELECT * FROM customer;
id | firstname | lastname | phone | birth | postcode
-----+-----------+----------+--------------+------------+----------
107 | Sarah | Smith | 060-911-0911 | 1965-10-10 | 90016
258 | Luke | Sanford | | 1951-09-25 | 90120
341 | Don | Goldner | 347-515-3423 | 1926-06-01 | 04520
Parcourir la liste des fonctions de masquage et écrire une règle pour cacher partiellement le numéro de téléphone. Activer le masquage dynamique. Appliquer le masquage dynamique uniquement sur la colonne
phone
pour un nouvel utilisateur nommé soustraitant.
SELECT anon.start_dynamic_masking();
LABEL FOR anon ON COLUMN customer.phone
SECURITY IS 'MASKED WITH FUNCTION anon.partial(phone,2,$$X-XXX-XX$$,2)';
SELECT anon.anonymize_column('customer','phone');
SELECT * FROM customer ;
Les numéros de téléphone apparaissent encore car ils ne sont pas masqués à l’utilisateur en cours. Il faut le déclarer pour les utilisateurs concernés :
CREATE ROLE soustraitant LOGIN ;
password soustraitant
\
GRANT SELECT ON customer TO soustraitant ;
LABEL FOR anon ON ROLE soustraitant IS 'MASKED'; SECURITY
Ce nouvel utilisateur verra à chaque fois des noms différents (masquage dynamique), et des numéros de téléphone partiellement masqués :
\c sensible soustraitantSELECT * FROM customer ;
id | firstname | lastname | phone | birth | postcode
-----+-----------+----------+--------------+------------+----------
107 | Sarah | Kovacek | 06X-XXX-XX11 | 1965-10-10 | 90016
258 | Luke | Effertz | ø | 1951-09-25 | 90120
341 | Don | Turcotte | 34X-XXX-XX23 | 1926-06-01 | 04520
Pour consulter la configuration de masquage en place, utiliser une
des vues fournies dans le schéma anon
:
=# SELECT * FROM anon.pg_masks \gx
-[ RECORD 1 ]----+----------------------------------------------------------
attrelid | 41853
attnum | 3
relnamespace | public
relname | customer
attname | lastname
format_type | text
col_description | MASKED WITH FUNCTION anon.fake_last_name()
masking_function | anon.fake_last_name()
masking_value |
priority | 100
masking_filter | anon.fake_last_name()
trusted_schema | t
-[ RECORD 2 ]----+----------------------------------------------------------
attrelid | 41853
attnum | 4
relnamespace | public
relname | customer
attname | phone
format_type | text
col_description | MASKED WITH FUNCTION anon.partial(phone,2,$$X-XXX-XX$$,2)
masking_function | anon.partial(phone,2,$$X-XXX-XX$$,2)
masking_value |
priority | 100
masking_filter | anon.partial(phone,2,$$X-XXX-XX$$,2)
trusted_schema | t
Maintenir de très grosses tables peut devenir fastidieux, voire
impossible : VACUUM FULL
trop long, espace disque
insuffisant, autovacuum pas assez réactif, réindexation interminable… Il
est aussi aberrant de conserver beaucoup de données d’archives dans des
tables lourdement sollicitées pour les données récentes.
Le partitionnement consiste à séparer une même table en plusieurs sous-tables (partitions) manipulables en tant que tables à part entière.
Maintenance
La maintenance s’effectue sur les partitions plutôt que sur
l’ensemble complet des données. En particulier, un
VACUUM FULL
ou une réindexation peuvent s’effectuer
partition par partition, ce qui permet de limiter les interruptions en
production, et lisser la charge. pg_dump
ne sait pas
paralléliser la sauvegarde d’une table volumineuse et non partitionnée,
mais parallélise celle de différentes partitions d’une même table.
C’est aussi un moyen de déplacer une partie des données dans un autre tablespace pour des raisons de place, ou pour déporter les parties les moins utilisées de la table vers des disques plus lents et moins chers.
Parcours complet de partitions
Certaines requêtes (notamment décisionnelles) ramènent tant de lignes, ou ont des critères si complexes, qu’un parcours complet de la table est souvent privilégié par l’optimiseur.
Un partitionnement, souvent par date, permet de ne parcourir qu’une ou quelques partitions au lieu de l’ensemble des données. C’est le rôle de l’optimiseur de choisir la partition (partition pruning), par exemple celle de l’année en cours, ou des mois sélectionnés.
Suppression des partitions
La suppression de données parmi un gros volume peut poser des problèmes d’accès concurrents ou de performance, par exemple dans le cas de purges.
En configurant judicieusement les partitions, on peut résoudre cette
problématique en supprimant une partition
(DROP TABLE nompartition ;
), ou en la détachant
(ALTER TABLE table_partitionnee DETACH PARTITION nompartition ;
)
pour l’archiver (et la réattacher au besoin) ou la supprimer
ultérieurement.
D’autres optimisations sont décrites dans ce billet de blog d’Adrien Nayrat : statistiques plus précises au niveau d’une partition, réduction plus simple de la fragmentation des index, jointure par rapprochement des partitions…
La principale difficulté d’un système de partitionnement consiste à partitionner avec un impact minimal sur la maintenance du code par rapport à une table classique.
L’application peut gérer le partitionnement elle-même, par exemple en créant des tables numérotées par mois, année… Le moteur de PostgreSQL ne voit que des tables classiques et ne peut assurer l’intégrité entre ces données.
C’est au développeur de réinventer la roue : choix de la table, gestion des index… La lecture des données qui concerne plusieurs tables peut devenir délicate.
Ce modèle extrêmement fréquent est bien sûr à éviter.
Un partitionnement entièrement géré par le moteur, n’existe réellement que depuis la version 10 de PostgreSQL. Il a été grandement amélioré en versions 11 et 12, en fonctionnalités comme en performances.
Jusqu’à PostgreSQL 9.6 n’existaient que le partitionnement dit par héritage, nettement moins flexible, et bien sûr le partitionnement géré intégralement par l’applicatif.
Principe du partitionnement par héritage :
PostgreSQL permet de créer des tables qui héritent les unes des autres. L’héritage d’une table mère transmet les propriétés suivantes à la table fille :
CHECK
.Les tables filles peuvent ajouter leurs propres colonnes. Par exemple :
CREATE TABLE animaux (nom text PRIMARY KEY);
INSERT INTO animaux VALUES ('Éponge');
INSERT INTO animaux VALUES ('Ver de terre');
CREATE TABLE cephalopodes (nb_tentacules integer) INHERITS (animaux);
INSERT INTO cephalopodes VALUES ('Poulpe', 8);
CREATE TABLE vertebres (nb_membres integer) INHERITS (animaux);
CREATE TABLE tetrapodes () INHERITS (vertebres);
ALTER TABLE ONLY tetrapodes ALTER COLUMN nb_membres SET DEFAULT 4 ;
CREATE TABLE poissons (eau_douce boolean) INHERITS (tetrapodes);
INSERT INTO poissons (nom, eau_douce) VALUES ('Requin', false);
INSERT INTO poissons (nom, nb_membres, eau_douce) VALUES ('Anguille', 0, false);
La table poissons
possède les champs des tables dont
elle hérite :
\d+ poissons
Table "public.poissons"
Column | Type | Collation | Nullable | Default | …
------------+---------+-----------+----------+---------+---
nom | text | | not null | | …
nb_membres | integer | | | 4 | …
eau_douce | boolean | | | | …
Inherits: tetrapodes Access method: heap
On peut créer toute une hiérarchie avec des branches parallèles, chacune avec ses colonnes propres :
CREATE TABLE reptiles (venimeux boolean) INHERITS (tetrapodes);
INSERT INTO reptiles VALUES ('Crocodile', 4, false);
INSERT INTO reptiles VALUES ('Cobra', 0, true);
CREATE TABLE mammiferes () INHERITS (tetrapodes);
CREATE TABLE cetartiodactyles (
boolean,
cornes boolean
bosse
) INHERITS (mammiferes);INSERT INTO cetartiodactyles VALUES ('Girafe', 4, true, false);
INSERT INTO cetartiodactyles VALUES ('Chameau', 4, false, true);
CREATE TABLE primates (debout boolean) INHERITS (mammiferes);
INSERT INTO primates (nom, debout) VALUES ('Chimpanzé', false);
INSERT INTO primates (nom, debout) VALUES ('Homme', true);
\d+ primates
Table "public.primates"
Column | Type | Collation | Nullable | Default | …
------------+---------+-----------+----------+---------+---
nom | text | | not null | | …
nb_membres | integer | | | 4 | …
debout | boolean | | | | …
Inherits: mammiferes Access method: heap
On remarquera que la clé primaire manque. En effet, l’héritage ne transmet pas :
Chaque table possède ses propres lignes :
SELECT * FROM poissons ;
nom | nb_membres | eau_douce
----------+------------+-----------
Requin | 4 | f Anguille | 0 | f
Par défaut une table affiche aussi le contenu de ses tables filles et les colonnes communes :
SELECT * FROM animaux ORDER BY 1 ;
nom
--------------
Anguille
Chameau
Chimpanzé
Cobra
Crocodile
Éponge
Girafe
Homme
Poulpe
Requin Ver de terre
SELECT * FROM tetrapodes ORDER BY 1 ;
nom | nb_membres
-----------+------------
Anguille | 0
Chameau | 4
Chimpanzé | 4
Cobra | 0
Crocodile | 4
Girafe | 4
Homme | 4 Requin | 4
EXPLAIN SELECT * FROM tetrapodes ORDER BY 1 ;
QUERY PLAN
---------------------------------------------------------------------------------
Sort (cost=420.67..433.12 rows=4982 width=36)
Sort Key: tetrapodes.nom
-> Append (cost=0.00..114.71 rows=4982 width=36)
-> Seq Scan on tetrapodes (cost=0.00..0.00 rows=1 width=36)
-> Seq Scan on poissons (cost=0.00..22.50 rows=1250 width=36)
-> Seq Scan on reptiles (cost=0.00..22.50 rows=1250 width=36)
-> Seq Scan on mammiferes (cost=0.00..0.00 rows=1 width=36)
-> Seq Scan on cetartiodactyles (cost=0.00..22.30 rows=1230 width=36) -> Seq Scan on primates (cost=0.00..22.50 rows=1250 width=36)
Pour ne consulter que le contenu de la table sans ses filles :
SELECT * FROM ONLY animaux ;
nom
--------------
Éponge Ver de terre
Limites et problèmes :
En conséquence, on a bien affaire à des tables indépendantes. Rien n’empêche d’avoir des doublons entre la table mère et la table fille. Cela empêche aussi bien sûr la mise en place de clé étrangère, puisqu’une clé étrangère s’appuie sur une contrainte d’unicité de la table référencée. Lors d’une insertion, voire d’une mise à jour, le choix de la table cible se fait par l’application ou un trigger sur la table mère.
Il faut être vigilant à bien recréer les contraintes et index manquants ainsi qu’à attribuer les droits sur les objets de manière adéquate. L’une des erreurs les plus fréquentes est d’oublier de créer les contraintes, index et droits qui n’ont pas été transmis.
Ce type de partitionnement est un héritage des débuts de PostgreSQL, à l’époque de la mode des « bases de donnée objet ». Dans la pratique, dans les versions antérieures à la version 10, l’héritage était utilisé pour mettre en place le partitionnement. La maintenance des index, des contraintes et la nécessité d’un trigger pour aiguiller les insertions vers la bonne table fille, ne facilitaient pas la maintenance. Les performances en écritures étaient bien en-deçà des tables classiques ou du nouveau partitionnement déclaratif.
Table partitionnée en détournant le partitionnement par héritage :
CREATE TABLE t3 (c1 integer, c2 text);
CREATE TABLE t3_1 (CHECK (c1 BETWEEN 0 AND 999999)) INHERITS (t3);
CREATE TABLE t3_2 (CHECK (c1 BETWEEN 1000000 AND 1999999)) INHERITS (t3);
CREATE TABLE t3_3 (CHECK (c1 BETWEEN 2000000 AND 2999999)) INHERITS (t3);
CREATE TABLE t3_4 (CHECK (c1 BETWEEN 3000000 AND 3999999)) INHERITS (t3);
CREATE TABLE t3_5 (CHECK (c1 BETWEEN 4000000 AND 4999999)) INHERITS (t3);
CREATE TABLE t3_6 (CHECK (c1 BETWEEN 5000000 AND 5999999)) INHERITS (t3);
CREATE TABLE t3_7 (CHECK (c1 BETWEEN 6000000 AND 6999999)) INHERITS (t3);
CREATE TABLE t3_8 (CHECK (c1 BETWEEN 7000000 AND 7999999)) INHERITS (t3);
CREATE TABLE t3_9 (CHECK (c1 BETWEEN 8000000 AND 8999999)) INHERITS (t3);
CREATE TABLE t3_0 (CHECK (c1 BETWEEN 9000000 AND 9999999)) INHERITS (t3);
-- Fonction et trigger de répartition pour les insertions :
CREATE OR REPLACE FUNCTION insert_into() RETURNS TRIGGER
LANGUAGE plpgsqlAS $FUNC$
BEGIN
IF NEW.c1 BETWEEN 0 AND 999999 THEN
INSERT INTO t3_1 VALUES (NEW.*);
ELSIF NEW.c1 BETWEEN 1000000 AND 1999999 THEN
INSERT INTO t3_2 VALUES (NEW.*);
ELSIF NEW.c1 BETWEEN 2000000 AND 2999999 THEN
INSERT INTO t3_3 VALUES (NEW.*);
ELSIF NEW.c1 BETWEEN 3000000 AND 3999999 THEN
INSERT INTO t3_4 VALUES (NEW.*);
ELSIF NEW.c1 BETWEEN 4000000 AND 4999999 THEN
INSERT INTO t3_5 VALUES (NEW.*);
ELSIF NEW.c1 BETWEEN 5000000 AND 5999999 THEN
INSERT INTO t3_6 VALUES (NEW.*);
ELSIF NEW.c1 BETWEEN 6000000 AND 6999999 THEN
INSERT INTO t3_7 VALUES (NEW.*);
ELSIF NEW.c1 BETWEEN 7000000 AND 7999999 THEN
INSERT INTO t3_8 VALUES (NEW.*);
ELSIF NEW.c1 BETWEEN 8000000 AND 8999999 THEN
INSERT INTO t3_9 VALUES (NEW.*);
ELSIF NEW.c1 BETWEEN 9000000 AND 9999999 THEN
INSERT INTO t3_0 VALUES (NEW.*);
END IF;
RETURN NULL;
END;
$FUNC$;
CREATE TRIGGER tr_insert_t3
BEFORE INSERT ON t3 FOR EACH ROW
EXECUTE PROCEDURE insert_into();
Noter qu’il reste encore à gérer les mises à jour de lignes… À cause de ce trigger, le temps d’insertion peut être allègrement multiplié par huit ou dix par rapport à une insertion dans une table normale ou dans une table avec le partitionnement déclaratif moderne.
La même table en partitionnement déclaratif par liste est :
CREATE TABLE t2 (c1 integer, c2 text) PARTITION BY RANGE (c1);
CREATE TABLE t2_1 PARTITION OF t2 FOR VALUES FROM ( 0) TO ( 1000000);
CREATE TABLE t2_2 PARTITION OF t2 FOR VALUES FROM (1000000) TO ( 2000000);
CREATE TABLE t2_3 PARTITION OF t2 FOR VALUES FROM (2000000) TO ( 3000000);
CREATE TABLE t2_4 PARTITION OF t2 FOR VALUES FROM (3000000) TO ( 4000000);
CREATE TABLE t2_5 PARTITION OF t2 FOR VALUES FROM (4000000) TO ( 5000000);
CREATE TABLE t2_6 PARTITION OF t2 FOR VALUES FROM (5000000) TO ( 6000000);
CREATE TABLE t2_7 PARTITION OF t2 FOR VALUES FROM (6000000) TO ( 7000000);
CREATE TABLE t2_8 PARTITION OF t2 FOR VALUES FROM (7000000) TO ( 8000000);
CREATE TABLE t2_9 PARTITION OF t2 FOR VALUES FROM (8000000) TO ( 9000000);
CREATE TABLE t2_0 PARTITION OF t2 FOR VALUES FROM (9000000) TO (10000000);
Si le partitionnement par héritage fonctionne toujours sur les versions récentes de PostgreSQL, il est déconseillé pour les nouveaux développements.
Le partitionnement déclaratif est le système à privilégier de nos
jours. Apparu en version 10, il est à présent mûr. Son but est de
permettre une mise en place et une administration simples des tables
partitionnées. Des clauses spécialisées ont été ajoutées aux ordres SQL,
comme CREATE TABLE
et ALTER TABLE
, pour
attacher (ATTACH PARTITION
) et détacher des partitions
(DETACH PARTITION
).
Au niveau de la simplification de la mise en place par rapport à l’ancien partitionnement par héritage, on peut noter qu’il n’est pas nécessaire de créer une fonction trigger ni d’ajouter des triggers pour gérer les insertions et mises à jour. Le routage est géré de façon automatique en fonction de la définition des partitions, au besoin vers une partition par défaut, et sans pénalité notable en performances. Contrairement au partitionnement par héritage, la table partitionnée ne contient pas elle-même de ligne, ce n’est qu’une coquille vide.
Il est possible de partitionner une table par valeurs. Ce type de partitionnement fonctionne uniquement avec une clé de partitionnement mono-colonne (on verra qu’il est possible de sous-partitionner). Il faut que le nombre de valeurs soit assez faible pour être listé explicitement. Le partitionnement par liste est adapté par exemple au partitionnement par :
Voici un exemple de création d’une table partitionnée par liste et de ses partitions :
CREATE TABLE t1(c1 integer, c2 text) PARTITION BY LIST (c1);
CREATE TABLE t1_a PARTITION OF t1 FOR VALUES IN (1, 2, 3);
CREATE TABLE t1_b PARTITION OF t1 FOR VALUES IN (4, 5);
Les noms des partitions sont à définir par l’utilisateur, il n’y a pas d’automatisme ni de convention particulière.
Lors de l’insertion, les données sont correctement redirigées vers leur partition, comme le montre cette requête :
INSERT INTO t1 VALUES (1);
INSERT INTO t1 VALUES (2);
INSERT INTO t1 VALUES (5);
SELECT tableoid::regclass, * FROM t1;
tableoid | c1 | c2
----------+----+----
t1_a | 1 |
t1_a | 2 | t1_b | 5 |
Il est aussi possible d’interroger directement une partition (ici
t1_a
et non t1
) :
SELECT * FROM t1_a ;
c1 | c2
----+----
1 | 2 |
Si aucune partition correspondant à la clé insérée n’est trouvée et qu’aucune partition par défaut n’est déclarée, une erreur se produit.
INSERT INTO t1 VALUES (0);
ERROR: no PARTITION OF relation "t1" found for row DETAIL: Partition key of the failing row contains (c1) = (0).
INSERT INTO t1 VALUES (6);
ERROR: no PARTITION OF relation "t1" found for row DETAIL: Partition key of the failing row contains (c1) = (6).
Si la clé de partitionnement d’une ligne est modifiée par un
UPDATE
, la ligne change automatiquement de partition (sauf
en version 10, où ce n’est pas implémenté, et l’on obtient une
erreur).
Le partitionnement par intervalle est très courant quand il y a de nombreuses valeurs différentes de la clé de partitionnement, ou qu’elle doit être multicolonne, par exemple :
Voici un exemple de création de la table partitionnée et de deux partitions :
CREATE TABLE t2(c1 integer, c2 text) PARTITION BY RANGE (c1);
CREATE TABLE t2_1 PARTITION OF t2 FOR VALUES FROM (1) to (100);
CREATE TABLE t2_2 PARTITION OF t2 FOR VALUES FROM (100) TO (MAXVALUE);
Le MAXVALUE
indique la valeur maximale du type de
données : t2_2
acceptera donc tous les entiers supérieurs
ou égaux à 100.
Noter que les bornes supérieures des partitions sont exclues ! La valeur 100 ira donc dans la seconde partition.
Lors de l’insertion, les données sont redirigées vers leur partition, s’il y en a une :
INSERT INTO t2 VALUES (0);
ERROR: no PARTITION OF relation "t2" found for row DETAIL: Partition key of the failing row contains (c1) = (0).
INSERT INTO t2 VALUES (10, 'dix');
INSERT INTO t2 VALUES (100, 'cent');
INSERT INTO t2 VALUES (10000, 'dix mille');
SELECT * FROM t2 ;
c1 | c2
-------+-----------
10 | dix
100 | cent
10000 | dix mille (3 lignes)
SELECT * FROM t2_2 ;
c1 | c2
-------+-----------
100 | cent
10000 | dix mille (2 lignes)
La colonne système tableoid
permet de connaître la
partition d’où provient une ligne :
SELECT ctid, tableoid::regclass, * FROM t2 ;
ctid | tableoid | c1 | c2
-------+----------+-------+-----------
(0,1) | t2_1 | 10 | dix
(0,1) | t2_2 | 100 | cent (0,2) | t2_2 | 10000 | dix mille
Si la clé de partitionnement n’est pas évidente et que le besoin est surtout de répartir la volumétrie en partitions de tailles équivalentes, le partitionnement par hachage est adapté. Voici comment partitionner par hachage une table en trois partitions :
CREATE TABLE t3(c1 integer, c2 text) PARTITION BY HASH (c1);
CREATE TABLE t3_1 PARTITION OF t3 FOR VALUES WITH (modulus 3, remainder 0);
CREATE TABLE t3_2 PARTITION OF t3 FOR VALUES WITH (modulus 3, remainder 1);
CREATE TABLE t3_3 PARTITION OF t3 FOR VALUES WITH (modulus 3, remainder 2);
Une grosse insertion de données répartira les données de manière équitable entre les différentes partitions :
INSERT INTO t3 SELECT generate_series(1, 1000000);
SELECT relname, count(*) FROM t3
JOIN pg_class ON t3.tableoid=pg_class.oid
GROUP BY 1;
relname | count
---------+--------
t3_1 | 333263
t3_2 | 333497 t3_3 | 333240
Avec le partitionnement par intervalle, il est possible de créer les partitions en utilisant plusieurs colonnes. On profitera de l’exemple ci-dessous pour montrer l’utilisation conjointe de tablespaces différents. Commençons par créer les tablespaces :
CREATE TABLESPACE ts0 LOCATION '/tablespaces/ts0';
CREATE TABLESPACE ts1 LOCATION '/tablespaces/ts1';
CREATE TABLESPACE ts2 LOCATION '/tablespaces/ts2';
CREATE TABLESPACE ts3 LOCATION '/tablespaces/ts3';
Créons maintenant la table partitionnée et deux partitions :
CREATE TABLE t2(c1 integer, c2 text, c3 date not null)
PARTITION BY RANGE (c1, c3);
CREATE TABLE t2_1 PARTITION OF t2
FOR VALUES FROM (1,'2017-08-10') TO (100, '2017-08-11')
TABLESPACE ts1;
CREATE TABLE t2_2 PARTITION OF t2
FOR VALUES FROM (100,'2017-08-11') TO (200, '2017-08-12')
TABLESPACE ts2;
La borne supérieure étant exclue, la valeur
(100, '2017-08-11')
fera donc partie de la seconde
partition. Si les valeurs sont bien comprises dans les bornes, tout va
bien :
INSERT INTO t2 VALUES (1, 'test', '2017-08-10');
INSERT INTO t2 VALUES (150, 'test2', '2017-08-11');
Mais si la valeur pour c1
est trop petite :
INSERT INTO t2 VALUES (0, 'test', '2017-08-10');
ERROR: no partition of relation "t2" found for row DÉTAIL : Partition key of the failing row contains (c1, c3) = (0, 2017-08-10).
De même, si la valeur pour c3
(colonne de type date) est
antérieure :
INSERT INTO t2 VALUES (1, 'test', '2017-08-09');
ERROR: no partition of relation "t2" found for row DÉTAIL : Partition key of the failing row contains (c1, c3) = (1, 2017-08-09).
Les valeurs spéciales MINVALUE
et MAXVALUE
permettent de ne pas indiquer de valeur de seuil limite. Les partitions
t2_0
et t2_3
pourront par exemple être
déclarées comme suit et permettront d’insérer les lignes qui étaient
ci-dessus en erreur.
CREATE TABLE t2_0 PARTITION OF t2
FOR VALUES FROM (MINVALUE, MINVALUE) TO (1,'2017-08-10')
TABLESPACE ts0;
CREATE TABLE t2_3 PARTITION OF t2
FOR VALUES FROM (200,'2017-08-12') TO (MAXVALUE, MAXVALUE)
TABLESPACE ts3;
Enfin, on peut consulter la table pg_class
afin de
vérifier la présence des différentes partitions :
ANALYZE t2;
SELECT relname,relispartition,relkind,reltuples
FROM pg_class WHERE relname LIKE 't2%';
relname | relispartition | relkind | reltuples
---------+----------------+---------+-----------
t2 | f | p | 0
t2_0 | t | r | 2
t2_1 | t | r | 1
t2_2 | t | r | 1 t2_3 | t | r | 0
Performances :
Si le premier champ de la clé de partitionnement n’est pas fourni, il semble que l’optimiseur ne sache pas cibler correctement les partitions. Il balaiera toutes les partitions. Ce peut être gênant si ce premier champ n’est pas systématiquement présent dans les requêtes.
Le sous-partitionnement est une alternative à étudier, également plus souple.
Il faut faire attention à ce que le nombre de combinaisons possibles ne mène pas à trop de partitions.
Principe :
Les partitions sont des tables à part entière, qui peuvent donc être elles-mêmes partitionnées. Ce peut être utile si les requêtes alternent entre deux schémas d’accès.
Exemple :
L’exemple ci-dessus crée deux partitions selon statut
(objets_123
et objets_45
). La première
partition est elle-même sous-partitionnée par année
(objets_123_2023
et objets_123_2024
). Cela
permet par exemple, de faciliter la purge des données ou d’accélérer le
temps de traitement si l’on requête sur une année entière. Il n’a pas
été jugé nécessaire de sous-partitionner la seconde partition
objets_45
(par exemple parce qu’elle est petite).
\dt objets*
Liste des relations
Schéma | Nom | Type | Propriétaire
--------+-----------------+--------------------+--------------
public | objets | table partitionnée | postgres
public | objets_123 | table partitionnée | postgres
public | objets_123_2023 | table | postgres
public | objets_123_2024 | table | postgres public | objets_45 | table | postgres
Il n’est pas obligatoire de sous-partitionner avec la même technique (liste, intervalle, hachage…) que le partitionnement de plus haut niveau.
Il n’y a pas besoin de fournir la première clé de partitionnement pour que les sous-partitions soient directement accessibles :
EXPLAIN (COSTS OFF) SELECT * FROM objets
WHERE annee = 2023 ;
QUERY PLAN
--------------------------------------------
Append
-> Seq Scan on objets_123_2023 objets_1
Filter: (annee = 2023)
-> Seq Scan on objets_45 objets_2 Filter: (annee = 2023)
Fournir uniquement la première clé de partitionnement entraînera le parcours de toutes les sous-partitions concernées :
EXPLAIN (COSTS OFF) SELECT * FROM objets
WHERE statut = 3 ;
QUERY PLAN
--------------------------------------------
Append
-> Seq Scan on objets_123_2023 objets_1
Filter: (statut = 3)
-> Seq Scan on objets_123_2024 objets_2 Filter: (statut = 3)
Bien sûr, l’idéal est de fournir les deux clés de partitionnement pour n’accéder qu’à une partition :
EXPLAIN (COSTS OFF) SELECT * FROM objets
WHERE statut = 3 AND annee = 2024 ;
QUERY PLAN
---------------------------------------------
Seq Scan on objets_123_2024 objets Filter: ((statut = 3) AND (annee = 2024))
Cette fonctionnalité peut être ponctuellement utile, mais il ne faut pas en abuser en raison de la complexité supplémentaire. Toutes les clés de partitionnement devront se retrouver dans les clés primaires techniques des tables. Le nombre de partitions peut devenir très important.
Comparaison avec le partitionnement multicolonne :
Le partitionnement multicolonne est conceptuellement plus simple. Pour un même besoin, le nombre de partitions est identique. Mais le sous-partitionnement est plus souple :
Ajouter une partition par défaut permet de ne plus avoir d’erreur au cas où une partition n’est pas définie. Par exemple :
CREATE TABLE t1(c1 integer, c2 text) PARTITION BY LIST (c1);
CREATE TABLE t1_a PARTITION OF t1 FOR VALUES IN (1, 2, 3);
CREATE TABLE t1_b PARTITION OF t1 FOR VALUES IN (4, 5);
INSERT INTO t1 VALUES (0);
ERROR: no PARTITION OF relation "t1" found for row DETAIL: Partition key of the failing row contains (c1) = (0).
INSERT INTO t1 VALUES (6);
ERROR: no PARTITION OF relation "t1" found for row DETAIL: Partition key of the failing row contains (c1) = (6).
-- partition par défaut
CREATE TABLE t1_defaut PARTITION OF t1 DEFAULT ;
-- on réessaie l'insertion
INSERT INTO t1 VALUES (0);
INSERT INTO t1 VALUES (6);
SELECT tableoid::regclass, * FROM t1;
tableoid | c1 | c2
-----------+----+----
t1_a | 1 |
t1_a | 2 |
t1_b | 5 |
t1_defaut | 0 | t1_defaut | 6 |
Comme la partition par défaut risque d’être parcourue intégralement à chaque ajout d’une nouvelle partition, il vaut mieux la garder de petite taille.
Un partitionnement par hachage ne peut posséder de table par défaut puisque les données sont forcément aiguillées vers une partition ou une autre.
Ajouter une table comme partition d’une table partitionnée est possible mais cela nécessite de vérifier que la contrainte de partitionnement est valide pour toute la table attachée, et que la partition par défaut ne contient pas de données qui devraient figurer dans cette nouvelle partition. Cela résulte en un parcours complet de la table attachée, et de la partition par défaut si elle existe, ce qui sera d’autant plus lent qu’elles sont volumineuses.
Ce peut être très coûteux en disque, mais le plus gros problème est
la durée du verrou sur la table partitionnée, pendant toute cette
opération. Il est donc conseillé d’ajouter une contrainte
CHECK
adéquate avant l’ATTACH
: la durée du verrou sera raccourcie d’autant.
Si des lignes pour cette nouvelle partition figurent déjà dans la partition par défaut, des opérations supplémentaires sont à réaliser pour les déplacer. Ce n’est pas automatique.
Exemple :
set ECHO all
\set timing off
\
DROP TABLE IF EXISTS t1 ;
-- Une table partitionnée avec partition par défaut
CREATE TABLE t1 (c1 integer, filler char (10)) PARTITION BY LIST (c1);
CREATE TABLE t1_123 PARTITION OF t1 FOR VALUES IN (1, 2, 3);
CREATE TABLE t1_45 PARTITION OF t1 FOR VALUES IN (4, 5);
CREATE TABLE t1_default PARTITION OF t1 DEFAULT ;
-- Données d'origine
INSERT INTO t1 SELECT 1+mod(i,5) FROM generate_series (1,5000000) i;
-- Les données sont bien dans les partitions t1_123 et t1_45
SELECT tableoid::regclass, c1, count(*) FROM t1
GROUP BY 1,2 ORDER BY c1 ;
-- Création d'une table pour les valeurs 6 à attacher
CREATE TABLE t1_6 (LIKE t1 INCLUDING ALL) ;
INSERT INTO t1_6 SELECT 6 FROM generate_series (1,1000000);
+ t1*
\dt
on
\timing -- on attache la table : elle est scannée, ce qui est long
ALTER TABLE t1 ATTACH PARTITION t1_6 FOR VALUES IN (6) ;
-- noter la nouvelle contrainte sur la table
+ t1_6
\d-- on la détache, la contrainte a disparu
ALTER TABLE t1 DETACH PARTITION t1_6 ;
+ t1_6
\d
-- on remet manuellement la même contrainte que ci-dessus
-- (ce qui reste long mais ne pose pas de verrou sur t1)
ALTER TABLE t1_6 ADD CONSTRAINT t1_6_ck CHECK(c1 IS NOT NULL AND c1 = 6) ;
+ t1_6
\d-- l'ATTACH est cette fois presque instantané
ALTER TABLE t1 ATTACH PARTITION t1_6 FOR VALUES IN (6) ;
off
\timing
-- On insère par erreur des valeurs 7 sans avoir fait la partition
-- (et sans avoir le droit de les enlever de t1 ensuite)
INSERT INTO t1 SELECT 7 FROM generate_series (1,100);
-- Créer la partition échoue avec "constraint for default partition "t1_default" would be violated""
CREATE TABLE t1_7 PARTITION OF t1 FOR VALUES IN (7);
-- Pour corriger cela, au sein d'une transaction,
-- on transfére les données de la partition par défaut
-- vers une nouvelle table qui est ensuite attachée
CREATE TABLE t1_7 (LIKE t1 INCLUDING ALL) ;
ALTER TABLE t1_7 ADD CONSTRAINT t1_7_ck CHECK(c1 IS NOT NULL AND c1 = 7) ;
BEGIN ;
INSERT INTO t1_7 SELECT * FROM t1_default WHERE c1=7 ;
DELETE FROM t1_default WHERE c1=7 ;
ALTER TABLE t1 ATTACH PARTITION t1_7 FOR VALUES IN (7) ;
COMMIT ;
Détacher une partition est beaucoup plus rapide qu’en attacher une. En effet, il n’est pas nécessaire de procéder à des vérifications sur les données des partitions. La partition détachée devient alors une table tout à fait classique. Elle conserve les index, contraintes, etc. dont elle a pu hériter de la table partitionnée originale.
Cependant, il reste nécessaire d’acquérir un verrou exclusif sur la
table partitionnée, ce qui peut prendre du temps si des transactions
sont en cours d’exécution. L’option CONCURRENTLY
(à partir
de PostgreSQL 14) mitige le problème malgré quelques
restrictions, notamment : pas d’utilisation dans une transaction,
incompatibilité avec la présence d’une partition par défaut, et
nécessité d’une commande FINALIZE
si l’ordre a échoué ou
été interrompu.
Une partition étant une table, supprimer la table revient à supprimer la partition, et bien sûr les données qu’elle contient. Il n’y a pas besoin de la détacher explicitement auparavant. L’opération est simple et rapide, mais demande un verrou exclusif.
Il est fréquent de partitionner par date pour profiter de cette facilité dans la purge des vieilles données, et réduire énormément sa durée mais aussi les écritures de journaux.
Voici le jeu de tests pour l’exemple qui suivra. Il illustre également l’utilisation de sous-partitions (ici sur la même clé, mais cela n’a rien d’obligatoire).
-- Table partitionnée
CREATE TABLE logs (dreception timestamptz, contenu text) PARTITION BY RANGE(dreception);
-- Partition 2018, elle-même partitionnée
CREATE TABLE logs_2018 PARTITION OF logs FOR VALUES FROM ('2018-01-01') TO ('2019-01-01')
PARTITION BY range(dreception);
-- Sous-partitions 2018
CREATE TABLE logs_201801 PARTITION OF logs_2018 FOR VALUES FROM ('2018-01-01') TO ('2018-02-01');
CREATE TABLE logs_201802 PARTITION OF logs_2018 FOR VALUES FROM ('2018-02-01') TO ('2018-03-01');
…-- Idem en 2019
CREATE TABLE logs_2019 PARTITION OF logs FOR VALUES FROM ('2019-01-01') TO ('2020-01-01')
PARTITION BY range(dreception);
CREATE TABLE logs_201901 PARTITION OF logs_2019 FOR VALUES FROM ('2019-01-01') TO ('2019-02-01');
…
Et voici le test des différentes fonctions :
SELECT pg_partition_root('logs_2019');
pg_partition_root
------------------- logs
SELECT pg_partition_root('logs_201901');
pg_partition_root
------------------- logs
SELECT pg_partition_ancestors('logs_2018');
pg_partition_ancestors
------------------------
logs_2018 logs
SELECT pg_partition_ancestors('logs_201901');
pg_partition_ancestors
------------------------
logs_201901
logs_2019 logs
SELECT * FROM pg_partition_tree('logs');
relid | parentrelid | isleaf | level
-------------+-------------+--------+-------
logs | | f | 0
logs_2018 | logs | f | 1
logs_2019 | logs | f | 1
logs_201801 | logs_2018 | t | 2
logs_201802 | logs_2018 | t | 2 logs_201901 | logs_2019 | t | 2
Noter les propriétés de « feuille » (leaf) et le niveau de profondeur dans le partitionnement.
Sous psql, \d
affichera toutes les tables, partitions
comprises, ce qui peut vite encombrer l’affichage. \dP
affiche uniquement les tables et index partitionnés :
=# \dP
Liste des relations partitionnées
Schéma | Nom | Propriétaire | Type | Table
--------+----------+--------------+--------------------+----------
public | logs | postgres | table partitionnée | public | t2 | postgres | index partitionné | bigtable
La table système pg_partitioned_table
permet des requêtes plus complexes. Le champ
pg_class.relpartbound
contient les définitions des clés de partitionnement.
Pour masquer les partitions dans certains outils, il peut être intéressant de déclarer les partitions dans un schéma différent de la table principale.
Dans un cadre « multitenant » avec de nombreux schémas, et des
partitions de même noms chacune dans son schéma, positionner
search_path
permet de sélectionner implicitement la
partition, facilitant la vie au développeur ou permettant de « mentir »
à l’application.
Le partitionnement impose une contrainte importante sur la modélisation : la clé de partitionnement doit impérativement faire partie de la clé primaire (ainsi que des contraintes et index uniques). En effet, PostgreSQL ne maintient pas d’index global couvrant toutes les partitions. Il ne peut donc garantir l’unicité d’un champ qu’au sein de chaque partition.
Dans beaucoup de cas cela ne posera pas de problème, notamment si on
partitionne justement sur tout ou partie de cette clé primaire. Dans
d’autres cas, c’est plus gênant. Si la vraie clé primaire est un
identifiant géré par la base à l’insertion (serial
ou
IDENTITY
), le risque reste limité. Mais avec des
identifiants générés côté applicatif, il y a un risque d’introduire un
doublon. Dans le cas où les valeurs de la clé de partitionnement ne sont
pas une simple constante (par exemple des dates au lieu d’une seule
année), le problème peut être mitigé en ajoutant une contrainte unique
directement sur chaque partition, garantissant l’unicité de la clé
primaire réelle au moins au sein de la partition.
Une solution générale est de créer une autre table non partitionnée avec la clé primaire réelle, et une contrainte vers cette table depuis la table partitionnée. Conceptuellement, cela est équivalent à ne pas partitionner une grosse table mais à en « sortir » les données dans une sous-table partitionnée portant une contrainte.
Exemple :
-- On voudrait partitionner ainsi mais le moteur refuse
CREATE TABLE factures_p
id bigint PRIMARY KEY,
(
d timestamptz,int NOT NULL,
id_client int NOT NULL DEFAULT 0)
montant_c PARTITION BY RANGE (d);
ERROR: unique constraint on partitioned table must include all partitioning columns DÉTAIL : PRIMARY KEY constraint on table "factures_p" lacks column "d" which is part of the partition key.
-- On se rabat sur une clé primaire incluant la date
CREATE TABLE factures_p
id bigint NOT NULL,
(NOT NULL,
d timestamptz int,
id_client int,
montant_c PRIMARY KEY (id, d)
)PARTITION BY RANGE (d);
CREATE TABLE factures_p_202310 PARTITION OF factures_p
FOR VALUES FROM ('2023-10-01') TO ('2023-11-01');
CREATE TABLE factures_p_202311 PARTITION OF factures_p
FOR VALUES FROM ('2023-11-01') TO ('2023-12-01');
ALTER TABLE factures_p_202310 ADD CONSTRAINT factures_p_202310_uq UNIQUE (id);
-- Ces contraintes sécurisent les clés primaire au niveau partition
ALTER TABLE factures_p_202311 ADD CONSTRAINT factures_p_202311_uq UNIQUE (id);
-- Ajout de quelques lignes de 1 à 5 sur les deux partitions
INSERT INTO factures_p (id, d, id_client)
SELECT i, '2023-10-26'::timestamptz+i*interval '2 days', 42 FROM generate_series (1,5) i;
BEGIN ;
-- Ce doublon est accepté car les deux valeurs 3 ne sont pas dans la même partition
INSERT INTO factures_p (id, d, id_client)
SELECT 3, '2023-11-01'::timestamptz-interval '1s', 42 ;
-- Vérification que 3 est en double
SELECT tableoid::regclass, id, d FROM factures_p ORDER BY id ;
ROLLBACK ;
-- Cette table permet de garantir l'unicité dans toutes les partitions
CREATE TABLE factures_ref (id bigint NOT NULL PRIMARY KEY,
NOT NULL,
d timestamptz UNIQUE (id,d) -- nécessaire pour la contrainte
) ;
INSERT INTO factures_ref SELECT id,d FROM factures_p ;
-- Contrainte depuis la table partitionnée
ALTER TABLE factures_p ADD CONSTRAINT factures_p_id_fk
FOREIGN KEY (id, d) REFERENCES factures_ref (id,d);
-- Par la suite, il faut insérer chaque nouvelle valeur de `id`
-- dans les deux tables
-- Ce doublon est à présent correctement rejeté :
WITH ins AS (
INSERT INTO factures_p (id, d, id_client)
SELECT 3, '2023-11-01'::timestamptz-interval '1s', 42
RETURNING id,d )
INSERT INTO factures_ref
SELECT id, d FROM ins ;
ERROR: duplicate key value violates unique constraint "factures_ref_pkey" DÉTAIL : Key (id)=(3) already exists.
Les index sont propagés de la table mère aux partitions : tout index créé sur la table partitionnée sera automatiquement créé sur les partitions existantes. Toute nouvelle partition disposera des index de la table partitionnée. La suppression d’un index se fait sur la table partitionnée et concernera toutes les partitions. Il n’est pas possible de supprimer un tel index d’une seule partition.
Gérer des index manuellement sur certaines partitions est possible. Par exemple, on peut n’avoir besoin de certains index que sur les partitions de données récentes, et ne pas les créer sur des partitions de données d’archives.
Une clé primaire ou unique peut exister sur une table partitionnée (mais elle devra contenir toutes les colonnes de la clé de partitionnement) ; ainsi qu’une clé étrangère d’une table partitionnée vers une table normale.
Depuis PostgreSQL 12, il est possible de créer une clé étrangère
vers une table partitionnée de la même manière qu’entre deux
tables normales. Par exemple, si les tables ventes
et
lignes_ventes
sont toutes deux partitionnées :
ALTER TABLE lignes_ventes
ADD CONSTRAINT lignes_ventes_ventes_fk
FOREIGN KEY (vente_id) REFERENCES ventes (vente_id) ;
Noter que les versions 10 et 11 possèdent des limites sur ces fonctionnalités, que l’on peut souvent contourner en créant index et contraintes manuellement sur chaque partition.
Cibler la partition par la clé :
Si la clé de partitionnement n’est pas fournie, l’exécution concernera toutes les partitions, qu’elles soient accédées par Seq Scan ou Index Scan.Autant que possible, le développeur doit fournir la clé de partitionnement dans chaque requête, et le plan ciblera directement la bonne partition.
Comme avec les index, il faut vérifier que la clé est bien claire pour PostgreSQL. Si ce n’est pas le cas toutes les partitions seront lues :
EXPLAIN SELECT * FROM pgbench_accounts WHERE aid + 0 = 123 LIMIT 1 ;
QUERY PLAN
---------------------------------------------------------------------------------
Limit (cost=0.00..6.29 rows=1 width=97)
-> Append (cost=0.00..314250.00 rows=50000 width=97)
-> Seq Scan on pgbench_accounts_1 (cost=0.00..3140.00 rows=500 width=97)
Filter: ((aid + 0) = 123)
-> Seq Scan on pgbench_accounts_2 (cost=0.00..3140.00 rows=500 width=97)
Filter: ((aid + 0) = 123)
…
…
Filter: ((aid + 0) = 123)
-> Seq Scan on pgbench_accounts_99 (cost=0.00..3140.00 rows=500 width=97)
Filter: ((aid + 0) = 123)
-> Seq Scan on pgbench_accounts_100 (cost=0.00..3140.00 rows=500 width=97) Filter: ((aid + 0) = 123)
alors que si PostgreSQL reconnaît la clé de partitionnement :
EXPLAIN SELECT * FROM pgbench_accounts WHERE aid = 123 LIMIT 1 ;
QUERY PLAN
---------------------------------------------------------------------------------
Limit (cost=0.29..8.31 rows=1 width=97)
-> Index Scan using pgbench_accounts_1_pkey on pgbench_accounts_1 pgbench_accounts (cost=0.29..8.31 rows=1 width=97) Index Cond: (aid = 123)
Partition pruning :
Dans le cas où la clé de partitionnement dépend du résultat d’un
calcul, d’une sous-requête ou d’une jointure, PostgreSQL prévoit un plan
concernant toutes les partitions, mais élaguera à l’exécution les appels
aux partitions non concernées. Ci-dessous, seule
pgbench_accounts_8
est interrogé (et ce peut être une autre
partition si l’on répète la requête) :
EXPLAIN (ANALYZE,COSTS OFF)
SELECT * FROM pgbench_accounts WHERE aid = (SELECT (random()*1000000)::int ) ;
QUERY PLAN
---------------------------------------------------------------------------------
Append (actual time=23.083..23.101 rows=1 loops=1)
InitPlan 1 (returns $0)
-> Result (actual time=0.001..0.002 rows=1 loops=1)
-> Index Scan using pgbench_accounts_1_pkey on pgbench_accounts_1 (never executed)
Index Cond: (aid = $0)
-> Index Scan using pgbench_accounts_2_pkey on pgbench_accounts_2 (never executed)
Index Cond: (aid = $0)
-> Index Scan using pgbench_accounts_3_pkey on pgbench_accounts_3 (never executed)
Index Cond: (aid = $0)
-> Index Scan using pgbench_accounts_4_pkey on pgbench_accounts_4 (never executed)
Index Cond: (aid = $0)
-> Index Scan using pgbench_accounts_5_pkey on pgbench_accounts_5 (never executed)
Index Cond: (aid = $0)
-> Index Scan using pgbench_accounts_6_pkey on pgbench_accounts_6 (never executed)
Index Cond: (aid = $0)
-> Index Scan using pgbench_accounts_7_pkey on pgbench_accounts_7 (never executed)
Index Cond: (aid = $0)
-> Index Scan using pgbench_accounts_8_pkey on pgbench_accounts_8 (actual time=23.077..23.080 rows=1 loops=1)
Index Cond: (aid = $0)
-> Index Scan using pgbench_accounts_9_pkey on pgbench_accounts_9 (never executed)
Index Cond: (aid = $0)
…
…
-> Index Scan using pgbench_accounts_100_pkey on pgbench_accounts_100 (never executed)
Index Cond: (aid = $0)
Planning Time: 1.118 ms Execution Time: 23.341 ms
Temps de planification :
Une limitation sérieuse du partitionnement tient au temps de planification qui augmente très vite avec le nombre de partitions, même petites. En effet, chaque partition ajoute ses statistiques et souvent plusieurs index aux tables système. Par exemple, dans le cas le plus défavorable d’une session qui démarre :
-- Base pgbench de taille 100, non partitionnée
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT * FROM pgbench_accounts WHERE aid = 123 LIMIT 1 ;
QUERY PLAN
---------------------------------------------------------------------------------
Limit (actual time=0.021..0.022 rows=1 loops=1)
Buffers: shared hit=4
-> Index Scan using pgbench_accounts_pkey on pgbench_accounts (actual time=0.021..0.021 rows=1 loops=1)
Index Cond: (aid = 123)
Buffers: shared hit=4
Planning:
Buffers: shared hit=70
Planning Time: 0.358 ms Execution Time: 0.063 ms
-- Base pgbench de taille 100, partitionnée en 100 partitions
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT * FROM pgbench_accounts WHERE aid = 123 LIMIT 1 ;
QUERY PLAN
---------------------------------------------------------------------------------
Limit (actual time=0.015..0.016 rows=1 loops=1)
Buffers: shared hit=3
-> Index Scan using pgbench_accounts_1_pkey on pgbench_accounts_1 pgbench_accounts (actual time=0.015..0.015 rows=1 loops=1)
Index Cond: (aid = 123)
Buffers: shared hit=3
Planning:
Buffers: shared hit=423
Planning Time: 1.030 ms Execution Time: 0.061 ms
La section Planning
indique le nombre de blocs qu’une
session qui démarre doit mettre en cache, liés notamment aux tables
systèmes et statistiques (ce phénomène est encore une raison d’éviter
des sessions trop courtes). Dans cet exemple, sur la base partitionnée,
il y a presque six fois plus de ces blocs, et on triple le temps de
planification, qui reste raisonnable.
En général, on considère qu’il ne faut pas dépasser 100 partitions si l’on ne veut pas pénaliser les transactions courtes. Les dernières versions de PostgreSQL sont cependant meilleures sur ce point.
Ce problème de planification est moins gênant pour les requêtes longues (analytiques).
Pour contourner cette limite, il est possible d’utiliser directement les partitions, s’il est facile pour le développeur (ou le générateur de code…) de trouver leur nom, en plus de toujours fournir la clé. Interroger directement une partition est en effet aussi rapide à planifier qu’interroger une table monolithique :
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT * FROM pgbench_accounts_1 WHERE aid = 123 LIMIT 1 ;
QUERY PLAN
---------------------------------------------------------------------------------
Limit (actual time=0.006..0.007 rows=1 loops=1)
Buffers: shared hit=3
-> Index Scan using pgbench_accounts_1_pkey on pgbench_accounts_1 (actual time=0.006..0.006 rows=1 loops=1)
Index Cond: (aid = 123)
Buffers: shared hit=3
Planning Time: 0.046 ms Execution Time: 0.016 ms
Utiliser directement les partitions est particulièrement économe si leur nombre est grand, mais on perd alors le côté « transparent » du partitionnement, et on augmente la complexité du code applicatif.
Paramètres « partitionwise » :
Dans des cas plus complexes, notamment en cas de jointure entre tables partitionnées, le temps de planification peut exploser. Par exemple, pour la requête suivante où la table partitionnée est jointe à elle-même, le plan sur la table non partitionnée, cache de session chaud, renvoie :
EXPLAIN (BUFFERS, COSTS OFF, SUMMARY ON) SELECT *
FROM pgbench_accounts a INNER JOIN pgbench_accounts b USING (aid)
WHERE a.bid = 55 ;
QUERY PLAN
---------------------------------------------------------------------------------
Gather
Workers Planned: 4
-> Nested Loop
-> Parallel Seq Scan on pgbench_accounts a
Filter: (bid = 55)
-> Index Scan using pgbench_accounts_pkey on pgbench_accounts b
Index Cond: (aid = a.aid)
Planning:
Buffers: shared hit=16 Planning Time: 0.168 ms
Avec cent partitions, le temps de planification est ici multiplié par 50 :
Gather
Workers Planned: 4
-> Parallel Hash Join
Hash Cond: (b.aid = a.aid)
-> Parallel Append
-> Parallel Seq Scan on pgbench_accounts_1 b_1
-> Parallel Seq Scan on pgbench_accounts_2 b_2
…
-> Parallel Seq Scan on pgbench_accounts_99 b_99
-> Parallel Seq Scan on pgbench_accounts_100 b_100
-> Parallel Hash
-> Parallel Append
-> Parallel Seq Scan on pgbench_accounts_1 a_1
Filter: (bid = 55)
-> Parallel Seq Scan on pgbench_accounts_2 a_2
Filter: (bid = 55)
…
-> Parallel Seq Scan on pgbench_accounts_99 a_99
Filter: (bid = 55)
-> Parallel Seq Scan on pgbench_accounts_100 a_100
Filter: (bid = 55) Planning Time: 5.513 ms
Ce plan est perfectible : il récupère tout
pgbench_accounts
et le joint à toute la table. Il serait
plus intelligent de travailler partition par partition puisque la clé de
jointure est celle de partitionnement. Pour que PostgreSQL cherche à
faire ce genre de chose, un paramètre doit être activé :
SET enable_partitionwise_join TO on ;
Les jointures se font alors entre partitions :
Gather
Workers Planned: 4
-> Parallel Append
-> Parallel Hash Join
Hash Cond: (a_55.aid = b_55.aid)
-> Parallel Seq Scan on pgbench_accounts_55 a_55
Filter: (bid = 55)
-> Parallel Hash
-> Parallel Seq Scan on pgbench_accounts_55 b_55
…
-> Nested Loop
-> Parallel Seq Scan on pgbench_accounts_100 a_100
Filter: (bid = 55)
-> Index Scan using pgbench_accounts_100_pkey on pgbench_accounts_100 b_100
Index Cond: (aid = a_100.aid)
Planning:
Buffers: shared hit=1200 Planning Time: 12.449 ms
Le temps d’exécution passe de 1,2 à 0,2 s, ce qui justifie les quelques millisecondes perdues en plus en planification.
Un autre paramètre est à activer si des agrégations sur plusieurs partitions sont à faire :
SET enable_partitionwise_aggregate TO on ;
enable_partitionwise_aggregate
et
enable_partitionwise_join
sont désactivés par défaut à
cause de leur coût en planification sur les petites requêtes, mais les
activer est souvent rentable. Avec SET
, cela peut se
décider requête par requête.
Les opérations de maintenance profitent grandement du fait de pouvoir scinder les opérations en autant d’étapes qu’il y a de partitions. Des données « froides » peuvent être déplacées dans un autre tablespace sur des disques moins chers, partition par partition, ce qui est impossible avec une table monolithique :
ALTER TABLE pgbench_accounts_8 SET TABLESPACE hdd ;
L’autovacuum et l’autoanalyze fonctionnent normalement et indépendamment sur chaque partition, comme sur les tables classiques. Ainsi ils peuvent se déclencher plus souvent sur les partitions actives. Par rapport à une grosse table monolithique, il y a moins souvent besoin de régler l’autovacuum.
Les ordres ANALYZE
et VACUUM
peuvent être
effectués sur une partition, mais aussi sur la table partitionnée,
auquel cas l’ordre redescendra en cascade sur les partitions (l’option
VERBOSE
permet de le vérifier). Les statistiques seront
calculées par partition, donc plus précises.
Reconstruire une table partitionnée avec VACUUM FULL
se
fera généralement partition par partition. Le partitionnement permet
ainsi de résoudre les cas où le verrou sur une table monolithique serait
trop long, ou l’espace disque total serait insuffisant.
Noter cependant ces spécificités sur les tables partitionnées :
REINDEX :
À partir de PostgreSQL 14, un REINDEX
sur la table
partitionnée réindexe toutes les partitions automatiquement. Dans les
versions précédentes, il faut réindexer partition par partition.
ANALYZE :
L’autovacuum ne crée pas spontanément de statistiques sur les données pour la table partitionnée dans son ensemble, mais uniquement partition par partition. Pour obtenir des statistiques sur toute la table partitionnée, il faut exécuter manuellement :
ANALYZE table_partitionnée ;
Grâce au partitionnement, un export par pg_dump --jobs
devient efficace puisque plusieurs partitions peuvent être sauvegardées
en parallèle.
La parallélisation peut être aussi un peu meilleure avec un outil de sauvegarde physique (comme pgBackRest ou Barman), qui parallélise les copies de fichiers, mais les grosses tables non partitionnées étaient de toute façon déjà découpées en fichier de 1 Go.
pg_dump
a des options pour gérer l’export des tables
partitionnées :
--load-via-partition-root
permet de générer des ordres
COPY
ciblant la table mère et non la partition. Ce peut
être pratique pour restaurer les données dans une base où la table est
partitionnée séparément.
À partir de PostgreSQL 16, n’exporter qu’une table partitionnée se
fait avec --table-and-children
(et non
--table
/-t
qui ne concernerait que la table
mère). Exclure des tables partitionnées se fait avec
--exclude-table-and-children
(et non
--exclude-table
/-T
). Pour exclure uniquement
les données d’une table partitionnée en gardant sa structure, on
utilisera --exclude-table-data-and-children
. Ces trois
options acceptent un motif (par exemple :
pgbench_accounts_*
) et peuvent être répétées dans la
commande.
Une table partitionnée ne peut être convertie en table classique, ni vice-versa. (Par contre, une table classique peut être attachée comme partition, ou une partition détachée).
Les partitions ont forcément le même schéma de données que leur partition mère.
Leur création n’est pas automatisée : il faut les créer par avance manuellement ou par script planifié, et éventuellement prévoir une partition par défaut pour les cas qui ont pu être oubliés.
Les clés de partition ne doivent pas se recouvrir. Les contraintes ne peuvent s’exercer qu’au sein d’une même partition : les clés d’unicité doivent donc inclure toute la clé de partitionnement, les contraintes d’exclusion ne peuvent vérifier toutes les partitions.
Il n’y a pas de notion d’héritage multiple.
Éviter d’avoir trop de partitions, pour limiter les risques de dérapage du temps de planification. Si possible, cibler les requêtes directement sur les partitions qui les intéressent.
L’ordre CLUSTER
, pour réécrire une table dans l’ordre
d’un index donné, ne fonctionne pour les tables partitionnées qu’à
partir de PostgreSQL 15. Il peut toutefois être exécuté manuellement
table par table.
Un TRUNCATE
d’une table distante n’est pas possible
avant PostgreSQL 14.
Il est possible d’attacher comme partitions des tables distantes,
généralement déclarées avec postgres_fdw
; cependant la
propagation d’index ne fonctionnera pas sur ces tables. Il faudra les
créer manuellement sur les instances distantes. (Restriction
supplémentaire en version 10 : les partitions distantes ne sont
accessibles qu’en lecture, si accédées via la table mère.)
Les partitions par défaut n’existent pas en version 10.
Les limitations sur les index et clés primaires et étrangères avant la version 12 ont été évoquées plus haut.
Les triggers de lignes ne se propagent pas en version 10. En v11, on
peut créer des triggers AFTER UPDATE … FOR EACH ROW
, mais
les BEFORE UPDATE … FOR EACH ROW
ne peuvent toujours pas
être créés sur la table mère. Il reste là encore la possibilité de les
créer partition par partition au besoin. À partir de la version 13, les
triggers BEFORE UPDATE … FOR EACH ROW
sont possibles, mais
il ne permettent pas de modifier la partition de destination.
Enfin, la version 10 ne permet pas de faire une mise à jour
(UPDATE
) d’une ligne où la clé de partitionnement est
modifiée de telle façon que la ligne doit changer de partition. Il faut
faire un DELETE
et un INSERT
à la place.
On constate que des limitations évoquées plus haut dépendent des versions de PostgreSQL. Si le partitionnement vous intéresse, il est conseillé d’utiliser une version la plus récente possible, au moins PostgreSQL 13.
Il est possible d’attacher comme partitions des tables distantes
(situées sur d’autres serveurs), généralement déclarées avec le
Foreign Data Wrapper postgres_fdw
.
NB : Dans le reste de ce chapitre, nous nommerons table étrangère (foreign table dans la documentation officielle) l’objet qui sert d’interface pour accéder, depuis l’instance locale, à la table distante (remote table), qui contient réellement les données.
Par exemple, si trois instances en France, Allemagne et Espagne
possèdent chacune des tables clients
et
commandes
ne contenant que les données de leur pays, on
peut créer une autre instance utilisant des tables étrangères pour
accéder aux trois tables, chaque table étrangère étant une partition
d’une table partitionnée de cette instance européene.
Pour les instances nationales, cette instance européenne n’est qu’un
client comme un autre, qui envoie des requêtes, et ouvre parfois des
curseurs (fonctionnement normal de postgres_fdw
). Si le
pays est précisé dans une requête, la bonne partition est ciblée, et
l’instance européenne n’interroge qu’une seule instance nationale.
La maquette suivante donne une idée du fonctionnement :
-- Maquette rapide sous psql de sharding
-- avec trois bases demosharding_fr , _de, _es
-- et une base globale pour le requêtage
set timing off
\set ECHO all
\set ON_ERROR_STOP 1
\
connect postgres postgres serveur1
\DROP DATABASE IF EXISTS demosharding_fr ;
CREATE DATABASE demosharding_fr ;
ALTER DATABASE demosharding_fr SET log_min_duration_statement TO 0 ;
connect postgres postgres serveur2
\DROP DATABASE IF EXISTS demosharding_de ;
CREATE DATABASE demosharding_de ;
ALTER DATABASE demosharding_de SET log_min_duration_statement TO 0 ;
connect postgres postgres serveur3
\DROP DATABASE IF EXISTS demosharding_es ;
CREATE DATABASE demosharding_es ;
ALTER DATABASE demosharding_es SET log_min_duration_statement TO 0 ;
connect postgres postgres serveur4
\DROP DATABASE IF EXISTS demosharding_global ;
CREATE DATABASE demosharding_global ;
ALTER DATABASE demosharding_global SET log_min_duration_statement TO 0 ;
-- Tables identiques sur chaque serveur
connect demosharding_fr postgres serveur1
\
CREATE TABLE clients (id_client int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
nom text,char (2) DEFAULT 'FR' CHECK (pays = 'FR')
pays
) ;CREATE TABLE commandes (id_commande bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
char (2) DEFAULT 'FR' CHECK (pays = 'FR'),
pays int REFERENCES clients ,
id_client float
montant
);
connect demosharding_de postgres serveur2
\
CREATE TABLE clients (id_client int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
nom text,char (2) DEFAULT 'DE' CHECK (pays = 'DE')
pays
) ;CREATE TABLE commandes (id_commande bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
char (2) DEFAULT 'DE' CHECK (pays = 'DE') ,
pays int REFERENCES clients,
id_client float
montant
);
connect demosharding_es postgres serveur3
\
CREATE TABLE clients (id_client int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
nom text,char (2) DEFAULT 'ES' CHECK (pays = 'ES')
pays
) ;CREATE TABLE commandes (id_commande bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
char (2) DEFAULT 'ES' CHECK (pays = 'ES'),
pays int REFERENCES clients ,
id_client float
montant
);
-- Tables partitionnées globales
connect demosharding_global postgres serveur4
\
CREATE TABLE clients (id_client int, nom text, pays char(2))
PARTITION BY LIST (pays);
CREATE TABLE commandes (id_commande bigint, pays char(2),
int, montant float)
id_client PARTITION BY LIST (pays);
-- Serveurs distants (adapter les chaines de connexion)
-- NB : l'option async_capable n existe pas avant PostgreSQL 14
CREATE EXTENSION postgres_fdw ;
CREATE SERVER dist_fr
FOREIGN DATA WRAPPER postgres_fdw
'localhost', dbname 'demosharding_fr', port '16001',
OPTIONS (host 'on', fetch_size '10000') ;
async_capable
CREATE SERVER dist_de
FOREIGN DATA WRAPPER postgres_fdw
'localhost', dbname 'demosharding_de', port '16001',
OPTIONS (host 'on', fetch_size '10000') ;
async_capable
CREATE SERVER dist_es
FOREIGN DATA WRAPPER postgres_fdw
'localhost', dbname 'demosharding_es', port '16001',
OPTIONS (host 'on', fetch_size '10000') ;
async_capable
CREATE USER MAPPING IF NOT EXISTS FOR current_user SERVER dist_fr ;
CREATE USER MAPPING IF NOT EXISTS FOR current_user SERVER dist_de ;
CREATE USER MAPPING IF NOT EXISTS FOR current_user SERVER dist_es ;
-- Les partitions distantes
CREATE FOREIGN TABLE clients_fr PARTITION OF clients FOR VALUES IN ('FR')
'clients') ;
SERVER dist_fr OPTIONS (table_name CREATE FOREIGN TABLE clients_de PARTITION OF clients FOR VALUES IN ('DE')
'clients') ;
SERVER dist_de OPTIONS (table_name CREATE FOREIGN TABLE clients_es PARTITION OF clients FOR VALUES IN ('ES')
'clients') ;
SERVER dist_es OPTIONS (table_name
CREATE FOREIGN TABLE commandes_fr PARTITION OF commandes FOR VALUES IN ('FR')
'commandes') ;
SERVER dist_fr OPTIONS (table_name CREATE FOREIGN TABLE commandes_de PARTITION OF commandes FOR VALUES IN ('DE')
'commandes') ;
SERVER dist_de OPTIONS (table_name CREATE FOREIGN TABLE commandes_es PARTITION OF commandes FOR VALUES IN ('ES')
'commandes') ;
SERVER dist_es OPTIONS (table_name
-- Alimentations des pays (séparément)
connect demosharding_fr postgres serveur1
\
WITH ins_clients AS (
INSERT INTO clients (nom)
SELECT md5 (random()::text) FROM generate_series (1,10) i
WHERE random()<0.8
RETURNING id_client
),AS (
ins_commandes INSERT INTO commandes (id_client, montant)
SELECT c.id_client, random()*j::float
FROM ins_clients c CROSS JOIN generate_series (1,100000) j
WHERE random()<0.8
RETURNING *
)SELECT count(*) FROM ins_commandes ;
connect demosharding_de postgres serveur2
\
\g
connect demosharding_es postgres serveur3
\
\g
connect demosharding_global postgres serveur4
\
-- Les ANALYZE redescendent sur les partitions
ANALYZE (VERBOSE) clients, commandes ;
-- Pour un plan optimal
SET enable_partitionwise_join TO on ;
SET enable_partitionwise_aggregate TO on ;
-- Requête globale : top 8 des clients
-- Plan disponible sur https://explain.dalibo.com/plan/27f964651518a65g
SELECT pays,
nom,count(DISTINCT id_commande) AS nb_commandes,
avg(montant) AS montant_avg_commande,
sum(montant) AS montant_sum
FROM
INNER JOIN clients USING (pays, id_client)
commandes GROUP BY 1,2
ORDER BY montant_sum DESC
LIMIT 8 ;
Une nouveauté de PostgreSQL 14 est ici particulièrement
intéressante : l’option async_capable
du serveur étranger
(éventuellement de la table) peut être passée à on
(le
défaut est off
). Les nœuds Foreign Scan typiques
des accès distants sont alors remplacés par des nœuds Async Foreign
Scan (asynchrones), et le serveur principal interroge alors
simultanément les trois serveurs qui lui renvoient les données. Dans cet
extrait des traces, les ordres FETCH
sont entremêlés :
…user=postgres,db=demosharding_de,app=postgres_fdw,client=::1
LOG: duration: 0.384 ms execute <unnamed>: DECLARE c1 CURSOR FOR
SELECT id_commande, pays, id_client, montant FROM public.commandes
…user=postgres,db=demosharding_es,app=postgres_fdw,client=::1
LOG: duration: 0.314 ms execute <unnamed>: DECLARE c2 CURSOR FOR
SELECT id_commande, pays, id_client, montant FROM public.commandes
…user=postgres,db=demosharding_fr,app=postgres_fdw,client=::1
LOG: duration: 0.374 ms execute <unnamed>: DECLARE c3 CURSOR FOR
SELECT id_commande, pays, id_client, montant FROM public.commandes
…user=postgres,db=demosharding_de,app=postgres_fdw,client=::1
LOG: duration: 6.081 ms statement: FETCH 10000 FROM c1
…user=postgres,db=demosharding_es,app=postgres_fdw,client=::1
LOG: duration: 5.878 ms statement: FETCH 10000 FROM c2
…user=postgres,db=demosharding_fr,app=postgres_fdw,client=::1
LOG: duration: 6.263 ms statement: FETCH 10000 FROM c3
…user=postgres,db=demosharding_de,app=postgres_fdw,client=::1
LOG: duration: 2.418 ms statement: FETCH 10000 FROM c1
…user=postgres,db=demosharding_de,app=postgres_fdw,client=::1
LOG: duration: 2.397 ms statement: FETCH 10000 FROM c1
…user=postgres,db=demosharding_es,app=postgres_fdw,client=::1
LOG: duration: 2.423 ms statement: FETCH 10000 FROM c2
…user=postgres,db=demosharding_fr,app=postgres_fdw,client=::1 LOG: duration: 4.381 ms statement: FETCH 10000 FROM c3
Dans cette configuration, activer les paramètres
enable_partitionwise_join
et
enable_partitionwise_aggregate
est particulièrement
important. Dans l’idéal, comme dans le plan suivant (voir la
version complète), ces paramètres permettent que les agrégations et
les jointures soient « poussées » au niveau du nœud (et calculées
directement sur les serveurs distants) :
-> Append (cost=6105.41..110039.12 rows=21 width=60) (actual time=330.325..594.923 rows=21 loops=1)
-> Async Foreign Scan (cost=6105.41..28384.99 rows=6 width=60) (actual time=2.284..2.286 rows=6 loops=1)
Output: commandes.pays, clients.nom, (count(DISTINCT commandes.id_commande)), (avg(commandes.montant)), (sum(commandes.montant))
Relations: Aggregate on ((public.commandes_de commandes) INNER JOIN (public.clients_de clients))
Remote SQL: SELECT r4.pays, r7.nom, count(DISTINCT r4.id_commande), avg(r4.montant), sum(r4.montant) FROM (public.commandes r4 INNER JOIN public.clients r7 ON (((r4.pays = r7.pays)) AND ((r4.id_client = r7.id_client)))) GROUP BY 1, 2
-> Async Foreign Scan (cost=6098.84..28353.37 rows=6 width=60) (actual time=2.077..2.078 rows=6 loops=1)
Output: commandes_1.pays, clients_1.nom, (count(DISTINCT commandes_1.id_commande)), (avg(commandes_1.montant)), (sum(commandes_1.montant))
Relations: Aggregate on ((public.commandes_es commandes_1) INNER JOIN (public.clients_es clients_1))
Remote SQL: SELECT r5.pays, r8.nom, count(DISTINCT r5.id_commande), avg(r5.montant), sum(r5.montant) FROM (public.commandes r5 INNER JOIN public.clients r8 ON (((r5.pays = r8.pays)) AND ((r5.id_client = r8.id_client)))) GROUP BY 1, 2
-> Async Foreign Scan (cost=9102.09..53300.65 rows=9 width=60) (actual time=2.189..2.190 rows=9 loops=1)
Output: commandes_2.pays, clients_2.nom, (count(DISTINCT commandes_2.id_commande)), (avg(commandes_2.montant)), (sum(commandes_2.montant))
Relations: Aggregate on ((public.commandes_fr commandes_2) INNER JOIN (public.clients_fr clients_2)) Remote SQL: SELECT r6.pays, r9.nom, count(DISTINCT r6.id_commande), avg(r6.montant), sum(r6.montant) FROM (public.commandes r6 INNER JOIN public.clients r9 ON (((r6.pays = r9.pays)) AND ((r6.id_client = r9.id_client)))) GROUP BY 1, 2
Quand on utilise les tables étrangères, il est conseillée d’utiliser
EXPLAIN (VERBOSE)
, pour afficher les requêtes envoyées aux
serveurs distants et vérifier que le minimum de volumétrie transite sur
le réseau.
Cet exemple est une version un peu primitive de sharding, à réserver aux cas où les données sont clairement séparées. L’administration d’une configuration « multimaître » peut devenir compliquée : cohérence des différents schémas et des contraintes sur chaque instance, copie des tables de référence communes, risques de recouvrement des clés primaires entre bases, gestion des indisponibilités, sauvegardes cohérentes…
Noter que l’utilisation de partitions distantes rend impossible notamment la gestion automatique des index, il faut retourner à une manipulation table par table.
L’extension pg_partman, de Crunchy Data, est un complément aux systèmes de partitionnement de PostgreSQL. Elle est apparue d’abord pour automatiser le partitionnement par héritage. Elle peut être utile avec le partitionnement déclaratif, pour simplifier la maintenance d’un partitionnement sur une échelle temporelle ou de valeurs (par range).
PostgresPro proposait un outil nommé pg_pathman, à présent déprécié en faveur du partitionnement déclaratif intégré à PostgreSQL.
timescaledb est une extension spécialisée dans les séries temporelles. Basée sur le partitionnement par héritage, elle vaut surtout pour sa technique de compression et ses utilitaires. La version communautaire sur Github ne comprend pas tout ce qu’offre la version commerciale.
citus est une autre extension commerciale. Le principe est de partitionner agressivement les tables sur plusieurs instances, et d’utiliser simultanément les processeurs, disques de toutes ces instances (sharding). Citus gère la distribution des requêtes, mais pas la maintenance des instances PostgreSQL supplémentaires. L’éditeur Citusdata a été racheté par Microsoft, qui le propose à présent dans Azure. En 2022, l’entièreté du code est passée sous licence libre. Le gain de performance peut être impressionnant, mais attention : certaines requêtes se prêtent très mal au sharding.
Le partitionnement par héritage n’a plus d’utilité pour la plupart des applications.
Le partitionnement déclaratif apparu en version 10 est mûr dans les dernières versions. Il introduit une complexité supplémentaire, que les développeurs doivent maîtriser, mais peut rendre de grands services quand la volumétrie augmente.
Nous travaillons sur la base cave. 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
Nous allons partitionner la table stock
sur l’année.
Pour nous simplifier la vie, nous allons limiter le nombre d’années
dans stock
(cela nous évitera la création de 50 partitions)
:
-- Création de lignes en 2001-2005
INSERT INTO stock SELECT vin_id, contenant_id, 2001 + annee % 5, sum(nombre)
FROM stock GROUP BY vin_id, contenant_id, 2001 + annee % 5;
-- purge des lignes prédédentes
DELETE FROM stock WHERE annee<2001;
Nous n’avons maintenant que des bouteilles des années 2001 à 2005.
- Renommer
stock
enstock_old
.- Créer une table partitionnée
stock
vide, sans index pour le moment.
- Créer les partitions de
stock
, avec la contrainte d’année :stock_2001
àstock_2005
.
- Insérer tous les enregistrements venant de l’ancienne table
stock
.
- Passer les statistiques pour être sûr des plans à partir de maintenant (nous avons modifié beaucoup d’objets).
- Vérifier la présence d’enregistrements dans
stock_2001
(syntaxeSELECT ONLY
).- Vérifier qu’il n’y en a aucun dans
stock
.
- Vérifier qu’une requête sur
stock
sur 2002 ne parcourt qu’une seule partition.
- Remettre en place les index présents dans la table
stock
originale.- Il se peut que d’autres index ne servent à rien (ils ne seront dans ce cas pas présents dans la correction).
- Quel est le plan pour la récupération du stock des bouteilles du
vin_id
1725, année 2003 ?
- Essayer de changer l’année de ce même enregistrement de
stock
(la même que la précédente). Pourquoi cela échoue-t-il ?
- Supprimer les enregistrements de 2004 pour
vin_id
= 1725.- Retenter la mise à jour.
- Pour vider complètement le stock de 2001, supprimer la partition
stock_2001
.
- Tenter d’ajouter au stock une centaine de bouteilles de 2006.
- Pourquoi cela échoue-t-il ?
- Créer une partition par défaut pour recevoir de tels enregistrements.
- Retenter l’ajout.
- Tenter de créer la partition pour l’année 2006. Pourquoi cela échoue-t-il ?
- Pour créer la partition sur 2006, au sein d’une seule transaction :
- détacher la partition par défaut ;
- y déplacer les enregistrements mentionnés ;
- ré-attacher la partition par défaut.
Créer une base pgbench vierge, de taille 10 ou plus.
NB : Pour le TP, la base sera d’échelle 10 (environ 168 Mo). Des échelles 100 ou 1000 seraient plus réalistes.
Dans une fenêtre en arrière-plan, laisser tourner un processus
pgbench
avec une activité la plus soutenue possible. Il ne doit pas tomber en erreur pendant que les tables vont être partitionnées ! Certaines opérations vont poser des verrous, le but va être de les réduire au maximum.
Pour éviter un « empilement des verrous » et ne pas bloquer trop longtemps les opérations, faire en sorte que la transaction échoue si l’obtention d’un verrou dure plus de 10 s.
Pour partitionner la table
pgbench_accounts
par hash sur la colonneaid
sans que le traitement pgbench tombe en erreur, préparer un script avec, dans une transaction :
- la création d’une table partitionnée par hash en 3 partitions au moins ;
- le transfert des données depuis
pgbench_accounts
;- la substitution de la table partitionnée à la table originale.
Tester et exécuter.
Supprimer l’ancienne table
pgbench_accounts_old
.
pgbench
doit continuer ses opérations en tâche de
fond.
La table
pgbench_history
se remplit avec le temps. Elle doit être partitionnée par date (champmtime
). Pour le TP, on fera 2 partitions d’une minute, et une partition par défaut. La table actuelle doit devenir une partition de la nouvelle table partitionnée.
- Écrire un script qui, dans une seule transaction, fait tout cela et substitue la table partitionnée à la table d’origine.
NB : Pour éviter de coder des dates en dur, il est possible, avec
psql
, d’utiliser une variable :SELECT ( now()+ interval '60s') AS date_frontiere \gset SELECT :'date_frontiere'::timestamptz ;
Exécuter le script, attendre que les données s’insèrent dans les nouvelles partitions.
- Continuer de laisser tourner
pgbench
en arrière-plan.- Détacher et détruire la partition avec les données les plus anciennes.
- Ajouter une clé étrangère entre
pgbench_accounts
etpgbench_history
. Voir les contraintes créées.
Si vous n’avez pas déjà eu un problème à cause du
statement_timeout
, dropper la contrainte et recommencer avec une valeur plus basse. Comment contourner ?
On veut créer un index sur
pgbench_history (aid)
.Pour ne pas gêner les écritures, il faudra le faire de manière concurrente. Créer l’index de manière concurrente sur chaque partition, puis sur la table partitionnée.
Pour nous simplifier la vie, nous allons limiter le nombre d’années
dans stock
(cela nous évitera la création de 50
partitions).
INSERT INTO stock
SELECT vin_id, contenant_id, 2001 + annee % 5, sum(nombre)
FROM stock
GROUP BY vin_id, contenant_id, 2001 + annee % 5 ;
DELETE FROM stock WHERE annee<2001 ;
Nous n’avons maintenant que des bouteilles des années 2001 à 2005.
- Renommer
stock
enstock_old
.- Créer une table partitionnée
stock
vide, sans index pour le moment.
ALTER TABLE stock RENAME TO stock_old;
CREATE TABLE stock(LIKE stock_old) PARTITION BY LIST (annee);
- Créer les partitions de
stock
, avec la contrainte d’année :stock_2001
àstock_2005
.
CREATE TABLE stock_2001 PARTITION of stock FOR VALUES IN (2001) ;
CREATE TABLE stock_2002 PARTITION of stock FOR VALUES IN (2002) ;
CREATE TABLE stock_2003 PARTITION of stock FOR VALUES IN (2003) ;
CREATE TABLE stock_2004 PARTITION of stock FOR VALUES IN (2004) ;
CREATE TABLE stock_2005 PARTITION of stock FOR VALUES IN (2005) ;
- Insérer tous les enregistrements venant de l’ancienne table
stock
.
INSERT INTO stock SELECT * FROM stock_old;
- Passer les statistiques pour être sûr des plans à partir de maintenant (nous avons modifié beaucoup d’objets).
ANALYZE;
- Vérifier la présence d’enregistrements dans
stock_2001
(syntaxeSELECT ONLY
).- Vérifier qu’il n’y en a aucun dans
stock
.
SELECT count(*) FROM stock_2001;
SELECT count(*) FROM ONLY stock;
- Vérifier qu’une requête sur
stock
sur 2002 ne parcourt qu’une seule partition.
EXPLAIN ANALYZE SELECT * FROM stock WHERE annee=2002;
QUERY PLAN
------------------------------------------------------------------------------
Append (cost=0.00..417.36 rows=18192 width=16) (...)
-> Seq Scan on stock_2002 (cost=0.00..326.40 rows=18192 width=16) (...)
Filter: (annee = 2002)
Planning Time: 0.912 ms Execution Time: 21.518 ms
- Remettre en place les index présents dans la table
stock
originale.- Il se peut que d’autres index ne servent à rien (ils ne seront dans ce cas pas présents dans la correction).
CREATE UNIQUE INDEX ON stock (vin_id,contenant_id,annee);
Les autres index ne servent à rien sur les partitions :
idx_stock_annee
est évidemment inutile, mais
idx_stock_vin_annee
aussi, puisqu’il est inclus dans
l’index unique que nous venons de créer.
- Quel est le plan pour la récupération du stock des bouteilles du
vin_id
1725, année 2003 ?
EXPLAIN ANALYZE SELECT * FROM stock WHERE vin_id=1725 AND annee=2003 ;
Append (cost=0.29..4.36 rows=3 width=16) (...)
-> Index Scan using stock_2003_vin_id_contenant_id_annee_idx on stock_2003 (...)
Index Cond: ((vin_id = 1725) AND (annee = 2003))
Planning Time: 1.634 ms Execution Time: 0.166 ms
- Essayer de changer l’année de ce même enregistrement de
stock
(la même que la précédente). Pourquoi cela échoue-t-il ?
UPDATE stock SET annee=2004 WHERE annee=2003 and vin_id=1725 ;
ERROR: duplicate key value violates unique constraint "stock_2004_vin_id_contenant_id_annee_idx" DETAIL: Key (vin_id, contenant_id, annee)=(1725, 1, 2004) already exists.
C’est une violation de contrainte unique, qui est une erreur normale : nous avons déjà un enregistrement de stock pour ce vin pour l’année 2004.
- Supprimer les enregistrements de 2004 pour
vin_id
= 1725.- Retenter la mise à jour.
DELETE FROM stock WHERE annee=2004 and vin_id=1725;
UPDATE stock SET annee=2004 WHERE annee=2003 and vin_id=1725 ;
- Pour vider complètement le stock de 2001, supprimer la partition
stock_2001
.
DROP TABLE stock_2001 ;
- Tenter d’ajouter au stock une centaine de bouteilles de 2006.
- Pourquoi cela échoue-t-il ?
INSERT INTO stock (vin_id, contenant_id, annee, nombre) VALUES (1, 1, 2006, 100) ;
ERROR: no partition of relation "stock" found for row DETAIL: Partition key of the failing row contains (annee) = (2006).
Il n’existe pas de partition définie pour l’année 2006, cela échoue donc.
- Créer une partition par défaut pour recevoir de tels enregistrements.
- Retenter l’ajout.
CREATE TABLE stock_default PARTITION OF stock DEFAULT ;
INSERT INTO stock (vin_id, contenant_id, annee, nombre) VALUES (1, 1, 2006, 100) ;
- Tenter de créer la partition pour l’année 2006. Pourquoi cela échoue-t-il ?
CREATE TABLE stock_2006 PARTITION of stock FOR VALUES IN (2006) ;
ERROR: updated partition constraint for default partition "stock_default" would be violated by some row
Cela échoue car des enregistrements présents dans la partition par défaut répondent à cette nouvelle contrainte de partitionnement.
- Pour créer la partition sur 2006, au sein d’une seule transaction :
- détacher la partition par défaut ;
- y déplacer les enregistrements mentionnés ;
- ré-attacher la partition par défaut.
BEGIN ;
ALTER TABLE stock DETACH PARTITION stock_default;
CREATE TABLE stock_2006 PARTITION of stock FOR VALUES IN (2006) ;
INSERT INTO stock SELECT * FROM stock_default WHERE annee = 2006 ;
DELETE FROM stock_default WHERE annee = 2006 ;
ALTER TABLE stock ATTACH PARTITION stock_default DEFAULT ;
COMMIT ;
Créer une base pgbench vierge, de taille 10 ou plus.
$ createdb pgbench
$ /usr/pgsql-14/bin/pgbench -i -s 10 pgbench
Dans une fenêtre en arrière-plan, laisser tourner un processus
pgbench
avec une activité la plus soutenue possible. Il ne doit pas tomber en erreur pendant que les tables vont être partitionnées ! Certaines opérations vont poser des verrous, le but va être de les réduire au maximum.
$ /usr/pgsql-14/bin/pgbench -n -T3600 -c20 -j2 --debug pgbench
L’activité est à ajuster en fonction de la puissance de la machine. Laisser l’affichage défiler dans une fenêtre pour bien voir les blocages.
Pour éviter un « empilement des verrous » et ne pas bloquer trop longtemps les opérations, faire en sorte que la transaction échoue si l’obtention d’un verrou dure plus de 10 s.
Un verrou en attente peut bloquer les opérations d’autres transactions venant après. On peut annuler l’opération à partir d’un certain seuil pour éviter ce phénomène :
=# SET lock_timeout TO '10s' ; pgbench
Cela ne concerne cependant pas les opérations une fois que les verrous sont acquis. On peut garantir qu’un ordre donné ne durera pas plus d’une certaine durée :
SET statement_timeout TO '10s' ;
En fonction de la rapidité de la machine et des données à déplacer, cette interruption peut être tolérable ou non.
Pour partitionner la table
pgbench_accounts
par hash sur la colonneaid
sans que le traitement pgbench tombe en erreur, préparer un script avec, dans une transaction :
- la création d’une table partitionnée par hash en 3 partitions au moins ;
- le transfert des données depuis
pgbench_accounts
;- la substitution de la table partitionnée à la table originale.
Tester et exécuter.
Le champ aid
n’a pas de signification, un
partitionnement par hash est adéquat.
Le script peut être le suivant :
on
\timing set ON_ERROR_STOP 1
\
SET lock_timeout TO '10s' ;
SET statement_timeout TO '10s' ;
BEGIN ;
-- Nouvelle table partitionnée
CREATE TABLE pgbench_accounts_part (LIKE pgbench_accounts INCLUDING ALL)
PARTITION BY HASH (aid) ;
CREATE TABLE pgbench_accounts_1 PARTITION OF pgbench_accounts_part
FOR VALUES WITH (MODULUS 3, REMAINDER 0 ) ;
CREATE TABLE pgbench_accounts_2 PARTITION OF pgbench_accounts_part
FOR VALUES WITH (MODULUS 3, REMAINDER 1 ) ;
CREATE TABLE pgbench_accounts_3 PARTITION OF pgbench_accounts_part
FOR VALUES WITH (MODULUS 3, REMAINDER 2 ) ;
-- Transfert des données
-- Bloquer les accès à la table le temps du transfert
-- (sinon risque de perte de données !)
LOCK TABLE pgbench_accounts ;
-- Copie des données
INSERT INTO pgbench_accounts_part
SELECT * FROM pgbench_accounts ;
-- Substitution par renommage
ALTER TABLE pgbench_accounts RENAME TO pgbench_accounts_old ;
ALTER TABLE pgbench_accounts_part RENAME TO pgbench_accounts ;
-- Contrôle
+
\d
-- On ne validera qu'après contrôle
-- (pendant ce temps les sessions concurrentes restent bloquées !)
COMMIT ;
À la moindre erreur, la transaction tombe en erreur. Il faudra
demander manuellement ROLLBACK
.
Si la durée fixée par statement_timeout
est dépassée, on
aura cette erreur :
ERROR: canceling statement due to statement timeout Time: 10115.506 ms (00:10.116)
Surtout, le traitement pgbench reprend en arrière-plan. On peut alors relancer le script corrigé plus tard.
Si tout se passe bien, un \d+
renvoie ceci :
Liste des relations
Schéma | Nom | Type | Propriétaire | Taille | …
--------+----------------------+--------------------+--------------+---------+--
public | pgbench_accounts | table partitionnée | postgres | 0 bytes |
public | pgbench_accounts_1 | table | postgres | 43 MB |
public | pgbench_accounts_2 | table | postgres | 43 MB |
public | pgbench_accounts_3 | table | postgres | 43 MB |
public | pgbench_accounts_old | table | postgres | 130 MB |
public | pgbench_branches | table | postgres | 136 kB |
public | pgbench_history | table | postgres | 5168 kB | public | pgbench_tellers | table | postgres | 216 kB |
On peut vérifier rapidement que les valeurs de aid
sont
bien réparties entre les 3 partitions :
SELECT aid FROM pgbench_accounts_1 LIMIT 3 ;
aid
-----
2
6 8
SELECT aid FROM pgbench_accounts_2 LIMIT 3 ;
aid
-----
3
7 10
SELECT aid FROM pgbench_accounts_3 LIMIT 3 ;
aid
-----
1
9 11
Après la validation du script, on voit apparaître les lignes dans les nouvelles partitions :
SELECT relname, n_live_tup
FROM pg_stat_user_tables
WHERE relname LIKE 'pgbench_accounts%' ;
relname | n_live_tup
----------------------+------------
pgbench_accounts_old | 1000002
pgbench_accounts_1 | 333263
pgbench_accounts_2 | 333497 pgbench_accounts_3 | 333240
Supprimer l’ancienne table
pgbench_accounts_old
.
DROP TABLE pgbench_accounts_old ;
pgbench
doit continuer ses opérations en tâche de
fond.
La table
pgbench_history
se remplit avec le temps. Elle doit être partitionnée par date (champmtime
). Pour le TP, on fera 2 partitions d’une minute, et une partition par défaut. La table actuelle doit devenir une partition de la nouvelle table partitionnée.
- Écrire un script qui, dans une seule transaction, fait tout cela et substitue la table partitionnée à la table d’origine.
NB : Pour éviter de coder des dates en dur, il est possible, avec
psql
, d’utiliser une variable :SELECT ( now()+ interval '60s') AS date_frontiere \gset SELECT :'date_frontiere'::timestamptz ;
La « date frontière » doit être dans le futur (proche). En effet,
pgbench
va modifier les tables en permanence, on ne sait
pas exactement à quel moment la transition aura lieu (et de toute façon
on ne maîtrise pas les valeurs de mtime
) : il continuera
donc à écrire dans l’ancienne table, devenue partition, pendant encore
quelques secondes.
Cette date est arbitrairement à 1 minute dans le futur, pour dérouler le script manuellement :
SELECT ( now()+ interval '60s') AS date_frontiere \gset
Et on peut réutiliser cette variable ainsi ;
SELECT :'date_frontiere'::timestamptz ;
Le script peut être celui-ci :
on
\timing set ON_ERROR_STOP 1
\
SET lock_timeout TO '10s' ;
SET statement_timeout TO '10s' ;
SELECT ( now()+ interval '60s') AS date_frontiere \gset
SELECT :'date_frontiere'::timestamptz ;
BEGIN ;
-- Nouvelle table partitionnée
CREATE TABLE pgbench_history_part (LIKE pgbench_history INCLUDING ALL)
PARTITION BY RANGE (mtime) ;
-- Des partitions pour les prochaines minutes
CREATE TABLE pgbench_history_1
PARTITION OF pgbench_history_part
FOR VALUES FROM (:'date_frontiere'::timestamptz )
TO (:'date_frontiere'::timestamptz + interval '1min' ) ;
CREATE TABLE pgbench_history_2
PARTITION OF pgbench_history_part
FOR VALUES FROM (:'date_frontiere'::timestamptz + interval '1min' )
TO (:'date_frontiere'::timestamptz + interval '2min' ) ;
-- Au cas où le service perdure au-delà des partitions prévues,
-- on débordera dans cette table
CREATE TABLE pgbench_history_default
PARTITION OF pgbench_history_part DEFAULT ;
-- Jusqu'ici pgbench continue de tourner en arrière plan
-- La table devient une simple partition
-- Ce renommage pose un verrou, les sessions pgbench sont bloquées
ALTER TABLE pgbench_history RENAME TO pgbench_history_orig ;
ALTER TABLE pgbench_history_part
PARTITION pgbench_history_orig
ATTACH FOR VALUES FROM (MINVALUE) TO (:'date_frontiere'::timestamptz) ;
-- Contrôle
\dP
-- Substitution de la table partitionnée à celle d'origine.
ALTER TABLE pgbench_history_part RENAME TO pgbench_history ;
-- Contrôle
+ pgbench_history
\d
COMMIT ;
Exécuter le script, attendre que les données s’insèrent dans les nouvelles partitions.
Pour surveiller le contenu des tables jusqu’à la transition :
SELECT relname, n_live_tup, now()
FROM pg_stat_user_tables
WHERE relname LIKE 'pgbench_history%' ;
3 \watch
Un \d+
doit renvoyer ceci :
Liste des relations
Schéma | Nom | Type | Propriétaire | Taille | …
--------+-------------------------+--------------------+--------------+---------+--
public | pgbench_accounts | table partitionnée | postgres | 0 bytes |
public | pgbench_accounts_1 | table | postgres | 44 MB |
public | pgbench_accounts_2 | table | postgres | 44 MB |
public | pgbench_accounts_3 | table | postgres | 44 MB |
public | pgbench_branches | table | postgres | 136 kB |
public | pgbench_history | table partitionnée | postgres | 0 bytes |
public | pgbench_history_1 | table | postgres | 672 kB |
public | pgbench_history_2 | table | postgres | 0 bytes |
public | pgbench_history_default | table | postgres | 0 bytes |
public | pgbench_history_orig | table | postgres | 8736 kB | public | pgbench_tellers | table | postgres | 216 kB |
- Continuer de laisser tourner
pgbench
en arrière-plan.- Détacher et détruire la partition avec les données les plus anciennes.
ALTER TABLE pgbench_history
PARTITION pgbench_history_orig ;
DETACH
-- On pourrait faire le DROP directement
DROP TABLE pgbench_history_orig ;
- Ajouter une clé étrangère entre
pgbench_accounts
etpgbench_history
. Voir les contraintes créées.
NB : les clés étrangères entre tables partitionnées ne sont pas disponibles avant PostgreSQL 12.
SET lock_timeout TO '3s' ;
SET statement_timeout TO '10s' ;
CREATE INDEX ON pgbench_history (aid) ;
ALTER TABLE pgbench_history
ADD CONSTRAINT pgbench_history_aid_fkey FOREIGN KEY (aid) REFERENCES pgbench_accounts ;
On voit que chaque partition porte un index comme la table mère. La contrainte est portée par chaque partition.
pgbench=# \d+ pgbench_history
Table partitionnée « public.pgbench_history »
…
Clé de partition : RANGE (mtime)
Index :
"pgbench_history_aid_idx" btree (aid)
Contraintes de clés étrangères :
"pgbench_history_aid_fkey" FOREIGN KEY (aid) REFERENCES pgbench_accounts(aid)
Partitions: pgbench_history_1 FOR VALUES FROM ('2020-02-14 17:41:08.298445')
TO ('2020-02-14 17:42:08.298445'),
pgbench_history_2 FOR VALUES FROM ('2020-02-14 17:42:08.298445')
TO ('2020-02-14 17:43:08.298445'), pgbench_history_default DEFAULT
pgbench=# \d+ pgbench_history_1
Table « public.pgbench_history_1 »
…
Partition de : pgbench_history FOR VALUES FROM ('2020-02-14 17:41:08.298445')
TO ('2020-02-14 17:42:08.298445')
Contrainte de partition : ((mtime IS NOT NULL)
AND(mtime >= '2020-02-14 17:41:08.298445'::timestamp without time zone)
AND (mtime < '2020-02-14 17:42:08.298445'::timestamp without time zone))
Index :
"pgbench_history_1_aid_idx" btree (aid)
Contraintes de clés étrangères :
TABLE "pgbench_history" CONSTRAINT "pgbench_history_aid_fkey"
FOREIGN KEY (aid) REFERENCES pgbench_accounts(aid) Méthode d'accès : heap
Si vous n’avez pas déjà eu un problème à cause du
statement_timeout
, dropper la contrainte et recommencer avec une valeur plus basse. Comment contourner ?
Le statement_timeout
peut être un problème :
SET
=# ALTER TABLE pgbench_history
pgbenchADD CONSTRAINT pgbench_history_aid_fkey FOREIGN KEY (aid)
REFERENCES pgbench_accounts ;
to statement timeout ERROR: canceling statement due
On peut créer les contraintes séparément sur les tables. Cela permet
de ne poser un verrou sur la partition active (sans doute
pgbench_history_default
) que pendant le strict minimum de
temps (les autres partitions de pgbench_history
ne sont pas
utilisées).
SET statement_timeout to '1s' ;
ALTER TABLE pgbench_history_1 ADD CONSTRAINT pgbench_history_aid_fkey
FOREIGN KEY (aid) REFERENCES pgbench_accounts ;
ALTER TABLE pgbench_history_2 ADD CONSTRAINT pgbench_history_aid_fkey
FOREIGN KEY (aid) REFERENCES pgbench_accounts ;
ALTER TABLE pgbench_history_default ADD CONSTRAINT pgbench_history_aid_fkey
FOREIGN KEY (aid) REFERENCES pgbench_accounts ;
La contrainte au niveau global sera alors posée presque instantanément :
ALTER TABLE pgbench_history ADD CONSTRAINT pgbench_history_aid_fkey
FOREIGN KEY (aid) REFERENCES pgbench_accounts ;
On veut créer un index sur
pgbench_history (aid)
.Pour ne pas gêner les écritures, il faudra le faire de manière concurrente. Créer l’index de manière concurrente sur chaque partition, puis sur la table partitionnée.
Construire un index de manière concurrente (clause
CONCURRENTLY
) permet de ne pas bloquer la table en écriture
pendant la création de l’index, qui peut être très longue. Mais il n’est
pas possible de le faire sur la table partitionnée :
CREATE INDEX CONCURRENTLY ON pgbench_history (aid) ;
ERROR: cannot create index on partitioned table "pgbench_history" concurrently
Mais on peut créer l’index sur chaque partition séparément :
CREATE INDEX CONCURRENTLY ON pgbench_history_1 (aid) ;
CREATE INDEX CONCURRENTLY ON pgbench_history_2 (aid) ;
CREATE INDEX CONCURRENTLY ON pgbench_history_default (aid) ;
S’il y a beaucoup de partitions, on peut générer dynamiquement ces ordres :
SELECT 'CREATE INDEX CONCURRENTLY ON ' ||
oid::regclass::text || ' (aid) ; '
c.FROM pg_class c
WHERE relname like 'pgbench_history%' AND relispartition \gexec
Comme lors de toute création concurrente, il faut vérifier que les index sont bien valides : la requête suivante ne doit rien retourner.
SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid ;
Enfin on crée l’index au niveau de la table partitionnée : il réutilise les index existants et sera donc créé presque instantanément :
CREATE INDEX ON pgbench_history(aid) ;
pgbench=# \d+ pgbench_history
..
Partition key: RANGE (mtime)
Indexes:
"pgbench_history_aid_idx" btree (aid) …
Nativement, lorsqu’un utilisateur est connecté à une base de données PostgreSQL, sa vision du monde est contenue hermétiquement dans cette base. Il n’a pas accès aux objets des autres bases de la même instance ou d’une autre instance.
Cependant, il existe principalement 3 méthodes pour accéder à des données externes à la base sous PostgreSQL.
La norme SQL/MED est la méthode recommandée pour accéder à des objets distants. Elle permet l’accès à de nombreuses sources de données différentes grâce l’utilisation de connecteurs appelés Foreign Data Wrappers.
Historiquement, les utilisateurs de PostgreSQL passaient par
l’extension dblink
, qui permet l’accès à des données
externes. Cependant, cet accès ne concerne que des serveurs PostgreSQL.
De plus, son utilisation prête facilement à accès moins performant et
moins sécurisés que la norme SQL/MED.
PL/Proxy est un cas d’utilisation très différent : cette extension, au départ développée par Skype, permet de distribuer des appels de fonctions PL sur plusieurs nœuds.
Le sharding n’est pas intégré de manière simple à PostgreSQL dans sa version communautaire. Il est déjà possible d’en faire une version primitive avec des partitions basées sur des tables distantes (donc avec SQL/MED), mais nous n’en sommes qu’au début. Des éditeurs proposent des extensions, propriétaires ou expérimentales, ou des forks de PostgreSQL dédiés. Comme souvent, il faut se poser la question du besoin réel par rapport à une instance PostgreSQL bien optimisée avant d’utiliser des outils qui vont ajouter une couche supplémentaire de complexité dans votre infrastructure.
SQL/MED est un des tomes de la norme SQL, traitant de l’accès aux données externes (Management of External Data).
Elle fournit donc un certain nombre d’éléments conceptuels, et de syntaxe, permettant la déclaration d’accès à des données externes. Ces données externes sont bien sûr présentées comme des tables.
PostgreSQL suit cette norme et est ainsi capable de requêter des
tables distantes à travers des pilotes (appelés Foreign Data
Wrapper). Les seuls connecteurs livrés par défaut sont
file_fdw
(pour lire des fichiers plats de type CSV
accessibles du serveur PostgreSQL) et postgres_fdw
(qui
permet de se connecter à un autre serveur PostgreSQL.
La norme SQL/MED définit quatre types d’objets.
Le Foreign Data Wrapper est le connecteur permettant la connexion à un serveur distant, l’exécution de requêtes sur ce serveur, et la récupération des résultats par l’intermédiaire d’une table distante.
Le Foreign Server est la définition d’un serveur distant. Il est lié à un Foreign Data Wrapper lors de sa création, des options sont disponibles pour indiquer le fichier ou l’adresse IP et le port, ainsi que d’autres informations d’importance pour le connecteur.
Un User Mapping permet de définir qui localement a le droit de se connecter sur un serveur distant en tant que tel utilisateur sur le serveur distant. La définition d’un User Mapping est optionnel.
Une Foreign Table contient la définition de la table distante : nom des colonnes, et type. Elle est liée à un Foreign Server.
Les trois Foreign Data Wrappers les plus aboutis sont sans conteste ceux pour PostgreSQL (disponible en module contrib), Oracle et SQLite. Ces trois pilotes supportent un grand nombre de fonctionnalités (si ce n’est pas toutes) de l’implémentation SQL/MED par PostgreSQL.
De nombreux pilotes spécialisés existent, entre autres pour accéder à des bases NoSQL comme MongDB, CouchDB ou Redis, ou à des fichiers.
Il existe aussi des drivers génériques :
La liste complète des Foreign Data Wrappers disponibles pour PostgreSQL peut être consultée sur le wiki de postgresql.org. Encore une fois, leur couverture des fonctionnalités disponibles est très variable ainsi que leur qualité. Il convient de rester prudent et de bien tester ces extensions.
Par exemple, pour ajouter le Foreign Data Wrapper pour PostgreSQL, on procédera ainsi :
CREATE EXTENSION postgres_fdw;
La création cette extension dans une base provoquera l’ajout du Foreign Data Wrapper :
b1=# CREATE EXTENSION postgres_fdw;
CREATE EXTENSION
b1=# \dx+ postgres_fdw
Objects in extension "postgres_fdw"
Object descriptiong
---------------------------------------------
foreign-data wrapper postgres_fdw
function postgres_fdw_disconnect(text)
function postgres_fdw_disconnect_all()
function postgres_fdw_get_connections()
function postgres_fdw_handler()
function postgres_fdw_validator(text[],oid)
(6 rows)
b1=# \dew
List of foreign-data wrappers
Name | Owner | Handler | Validatorg
--------------+----------+----------------------+------------------------
postgres_fdw | postgres | postgres_fdw_handler | postgres_fdw_validator (1 row)
L’implémentation SQL/MED permet l’ajout de ces fonctionnalités dans un Foreign Data Wrapper. Cependant, une majorité de ces fonctionnalités est optionnelle. Seule la lecture des données est obligatoire.
Les chapitres suivant montrent des exemples de ces fonctionnalités sur deux Foreign Data Wrappers.
Les Foreign Data Wrappers sont fréquement améliorés. La
dernière optimisation en date concerne la gestion de la commande
ANALYZE
en version 16 de PostgreSQL.
Jusque PostgreSQL 15, lorsque ANALYZE
était exécuté sur
une table distante, l’échantillonnage était effectué localement à
l’instance. Les données étaient donc intégralement rapatriées avant que
ne soient effectuées les opérations d’échantillonnage. Pour des grosses
tables, cette manière de faire était tout sauf optimisée. À partir de
PostgreSQL 16, l’échantillonnage des lignes se fait par défaut sur le
serveur distant grâce à l’option analyze_sampling
. La
volumétrie transférée est alors bien plus basse. Le calcul des
statistiques des données sur cet échantillon se fait toujours sur
l’instance qui lance ANALYZE
.
Cette option peut prendre les valeurs off
,
auto
, system
, bernoulli
et
random
. La valeur par défaut est auto
qui
permettra d’utiliser soit bernoulli
(cas général) soit
random
(pour des serveurs distants plus anciens que
PostgreSQL 9.5). Par exemple :
ALTER FOREIGN TABLE t1_fdw OPTIONS ( analyze_sampling 'auto' );
ALTER FOREIGN TABLE t1_fdw OPTIONS ( SET analyze_sampling 'off' );
analyze_sampling
peut être appliqué sur le foreign
server ou la foreign table directement. sur la
FOREIGN TABLE
directement.
Il est possible de créer des triggers locaux sur des tables
étrangères. Un trigger sur TRUNCATE
n’est pas possible
avant PostgreSQL 16.
Pour accéder aux données d’un autre serveur, il faut pouvoir s’y connecter. Le Foreign Server regroupe les informations permettant cette connexion : par exemple adresse IP et port.
Voici un exemple d’ajout de serveur distant :
CREATE SERVER serveur2
FOREIGN DATA WRAPPER postgres_fdw
'192.168.122.1',
OPTIONS (host '5432',
port 'b1') ; dbname
Définir un User Mapping permet d’indiquer au Foreign Data Wrapper quel utilisateur utilisé pour la connexion au serveur distant.
Par exemple, avec cette définition :
CREATE USER MAPPING FOR bob SERVER serveur2 OPTIONS (user 'alice', password 'secret');
Si l’utilisateur bob
local accède à une table distante
dépendant du serveur distant serveur2
, la connexion au
serveur distant passera par l’utilisateur alice
sur le
serveur distant.
Voici un premier exemple pour une table simple :
CREATE FOREIGN TABLE films (
char(5) NOT NULL,
code varchar(40) NOT NULL,
titre integer NOT NULL,
did date,
date_prod type varchar(10),
interval hour to minute
duree
) SERVER serveur2 ;
Lors de l’accès (avec un SELECT
par exemple) à la table
films
, PostgreSQL va chercher la définition du serveur
serveur2
, ce qui lui permettra de connaître le Foreign
Data Wrapper responsable de la récupération des données et donnera
la main à ce connecteur.
Et voici un second exemple, cette fois pour une partition :
CREATE FOREIGN TABLE stock202112
PARTITION OF stock FOR VALUES FROM ('2021-12-01') TO ('2022-01-01')
SERVER serveur2;
Dans ce cas, l’accès à la table partitionnée locale
stock
accédera à des données locales (les autres
partitions) mais aussi à des données distantes avec au moins la
partition stock202112
.
Cette étape de création des tables distantes est fastidieuse et peut
amener des problèmes si on se trompe sur le nom des colonnes ou sur leur
type. C’est d’autant plus vrai que le nombre de tables à créer est
important. Dans ce cas, elle peut être avantageusement remplacée par un
appel à l’ordre IMPORT FOREIGN SCHEMA
. Disponible à partir
de la version 9.5, il permet l’import d’un schéma complet.
Quel que soit le connecteur, la création d’un accès se fait en 3 étapes minimum :
Éventuellement, on peut vouloir créer un User Mapping, mais
ce n’est pas nécessaire pour le FDW file_fdw
.
En reprenant l’exemple ci-dessus et avec un fichier
/tmp/fichier_donnees_statistiques.csv
contenant les lignes
suivantes :
1;1.2
2;2.4
3;0 4;5.6
Voici ce que donnerait quelques opérations sur cette table distante :
SELECT * FROM donnees_statistiques;
f1 | f2g
----+-----
1 | 1.2
2 | 2.4
3 | 0
4 | 5.6 (4 rows)
SELECT * FROM donnees_statistiques WHERE f1=2;
f1 | f2g
----+-----
2 | 2.4 (1 row)
EXPLAIN SELECT * FROM donnees_statistiques WHERE f1=2;
QUERY PLAN
-------------------------------------------------------------------------
Foreign Scan on donnees_statistiques (cost=0.00..1.10 rows=1 width=64)
Filter: (f1 = '2'::numeric)
Foreign File: /tmp/fichier_donnees_statistiques.csv
Foreign File Size: 25 b (4 rows)
=# insert into donnees_statistiques values (5,100.23);
postgresinsert into foreign table "donnees_statistiques" ERROR: cannot
Nous créons une table sur un serveur distant. Par simplicité, nous utiliserons le même serveur mais une base différente. Créons cette base et cette table :
dalibo=# CREATE DATABASE distante;
CREATE DATABASE
dalibo=# \c distante
You are now connected to database "distante" as user "dalibo".
distante=# CREATE TABLE personnes (id integer, nom text);
CREATE TABLE
distante=# INSERT INTO personnes (id, nom) VALUES (1, 'alice'),
(2, 'bertrand'), (3, 'charlotte'), (4, 'david');
INSERT 0 4
distante=# ANALYZE personnes; ANALYZE
Maintenant nous pouvons revenir à notre base d’origine et mettre en place la relation avec le « serveur distant » :
distante=# \c dalibo
You are now connected to database "dalibo" as user "dalibo".
dalibo=# CREATE EXTENSION postgres_fdw;
CREATE EXTENSION
dalibo=# CREATE SERVER serveur_distant FOREIGN DATA WRAPPER postgres_fdw
OPTIONS (HOST 'localhost',PORT '5432', DBNAME 'distante');
CREATE SERVER
dalibo=# CREATE USER MAPPING FOR dalibo SERVER serveur_distant
OPTIONS (user 'dalibo', password 'mon_mdp');
CREATE USER MAPPING
dalibo=# CREATE FOREIGN TABLE personnes (id integer, nom text)
SERVER serveur_distant; CREATE FOREIGN TABLE
Et c’est tout ! Nous pouvons désormais utiliser la table distante
personnes
comme si elle était une table locale de notre
base.
SELECT * FROM personnes;
id | nom
----+-----------
1 | alice
2 | bertrand
3 | charlotte 4 | david
EXPLAIN (ANALYZE, VERBOSE) SELECT * FROM personnes;
QUERY PLAN
----------------------------------------------------------------------------
Foreign Scan on public.personnes (cost=100.00..150.95 rows=1365 width=36)
(actual time=0.655..0.657 rows=4 loops=1)
Output: id, nom
Remote SQL: SELECT id, nom FROM public.personnes Total runtime: 1.197 ms
En plus, si nous filtrons notre requête, le filtre est exécuté sur le serveur distant, réduisant considérablement le trafic réseau et le traitement associé.
EXPLAIN (ANALYZE, VERBOSE) SELECT * FROM personnes WHERE id = 3;
QUERY PLAN
----------------------------------------------------------------------------
Foreign Scan on public.personnes (cost=100.00..127.20 rows=7 width=36)
(actual time=1.778..1.779 rows=1 loops=1)
Output: id, nom
Remote SQL: SELECT id, nom FROM public.personnes WHERE ((id = 3)) Total runtime: 2.240 ms
Noter qu’EXPLAIN
exige l’option VERBOSE
pour afficher le code envoyé à l’instance distante.
Il est possible d’écrire vers ces tables aussi, à condition que le connecteur FDW le permette.
En utilisant l’exemple de la section précédente, on note qu’il y a un aller-retour entre la sélection des lignes à modifier (ou supprimer) et la modification (suppression) de ces lignes :
EXPLAIN (ANALYZE, VERBOSE)
UPDATE personnes
SET nom = 'agathe' WHERE id = 1 ;
QUERY PLAN
-------------------------------------------------------------------------------
Update on public.personnes (cost=100.00..140.35 rows=12 width=10)
(actual time=2.086..2.086 rows=0 loops=1)
Remote SQL: UPDATE public.personnes SET nom = $2 WHERE ctid = $1
-> Foreign Scan on public.personnes (cost=100.00..140.35 rows=12 width=10)
(actual time=1.040..1.042 rows=1 loops=1)
Output: id, 'agathe'::text, ctid
Remote SQL: SELECT id, ctid FROM public.personnes WHERE ((id = 1))
FOR UPDATE Total runtime: 2.660 ms
SELECT * FROM personnes;
id | nom
----+-----------
2 | bertrand
3 | charlotte
4 | david 1 | agathe
On peut aussi constater que l’écriture distante respecte les transactions :
dalibo=# BEGIN;
BEGIN
dalibo=# DELETE FROM personnes WHERE id=2;
DELETE 1
dalibo=# SELECT * FROM personnes;
id | nom
----+-----------
3 | charlotte
4 | david
1 | agathe
(3 rows)
dalibo=# ROLLBACK;
ROLLBACK
dalibo=# SELECT * FROM personnes;
id | nom
----+-----------
2 | bertrand
3 | charlotte
4 | david
1 | agathe (4 rows)
Attention à ne pas perdre de vue qu’une table distante n’est pas une table locale. L’accès à ses données est plus lent, surtout quand on souhaite récupérer de manière répétitive peu d’enregistrements : on a systématiquement une latence réseau, éventuellement une analyse de la requête envoyée au serveur distant, etc.
Les jointures ne sont pas « poussées » au serveur distant avant PostgreSQL 9.6 et pour des bases PostgreSQL. Un accès par Nested Loop (boucle imbriquée entre les deux tables) est habituellement inenvisageable entre deux tables distantes : la boucle interne (celle qui en local serait un accès à une table par index) entraînerait une requête individuelle par itération, ce qui serait horriblement peu performant.
Comme avec tout FDW, il existe des restrictions. Par exemple, avec
postgres_fdw
, un TRUNCATE
d’une table distante
n’est pas possible avant PostgreSQL 14.
Les tables distantes sont donc à réserver à des accès intermittents. Il ne faut pas les utiliser pour développer une application transactionnelle par exemple. Noter qu’entre serveurs PostgreSQL, chaque version améliore les performances (notamment pour « pousser » le maximum d’informations et de critères au serveur distant).
Pour améliorer les performances lors de l’utilisation de Foreign Data Wrapper, une pratique courante est de faire une vue matérialisée de l’objet distant. Les données sont récupérées en bloc et cette vue matérialisée peut être indexée. C’est une sorte de mise en cache. Évidemment cela ne convient pas à toutes les applications.
La documentation
de postgres_fdw mentionne plusieurs paramètres, et le plus
intéressant pour des requêtes de gros volume est
fetch_size
: la valeur par défaut n’est que de 100, et
l’augmenter permet de réduire les aller-retours à travers le réseau.
Cette fonctionnalité utilise le mécanisme d’héritage de PostgreSQL.
Exemple d’une table locale qui hérite d’une table distante
La table parent (ici une table distante) sera la table
fgn_stock_londre
et la table enfant sera la table
local_stock
(locale). Ainsi la lecture de la table
fgn_stock_londre
retournera les enregistrements de la table
fgn_stock_londre
et de la table
local_stock
.
Sur l’instance distante :
Créer une table stock_londre
sur l’instance distante
dans la base nommée « cave » et insérer des valeurs :
CREATE TABLE stock_londre (c1 int);
INSERT INTO stock_londre VALUES (1),(2),(4),(5);
Sur l’instance locale :
Créer le serveur et la correspondance des droits :
CREATE EXTENSION postgres_fdw ;
CREATE SERVER pgdistant
FOREIGN DATA WRAPPER postgres_fdw
'192.168.0.42', port '5432', dbname 'cave');
OPTIONS (host
CREATE USER MAPPING FOR mon_utilisateur
SERVER pgdistantuser 'utilisateur_distant', password 'mdp_utilisateur_distant'); OPTIONS (
Créer une table distante fgn_stock_londre
correspondant
à la table stock_londre
de l’autre instance :
CREATE FOREIGN TABLE fgn_stock_londre (c1 int) SERVER pgdistant
'public' , table_name 'stock_londre'); OPTIONS (schema_name
On peut bien lire les données :
SELECT tableoid::regclass,* FROM fgn_stock_londre;
tableoid | c1
------------------+----
fgn_stock_londre | 1
fgn_stock_londre | 2
fgn_stock_londre | 4
fgn_stock_londre | 5 (4 lignes)
Voici le plan d’exécution associé :
EXPLAIN ANALYZE SELECT * FROM fgn_stock_londre;
QUERY PLAN
----------------------------------------------------------------------------
Foreign Scan on fgn_stock_londre (cost=100.00..197.75 rows=2925 width=4) (actual time=0.388..0.389 rows=4 loops=1)
Créer une table local_stock
sur l’instance locale qui va
hériter de la table mère :
CREATE TABLE local_stock () INHERITS (fgn_stock_londre);
On insère des valeurs dans la table local_stock
:
INSERT INTO local_stock VALUES (10),(15);
INSERT 0 2
La table local_stock
ne contient bien que 2
valeurs :
SELECT * FROM local_stock ;
c1
----
10
15 (2 lignes)
En revanche, la table fgn_stock_londre
ne contient plus
4 valeurs mais 6 valeurs :
SELECT tableoid::regclass,* FROM fgn_stock_londre;
tableoid | c1
------------------+----
fgn_stock_londre | 1
fgn_stock_londre | 2
fgn_stock_londre | 4
fgn_stock_londre | 5
local_stock | 10
local_stock | 15 (6 lignes)
Dans le plan d’exécution on remarque bien la lecture des deux tables :
EXPLAIN ANALYZE SELECT * FROM fgn_stock_londre;
QUERY PLAN
-------------------------------------------------------------------------
Append (cost=100.00..233.25 rows=5475 width=4)
(actual time=0.438..0.444 rows=6 loops=1)
-> Foreign Scan on fgn_stock_londre
(cost=100.00..197.75 rows=2925 width=4)
(actual time=0.438..0.438 rows=4 loops=1)
-> Seq Scan on local_stock (cost=0.00..35.50 rows=2550 width=4)
(actual time=0.004..0.005 rows=2 loops=1)
Planning time: 0.066 ms
Execution time: 0.821 ms (5 lignes)
Note : Les données de la table stock_londre
sur
l’instance distante n’ont pas été modifiées.
Exemple d’une table distante qui hérite d’une table locale
La table parent sera la table master_stock
et la table
fille (ici distante) sera la table fgn_stock_londre
. Ainsi
une lecture de la table master_stock
retournera les valeurs
de la table master_stock
et de la table
fgn_stock_londre
, sachant qu’une lecture de la table
fgn_stock_londre
retourne les valeurs de la table
fgn_stock_londre
et local_stock
. Une lecture
de la table master_stock
retournera les valeurs des 3
tables : master_stock
, fgn_stock_londre
,
local_stock
.
Créer une table master_stock
, insérer des valeurs
dedans :
CREATE TABLE master_stock (LIKE fgn_stock_londre);
INSERT INTO master_stock VALUES (100),(200);
SELECT tableoid::regclass,* FROM master_stock;
tableoid | c1
--------------+-----
master_stock | 100
master_stock | 200 (2 rows)
Modifier la table fgn_stock_londre
pour qu’elle hérite
de la table master_stock
:
ALTER TABLE fgn_stock_londre INHERIT master_stock ;
La lecture de la table master_stock
nous montre bien les
valeurs des 3 tables :
SELECT tableoid::regclass,* FROM master_stock ;
tableoid | c1
------------------+-----
master_stock | 100
master_stock | 200
fgn_stock_londre | 1
fgn_stock_londre | 2
fgn_stock_londre | 4
fgn_stock_londre | 5
local_stock | 10
local_stock | 15 (8 lignes)
Le plan d’exécution confirme bien la lecture des 3 tables :
EXPLAIN ANALYSE SELECT * FROM master_stock ;
QUERY PLAN
--------------------------------------------------------------------------
Append (cost=0.00..236.80 rows=5730 width=4)
(actual time=0.004..0.440 rows=8 loops=1)
-> Seq Scan on master_stock (cost=0.00..3.55 rows=255 width=4)
(actual time=0.003..0.003 rows=2 loops=1)
-> Foreign Scan on fgn_stock_londre
(cost=100.00..197.75 rows=2925 width=4)
(actual time=0.430..0.430 rows=4 loops=1)
-> Seq Scan on local_stock (cost=0.00..35.50 rows=2550 width=4)
(actual time=0.003..0.004 rows=2 loops=1)
Planning time: 0.073 ms
Execution time: 0.865 ms (6 lignes)
Dans cet exemple, on a un héritage « imbriqué » :
master_stock
est parent de la table distante
fgn_stock_londre
fgn_stock_londre
est enfant de la
table master_stock
et parent de la table
local_stock
local_stock
est enfant de la table distante
fgn_stock_londre
master_stock
├─fgn_stock_londre => stock_londre ├─local_stock
Créons un index sur master_stock
et ajoutons des données
dans la table master_stock
:
CREATE INDEX fgn_idx ON master_stock(c1);
INSERT INTO master_stock (SELECT generate_series(1,10000));
Maintenant effectuons une simple requête de sélection :
SELECT tableoid::regclass,* FROM master_stock WHERE c1=10;
tableoid | c1--------------+----
10
master_stock | 10
local_stock | 2 lignes) (
Étudions le plan d’exécution associé :
EXPLAIN ANALYZE SELECT tableoid::regclass,* FROM master_stock WHERE c1=10;
QUERY PLAN
-------------------------------------------------------------------------------
Result (cost=0.29..192.44 rows=27 width=8)
(actual time=0.010..0.485 rows=2 loops=1)
-> Append (cost=0.29..192.44 rows=27 width=8)
(actual time=0.009..0.483 rows=2 loops=1)
-> Index Scan using fgn_idx on master_stock
(cost=0.29..8.30 rows=1 width=8)
(actual time=0.009..0.010 rows=1 loops=1)
Index Cond: (c1 = 10)
-> Foreign Scan on fgn_stock_londre
(cost=100.00..142.26 rows=13 width=8)
(actual time=0.466..0.466 rows=0 loops=1)
-> Seq Scan on local_stock (cost=0.00..41.88 rows=13 width=8)
(actual time=0.007..0.007 rows=1 loops=1)
Filter: (c1 = 10) Rows Removed by Filter: 1
L’index ne se fait que sur master_stock
.
En ajoutant l’option ONLY
après la clause
FROM
, on demande au moteur de n’afficher que la table
master_stock
et pas les tables filles :
SELECT tableoid::regclass,* FROM ONLY master_stock WHERE c1=10;
tableoid | c1--------------+----
10
master_stock | 1 ligne) (
Attention, si on supprime les données sur la table parent, la suppression se fait aussi sur les tables filles :
BEGIN;
DELETE FROM master_stock;
-- [DELETE 10008]
SELECT * FROM master_stock ;
c1
----
(0 ligne)
ROLLBACK;
En revanche avec l’option ONLY
, on ne supprime que les
données de la table parent :
BEGIN;
DELETE FROM ONLY master_stock;
-- [DELETE 10002] ROLLBACK;
Enfin, si nous ajoutons une contrainte CHECK
sur la
table distante, l’exclusion de partition basées sur ces contraintes
s’appliquent naturellement :
ALTER TABLE fgn_stock_londre ADD CHECK (c1 < 100);
ALTER TABLE local_stock ADD CHECK (c1 < 100);
--local_stock hérite de fgn_stock_londre !
EXPLAIN (ANALYZE,verbose) SELECT tableoid::regclass,*g
FROM master_stock WHERE c1=200;
QUERY PLAN
-------------------------------------------------------------
Result (cost=0.29..8.32 rows=2 width=8)
(actual time=0.009..0.011 rows=2 loops=1)
Output: (master_stock.tableoid)::regclass, master_stock.c1
-> Append (cost=0.29..8.32 rows=2 width=8)
(actual time=0.008..0.009 rows=2 loops=1)
-> Index Scan using fgn_idx on public.master_stock
(cost=0.29..8.32 rows=2 width=8)
(actual time=0.008..0.008 rows=2 loops=1)
Output: master_stock.tableoid, master_stock.c1
Index Cond: (master_stock.c1 = 200)
Planning time: 0.157 ms
Execution time: 0.025 ms (8 rows)
Attention : La contrainte CHECK
sur
fgn_stock_londre
est locale seulement. Si
cette contrainte n’existe pas sur la table distante, le résultat de la
requête pourra alors être faux !
Sur le serveur distant :
INSERT INTO stock_londre VALUES (200);
Sur le serveur local :
SELECT tableoid::regclass,* FROM master_stock WHERE c1=200;
tableoid | c1
--------------+-----
master_stock | 200 master_stock | 200
ALTER TABLE fgn_stock_londre DROP CONSTRAINT fgn_stock_londre_c1_check;
SELECT tableoid::regclass,* FROM master_stock WHERE c1=200;
tableoid | c1
------------------+-----
master_stock | 200
master_stock | 200 fgn_stock_londre | 200
Le module dblink
de PostgreSQL a une logique différente
de SQL/MED : ce dernier crée des tables virtuelles qui masquent des
accès distants, alors qu’avec dblink
, une requête est
fournie à une fonction, qui l’exécute à distance puis renvoie le
résultat.
Voici un exemple d’utilisation :
SELECT *
FROM dblink('host=serveur port=5432 user=postgres dbname=b1',
'SELECT proname, prosrc FROM pg_proc')
AS t1(proname name, prosrc text)
WHERE proname LIKE 'bytea%';
L’appel à la fonction dblink()
va réaliser une connexion
à la base b1
et l’exécution de la requête indiquée dans le
deuxième argument. Le résultat de cette requête est renvoyé comme
résultat de la fonction. Noter qu’il faut nommer les champs obtenus.
Généralement, on encapsule l’appel à dblink()
dans une
vue, ce qui donnerait par exemple :
CREATE VIEW pgproc_b1 AS
SELECT *
FROM dblink('host=serveur port=5432 user=postgres dbname=b1',
'SELECT proname, prosrc FROM pg_proc')
AS t1(proname name, prosrc text);
SELECT *
FROM pgprocb1
WHERE proname LIKE 'bytea%';
Un problème est que, rapidement, on ne se rappelle plus que c’est une table externe et que, même si le résultat contient peu de lignes, tout le contenu de la table distante est récupérés avant que le filtre ne soit exécuté. Donc même s’il y a un index qui aurait pu être utilisé pour ce prédicat, il ne pourra pas être utilisé. Il est rapidement difficile d’obtenir de bonnes performances avec cette extension.
Noter que dblink
n’est pas aussi riche que son homonyme
dans d’autres SGBD concurrents.
De plus, cette extension est un peu ancienne et ne bénéficie pas de
nouvelles fonctionnalités sur les dernières versions de PostgreSQL. On
préférera utiliser à la place l’implémentation de SQL/MED de PostgreSQL
et le Foreign Data Wrapper postgres_fdw
qui
évoluent de concert à chaque version majeure et deviennent de plus en
plus puissants au fil des versions. Cependant, dblink
a
encore l’intérêt d’émuler des transactions autonomes ou d’appeler des
fonctions sur le serveur distant, ce qui est impossible directement avec
postgres_fdw
.
dblink
fournit quelques fonctions plus évoluées que
l’exemple ci-dessus, décrites dans la documentation.
PL/Proxy propose d’exécuter une fonction suivant un mode parmi trois :
On peut mettre en place un ensemble de fonctions PL/Proxy pour « découper » une table volumineuse et la répartir sur plusieurs instances PostgreSQL.
Le langage PL/Proxy offre alors la possibilité de développer une couche d’abstraction transparente pour l’utilisateur final qui peut alors consulter et manipuler les données comme si elles se trouvaient dans une seule table sur une seule instance PostgreSQL.
On peut néanmoins se demander l’avenir de ce projet. La dernière version date de septembre 2020, et il n’y a eu aucune modification des sources depuis cette version. La société qui a développé ce langage au départ a été rachetée par Microsoft. Le développement du langage dépend donc d’un très petit nombre de contributeurs.
Avec le foreign data wrapper
file_fdw
, créer une table distante qui présente les champs du fichier/etc/passwd
sous forme de table.
Vérifier son bon fonctionnement avec un simple
SELECT
.
Accéder à une table de votre choix d’une autre machine, par exemple
stock
dans la basecave
, à travers une table distante (postgres_fdw
) : configuration dupg_hba.conf
, installation de l’extension dans une base locale, création du serveur, de la table, du mapping pour les droits.
Visualiser l’accès par un
EXPLAIN (ANALYZE VERBOSE) SELECT …
.
Avec le foreign data wrapper
file_fdw
, créer une table distante qui présente les champs du fichier/etc/passwd
sous forme de table.
Vérifier son bon fonctionnement avec un simple
SELECT
.
CREATE EXTENSION file_fdw;
CREATE SERVER files FOREIGN DATA WRAPPER file_fdw;
CREATE FOREIGN TABLE passwd (
login text,
passwd text,uid int,
int,
gid
username text,
homedir text,
shell text)
SERVER files'/etc/passwd', format 'csv', delimiter ':'); OPTIONS (filename
Accéder à une table de votre choix d’une autre machine, par exemple
stock
dans la basecave
, à travers une table distante (postgres_fdw
) : configuration dupg_hba.conf
, installation de l’extension dans une base locale, création du serveur, de la table, du mapping pour les droits.
Visualiser l’accès par un
EXPLAIN (ANALYZE VERBOSE) SELECT …
.
Tout d’abord, vérifier que la connexion se fait sans mot de passe à la cible depuis le compte postgres de l’instance locale vers la base distante où se trouve la table cible.
Si cela ne fonctionne pas, vérifier le listen_addresses
,
le fichier pg_hba.conf
et le firewall de la base
distance, et éventuellement le ~postgres/.pgpass
sur le
serveur local.
Une fois la connexion en place, dans la base locale voulue, installer le foreign data wrapper :
CREATE EXTENSION postgres_fdw ;
Créer le foreign server vers le serveur cible (ajuster les options) :
CREATE SERVER serveur_voisin
FOREIGN DATA WRAPPER postgres_fdw
'192.168.0.18', port '5432', dbname 'cave'); OPTIONS (host
Créer un user mapping, c’est-à-dire une correspondance entre l’utilisateur local et l’utilisateur distant :
CREATE USER MAPPING FOR mon_utilisateur
SERVER serveur_voisinuser 'utilisateur_distant', password 'mdp_utilisateur_distant'); OPTIONS (
Puis créer la foreign table :
CREATE FOREIGN TABLE stock_voisin (
integer, contenant_id integer, annee integer, nombre integer)
vin_id
SERVER serveur_voisin'public', table_name 'stock_old'); OPTIONS (schema_name
Vérifier le bon fonctionnement :
SELECT * FROM stock_voisin WHERE vin_id=12;
Vérifier le plan :
EXPLAIN (ANALYZE, VERBOSE) SELECT * FROM stock_voisin WHERE vin_id=12 ;
Il faut l’option VERBOSE
pour voir la requête envoyée au
serveur distant. Vous constatez que le prédicat sur vin_id
a été transmis, ce qui est le principal avantage de cette implémentation
sur les DBLinks.
Principe :
Sous PostgreSQL, les tables temporaires sont créées dans une session, et disparaissent à la déconnexion. Elles ne sont pas visibles par les autres sessions. Elles ne sont pas journalisées, ce qui est très intéressant pour les performances. Elles s’utilisent comme les autres tables, y compris pour l’indexation, les triggers, etc.
Les tables temporaires semblent donc idéales pour des tables de travail temporaires et « jetables ».
Cependant, il est déconseillé d’abuser des tables temporaires. En
effet, leur création/destruction permanente entraîne une fragmentation
importante des tables systèmes (en premier lieu
pg_catalog.pg_class
,
pg_catalog.pg_attribute
…), qui peuvent devenir énormes. Ce
n’est jamais bon pour les performances, et peut nécessiter un
VACUUM FULL
des tables système !
Le démon autovacuum ne voit pas les tables temporaires ! Les
statistiques devront donc être mises à jour manuellement avec
ANALYZE
, et il faudra penser à lancer VACUUM
explicitement après de grosses modifications.
Aspect technique :
Les tables temporaires sont créées dans un schéma temporaire
pg_temp_…
, ce qui explique qu’elles ne sont pas visibles
dans le schéma public
.
Physiquement, par défaut, elles sont stockées sur le disque avec les
autres données de la base, et non dans base/pgsql_tmp
comme
les fichiers temporaires. Il est possible de définir des tablespaces
dédiés aux objets temporaires (fichiers temporaires et données des
tables temporaires) à l’aide du paramètre temp_tablespaces
,
à condition de donner des droits CREATE
dessus aux
utilisateurs. Le nom du fichier d’une table temporaire est
reconnaissable car il commence par t
. Les éventuels index
de la table suivent les même règles.
Exemple :
CREATE TEMP TABLE travail (x int PRIMARY KEY) ;
EXPLAIN (COSTS OFF, ANALYZE, BUFFERS, WAL)
INSERT INTO travail SELECT i FROM generate_series (1,1000000) i ;
QUERY PLAN
-------------------------------------------------------------------------------
Insert on travail (actual time=1025.752..1025.753 rows=0 loops=1)
Buffers: shared hit=13, local hit=2172174 read=4 dirtied=7170 written=10246
I/O Timings: read=0.012
-> Function Scan on generate_series i (actual time=77.112..135.624 rows=1000000 loops=1)
Planning Time: 0.028 ms Execution Time: 1034.984 ms
SELECT pg_relation_filepath ('travail') ;
pg_relation_filepath
-----------------------
base/13746/t7_5148873
\d pg_temp_7.travail
Table « pg_temp_7.travail »
Colonne | Type | Collationnement | NULL-able | Par défaut
---------+---------+-----------------+-----------+------------
x | integer | | not null |
Index : "travail_pkey" PRIMARY KEY, btree (x)
Cache :
Dans les plans d’exécution avec BUFFERS
, l’habituelle
mention shared
est remplacée par local
pour
les tables temporaires. En effet, leur cache disque dédié est au niveau
de la session, non des shared buffers. Ce cache est défini par
le paramètre temp_buffers
(exprimé par session, et à 8 Mo
par défaut). Ce paramètre peut être augmenté, avant la création de la
table. Bien sûr, on risque de saturer la RAM en cas d’abus ou s’il y a
trop de sessions, comme avec work_mem
. Ce cache n’empêche
pas l’écriture des petites tables temporaires sur le disque.
Pour éviter de recréer perpétuellement la même table temporaire, une
table unlogged (voir plus bas) sera sans doute plus indiquée.
Le contenu de cette dernière sera aussi visible des autres sessions, ce
qui est pratique pour suivre la progression d’un traitement, faciliter
le travail de l’autovacuum, ou déboguer. Sinon, il est fréquent de
pouvoir remplacer une table temporaire par une CTE (clause
WITH
) ou un tableau en mémoire.
L’extension pgtt émule un autre type de table temporaire dite « globale » pour la compatibilité avec d’autres SGBD.
Une table unlogged est une table non journalisée. Comme la journalisation est responsable de la durabilité, une table non journalisée n’a pas cette garantie.
La table est systématiquement remise à zéro au redémarrage après un arrêt brutal. En effet, tout arrêt d’urgence peut entraîner une corruption des fichiers de la table ; et sans journalisation, il ne serait pas possible de la corriger au redémarrage et de garantir l’intégrité.
La non-journalisation de la table implique aussi que ses données ne sont pas répliquées vers des serveurs secondaires, et que les tables ne peuvent figurer dans une publication (réplication logique). En effet, les modes de réplication natifs de PostgreSQL utilisent les journaux de transactions. Pour la même raison, une restauration de sauvegarde PITR ne restaurera pas le contenu de la table. Le bon côté est qu’on allège la charge sur la sauvegarde et la réplication.
Les contraintes doivent être respectées même si la table unlogged est vidée : une table normale ne peut donc avoir de clé étrangère pointant vers une table unlogged. La contrainte inverse est possible, tout comme une contrainte entre deux tables unlogged.
À part ces limitations, les tables unlogged se comportent exactement comme les autres. Leur intérêt principal est d’être en moyenne 5 fois plus rapides à la mise à jour. Elles sont donc à réserver à des cas d’utilisation particuliers, comme :
Les tables unlogged ne doivent pas être confondues avec les tables temporaires (non journalisées et visibles uniquement dans la session qui les a créées). Les tables unlogged ne sont pas ignorées par l’autovacuum (les tables temporaires le sont). Abuser des tables temporaires a tendance à générer de la fragmentation dans les tables système, alors que les tables unlogged sont en général créées une fois pour toutes.
Une table unlogged se crée exactement comme une table
journalisée classique, excepté qu’on rajoute le mot
UNLOGGED
dans la création.
Il est possible de basculer une table à volonté de normale à unlogged et vice-versa.
Quand une table devient unlogged, on pourrait imaginer que
PostgreSQL n’a rien besoin d’écrire. Malheureusement, pour des raisons
techniques, la table doit tout de même être réécrite. Elle est
défragmentée au passage, comme lors d’un VACUUM FULL
. Ce
peut être long pour une grosse table, et il faudra voir si le gain par
la suite le justifie.
Les écritures dans les journaux à ce moment sont théoriquement
inutiles, mais là encore des optimisations manquent et il se peut que de
nombreux journaux soient écrits si les sommes de contrôles ou
wal_log_hints
sont activés. Par contre il n’y aura plus
d’écritures dans les journaux lors des modifications de cette table, ce
qui reste l’intérêt majeur.
Quand une table unlogged devient logged (journalisée), la réécriture a aussi lieu, et tout le contenu de la table est journalisé (c’est indispensable pour la sauvegarde PITR et pour la réplication notamment), ce qui génère énormément de journaux et peut prendre du temps.
Par exemple, une table modifiée de manière répétée pendant un batch, peut être définie unlogged pour des raisons de performance, puis basculée en logged en fin de traitement pour pérenniser son contenu.
Les valeurs par défaut sont très connues mais limitées. PostgreSQL connaît les colonnes générées (ou calculées).
La syntaxe est :
<type> GENERATED ALWAYS AS ( <expression> ) STORED ; nomchamp
Les colonnes générées sont recalculées à chaque fois que les champs
sur lesquels elles sont basées changent, donc aussi lors d’un
UPDATE
(avant PostgreSQL 13, ils étaient systématiquement
recalculés, parfois inutilement). Ces champs calculés sont
impérativement marqués ALWAYS
, c’est-à-dire obligatoires et
non modifiables, et STORED
, c’est-à-dire stockés sur le
disque (et non recalculés à la volée comme dans une vue). Ils ne doivent
pas se baser sur d’autres champs calculés.
Un intérêt est que les champs calculés peuvent porter des
contraintes, par exemple la clause CHECK
ci-dessous, mais
encore des clés étrangères ou unique.
Exemple :
CREATE TABLE paquet (
PRIMARY KEY,
code text DEFAULT now(),
reception timestamptz DEFAULT now() + interval '3d',
livraison timestamptz int, longueur int, profondeur int,
largeur int
volume GENERATED ALWAYS AS ( largeur * longueur * profondeur )
CHECK (volume > 0.0)
STORED
) ;
INSERT INTO paquet (code, largeur, longueur, profondeur)
VALUES ('ZZ1', 3, 5, 10) ;
\x on
TABLE paquet ;
-[ RECORD 1 ]-----------------------------
code | ZZ1
reception | 2024-04-19 18:02:41.021444+02
livraison | 2024-04-22 18:02:41.021444+02
largeur | 3
longueur | 5
profondeur | 10 volume | 150
-- Les champs DEFAULT sont modifiables
-- Changer la largeur va modifier le volume
UPDATE paquet
SET largeur=4,
= '2024-07-14'::timestamptz,
livraison = '2024-04-20'::timestamptz
reception WHERE code='ZZ1' ;
TABLE paquet ;
-[ RECORD 1 ]----------------------
code | ZZ1
reception | 2024-04-20 00:00:00+02
livraison | 2024-07-14 00:00:00+02
largeur | 4
longueur | 5
profondeur | 10 volume | 200
-- Le volume ne peut être modifié
UPDATE paquet
SET volume = 250
WHERE code = 'ZZ1' ;
ERROR: column "volume" can only be updated to DEFAULT DETAIL : Column "volume" is a generated column.
Expression immutable :
Avec GENERATED
, l’expression du calcul doit être
« immutable », c’est-à-dire ne dépendre que des autres
champs de la même ligne, n’utiliser que des fonctions elles-mêmes
immutables, et rien d’autre. Il n’est donc pas possible d’utiliser des
fonctions comme now()
, ni des fonctions de conversion de
date dépendant du fuseau horaire, ou du paramètre de formatage de la
session en cours (toutes choses autorisées avec DEFAULT
),
ni des appels à d’autres lignes ou tables…
La colonne calculée peut être convertie en colonne « normale » :
ALTER TABLE paquet ALTER COLUMN volume DROP EXPRESSION ;
Mais modifier l’expression n’est pas (encore) possible, sauf à supprimer la colonne générée et en créer une nouvelle, ce qui implique de recalculer toutes les lignes et réécrire toute la table.
Il est possible de créer sa propre fonction pour l’expression, qui doit aussi être immutable :
CREATE OR REPLACE FUNCTION volume (l int, h int, p int)
int
RETURNS AS $$
SELECT l * h * p ;
$$
LANGUAGE sql-- cette fonction dépend uniquement des données de la ligne donc :
PARALLEL SAFE
IMMUTABLE ;
ALTER TABLE paquet DROP COLUMN volume ;
ALTER TABLE paquet ADD COLUMN volume int
GENERATED ALWAYS AS ( volume (largeur, longueur, profondeur) )
STORED;
TABLE paquet ;
-[ RECORD 1 ]----------------------
code | ZZ1
reception | 2024-04-20 00:00:00+02
livraison | 2024-07-14 00:00:00+02
largeur | 4
longueur | 5
profondeur | 10 volume | 200
Attention : modifier la fonction ne réécrit pas spontanément la table, il faut forcer la réécriture avec par exemple :
UPDATE paquet SET longueur = longueur ;
Ne pas réécrire les anciennes valeurs calculées n’est pas un moyen de les conserver. En effet, en cas de sauvegarde logique et restauration, tous les champs seront recalculés avec la dernière formule !
Un autre piège : il faut résister à la tentation de déclarer une fonction comme immutable sans la certitude qu’elle l’est bien (penser aux paramètres de session, aux fuseaux horaires…), sous peine d’incohérences dans les données.
Cas d’usage :
Les colonnes générées économisent la création de triggers, ou de vues de « présentation ». Elles facilitent la dénormalisation de données calculées dans une même table tout en garantissant l’intégrité.
Un cas d’usage courant est la dénormalisation d’attributs JSON pour les manipuler comme des champs de table classiques :
ALTER TABLE personnes
ADD COLUMN lastname text
GENERATED ALWAYS AS ((datas->>'lastName')) STORED ;
L’accès au champ est notablement plus rapide que l’analyse systématique du champ JSON.
Par contre, les colonnes GENERATED
ne sont
pas un bon moyen pour créer des champs portant la
dernière mise à jour. Certes, PostgreSQL ne vous empêchera pas de
déclarer une fonction (abusivement) immutable utilisant
now()
ou une variante. Mais ces informations seront perdues
en cas de restauration logique. Dans ce cas, les triggers restent une
option plus complexe mais plus propre.
Une des nouveautés les plus visibles et techniquement pointues de la v11 est la « compilation à la volée » (Just In Time compilation, ou JIT) de certaines expressions dans les requêtes SQL. Le JIT n’est activé par défaut qu’à partir de la version 12.
Dans certaines requêtes, l’essentiel du temps est passé à décoder des
enregistrements (tuple deforming), à analyser des clauses
WHERE
, à effectuer des calculs. En conséquence, l’idée du
JIT est de transformer tout ou partie de la requête en un programme
natif directement exécuté par le processeur.
Cette compilation est une opération lourde qui ne sera effectuée que pour des requêtes qui en valent le coup, donc qui dépassent un certain coût. Au contraire de la parallélisation, ce coût n’est pas pris en compte par le planificateur. La décision d’utiliser le JIT ou pas se fait une fois le plan décidé, si le coût calculé de la requête dépasse un certain seuil.
Le JIT de PostgreSQL s’appuie actuellement sur la chaîne de
compilation LLVM, choisie pour sa flexibilité. L’utilisation nécessite
un PostgreSQL compilé avec l’option --with-llvm
et
l’installation des bibliothèques de LLVM.
Sur Debian, avec les paquets du PGDG, les dépendances sont en place dès l’installation.
Sur Rocky Linux/Red Hat 8 et 9, l’installation du paquet dédié suffit :
# dnf install postgresql14-llvmjit
Sur CentOS/Red Hat 7, ce paquet supplémentaire nécessite lui-même des paquets du dépôt EPEL :
# yum install epel-release
# yum install postgresql14-llvmjit
Les systèmes CentOS/Red Hat 6 ne permettent pas d’utiliser le JIT.
Si PostgreSQL ne trouve pas les bibliothèques nécessaires, il ne renvoie pas d’erreur et continue sans tenter de JIT. Pour tester si le JIT est fonctionnel sur votre machine, il faut le chercher dans un plan quand on force son utilisation ainsi :
SET jit=on;
SET jit_above_cost TO 0 ;
EXPLAIN (ANALYZE) SELECT 1;
QUERY PLAN
-------------------------------------------------------------------------------
Result (cost=0.00..0.01 rows=1 width=4) (… rows=1 loops=1)
Planning Time: 0.069 ms
JIT:
Functions: 1
Options: Inlining false, Optimization false, Expressions true,
Deforming true
Timing: Generation 0.123 ms, Inlining 0.000 ms, Optimization 0.187 ms,
Emission 2.778 ms, Total 3.088 ms Execution Time: 3.952 ms
La documentation officielle est assez accessible : https://doc.postgresql.fr/current/jit.html
Le JIT ne peut pas encore compiler toute une requête. La version actuelle se concentre sur des goulots d’étranglement classiques :
WHERE
pour filtrer les lignes ;GROUP BY
…Les jointures ne sont pas (encore ?) concernées par le JIT.
Le code résultant est utilisable plus efficacement avec les processeurs actuels qui utilisent les pipelines et les prédictions de branchement.
Pour les détails, on peut consulter notamment cette conférence très technique au FOSDEM 2018 par l’auteur principal du JIT, Andres Freund.
De l’avis même de son auteur, l’algorithme de déclenchement du JIT est « naïf ». Quatre paramètres existent (hors débogage).
jit = on
(défaut à partir de la v12) active le JIT
si l’environnement technique évoqué plus haut le
permet.
La compilation n’a cependant lieu que pour un coût de requête calculé
d’au moins jit_above_cost
(par défaut 100 000, une valeur
élevée). Puis, si le coût atteint jit_inline_above_cost
(500 000), certaines fonctions utilisées par la requête et supportées
par le JIT sont intégrées dans la compilation. Si
jit_optimize_above_cost
(500 000) est atteint, une
optimisation du code compilé est également effectuée. Ces deux dernières
opérations étant longues, elles ne le sont que pour des coûts assez
importants.
Ces seuils sont à comparer avec les coûts des requêtes, qui incluent les entrées-sorties, donc pas seulement le coût CPU. Ces seuils sont un peu arbitraires et nécessiteront sans doute un certain tuning en fonction de vos requêtes et de vos processeurs.
Des contre-performances dues au JIT ont déjà été observées, menant à monter les seuils. Le JIT est trop jeune pour que les développeurs de PostgreSQL eux-mêmes aient des règles d’ajustement des valeurs des différents paramètres. Il est fréquent de le désactiver ou de monter radicalement les seuils de déclenchement.
Un exemple de plan d’exécution sur une grosse table donne :
EXPLAIN (ANALYZE) SELECT sum(x), count(id)
# FROM bigtable WHERE id + 2 > 500000 ;
QUERY PLAN
-------------------------------------------------------------------------------
Finalize Aggregate (cost=3403866.94..3403866.95 rows=1 width=16) (…)
-> Gather (cost=3403866.19..3403866.90 rows=7 width=16)
(actual time=11778.983..11784.235 rows=8 loops=1)
Workers Planned: 7
Workers Launched: 7
-> Partial Aggregate (cost=3402866.19..3402866.20 rows=1 width=16)(…)
-> Parallel Seq Scan on bigtable (…)
Filter: ((id + 2) > 500000)
Rows Removed by Filter: 62500
Planning Time: 0.047 ms
JIT:
Functions: 42
Options: Inlining true, Optimization true, Expressions true, Deforming true
Timing: Generation 5.611 ms, Inlining 422.019 ms, Optimization 229.956 ms,
Emission 125.768 ms, Total 783.354 ms Execution Time: 11785.276 ms
Le plan d’exécution est complété, à la fin, des informations suivantes :
Dans l’exemple ci-dessus, on peut constater que ces coûts ne sont pas négligeables par rapport au temps total. Il reste à voir si ce temps perdu est récupéré sur le temps d’exécution de la requête… ce qui en pratique n’a rien d’évident.
Sans JIT, la durée de cette requête était d’environ 17 s. Ici le JIT est rentable.
Vu son coût élevé, le JIT n’a d’intérêt que pour les requêtes utilisant beaucoup le CPU et où il est le facteur limitant.
Ce seront donc surtout des requêtes analytiques agrégeant beaucoup de lignes, comprenant beaucoup de calculs et filtres, et non les petites requêtes d’un ERP.
Il n’y a pas non plus de mise en cache du code compilé.
Si gain il y a, il est relativement modeste en deçà de quelques millions de lignes, et devient de plus en plus important au fur et à mesure que la volumétrie augmente, à condition bien sûr que d’autres limites n’apparaissent pas (bande passante…).
Documentation officielle : https://docs.postgresql.fr/current/jit-decision.html
L’indexation FTS est un des cas les plus fréquents d’utilisation non-relationnelle d’une base de données : les utilisateurs ont souvent besoin de pouvoir rechercher une information qu’ils ne connaissent pas parfaitement, d’une façon floue :
PostgreSQL doit donc permettre de rechercher de façon efficace dans un champ texte. L’avantage de cette solution est d’être intégrée au SGBD. Le moteur de recherche est donc toujours parfaitement à jour avec le contenu de la base, puisqu’il est intégré avec le reste des transactions.
Le principe est de décomposer le texte en « lexèmes » propres à chaque langue. Cela implique donc une certaine forme de normalisation, et permettent aussi de tenir compte de dictionnaires de synonymes. Le dictionnaire inclue aussi les termes courants inutiles à indexer (stop words) propres à la langue (le, la, et, the, and, der, daß…).
Décomposition et recherche en plein texte utilisent des fonctions et opérateurs dédiés, ce qui nécessite donc une adaptation du code. Ce qui suit n’est qu’un premier aperçu. La recherche plein texte est un chapitre entier de la documentation officielle.
Adrien Nayrat a donné une excellente conférence sur le sujet au PGDay France 2017 à Toulouse (slides).
to_tsvector
analyse un texte et le décompose en lexèmes,
et non en mots. Les chiffres indiquent ici les positions et ouvrent la
possibilité à des scores de proximité. Mais des indications de poids
sont possibles.
Autre exemple de décomposition d’une phrase :
SHOW default_text_search_config ;
default_text_search_config
---------------------------- pg_catalog.french
SELECT to_tsvector (
'La documentation de PostgreSQL est sur https://www.postgresql.org/') ;
to_tsvector
---------------------------------------------------- 'document':2 'postgresql':4 'www.postgresql.org':7
Les mots courts et le verbe « être » sont repérés comme termes trop courants, la casse est ignorée, même l’URL est décomposée en protocole et hôte. On peut voir en détail comment la FTS a procédé :
SELECT description, token, dictionary, lexemes
FROM ts_debug('La documentation de PostgreSQL est sur https://www.postgresql.org/') ;
dictionary | lexemes
description | token | -----------------+--------------------+-------------+----------------------
all ASCII | La | french_stem | {}
Word,
Space symbols | | ¤ | ¤all ASCII | documentation | french_stem | {document}
Word,
Space symbols | | ¤ | ¤all ASCII | de | french_stem | {}
Word,
Space symbols | | ¤ | ¤all ASCII | PostgreSQL | french_stem | {postgresql}
Word,
Space symbols | | ¤ | ¤all ASCII | est | french_stem | {}
Word,
Space symbols | | ¤ | ¤all ASCII | sur | french_stem | {}
Word,
Space symbols | | ¤ | ¤// | ¤ | ¤
Protocol head | https:
Host | www.postgresql.org | simple | {www.postgresql.org}/ | ¤ | ¤ Space symbols |
Si l’on se trompe de langue, les termes courants sont mal repérés (et la recherche sera inefficace) :
SELECT to_tsvector ('english',
'La documentation de PostgreSQL est sur https://www.postgresql.org/');
to_tsvector
---------------------------------------------------------------------------------- 'de':3 'document':2 'est':5 'la':1 'postgresql':4 'sur':6 'www.postgresql.org':7
Pour construire un critère de recherche, to_tsquery
est
nécessaire :
SELECT * FROM textes
WHERE to_tsvector('french',contenu) @@ to_tsquery('Valjean & Cosette');
Les termes à chercher peuvent être combinés par &
,
|
(ou), !
(négation), <->
(mots successifs), <N>
(séparés par N lexèmes).
@@
est l’opérateur de correspondance. Il y
en a d’autres.
Il existe une fonction phraseto_tsquery
pour donner une
phrase entière comme critère, laquelle sera décomposée en lexèmes :
SELECT livre, contenu FROM textes
WHERE
'Les Misérables Tome V%'
livre ILIKE AND ( to_tsvector ('french',contenu)
'c''est la fautes de Voltaire')
@@ phraseto_tsquery(OR to_tsvector ('french',contenu)
'nous sommes tombés à terre')
@@ phraseto_tsquery( );
livre | contenu
-------------------------------------------------+----------------------------
…
Les misérables Tome V Jean Valjean, Hugo, Victor | Je suis tombé par terre, Les misérables Tome V Jean Valjean, Hugo, Victor | C'est la faute à Voltaire,
Les lexèmes, les termes courants, la manière de décomposer un terme… sont fortement liés à la langue.
Des configurations toutes prêtes sont fournies par PostgreSQL pour certaines langues :
# \dF
Liste des configurations de la recherche de texte
Schéma | Nom | Description
------------+------------+---------------------------------------
pg_catalog | arabic | configuration for arabic language
pg_catalog | danish | configuration for danish language
pg_catalog | dutch | configuration for dutch language
pg_catalog | english | configuration for english language
pg_catalog | finnish | configuration for finnish language
pg_catalog | french | configuration for french language
pg_catalog | german | configuration for german language
pg_catalog | hungarian | configuration for hungarian language
pg_catalog | indonesian | configuration for indonesian language
pg_catalog | irish | configuration for irish language
pg_catalog | italian | configuration for italian language
pg_catalog | lithuanian | configuration for lithuanian language
pg_catalog | nepali | configuration for nepali language
pg_catalog | norwegian | configuration for norwegian language
pg_catalog | portuguese | configuration for portuguese language
pg_catalog | romanian | configuration for romanian language
pg_catalog | russian | configuration for russian language
pg_catalog | simple | simple configuration
pg_catalog | spanish | configuration for spanish language
pg_catalog | swedish | configuration for swedish language
pg_catalog | tamil | configuration for tamil language pg_catalog | turkish | configuration for turkish language
La recherche plein texte est donc directement utilisable pour le
français ou l’anglais et beaucoup d’autres langues européennes. La
configuration par défaut dépend du paramètre
default_text_search_config
, même s’il est conseillé de
toujours passer explicitement la configuration aux fonctions. Ce
paramètre peut être modifié globalement, par session ou par un
ALTER DATABASE SET
.
En demandant le détail de la configuration french
, on
peut voir qu’elle se base sur des « dictionnaires » pour chaque type
d’élément qui peut être rencontré : mots, phrases mais aussi URL,
entiers…
# \dF+ french
Configuration « pg_catalog.french » de la recherche de texte
Analyseur : « pg_catalog.default »
Jeton | Dictionnaires
-----------------+---------------
asciihword | french_stem
asciiword | french_stem
email | simple
file | simple
float | simple
host | simple
hword | french_stem
hword_asciipart | french_stem
hword_numpart | simple
hword_part | french_stem
int | simple
numhword | simple
numword | simple
sfloat | simple
uint | simple
url | simple
url_path | simple
version | simple word | french_stem
On peut lister ces dictionnaires :
# \dFd
Liste des dictionnaires de la recherche de texte
Schéma | Nom | Description
------------+-----------------+---------------------------------------------
…
pg_catalog | english_stem | snowball stemmer for english language
…
pg_catalog | french_stem | snowball stemmer for french language
…
pg_catalog | simple | simple dictionary: just lower case
| and check for stopword …
Ces dictionnaires sont de type « Snowball », incluant notamment des
algorithmes différents pour chaque langue. Le dictionnaire
simple
n’est pas lié à une langue et correspond à une
simple décomposition après passage en minuscule et recherche de termes
courants anglais : c’est suffisant pour des éléments comme les URL.
D’autres dictionnaires peuvent être combinés aux existants pour créer une nouvelle configuration. Le principe est que les dictionnaires reconnaissent certains éléments, et transmettent aux suivants ce qu’ils n’ont pas reconnu. Les dictionnaires précédents, de type Snowball, reconnaissent tout et doivent donc être placés en fin de liste.
Par exemple, la contrib unaccent
permet de faire
une configuration négligeant les accents. La contrib
dict_int
fournit un dictionnaire qui
réduit la précision des nombres pour réduire la taille de l’index.
La contrib dict_xsyn
permet de créer un dictionnaire pour
gérer une
liste de synonymes. Mais les dictionnaires de synonymes peuvent être
gérés
manuellement. Les fichiers adéquats sont déjà présents ou à ajouter
dans $SHAREDIR/tsearch_data/
(par exemple
/usr/pgsql-14/share/tsearch_data
sur Red Hat/CentOS ou
/usr/share/postgresql/14/tsearch_data
sur Debian).
Par exemple, en utilisant le fichier d’exemple
$SHAREDIR/tsearch_data/synonym_sample.syn
, dont le contenu
est :
postgresql pgsql
postgre pgsql
gogle googl
indices index*
on peut définir un dictionnaire de synonymes, créer une nouvelle
configuration reprenant french
, et y insérer le nouveau
dictionnaire en premier élément :
CREATE TEXT SEARCH DICTIONARY messynonymes (template=synonym, synonyms='synonym_sample');
CREATE TEXT SEARCH CONFIGURATION french2 (copy=french);
ALTER TEXT SEARCH CONFIGURATION french2
ALTER MAPPING FOR asciiword,hword,asciihword,word
WITH messynonymes, french_stem ;
À l’usage :
SELECT to_tsvector ('french2', 'PostgreSQL s''abrège en pgsql ou Postgres') ;
to_tsvector
------------------------- 'abreg':3 'pgsql':1,5,7
Les trois versions de « PostgreSQL » ont été reconnues.
Pour une analyse plus fine, on peut ajouter d’autres dictionnaires linguistiques depuis des sources extérieures (Ispell, OpenOffice…). Ce n’est pas intégré par défaut à PostgreSQL mais la procédure est dans la documentation.
Des « thesaurus » peuvent être même être créés pour remplacer des expressions par des synonymes (et identifier par exemple « le meilleur SGBD » et « PostgreSQL »).
Principe :
Sans indexation, une recherche FTS fonctionne, mais parcourra
entièrement la table. L’indexation est possible, avec GIN ou GiST. On
peut stocker le vecteur résultat de to_tsvector
dans une
autre colonne de la table, et c’est elle qui sera indexée. Jusque
PostgreSQL 11, il est nécessaire de le faire manuellement, ou d’écrire
un trigger pour cela. À partir de PostgreSQL 12, on peut utiliser une
colonne générée (il est nécessaire de préciser la configuration FTS),
qui sera stockée sur le disque :
-- Attention, ceci réécrit la table
ALTER TABLE textes
ADD COLUMN vecteur tsvector
GENERATED ALWAYS AS (to_tsvector ('french', contenu)) STORED ;
Les critères de recherche porteront sur la colonne
vecteur
:
SELECT * FROM textes
WHERE vecteur @@ to_tsquery ('french','Roméo <2> Juliette');
Cette colonne sera ensuite indexée par GIN pour avoir des temps d’accès corrects :
CREATE INDEX on textes USING gin (vecteur) ;
Alternative : index fonctionnel
Plus simplement, il peut suffire de créer juste un index fonctionnel
sur to_tsvector ('french', contenu)
. On épargne ainsi
l’espace du champ calculé dans la table.
Par contre, l’index devra porter sur le critère de recherche exact, sinon il ne sera pas utilisable. Cela n’est donc pertinent que si la majorité des recherches porte sur un nombre très restreint de critères, et il faudra un index par critère.
CREATE INDEX idx_fts ON public.textes
USING gin (to_tsvector('french'::regconfig, contenu))
SELECT * FROM textes
WHERE to_tsvector ('french', contenu) @@ to_tsquery ('french','Roméo <2> Juliette');
Exemple complet de mise en place de FTS :
CREATE TEXT SEARCH CONFIGURATION depeches (COPY= french);
CREATE EXTENSION unaccent ;
ALTER TEXT SEARCH CONFIGURATION depeches ALTER MAPPING FOR
WITH unaccent,french_stem; hword, hword_part, word
depeche
, avec
des poids différents pour le titre et le texte, ici gérée manuellement
avec un trigger.CREATE TABLE depeche (id int, titre text, texte text) ;
ALTER TABLE depeche ADD vect_depeche tsvector;
UPDATE depeche
SET vect_depeche =
'depeches',coalesce(titre,'')), 'A') ||
(setweight(to_tsvector('depeches',coalesce(texte,'')), 'C'));
setweight(to_tsvector(
CREATE FUNCTION to_vectdepeche( )
trigger
RETURNS
LANGUAGE plpgsql-- common options: IMMUTABLE STABLE STRICT SECURITY DEFINER
AS $function$
BEGIN
NEW.vect_depeche :=
'depeches',coalesce(NEW.titre,'')), 'A') ||
setweight(to_tsvector('depeches',coalesce(NEW.texte,'')), 'C');
setweight(to_tsvector(return NEW;
END
$function$;
CREATE TRIGGER trg_depeche before INSERT OR update ON depeche
FOR EACH ROW execute procedure to_vectdepeche();
CREATE INDEX idx_gin_texte ON depeche USING gin(vect_depeche);
ANALYZE depeche ;
SELECT titre,texte FROM depeche WHERE vect_depeche @@
'depeches','varicelle');
to_tsquery(SELECT titre,texte FROM depeche WHERE vect_depeche @@
'depeches','varicelle & médecin'); to_tsquery(
SELECT titre,texte
FROM depeche
WHERE vect_depeche @@ to_tsquery('depeches','varicelle & médecin')
ORDER BY ts_rank_cd(vect_depeche, to_tsquery('depeches','varicelle & médecin'));
SELECT titre,ts_rank_cd(vect_depeche,query) AS rank
FROM depeche, to_tsquery('depeches','varicelle & médecin') query
WHERE query@@vect_depeche
ORDER BY rank DESC ;
Une recherche FTS est directement possible sur des champs JSON. Voici un exemple :
CREATE TABLE commandes (info jsonb);
INSERT INTO commandes (info)
VALUES
('{ "client": "Jean Dupont",
"articles": {"produit": "Enveloppes A4","qté": 24}}'
),
('{ "client": "Jeanne Durand",
"articles": {"produit": "Imprimante","qté": 1}}'
),
('{ "client": "Benoît Delaporte",
"items": {"produit": "Rame papier normal A4","qté": 5}}'
),
('{ "client": "Lucie Dumoulin",
"items": {"produit": "Pochette Papier dessin A3","qté": 5}}'
);
La décomposition par FTS donne :
SELECT to_tsvector('french', info) FROM commandes ;
to_tsvector
------------------------------------------------
'a4':5 'dupont':2 'envelopp':4 'jean':1
'durand':2 'imprim':4 'jeann':1
'a4':4 'benoît':6 'delaport':7 'normal':3 'papi':2 'ram':1 'a3':4 'dessin':3 'dumoulin':7 'luc':6 'papi':2 'pochet':1
Une recherche sur « papier » donne :
SELECT info FROM commandes c
WHERE to_tsvector ('french', c.info) @@ to_tsquery('papier') ;
info
----------------------------------------------------------------------------------
{"items": {"qté": 5, "produit": "Rame papier normal A4"}, "client": "Benoît Delaporte"} {"items": {"qté": 5, "produit": "Pochette Papier dessin A3"}, "client": "Lucie Dumoulin"}
Plus d’information chez Depesz : Full Text Search support for json and jsonb.
Afficher le nom du journal de transaction courant.
Créer une base pgbench vierge, de taille 80 (environ 1,2 Go). Les tables doivent être en mode unlogged.
Afficher la liste des objets unlogged dans la base pgbench.
Afficher le nom du journal de transaction courant. Que s’est-il passé ?
Passer l’ensemble des tables de la base pgbench en mode logged.
Afficher le nom du journal de transaction courant. Que s’est-il passé ?
Repasser toutes les tables de la base pgbench en mode unlogged.
Afficher le nom du journal de transaction courant. Que s’est-il passé ?
Réinitialiser la base pgbench toujours avec une taille 80 mais avec les tables en mode logged. Que constate-t-on ?
Réinitialiser la base pgbench mais avec une taille de 10. Les tables doivent être en mode unlogged.
Compter le nombre de lignes dans la table
pgbench_accounts
.
Simuler un crash de l’instance PostgreSQL.
Redémarrer l’instance PostgreSQL.
Compter le nombre de lignes dans la table
pgbench_accounts
. Que constate-t-on ?
Vous aurez besoin de la base textes. La base est
disponible en deux versions : complète sur https://dali.bo/tp_gutenberg (dump de 0,5 Go, table de
21 millions de lignes dans 3 Go) ou https://dali.bo/tp_gutenberg10 pour un extrait d’un
dizième. Le dump peut se restaurer par exemple dans une nouvelle base,
et contient juste une table nommée textes
.
curl -kL https://dali.bo/tp_gutenberg -o /tmp/gutenberg.dmp
createdb gutenberg
pg_restore -d gutenberg /tmp/gutenberg.dmp
# le message sur le schéma public exitant est normale
rm -- /tmp/gutenberg.dmp
Ce TP utilise la version complète de la base textes basée sur le projet Gutenberg. Un index GIN va permettre d’utiliser la Full Text Search sur la table textes.
Créer un index GIN sur le vecteur du champ
contenu
(fonctionto_tsvector
).
Quelle est la taille de cet index ?
Quelle performance pour trouver « Fantine » (personnage des Misérables de Victor Hugo) dans la table ? Le résultat contient-il bien « Fantine » ?
Trouver les lignes qui contiennent à la fois les mots « affaire » et « couteau » et voir le plan.
Afficher le nom du journal de transaction courant.
SELECT pg_walfile_name(pg_current_wal_lsn()) ;
pg_walfile_name
-------------------------- 000000010000000100000024
Créer une base pgbench vierge, de taille 80 (environ 1,2 Go). Les tables doivent être en mode unlogged.
$ createdb pgbench
$ /usr/pgsql-14/bin/pgbench -i -s 80 --unlogged-tables pgbench
dropping old tables...
NOTICE: table "pgbench_accounts" does not exist, skipping
NOTICE: table "pgbench_branches" does not exist, skipping
NOTICE: table "pgbench_history" does not exist, skipping
NOTICE: table "pgbench_tellers" does not exist, skipping
creating tables...
generating data (client-side)...
8000000 of 8000000 tuples (100%) done (elapsed 4.93 s, remaining 0.00 s)
vacuuming...
creating primary keys...
done in 8.84 s (drop tables 0.00 s, create tables 0.01 s, client-side generate 5.02 s, vacuum 1.79 s, primary keys 2.02 s).
Afficher la liste des objets unlogged dans la base pgbench.
SELECT relname FROM pg_class
WHERE relpersistence = 'u' ;
relname
-----------------------
pgbench_accounts
pgbench_branches
pgbench_history
pgbench_tellers
pgbench_branches_pkey
pgbench_tellers_pkey pgbench_accounts_pkey
Les 3 objets avec le suffixe pkey correspondent aux clés primaires des tables créées par pgbench. Comme elles dépendent des tables, elles sont également en mode unlogged.
Afficher le nom du journal de transaction courant. Que s’est-il passé ?
SELECT pg_walfile_name(pg_current_wal_lsn()) ;
pg_walfile_name
-------------------------- 000000010000000100000024
Comme l’initialisation de pgbench a été réalisée en mode unlogged, aucune information concernant les tables et les données qu’elles contiennent n’a été inscrite dans les journaux de transaction. Donc le journal de transaction est toujours le même.
Passer l’ensemble des tables de la base pgbench en mode logged.
ALTER TABLE pgbench_accounts SET LOGGED;
ALTER TABLE pgbench_branches SET LOGGED;
ALTER TABLE pgbench_history SET LOGGED;
ALTER TABLE pgbench_tellers SET LOGGED;
Afficher le nom du journal de transaction courant. Que s’est-il passé ?
SELECT pg_walfile_name(pg_current_wal_lsn());
pg_walfile_name
-------------------------- 000000010000000100000077
Comme toutes les tables de la base pgbench ont été
passées en mode logged, une réécriture de celles-ci a
eu lieu (comme pour un VACUUM FULL
). Cette réécriture
additionnée au mode logged a entraîné une forte
écriture dans les journaux de transaction. Dans notre cas, 83 journaux
de transaction ont été consommés, soit approximativement 1,3 Go
d’utilisé sur disque.
Il faut donc faire particulièrement attention à la quantité de journaux de transaction qui peut être générée lors du passage d’une table du mode unlogged à logged.
Repasser toutes les tables de la base pgbench en mode unlogged.
ALTER TABLE pgbench_accounts SET UNLOGGED;
ALTER TABLE pgbench_branches SET UNLOGGED;
ALTER TABLE pgbench_history SET UNLOGGED;
ALTER TABLE pgbench_tellers SET UNLOGGED;
Afficher le nom du journal de transaction courant. Que s’est-il passé ?
SELECT pg_walfile_name(pg_current_wal_lsn());
pg_walfile_name
-------------------------- 000000010000000100000077
Le processus est le même que précedemment, mais, lors de la réécriture des tables, aucune information n’est stockée dans les journaux de transaction.
Réinitialiser la base pgbench toujours avec une taille 80 mais avec les tables en mode logged. Que constate-t-on ?
$ /usr/pgsql-14/bin/pgbench -i -s 80 -d pgbench
dropping old tables...
creating tables...
generating data (client-side)...
8000000 of 8000000 tuples (100%) done (elapsed 9.96 s, remaining 0.00 s)
vacuuming...
creating primary keys...
done in 16.60 s (drop tables 0.11 s, create tables 0.00 s, client-side generate 10.12 s, vacuum 2.87 s, primary keys 3.49 s).
On constate que le temps mis par pgbench pour initialiser sa base est beaucoup plus long en mode logged que unlogged. On passe de 8,84 secondes en unlogged à 16,60 secondes en mode logged. Cette augmentation du temps de traitement est due à l’écriture dans les journaux de transaction.
Réinitialiser la base pgbench mais avec une taille de 10. Les tables doivent être en mode unlogged.
$ /usr/pgsql-14/bin/pgbench -i -s 10 -d pgbench --unlogged-tables
dropping old tables...
creating tables...
generating data (client-side)...
1000000 of 1000000 tuples (100%) done (elapsed 0.60 s, remaining 0.00 s)
vacuuming...
creating primary keys...
done in 1.24 s (drop tables 0.02 s, create tables 0.02 s, client-side generate 0.62 s, vacuum 0.27 s, primary keys 0.31 s).
Compter le nombre de lignes dans la table
pgbench_accounts
.
SELECT count(*) FROM pgbench_accounts ;
count
--------- 1000000
Simuler un crash de l’instance PostgreSQL.
$ ps -ef | grep postmaster
postgres 697 1 0 14:32 ? 00:00:00 /usr/pgsql-14/bin/postmaster -D ...
$ kill -9 697
Ne faites jamais un kill -9
sur un processus de
l’instance PostgreSQL en production, bien sûr !
Redémarrer l’instance PostgreSQL.
$ /usr/pgsql-14/bin/pg_ctl -D /var/lib/pgsql/14/data start
Compter le nombre de lignes dans la table
pgbench_accounts
. Que constate-t-on ?
SELECT count(*) FROM pgbench_accounts ;
count
------- 0
Lors d’un crash, PostgreSQL remet tous les objets unlogged à zéro.
Créer un index GIN sur le vecteur du champ
contenu
(fonctionto_tsvector
).
=# CREATE INDEX idx_fts ON textes
textesUSING gin (to_tsvector('french',contenu));
CREATE INDEX
Quelle est la taille de cet index ?
La table « pèse » 3 Go (même si on pourrait la stocker de manière beaucoup plus efficace). L’index GIN est lui-même assez lourd dans la configuration par défaut :
=# SELECT pg_size_pretty(pg_relation_size('idx_fts'));
textes
pg_size_pretty----------------
593 MB
1 ligne) (
Quelle performance pour trouver « Fantine » (personnage des Misérables de Victor Hugo) dans la table ? Le résultat contient-il bien « Fantine » ?
=# EXPLAIN (ANALYZE,BUFFERS) SELECT * FROM textes
textesWHERE to_tsvector('french',contenu) @@ to_tsquery('french','fantine');
QUERY PLAN
-------------------------------------------------------------------------------
Bitmap Heap Scan on textes (cost=107.94..36936.16 rows=9799 width=123)
(actual time=0.423..1.149 rows=326 loops=1)
Recheck Cond: (to_tsvector('french'::regconfig, contenu)
@@ '''fantin'''::tsquery)
Heap Blocks: exact=155
Buffers: shared hit=159
-> Bitmap Index Scan on idx_fts (cost=0.00..105.49 rows=9799 width=0)
(actual time=0.210..0.211 rows=326 loops=1)
Index Cond: (to_tsvector('french'::regconfig, contenu)
@@ '''fantin'''::tsquery)
Buffers: shared hit=4
Planning Time: 1.248 ms Execution Time: 1.298 ms
On constate donc que le Full Text Search est très efficace du moins pour le Full Text Search + GIN : trouver 1 mot parmi plus de 100 millions avec 300 enregistrements correspondants dure 1,5 ms (cache chaud).
Si l’on compare avec une recherche par trigramme (extension
pg_trgm
et index GIN), c’est bien meilleur. À l’inverse,
les trigrammes permettent des recherches floues (orthographe
approximative), des recherches sur autre chose que des mots, et ne
nécessitent pas de modification de code.
Par contre, la recherche n’est pas exacte, « Fantin » est fréquemment
trouvé. En fait, le plan montre que c’est le vrai critère retourné par
to_tsquery('french','fantine')
et transformé en
'fantin'::tsquery
. Si l’on tient à ce critère précis il
faudra ajouter une clause plus classique
contenu LIKE '%Fantine%'
pour filtrer le résultat après que
le FTS ait « dégrossi » la recherche.
Trouver les lignes qui contiennent à la fois les mots « affaire » et « couteau » et voir le plan.
10 lignes sont ramenées en quelques millisecondes :
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM textes
WHERE to_tsvector('french',contenu) @@ to_tsquery('french','affaire & couteau')
;
QUERY PLAN
-------------------------------------------------------------------------------
Bitmap Heap Scan on textes (cost=36.22..154.87 rows=28 width=123)
(actual time=6.642..6.672 rows=10 loops=1)
Recheck Cond: (to_tsvector('french'::regconfig, contenu)
@@ '''affair'' & ''couteau'''::tsquery)
Heap Blocks: exact=10
Buffers: shared hit=53
-> Bitmap Index Scan on idx_fts (cost=0.00..36.21 rows=28 width=0)
(actual time=6.624..6.624 rows=10 loops=1)
Index Cond: (to_tsvector('french'::regconfig, contenu)
@@ '''affair'' & ''couteau'''::tsquery)
Buffers: shared hit=43
Planning Time: 0.519 ms Execution Time: 6.761 ms
Noter que les pluriels « couteaux » et « affaires » figurent parmi
les résultats puisque la recherche porte sur les léxèmes
'affair'' & ''couteau'
.
La boutique de Paul a beaucoup de clients. Paul demande à son ami Pierre, data scientist, des statistiques sur ses clients (âge moyen, etc.).
Pierre demande un accès direct à la base de données pour écrire ses requêtes SQL.
Jack est un employé de Paul, chargé des relations avec les divers fournisseurs de la boutique.
Paul respecte la vie privée de ses fournisseurs. Il doit masquer les informations personnelles à Pierre, mais Jack doit pouvoir lire les vraies données, et y écrire.
Crédits
Cet exemple pratique est un travail collectif de Damien Clochard, Be Hai Tran, Florent Jardin et Frédéric Yhuel.
La version originale en anglais est diffusée sous licence PostgreSQL.
Le présent document en est l’adaptation en français.
Paul’s Boutique est le second album studio du groupe de hip-hop américain les Beastie Boys, sorti le 25 juillet 1989 chez Capitol Records.
La photo ci-dessus est d’Erwin Bernal, sous licence CC BY 2.0.
postgresql_anonymizer
est une extension pour masquer ou
remplacer des données
personnelles (ou PII pour personnally identifiable
information) ou toute donnée sensible dans une base de données
PostgreSQL.
Le projet a une approche déclarative de l’anonymisation. Vous pouvez déclarer les règles de masquage dans PostgreSQL avec du DDL (Data Definition Language, ou langage de définition des données) et spécifier votre stratégie d’anonymisation dans la définition de la table.
Une fois les règles de masquage définies, vous pouvez accéder aux données anonymisées de quatre manières différentes :
Cette présentation n’entrera pas dans le détail du RGPD et des principes généraux d’anonymisation. Pour plus d’informations, référez-vous à la présentation de Damien Clochard ci-dessous :
Voir section « Installation » dans la documentation pour savoir comment installer l’extension dans votre instance PostgreSQL.
Par exemple :
sudo yum install postgresql_anonymizer_14
Sur Debian/Ubuntu, les paquets ne sont pas disponibles au moment où ceci est écrit. Le PGXN permet d’installer l’extension (ici en PostgreSQL 14) :
sudo apt install pgxnclient postgresql-server-dev-14
sudo pgxn install postgresql_anonymizer
S’il y a plusieurs versions de PostgreSQL installées, indiquer le répertoire des binaires de la bonne version ainsi :
sudo PATH=/usr/lib/postgresql/14/bin:$PATH pgxn install postgresql_anonymizer
L’extension sera compilée et installée.
Sauf précision contraire, toutes les commandes sont à exécuter en tant que paul.
Le masquage statique est la manière la plus simple de cacher des données personnelles. L’idée est simplement de détruire les données originales et de les remplacer par des données artificielles.
Nous allons voir :
SELECT * FROM customer;
id | firstname | lastname | phone | birth | postcode
-----+-----------+-----------+--------------+------------+----------
107 | Sarah | Conor | 060-911-0911 | 1965-10-10 | 90016
258 | Luke | Skywalker | | 1951-09-25 | 90120
341 | Don | Draper | 347-515-3423 | 1926-06-01 | 04520
(3 lignes)
NB : l’extension pgcrypto
sera installée
automatiquement. Ses binaires sont généralement livrés avec
PostgreSQL.
Paul veut masquer le nom de famille et le numéro de téléphone de ses clients.
Pour cela, il utilise les fonctions fake_last_name()
et
partial()
.
Cette fonction ne fait qu’appliquer la règle et doit renvoyer True. Ensuite :
SELECT id, firstname, lastname, phone
FROM customer;
id | firstname | lastname | phone
-----+-----------+----------+--------------
107 | Sarah | Okuneva | 06X-XXX-XX11
258 | Luke | Okuneva |
341 | Don | Boyle | 34X-XXX-XX23
Cette technique est nommée « masquage statique » car la donnée réelle a été détruite de manière définitive. L’anonymisation dynamique et les exports seront vus plus loin.
Déclarer une nouvelle règle de masquage et relancer l’anonymisation statique.
Paul réalise que le code postal est un bon indice sur l’endroit où vivent ses clients. Cependant, il voudrait pouvoir faire des statistiques par département.
Créer une règle de masquage pour remplacer les 3 derniers chiffres du code postal par ‘x’.
Agréger les clients selon le code postal anonymisé.
Paul veut des statistiques selon l’âge. Mais il veut aussi masquer les vraies dates de naissance de ses clients.
Remplacer toutes les dates de naissance par le 1er janvier, en conservant l’année réelle. Utiliser la fonction make_date.
Même si un client est correctement anonymisé, il est possible d’isoler un individu grâce à des données d’autres tables. Par exemple, il est possible d’identifier le meilleur client de Paul avec une telle requête :
WITH best_client AS (
SELECT SUM(amount), fk_customer_id
FROM payout
GROUP BY fk_customer_id
ORDER BY 1 DESC
LIMIT 1
)SELECT c.*
FROM customer c
JOIN best_client b ON (c.id = b.fk_customer_id) ;
id | firstname | lastname | phone | birth | postcode
-----+-----------+----------+--------------+------------+----------
341 | Don | Boyle | 34X-XXX-XX23 | 1926-06-01 | 04520
Ce processus est appelé « singularisation » (singling out) d’une personne.
Il faut donc aller plus loin dans l’anonymisation, en supprimant le
lien entre une personne et sa société. Dans la table des commandes
order
, ce lien est matérialisé par une clé étrangère sur le
champ fk_company_id
. Mais nous ne pouvons supprimer des
valeurs de cette colonne ou y insérer de faux identifiants, car cela
briserait la contrainte de clé étrangère.
Comment séparer les clients de leurs paiements tout en respectant l’intégrité des données ?
Trouver une fonction qui mélange les valeurs de
fk_company_id
dans la tablepayout
. Consulter la section shuffling de la documentation.
LABEL FOR anon ON COLUMN customer.firstname
SECURITY IS 'MASKED WITH FUNCTION anon.fake_first_name()' ;
SELECT anon.anonymize_table('customer') ;
La table anonymisée devient :
SELECT id, firstname, lastname
FROM customer ;
id | firstname | lastname
-----+-----------+----------
107 | Hans | Barton
258 | Jacqueline | Dare
341 | Sibyl | Runte
LABEL FOR anon ON COLUMN customer.postcode
SECURITY IS 'MASKED WITH FUNCTION anon.partial(postcode,2,$$xxx$$,0)' ;
SELECT anon.anonymize_table('customer') ;
Le code postal anonymisé devient :
SELECT id, firstname, lastname, postcode
FROM customer ;
id | firstname | lastname | postcode
-----+-----------+----------+----------
107 | Curt | O'Hara | 90xxx
258 | Agusta | Towne | 90xxx
341 | Sid | Hane | 04xxx
Noter que les noms ont encore changé après application de
anon.anonymize_table()
.
SELECT postcode, COUNT(id)
FROM customer
GROUP BY postcode;
postcode | count
----------+-------
90xxx | 2
04xxx | 1
LABEL FOR anon ON COLUMN customer.birth
SECURITY IS 'MASKED WITH FUNCTION make_date(EXTRACT(YEAR FROM birth)::INT,1,1)';
SELECT anon.anonymize_table('customer');
Les dates de naissance anonymisées deviennent :
SELECT id, firstname, lastname, birth
FROM customer ;
id | firstname | lastname | birth
-----+-----------+----------+------------
107 | Pinkie | Sporer | 1965-01-01
258 | Zebulon | Gerlach | 1951-01-01
341 | Erna | Emmerich | 1926-01-01
Pour mélanger les valeurs de fk_customer_id
:
SELECT anon.shuffle_column('payout','fk_customer_id','id') ;
Si l’on essaie à nouveau d’identifier le meilleur client :
WITH best_client AS (
SELECT SUM(amount), fk_customer_id
FROM payout
GROUP BY fk_customer_id
ORDER BY 1 DESC
LIMIT 1
)SELECT c.*
FROM customer c
JOIN best_client b ON (c.id = b.fk_customer_id) ;
id | firstname | lastname | phone | birth | postcode
-----+-----------+----------+-------+------------+----------
258 | Zebulon | Gerlach | | 1951-01-01 | 90xxx
Noter que le lien entre un client (customer
) et ses
paiements (payout
) est à présent complètement faux !
Par exemple, si un client A a deux paiements, l’un se retrouvera associé à un client B, et l’autre à un client C. En d’autres termes, cette méthode de mélange respectera la contrainte d’intégrité technique, mais brisera l’intégrité des données. Pour certaines utilisations, ce peut être problématique.
Ici, Pierre ne pourra pas produire de rapport avec les données mélangées.
Avec le masquage dynamique, le propriétaire de la base peut masquer les données personnelles à certains utilisateurs, tout en laissant aux autres les droits de lire et modifier les données réelles.
SELECT * FROM company ;
id | name | vat_id
-----+---------------------+----------------
952 | Shadrach | FR62684255667
194 | Johnny's Shoe Store | CHE670945644
346 | Capitol Records | GB663829617823
SELECT * FROM supplier;
id | fk_company_id | contact | phone | job_title
-----+---------------+----------------+-------------+---------------
299 | 194 | Johnny Ryall | 597-500-569 | CEO
157 | 346 | George Clinton | 131-002-530 | Sales manager
Le rôle pierre devient « masqué », dans le sens où le super-utilisateur va pouvoir lui imposer un masque qui va changer sa vision des données.
En tant que Pierre, on essaie de lire la table des fournisseurs :
\c boutique pierreSELECT * FROM supplier;
id | fk_company_id | contact | phone | job_title
-----+---------------+----------------+-------------+---------------
299 | 194 | Johnny Ryall | 597-500-569 | CEO
157 | 346 | George Clinton | 131-002-530 | Sales manager
Pour le moment, il n’y a pas de règle de masquage : Pierre peut voir les données originales dans chaque table.
Pierre essaie de lire la table des fournisseurs :
\c boutique pierreSELECT * FROM supplier ;
id | fk_company_id | contact | phone | job_title
-----+---------------+--------------+-------------+---------------
299 | 194 | CONFIDENTIAL | 597-500-569 | CEO
157 | 346 | CONFIDENTIAL | 131-002-530 | Sales manager
Si Jack essaie de lire les vraies données, ce sont encore les bonnes :
\c boutique jackSELECT * FROM supplier;
id | fk_company_id | contact | phone | job_title
-----+---------------+----------------+-------------+---------------
299 | 194 | Johnny Ryall | 597-500-569 | CEO
157 | 346 | George Clinton | 131-002-530 | Sales manager
Masquer le nom du fournisseur n’est pas suffisant pour anonymiser les données.
Se connecter en tant que Pierre. Écrire une requête simple permettant de recouper certains fournisseurs en se basant sur leur poste et leur société.
Les noms des sociétés et les postes de travail sont disponibles dans de nombreux jeux de données publics. Une simple recherche sur LinkedIn ou Google révèle les noms des principaux dirigeants de la plupart des sociétés…
On nomme « recoupement » la possibilité de rapprocher plusieurs données concernant la même personne.
Nous devons donc anonymiser aussi la table company
. Même
si elle ne contient pas d’informations personnelles, certains champs
peuvent être utilisés pour identifier certains de leurs employés…
Écrire deux règles de masquage pour la table
company
. La première doit remplacer le champnom
avec un faux nom. La seconde remplacervat_id
avec une suite aléatoire de dix caractères. NB : dans la documentation, consulter les générateurs de données factices et fonctions aléatoires (faking functions).
Vérifier que Pierre ne peut pas voir les vraies données sur la société.
À cause du masquage dynamique, les valeurs artificielles sont différentes à chaque fois que Pierre lit la table. Ce n’est pas toujours très pratique.
Pierre préfère appliquer tout le temps les mêmes valeurs artificielles pour une même société. Cela correspond à la « pseudonymisation ».
La pseudonymisation consiste à générer systématiquement les mêmes données artificielles pour un individu donné à la place de ses données réelles.
Écrire une nouvelle règle de masquage à partir du champ
name
, grâce à une fonction de pseudonymisation.
\c boutique pierreSELECT s.id, s.contact, s.job_title, c.name
FROM supplier s
JOIN company c ON s.fk_company_id = c.id ;
id | contact | job_title | name
-----+--------------+---------------+---------------------
299 | CONFIDENTIAL | CEO | Johnny's Shoe Store
157 | CONFIDENTIAL | Sales manager | Capitol Records
\c boutique paulLABEL FOR anon ON COLUMN company.name
SECURITY IS 'MASKED WITH FUNCTION anon.fake_company()';
LABEL FOR anon ON COLUMN company.vat_id
SECURITY IS 'MASKED WITH FUNCTION anon.random_string(10)';
En tant Pierre, relire la table :
\c boutique pierreSELECT * FROM company;
id | name | vat_id
-----+-----------------------------+------------
952 | Graham, Davis and Bauer | LYFVSI3WT5
194 | Martinez-Smith | 9N62K8M6JD
346 | James, Rodriguez and Nelson | OHB20OZ4Q3
À chaque lecture de la table, Pierre voit des données différentes :
SELECT * FROM company;
id | name | vat_id
-----+-----------------------------+------------
952 | Holt, Moreno and Richardson | KPAJP2Q4PK
194 | Castillo Group | NVGHZ1K50Z
346 | Mccarthy-Davis | GS3AHXBQTK
\c boutique paulALTER FUNCTION anon.pseudo_company SECURITY DEFINER;
LABEL FOR anon ON COLUMN company.name
SECURITY IS 'MASKED WITH FUNCTION anon.pseudo_company(id)';
Pour Pierre, les valeurs pseudonymisées resteront identiques entre deux appels (mais pas le code TVA) :
\c boutique pierreSELECT * FROM company;
id | name | vat_id
-----+-----------------+------------
952 | Wilkinson LLC | IKL88GJVT4
194 | Johnson PLC | VOOJ6UKR6H
346 | Young-Carpenter | DUR78F15VD
SELECT * FROM company;
id | name | vat_id
-----+-----------------+------------
952 | Wilkinson LLC | DIUAMTI653
194 | Johnson PLC | UND2DQGL4S
346 | Young-Carpenter | X6EOT023AK
Dans beaucoup de situations, le besoin est simplement d’exporter les données anonymisées pour les importer dans une autre base de données, pour mener des tests ou produire des statistiques. C’est ce que permet de faire l’outil pg_dump_anon.
\c boutique paulcopy website_comment from '/tmp/website_comment.tsv' \
SELECT jsonb_pretty(message)
FROM website_comment
ORDER BY id ASC
LIMIT 1 ;
jsonb_pretty
-----------------------------------
{ +
"meta": { +
"name": "Lee Perry", +
"ip_addr": "40.87.29.113"+
}, +
"content": "Hello Nasty!" +
}
La colonne comment
contient beaucoup d’informations
personnelles. Le fait de ne pas utiliser un schéma standard pour les
commentaires rend ici la tâche plus complexe.
Comme on peut le voir, les visiteurs du site peuvent écrire toutes sortes d’informations dans la section « commentaire ». La meilleure option serait donc de supprimer entièrement la clé JSON car il est impossible d’y exclure les données sensibles.
Il est possible de nettoyer la colonne comment
en
supprimant la clé content
:
SELECT message - ARRAY['content']
FROM website_comment
WHERE id=1 ;
Créer en premier lieu un schéma dédié, ici my_masks,
et le déclarer en trusted
(« de confiance »). Cela signifie
que l’extension anon
va considérer les fonctions de ce
schéma comme des fonctions de masquage valides.
Seul un super-utilisateur devrait être capable d’ajouter des fonctions dans ce schéma !
Cette fonction de masquage se contente de supprimer du JSON le champ avec le message :
CREATE OR REPLACE FUNCTION my_masks.remove_content(j jsonb)
RETURNS jsonbAS $func$
SELECT j - ARRAY['content']
$func$ LANGUAGE sql ;
Exécuter la fonction :
SELECT my_masks.remove_content(message)
FROM website_comment ;
{"meta": {"name": "Lee Perry", "ip_addr": "40.87.29.113"}}
{"meta": {"name": "", "email": "biz@bizmarkie.com"}}
{"meta": {"name": "Jimmy"}}
La fonction va pouvoir ensuite être utilisée dans une règle de masquage.
Enfin, une sauvegarde logique anonymisée de la table
peut être exportée avec l’utilitaire pg_dump_anon
. Celui-ci
est un script, livré avec l’extension, disponible dans le répertoire des
binaires :
L’outil utilise pg_dump
, il vaut mieux qu’il n’y ait pas
d’ambiguïté sur le chemin :
export PATH=$PATH:$(pg_config --bindir)
pg_dump_anon.sh --help
export PATH=$PATH:$(pg_config --bindir)
export PGHOST=localhost
export PGUSER=paul
pg_dump_anon.sh boutique --table=website_comment > /tmp/dump.sql
En ne demandant que les données (option -a
), le résultat
contient notamment :
COPY public.website_comment (id, message) FROM stdin;
1 {"meta": {"name": "Lee Perry", "ip_addr": "40.87.29.113"}, "content": "Hello Nasty!"}
2 {"meta": {"name": "", "email": "biz@bizmarkie.com"}, "content": "Great Shop"}
3 {"meta": {"name": "Jimmy"}, "content": "Hi ! This is me, Jimmy James "}
\.
pg_dump_anon ne vise pas à réimplémenter toutes les fonctionnalités de pg_dump. Il n’en supporte qu’une partie, notamment l’extraction d’objets précis, mais pas la compression par exemple. De plus, en raison de son fonctionnement interne, il y a un risque que la restauration des données soit incohérente, notamment en cas de DML ou DDL pendant la sauvegarde. Une sauvegarde totalement cohérente impose de passer un masquage statique et un export classique par pg_dump.
Le produit est en développement actif et la situation peut avoir changé depuis que ces lignes ont été écrites. Il est notamment prévu que le script bash soit remplacé par un script en go.
Créer une base de données nommée boutique_anon. y insérer les données anonymisées provenant de la base de données boutique.
Pierre compte extraire des informations générales depuis les métadonnées. Par exemple, il souhaite calculer le nombre de visiteurs uniques sur la base des adresses IP des visiteurs, mais une adresse IP est un identifiant indirect.
Paul doit donc anonymiser cette colonne tout en conservant la possibilité de faire le lien entre les enregistrements provenant de la même adresse.
Remplacer la fonctionremove_content
par la fonctionclean_comment
(ci-dessous), qui : - supprime la clé JSONcontent
; - remplace la valeur dans la colonnename
par un faux nom ; - remplace l’adresse IP dans la colonneip_address
par sa somme de contrôlemd5
; - met àNULL
la clé
CREATE OR REPLACE FUNCTION my_masks.clean_comment(message jsonb)
RETURNS jsonb
VOLATILE
LANGUAGE SQLAS $func$
SELECT
jsonb_set(
message,ARRAY['meta'],
jsonb_build_object('name',anon.fake_last_name(),
'ip_address', md5((message->'meta'->'ip_addr')::TEXT),
'email', NULL
)- ARRAY['content'];
) $func$;
export PATH=$PATH:$(pg_config --bindir)
export PGHOST=localhost
export PGUSER=paul
dropdb --if-exists boutique_anon
createdb boutique_anon --owner paul
pg_dump_anon.sh boutique | psql --quiet boutique_anon
export PGHOST=localhost
export PGUSER=paul
psql boutique_anon -c 'SELECT COUNT(*) FROM company'
count
-------
3
Suite à utilisation de la fonction personnalisée
clean_comment
, les données n’ont plus rien à voir :
SELECT my_masks.clean_comment(message)
FROM website_comment;
clean_comment
-----------------------------------------------------------------------------------------------
{"meta": {"name": "Heller", "email": null, "ip_address": "1d8cbcdef988d55982af1536922ddcd1"}}
{"meta": {"name": "Christiansen", "email": null, "ip_address": null}}
{"meta": {"name": "Frami", "email": null, "ip_address": null}}
(3 lignes)
On applique le masquage comme à l’habitude :
LABEL FOR anon ON COLUMN website_comment.message
SECURITY IS 'MASKED WITH FUNCTION my_masks.clean_comment(message)';
L’idée derrière la généralisation est de pouvoir flouter une donnée originale.
Par exemple, au lieu de dire « Monsieur X est né le 25 juillet 1989 », on peut dire « Monsieur X est né dans les années 1980 ». L’information reste vraie, bien que moins précise, et elle rend plus difficile l’identification de la personne.
Bien sûr, stocker les caractéristiques physiques d’employés est généralement illégal. Quoi qu’il en soit, il sera impératif de les masquer.
Ce fichier charge 16 lignes, dont :
SELECT full_name,first_day, hair, size, asthma
FROM employee
LIMIT 3 ;
full_name | first_day | hair | size | asthma
--------------+------------+-------+------+--------
Luna Dickens | 2018-07-22 | blond | L | t
Paul Wolf | 2020-01-15 | bald | M | f
Rowan Hoeger | 2018-12-01 | dark | XXL | t
Paul souhaite savoir s’il y a une corrélation entre l’asthme et la couleur des yeux.
Il fournit à Pierre la vue ci-dessus, qui peut désormais écrire des requêtes sur cette vue :
SELECT *
FROM v_asthma_eyes
LIMIT 3;
eyes | asthma
-------+--------
blue | t
brown | f
blue | t
SELECT
eyes,100*COUNT(1) FILTER (WHERE asthma) / COUNT(1) AS asthma_rate
FROM v_asthma_eyes
GROUP BY eyes ;
eyes | asthma_rate
-------+-------------
green | 100
brown | 37
blue | 33
Paul vient de prouver que l’asthme est favorisé par les yeux verts, et surtout de trouver une corrélation entre deux champs.
SELECT anon.k_anonymity('v_asthma_eyes');
k_anonymity
-------------
2
La vue v_asthma_eyes
a le niveau « 2-anonymity ». Cela
signifie que chaque combinaison de quasi-identifiants
(eyes
-asthma
) apparaît au moins 2 fois dans le
jeu de données.
En d’autres termes, cela veut dire qu’un individu ne peut pas être distingué d’au moins un autre individu (k-1) dans cette vue.
Pour les détails sur le K-anonymat, voir cet article sur Wikipédia.
\c boutique pierreSELECT *
FROM v_staff_per_month
LIMIT 3;
first_day | last_day
-------------------------+-------------------------
[2018-07-01,2018-08-01) | [2018-12-01,2019-01-01)
[2020-01-01,2020-02-01) | (,)
[2018-12-01,2019-01-01) | [2018-12-01,2019-01-01)
Pierre peut écrire une requête pour trouver le nombre d’employés embauchés en novembre 2021 :
SELECT COUNT(1)
FILTER (
WHERE make_date(2019,11,1)
BETWEEN lower(first_day)
AND COALESCE(upper(last_day),now())
)FROM v_staff_per_month ;
count
-------
4
Dans ce cas, le résultat est 1, ce qui veut dire qu’au moins une personne peut être directement identifiée par les dates de ses premier et dernier jour en poste.
Dans ce cas, la généralisation est insuffisante.
Généraliser les dates en mois n’est pas suffisant. > Écrire une
autre vue > v_staff_per_year qui va généraliser les dates en années.
> Simplifier également la vue en utilisant un intervalle de
int
pour stocker > l’année, plutôt qu’un intervalle de
date
.
Combien de personnes ont travaillé pour Paul chaque année entre 2018 et 2021 ?
Quel est le facteur k-anonymat de la vue v_staff_per_year ?
Cette vue généralise les dates en années :
\c boutique paulDROP MATERIALIZED VIEW IF EXISTS v_staff_per_year;
CREATE MATERIALIZED VIEW v_staff_per_year AS
SELECT
int4range(extract(year from first_day)::INT,
extract(year from last_day)::INT,
'[]' -- include upper bound
AS period
) FROM employee;
SELECT *
FROM v_staff_per_year
LIMIT 3;
period
-------------
[2018,2019)
[2020,)
[2018,2019)
Les personnes ayant travaillé pour Paul entre 2018 et 2021 sont :
SELECT
year,
COUNT(1) FILTER (
WHERE year <@ period
)FROM
2018,2021) year,
generate_series(
v_staff_per_yearGROUP BY year
ORDER BY year ASC;
year | count
------+-------
2018 | 4
2019 | 6
2020 | 9
2021 | 10
Le k-anonymat de cette vue est meilleur :
LABEL FOR anon ON COLUMN v_staff_per_year.period
SECURITY IS 'INDIRECT IDENTIFIER';
SELECT anon.k_anonymity('v_staff_per_year');
k_anonymity
-------------
2
Ce module permet d’aborder le pooling.
Ce qui suit ne portera que sur un unique serveur, et n’aborde pas le sujet de la répartition de charge.
Nous étudierons principalement un logiciel : PgBouncer.
Dans cette partie, nous allons étudier la théorie des poolers de connexion. La partie suivante sera la mise en pratique avec l’outil PgBouncer.
Un serveur de pool de connexions s’intercale entre les clients et le système de gestion de bases de données. Les clients ne se connectent plus directement sur le SGBD pour accéder aux bases. Ils passent par le pooler qui se fait passer pour le serveur de bases de données. Le pooler maintient alors des connexions vers le SGBD et en gère lui-même l’attribution aux utilisateurs.
Chaque connexion au SGBD est définie par deux paramètres : le rôle de connexion et la base de données. Ainsi, une connexion maintenue par le pooler ne sera attribuée à un utilisateur que si ce couple rôle/base de données est le même.
Les conditions de création de connexions au SGBD sont donc définies dans la configuration du pooler.
Un pooler peut se présenter sous différentes formes :
Nous nous consacrons dans ce module aux pools de connexions accessibles à travers un service.
Noter qu’il ne faut pas confondre un pooler avec un outil de répartition de charge (même si un pooler peut également permettre la répartition de charge, comme PgPool).
L’emplacement d’un pooler se décide au cas par cas selon l’architecture. Il peut se trouver intégré à l’application, et lui être dédié, ce qui garantit une latence faible entre pooler et application. Il peut être centralisé sur le serveur de bases de données et servir plusieurs applications, voire se trouver sur une troisième machine. Il faut aussi réfléchir à ce qui se passera en cas de bascule entre deux instances.
Le maintien des connexions entre le pooler et le SGBD apporte un gain non négligeable lors de l’établissement des connexions. Effectivement, pour chaque nouvelle connexion à PostgreSQL, nous avons :
Tout ceci engendre une consommation du processeur.
Ce travail peut durer plusieurs dizaines, voire centaines de millisecondes. Cette latence induite peut alors devenir un réel goulot d’étranglement dans certains contextes. Or, une connexion déjà active maintenue dans un pool peut être attribuée à une nouvelle session immédiatement : cette latence est donc de facto fortement limitée par le pooler.
En fonction du mode de fonctionnement, de la configuration et du type de pooler choisi, sa transparence vis-à-vis de l’application et son impact sur les performances seront différents.
De plus, cette position privilégiée entre les utilisateurs et le SGBD permet au pooler de contrôler et centraliser les connexions vers le ou les SGBD. Effectivement, les applications pointant sur le serveur de pool de connexions, le SGBD peut être situé n’importe où, voire sur plusieurs serveurs différents. Le pooler peut aiguiller les connexions vers un serveur différent en fonction de la base de données demandée. Certains poolers peuvent détecter une panne d’un serveur et aiguiller vers un autre. En cas de switchover, failover, évolution ou déplacement du SGBD, il peut suffire de reconfigurer le pooler.
Enfin, les sessions entrantes peuvent être mises en attente si plus aucune connexion n’est disponible et qu’elles ne peuvent pas en créer de nouvelle. On évite donc de lever immédiatement une erreur, ce qui est le comportement par défaut de PostgreSQL.
Pour la base de données, le pooler est une application comme une autre.
Si la configuration le permet (pg_hba.conf
), il est
possible de se connecter à une instance aussi bien via le pooler que
directement selon l’utilisation (application, batch,
administration…)
Les fonctionnalités de PostgreSQL utilisables au travers d’un pooler varient suivant son mode de fonctionnement du pooler (par requêtes, transactions ou sessions). Nous verrons que plus la mutualisation est importante, plus les restrictions apparaissent.
Un pooler est un élément en plus entre l’application et vos données, donc il aura un coût en performances. Il ajoute notamment une certaine latence. On n’introduit donc pas un pooler sans avoir identifié un problème. Si la configuration est bien faite, cet impact est normalement négligeable, ou en tout cas sera compensé par des gains au niveau de la base de données, ou en administration.
Comme dans tout système de proxy, un des points délicats de la configuration est l’authentification, avec certaines restrictions.
Un pooler est un élément en plus dans votre architecture. Il la rend donc plus complexe et y ajoute ses propres besoins en administration, en supervision et ses propres modes de défaillance. Si vous faites passer toutes vos connexions par un pooler, celui-ci devient un nouveau point de défaillance possible (SPOF). Une redondance est bien sûr possible, mais complique à nouveau les choses.
Un pool de connexion par session attribue une connexion au SGBD à un unique utilisateur pendant toute la durée de sa session. Si aucune connexion à PostgreSQL n’est disponible, une nouvelle connexion est alors créée, dans la limite exprimée dans la configuration du pooler. Si cette limite est atteinte, la session est mise en attente ou une erreur est levée.
L’intérêt d’un pool de connexion en mode session est principalement de conserver les connexions ouvertes vers le SGBD. On économise ainsi le temps d’établissement de la connexion pour les nouvelles sessions entrantes si une connexion est déjà disponible. Dans ce cas, le pooler permet d’avoir un comportement de type pre-fork côté SGBD.
L’autre intérêt est de ne pas rejeter une connexion, même s’il n’y a plus de connexions possibles au SGBD. Contrairement au comportement de PostgreSQL, les connexions sont placées en attente si elles ne peuvent pas être satisfaites immédiatement.
Ce mode de fonctionnement est très simple et robuste, c’est le plus transparent vis-à-vis des sessions clientes, avec un impact quasi nul sur le code applicatif.
Aucune optimisation du temps de travail côté SGBD n’est donc possible. S’il peut être intéressant de limiter le nombre de sessions ouvertes sur le pooler, il sera en revanche impossible d’avoir plus de sessions ouvertes sur le pooler que de connexions disponibles sur le SGDB.
Dans le schéma présenté ici, chaque bloc représente une transaction
délimitée par une instruction BEGIN
, suivie plus tard d’un
COMMIT
ou d’un ROLLBACK
. Chaque zone colorée
représente une requête au sein de la transaction.
Un pool de connexions par transactions multiplexe les transactions
des utilisateurs entre une ou plusieurs connexions au SGBD. Une
transaction est débutée sur la première connexion à la base qui soit
inactive (idle
). Toutes les requêtes d’une transaction sont
envoyées sur la même connexion.
Ce schéma suppose que le pool accorde la première connexion disponible en partant du haut dans l’ordre où les transactions se présentent.
Les intérêts d’un pool de connexion en mode transaction sont multiples en plus de cumuler ceux d’un pool de connexion par session.
Il est désormais possible de partager une même connexion au SGBD entre plusieurs sessions utilisateurs. En effet, il existe de nombreux contextes où une session a un taux d’occupation relativement faible : requêtes très simples et exécutées très rapidement, génération des requêtes globalement plus lente que la base de données, couche applicative avec des temps de traitement des données reçues plus importants que l’exécution côté SGBD, etc.
Avoir la capacité de multiplexer les transactions de plusieurs sessions entre plusieurs connexions permet ainsi de limiter le nombre de connexions à la base en optimisant leur taux d’occupation. Cette économie de connexions côté PostgreSQL a plusieurs avantages :
En revanche, avec ce mode de fonctionnement, le pool de connexions
n’assure pas aux clients connectés que leurs requêtes et transactions
iront toujours vers la même connexion, bien au contraire ! Ainsi, si
l’application utilise des requêtes préparées (c’est-à-dire en trois
phases PREPARE
, BIND
, EXECUTE
),
la commande PREPARE
pourrait être envoyée sur une connexion
alors que les commandes EXECUTE
pourraient être dirigées
vers d’autres connexions, menant leur exécution tout droit à une erreur.
Seules les requêtes au sein d’une même transaction sont assurées
d’être exécutées sur la même connexion. Ainsi, au début de cette
transaction, la connexion est alors réservée exclusivement à
l’utilisateur propriétaire de la transaction. Donc si le client prend
son temps entre les différentes étapes d’une transaction (statut
idle in transaction
pour PostgreSQL), il monopolisera la
connexion sans que les autres clients puissent en profiter.
Ce type de pool de connexion a donc un impact non négligeable à prendre en compte lors du développement.
Un pool de connexions par requêtes multiplexe les requêtes des utilisateurs entre une ou plusieurs connexions au SGBD.
Dans le schéma présenté ici, chaque bloc coloré représente une requête. Elles sont placées exactement aux mêmes instants que dans le schéma présentant le pool de connexion en mode transactions.
Les intérêts d’un pool de connexions en mode requêtes sont les mêmes que pour un pool de connexion en mode de transactions. Cependant, dans ce mode, toutes les requêtes des clients sont multiplexées à travers les différentes connexions disponibles et inactives.
Ainsi, il est désormais possible d’optimiser encore plus le temps de
travail des connexions au SGBD, supprimant la possibilité de bloquer une
connexion dans un état idle in transaction
. Nous sommes
alors capables de partager une même connexion avec encore plus de
clients, augmentant ainsi le nombre de sessions disponibles sur le pool
de connexions tout en conservant un nombre limité de connexions côté
SGBD.
En revanche, si les avantages sont les mêmes que ceux d’un pooler de connexion en mode transactions, les limitations sont elles aussi plus importantes. Il n’est effectivement plus possible d’utiliser des transactions, en plus des requêtes préparées !
En pratique, le pooling par requête sert à interdire totalement les transactions. En effet, un pooling par transaction n’utilisant que des transactions implicites (d’un seul ordre) parviendra au même résultat.
Deux projets sous licence BSD coexistent dans l’écosystème de PostgreSQL pour mettre en œuvre un pool de connexion : PgBouncer et PgPool-II.
PgPool-II est le projet le plus ancien, développé et maintenu principalement par SRA OSS. Ce projet est un véritable couteau suisse capable d’effectuer bien plus que du pooling (répartition de charge, bascules…). Malheureusement, cette polyvalence a un coût important en termes de fonctionnalités et complexités. PgPool n’est effectivement capable de travailler qu’en tant que pool de connexion par session.
PgBouncer est un projet créé par Skype. Il a pour objectifs :
PgBouncer étant le plus évolué des deux, nous allons le mettre en œuvre dans les pages suivantes.
PgBouncer est techniquement assez simple : il s’agit d’un simple démon, auxquelles les applicatifs se connectent (en croyant avoir affaire à PostgreSQL), et qui retransmet requêtes et données.
PgBouncer dispose de nombreuses fonctionnalités, toutes liées au pooling de connexions. La majorité de ces fonctionnalités ne sont pas disponibles avec PgPool.
À l’inverse de ce dernier, PgBouncer n’offre pas de répartition de charge. Ses créateurs renvoient vers des outils au niveau TCP comme HAProxy. De même, pour les bascules d’un serveur à l’autre, ils conseillent plutôt de s’appuyer sur le niveau DNS.
Ce qui suit n’est qu’un extrait de la documentation de référence, assez courte : https://www.pgbouncer.org/config.html. La FAQ est également à lire.
PgBouncer est disponible sous la forme d’un paquet binaire sur les principales distributions Linux et les dépôts du PGDG.
Il y a quelques différences mineures d’empaquetage : sous Red Hat/CentOS/Rocky Linux, le processus tourne avec un utilisateur système pgbouncer dédié, alors que sur Debian et dérivées, il fonctionne sous l’utilisateur postgres.
Il est bien sûr possible de recompiler depuis les sources.
Sous Windows, le projet fournit une archive à décompresser.
Les paquets binaires créent un fichier de configuration
/etc/pgbouncer/pgbouncer.ini
.
Une ligne de configuration concerne un seul paramètre, avec le format suivant :
parametre = valeur
PgBouncer n’accepte pas que l’utilisateur spécifie une unité pour les valeurs. L’unité prise en compte par défaut est la seconde.
Il y a plusieurs sections :
[databases]
), où on spécifie pour
chaque base la chaîne de connexion à utiliser ;[users]
), pour des propriétés liées
aux utilisateurs ;[pgbouncer]
), où se fait tout le reste de la
configuration de PgBouncer.PgBouncer accepte les connexions en mode socket Unix et via TCP/IP. Les paramètres disponibles ressemblent beaucoup à ce que PostgreSQL propose.
listen_addr
correspond aux interfaces réseau sur
lesquels PgBouncer va écouter. Il est par défaut configuré à la boucle
locale, mais vous pouvez ajouter les autres interfaces disponibles, ou
tout simplement une étoile pour écouter sur toutes les interfaces.
listen_port
précise le port de connexion :
traditionnellement, c’est 6432, mais on peut le changer, par exemple à
5432 pour que la configuration de connexion des clients reste
identique.
Si PostgreSQL se trouve sur le même serveur et que vous voulez utiliser le port 5432 pour PgBouncer, il faudra bien sûr changer le port de connexion de PostgreSQL.
Pour une connexion uniquement en local par la socket Unix, il est
possible d’indiquer où le fichier socket doit être créé (paramètre
unix_socket_dir
: /tmp
sur Red Hat/CentOS,
/var/run/postgresql
sur Debian et dérivés), quel groupe
doit lui être affecté (unix_socket_group
) et les droits du
fichier (unix_socket_mode
). Si un groupe est indiqué, il
est nécessaire que l’utilisateur détenteur du processus
pgbouncer
soit membre de ce groupe.
Cela est pris en compte par les paquets binaires d’installation.
PgBouncer supporte également le chiffrement TLS.
Lorsque l’utilisateur cherche à se connecter à PostgreSQL, il va indiquer l’adresse IP du serveur où est installé PgBouncer et le numéro de port où écoute PgBouncer. Il va aussi indiquer d’autres informations comme la base qu’il veut utiliser, le nom d’utilisateur pour la connexion, son mot de passe, etc.
Lorsque PgBouncer reçoit cette requête de connexion, il extrait le
nom de la base et va chercher dans la section [databases]
si cette base de données est indiquée. Si oui, il remplacera tous les
paramètres de connexion qu’il trouve dans son fichier de configuration
et établira la connexion entre ce client et cette base. Si jamais la
base n’est pas indiquée, il cherchera s’il existe une base de connexion
par défaut (nom indiqué par une étoile) et l’utilisera dans ce cas.
Exemples de chaîne de connexion :
prod = host=p1 port=5432 dbname=erp pool_size=40 pool_mode=transaction
prod = host=p1 port=5432 dbname=erp pool_size=10 pool_mode=session
Il est donc possible de faire beaucoup de chose :
user
;Néanmoins, les variables user
et password
sont très peu utilisées.
La chaîne de connexion est du type libpq mais tout ce qu’accepte la
libpq n’est pas forcément accepté par PgBouncer (notamment pas de
variable service, pas de possibilité d’utiliser directement le fichier
standard .pgpass
).
Le paramètre auth_hba_file
peut pointer vers un fichier
de même format que pg_hba.conf
pour filtrer les accès au
niveau du pooler (en plus des bases).
PgBouncer n’a pas accès à l’authentification de PostgreSQL. De plus, son rôle est de donner accès à des connexions déjà ouvertes à des clients. PgBouncer doit donc s’authentifier auprès de PostgreSQL à la place des clients, et vérifier lui-même les mots de passe de ces clients. (Ce mécanisme ne dispense évidemment pas les clients de fournir les mots de passe.)
La première méthode, et la plus simple, est de déclarer les
utilisateurs dans le fichier pointé par le paramètre
auth_file
, par défaut userlist.txt
. Les
utilisateurs et mots de passe y sont stockés comme ci-dessous selon le
type d’authentification, obligatoirement encadrés avec des guillemets
doubles.
"guillaume" "supersecret"
"marc" "md59fa7827a30a483125ca3b7218bad6fee"
"pgbench" "SCRAM-SHA-256$4096:Rqk+MWaDN9rKXOLuoj8eCw==$ry5DD2Ptk…+6do76FN/ys="
Le type d’authentification est plus limité que ce que PostgreSQL
propose. Le type trust
indique que l’utilisateur sera
accepté par PgBouncer quel que soit le mot de passe qu’il fournit ; il
faut que le serveur PostgreSQL soit configuré de la même façon. Cela est
bien sûr déconseillé. auth_type
peut prendre les valeurs
md5
ou scram-sha-256
pour autoriser des mots
de passe chiffrés. Pour des raisons de compatibilité descendante,
md5
permet aussi d’utiliser scram-sha-256
.
Les paramètres de configuration admin_users
et
stats_users
permettent d’indiquer la liste d’utilisateurs
pouvant se connecter à PgBouncer directement pour obtenir des commandes
de contrôle sur PgBouncer ainsi que des statistiques d’activité. Ils
peuvent être déclarés dans le fichier des mots de passe avec un mot de
passe arbitraire en clair.
userlist.txt
est évidemment un fichier dont les accès
doivent être les plus restreints possibles.
La maintenance du fichier de mots de passe peut vite devenir
fastidieuse. Il est possible de déléguer un rôle à la recherche des mots
de passe avec le paramètre auth_user
, à poser
globalement
auth_user = frontend
ou au niveau de la base :
prod = host=p1 port=5432 dbname=erp pool_mode=transaction auth_user=frontend
Ce rôle se connectera et ira valider dans l’instance le hash
du mot de passe du client. Il sera donc inutile de déclarer d’autres
rôles dans userlist.txt
.
Le rôle d’authentification et son mot de passe se déclarent par exemple ainsi :
SET password_encryption = 'scram-sha-256' ;
CREATE ROLE frontend PASSWORD 'motdepassecompliqué' LOGIN ;
SELECT rolpassword FROM pg_authid WHERE rolname = 'frontend' \gx
Le hachage du mot de passe obtenu est recopié dans
userlist.txt
:
"frontend" "SCRAM-SHA-256$4096:LaN76vw5sMU/0kvs9joNpA==$/ … ="
L’utilisateur frontend va utiliser le paramètre
auth_query
pour savoir comment récupérer les identifiants
de connexion de l’utilisateur applicatif qui veut se connecter. Par
défaut, il s’agit simplement de requêter la vue
pg_shadow
:
auth_query = SELECT usename, passwd FROM pg_shadow WHERE usename=$1
D’autres variantes sont possibles, comme une requête plus élaborée
sur pg_authid
, ou une fonction avec les bons droits de
consultation avec une clause SECURITY DEFINER
. La
documentation donne un exemple :
CREATE OR REPLACE FUNCTION pgbouncer.user_lookup
IN i_username text, OUT uname text, OUT phash text)
(record AS $$
RETURNS BEGIN
SELECT usename, passwd FROM pg_catalog.pg_shadow
WHERE usename = i_username INTO uname, phash;
RETURN;
END;
DEFINER;
$$ LANGUAGE plpgsql SECURITY REVOKE ALL ON FUNCTION pgbouncer.user_lookup(text) FROM public, pgbouncer;
GRANT EXECUTE ON FUNCTION pgbouncer.user_lookup(text) TO pgbouncer;
et cette fonction s’utilise ainsi :
auth_query = SELECT * FROM pgbouncer.user_lookup($1);
Il faut évidemment que l’utilisateur d’authentification (et seulement lui) ait les droits nécessaires, et cela dans toutes les bases impliquées.
La mise en place de cette configuration est facilement source d’erreur, il faut bien surveiller les traces de PostgreSQL et PgBouncer.
max_client_conn
(100)ulimit
!max_db_connections
default_pool_size
(20)min_pool_size
(0)reserve_pool_size
(0)PostgreSQL dispose d’un nombre de connexions maximum
(max_connections
dans postgresql.conf
, 100 par
défaut). Il est un compromis entre le nombre de requêtes simultanément
actives, leur complexité, le nombre de CPU, le nombre de processus
gérables par l’OS… L’utilisation d’un pooler en multiplexage se justifie
notamment quand des centaines, voire des milliers, de connexions
simultanées sont nécessaires, celles-ci étant inactives la plus grande
partie du temps. Même avec un nombre modeste de connexions, une
application se connectant et se déconnectant très souvent peut profiter
d’un pooler.
Les paramètres suivants de pgbouncer.ini
permettent de
paramétrer tout cela et de poser différentes limites. Les valeurs
dépendent beaucoup de l’utilisation : pooler unique pour une
seule base, poolers multiples pour plusieurs bases, utilisateur
applicatif unique ou pas…
Nombre de connexions côté client :
Le paramètre de configuration max_client_conn
permet
d’indiquer le nombre total maximum de connexions clientes à PgBouncer.
Sa valeur par défaut est de seulement 100, comme l’équivalent sous
PostgreSQL.
Un max_client_conn
élevé permet d’accepter plus de
connexions depuis les applications que n’en offrirait PostgreSQL. Si ce
nombre de clients est dépassé, les applications se verront refuser les
connexions. En dessous, PgBouncer accepte les connexions, et, au pire,
les met en attente. Cela peut arriver si la base PostgreSQL, saturée en
connexions, refuse la connexion ; ou si PgBouncer ne peut ouvrir plus de
connexions à la base à cause d’une des autres limites ci-dessous.
L’application subira donc une latence supplémentaire, mais évitera un
refus de connexion qu’elle ne saura pas forcément bien gérer.
max_db_connections
représente le maximum de connexions,
tous utilisateurs confondus, à une base donnée, déclarée dans PgBouncer,
donc du point de vue d’un client. Cela peut être modifié dans les
chaînes de connexions pour arbitrer entre les différentes bases.
S’il n’y a qu’une base utile, côté serveur comme côté PgBouncer, et
que tout l’applicatif passe par ce dernier,
max_db_connections
peut être proche du
max_connections
. Mais il faut laisser un peu de place aux
connexions administratives, de supervision, etc.
Connexions côté serveur :
default_pool_size
est le nombre maximum de connexions
PgBouncer/PostgreSQL d’un pool. Un pool est un couple
utilisateur/base de données côté PgBouncer. Il est possible de
personnaliser cette valeur base par base, en ajoutant
pool_size=…
dans la chaîne de connexion. Si dans cette même
chaîne il y a un paramètre user
qui impose le nom, il n’y a
plus qu’un pool.
S’il y a trop de demandes de connexion pour le pool, les transactions
sont mises en attente. Cela peut être nécessaire pour équilibrer les
ressources entre les différents utilisateurs, ou pour ne pas trop
charger le serveur ; mais l’attente peut devenir intolérable pour
l’application. Une « réserve » de connexions peut alors être définie
avec reserve_pool_size
: ces connexions sont utilisables
dans une situation grave, c’est-à-dire si des connexions se retrouvent à
attendre plus d’un certain délai, défini par
reserve_pool_timeout
secondes.
À l’inverse, pour faciliter les montées en charge rapides,
min_pool_size
définit un nombre de connexions qui seront
immédiatement ouvertes dès que le pool voit sa première connexion, puis
maintenues ouvertes.
Ces deux derniers paramètres peuvent aussi être globaux ou personnalisés dans les chaînes de connexion.
Descripteurs de fichiers :
PgBouncer utilise des descripteurs de fichiers pour les connexions. Le nombre de descripteurs peut être bien plus important que ce que n’autorise par défaut le système d’exploitation. Le maximum théorique est de :
max_client_conn + (max_pool_size * nombre de bases * nombre d’utilisateurs)
Le cas échéant (en pratique, au-delà de 1000 connexions au pooler), il faudra augmenter le nombre de descripteurs disponibles, sous peine d’erreurs de connexion :
ERROR accept() failed: Too many open files
Sur Debian et dérivés, un moyen simple est de rajouter cette commande
dans /etc/default/pgbouncer
:
ulimit -n 8192
Mais plus généralement, il est possible de modifier le service systemd ainsi :
sudo systemctl edit pgbouncer
ce qui revient à créer un fichier
/etc/systemd/system/pgbouncer.service.d/override.conf
contenant la nouvelle valeur :
[Service]
LimitNOFILE=8192
Puis il faut redémarrer le pooler :
sudo systemctl restart pgbouncer
et vérifier la prise en compte dans le fichier de traces de
PgBouncer, nommé pgbouncer.log
(dans
/var/log/postgresql/
sous Debian,
/var/log/pgbouncer/
sur CentOS/Red Hat) :
LOG kernel file descriptor limit: 8192 (hard: 8192);
max_client_conn: 4000, max expected fd use: 6712
Grâce au paramètre pool_mode
(dans la chaîne de
connexion à la base par exemple), PgBouncer accepte les différents modes
de pooling :
Les restrictions de chaque mode sont listées sur le site.
Lorsqu’un client se connecte, il peut utiliser des paramètres de
connexion que PgBouncer ne connaît pas ou ne sait pas gérer. Si
PgBouncer détecte un paramètre de connexion qu’il ne connaît pas, il
rejette purement et simplement la connexion. Le paramètre
ignore_startup_parameters
permet de changer ce
comportement, d’ignorer le paramètre et de procéder à la connexion. Par
exemple, une variable d’environnement PGOPTIONS
interdit la
connexion depuis psql, il faudra donc définir :
ignore_startup_parameters = options
ce qui malheureusement réduit à néant l’intérêt de cette variable pour modifier le comportement de PostgreSQL.
À la déconnexion du client, comme la connexion côté PostgreSQL peut être réutilisée par un autre client, il est nécessaire de réinitialiser la session : enlever la configuration de session, supprimer les tables temporaires, supprimer les curseurs, etc. Pour cela, PgBouncer exécute une liste de requêtes configurables ainsi :
server_reset_query = DISCARD ALL
Ce défaut suffira généralement. Il n’est en principe utile qu’en pooling de session, mais peut être forcé en pooling par transaction ou par requête :
server_reset_query_always = 1
En mode transactionnel, PgBouncer réutilise les mêmes connexions pour des transactions différentes et simultanées.
Chaque fois qu’une transaction commence (avec un BEGIN
),
se termine (avec un COMMIT
), ou même lorsqu’une requête
ordinaire est exécutée, PgBouncer maintient la même connexion pour un
client donné. Mais dès la fin de la transaction, PgBouncer libère la
connexion pour réutilisation par la prochaine transaction ou requête.
(C’est la différence par rapport au mode session, où PgBouncer attend la
fin de la session.)
Le mode transactionnel réduit notablement le nombre de connexions nécessaires.
Cependant, jusqu’à la version 1.21 de PgBouncer, l’utilisation d’instructions préparées n’était pas possible en mode transactionnel. On ne pouvait donc pas bénéficier de leurs avantages tels que la mise en cache des plans de requête.
La version 1.21 de PgBouncer introduit le support des instructions préparées en mode transactionnel, et ceci est transparent pour les clients compatibles.
Le nouveau paramètre max_prepared_statements
de
PgBouncer permet de gérer le nombre d’instructions préparées pour chaque
connexion. Cette valeur est par défaut à 0 (désactivé). La valeur 10 est
recommandée comme point de départ avant tests. Il faudra ensuite
contrôler l’utilisation de la mémoire et du CPU côté PgBouncer. La documentation
de PgBouncer fournit des informations permettant d’estimer le besoin en
mémoire.
Le mode transactionnel de PgBouncer n’est compatible qu’avec les
instructions préparées au niveau du protocole, ce qui peut ne pas
fonctionner correctement avec certaines bibliothèques clientes (ou «
connecteur »), et en particulier pas pour les commandes d’instructions
préparées au niveau SQL (c’est-à-dire PREPARE
,
EXECUTE
et DEALLOCATE
, directement transmis à
PostgreSQL et non gérés par PgBouncer, à l’exception de
DEALLOCATE ALL
et DISCARD ALL
qui, eux, sont
pris en charge).
Ensuite il n’est pas pour le moment possible d’utiliser
DEALLOCATE
, ce qui présente une limitation de la gestion
des instructions préparées. (Cette limitation pourrait être levée par
l’ajout d’une fonctionnalité à partir de la version 17 de PostgreSQL.)
Des erreurs peuvent donc être observées au niveau des clients qui
réalise des DEALLOCATE
.
Vérifiez donc dans la documentation de votre connecteur PostgreSQL la compatibilité avec ce mode. Par exemple, PHP/PDO n’est pour le moment pas compatible.
PgBouncer dispose d’un grand nombre de paramètres de durée de vie. Ils permettent d’éviter de conserver des connexions trop longues, notamment si elles sont inactives. C’est un avantage sur PostgreSQL qui ne dispose pas de ce type de paramétrage.
Les paramètres en client_*
concernent les connexions
entre le client et PgBouncer, ceux en server_*
concernent
les connexions entre PgBouncer et PostgreSQL.
Il est ainsi possible de libérer plus ou moins rapidement des connexions inutilisées, notamment s’il y a plusieurs pools concurrents, ou plusieurs sources de connexions à la base, ou si les pics de connexions sont irréguliers.
Il faut cependant faire attention. Par exemple, interrompre les
connexions inactives avec client_idle_timeout
peut couper
brutalement la connexion à une application cliente qui ne s’y attend
pas.
PgBouncer dispose de quelques options de configuration pour les traces.
Le paramètre logfile
indique l’emplacement (par défaut
/var/log/pgbouncer
sur Red Hat/CentOS,
/var/log/postgres
sur Debian et dérivés). On peut rediriger
vers syslog
.
Ensuite, il est possible de configurer les évènements tracés,
notamment les connexions (avec log_connections
) et les
déconnexions (avec log_disconnections
).
Par défaut, log_stats
est activé : PgBouncer trace alors
les statistiques sur les dernières 60 secondes
(paramètresstats_period
).
2020-11-30 19:10:07.839 CET [290804] LOG stats: 54 xacts/s, 380 queries/s,
in 23993 B/s, out 10128 B/s, xact 304456 us, query 43274 us, wait 14685821 us
PgBouncer possède une pseudo-base nommée pgbouncer
. Il
est possible de s’y connecter avec psql ou un autre outil. Il faut pour
cela se connecter avec un utilisateur autorisé (déclaration par les
paramètres admin_users
et stats_users
). Elle
permet de répondre à quelques ordres d’administration et de consulter
quelques vues.
Les utilisateurs « administrateurs » ont le droit d’exécuter des
instructions de contrôle, comme recharger la configuration
(RELOAD
), mettre le système en pause (PAUSE
),
supprimer la pause (RESUME
), forcer une
déconnexion/reconnexion dès que possible (RECONNECT
, le
plus propre en cas de modification de configuration), tuer toutes les
sessions d’une base (KILL
), arrêter PgBouncer
(SHUTDOWN
), etc.
Les utilisateurs statistiques peuvent récupérer des informations sur
l’activité de PgBouncer : statistiques sur les bases, les pools de
connexions, les clients, les serveurs, etc. avec
SHOW STATS
, SHOW STATS_AVERAGE
,
SHOW TOTALS
, SHOW MEM
, etc.
# sudo -iu postgres psql -h /var/run/postgresql -p 6432 pgbouncer
psql (13.1 (Ubuntu 13.1-1.pgdg20.04+1), serveur 1.14.0/bouncer)
pgbouncer=# SHOW help ;
NOTICE: Console usage
DÉTAIL :
SHOW HELP|CONFIG|DATABASES|POOLS|CLIENTS|SERVERS|USERS|VERSION
SHOW FDS|SOCKETS|ACTIVE_SOCKETS|LISTS|MEM
SHOW DNS_HOSTS|DNS_ZONES
SHOW STATS|STATS_TOTALS|STATS_AVERAGES|TOTALS
SET key = arg
RELOAD
PAUSE [<db>]
RESUME [<db>]
DISABLE <db>
ENABLE <db>
RECONNECT [<db>]
KILL <db>
SUSPEND
SHUTDOWN
pgbouncer=# SHOW DATABASES \gx
-[ RECORD 1 ]-------+--------------------------------------
name | pgbench_1000_sur_server3
host | 192.168.74.5
port | 13002
database | pgbench_1000
force_user |
pool_size | 10
reserve_pool | 7
pool_mode | session
max_connections | 0
current_connections | 17
paused | 0
disabled | 0
-[ RECORD 2 ]-------+--------------------------------------
…
pgbouncer=# SHOW POOLS \gx
-[ RECORD 1 ]-------------------------------------
database | pgbench_1000_sur_server3
user | pgbench
cl_active | 10
cl_waiting | 80
sv_active | 10
sv_idle | 0
sv_used | 0
sv_tested | 0
sv_login | 0
maxwait | 0
maxwait_us | 835428
pool_mode | session
-[ RECORD 2 ]-------------------------------------
database | pgbouncer
user | pgbouncer
cl_active | 1
cl_waiting | 0
sv_active | 0
sv_idle | 0
sv_used | 0
sv_tested | 0
sv_login | 0
maxwait | 0
maxwait_us | 0
pool_mode | statement
pgbouncer=# SHOW STATS \gx
-[ RECORD 1 ]-----+-------------------------
database | pgbench_1000_sur_server3
total_xact_count | 16444
total_query_count | 109711
total_received | 6862181
total_sent | 3041536
total_xact_time | 8885633095
total_query_time | 8873756132
total_wait_time | 14123238083
avg_xact_count | 103
avg_query_count | 667
avg_recv | 41542
avg_sent | 17673
avg_xact_time | 97189
avg_query_time | 14894
avg_wait_time | 64038262
-[ RECORD 2 ]-----+-------------------------
database | pgbouncer
total_xact_count | 1
total_query_count | 1
total_received | 0
total_sent | 0
total_xact_time | 0
total_query_time | 0
total_wait_time | 0
avg_xact_count | 0
avg_query_count | 0
avg_recv | 0
avg_sent | 0
avg_xact_time | 0
avg_query_time | 0
avg_wait_time | 0
pgbouncer=# SHOW MEM ;
name | size | used | free | memtotal
--------------+------+------+------+----------
user_cache | 360 | 11 | 39 | 18000
db_cache | 208 | 5 | 73 | 16224
pool_cache | 480 | 2 | 48 | 24000
server_cache | 560 | 17 | 33 | 28000
client_cache | 560 | 91 | 1509 | 896000
iobuf_cache | 4112 | 74 | 1526 | 6579200
Toutes ces informations sont utilisées notamment par la sonde Nagios check_postgres pour permettre une supervision de cet outil.
L’outil d’audit pgCluu peut
intégrer cette base à ses rapports. Il faudra penser à ajouter la chaîne
de connexion à PgBouncer, souvent
--pgbouncer-args='-p 6432'
, aux paramètres de
pgcluu_collectd
.
Créer un rôle PostgreSQL nommé pooler avec un mot de passe.
Pour mieux suivre les traces, activer
log_connections
etlog_disconnections
, et passerlog_min_duration_statement
à 0.
Installer PgBouncer. Configurer
/etc/pgbouncer/pgbouncer.ini
pour pouvoir se connecter à n’importe quelle base du serveur via PgBouncer (port 6432). Ajouter pooler dans/etc/pgbouncer/userlist.txt
. L’authentification doit êtremd5
. Ne pas oublierpg_hba.conf
. Suivre le contenu de/var/log/pgbouncer/pgbouncer.log
. Se connecter par l’intermédiaire du pooler sur une base locale.
Activer l’accès à la pseudo-base
pgbouncer
pour les utilisateurs postgres et pooler. Laisser la session ouverte pour suivre les connexions en cours.
Ouvrir deux connexions sur le pooler. Combien de connexions sont-elles ouvertes côté serveur ?
Passer PgBouncer en pooling par transaction. Bien vérifier qu’il n’y a plus de connexions ouvertes.
Rouvrir deux connexions via PgBouncer. Cette fois, combien de connexions sont ouvertes côté serveur ?
Successivement et à chaque fois dans une transaction, créer une table dans une des sessions ouvertes, puis dans l’autre insérer des données. Suivre le nombre de connexions ouvertes. Recommencer avec des transactions simultanées.
Passer le pooler en mode pooling par requête et tenter d’ouvrir une transaction.
Repasser PgBouncer en pooling par session.
Créer une base nommée
bench
appartenant à pooler. Avec pgbench, l’initialiser avec un scale factor de 100.
Lancer des tests (lectures uniquement, avec
--select
) de 60 secondes avec 80 connexions : une fois sur le pooler, et une fois directement sur le serveur. Comparer les performances.
Refaire ce test en demandant d’ouvrir et fermer les connexions (
-C
), sur le serveur puis sur le pooler. Effectuer unSHOW POOLS
pendant ce dernier test.
Créer un rôle PostgreSQL nommé pooler avec un mot de passe.
Les connexions se feront avec l’utilisateur pooler que nous allons créer avec le (trop évident) mot de passe « pooler » :
$ createuser --login --pwprompt --echo pooler
Saisir le mot de passe pour le nouveau rôle :
Le saisir de nouveau :
…
CREATE ROLE pooler PASSWORD 'md52a1394e4bcb2e9370746790c13ac33ac'
NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT LOGIN;
(NB : le hash sera beaucoup plus complexe si le chiffrement SCRAM-SHA-256 est activé, mais cela ne change rien au principe.)
Pour mieux suivre les traces, activer
log_connections
etlog_disconnections
, et passerlog_min_duration_statement
à 0.
PostgreSQL trace les rejets de connexion, mais, dans notre cas, il est intéressant de suivre aussi les connexions abouties.
Dans postgresql.conf
:
log_connections = on
log_disconnections = on
log_min_duration_statement = 0
Puis on recharge la configuration :
sudo systemctl reload postgresql-14
En cas de problème, le suivi des connexions dans
/var/lib/pgsql/14/data/log
peut être très pratique.
Installer PgBouncer. Configurer
/etc/pgbouncer/pgbouncer.ini
pour pouvoir se connecter à n’importe quelle base du serveur via PgBouncer (port 6432). Ajouter pooler dans/etc/pgbouncer/userlist.txt
. L’authentification doit êtremd5
. Ne pas oublierpg_hba.conf
. Suivre le contenu de/var/log/pgbouncer/pgbouncer.log
. Se connecter par l’intermédiaire du pooler sur une base locale.
L’installation est simple :
sudo dnf install pgbouncer
La configuration se fait dans
/etc/pgbouncer/pgbouncer.ini
.
Dans la section [databases]
on spécifie la chaîne de
connexion à l’instance, pour toute base :
* = host=127.0.0.1 port=5432
Il faut ajouter l’utilisateur au fichier
/etc/pgbouncer/userlist.txt
. La syntaxe est de la forme
"user" "hachage du mot de passe"
. La commande
createuser
l’a renvoyé ci-dessus, mais généralement il
faudra aller interroger la vue pg_shadow
ou la table
pg_authid
de l’instance PostgreSQL :
SELECT usename,passwd FROM pg_shadow WHERE usename = 'pooler';
usename | passwd
---------+-------------------------------------
pooler | md52a1394e4bcb2e9370746790c13ac33ac
Le fichier /etc/pgbouncer/userlist.txt
contiendra donc
:
"pooler" "md52a1394e4bcb2e9370746790c13ac33ac"
Il vaut mieux que seul l’utilisateur système dédié (pgbouncer sur Red Hat/CentOS/Rocky Linux) voit ce fichier :
sudo chown pgbouncer: userlist.txt
De plus il faut préciser dans pgbouncer.ini
que nous
fournissons des mots de passe hachés :
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
Si ce n’est pas déjà possible, il faut autoriser l’accès de
pooler en local à l’instance PostgreSQL. Du point de
vue de PostgreSQL, les connexions se feront depuis 127.0.0.1 (IP du
pooler). Ajouter cette ligne dans le fichier pg_hba.conf
et
recharger la configuration de l’instance :
host all pooler 127.0.0.1/32 md5
sudo systemctl reload postgresql-14
Enfin, on peut démarrer le pooler :
sudo systemctl restart pgbouncer
Dans une autre session, on peut suivre les tentatives de connexion :
sudo tail -f /var/log/pgbouncer/pgbouncer.log
La connexion directement au pooler doit fonctionner :
psql -h 127.0.0.1 -p 6432 -U pooler -d postgres
Mot de passe pour l'utilisateur pooler :
psql (14.1)
Saisissez « help » pour l'aide.
postgres=>
Dans pgbouncer.log
:
2020-12-02 08:42:35.917 UTC [2208] LOG C-0x152a490: postgres/pooler@127.0.0.1:55096
login attempt: db=postgres user=pooler tls=no
Noter qu’en cas d’erreur de mot de passe, l’échec apparaîtra dans ce
dernier fichier, et pas dans postgresql.log
.
Activer l’accès à la pseudo-base
pgbouncer
pour les utilisateurs postgres et pooler. Laisser la session ouverte pour suivre les connexions en cours.
; comma-separated list of users, who are allowed to change settings
admin_users = postgres,pooler
; comma-separated list of users who are just allowed to use SHOW command
stats_users = stats, postgres,pooler
sudo systemctl reload pgbouncer
$ psql -h 127.0.0.1 -p6432 -U pooler -d pgbouncer
Mot de passe pour l'utilisateur pooler :
psql (14.1, serveur 1.15.0/bouncer)
Saisissez « help » pour l'aide.
pgbouncer=# SHOW HELP ;
NOTICE: Console usage
DÉTAIL :
SHOW HELP|CONFIG|DATABASES|POOLS|CLIENTS|SERVERS|USERS|VERSION
…
Si une connexion via PgBouncer est ouverte par ailleurs, on la retrouve ici :
pgbouncer=# SHOW POOLS \gx
-[ RECORD 1 ]---------
database | pgbouncer
user | pgbouncer
cl_active | 1
cl_waiting | 0
sv_active | 0
sv_idle | 0
sv_used | 0
sv_tested | 0
sv_login | 0
maxwait | 0
maxwait_us | 0
pool_mode | statement
-[ RECORD 2 ]---------
database | postgres
user | pooler
cl_active | 1
cl_waiting | 0
sv_active | 1
sv_idle | 0
sv_used | 0
sv_tested | 0
sv_login | 0
maxwait | 0
maxwait_us | 0
pool_mode | session
Ouvrir deux connexions sur le pooler. Combien de connexions sont-elles ouvertes côté serveur ?
Le pooling par session est le mode par défaut de PgBouncer.
On se connecte dans 2 sessions différentes :
$ psql -h 127.0.0.1 -p6432 -U pooler -d postgres
psql (14.1)
postgres=>
$ psql -h 127.0.0.1 -p6432 -U pooler -d postgres
…
SELECT COUNT(*) FROM pg_stat_activity
WHERE backend_type='client backend' AND usename='pooler' ;
count
-------
2
Ici, PgBouncer a donc bien ouvert autant de connexions côté serveur que côté pooler.
Passer PgBouncer en pooling par transaction. Bien vérifier qu’il n’y a plus de connexions ouvertes.
Il faut changer le pool_mode
dans
pgbouncer.ini
, soit globalement :
; When server connection is released back to pool:
; session - after client disconnects
; transaction - after transaction finishes
; statement - after statement finishes
pool_mode = transaction
soit dans la définition des connexions :
* = host=127.0.0.1 port=5432 pool_mode=transaction
En toute rigueur, il n’y a besoin que de recharger la configuration de PgBouncer, mais il y a le problème des connexions ouvertes. Dans notre cas, nous pouvons forcer une déconnexion brutale :
sudo systemct restart pgbouncer
Rouvrir deux connexions via PgBouncer. Cette fois, combien de connexions sont ouvertes côté serveur ?
Après reconnexion de 2 sessions, la pseudo-base indique 2 connexions clientes, 1 serveur :
pgbouncer=# SHOW POOLS \gx
…
-[ RECORD 2 ]-----------
database | postgres
user | pooler
cl_active | 2
cl_waiting | 0
sv_active | 0
sv_idle | 0
sv_used | 1
sv_tested | 0
sv_login | 0
maxwait | 0
maxwait_us | 0
pool_mode | transaction
Ce que l’on retrouve en demandant directement au serveur :
=> SELECT COUNT(*) FROM pg_stat_activity
postgresWHERE backend_type='client backend' AND usename='pooler' ;
count
-------
1
Successivement et à chaque fois dans une transaction, créer une table dans une des sessions ouvertes, puis dans l’autre insérer des données. Suivre le nombre de connexions ouvertes. Recommencer avec des transactions simultanées.
Dans la première connexion ouvertes :
BEGIN ;
CREATE TABLE log (i timestamptz) ;
COMMIT ;
Dans la deuxième :
BEGIN ;
INSERT INTO log SELECT now() ;
END ;
On a bien toujours une seule connexion :
pgbouncer=# SHOW POOLS \gx
…
-[ RECORD 2 ]-----------
database | postgres
user | pooler
cl_active | 2
cl_waiting | 0
sv_active | 0
sv_idle | 0
sv_used | 1
sv_tested | 0
sv_login | 0
maxwait | 0
maxwait_us | 0
pool_mode | transaction
Du point de vue du serveur PostgreSQL, tout s’est passé dans la même session (même PID) :
… 10:01:45.448 UTC [2841] LOG: duration: 0.025 ms statement: BEGIN ;
… 10:01:45.450 UTC [2841] LOG: duration: 0.631 ms statement: CREATE TABLE log (i timestamptz) ;
… 10:01:45.454 UTC [2841] LOG: duration: 4.037 ms statement: COMMIT ;
… 10:01:49.128 UTC [2841] LOG: duration: 0.053 ms statement: BEGIN ;
… 10:01:49.129 UTC [2841] LOG: duration: 0.338 ms statement: INSERT INTO log SELECT now() ;
… 10:01:49.763 UTC [2841] LOG: duration: 4.393 ms statement: END ;
À présent, commençons la seconde transaction avant la fin de la première.
Session 1 :
BEGIN ; INSERT INTO log SELECT now() ;
Session 2 :
BEGIN ; INSERT INTO log SELECT now() ;
De manière transparente, une deuxième connexion au serveur a été créée :
pgbouncer=# show pools \gx
…
-[ RECORD 2 ]-----------
database | postgres
user | pooler
cl_active | 2
cl_waiting | 0
sv_active | 2
sv_idle | 0
sv_used | 0
sv_tested | 0
sv_login | 0
maxwait | 0
maxwait_us | 0
pool_mode | transaction
Ce que l’on voit dans les traces de PostgreSQL :
… 10:05:49.695 UTC [2841] LOG: duration: 0.144 ms statement: select 1
… 10:05:49.695 UTC [2841] LOG: duration: 0.014 ms statement: BEGIN ;
… 10:05:49.695 UTC [2841] LOG: duration: 0.110 ms statement: INSERT INTO log SELECT now() ;
… 10:05:52.320 UTC [2943] LOG: connection received: host=127.0.0.1 port=50554
… 10:05:52.321 UTC [2943] LOG: connection authorized: user=pooler database=postgres
… 10:05:52.323 UTC [2943] LOG: duration: 0.171 ms statement: SET application_name='psql';
… 10:05:52.323 UTC [2943] LOG: duration: 0.015 ms statement: BEGIN ;
… 10:05:52.324 UTC [2943] LOG: duration: 0.829 ms statement: INSERT INTO log SELECT now() ;
Du point de l’application, cela a été transparent.
Cette deuxième connexion va rester ouverte, mais elle n’est pas forcément associée à la deuxième session. Cela peut se voir simplement ainsi en demandant le PID du backend sur le serveur, qui sera le même dans les deux sessions :
=> SELECT pg_backend_pid() ; postgres
pg_backend_pid
----------------
2841
Passer le pooler en mode pooling par requête et tenter d’ouvrir une transaction.
De la même manière que ci-dessus, soit :
pool_mode = statement
soit :
* = host=127.0.0.1 port=5432 pool_mode=statement
Redémarrage du pooler :
# systemctl restart pgbouncer
Si on essaie de démarrer une transaction :
BEGIN;
ERROR: transaction blocks not allowed in statement pooling mode
la connexion au serveur a été coupée de façon inattendue
Le serveur s’est peut-être arrêté anormalement avant ou durant le
traitement de la requête.
La connexion au serveur a été perdue. Tentative de réinitialisation : Succès.
Le pooling par requête empêche l’utilisation de transactions.
Repasser PgBouncer en pooling par session.
Cela revient à revenir au mode par défaut
(pool_mode=session
).
Créer une base nommée
bench
appartenant à pooler. Avec pgbench, l’initialiser avec un scale factor de 100.
Le pooler n’est pas configuré pour que postgres puisse s’y connecter, il faut donc se connecter directement à l’instance pour créer la base :
postgres$ createdb -h /var/run/postgresql -p 5432 --owner pooler bench
La suite peut passer par le pooler :
$ /usr/pgsql-14/bin/pgbench -i -s 100 -U pooler -h 127.0.0.1 -p 6432 bench
Password:
dropping old tables...
NOTICE: table "pgbench_accounts" does not exist, skipping
NOTICE: table "pgbench_branches" does not exist, skipping
NOTICE: table "pgbench_history" does not exist, skipping
NOTICE: table "pgbench_tellers" does not exist, skipping
creating tables...
generating data (client-side)...
10000000 of 10000000 tuples (100%) done (elapsed 25.08 s, remaining 0.00 s)
vacuuming...
creating primary keys...
done in 196.24 s (drop tables 0.00 s, create tables 0.06 s, client-side generate 28.00 s,
vacuum 154.35 s, primary keys 13.83 s).
Lancer des tests (lectures uniquement, avec
--select
) de 60 secondes avec 80 connexions : une fois sur le pooler, et une fois directement sur le serveur. Comparer les performances.
NB : Pour des résultats rigoureux, pgbench doit être utilisé sur une plus longue durée.
Sur le pooler, on lance :
$ /usr/pgsql-14/bin/pgbench \
--select -T 60 -c 80 -p 6432 -U pooler -h 127.0.0.1 -d bench 2>/dev/null
starting vacuum...end.
transaction type: <builtin: select only>
scaling factor: 100
query mode: simple
number of clients: 80
number of threads: 1
duration: 60 s
number of transactions actually processed: 209465
latency average = 22.961 ms
tps = 3484.222638 (including connections establishing)
tps = 3484.278500 (excluding connections establishing)
(Ces chiffres ont été obtenus sur un portable avec SSD.)
On recommence directement sur l’instance. (Si l’ordre échoue par saturation des connexions, il faudra attendre que PgBouncer relâche les 20 connexions qu’il a gardées ouvertes.)
$ /usr/pgsql-14/bin/pgbench \
--select -T 60 -c 80 -p 5432 -U pooler -h 127.0.0.1 -d bench 2>/dev/null
starting vacuum...end.
transaction type: <builtin: select only>
scaling factor: 100
query mode: simple
number of clients: 80
number of threads: 1
duration: 60 s
number of transactions actually processed: 241482
latency average = 19.884 ms
tps = 4023.255058 (including connections establishing)
tps = 4023.573501 (excluding connections establishing)
Le test n’est pas assez rigoureux (surtout sur une petite machine de test) pour dire plus que : les résultats sont voisins.
Refaire ce test en demandant d’ouvrir et fermer les connexions (
-C
), sur le serveur puis sur le pooler. Effectuer unSHOW POOLS
pendant ce dernier test.
Sur le serveur :
$ /usr/pgsql-14/bin/pgbench \
-C --select -T 60 -c 80 -p 5432 -U pooler -h 127.0.0.1 -d bench 2>/dev/null
Password:
transaction type: <builtin: select only>
scaling factor: 100
query mode: simple
number of clients: 80
number of threads: 1
duration: 60 s
number of transactions actually processed: 9067
latency average = 529.654 ms
tps = 151.041956 (including connections establishing)
tps = 152.922609 (excluding connections establishing)
On constate une division par 26 du débit de transactions : le coût des connexions/déconnexions est énorme.
Si on passe par le pooler :
$ /usr/pgsql-14/bin/pgbench \
-C --select -T 60 -c 80 -p 6432 -U pooler -h 127.0.0.1 -d bench 2>/dev/null
Password:
transaction type: <builtin: select only>
scaling factor: 100
query mode: simple
number of clients: 80
number of threads: 1
duration: 60 s
number of transactions actually processed: 49926
latency average = 96.183 ms
tps = 831.745556 (including connections establishing)
tps = 841.461561 (excluding connections establishing)
On ne retrouve pas les performances originales, mais le gain est tout de même d’un facteur 5, puisque les connexions existantes sur le serveur PostgreSQL sont réutilisées et n’ont pas à être recréées.
Pendant ce dernier test, on peut consulter les connexions ouvertes : il n’y en que 20, pas 80. Noter le grand nombre de celles en attente.
pgbouncer=# SHOW POOLS \gx
-[ RECORD 1 ]---------
database | bench
user | pooler
cl_active | 20
cl_waiting | 54
sv_active | 20
sv_idle | 0
sv_used | 0
sv_tested | 0
sv_login | 0
maxwait | 0
maxwait_us | 73982
pool_mode | session
…
Ces tests n’ont pas pour objectif d’être représentatif mais juste de mettre en évidence le coût d’ouverture/fermeture de connexion. Dans ce cas, le pooler peut apporter un gain très significatif sur les performances.