UUID
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 :
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 ,
nom char (50 ) UNIQUE ,
filler char (50 ) default ''
) ;
CREATE TABLE donnees1 (id_donnee bigserial PRIMARY KEY ,
id_capteur int NOT NULL REFERENCES capteurs1,
horodatage timestamp with time zone ,
valeur1 int ,
valeur2 int ,
valeur3 float
) ;
CREATE INDEX ON donnees1 (horodatage) ;
-- Version avec les UUID
CREATE TABLE capteurs2 (id_capteur uuid PRIMARY KEY ,
nom char (50 ) UNIQUE ,
filler char (50 ) default ''
) ;
CREATE TABLE donnees2 (id_donnee uuid PRIMARY KEY ,
id_capteur uuid NOT NULL REFERENCES capteurs2,
horodatage timestamp with time zone ,
valeur1 int ,
valeur2 int ,
valeur3 float
) ;
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
m.id_capteur, -- clé étrangère
now(), (random ()* 1000 )::int ,(random ()* 1000 )::int ,random ()
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
m.id_capteur, -- clé étrangère
now(), (random ()* 1000 )::int ,(random ()* 1000 )::int ,random ()
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 (* ),
pg_size_pretty(count (bufferid)* 8192 ) as Taille_Mo
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,
pg_size_pretty(count (bufferid)* 8192 ) as Taille_Mo,
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 :
Dans une autre fenêtre, lancer pgbench
avec deux
clients, et le script pour remplir la table donnees1
:
# 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 remplir
donnees2
:
# 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/insere1.sql -f /tmp/insere2.sql
# Sous Debian/Ubuntu
pgbench capteurs -c2 -j1 -n -T1800 -P1 \
-f /tmp/insere1.sql -f /tmp/insere2.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 :
dans donnees1
, le débit en insertion devrait rester
correct, car seuls les derniers blocs en cache sont utiles ;
dans 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 de donnees2
, 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()) placing
substring(int8send((extract (epoch from clock_timestamp())* 1000 )::bigint )
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
m.id_capteur, -- clé étrangère
now(), (random ()* 1000 )::int ,(random ()* 1000 )::int ,random ()
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/insere1.sql -f /tmp/insere2.sql
# Sous Debian/Ubuntu
pgbench capteurs -c2 -j1 -n -T1800 -P1 \
-f /tmp/insere1.sql -f /tmp/insere2.sql
Après quelques dizaines de secondes :
les deux tables doivent être présentes dans le cache de manière
similaire ;
les index primaires doivent être présents de manière
anecdotique ;
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 charger donnees2
,
alternez entre les deux versions de la fonction
insere_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
).
jsonb : lecture de champs
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’attribut adresse
du champ JSON
personne
) de Gaston Lagaffe, grâce aux attributs
prenom
et nom
. 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 fonction
json_to_record()
et LATERAL
. Rajouter
ville
et pays
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,
LATERAL jsonb_to_record (personne) AS r (nom text, prenom text, date_naissance date )
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 ),
LATERAL jsonb_to_record (personne-> 'adresse' )
AS 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' ,
personne->> 'prenom' ,
(personne->> 'date_naissance' )::date ,
personne#>> '{adresse,ville}' ,
personne#>> '{adresse,pays}'
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' ,
personne->> 'prenom' ,
(personne->> 'date' )::date ,
personne#>> '{adresse,ville}' ,
personne#>> '{adresse,pays}'
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 ),
LATERAL jsonb_to_record (personne-> 'adresse' )
AS 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.
jsonb : index GIN
jsonb_path_ops
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 ),
LATERAL jsonb_to_record (personne-> 'adresse' )
AS 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.
jsonb et tableaux
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' ],
personne['adresse' ]-> 'telephones' ,
personne['adresse' ]['telephones' ]#> '{}' ,
personne['adresse' ]['telephones' ]-> 0 ,
personne['adresse' ]->> 'telephones' ,
personne['adresse' ]['telephones' ]#>> '{}' ,
personne['adresse' ]['telephones' ]->> 0
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,
jsonb_array_elements_text (personne-> 'adresse' -> 'telephones' ) AS tel
FROM 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
jsonb_array_elements_text (personne-> 'adresse' -> 'telephones' ) tels(t)
) AS 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,
array_agg (t) AS tels
FROM json.personnes p
LEFT OUTER JOIN LATERAL jsonb_array_elements_text (
personne-> 'adresse' -> 'telephones' ) AS tel(t) ON (true )
WHERE personne @> '{"nom": "Prunelle", "prenom": "Léon"}' ::jsonb
GROUP BY 1 ,2 ;
(Noter que la fonction sœur jsonb_array_elements()
renverrait, elle, des JSON.)
Accès JSONPath
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 un OR
;
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)
Index fonctionnel,
colonne générée et JSON
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' ));
VACUUM ANALYZE json.personnes ;
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’attribut pays
.
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 ;
VACUUM ANALYZE json.personnes ;
Comparer les temps d’exécution du décompte des pays par l’attribut,
et par cette colonne générée.
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 ;
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 dans pg_stats
. Cet index est-il
utilisable pour des filtres et le décompte par pays
?
CREATE INDEX personnes_g_pays_btree ON json.personnes (pays);
VACUUM ANALYZE json.personnes ;
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
, et ville
(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 (
make_date (left (personne->> 'date_naissance' ,4 )::int ,
substring(personne->> 'date_naissance' ,6 ,2 )::int ,
left (personne->> 'date_naissance' ,2 )::int ))
STORED,
ADD COLUMN ville text GENERATED ALWAYS AS ( personne-> 'adresse' ->> 'ville' ) STORED ;
VACUUM ANALYZE json.personnes ;
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)
RETURNS date
-- Cette fonction requiert que les dates soient bien
-- stockées au format ci-dessous
-- et ne fait aucune gestion d'erreur sinon
LANGUAGE sql
IMMUTABLE PARALLEL SAFE
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 ),
LATERAL jsonb_to_record (personne-> 'adresse' )
AS 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.
jsonb et mise à jour
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 ;
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 ||
jsonb_build_object ('animaux' , 1 + mod ((personne->> 'numgen' )::int , 50 ))
WHERE mod ((personne->> 'numgen' )::int ,50 ) = 0 ;
-- Conseillé après chaque mise à jour importante
VACUUM ANALYZE json.personnes ;
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…
Large Objects
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'"
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' ,
lo_import('/var/lib/pgsql/15/data/postgresql.conf' ) ;
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';"