Indexation
de pattern avec les varchar_patterns et pg_trgm
Créer un index simple sur la colonne contenu de la
table.
CREATE INDEX ON textes(contenu);
Il y aura une erreur si la base gutenberg est dans
sa version complète, un livre de Marcel Proust dépasse la taille
indexable maximale :
ERROR: index row size 2968 exceeds maximum 2712 for index "textes_contenu_idx"
ASTUCE : Values larger than 1/3 of a buffer page cannot be indexed.
Consider a function index of an MD5 hash of the value, or use full text indexing.
Pour l’exercice, on supprime ce livre avant d’indexer la colonne :
DELETE FROM textes where livre = 'Les Demi-Vierges, Prévost, Marcel ' ;
CREATE INDEX ON textes(contenu);
Rechercher un enregistrement commençant par « comme disent » :
l’index est-il utilisé ?
Le plan exact peut dépendre de la version de PostgreSQL, du
paramétrage exact, d’éventuelles modifications à la table. Dans beaucoup
de cas, on obtiendra :
SET jit TO off ;
SET max_parallel_workers_per_gather TO 0 ;
VACUUM ANALYZE textes;
EXPLAIN ANALYZE SELECT * FROM textes WHERE contenu LIKE 'comme disent% ' ;
QUERY PLAN
------------------------------------------------------------------
Seq Scan on textes (cost=0.00..669657.38 rows=1668 width=124)
(actual time=305.848..6275.845 rows=47 loops=1)
Filter: (contenu ~~ 'comme disent%'::text)
Rows Removed by Filter: 20945503
Planning Time: 1.033 ms
Execution Time: 6275.957 ms
C’est un Seq Scan : l’index n’est pas utilisé !
Dans d’autres cas, on aura ceci (avec PostgreSQL 12 et la version
complète de la base ici) :
EXPLAIN ANALYZE SELECT * FROM textes WHERE contenu LIKE 'comme disent% ' ;
QUERY PLAN
------------------------------------------------------------------
Index Scan using textes_contenu_idx on textes (…)
Index Cond: (contenu ~~ 'comme disent%'::text)
Rows Removed by Index Recheck: 110
Buffers: shared hit=28 read=49279
I/O Timings: read=311238.192
Planning Time: 0.352 ms
Execution Time: 313481.602 ms
C’est un Index Scan mais il ne faut pas crier victoire :
l’index est parcouru entièrement (50 000 blocs !). Il ne sert qu’à lire
toutes les valeurs de contenu en lisant moins de blocs que
par un Seq Scan de la table. Le choix de PostgreSQL entre
lire cet index et lire la table dépend notamment du paramétrage et des
tailles respectives.
Le problème est que l’index sur contenu utilise la
collation C et non la collation par défaut de la base,
généralement en_US.UTF-8 ou fr_FR.UTF-8. Pour
contourner cette limitation, PostgreSQL fournit deux classes
d’opérateurs : varchar_pattern_ops pour
varchar et text_pattern_ops pour
text.
Créer un index utilisant la classe text_pattern_ops.
Refaire le test.
DROP INDEX textes_contenu_idx;
CREATE INDEX ON textes(contenu text_pattern_ops);
EXPLAIN (ANALYZE ,BUFFERS)
SELECT * FROM textes WHERE contenu LIKE 'comme disent% ' ;
QUERY PLAN
------------------------------------------------------------------
Index Scan using textes_contenu_idx1 on textes
(cost=0.56..8.58 rows=185 width=130)
(actual time=0.530..0.542 rows=4 loops=1)
Index Cond: ((contenu ~>=~ 'comme disent'::text)
AND (contenu ~<~ 'comme disenu'::text))
Filter: (contenu ~~ 'comme disent%'::text)
Buffers: shared hit=4 read=4
Planning Time: 1.112 ms
Execution Time: 0.618 ms
On constate que comme l’ordre choisi est l’ordre ASCII, l’optimiseur
sait qu’après « comme disent », c’est « comme disenu » qui apparaît dans
l’index.
Noter que Index Cond contient le filtre utilisé pour
l’index (réexprimé sous forme d’inégalités en collation C)
et Filter un filtrage des résultats de l’index.
On veut chercher les lignes finissant par « Et vivre ». Indexer
reverse(contenu) et trouver les lignes.
Cette recherche n’est possible avec un index B-Tree qu’en utilisant
un index sur fonction :
CREATE INDEX ON textes(reverse(contenu) text_pattern_ops);
Il faut ensuite utiliser ce reverse systématiquement
dans les requêtes :
EXPLAIN (ANALYZE )
SELECT * FROM textes WHERE reverse(contenu) LIKE reverse('%Et vivre ' ) ;
QUERY PLAN
--------------------------------------------------------------------------
Index Scan using textes_reverse_idx on textes
(cost=0.56..377770.76 rows=104728 width=123)
(actual time=0.083..0.098 rows=2 loops=1)
Index Cond: ((reverse(contenu) ~>=~ 'erviv tE'::text)
AND (reverse(contenu) ~<~ 'erviv tF'::text))
Filter: (reverse(contenu) ~~ 'erviv tE%'::text)
Planning Time: 1.903 ms
Execution Time: 0.421 ms
On constate que le résultat de reverse(contenu) a été
directement utilisé par l’optimiseur. La requête est donc très rapide.
On peut utiliser une méthode similaire pour la recherche insensible à la
casse, en utiliser lower() ou upper().
Toutefois, ces méthodes ne permettent de filtrer qu’au début ou à la
fin de la chaîne, ne permettent qu’une recherche sensible ou insensible
à la casse, mais pas les deux simultanément, et imposent aux
développeurs de préciser reverse, lower, etc.
partout.
Installer l’extension pg_trgm, puis créer un index GIN
spécialisé de recherche dans les chaînes. Rechercher toutes les lignes
de texte contenant « Valjean » de façon sensible à la casse, puis
insensible.
Pour installer l’extension pg_trgm :
CREATE EXTENSION pg_trgm;
Pour créer un index GIN sur la colonne contenu :
CREATE INDEX idx_textes_trgm ON textes USING gin (contenu gin_trgm_ops);
Recherche des lignes contenant « Valjean » de façon sensible à la
casse :
EXPLAIN (ANALYZE )
SELECT * FROM textes WHERE contenu LIKE '%Valjean% ' ;
QUERY PLAN
---------------------------------------------------------------------------
Bitmap Heap Scan on textes (cost=77.01..6479.68 rows=1679 width=123)
(actual time=11.004..14.769 rows=1213 loops=1)
Recheck Cond: (contenu ~~ '%Valjean%'::text)
Rows Removed by Index Recheck: 1
Heap Blocks: exact=353
-> Bitmap Index Scan on idx_textes_trgm
(cost=0.00..76.59 rows=1679 width=0)
(actual time=10.797..10.797 rows=1214 loops=1)
Index Cond: (contenu ~~ '%Valjean%'::text)
Planning Time: 0.815 ms
Execution Time: 15.122 ms
Puis insensible à la casse :
EXPLAIN ANALYZE SELECT * FROM textes WHERE contenu ILIKE '%Valjean% ' ;
QUERY PLAN
---------------------------------------------------------------------------
Bitmap Heap Scan on textes (cost=77.01..6479.68 rows=1679 width=123)
(actual time=13.135..23.145 rows=1214 loops=1)
Recheck Cond: (contenu ~~* '%Valjean%'::text)
Heap Blocks: exact=353
-> Bitmap Index Scan on idx_textes_trgm
(cost=0.00..76.59 rows=1679 width=0)
(actual time=12.779..12.779 rows=1214 loops=1)
Index Cond: (contenu ~~* '%Valjean%'::text)
Planning Time: 2.047 ms
Execution Time: 23.444 ms
On constate que l’index a été nettement plus long à créer, et que la
recherche est plus lente. La contrepartie est évidemment que les
trigrammes sont infiniment plus souples. On constate aussi que le
LIKE a dû encore filtrer 1 enregistrement après le parcours
de l’index : en effet l’index trigramme est insensible à la casse, il
ramène donc trop d’enregistrements, et une ligne avec « VALJEAN » a dû
être filtrée.
Rechercher toutes les lignes contenant « Fantine » OU « Valjean » :
on peut utiliser une expression rationnelle.
EXPLAIN ANALYZE SELECT * FROM textes WHERE contenu ~ 'Valjean|Fantine ' ;
QUERY PLAN
-----------------------------------------------------------------------------
Bitmap Heap Scan on textes (cost=141.01..6543.68 rows=1679 width=123)
(actual time=159.896..174.173 rows=1439 loops=1)
Recheck Cond: (contenu ~ 'Valjean|Fantine'::text)
Rows Removed by Index Recheck: 1569
Heap Blocks: exact=1955
-> Bitmap Index Scan on idx_textes_trgm
(cost=0.00..140.59 rows=1679 width=0)
(actual time=159.135..159.135 rows=3008 loops=1)
Index Cond: (contenu ~ 'Valjean|Fantine'::text)
Planning Time: 2.467 ms
Execution Time: 174.284 ms
Rechercher toutes les lignes mentionnant à la fois « Fantine » ET
« Valjean ». Une formulation d’expression rationnelle simple est
« Fantine puis Valjean » ou « Valjean puis Fantine ».
EXPLAIN ANALYZE SELECT * FROM textes
WHERE contenu ~ '(Valjean.*Fantine)|(Fantine.*Valjean) ' ;
QUERY PLAN
------------------------------------------------------------------------------
Bitmap Heap Scan on textes (cost=141.01..6543.68 rows=1679 width=123)
(actual time=26.825..26.897 rows=8 loops=1)
Recheck Cond: (contenu ~ '(Valjean.*Fantine)|(Fantine.*Valjean)'::text)
Heap Blocks: exact=6
-> Bitmap Index Scan on idx_textes_trgm
(cost=0.00..140.59 rows=1679 width=0)
(actual time=26.791..26.791 rows=8 loops=1)
Index Cond: (contenu ~ '(Valjean.*Fantine)|(Fantine.*Valjean)'::text)
Planning Time: 5.697 ms
Execution Time: 26.992 ms
auto_explain
Installer le module auto_explain (documentation : https://docs.postgresql.fr/current/auto-explain.html ).
Dans le fichier postgresql.conf, chargement du module et
activation globale pour toutes les requêtes (ce qu’on évitera
de faire en production) :
shared_preload_libraries = 'auto_explain '
auto_explain.log_min_duration = 0
Redémarrer PostgreSQL.
Exécuter des requêtes sur n’importe quelle base de données, et
inspecter les traces générées.
Le plan de la moindre requête (même un \d+) doit
apparaître dans la trace.
Passer le niveau de messages de sa session
(client_min_messages) à log.
Il est possible de recevoir les messages directement dans sa session.
Tous les messages de log sont marqués d’un niveau de priorité. Les
messages produits par auto_explain sont au niveau
log. Il suffit donc de passer le paramètre
client_min_messages au niveau log.
Positionner le paramètre de session comme ci-dessous, ré-exécuter la
requête.
SET client_min_messages TO log;
SELECT…
pg_stat_statements
pg_stat_statements nécessite une bibliothèque
préchargée. La positionner dans le fichier postgresql.conf,
redémarrer PostgreSQL et créer l’extension.
Le paramètre à modifier est
shared_preload_libraries :
shared_preload_libraries = 'auto_explain,pg_stat_statements '
Si une autre extension (ici auto_explain) est également
présente, on peut les déclarer ensemble ainsi :
shared_preload_libraries = 'auto_explain,pg_stat_statements '
Redémarrer PostgreSQL.
Dans la base postgres (par exemple), créer
l’extension :
CREATE EXTENSION IF NOT EXISTS pg_stat_statements ;
Inspecter le contenu de l’extension
pg_stat_statements (\dx et
\dx+).
L’extension installe divers objets, mais seule la vue
pg_stat_statements nous intéressera ici.
Objets dans l'extension « pg_stat_statements »
Description d'objet
--------------------------------------
function pg_stat_statements(boolean)
function pg_stat_statements_info()
function pg_stat_statements_reset(oid,oid,bigint,boolean)
type pg_stat_statements
type pg_stat_statements[]
type pg_stat_statements_info
type pg_stat_statements_info[]
view pg_stat_statements
view pg_stat_statements_info
Vérifier que le serveur est capable d’activer la
mesure de la durée des entrées-sorties avec pg_test_timing.
Puis l’activer (track_io_timing), sans oublier de
redémarrer PostgreSQL.
pg_test_timing
est livré avec PostgreSQL.
# Chemin du packaging RPM du PGDG
/usr/pgsql-17/bin/pg_test_timing
(Sur Debian & dérivé, le chemin est
/usr/lib/postgresql/17/bin/pg_test_timing).
Testing timing overhead for 3 seconds.
Per loop time including overhead: 33.24 ns
Histogram of timing durations:
< us % of total count
1 97.25509 87770521
2 2.72390 2458258
4 0.00072 646
8 0.00244 2200
16 0.00984 8882
32 0.00328 2958
64 0.00298 2689
128 0.00099 892
256 0.00055 499
512 0.00016 141
1024 0.00006 53
2048 0.00000 1
pg_test_timing
est livré avec PostgreSQL et mesure les performances de l’horloge
système. Si le temps de mesure renvoyé sur la deuxième ligne n’est que
de quelques dizaines de nanosecondes, la machine est suffisamment rapide
pour que track_io_timing renvoie des résultats précis et
sans ralentir la requête. C’est le cas sur presque toutes les machines
et systèmes d’exploitation actuels, mais il y a parfois des surprises,
par exemple dans certaines machines virtuelles ou selon la source de
l’horloge système. Sinon, éviter d’activer track_io_timing
sur un serveur de production. Sur une machine de test ou de formation,
ce n’est pas un problème.
Dans le fichier postgresql.conf, positionner :
Changer ce paramètre nécessite aussi de redémarrer PostgreSQL.
Depuis un autre terminal, créer une base pgbench
(si pas déjà disponible), l’initialiser (même si elle existait), et
lancer une activité dessus en arrière-plan :
# en tant qu'utilisateur postgres
createdb -e pgbench
/usr/pgsql-17/bin/pgbench -i -s135 pgbench
/usr/pgsql-17/bin/pgbench -c5 -j1 pgbench -T 600 -P1
SELECT pg_catalog.set_config('search_path', '', false);
CREATE DATABASE pgbench;
/usr/pgsql-17/bin/pgbench -i -s135 pgbench
…
creating tables...
generating data (client-side)...
13500000 of 13500000 tuples (100%) done (elapsed 10.99 s, remaining 0.00 s)
vacuuming...
creating primary keys...
done in 14.97 s (drop tables 0.00 s, create tables 0.02 s, client-side generate 11.04 s, vacuum 0.34 s, primary keys 3.57 s).
/usr/pgsql-17/bin/pgbench -c5 -j1 pgbench -T 600 -P1
pgbench (17.4)
starting vacuum...end.
progress: 1.0 s, 2364.9 tps, lat 2.078 ms stddev 1.203, 0 failed
progress: 2.0 s, 2240.0 tps, lat 2.221 ms stddev 0.871, 0 failed
…
On a donc 5 clients qui vont mettre à jour la base à raison de 2000
transactions par seconde (valeur très dépendante des CPUs et des
disques).
Dans la vue pg_stat_statements,
récupérer les 5 requêtes les plus gourmandes en temps cumulé sur
l’instance et leur nombre de lignes.
SELECT calls, query, rows ,
total_exec_time* interval '1ms ' AS tps_total
FROM pg_stat_statements
ORDER BY total_exec_time DESC LIMIT 5
\gx
Le résultat va dépendre de l’historique de votre instance, et du
temps déroulé depuis le lancement de pgbench, mais c’est
probablement proche de ceci :
-[ RECORD 1 ]------------------------------------------------------------------
calls | 118591
query | UPDATE pgbench_accounts SET abalance = abalance + $1 WHERE aid = $2
rows | 118591
tps_total | 00:00:22.81672
-[ RECORD 2 ]------------------------------------------------------------------
calls | 1
query | copy pgbench_accounts from stdin with (freeze on)
rows | 13500000
tps_total | 00:00:08.079233
-[ RECORD 3 ]------------------------------------------------------------------
calls | 118591
query | UPDATE pgbench_branches SET bbalance = bbalance + $1 WHERE bid = $2
rows | 118591
tps_total | 00:00:04.944426
-[ RECORD 4 ]------------------------------------------------------------------
calls | 1
query | alter table pgbench_accounts add primary key (aid)
rows | 0
tps_total | 00:00:03.057966
-[ RECORD 5 ]------------------------------------------------------------------
calls | 118591
query | UPDATE pgbench_tellers SET tbalance = tbalance + $1 WHERE tid = $2
rows | 118591
tps_total | 00:00:01.595914
Noter que l’unique ordre COPY pour créer la base dure
plus que la centaine de milliers d’occurences de la cinquième
requête.
Quelle est la requête générant le plus d’écritures directes sur
disques (written ) ? Et en temps d’écriture ?
Pour les written , il faut tenir compte des trois sources :
blocs du cache partagé, blocs des backends , fichiers
temporaires.
SELECT calls,
pg_size_pretty (8192 :: numeric
* (shared_blks_written+ local_blks_written+ temp_blks_written)) AS written,
pg_size_pretty (8192 :: numeric * shared_blks_written) AS shared_written,
pg_size_pretty (8192 :: numeric * temp_blks_written) AS temp_written,
shared_blk_write_time * interval '1ms ' AS shared_blk_write_time,
local_blk_write_time * interval '1ms ' AS local_blk_write_time,
temp_blk_write_time * interval '1ms ' AS temp_blk_write_time,
query
FROM pg_stat_statements
ORDER BY shared_blks_written+ local_blks_written+ temp_blks_written DESC LIMIT 3
\gx
(Cette requête est valable pour PostgreSQL 17 ; auparavant les champs
shared_blk_write_time et local_blk_write_time
étaient regroupés dans un champ blk_write_time.)
-[ RECORD 1 ]---------+------------------------------------------------------------------
calls | 1
written | 3442 MB
shared_written | 3442 MB
temp_written | 0 bytes
shared_blk_write_time | 00:00:00.677007
local_blk_write_time | 00:00:00
temp_blk_write_time | 00:00:00
query | copy pgbench_accounts from stdin with (freeze on)
-[ RECORD 2 ]---------+------------------------------------------------------------------
calls | 374073
written | 3171 MB
shared_written | 3171 MB
temp_written | 0 bytes
shared_blk_write_time | 00:00:07.658022
local_blk_write_time | 00:00:00
temp_blk_write_time | 00:00:00
query | UPDATE pgbench_accounts SET abalance = abalance + $1 WHERE aid = $2
-[ RECORD 3 ]---------+------------------------------------------------------------------
calls | 1
written | 516 MB
shared_written | 0 bytes
temp_written | 516 MB
shared_blk_write_time | 00:00:00
local_blk_write_time | 00:00:00
temp_blk_write_time | 00:00:00.174825
query | alter table pgbench_accounts add primary key (aid)
Il y a donc beaucoup d’écritures directes (champs
*_written). C’est le signe que le cache en écriture de
PostgreSQL est insuffisant (la base fait 2 Go, à peu près intégralement
balayée, et le shared_buffers par défaut ne fait que 128
Mo), ou que le background writer doit être modifié pour
nettoyer plus souvent les blocs dirty .
On note que l’UPDATE et le COPY ont écrit
des blocs qui auraient dû passer uniquement par le cache, alors le
ALTER TABLE, lui, a essentiellement écrit un fichier
temporaire (champs temp_*), ce qui est logique lors d’une
création d’index.
Avec des shared buffers plus importants, les
shared_written sont quasiment absents. Ils proviennent
essentiellement d’ordres lourds comme COPY.
Quel est le hit ratio des requêtes les plus fréquentes ?
SELECT calls, total_exec_time,
round (100.0 * shared_blks_hit
/ nullif (shared_blks_hit+ shared_blks_read, 0 ),2 ) AS "hit %" ,
query
FROM pg_stat_statements
ORDER BY total_exec_time DESC LIMIT 5 ;
-[ RECORD 1 ]---+-------------------------------------------------------------------
calls | 498865
total_exec_time | 96688.18017399928
hit % | 79.21
query | UPDATE pgbench_accounts SET abalance = abalance + $1 WHERE aid = $2
-[ RECORD 2 ]---+-------------------------------------------------------------------
calls | 498865
total_exec_time | 24437.844524000397
hit % | 100.00
query | UPDATE pgbench_branches SET bbalance = bbalance + $1 WHERE bid = $2
-[ RECORD 3 ]---+-------------------------------------------------------------------
calls | 1
total_exec_time | 8079.232832
hit % | 100.00
query | copy pgbench_accounts from stdin with (freeze on)
-[ RECORD 4 ]---+-------------------------------------------------------------------
calls | 498865
total_exec_time | 7014.86179799995
hit % | 100.00
query | UPDATE pgbench_tellers SET tbalance = tbalance + $1 WHERE tid = $2
-[ RECORD 5 ]---+-------------------------------------------------------------------
calls | 498865
total_exec_time | 3853.511855000023
hit % | 100.00
query | SELECT abalance FROM pgbench_accounts WHERE aid = $1
On constate que le hit ratio est parfait, sauf la première
requête. C’est logique, car la table pgbench_accounts ne
tient pas dans le cache par défaut et elle est balayée à peu près
entièrement par les requêtes de pgbench.
PL/Python, import
de page web et compression
Sur la base du code suivant en python 3 utilisant un des modules
standard (documentation : https://docs.python.org/3/library/urllib.request.html ),
créer une fonction PL/Python récupérant le code HTML d’une page web avec
un simple
SELECT pageweb('https://www.postgresql.org/') :
import urllib.request
f = urllib.request.urlopen('https://www.postgresql.org/' )
print (f.read().decode('utf-8' ))
Il faut bien évidemment que PL/Python soit installé. D’abord le
paquet, ici sous Rocky Linux 8 avec PostgreSQL 17 (le nom du paquet doit
être adapté à la version de PostgreSQL) :
# dnf install postgresql17-plpython3
Sur Debian, Ubuntu et dérivés ce sera :
# apt install postgresql-plpython3-17
Puis, dans la base de données concernée :
CREATE EXTENSION plpython3u ;
La fonction PL/Python est :
CREATE OR REPLACE FUNCTION pageweb (url text )
RETURNS text
AS $$
import urllib.request
f = urllib.request.urlopen(url)
return f.read ().decode ('utf-8 ' )
$$ LANGUAGE plpython3u COST 10000 ;
Évidemment, il ne s’agit que d’un squelette ne gérant pas les
erreurs, les redirections, etc.
Stocker le résultat dans une table.
On vérifie ainsi le bon fonctionnement :
CREATE TABLE pagesweb (url text , page text , pagebz2 bytea , page2 text ) ;
INSERT INTO pagesweb (url, page)
SELECT 'https://www.postgresql.org/ ' , pageweb('https://www.postgresql.org/ ' ) ;
Puis stocker cette page en compression maximale dans un champ
bytea, en passant par une fonction python inspirée du code
suivant (documentation : https://docs.python.org/3/library/bz2.html ) :
import bz2
compressed_data = bz2.compress(data, compresslevel= 9 )
import bz2
c= bz2.compress(data, compresslevel= 9 )
Même si la page récupérée est en texte, la fonction python exige du
binaire, donc le champ en entrée sera du bytea :
-- version pour bytea
CREATE OR REPLACE FUNCTION bz2 (objet bytea )
RETURNS bytea
AS $$
import bz2
return bz2.compress(objet, compresslevel= 9 )
$$ LANGUAGE plpython3u IMMUTABLE COST 1000000 ;
On peut faire la conversion depuis text à l’appel ou
modifier la fonction pour qu’elle convertisse d’elle-même. Mais le plus
confortable est de créer une fonction SQL de même nom qui se chargera de
la conversion. Selon le type en paramètre, l’une ou l’autre fonction
sera appelée.
-- fonction d'enrobage pour s'épargner une conversion explicite en bytea
CREATE OR REPLACE FUNCTION bz2 (objet text )
RETURNS bytea
AS $$
SELECT bz2(objet:: bytea ) ;
$$ LANGUAGE sql IMMUTABLE ;
Compression de la page :
UPDATE pagesweb
SET pagebz2 = bz2 (page) ;
NB : PostgreSQL stocke déjà les textes longs sous forme compressée
(mécanisme du TOAST).
Tout ceci n’a donc d’intérêt que pour gagner quelques octets
supplémentaires, ou si le .bz2 doit être réutilisé
directement. Noter que l’on utilise ici uniquement des fonctionnalités
standards de PostgreSQL et python3, sans module extérieur à la fiabilité
inconnue.
De plus, les données ne quittent pas le serveur, épargnant du trafic
réseau.
Écrire la fonction de décompression avec la fonction python
bz2.decompress.
CREATE OR REPLACE FUNCTION bz2d (objet bytea )
RETURNS bytea
AS $$
import bz2
return bz2.decompress(objet)
$$ LANGUAGE plpython3u IMMUTABLE COST 1000000 ;
Utiliser ensuite convert_from( bytea, 'UTF8') pour
récupérer un text.
CREATE OR REPLACE FUNCTION bz2_to_text (objetbz2 bytea )
RETURNS text
AS $$
SELECT convert_from( bz2d(objetbz2), 'UTF8 ' )
$$ LANGUAGE sql IMMUTABLE ;
Vérification que l’on obtient au final le même texte qu’avant
compression :
UPDATE pagesweb
SET page2 = bz2_to_text( pagebz2 )
;
-- Vérification que la page décompressée est identique à l'originale
SELECT count (* ) AS pages,
count (* ) FILTER (WHERE page = page2) AS pages_identique
FROM pagesweb ;
pages | pages_identique
-------+-----------------
1 | 1
Créer la fonction multi_replace en PL/pgSQL à partir du
wiki PostgreSQL : https://wiki.postgresql.org/wiki/Multi_Replace_plpgsql
Le code sur le wiki est le suivant :
/* This function quotes characters that may be interpreted as special
in a regular expression.
It's used by the function below and declared separately for clarity. */
CREATE FUNCTION quote_meta(text ) RETURNS text AS $$
SELECT regexp_replace ($1, '([\[\]\\\^\$ \.\|\?\*\+\(\)])', '\\\1', 'g');
$$ LANGUAGE SQL strict immutable;
/* Substitute a set of substrings within a larger string.
When several strings match, the longest wins.
Similar to php's strtr(string $str, array $replace_pairs).
Example:
select multi_replace('foo and bar is not foobar',
'{"bar":"foo", "foo":"bar", "foobar":"foobar"}'::jsonb);
=> 'bar and foo is not foobar'
*/
CREATE FUNCTION multi_replace(str text, substitutions jsonb)
RETURNS text
AS $$
DECLARE
rx text;
s_left text;
s_tail text;
res text:='';
BEGIN
SELECT string_agg(quote_meta(term), '|' )
FROM jsonb_object_keys(substitutions) AS x(term)
WHERE term <> ''
INTO rx;
IF (COALESCE(rx, '') = '') THEN
-- the loop on the RE can't work with an empty alternation
RETURN str;
END IF;
rx := concat('^(.*?)(', rx, ')(.*)$'); -- match no more than 1 row
loop
s_tail := str;
SELECT
concat(matches[1], substitutions->>matches[2]),
matches[3]
FROM
regexp_matches(str, rx, 'g') AS matches
INTO s_left, str;
exit WHEN s_left IS NULL;
res := res || s_left;
END loop;
res := res || s_tail;
RETURN res;
END
$$ LANGUAGE plpgsql strict immutable;
Récupérer la fonction en PL/perl sur le même wiki : https://wiki.postgresql.org/wiki/Multi_Replace_Perl .
Évidemment, il faudra l’extension dédiée au langage Perl :
# dnf install postgresql14-plperl
Le code de la fonction est :
CREATE FUNCTION multi_replace(string text , orig text [], repl text [])
RETURNS text
AS $BODY$
my ($string, $ orig, $repl) = @_;
my %subs;
if (@$orig != @$repl) {
elog(ERROR, "array sizes mismatch");
}
if (ref @$orig[0] eq 'ARRAY' || ref @$repl[0] eq 'ARRAY') {
elog(ERROR, "array dimensions mismatch");
}
@subs{@$orig} = @$repl;
my $re = join "|", map quotemeta,
sort { (length($b) <=> length($a)) } keys %subs;
$re = qr/($re)/;
$string =~ s/$re/$subs{$1}/g;
return $string;
$BODY$ language plperl strict immutable;
Vérifier que les deux fonctions ont le même nom mais des types de
paramètres différents.
\df multi_replace
Liste des fonctions
Schéma | Nom | …résultat | Type … paramètres | Type
-------+---------------+-----------+--------------------------+------
public | multi_replace | text | string text, | func
| | | orig text[], repl text[] |
public | multi_replace | text | str text, | func
| | | substitutions jsonb |
PostgreSQL sait quelle fonction appeler selon les paramètres
fournis.
Le test va consister à transposer tous les noms et lieux des
Misérables de Victor Hugo dans une version américaine :
Le test va consister à transposer tous les noms et lieux des
Misérables de Victor Hugo dans une version américaine :
charger la base du projet Gutenberg si elle n’est pas déjà en
place.
créer une table miserables reprenant tous les livres
dont le titre commence par « Les misérables ».
CREATE TABLE miserables as select * from textes
WHERE livre LIKE 'Les misérables% ' ;
Cette table fait 68 000 lignes.
Tester le bon fonctionnement avec ces requêtes :
SELECT multi_replace (contenu,'{"Valjean":"Valjohn", "Cosette":"Lucy"}' ::jsonb )
FROM miserables
WHERE contenu ~ '(Valjean|Cosette)' LIMIT 5 ;
SELECT multi_replace(contenu, '{Valjean,Cosette}' , '{Valjohn, Lucy}' )
FROM miserables
WHERE contenu ~ '(Valjean|Cosette)' LIMIT 5 ;
Le texte affiché doit comporter « Jean Valjohn » et « Lucy ».
Pour faciliter la modification, prévoir une table pour stocker les
critères :
CREATE TABLE remplacement (j jsonb, old_t text[], new_t text[]) ;
Insérer par exemple les données suivantes :
INSERT INTO remplacement (j)
SELECT '{"Valjean":"Valjohn", "Jean Valjean":"John Valjohn",
"Cosette":"Lucy", "Fantine":"Fanny", "Javert":"Green",
"Thénardier":"Thenardy", "Éponine":"Sharon", "Azelma":"Azealia",
"Marius":"Marc", "Gavroche":"Garry", "Enjolras":"Joker",
"Notre-Dame":"Empire State Building", "Victor Hugo":"Victor Hugues",
"Hugo":"Hugues", "Fauchelevent":"Dropwind", "Bouchart":"Butcher",
"Célestine":"Celeste","Mabeuf":"Myoax", "Leblanc":"White",
"Combeferre":"Combiron", "Magloire":"Glory",
"Gillenormand":"Jillnorthman", "France":"États-Unis",
"Paris":"New York", "Louis Philippe":"Andrew Jackson" }' ::jsonb ;
Copier le contenu sous forme de tableau de caractères dans les autres
champs :
UPDATE remplacement
SET old_t = noms_old , new_t = noms_new
FROM (SELECT array_agg (key ) AS noms_old, array_agg (value ) AS noms_new
FROM (
SELECT (jsonb_each_text (j)).* FROM remplacement
) j1
) j2 ;
On vérifie le contenu :
SELECT * FROM remplacement \gx
Comparer la performance des deux fonctions suivantes :
\pset pager off
-- fonction en PL∕perl
EXPLAIN (ANALYZE , BUFFERS)
SELECT multi_replace (contenu, (SELECT j FROM remplacement))
FROM miserables ;
-- fonction en PL/pgSQL
EXPLAIN (ANALYZE , BUFFERS)
SELECT multi_replace (contenu,
(SELECT old_t FROM remplacement),
(SELECT new_t FROM remplacement) )
FROM miserables ;
\pset pager off
EXPLAIN (ANALYZE , BUFFERS)
SELECT multi_replace (contenu, (SELECT j FROM remplacement))
FROM miserables ;
EXPLAIN (ANALYZE , BUFFERS)
SELECT multi_replace (contenu,
(SELECT old_t FROM remplacement),
(SELECT new_t FROM remplacement) )
FROM miserables ;
Selon les performances de la machine, les résultats peuvent varier,
mais la première (en PL/perl) est probablement plus rapide. La fonction
en PL/perl montre son intérêt quand il y a beaucoup de
substitutions.
hll
Installer l’extension hll dans la
base de données de test :
le paquet est hll_18 ou
postgresql-18-hll (adapter le numéro à votre version de
PostgreSQL) selon la distribution ;
l’extension se nomme hll ;
elle nécessite d’être préalablement déclarée
dans shared_preload_libraries.
Sur Rocky Linux et autres dérivés Red Hat :
Sur Debian, Ubuntu et dérivés :
# apt install postgresql-18-hll
Modifier postgresql.conf ainsi afin que la bibliothèque
soit préchargée dès le démarrage du serveur :
shared_preload_libraries = 'hll'
Redémarrer PostgreSQL.
Installer l’extension dans la base :
Créer un jeu de données simulant des voyages en
transport en commun, par passager selon la date :
CREATE UNLOGGED TABLE voyages
(voyage_id bigint GENERATED ALWAYS AS IDENTITY ,
passager_id text ,
d date
) ;
INSERT INTO voyages(passager_id, d)
SELECT
random (1 , 1 _000_000):: text ,
'2026-01-01 00:00:00 ' :: timestamptz + interval '1 second ' * random (1 , 31 _500_000)
FROM generate_series (1 , 10 _000_000);
Cette table de 10 millions de voyages étalés de janvier à décembre
2026 pèse 498 Mo.
À cause de la partie aléatoire, vos résultats peuvent s’écarter
légèrement de ceux qui suivent.
Activer l’affichage du temps
(timing).
Désactiver JIT.
Passer la mémoire de tri à 64 Mo.
Précharger la table dans le cache de
PostgreSQL.
\timing on
SET track_io_timing TO on ;
SET jit TO off ;
SET work_mem TO '64MB ' ;
CREATE EXTENSION pg_prewarm ;
SELECT pg_prewarm('voyages ' ) ;
Calculer, par mois, le nombre exact de voyages, et
de passagers distincts .
D’après le plan de la requête, quelle est la
consommation mémoire ? Et la consommation de fichiers temporaires ?
SELECT
date_trunc ('month ' , d):: date AS mois,
COUNT (* ) AS nb_voyages,
count (DISTINCT passager_id) AS nb_d_passagers_mois
FROM voyages
GROUP BY 1 ORDER BY 1 ;
mois | nb_voyages | nb_d_passagers_mois
------------+------------+---------------------
2026-01-01 | 851758 | 573380
2026-02-01 | 767095 | 535770
2026-03-01 | 848654 | 572580
2026-04-01 | 824236 | 561478
2026-05-01 | 851151 | 573469
2026-06-01 | 823289 | 561123
2026-07-01 | 850861 | 572970
2026-08-01 | 848652 | 572151
2026-09-01 | 823255 | 561005
2026-10-01 | 851862 | 573217
2026-11-01 | 821138 | 559978
2026-12-01 | 838049 | 567395
(12 lignes)
Durée : 6631.846 ms (00:06.632)
Le plan de cette même requête avec
EXPLAIN (ANALYZE, BUFFERS) est :
QUERY PLAN
---------------------------------------------------------------------------------
GroupAggregate (cost=1572249.65..1672254.74 rows=365 width=20)
(actual time=7046.366..8711.295 rows=12.00 loops=1)
Group Key: ((date_trunc('month'::text, (d)))::date)
Buffers: shared hit=63695, temp read=25556 written=25564
I/O Timings: temp read=28.808 write=63.543
-> Sort (cost=1572249.65..1597249.32 rows=9999871 width=10)
(actual time=6896.440..7959.486 rows=10000000.00 loops=1)
Sort Key: ((date_trunc('month'::text, (d)))::date), passager_id
Sort Method: external merge Disk: 204448kB
Buffers: shared hit=63695, temp read=25556 written=25564
I/O Timings: temp read=28.808 write=63.543
-> Seq Scan on voyages (cost=0.00..238692.74 rows=9999871 width=10)
(actual time=0.025..1887.043 rows=10000000.00 loops=1)
Buffers: shared hit=63695
Settings: work_mem = '64MB', jit = 'off'
Planning Time: 0.103 ms
Execution Time: 8723.321 ms
L’essentiel du temps est passé dans l’opération de tri.
work_mem n’est pas assez haut pour que celle-ci se fasse en
mémoire, des fichiers temporaires sont donc utilisés pour un total de
200 Mo sur disque. Avec un disque performant, ce n’est pas forcément un
problème en matière de temps d’exécution (comme ici), mais cela
réprésente tout de même une consommation d’I/O qui peut participer à la
charge générale du serveur. Pour la consommation mémoire, ce n’est pas
indiqué explicitement dans le plan, mais le processus backend a
utilisé la totalité de work_mem, donc 64 Mo, car il a
découpé l’ensemble à trier par batchs de 64 Mo, triant ceux-ci
un par un en mémoire, et les sauvegardants en fichiers temporaires au
fur et à mesure.
Calculer, pour toute l’année, le nombre exact de
passagers distincts .
SELECT COUNT (DISTINCT passager_id) AS nb_d_passagers_annee FROM voyages;
nb_d_passagers_annee
----------------------
999947
(1 row)
Durée: 2535.354 ms (00:02.535)
On a donc environ 800 000 voyages chaque mois, répartis sur environ
500 000 passagers mensuels, mais il n’y a en fait qu’un million de
personnes distinctes.
Recompter les passagers dans les deux cas en
remplaçant le COUNT(DISTINCT) par cette expression :
hll_cardinality(hll_add_agg(hll_hash_text(passager_id)))::int
Les ID des passagers sont hachés, agrégés, et le calcul de
cardinalité se fait sur l’ensemble complet.
SELECT
date_trunc ('month ' , d):: date AS mois,
COUNT (* ) AS nb_voyages,
hll_cardinality(hll_add_agg(hll_hash_text(passager_id))):: int
AS nb_d_passagers_mois
FROM voyages
GROUP BY 1 ORDER BY 1 ;
mois | nb_voyages | nb_d_passagers_mois
------------+------------+---------------------
2026-01-01 | 851758 | 583575
2026-02-01 | 767095 | 542188
2026-03-01 | 848654 | 576094
2026-04-01 | 824236 | 586076
2026-05-01 | 851151 | 593500
2026-06-01 | 823289 | 565436
2026-07-01 | 850861 | 562444
2026-08-01 | 848652 | 562748
2026-09-01 | 823255 | 564521
2026-10-01 | 851862 | 557445
2026-11-01 | 821138 | 574005
2026-12-01 | 838049 | 575298
(12 lignes)
Durée : 859,646 ms
L’accélération par rapport à la première requête est notable (facteur
8 ici, mais cela dépend beaucoup des caractéristiques du serveur). Les
chiffres sont différents, mais très proches (écart souvent inférieur à
1 %, au maximum 2,8 %).
Le plan est parallélisé. Aucun fichier temporaire n’est produit, et
la consommation mémoire est faible : moins de 2 Mo par processus, donc
moins de 6 Mo au total.
QUERY PLAN
---------------------------------------------------------------------------------
Finalize GroupAggregate (cost=179299.71..179419.62 rows=365 width=16)
(actual time=988.000..990.968 rows=12.00 loops=1)
Group Key: ((date_trunc('month'::text, (d)::timestamp with time zone))::date)
Buffers: shared hit=63711
-> Gather Merge (cost=179299.71..179401.74 rows=876 width=44)
(actual time=987.960..990.737 rows=36.00 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=63711
-> Sort (cost=178299.69..178300.60 rows=365 width=44)
(actual time=976.763..976.764 rows=12.00 loops=3)
Sort Key: ((date_trunc('month'::text, (d)))::date)
Sort Method: quicksort Memory: 50kB
Buffers: shared hit=63711
Worker 0: Sort Method: quicksort Memory: 50kB
Worker 1: Sort Method: quicksort Memory: 50kB
-> Partial HashAggregate
(cost=178276.86..178284.16 rows=365 width=44)
(actual time=976.730..976.733 rows=12.00 loops=3)
Group Key: (date_trunc('month'::text, (d)))::date
Batches: 1 Memory Usage: 1682kB
Buffers: shared hit=63695
Worker 0: Batches: 1 Memory Usage: 1682kB
Worker 1: Batches: 1 Memory Usage: 1682kB
-> Parallel Seq Scan on voyages
(cost=0.00..136610.73 rows=4166613 width=10)
(actual time=0.014..653.111 rows=3333333.33 loops=3)
Buffers: shared hit=63695
Settings: work_mem = '64MB', jit = 'off'
Planning Time: 0.123 ms
Execution Time: 991.025 ms
Pour l’année, on a un résultat similaire :
SELECT
hll_cardinality (hll_add_agg (hll_hash_text (passager_id))):: int
AS nb_d_passagers_annee
FROM voyages;
nb_d_passagers_annee
----------------------
1007027
(1 ligne)
Durée : 159,006 ms
L’écart est de 0,7 % pour une durée réduite d’un facteur 16. La perte
en précision est-elle acceptable pour les besoins applicatifs ? C’est un
choix fonctionnel. On peut d’ailleurs agir dessus.
Réexécuter la requête mensuelle, en utilisant le nombre maximal de
registres (17, contre 11 par défaut) :
SELECT
date_trunc('month', d)::date AS mois,
COUNT(*) AS nb_voyages,
hll_cardinality(hll_add_agg(hll_hash_text(passager_id), 17))::int
AS nb_d_passagers_mois
FROM voyages
GROUP BY 1 ORDER BY 1 ;
La requête mensuelle dure à peine plus longtemps pour un écart par
rapport à la réalité de l’ordre de 0,02 à 0,6 %.
Selon les cas et après des tests soigneux, on testera donc l’intérêt
de modifier ces paramètres tels que décrits sur le site du projet : https://github.com/citusdata/postgresql-hll
Créer une table d’agrégat par mois avec un champ
d’agrégat hll et la remplir.
CREATE TABLE voyages_mois
(mois date ,
nb_exact_passagers_mois int ,
passagers_hll hll
) ;
INSERT INTO voyages_mois
SELECT
date_trunc ('month ' , d):: date ,
COUNT (DISTINCT passager_id),
hll_add_agg (hll_hash_text (passager_id), 17 )
FROM voyages
GROUP BY 1 ;
Cette table d’agrégat n’a que 12 lignes mais contient un champ de
type hll agrégeant les passager_id de ce mois.
Sa taille n’est que d’1 Mo :
hll=# \d+
Liste des relations
Schéma | Nom | Type | Propriétaire | Taille | …
--------+-----------------------+----------+--------------+------------+--
public | voyages | table | postgres | 442 MB |
public | voyages_mois | table | postgres | 1072 kB |
public | voyages_voyage_id_seq | séquence | postgres | 8192 bytes |
À partir de cette table d’agrégat :
calculer le nombre moyen mensuel de passagers distincts,
recalculer le nombre de passagers distincts sur l’année à partir de
cette table d’agrégat.
La fonction pour agréger des champs de type hll est
hll_union_agg. La requête est donc :
SELECT AVG (nb_exact_passagers_mois):: int AS passagers_mois_moyen,
hll_cardinality(hll_union_agg(passagers_hll)):: int AS nb_passagers_annuels
FROM voyages_mois ;
passagers_mois_moyen | nb_passagers_annuels
----------------------+----------------------
565376 | 1000658
(1 ligne)
Temps : 3,391 ms
L’extension HyperLogLog permet donc d’utiliser des tables d’agrégat
pour un COUNT(DISTINCT). De manière presque instantanée, on
retrouve la même estimation presque parfaite que ci-dessus. Il aurait
été impossible de la recalculer depuis la table d’agrégat (au contraire
de la moyenne par mois, ou d’une somme du nombre de voyages).