Lyon, le 5 janvier 2024

Voici le premier numéro d’une série d’articles concernant le fonctionnement de PostgreSQL. Nommée « Les mains dans le cambouis », elle consistera, comme son titre le laisse suggérer, à détailler et comprendre le fonctionnement du moteur de PostgreSQL.

moteur

Et pour ce premier article, nous avons choisi de parler du sujet des checkpoints, de leur utilité, de leur lien avec le cache et des paramètres qui existent pour les configurer.

Quésaco ?

Un petit tour sur la documentation de PostgreSQL nous permet de trouver la bonne définition d’un checkpoint (voir wal-configuration). Il s’agit d’un instant précis, dans la timeline d’une instance, à partir duquel on peut considérer que les fichiers sur disque sont bien à jour avec ce qui a été enregistré dans les journaux de transactions jusqu’au début de l’exécution du checkpoint.

C’est le processus checkpointer de l’instance qui se charge de cela en déclenchant aussi souvent que possible des checkpoints. Il existe également la commande SQL CHECKPOINT qui permet d’en déclencher manuellement. D’autres activités de l’instance déclenchent aussi des checkpoints automatiques, comme l’arrêt de l’instance, une sauvegarde PITR, etc.

Rappelons que, dans son fonctionnement normal, PostgreSQL n’écrit pas directement sur disque dans les fichiers de données, mais le fait dans des journaux de transaction. Les fichiers de données sont modifiés dans le cache, donc en mémoire. C’est le processus checkpointer qui va régulièrement les écrire sur disque pour nettoyer le cache.

Cache partagé

Pour comprendre ce qu’il se cache derrière “nettoyer le cache”, il est nécessaire de se pencher sur le cache de PostgreSQL. La taille de ce dernier est configurée grâce au paramètre shared_buffers, positionné par défaut à 128 Mo. PostgreSQL travaille avec des blocs de 8 ko, cela revient donc à 16384 blocs de données alloués au cache partagé.

Le module pg_buffercache permet d’examiner son contenu et son état.

Cette extension fournit la vue pg_buffercache qui permet de voir facilement le contenu du cache en retournant une ligne par bloc. Pour chaque bloc de 8 ko, les informations dont on dispose sont les suivantes :

demo=# \x
Expanded display is on.
demo=# select * from pg_buffercache ;
-[ RECORD 1 ]----+------
bufferid         | 1        -- ID du bloc
relfilenode      | 1262     -- numéro du fichier sur disque
reltablespace    | 1664     -- OID du tablespace où se trouve la relation
reldatabase      | 0        -- OID de la base de données où se trouve la relation
relforknumber    | 0        -- numéro du fork dans la relation
relblocknumber   | 0        -- numéro du bloc de la relation
isdirty          | f        -- est ce que le bloc est modifié (dirty dans la VO)
usagecount       | 5        -- compteur lié au clock-sweep
pinning_backends | 0        -- nombre de processus utilisant ce bloc

Plusieurs éléments sont intéressants dans ces informations, mais limitons-nous pour le moment à isdirty. Il indique si le bloc en question a été écrit sur disque (false) ou si sa version modifiée n’est présente que dans le cache (true). Les blocs dirty verront leur contenu être écrit sur disque lors du prochain passage d’un checkpoint.

Partons d’une instance tout juste installée et créons une nouvelle base demo.

postgres=# create database demo;
CREATE DATABASE
postgres=# \c demo 
You are now connected to database "demo" as user "postgres".
demo=# create extension pg_buffercache ;
CREATE EXTENSION

Créons une nouvelle table t1.

demo=# create table t1 (i integer);
CREATE TABLE

L’extension pg_buffercache permet de remonter que 16 blocs du cache ont été modifiés.

demo=#  select count(*) from pg_buffercache where isdirty;
 count 
-------
    16
(1 row)

En effet, la création d’une table va écrire des données dans différents catalogues systèmes (pg_class, pg_attribute pour ne citer que les deux plus évidents). Ces 16 blocs concernent donc les catalogues systèmes. Si nous forçons le passage d’un checkpoint, ces blocs-là seront écrits sur disque.

demo=# CHECKPOINT;
CHECKPOINT
demo=#  select count(*) from pg_buffercache where isdirty;
 count 
-------
    0
(1 row)

Dès lors qu’une opération est faite sur la table, par exemple une insertion, PostgreSQL aura besoin de blocs du cache, qui seront alors considérés comme dirty. Insérons plusieurs entiers dans la table t1 :

demo=# insert into t1 select generate_series(1,10000);
INSERT 0 10000
demo=#  select count(*) from pg_buffercache where isdirty;
 count 
-------
   49
(1 row)

49 blocs du cache ont été utilisés pour l’opération d’INSERT. Ils sont donc marqués comme dirty. CHECKPOINT permet de nettoyer le cache. Plus aucun bloc n’est considéré dirty.

demo=# CHECKPOINT;
CHECKPOINT
demo=# select count(*) from pg_buffercache where isdirty;
 count 
-------
     0
(1 row)

“Plus aucun bloc n’est considéré dirty, cela ne signifie pas que les blocs ont disparu du cache. Bien au contraire, ils restent là tant que PostgreSQL estime en avoir besoin. La partie Notions essentielles de gestion du cache de notre formation DBA2 décrit les notions et mécanismes à ce sujet.

demo=# SELECT
relname,
isdirty,
count(bufferid) AS blocs,
pg_size_pretty(count(bufferid) * current_setting ('block_size')::int) AS taille
FROM pg_buffercache b
INNER JOIN pg_class c ON c.relfilenode = b.relfilenode
WHERE relname = 't1'
GROUP BY relname, isdirty;

 relname | isdirty | blocs | taille 
---------+---------+-------+--------
 t1      | f       |    49 | 392 kB
(1 row)

Les traces de l’instance montrent en détail ce qu’il s’est passé lors du checkpoint. Le paramètre log_checkpoints doit être configuré à on.

2023-12-27 16:18:49.315 CET [68301] LOG:  checkpoint starting: immediate force wait
2023-12-27 16:18:49.327 CET [68301] LOG:  checkpoint complete: wrote 50 buffers (0.3%); 0 WAL file(s) added, 0 removed, 0 recycled; write=0.003 s, sync=0.003 s, total=0.013 s; sync files=4, longest=0.002 s, average=0.001 s; distance=630 kB, estimate=1853 kB; lsn=0/1B971D0, redo lsn=0/1B97198
  • checkpoint starting: immediate force wait indique que le checkpoint a été démarré car forcé manuellement ;
  • checkpoint complete indique que l’opération s’est bien terminée et donne des détails supplémentaires, notamment :
    • wrote 50 buffers (0.3%) qui indique le nombre de buffers écrits et indique également qu’ils correspondent à 0,3% du cache partagé de l’instance ;
    • lsn=0/1AF3E48 qui indique le Log Segment Number lors de la fin du checkpoint ;
    • et redo lsn=0/1AF3E10 qui indique le Log Segment Number au début du checkpoint. C’est à partir de cet instant là qu’il faudra rejouer les journaux de transactions.

En plus de faire le ménage dans le cache partagé (suppression du flag dirty) et d’écrire dans les fichiers de données, un checkpoint va également ajouter un enregistrement checkpoint dans les journaux de transactions et sa position dans le fichier global/pg_control de l’instance.

L’utilitaire pg_controldata permet de retrouver ces informations là sans devoir lire le fichier de traces :

$ pg_controldata . | grep -E "(Latest checkpoint location)|(Latest checkpoint's REDO)"
Latest checkpoint location:           0/1B971D0
Latest checkpoint's REDO location:    0/1B97198
Latest checkpoint's REDO WAL file:    000000010000000000000001

La troisième ligne indique quel journal de transactions est associé au redo lsn. Tous les journaux antérieurs à celui-ci ne sont donc désormais plus utiles et pourront être recyclés automatiquement par PostgreSQL.

Paramétrage des checkpoints

Nous avons vu en quoi consiste une opération de checkpoint et en avons lancé manuellement. Comme dit précédement, un processus serveur (checkpointer) est dédié à l’exécution de cette opération. Il s’agit en fait d’une boucle d’exécution qui va s’exécuter puis dormir pendant un certain temps et pourra être interrompue à la réception de certains signaux.

Plusieurs paramètres influencent le comportement du checkpointer.

Tout d’abord, le paramètre checkpoint_timeout indique le temps maximal entre deux checkpoints. Par défaut, il est configuré à 5 minutes. Nous verrons plus tard que réduire au minimum cette durée n’est pas une bonne idée mais que l’augmenter pourrait être intéressant.

PostgreSQL calcule le temps passé entre les deux derniers checkpoints et applique la valeur du paramètre checkpoint_completion_target (0,9 par défaut). La durée obtenue indique le temps que doit passer le checkpointer à écrire sur disque les blocs du cache considérés comme dirty. L’idée est de lisser au mieux les écritures sur disque pour permettre aux autres processus d’accéder plus rapidement aux disques.

max_wal_size indique la taille maximale d’écriture dans les journaux de transactions entre deux checkpoints. Si elle est dépassée avant l’expiration du délai checkpoint_timeout, un checkpoint est déclenché. De ce fait, un checkpoint peut être déclenché de deux manières :

  • par expiration du temps (time) ;
  • ou par dépassement de cette volumétrie (wal).

Lien entre les checkpoints et la taille de pg_wal

Nous avons vu qu’un passage de checkpoint permet de savoir quels journaux de transaction ne sont plus utiles. Intuitivement, on comprend donc que plus les checkpoints sont éloignés, plus on conservera de journaux. Cette conclusion a une importance en cas de restauration de l’instance. En effet, le temps de restauration sera d’autant plus rallongé que le nombre de journaux est important.

On aurait donc envie de réduire au minimum le paramètre checkpoint_timeout pour accélérer la reprise. Oui, mais … non. Ce n’est pas une si bonne idée que cela.

Dans le cas où le paramètre full_page_writes est configuré à on (ce qui est par défaut le cas et ne doit être changé que pour des cas très précis en connaissance de cause), il reste un autre facteur à considérer. Ce paramètre force PostgreSQL à écrire intégralement, dans le journal de transaction en cours, le bloc complet lors de sa première modification après un checkpoint. Ceci afin de s’assurer que ce bloc puisse être entièrement restauré en cas de crash. Une explication plus détaillée se trouve sur la page wiki du projet PostgreSQL.

Dans ce cas-là donc, un intervalle de checkpoints plus petit augmentera le volume d’écriture des journaux de transaction, annulant en partie l’intérêt d’utiliser cet intervalle plus petit et générant plus de sollications disques.

Pour illustrer nos propos, voici les résultats d’un test montrant le lien entre le paramètre checkpoint_timeout (abscisses) et la volumétrie de journaux générés (ordonnées). Chaque test individuel a duré environ une heure, le test global a donc pris 5 heures. L’outil utilisé pour générer de l’activité est pg_bench.

resultats1

Ce qui a été dit juste avant se vérifie parfaitement avec ce graphique : avec un checkpoint_timeout à 1min, le volume de journaux atteint presque 30 Go tandis qu’avec 30min, la volumétrie est inférieure à 5 Go. La différence est très marquée et mérite d’être signalée : moins d’écritures disques, moins de journaux à archiver pour les sauvegardes PITR, moins de données à envoyer (en flux ou par segment) aux serveurs secondaires dans le cas d’une réplication. Les avantages sont conséquents, et les inconvénients mineurs. Si l’instance PostgreSQL se trouve installée sur un serveur avec des capacités de stockage réduites, il pourrait être intéressant d’utiliser un checkpoint_timeout élevé (dans ce cas-là, ne pas oublier de faire suivre max_wal_size). Pour autant, les journaux de transactions seront conservés plus longtemps et le temps de reprise en cas de crash sera également allongé.

Comme souvent avec PostgreSQL, il faut faire preuve d’équilibre pour trouver la configuration qui convient le plus à la charge de travail.

Conclusion

Et voilà, c’est la fin de notre premier article Les mains dans le cambouis sur les checkpoints. Nous venons de découvrir une (petite) partie du moteur PostgreSQL. Les checkpoints sont essentiels au bon fonctionnement de l’instance et méritent d’être compris et configurés correctement. Le prochain sujet technique n’est pas encore déterminé.

Des questions, des commentaires ? Écrivez-nous !


DALIBO

DALIBO est le spécialiste français de PostgreSQL®. Nous proposons du support, de la formation et du conseil depuis 2005.