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 textes
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_stats_statements nécessite une bibliothèque
préchargée. La positionner dans le fichier postgresql.conf
,
redémarrer PostgreSQL et créer l’extension.
Si une autre extension (ici auto_explain
) est également
présente, on peut les lister 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+
).
Objets dans l'extension « pg_stat_statements »
Description d'objet
--------------------------------------
function pg_stat_statements(boolean)
function pg_stat_statements_reset()
view pg_stat_statements
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 :
/usr/pgsql-16/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
Si le temps de mesure n’est que de quelques dizaines de nanosecondes,
c’est OK. (C’est le cas sur presque toutes les machines et systèmes
d’exploitation actuels, mais il y a parfois des surprises.) Sinon,
éviter de faire ce qui suit sur un serveur de production. Sur une
machine de formation, ce n’est pas un problème.
Dans le fichier postgresql.conf
, positionner :
Changer ce paramètre nécessite 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 tant qu'utilisateur postgres
createdb -e pgbench
/usr/pgsql-16/bin/pgbench -i -s135 pgbench
/usr/pgsql-16/bin/pgbench -c5 -j1 pgbench -T 600 -P1
SELECT pg_catalog.set_config('search_path', '', false);
CREATE DATABASE pgbench;
/usr/pgsql-16/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-16/bin/pgbench -c5 -j1 pgbench -T 600 -P1
pgbench (16.2)
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 | 879669
query | UPDATE pgbench_accounts SET abalance = abalance + $1 WHERE aid = $2
rows | 879669
tps_total | 00:02:56.184131
-[ RECORD 2 ]------------------------------------------------------------------
calls | 879664
query | UPDATE pgbench_branches SET bbalance = bbalance + $1 WHERE bid = $2
rows | 879664
tps_total | 00:00:44.803628
-[ RECORD 3 ]------------------------------------------------------------------
calls | 879664
query | UPDATE pgbench_tellers SET tbalance = tbalance + $1 WHERE tid = $2
rows | 879664
tps_total | 00:00:12.196055
-[ RECORD 4 ]------------------------------------------------------------------
calls | 1
query | copy pgbench_accounts from stdin with (freeze on)
rows | 13500000
tps_total | 00:00:10.698976
-[ RECORD 5 ]------------------------------------------------------------------
calls | 879664
query | SELECT abalance FROM pgbench_accounts WHERE aid = $1
rows | 879664
tps_total | 00:00:06.530169
Noter que l’unique COPY
pour créer la base dure plus que
les centaines 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,
blk_write_time * interval '1ms' AS 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 ;
-[ RECORD 1 ]-------+-------------------------------------------------------------
calls | 2400667
written | 15 GB
shared_written | 15 GB
temp_written | 0 bytes
blk_write_time | 00:00:11.840499
temp_blk_write_time | 00:00:00
query | UPDATE pgbench_accounts SET abalance = abalance + $1 WHERE a.
|.id = $2
-[ RECORD 2 ]-------+-------------------------------------------------------------
calls | 1
written | 3442 MB
shared_written | 3442 MB
temp_written | 0 bytes
blk_write_time | 00:00:00
temp_blk_write_time | 00:00:00
query | copy pgbench_accounts from stdin with (freeze on)
-[ RECORD 3 ]-------+-------------------------------------------------------------
calls | 1
written | 516 MB
shared_written | 0 bytes
temp_written | 516 MB
blk_write_time | 00:00:00
temp_blk_write_time | 00:00:00
query | alter table pgbench_accounts add primary key (aid)
Il y a donc beaucoup d’écritures directes. 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 (c’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 | 2400667
total_exec_time | 464702.3557850064
hit % | 73.24
query | UPDATE pgbench_accounts SET abalance = abalance + $1 WHERE aid =.
|. $2
-[ RECORD 2 ]---+-----------------------------------------------------------------
calls | 2400658
total_exec_time | 141310.02034101041
hit % | 100.00
query | UPDATE pgbench_branches SET bbalance = bbalance + $1 WHERE bid =.
|. $2
-[ RECORD 3 ]---+-----------------------------------------------------------------
calls | 2400659
total_exec_time | 34201.65339700031
hit % | 100.00
query | UPDATE pgbench_tellers SET tbalance = tbalance + $1 WHERE tid = .
|.$2
-[ RECORD 4 ]---+-----------------------------------------------------------------
calls | 2400661
total_exec_time | 16494.857696000774
hit % | 100.00
query | SELECT abalance FROM pgbench_accounts WHERE aid = $1
-[ RECORD 5 ]---+-----------------------------------------------------------------
calls | 2400656
total_exec_time | 11685.776115000388
hit % | 100.00
query | INSERT INTO pgbench_history (tid, bid, aid, delta, mtime) VALUES.
|. ($1, $2, $3, $4, CURRENT_TIMESTAMP)
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 14 :
# dnf install postgresql14-plpython3
Sur Debian et dérivés ce sera :
# apt install postgresql-plpython3-14
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_14
ou
postgresql-14-hll
(ou l’équivalent pour les autres numéros
de versions) 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 et dérivés :
# apt install postgresql-14-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 TABLE voyages
(voyage_id bigint GENERATED ALWAYS AS IDENTITY,
passager_id text,
d date
) ;
INSERT INTO voyages (passager_id, d)
SELECT sem+ mod (i, sem+ 1 ) || '-' || mod (i,77777 ) AS passager_id, d
FROM generate_series (0 ,51 ) sem,
LATERAL
(SELECT i,
'2019-01-01' ::date + sem * interval '7 days' + i * interval '2s' AS d
FROM generate_series (1 ,
(case when sem in (31 ,32 ,33 ) then 0 else 22 end + abs (30 - sem))* 5000 ) i
) j
;
Cette table de 9 millions de voyages étalés de janvier à décembre
2019 pèse 442 Mo.
Activer l’affichage du temps
(timing
).
Désactiver JIT et le parallélisme.
Passer la mémoire de tri à 1 Go.
Précharger la table dans le cache de
PostgreSQL.
\timing on
SET max_parallel_workers_per_gather TO 0 ;
SET jit TO off ;
SET work_mem TO '1GB' ;
CREATE EXTENSION pg_prewarm ;
SELECT pg_prewarm('voyages' ) ;
Calculer, par mois, le nombre exact de voyages et
de passagers distincts .
Dans le plan de la requête, chercher où est perdu
le temps.
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
------------+------------+---------------------
2019-01-01 | 1139599 | 573853
2019-02-01 | 930000 | 560840
2019-03-01 | 920401 | 670993
2019-04-01 | 793199 | 613376
2019-05-01 | 781801 | 655970
2019-06-01 | 570000 | 513439
2019-07-01 | 576399 | 518478
2019-08-01 | 183601 | 179913
2019-09-01 | 570000 | 527994
2019-10-01 | 779599 | 639944
2019-11-01 | 795401 | 728657
2019-12-01 | 830000 | 767419
(12 lignes)
Durée : 57301,383 ms (00:57,301)
Le plan de cette même requête avec
EXPLAIN (ANALYZE, BUFFERS)
est :
QUERY PLAN
-------------------------------------------------------------------------------
GroupAggregate (cost=1235334.73..1324038.21 rows=230 width=20)
(actual time=11868.776..60192.466 rows=12 loops=1)
Group Key: ((date_trunc('month'::text, (d)::timestamp with time zone))::date)
Buffers: shared hit=56497
-> Sort (cost=1235334.73..1257509.59 rows=8869946 width=12)
(actual time=5383.305..5944.522 rows=8870000 loops=1)
Sort Key:
((date_trunc('month'::text, (d)::timestamp with time zone))::date)
Sort Method: quicksort Memory: 765307kB
Buffers: shared hit=56497
-> Seq Scan on voyages (cost=0.00..211721.06 rows=8869946 width=12)
(actual time=0.055..3714.690 rows=8870000 loops=1)
Buffers: shared hit=56497
Planning Time: 0.439 ms
Execution Time: 60278.583 ms
Le plan est visible sur https://explain.dalibo.com/plan/Hj (pour PostgreSQL 14).
Il suppose que shared_buffers
est assez grand pour que tous
tous les accès se fassent en mémoire (shared hits ). Le
work_mem
élevé permet que le tri des 765 Mo soit aussi en
mémoire. Le cas est donc idéal. L’essentiel du temps est perdu en tri.
Pour donner une idée de la lourdeur d’un
COUNT(DISTINCT)
: un décompte non distinct (qui revient à
calculer le nombre de voyages) prend sur la même machine 5 secondes,
même moins si le parallélisme est utilisé, mais ce qu’un
COUNT (DISTINCT)
ne permet pas.
Calculer, pour l’année, le nombre exact de voyages
et de passagers distincts .
SELECT COUNT (* ) AS nb_voyages,
COUNT (DISTINCT passager_id) AS nb_d_passagers_annee
FROM voyages;
nb_voyages | nb_d_passagers_annee
------------+----------------------
8870000 | 4731210
Durée : 60396,816 ms (01:00,397)
On a donc plusieurs millions de voyages chaque mois, répartis sur
quelques centaines de milliers de passagers mensuels, qui ne totalisent
que 4,7 millions de personnes distinctes. Il y a donc un fort turn-over
tout au long de l’année sans que ce soit un renouvellement complet d’un
mois sur l’autre.
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,
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_d_passagers_mois
------------+---------------------
2019-01-01 | 563372
2019-02-01 | 553182
2019-03-01 | 683411
2019-04-01 | 637927
2019-05-01 | 670292
2019-06-01 | 505151
2019-07-01 | 517140
2019-08-01 | 178431
2019-09-01 | 527655
2019-10-01 | 632810
2019-11-01 | 708418
2019-12-01 | 766208
(12 lignes)
Durée : 4556,646 ms (00:04,557)
L’accélération est foudroyante (facteur 10 ici). Les chiffres sont
différents, mais très proches (écart souvent inférieur à 1 %, au maximum
2,8 %).
Le plan indique un parcours de table et un agrégat par hachage :
Sort (actual time=5374.025..5374.025 rows=12 loops=1)
Sort Key: ((date_trunc('month'::text, (d)::timestamp with time zone))::date)
Sort Method: quicksort Memory: 25kB
Buffers: shared hit=56497
-> HashAggregate (actual time=5373.793..5374.009 rows=12 loops=1)
Group Key: (date_trunc('month'::text, (d)::timestamp with time zone))::date
Buffers: shared hit=56497
-> Seq Scan on voyages (actual time=0.020..3633.757 rows=8870000 loops=1)
Buffers: shared hit=56497
Planning Time: 0.122 ms
Execution Time: 5374.062 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
----------------------
4645096
(1 ligne)
Durée : 1461,006 ms (00:01,461)
L’écart est de 1,8 % pour une durée réduite d’un facteur 40. Cet
écart est-il acceptable pour les besoins applicatifs ? C’est un choix
fonctionnel. On peut d’ailleurs agir dessus.
Réexécuter les requêtes après modification du paramétrage de
hll
:
SELECT hll_set_defaults(17 , 5 , - 1 , 0 );
Les défauts sont :
log2m=11, regwidth=5, expthresh=-1, sparseon=1
.
La requête mensuelle dure à peine plus longtemps (environ 6 s sur la
machine de test) pour un écart par rapport à la réalité de l’ordre de
0,02 à 0,6 %.
La requête sur l’année dure environ le même temps pour seulement
0,2 % d’erreur cette fois :
nb_d_passagers_annee
----------------------
4741645
(1 ligne)
Durée : 1122,149 ms (00:01,122)
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))
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
----------------------+----------------------
579240 | 4741645
(1 ligne)
Temps : 17,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).
Avec une fonction de fenêtrage sur hll_union_agg
,
calculer une moyenne glissante sur 3 mois du nombre de passagers
distincts.
SELECT mois,
nb_exact_passagers_mois,
CASE WHEN ROW_NUMBER () OVER () > 2 THEN
hll_cardinality(hll_union_agg(passagers_hll)
OVER (ORDER BY mois ASC ROWS 2 PRECEDING ) )::bigint
ELSE null END AS nb_d_passagers_3_mois_glissants
FROM voyages_mois
;
mois | nb_exact_passagers_mois | nb_d_passagers_3_mois_glissants
------------+-------------------------+---------------------------------
2019-01-01 | 573853 |
2019-02-01 | 560840 |
2019-03-01 | 670993 | 1463112
2019-04-01 | 613376 | 1439444
2019-05-01 | 655970 | 1485437
2019-06-01 | 513439 | 1368534
2019-07-01 | 518478 | 1333368
2019-08-01 | 179913 | 1018605
2019-09-01 | 527994 | 1057278
2019-10-01 | 639944 | 1165308
2019-11-01 | 728657 | 1579378
2019-12-01 | 767419 | 1741934
(12 lignes)
Temps : 78,522 ms
Un COUNT(DISTINCT)
avec une fonction de fenêtrage n’est
en pratique pas faisable, en tout cas pas aussi aisément, et bien plus
lentement.