SQLite est l’intrus de cette série : pas de serveur, pas de répertoire de données, pas de processus d’arrière-plan. Les fichiers SQLite tiennent dans un seul fichier ordinaire que l’on peut lire avec un vidage hexadécimal. Cet article prolonge les trois premiers, InnoDB, PostgreSQL et Oracle avec les trois côte à côte. Le tableau final ne remplace rien : il ajoute une quatrième colonne à la comparaison.
J’ai rencontré SQLite dans d’autres outils, par exemple ProxySQL, qui garde sa configuration dans une base SQLite. Alors j’ai voulu voir les octets. Tout ce qui suit vient d’une base de laboratoire : testé sur Rocky Linux 10.2, SQLite 3.46.1, WSL2. Je prévois aussi une série plus détaillée en sept parties, « SQLite from the inside », pour 2027. Cet article reste court et suit le même plan que les trois autres.
Un fichier, jusqu’à trois voisins
Une base, c’est un fichier. Le journal, le WAL et le shm n’existent que lorsque SQLite en a besoin.
Une base SQLite est un seul fichier, qu’un programme ouvre directement. Dans le mode par défaut, un deuxième fichier n’apparaît que pendant une transaction d’écriture. C’est le journal de retour arrière, et il garde la version d’origine de chaque page que la transaction modifie.
-rw-r--r--. 1 root root 8192 Nov 2 21:21 demo.db
-rw-r--r--. 1 root root 4616 Nov 2 21:21 demo.db-journal
Le journal fait 4 616 octets. Son en-tête l’explique : un secteur de 512 octets, puis un enregistrement de page de 4 + 4 096 + 4 octets (numéro de page, page, somme de contrôle). Au COMMIT, le journal a disparu. Pendant la transaction, ses huit premiers octets, là où se trouve la chaîne magique, valaient encore zéro. Je n’ai pas testé pourquoi, donc je rapporte seulement ce que j’ai vu.
En mode WAL, deux autres fichiers apparaissent : le fichier -wal, où s’ajoutent les nouvelles versions de pages, et le fichier -shm, un index de 32 Kio en mémoire partagée. Contrairement aux autres modes de journal, le mode WAL est enregistré dans la base : il survit à un redémarrage. Les deux fichiers disparaissent à la fermeture propre de la dernière connexion, comme le dit la documentation.
L’en-tête de 100 octets
L’en-tête, champ par champ, avec les valeurs lues dans la base du laboratoire.
Chaque fichier de base SQLite commence par un en-tête de 100 octets. Les 16 premiers octets sont le texte SQLite format 3 et un octet nul. Viennent ensuite la taille de page, le compteur de changements, la taille en pages, la liste libre, le cookie de schéma, l’encodage du texte et la version de la bibliothèque qui a écrit le fichier. Tous les nombres sont en gros-boutiste.
Deux champs sont à ta disposition. PRAGMA user_version et PRAGMA application_id écrivent directement dans les octets 60 à 71 : après avoir réglé 42 et 1234567890, les octets valent 0000 002a et 4996 02d2. Beaucoup d’outils s’en servent pour marquer le format d’un fichier.
Pages et arbres B dans les fichiers SQLite
Après l’en-tête, le fichier est une suite de pages de même taille, ici 4 096 octets. La page 1 contient l’en-tête et la table de schéma, sqlite_schema. Chaque table est un arbre B classé par son rowid, et chaque index est un autre arbre B. Chaque arbre a une page racine, dont le numéro est dans sqlite_schema. Ce numéro n’a jamais changé pendant mes tests, même quand l’arbre grandissait. La table virtuelle dbstat, compilée dans le paquet Rocky, compte les pages de chaque arbre.
Avec 2 000 lignes, la table a besoin d’une page interne et de 22 feuilles. L’index est un arbre à part, avec une page interne et 14 feuilles. Dans un index, les cellules internes portent aussi des entrées : 13 + 1 987 donnent les 2 000 clés. Une page interne de table ne porte que des clés et des pointeurs vers les enfants.
La forme des arbres mesurée avec dbstat, et la règle de débordement vérifiée avec la documentation.
Une table déclarée WITHOUT ROWID est stockée dans un seul arbre, classé par sa clé primaire. Une table normale avec une clé texte a besoin de deux arbres, la table et un index automatique.
type|name|tbl_name|rootpage
table|kv|kv|45 -- WITHOUT ROWID: one tree
table|kv2|kv2|46 -- rowid table with a TEXT primary key: the table...
index|sqlite_autoindex_kv2_1|kv2|47 -- ...and a second tree for the key
Les grandes valeurs quittent la page. Avec une valeur de 20 000 caractères, la feuille a gardé 3 637 octets et quatre pages de débordement ont porté le reste, 4 092 octets chacune. La documentation donne la règle, avec U = 4 096 et une charge P = 20 005 : X = U – 35 = 4 061 et M = 489. Comme P dépasse X, la partie locale vaut K = M + ((P – M) % (U – 4)) = 3 637. C’est exactement ce que rapporte dbstat.
La page 2 de la base du laboratoire. Les pointeurs croissent depuis le début, les cellules depuis la fin.
Une page d’arbre B a un en-tête de 8 octets sur une feuille (12 sur une page interne), puis un tableau de pointeurs de 2 octets vers les cellules. Les cellules elles-mêmes sont écrites depuis la fin de la page, donc l’espace libre se trouve au milieu. Sur la page 2 du laboratoire, le premier octet est 0d (une feuille de table), il y a 2 cellules, et le contenu commence au décalage 4 038.
L’espace libéré dans la zone de contenu devient un bloc libre, une petite liste chaînée dans la page. Les trous de 1 à 3 octets sont seulement comptés comme octets fragmentés dans l’en-tête.
Un enregistrement, octet par octet
Une cellule contient une taille de données, un rowid et un enregistrement. Les tailles et les rowids sont des entiers variables : 1 à 9 octets, 7 bits à la fois, et le bit de poids fort signifie qu’un octet suit. L’enregistrement commence par la taille de son en-tête, puis un type par colonne. Un type de 13 ou plus et impair désigne du texte, et la longueur vaut (type – 13) / 2. Le type 0 désigne NULL. Ici la colonne id est le rowid : SQLite stocke NULL et garde la valeur dans la clé de la cellule.
1d 01 05 00 17 2f 11 41 6c 69 63 65 61 6c 69 63 65 40 65 78 61 6d 70 6c 65 2e 6e 65 74 46 52
| | | | | | '----- 'Alice' 'alice@example.net' 'FR' (5 + 17 + 2 bytes)
| | | | | '-- serial type 17: text of 2 bytes
| | | | '----- serial type 47: text of 17 bytes
| | | '-------- serial type 23: text of 5 bytes
| | '----------- serial type 0: NULL (id is the rowid)
| '-------------- record header: 5 bytes
'----------------- rowid 1, payload of 29 bytes (1d is the payload size, 01 the rowid)
SELECT rowid, id, name, email, country FROM customers renvoie 1|1|Alice|alice@example.net|FR : le décodage est juste.
UPDATE et DELETE sur le disque
Rien dans les fichiers SQLite ne fonctionne comme un stock d’annulation. SQLite modifie la page elle-même, et il garde l’ancienne version dans le journal ou le WAL. Chaque changement validé incrémente le compteur de changements dans l’en-tête. Un UPDATE de même taille a réécrit la valeur sur place, avec le même nombre de cellules et le même espace libre, mais le compteur est passé de 7 à 8.
-- page 3 is the first leaf of customers
before change counter 7 page 3: ncell 102, payload 3655, unused 25 page_count 39
same-size UPDATE counter 8 page 3: ncell 102, payload 3655, unused 25
larger UPDATE counter 9 page 3: ncell 89, payload 3242, unused 490 page_count 40
L’UPDATE plus grand a fait davantage. La page était presque pleine, donc elle a dû se diviser : la page 3 est passée de 102 à 89 cellules, le fichier a gagné une page et la racine un pointeur. En bref, une ligne qui grossit peut coûter une page.
Un DELETE libère la cellule et transforme son espace en bloc libre. Le paquet Rocky est compilé avec SECURE_DELETE, donc PRAGMA secure_delete renvoie 1 et les octets libérés sont mis à zéro. Avec le réglage désactivé, deux occurrences de l’adresse e-mail sont restées dans le fichier.
$ sqlite3 del.db "PRAGMA secure_delete;"
1
-- page 3 header after DELETE FROM customers WHERE id=5:
cells 101 | first freeblock offset 3921 | fragmented bytes 0
freeblock at 3921: next 0, size 36 bytes
its bytes: 00 00 00 24 00 00 00 00 00 00 00 00 [...] 00 00 00 00 <- zeroed
$ grep -a -c "user5@example.net" del.db # secure_delete on
0
$ sqlite3 del2.db "PRAGMA secure_delete=OFF; DELETE FROM customers WHERE id=5;"
$ grep -a -c "user5@example.net" del2.db # secure_delete off
2
Pourquoi les fichiers SQLite ne rétrécissent pas
Quand une page entière se vide, elle rejoint la liste libre, que décrivent les octets 32 à 39 de l’en-tête. Le fichier garde sa taille. Après un DELETE de 1 800 lignes, 31 pages sur 39 étaient libres, et le fichier faisait toujours 159 744 octets. VACUUM l’a reconstruit en 32 768 octets.
Avec auto_vacuum en mode incrémental, on peut rendre des pages par étapes. L’octet 52 de l’en-tête contient alors la plus grande page racine, et l’octet 64 passe à 1. La page 2 devient une table de pointeurs, faite d’entrées de 5 octets : un type et une page parente. dbstat saute cette page.
Un type de page est documenté mais non testé ici : la page d’octet verrou, qui n’existe que si le fichier dépasse 1 Gio.
Le WAL
Trois trames mesurées dans le WAL, puis des points de contrôle PASSIVE et TRUNCATE.
En mode WAL, les fichiers SQLite changent dans un autre ordre : un écrivain ajoute d’abord des trames au fichier -wal. Chaque trame est un en-tête de 24 octets suivi d’une page. Une trame dont le champ de taille n’est pas nul est un commit. Les lecteurs utilisent le dernier commit et l’index shm pour trouver la bonne version de chaque page.
-rw-r--r--. 1 root root 159744 Nov 2 21:23 wal.db
-rw-r--r--. 1 root root 32768 Nov 2 21:23 wal.db-shm
-rw-r--r--. 1 root root 12392 Nov 2 21:23 wal.db-wal
Un point de contrôle copie les trames dans le fichier principal. Un point de contrôle PASSIVE l’a fait sans toucher au fichier WAL, alors que TRUNCATE l’a vidé. J’ai désactivé le point de contrôle automatique pour garder les trames visibles ; par défaut, il se déclenche à 1 000 pages.
PRAGMA wal_checkpoint(PASSIVE); -> 0|3|3 (busy, frames in the WAL, frames copied) -wal still 12392 bytes
PRAGMA wal_checkpoint(TRUNCATE); -> 0|0|0 -wal now 0 bytes
last connection closes -> demo.db-wal and demo.db-shm are gone
Les quatre moteurs côte à côte
Voici comment les fichiers SQLite se comparent aux trois premières parties, avec les valeurs par défaut.
InnoDB
PostgreSQL
Oracle
SQLite
Où vivent les données
fichiers de tablespace, un par table par défaut
un fichier par table ou index, en segments
datafiles dans des tablespaces
un seul fichier pour tout
Unité d’E/S
page de 16 Ko
page de 8 Ko
bloc, 8 Ko par défaut
page, 4 096 octets ici
Format d’une ligne
index clusterisé : la table est l’arbre de la clé primaire
tas, les index y pointent
bloc avec un répertoire de lignes et un ROWID
arbre B classé par rowid, ou par clé pour WITHOUT ROWID
Anciennes versions
journaux d’annulation, à part
dans la table, nettoyées par VACUUM
tablespace d’annulation
aucune : le journal ou le WAL les garde jusqu’au commit
Sécurité en cas de panne
journal de reprise et doublewrite buffer
WAL et écriture de pages complètes
journaux de reprise
journal de retour arrière ou WAL
Récupérer de la place
reconstruire la table
VACUUM, VACUUM FULL
shrink, move
VACUUM, incremental_vacuum
Le plus proche cousin de SQLite ici est PostgreSQL pour le WAL, et InnoDB pour l’arbre. La grande différence est qu’aucun processus serveur ne se tient entre ton programme et le fichier : le format du fichier et les verrous de fichier du système portent les garanties.
Les sorties enregistrées viennent de mon propre laboratoire : Rocky Linux 10.2, SQLite 3.46.1, WSL2.
Cet article fait partie d’une série de notes d’admin système (étiquette « carnet d’admin »). Série « Dans les fichiers d’une base de données », partie 4/3+1. · ← Article précédent
En bref : j’ai fait un kill -9 sur les processus postgres : une colonne identity PostgreSQL est passée de l’id 9 à 37, parce que les séquences PostgreSQL journalisent 32 valeurs d’avance dans le WAL. Rollbacks et insertions en échec brûlent aussi des valeurs, et une colonne BY DEFAULT ignore les id explicites. Préférer GENERATED ... AS IDENTITY à serial, et ne jamais compter sur des id continus.
PostgreSQL 18.6, cluster jetable sous Rocky Linux 10.2. Partie 1 sur 5 (PostgreSQL, MariaDB, Oracle, SQL Server, résumé). Toutes les sorties viennent de mes propres tests.
Un kill -9 sur PostgreSQL 18.6 : 27 valeurs réservées et jamais distribuées.
Trois façons d’obtenir une valeur générée
CREATE TABLE t_ident (id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY, v text);
CREATE TABLE t_serial (id serial PRIMARY KEY, v text);
CREATE SEQUENCE s1;
CREATE TABLE t_seq (id int PRIMARY KEY DEFAULT nextval('s1'), v text);
Chaque INSERT ... RETURNING id renvoie 1 sur une table neuve. Le catalogue montre la différence :
column_name | column_default | is_identity | identity_generation
-------------+--------------------------------------+-------------+---------------------
id | | YES | ALWAYS
id | nextval('s1'::regclass) | NO |
id | nextval('t_serial_id_seq'::regclass) | NO |
Une colonne identity n’a pas d’expression par défaut ; la séquence est attachée à la colonne (pg_get_serial_sequence('t_ident','id') renvoie public.t_ident_id_seq). serial est un raccourci : colonne entière plus défaut appelant nextval.
ALWAYS ou BY DEFAULT
Avec GENERATED BY DEFAULT, une valeur explicite est acceptée mais ne fait pas avancer le compteur :
INSERT INTO t_by(id, v) VALUES (2, 'explicit 2'); -- ok
INSERT INTO t_by(v) VALUES ('default 1') RETURNING id; -- 1
INSERT INTO t_by(v) VALUES ('default 2') RETURNING id; -- ERROR: duplicate key ... (id)=(2) already exists
INSERT INTO t_by(v) VALUES ('default 3') RETURNING id; -- 3
L’insertion en échec a brûlé la valeur 2 du compteur. Avec GENERATED ALWAYS, une valeur explicite est refusée sauf demande expresse :
ERROR: cannot insert a non-DEFAULT value into column "id"
DETAIL: Column "id" is an identity column defined as GENERATED ALWAYS.
HINT: Use OVERRIDING SYSTEM VALUE to override.
Après OVERRIDING SYSTEM VALUE VALUES (50, ...), l’insertion par défaut suivante a renvoyé 1 : le compteur est indépendant des données.
Idem avec une violation d’unicité : id 1, erreur sur le doublon, puis 3. Un compteur sans trou demande une autre conception (table de compteur verrouillée), au prix de la sérialisation des écritures.
Concurrence : l’ordre des id n’est pas l’ordre des commits
La session A insère en premier et valide 3 secondes après B ; la session B insère en second et valide aussitôt :
A got id 1
B got id 2
B: committed 08:18:48.652
A: committed 08:18:51.154
La ligne de B est visible avant celle de A, bien que son id soit plus grand. Ne pas utiliser « max(id) déjà vu » comme curseur de réplication ou de polling.
CACHE est par session
CREATE SEQUENCE s_cache CACHE 10;
same session twice : 1|2
new session : 11
another new session: 21
Avec les séquences PostgreSQL, chaque session réserve un bloc de 10. Les valeurs de sessions différentes s’entrelacent et des trous apparaissent dès qu’une session se termine.
Limites et CYCLE
ERROR: nextval: reached maximum value of sequence "s_small" (3)
Après ALTER SEQUENCE s_small CYCLE, la valeur suivante est 1. Une identity sur smallint s’arrête à 32767 avec la même erreur sur t_small_id_seq. Les identity integer s’arrêtent à 2147483647 : prendre bigint pour les tables à longue vie.
Redémarrage et crash
Trois lignes insérées, la dernière supprimée, redémarrage propre :
sequence before the restart: 3|30|t (last_value | log_cnt | is_called)
sequence after the restart: 3|30|t
new id = 4
Puis cinq lignes de plus (dernier id 9), kill -9 de tous les processus postgres, redémarrage :
sequence before the crash: 9|27|t
sequence after the crash: 36|0|t
new id = 37
ids now: 1,2,4,5,6,7,8,9,37
log_cnt est le nombre de valeurs encore couvertes par le dernier enregistrement WAL. PostgreSQL écrit un enregistrement WAL pour 32 valeurs ; après la récupération sur crash, il repart de la fin de la plage réservée (9 + 27 = 36), donc l’id suivant est 37. Un arrêt propre écrit l’état exact, sans trou.
Où lire l’état des séquences PostgreSQL
SELECT * FROM pg_sequences; -- départ, incrément, max, cache_size, cycle, last_value
SELECT last_value, log_cnt, is_called FROM t_ident_id_seq;
SELECT attrelid::regclass, attname, attidentity FROM pg_attribute WHERE attidentity <> ''; -- 'a' ALWAYS, 'd' BY DEFAULT
J’ai lancé un script en lecture seule sur ma propre machine pour regarder à l’intérieur d’une table LanceDB, et un assistant IA (Claude, d’Anthropic) m’a aidée à écrire ce script et à rédiger cet article. Les chiffres ci-dessous sont la sortie réelle de ce lancement, avec LanceDB 0.41.0 et pyarrow 26.0.1. J’ai remplacé le nom de la table et les chemins. C’est le premier article d’une série sur LanceDB vu par une DBA.
Réponse rapide
Une table LanceDB est un répertoire, et rien d’autre : des fichiers de données dans data/, un manifeste par version dans _versions/, un fichier de transaction par validation dans _transactions/, et un répertoire _indices/ dès qu’on construit un index. Ma table contient 1 130 595 lignes de vecteurs à 1024 dimensions et pèse 13,4 Gio sur disque, mais sa version courante, la 265, ne référence qu’un seul fichier de données de 5,8 Gio. Les 7,6 Gio restants environ sont répartis dans 259 anciens fichiers de données dont aucune version courante n’a besoin, et ils restent sur disque tant que personne ne nettoie les anciennes versions. Chaque ajout crée un nouveau fichier de données et une nouvelle version, c’est ainsi qu’une table atteint 265 versions et 260 fichiers de données, et la dernière version a fusionné 226 fragments en un sans supprimer les anciens fichiers.
Versions 263 à 265 et part de data/ que lit la version courante.
Ce que contient le répertoire d’une table LanceDB
La première partie de mon script parcourt le répertoire de la table LanceDB, docs_index.lance, et n’a pas besoin de LanceDB. L’arborescence contient trois sous-répertoires. La documentation de l’agencement des fichiers en liste d’autres, dont deux, _indices/ et _deletions/, sont absents de ma table.
Répertoire
Ce que dit la documentation
Ce que j’ai trouvé
data/
fichiers de données (*.lance)
260 fichiers, 13,4 Gio, médiane 21,6 Mio, le plus gros 5,8 Gio
_versions/
manifestes (*.manifest) et un latest_version_hint.json optionnel
265 manifestes, 2,4 Mio au total, plus un fichier d’indice de 15 octets
_transactions/
fichiers de transaction (*.txn)
265 fichiers, 56,7 Kio au total, médiane 132 octets
_indices/
un dossier par index
absent
La table compte 791 fichiers en tout. Les statistiques donnent num_indices: 0 et num_deleted_rows: 0, ce qui correspond à l’absence de ces deux répertoires : je n’ai jamais construit d’index et jamais supprimé de ligne.
Une version, c’est un manifeste et une transaction
Le manifeste est le cœur du format. La documentation le décrit comme la description immuable d’une version unique, avec le schéma, la liste des fragments, le numéro de version, un horodatage et une référence à la transaction qui l’a créée. Ma table a 265 manifestes pour 265 versions, donc exactement un manifeste par version, et 265 fichiers de transaction, donc chaque validation en a laissé un.
Les noms portent de l’information. Un fichier de transaction s’appelle {read_version}-{uuid}.txn, où le nombre est la version à partir de laquelle la transaction a été construite, et le plus gros de ma table est 263-45522501-…txn, de 20,1 Kio. Les manifestes déroutent au début, mais la logique est simple : le fichier de la version 265 est 18446744073709551350.manifest. Ce nombre est 18446744073709551615, le plus grand entier non signé sur 64 bits, moins 265, et la version 1 est 18446744073709551614.manifest. Les noms décroissent donc quand les versions augmentent. Les pages de documentation que j’ai lues n’expliquent pas ce schéma, donc ma lecture n’est qu’une hypothèse : la version la plus récente arrive en premier dans un listing de répertoire. Le fichier latest_version_hint.json fait 15 octets. La documentation parle d’une « simple optimisation », destinée à accélérer la recherche de la dernière version sur les stockages où lister un répertoire coûte cher.
Chaque ajout crée un fragment
Les cinq dernières versions montrent comment la table a grossi. list_versions a renvoyé ces lignes, que j’ai réduites aux champs intéressants et alignées en tableau :
De la 261 à la 263, chaque version ajoute un fichier de données et quelques milliers de lignes : 4 128 lignes pour le premier ajout et 3 435 pour le second. C’est l’opération Append de la documentation, qui ajoute de nouveaux fragments sans modifier les données existantes. De petits ajouts produisent donc de petits fichiers de données, et une table alimentée par petits lots accumule les fragments : la mienne en avait 226, et le fichier de données médian fait 21,6 Mio.
Les versions 264 et 265 sont séparées de 33 millisecondes, et le nombre de lignes ne change pas. J’ai décodé leurs transactions avec les définitions protobuf publiées dans le dépôt Lance. La version 264 est une transaction reserve_fragments de 46 octets : elle réserve un identifiant de fragment, et le max_fragment_id du manifeste passe de 258 à 259 alors que les 226 fragments restent les mêmes. La version 265 est une transaction rewrite, le compactage : elle remplace les 226 anciens fragments par un nouveau fragment, d’identifiant 259, qui contient les 1 130 595 lignes. La documentation décrit une Rewrite comme une opération qui « réorganise les données sans modification sémantique », et c’est ce que montre le fichier. Les versions 21 et 33 de la même table contiennent des réécritures plus anciennes, de 21 fragments en un et de 6 en un, et la première version est un overwrite de 329 octets.
La version courante d’une table LanceDB est plus petite que la table
Les statistiques de la version 265 donnent la taille de ce dont la table a réellement besoin :
Cela fait 5,8 Gio, soit environ 5 487 octets par ligne. La colonne de vecteurs contient 1024 flottants de 4 octets, donc 4 096 octets par ligne, et les 1 391 octets restants par ligne contiennent le texte, le titre, le chemin et le numéro de segment. Le répertoire data/ pèse 13,4 Gio, ce qui laisse environ 7,6 Gio dans 259 fichiers de données que la version 265 ne référence pas. Ce sont les fragments des versions précédentes, toujours là parce que rien ne les a supprimés. Dans cette table, la fusion des fragments n’a pas réduit le répertoire : le nouveau fichier a été ajouté et les anciens sont restés en place, toujours référencés par les anciennes versions.
Pourquoi le manifeste d’un seul fragment pèse 20,5 Kio
Les manifestes grossissent avec le nombre de fragments : 628 et 736 octets pour les premiers, 20,4 Kio à 226 fragments, soit environ 90 octets par fragment. La version 265 n’a qu’un fragment et son fichier fait pourtant 21 031 octets, et je l’ai décodé pour comprendre pourquoi. Le fichier commence par une longueur sur 4 octets et une copie de la transaction, puis une seconde longueur et le manifeste proprement dit, puis un pied de 16 octets qui se termine par la marque LANC. Le manifeste proprement dit fait 391 octets. Les 20 620 autres octets sont la transaction intégrée, la même réécriture que celle stockée dans 263-45522501-…txn, qui liste les 226 fragments qu’elle remplace, à 88,6 octets chacun en moyenne. À titre de comparaison, les manifestes des versions 263 et 264 font chacun 20,8 Kio de liste de fragments, plus une petite transaction intégrée de 136 et 50 octets.
Les 20 Kio du dernier manifeste ne décrivent donc pas la version courante. Ils enregistrent ce que le compactage a retiré de la table, et ils sont présents deux fois sur disque, dans le manifeste et dans le fichier de transaction. Ce champ s’appelle transaction_section dans le protobuf. Je n’ai pas trouvé cela décrit dans les pages du format que j’ai lues.
Ce que je n’ai pas testé
Je n’ai lancé aucune opération d’écriture sur la table d’origine, parce que je voulais la laisser intacte : ni compactage, ni nettoyage des anciennes versions, ni index. Le script ne fait que lister des fichiers et appeler count_rows, schema, list_versions, list_indices et stats. J’ai décodé les manifestes et les fichiers de transaction uniquement pour les versions 1, 21, 33 et 263 à 265, et uniquement sur une copie de la table. Je n’ai pas non plus mesuré de temps de requête, qui dépend de l’index et viendra dans un prochain article.
systemd-analyze security est la commande que je lance avant de modifier quoi que ce soit dans une unité systemd. Elle attribue à chaque service une note d’exposition, liste les options de sandboxing que l’unité n’utilise pas encore et indique par où commencer. Cet article est la partie 1 d’une série de huit sur le durcissement de systemd, directive par directive. Je couvre ici la méthode : comment fonctionnent les drop-ins, comment lire la sortie de systemd-analyze security, comment tester une modification sans risque et quels services traiter en premier.
Les parties suivantes passent en revue les directives, une famille à la fois. Pour chaque directive, j’explique ce qu’elle fait, quels services de ma stack elle touche et ce qu’elle peut casser. Cette première partie reste sur la méthode, parce que toutes les suivantes s’appuient dessus. Aucune expérience de systemd n’est nécessaire : chaque étape vient avec la commande complète, un commentaire sur chaque ligne et un petit lab sur un service de démonstration inoffensif.
Banc de test. Toutes les sorties de cet article viennent d’exécutions réelles sur Rocky Linux 10.2 avec systemd 257, dans une machine virtuelle WSL2 (noyau 6.18.54.1-microsoft-standard-WSL2, session root). SELinux y est désactivé et certaines fonctions du noyau peuvent différer d’un serveur classique. Je le signale à chaque endroit où cela compte. Quand je coupe des lignes d’une sortie, je les remplace par [...], et je marque non testées les commandes que je n’ai pas lancées.
Avant de commencer
Lisez d’abord cette courte liste. Elle vous évite l’erreur classique : durcir un service de production du premier coup.
Une machine de test. Utilisez une machine virtuelle ou un serveur de rechange sous systemd. J’ai tout exécuté sur Rocky Linux 10.2 uniquement ; ces commandes appartiennent à systemd lui-même et devraient se comporter de la même façon sur d’autres distributions, mais je ne l’ai pas testé.
Un shell avec les droits sudo. Chaque commande qui modifie le système commence par sudo.
Un service inoffensif pour vos premiers essais. Le lab ci-dessous crée un service de démonstration nommé demo-hardening-sandbox.service : vous ne touchez donc à aucun vrai service.
Un moyen de tout annuler. Chaque changement vit dans un seul fichier drop-in, et supprimer ce fichier annule le changement. Le lab se termine par une étape de nettoyage qui supprime tout ce qu’il a créé.
Un systemd récent. Plusieurs options de systemd-analyze security sont apparues dans la version 250 : vérifiez donc la vôtre avec systemctl --version.
Un court glossaire
Terme
Sens dans cet article
Unité
Un fichier qui décrit ce que systemd gère. Une unité de type service se termine par .service.
Service
Une unité qui lance un programme : un démon qui reste actif, ou une commande qui s’exécute une fois puis se termine.
Drop-in
Un petit fichier .conf placé dans un répertoire .d, qui modifie une unité sans toucher au fichier d’origine.
Directive
Un réglage d’un fichier d’unité, par exemple ProtectSystem=strict.
Sandboxing
Les directives qui limitent ce qu’un service peut voir et faire.
Namespace
Une fonction du noyau qui donne à un processus sa propre vue du système, par exemple ses propres points de montage. Plusieurs directives s’appuient dessus.
Capability
Une part des pouvoirs de root, par exemple ouvrir un port bas, que le noyau peut accorder séparément.
Appel système
Une requête d’un programme au noyau, par exemple ouvrir un fichier.
Niveau d’exposition
La note que systemd-analyze security calcule pour une unité.
Unité transitoire
Une unité temporaire que systemd-run crée à la volée ; elle disparaît quand elle s’arrête.
En un coup d’œil
Étape
Commande
Ce qu’elle vous apprend
Noter tous les services actifs
systemd-analyze security
Une ligne par service chargé, avec un niveau d’exposition
Détailler un service
systemd-analyze security UNITÉ
Chaque réglage, appliqué (✓) ou non (✗), avec son exposition
Contrôler un fichier avant de le déployer
systemd-analyze verify FICHIER puis security --offline=true FICHIER
Les erreurs de syntaxe, puis la revue hors ligne
Changer une directive
systemctl edit UNITÉ
Un fichier drop-in, rechargé à l’enregistrement
Expérimenter sans modifier de fichier
systemd-run -p DIRECTIVE=VALEUR ...
Une unité temporaire avec les propriétés de votre choix
Lire un échec
systemctl status UNITÉ et journalctl -u UNITÉ
Le code de sortie, puis les messages du service lui-même
Revenir en arrière
systemctl revert UNITÉ
Supprime les drop-ins de l’unité
Pourquoi durcir les services avec systemd
Un service qui n’a aucune directive de sandboxing tourne sans aucune de ces protections. Le manuel systemd.exec qualifie les options de sandboxing de moyen efficace de limiter l’exposition du système, et recommande d’en activer autant que possible sans empêcher le processus de fonctionner. Mon service de démonstration montre le point de départ : sans aucun sandboxing, systemd-analyze security lui attribue 9,6 et l’étiquette UNSAFE, avec 75 réglages non appliqués et 4 appliqués (vous le mesurerez vous-même dans le lab).
Le sandboxing ne remplace pas les autres couches de défense. En revanche, il en ajoute une qui ne coûte presque rien : quelques lignes dans un drop-in. Deux limites du manuel valent d’être retenues. D’abord, beaucoup d’options sont désactivées sans bruit sur les systèmes où le mécanisme sous-jacent n’existe pas. Ensuite, certaines fonctions de sandboxing ne sont en général pas disponibles dans les services utilisateur.
Comment une unité et ses drop-ins s’assemblent
Un drop-in est la façon la plus sûre de modifier un service. Au lieu d’éditer le fichier installé par un paquet, vous ajoutez un petit fichier à côté, et systemd fusionne les deux. Le schéma montre quel fichier l’emporte.
Quel fichier l'emporte quand une unité systemd et ses drop-ins sont fusionnés.
Où vivent les drop-ins et lequel gagne
Pour une unité nommée foo.service, systemd cherche un répertoire foo.service.d/. Il ne lit que les fichiers qui se terminent par .conf et les fusionne par ordre alphanumérique après avoir lu le fichier principal de l’unité. Chaque drop-in doit contenir son propre en-tête de section, par exemple [Service].
Emplacement
Rôle
Priorité
/usr/lib/systemd/system/
Unités et drop-ins fournis par les paquets
La plus basse
/run/systemd/system/
Changements d’exécution, perdus au redémarrage
Intermédiaire
/etc/systemd/system/
Vos propres unités et drop-ins
La plus haute
Le manuel ajoute trois règles. Les drop-ins de /etc/ l’emportent sur ceux de /run/, qui l’emportent sur ceux de /usr/lib/. Les drop-ins l’emportent sur les fichiers d’unité, où qu’ils se trouvent. À niveau égal, les fichiers de noms différents s’appliquent dans l’ordre lexicographique. Cette dernière règle explique pourquoi je donne à mes fichiers un nom qui indique leur rôle, comme hardening.conf.
Créer, vérifier et annuler un drop-in
Le lab ci-dessous lance les vraies commandes dans l’ordre, avec leur sortie : systemctl edit pour créer un drop-in, systemctl cat pour voir ce que systemd lit, systemctl show pour voir ce qu’il a chargé, et systemctl revert pour l’annuler. Un point mérite l’attention. systemctl edit recharge la configuration à votre place, et un rechargement conserve l’état d’exécution. Un service déjà lancé garde donc son ancien sandbox jusqu’à son redémarrage. L’erreur 4 du lab le démontre.
Lire la sortie de systemd-analyze security
L’outil systemd-analyze security analyse les réglages de sécurité et de sandboxing des unités de service. Il ne démarre rien. Il lit les réglages, puis les compare aux protections que systemd sait appliquer.
Lire la sortie de systemd-analyze security, ligne par ligne.
Un service ou tout le système
Sans nom d’unité, systemd-analyze security inspecte tous les services chargés qui tournent durablement et affiche un tableau concis, une ligne par unité. Avec un ou plusieurs noms d’unité, il affiche une analyse détaillée de chacune. Voici le tableau de tout le système, coupé après quatre lignes :
systemd-analyze security --no-pager
UNIT EXPOSURE PREDICATE HAPPY
NetworkManager.service 7.8 EXPOSED 🙁
auditd.service 9.4 UNSAFE 😨
cockpit-wsinstance-http.service 5.4 MEDIUM 😐
cockpit.service 6.2 MEDIUM 😐
[...]
Les colonnes sont l’unité, son niveau d’exposition, une étiquette (le « predicate ») et un visage. Sur le banc de test, j’ai vu les étiquettes MEDIUM, EXPOSED et UNSAFE ; le manuel ne les définit pas. Fiez-vous au nombre, et voyez l’étiquette comme un repère visuel rapide.
Le tableau, ligne par ligne
Pour un service, la sortie a trois colonnes : NAME, DESCRIPTION et EXPOSURE. Chaque ligne commence par une marque : ✗ signifie que le réglage n’est pas appliqué, ✓ qu’il l’est. La dernière ligne donne le résultat global. Voici le début du tableau pour le service de démonstration du lab, avant tout sandboxing :
systemd-analyze security --no-pager demo-hardening-sandbox.service | head -n 6
NAME DESCRIPTION EXPOSURE
✗ RootDirectory=/RootImage= Service runs within the host's root directory 0.1
SupplementaryGroups= Service runs as root, option does not matter
RemoveIPC= Service runs as root, option does not apply
✗ User=/DynamicUser= Service runs as root user 0.4
✗ CapabilityBoundingSet=~CAP_SYS_TIME Service processes may change the system clock 0.2
Lisez-le comme une liste de tâches. Chaque ligne ✗ est une protection que vous pouvez encore ajouter, et le nombre à droite indique l’exposition qu’elle retirerait. Les lignes sans marque et sans nombre ne s’appliquent pas à cette unité : l’outil vous dit par exemple que RemoveIPC= n’a pas d’importance quand le service tourne en root. Pour ne garder que le travail restant, filtrez la sortie :
✗ RootDirectory=/RootImage= Service runs within the host's root directory 0.1
✗ User=/DynamicUser= Service runs as root user 0.4
✗ CapabilityBoundingSet=~CAP_SYS_TIME Service processes may change the system clock 0.2
✗ NoNewPrivileges= Service processes may acquire new privileges 0.2
Ce que la note signifie, et ce qu’elle ne signifie pas
Le manuel décrit le niveau d’exposition global comme une estimation de 0,0 à 10,0. Une note élevée signale peu de sandboxing, une note basse des restrictions serrées. Soyez prudent avec ce nombre, car le manuel est explicite sur ses limites :
Il ne couvre que les fonctions de sécurité par service que systemd implémente lui-même. Il ignore ce que le programme fait pour se protéger, et il ignore SELinux.
Une note élevée ne veut pas dire que le service est vulnérable, et une note basse ne prouve pas qu’il est sûr. Elle indique seulement que le service pourrait profiter de réglages supplémentaires.
Beaucoup de réglages peuvent être annulés individuellement par d’autres réglages. L’outil tient compte de certaines combinaisons, pas de toutes.
Les réglages s’appliquent aux opérations propres du service. Les requêtes envoyées par IPC, comme D-Bus, peuvent atteindre des services qui ne sont pas restreints de la même façon : vérifiez donc séparément la politique d’accès IPC.
En pratique, traitez la note de systemd-analyze security comme une boussole, pas comme un objectif. En effet, elle aide à repérer les services sans aucune protection et confirme qu’un changement a produit l’effet voulu.
Les options à connaître
Option
Ce qu’elle fait
Testée ici
--offline=BOOL
Passe en revue des fichiers d’unité sans interroger le gestionnaire en cours d’exécution (ajoutée en version 250)
Oui : fonctionne avec un chemin de fichier et avec un nom d’unité
--threshold=NOMBRE
Renvoie une erreur quand le niveau global dépasse la valeur (ajoutée en 250)
Oui : code de sortie 1 au-dessus du seuil, 0 en dessous
--json=MODE
Affiche le tableau en JSON : off, pretty ou short
Oui : short
--security-policy=CHEMIN
Applique vos propres exigences en JSON ; un poids de 0 signifie « ne pas contrôler » (ajoutée en 250)
Non testée
--profile=CHEMIN
Avec --offline, prend en compte un profil portable (ajoutée en 250)
Non testée
--no-pager
Affiche directement, sans pagineur
Oui
Voici les résultats réels pour le service de démonstration une fois le drop-in du lab en place (sa note est 8,9, comme le montre l’étape 3 du lab). J’ai lancé deux fois --offline, sur le chemin du fichier puis sur le nom de l’unité :
→ Overall exposure level for demo-hardening-sandbox.service: 8.9 EXPOSED 🙁
exit=1
→ Overall exposure level for demo-hardening-sandbox.service: 8.9 EXPOSED 🙁
exit=0
Le mode JSON donne un objet par ligne, avec les champs set, name, json_field, description et exposure. Voici les 330 premiers caractères :
systemd-analyze security --json=short --no-pager demo-hardening-sandbox.service | head -c 330
[{"set":false,"name":"RootDirectory=/RootImage=","json_field":"RootDirectoryOrRootImage","description":"Service runs within the host's root directory","exposure":"0.1"},{"set":null,"name":"SupplementaryGroups=","json_field":"SupplementaryGroups","description":"Service runs as root, option does not matter","exposure":null},{"set"
[...]
Un détail mérite un avertissement. Le manuel dit que le niveau global va de 0,0 à 10,0, mais il documente un seuil par défaut de 100, et précise que --offline renvoie une erreur au-dessus de ce seuil. Le seuil par défaut ne se déclenche donc jamais. Si vous utilisez --threshold dans un script, passez une valeur explicite et testez les deux côtés du seuil, comme je l’ai fait ci-dessus.
Contrôler une unité avant de la déployer
Deux commandes vous aident avant qu’un fichier n’entre dans /etc/systemd/system/. systemd-analyze verify détecte les sections et directives inconnues, les dépendances manquantes, les pages de manuel citées dans Documentation= qui n’existent pas, et les commandes de ExecStart= introuvables ou non exécutables. Ensuite, le mode hors ligne de systemd-analyze security (--offline=true) passe le fichier en revue sans interroger le gestionnaire en cours d’exécution. Voici une exécution propre sur l’unité du lab :
L’absence de message et le code 0 signifient que verify n’a rien à signaler. Cependant, le manuel ne donne aucun exemple de revue hors ligne d’un chemin de fichier. Cela fonctionne sur mon banc de test, mais essayez-le sur une copie avant de vous y fier.
Une boucle de durcissement sûre
Le durcissement tourne mal quand vous changez cinq choses à la fois et ne savez plus laquelle a cassé le service. La boucle ci-dessous l’évite. Elle commence par systemd-analyze security, puis ajoute une directive à la fois, et elle se termine par un test du vrai travail du service.
La boucle de durcissement : une directive à la fois, puis la garder ou l'annuler.
Mesurer. Lancez systemd-analyze security UNITÉ et notez les lignes ✗ que vous comptez corriger.
Changer une directive dans un drop-in avec sudo systemctl edit UNITÉ.
Redémarrer avec sudo systemctl restart UNITÉ. Un simple rechargement ne donne pas son nouveau sandbox à un service déjà lancé.
Lire le résultat avec systemctl status UNITÉ et journalctl -u UNITÉ.
Tester le vrai travail : se connecter, envoyer un fichier, lancer la sauvegarde, envoyer un courrier. Un service peut démarrer et échouer plus tard.
Garder ou annuler. Gardez le drop-in dans votre gestion de configuration, ou supprimez-le, lancez sudo systemctl daemon-reload et redémarrez.
Les deux dernières étapes comptent le plus. Un sandbox peut laisser un service démarrer alors que l’une de ses tâches échoue plus tard, par exemple parce qu’elle a besoin d’un chemin désormais en lecture seule. C’est pourquoi la boucle se termine par un test du vrai travail, et non par un démarrage réussi.
Lire un échec
Quand un sandbox casse un service, le code de sortie et le message du service vous indiquent la directive à assouplir. Voici une unité de démonstration qui tourne avec ProtectSystem=strict et tente de créer un fichier dans /etc. La sortie de status est coupée après le résumé, car les lignes du journal qui suivent portent le nom de ma machine de test :
sudo systemctl start demo-hardening-fail.service
systemctl status --no-pager -n 5 demo-hardening-fail.service
× demo-hardening-fail.service - Demo unit that the sandbox breaks (harmless)
Loaded: loaded (/etc/systemd/system/demo-hardening-fail.service; static)
Active: failed (Result: exit-code) since Mon 2026-11-02 14:54:40 CET; 35ms ago
Invocation: 55376c422cb741a69786c3968cc455a2
Process: 2008 ExecStart=/bin/touch /etc/demo-hardening-fail (code=exited, status=1/FAILURE)
Main PID: 2008 (code=exited, status=1/FAILURE)
Mem peak: 1.2M
CPU: 5ms
[...]
Le journal montre le message du programme lui-même. -u sélectionne l’unité, -o cat n’affiche que le message et -n 6 limite le nombre de lignes :
Starting demo-hardening-fail.service - Demo unit that the sandbox breaks (harmless)...
/bin/touch: cannot touch '/etc/demo-hardening-fail': Read-only file system
demo-hardening-fail.service: Main process exited, code=exited, status=1/FAILURE
demo-hardening-fail.service: Failed with result 'exit-code'.
Failed to start demo-hardening-fail.service - Demo unit that the sandbox breaks (harmless).
Le message dit Read-only file system : la directive responsable est donc ProtectSystem=. La solution est d’autoriser le chemin dont le programme a vraiment besoin avec ReadWritePaths=, que la partie 2 de la série détaille. Les messages de systemd restent en anglais sur le banc de test.
Lab pratique : voir trois directives à l’œuvre
Ce lab prend environ quinze minutes. Il utilise un service de démonstration qui se contente de lister, tester et créer des fichiers, et il supprime tout à la fin. Chaque sortie ci-dessous est une sortie réelle du banc de test. Les lignes qui commencent par --- dans les sorties sont des libellés que mon script de test a affichés entre les commandes, en anglais.
Étape 1 : créer le service de démonstration et le lancer sans sandbox
Créez le fichier /etc/systemd/system/demo-hardening-sandbox.service avec votre éditeur, par exemple sudo nano /etc/systemd/system/demo-hardening-sandbox.service. Les lignes qui commencent par # sont des commentaires. Mettez toujours un commentaire sur sa propre ligne, jamais après un réglage.
# Démonstration inoffensive : compte les entrées de /home, teste une écriture dans /etc et crée un fichier dans /tmp.
[Unit]
Description=Service de démonstration du sandboxing (inoffensif)
[Service]
# oneshot : systemd attend la fin de la commande ; l'unité ne reste jamais « active ».
Type=oneshot
# systemd ne lance pas de shell tout seul : la commande démarre donc /bin/sh explicitement.
ExecStart=/bin/sh -c 'echo "entrées dans /home :"; ls -A /home | wc -l; touch /etc/demo-hardening-test && rm /etc/demo-hardening-test && echo "etc : écriture ok" || echo "etc : écriture refusée"; touch /tmp/demo-hardening-marker; echo "terminé"'
Demandez maintenant à systemd de lire le nouveau fichier, affichez-le, lancez le service et lisez sa sortie. Un service oneshot s’exécute une fois, et systemctl start attend sa fin :
[...]
Starting demo-hardening-sandbox.service - Service de démonstration du sandboxing (inoffensif)...
entrées dans /home :
1
etc : écriture ok
terminé
demo-hardening-sandbox.service: Deactivated successfully.
Finished demo-hardening-sandbox.service - Service de démonstration du sandboxing (inoffensif).
-rw-r--r-- root /tmp/demo-hardening-marker
systemctl cat affiche le fichier précédé d’une ligne de commentaire avec son chemin. Le journal garde les exécutions précédentes du service sur le banc de test (celles de la variante anglaise de ce lab) : j’ai coupé ces anciennes lignes avec [...]. Lisez le résultat. Le service a vu une entrée dans /home, il a pu écrire dans /etc et il a créé son fichier témoin dans le vrai /tmp. Rien n’est encore protégé. Mesurez le point de départ :
✗ ProtectSystem= Service has full access to the OS file hierarchy 0.2
✗ ProtectHome= Service has full access to home directories 0.2
✗ PrivateTmp= Service has access to other software's temporary files 0.2
Étape 2 : ajouter le drop-in
La méthode habituelle est l’éditeur interactif : sudo systemctl edit demo-hardening-sandbox.service ouvre un nouveau fichier nommé override.conf (non testé ici, car il demande un terminal). Pour que le lab reste reproductible, utilisez la forme non interactive. --stdin lit le contenu sur l’entrée standard et --drop-in=hardening choisit le nom du fichier :
sudo systemctl edit --stdin --drop-in=hardening demo-hardening-sandbox.service <<'EOF'
[Service]
# Tout le système de fichiers devient en lecture seule pour ce service (sauf /dev, /proc et /sys).
ProtectSystem=strict
# /home, /root et /run/user deviennent inaccessibles et vides.
ProtectHome=true
# /tmp et /var/tmp deviennent privés à ce service et sont supprimés à son arrêt.
PrivateTmp=true
EOF
Chaque ligne de ce fichier fait une chose. [Service] est l’en-tête de section obligatoire. ProtectSystem=strict monte toute la hiérarchie du système de fichiers en lecture seule, sauf /dev, /proc et /sys. ProtectHome=true rend /home, /root et /run/user inaccessibles et vides. PrivateTmp=true donne au service son propre /tmp et /var/tmp, supprimés à son arrêt. Systemd répond avec le chemin du nouveau fichier :
Vérifiez maintenant ce que systemd lit et ce qu’il a chargé. La sortie de systemctl cat commence par le fichier principal de l’unité ; je coupe cette partie :
systemctl cat demo-hardening-sandbox.service
systemctl show --property=ProtectSystem --property=ProtectHome --property=PrivateTmp demo-hardening-sandbox.service
[...]
# /etc/systemd/system/demo-hardening-sandbox.service.d/hardening.conf
[Service]
# Tout le système de fichiers devient en lecture seule pour ce service (sauf /dev, /proc et /sys).
ProtectSystem=strict
# /home, /root et /run/user deviennent inaccessibles et vides.
ProtectHome=true
# /tmp et /var/tmp deviennent privés à ce service et sont supprimés à son arrêt.
PrivateTmp=true
PrivateTmp=yes
ProtectHome=yes
ProtectSystem=strict
systemctl show affiche yes là où le fichier dit true : les deux désignent le même booléen. Les trois dernières lignes sont les valeurs que systemd a chargées, et celles d’avant sont le fichier. Relancez le service et comparez avec l’étape 1 :
entrées dans /home :
0
touch: cannot touch '/etc/demo-hardening-test': Read-only file system
etc : écriture refusée
terminé
demo-hardening-sandbox.service: Deactivated successfully.
Finished demo-hardening-sandbox.service - Service de démonstration du sandboxing (inoffensif).
ls: cannot access '/tmp/demo-hardening-marker': No such file or directory
Trois choses ont changé, et elles correspondent au manuel. Le service voit maintenant 0 entrée dans /home. Son écriture dans /etc échoue avec Read-only file system. Et le fichier témoin qu’il a créé n’est pas visible depuis l’hôte, car le service a utilisé un /tmp privé.
→ Overall exposure level for demo-hardening-sandbox.service: 8.9 EXPOSED 🙁
✓ ProtectSystem= Service has strict read-only access to the OS file hierarchy
✓ PrivateTmp= Service has no access to other software's temporary files
✓ ProtectHome= Service has no access to home directories
La note de systemd-analyze security est passée de 9,6 UNSAFE à 8,9 EXPOSED, les trois lignes sont passées de ✗ à ✓, et les compteurs sont passés de 75 non appliqués et 4 appliqués à 71 et 8. Trois directives donnent un gain modeste, ce qui est normal : un service root garde beaucoup d’autres expositions, comme l’utilisateur sous lequel il tourne et ses capabilities. Les parties 3 et 4 de la série s’en occupent.
Étape 4 : essayer une directive à la fois avec systemd-run
systemd-run lance une commande comme unité transitoire, et -p fixe une propriété dans le même format que systemctl set-property. Les options ci-dessous sont celles du manuel : --quiet masque le message de démarrage, --pipe relie la commande à l’entrée et à la sortie de votre terminal, --wait attend la fin et --collect décharge l’unité ensuite, même après un échec. (Le manuel documente aussi -t pour un shell interactif ; je ne l’ai pas testé.) Le manuel ne donne aucun exemple de sandboxing pour systemd-run : les résultats ci-dessous sont donc mes propres tests. Les lignes echo affichent les libellés que vous voyez dans la sortie, et chaque ls contrôle ensuite le vrai système de fichiers :
RUN="sudo systemd-run --quiet --pipe --wait --collect"
echo "--- no sandbox: write to /etc"; $RUN /bin/touch /etc/demo-hardening-t1; echo "exit=$?"; ls /etc/demo-hardening-t1; rm -f /etc/demo-hardening-t1
echo "--- ProtectSystem=strict: write to /etc"; $RUN -p ProtectSystem=strict /bin/touch /etc/demo-hardening-t2; echo "exit=$?"; ls /etc/demo-hardening-t2 2>&1
echo "--- ProtectSystem=true: write to /etc, then to /usr"
$RUN -p ProtectSystem=true /bin/touch /etc/demo-hardening-t3; echo "etc exit=$?"; ls /etc/demo-hardening-t3; rm -f /etc/demo-hardening-t3
$RUN -p ProtectSystem=true /bin/touch /usr/demo-hardening-t4; echo "usr exit=$?"; ls /usr/demo-hardening-t4 2>&1
echo "--- ProtectSystem=full: write to /etc"; $RUN -p ProtectSystem=full /bin/touch /etc/demo-hardening-t5; echo "exit=$?"; ls /etc/demo-hardening-t5 2>&1
echo "--- /home entries seen by the service, without and with ProtectHome=true"
$RUN /bin/sh -c 'ls -A /home | wc -l'
$RUN -p ProtectHome=true /bin/sh -c 'ls -A /home | wc -l'
echo "--- ProtectHome=true: ls -ld /home inside the service"; $RUN -p ProtectHome=true /bin/ls -ld /home | awk '{print $1, $3, $4}'
echo "--- PrivateTmp=true: file created in /tmp by the service, seen from the host?"
rm -f /tmp/demo-hardening-t6
$RUN -p PrivateTmp=true /bin/touch /tmp/demo-hardening-t6; echo "service exit=$?"; ls /tmp/demo-hardening-t6 2>&1
echo "--- without PrivateTmp, same test"
$RUN /bin/touch /tmp/demo-hardening-t7; echo "service exit=$?"; ls /tmp/demo-hardening-t7 2>&1; rm -f /tmp/demo-hardening-t7
--- no sandbox: write to /etc
exit=0
/etc/demo-hardening-t1
--- ProtectSystem=strict: write to /etc
/bin/touch: cannot touch '/etc/demo-hardening-t2': Read-only file system
exit=1
ls: cannot access '/etc/demo-hardening-t2': No such file or directory
--- ProtectSystem=true: write to /etc, then to /usr
etc exit=0
/etc/demo-hardening-t3
/bin/touch: cannot touch '/usr/demo-hardening-t4': Read-only file system
usr exit=1
ls: cannot access '/usr/demo-hardening-t4': No such file or directory
--- ProtectSystem=full: write to /etc
/bin/touch: cannot touch '/etc/demo-hardening-t5': Read-only file system
exit=1
ls: cannot access '/etc/demo-hardening-t5': No such file or directory
--- /home entries seen by the service, without and with ProtectHome=true
1
0
--- ProtectHome=true: ls -ld /home inside the service
d--------- root root
--- PrivateTmp=true: file created in /tmp by the service, seen from the host?
service exit=0
ls: cannot access '/tmp/demo-hardening-t6': No such file or directory
--- without PrivateTmp, same test
service exit=0
/tmp/demo-hardening-t7
Ce tableau lit les résultats comme le manuel les décrit :
Directive
Ce que mon test a montré
Manuel
ProtectSystem=true
L’écriture dans /etc fonctionne, l’écriture dans /usr est refusée
/usr et les répertoires du chargeur de démarrage en lecture seule
ProtectSystem=full
L’écriture dans /etc est refusée
/etc en lecture seule également
ProtectSystem=strict
L’écriture dans /etc est refusée
Toute la hiérarchie en lecture seule, sauf /dev, /proc, /sys
ProtectHome=true
0 entrée vue, et /home affiche le mode d---------
Répertoires inaccessibles et vides
PrivateTmp=true
Le fichier créé par le service est absent sur l’hôte
/tmp et /var/tmp non partagés avec les processus extérieurs
Ces résultats confirment le manuel sur mon banc de test. Ils viennent d’un noyau WSL2 : confirmez-les sur votre propre serveur avant de vous appuyer sur une directive en production.
Étape 5 : annuler et nettoyer
systemctl revert supprime les drop-ins d’une unité. Il laisse en place un fichier d’unité qui n’a pas de version fournie par un paquet, comme notre fichier de démonstration. Supprimez ensuite les fichiers de démonstration et prévenez systemd :
La première commande a affiché les deux lignes ci-dessus. Après le revert, le fichier d’unité était toujours là et le répertoire .d avait disparu. Après le nettoyage, j’ai vérifié qu’il ne restait aucune unité de démonstration, aucun fichier dans /tmp, /etc ou /usr, et aucune unité de démonstration chargée (tous les compteurs à 0).
Erreurs courantes
Chaque erreur ci-dessous s’est produite sur le banc de test, avec le message réel. Je les ai reproduites sur une unité jetable nommée demo-hardening-mistakes.service (un service oneshot qui lance /bin/true) et sur un service sleep, puis j’ai supprimé les deux.
Erreur 1 : un drop-in sans en-tête de section
Le drop-in a-no-header.conf ne contenait que ProtectSystem=strict. Systemd a ignoré la ligne, et la valeur chargée est restée no :
Erreur 3 : un commentaire sur la même ligne qu’un réglage
J’ai écrit ProtectSystem=strict # make everything read-only sur une seule ligne. Systemd a lu tout le texte comme la valeur, l’a rejeté et a gardé la valeur par défaut :
ProtectSystem=no
/etc/systemd/system/demo-hardening-mistakes.service.d/c-inline-comment.conf:2: Failed to parse ProtectSystem=strict # make everything read-only, ignoring: Invalid argument
Erreur 4 : un rechargement sans redémarrage
Un service sleep tournait déjà. J’ai ajouté ProtectSystem=strict puis lancé daemon-reload. systemctl show a répondu strict, mais le processus a gardé le même PID et le même namespace de montage que le PID 1. Seul le redémarrage lui a donné un nouveau PID et son propre namespace :
PID=$(systemctl show --property=MainPID --value demo-hardening-sleep.service)
readlink /proc/1/ns/mnt
readlink /proc/$PID/ns/mnt
J’ai lancé ces trois lignes avant le changement, après le rechargement et après le redémarrage. Les résultats, avec leurs libellés, sont :
--- mount namespace of PID 1 and of the service
pid1: mnt:[4026532218]
service: mnt:[4026532218]
--- after drop-in + daemon-reload (no restart)
show ProtectSystem: strict
same PID: 1886 (was 1886)
service: mnt:[4026532218]
--- after systemctl restart
new PID: 1948
pid1: mnt:[4026532218]
service: mnt:[4026532287]
Autres erreurs à éviter
Modifier le fichier du paquet dans /usr/lib/systemd/system/. Utilisez plutôt un drop-in dans /etc/systemd/system/ ; le manuel donne la priorité à /etc/ sur /usr/lib/.
Juger un changement sur « le service démarre ». Testez le vrai travail, comme dans la boucle ci-dessus.
Changer plusieurs directives à la fois. Vous ne saurez pas laquelle a cassé le service.
Viser une note de 0. Le manuel précise qu’une note élevée ne signifie pas qu’un service est vulnérable. Servez-vous de la note de systemd-analyze security pour prioriser.
Utiliser des directives de namespace de montage dans un service utilisateur. Le manuel avertit que certaines fonctions de sandboxing ne sont en général pas disponibles dans les services utilisateur.
Une checklist à suivre
Travaillez d’abord sur une machine de test ou sur un service inoffensif.
Lancez systemd-analyze security UNITÉ et notez la note et les lignes ✗.
Écrivez une directive dans un drop-in sous /etc/systemd/system/UNITÉ.d/, dans un fichier .conf avec un en-tête [Service] et des commentaires sur leur propre ligne.
Lancez systemctl cat UNITÉ pour vérifier que systemd lit votre fichier.
Lancez sudo systemctl restart UNITÉ ; un simple rechargement ne suffit pas pour un service déjà lancé.
Lisez systemctl status UNITÉ et journalctl -u UNITÉ.
Testez le vrai travail du service, pas seulement son démarrage.
Mesurez à nouveau avec systemd-analyze security UNITÉ.
Gardez le drop-in dans votre gestion de configuration, ou supprimez-le avec systemctl revert puis redémarrez.
Recommencez avec la directive suivante.
Les familles de directives des prochaines parties
Les prochaines parties prennent chaque famille à tour de rôle. Chaque nom de directive ci-dessous est documenté dans la page de manuel systemd.exec ; IPAddressAllow= et IPAddressDeny= le sont dans systemd.resource-control.
Quels tours de passe-passe lui restent possibles ?
8
Toute la stack
Une matrice de chaque directive face à chaque service
Que dois-je appliquer où ?
Ma stack, et par où commencer
Le durcissement demande un modèle de menace, même simple. Je pars du principe qu’un attaquant a déjà pris le contrôle du processus d’un service, et je me demande ce que ce processus pourrait faire ensuite : lire des secrets, écrire un mécanisme de persistance, joindre d’autres services ou obtenir plus de privilèges. Chaque directive retire l’une de ces options.
Mes serveurs font tourner une stack auto-hébergée typique, que les parties suivantes couvriront service par service :
un reverse proxy devant chaque application publique, avec son bouncer CrowdSec ;
des applications PHP-FPM, ainsi que des applications Node.js, Python, Go et .NET (un serveur multimédia et un serveur de bibliothèque) ;
MariaDB, PostgreSQL et Valkey, avec pgBackRest pour les sauvegardes PostgreSQL ;
un serveur de messagerie, un serveur SFTP et WebDAV, et une messagerie d’équipe ;
des agents de sécurité et de supervision : un agent SIEM, une protection contre la force brute, un IDS et des exporters de métriques ;
un VPN et une console d’administration web.
Je commence par les services qui réunissent trois critères : ils sont joignables depuis Internet, directement ou derrière le reverse proxy ; des tiers les ont écrits ; et ils n’ont aucune politique SELinux propre. Ce sont les applications web. Les bases de données et les agents viennent après, car le réseau ne peut pas les joindre directement.
Ce que je retiens
Mesurez avant de changer. systemd-analyze security donne une note et une liste de tâches, sans rien modifier du service.
Utilisez des drop-ins. Ils résistent mieux aux mises à jour de paquets qu’une modification du fichier du paquet, et en supprimer un suffit pour revenir en arrière.
Redémarrez après un changement. Un simple rechargement laisse un processus déjà lancé dans son ancien sandbox.
Changez une directive à la fois et testez le vrai travail du service.
Voyez la note comme une boussole. Elle ne compte que ce que systemd restreint lui-même.
Testez les affirmations. Chaque résultat de cet article vient d’une exécution réelle ; lancez le lab vous-même et comparez.
J’indique pour chaque affirmation sa source : Haas, Eisentraut, les pages du projet, un site d’actualité français, Numerama (une source secondaire), ou mon propre comptage. Les chiffres d’un billet restent « selon le billet ». J’ai lu les pages du projet début novembre 2026, et les dates postérieures au 5 novembre sont des prévisions, pas des faits.
Ce que dit Haas sur le retard de PostgreSQL 19
Haas écrit que les versions majeures de la dernière décennie sont sorties entre la mi-septembre et la mi-octobre. Cette année, dit-il, PostgreSQL 19 sortira très probablement en novembre, et un nouveau report n’est pas exclu. Il note aussi que trois grandes fonctionnalités ont quitté la version entre le 27 août et le 15 septembre : UPDATE/DELETE FOR PORTION OF, les requêtes de graphes de propriétés SQL (SQL/PGQ) et ALTER TABLE … MERGE/SPLIT PARTITION. Le gel des fonctionnalités, ajoute-t-il, tombe chaque année le 8 avril.
Son explication, c’est le volume. Selon le billet, les LLM ont rendu les vieux bogues bien plus faciles à trouver, alors que les corriger n’est devenu plus facile que dans une moindre mesure. Beaucoup de gens signalent maintenant des bogues trouvés avec des LLM, et les committers sont très occupés à les corriger. Il n’accuse pas davantage d’erreurs des committers, car il juge peu probable que ce soit la cause principale. Il pense plutôt que les committers sont restés aussi prudents qu’avant et que les LLM ont repéré vite une plus grande part de leurs erreurs.
Il donne ensuite deux raisons aux retraits tardifs. D’abord, les LLM peuvent rédiger des correctifs, mais ceux-ci ne sont pas toujours corrects, donc des humains relisent et valident encore. Ensuite, le temps qui aurait servi à relire la version est parti dans les problèmes de sécurité et les bogues des anciennes branches. À ses yeux, le retard n’est pas particulièrement inquiétant. En revanche, il juge le long terme moins clair : comment le projet rattrapera-t-il des décennies de bogues tout en continuant d’innover ? Il écrit qu’il ne sait pas encore.
Tel que le billet le décrit : un flux qualitatif, sans chiffres.
Ce que Haas n’affirme pas
Il n’affirme pas que les correctifs écrits par des LLM sont fiables.
Il ne donne pas de date de sortie ferme, et il ne compte pas combien de bogues les LLM ont trouvés au total.
Il n’explique pas pourquoi chacune des trois fonctionnalités a été retirée.
Il juge toujours le code de PostgreSQL d’une très grande qualité.
Ce que dit Eisentraut sur les retraits
Eisentraut a écrit plus tôt et donne un tableau plus large. Il dit qu’environ 8 à 10 fonctionnalités importantes, selon la façon de compter, ont quitté la branche de PostgreSQL 19 après le début de la bêta, et qu’il était développeur ou committer de certaines. Il présente son billet comme un ensemble d’opinions personnelles, et il estime que les causes sont probablement une combinaison, différente pour chaque fonctionnalité.
La qualité du code : peut-être que le code n’était pas assez bon, et il dit que l’équipe en tirera des leçons.
Des fonctionnalités trop dures à terminer : certaines avaient des défauts d’architecture difficiles à corriger pendant la bêta.
Une longue traîne de bogues de cas limites : la relecture de code assistée par LLM les trouve désormais bien plus vite, et il donne l’exemple d’une liste de « une quarantaine de rapports de défauts ». Cette phrase illustre l’idée. Ce n’est pas une donnée mesurée.
Moins de relecture : les développeurs seniors ont passé d’avril à août sur des correctifs de sécurité, donc la relecture habituelle de la bêta a diminué.
La complexité : dans PostgreSQL, « tout fonctionne avec tout », ce qui rend certaines fonctionnalités très difficiles à réussir.
Pas de statut expérimental : la communauté en discute, mais elle n’a pas encore de moyen de le faire.
Point important : il dit que la situation ne vient pas du « vibe coding », car le code retiré est plus ancien que cela. Selon lui, la relecture par IA a relevé la barre de qualité après l’écriture du code. Il ajoute que cela devrait se résorber dans les prochains cycles, sauf si la relecture par LLM continue de progresser au rythme actuel. Il dit aussi que la communauté a préféré tenir le calendrier plutôt que de le repousser. Il s’attend à ce que beaucoup de fonctionnalités retirées reviennent d’ici peu. Enfin, il cite REPACK CONCURRENTLY, la réplication logique des séquences et pg_plan_advice parmi les points forts, et il évoque quelques fonctionnalités prêtes à environ 80 % pour PostgreSQL 20.
Ce que le projet a publié lui-même
Pour le retard de PostgreSQL 19, le plus important est l’annonce de la version candidate du 15 octobre 2026, qui donne un calendrier. La prochaine mise à jour planifiée, le 12 novembre, contiendra un grand nombre de correctifs, donc l’équipe de publication a décidé de les inclure dans PostgreSQL 19 avant la disponibilité générale. Par conséquent, l’équipe prévoit une seconde version candidate le 12 novembre, et la disponibilité générale le 19 novembre 2026, sauf si un problème exige un report ou une autre version candidate. Au 5 novembre 2026, ces dates sont des prévisions.
Seulement des dates citées par le billet ou par postgresql.org début novembre 2026.
Certaines pages ne concordent pas avec cette annonce. Par exemple, la page de la feuille de route indiquait encore début novembre que la prochaine version majeure était prévue pour octobre 2026. De son côté, les notes de version de développement affichaient une date de sortie « 2026-??-?? » au 9 octobre, et leur vue d’ensemble ne mentionnait pas les trois fonctionnalités retirées. J’ai cherché leurs noms dans cette page et je n’en ai trouvé aucun.
Deux autres pages comptent pour l’exploitation. La politique de versions dit que chaque version majeure reçoit cinq ans de support et au moins une version mineure tous les trois mois. Elle recommande aussi d’exécuter la version mineure courante. De son côté, la page sur la bêta déconseille fortement les versions bêta en production.
Vérifier les chiffres
Haas donne des nombres de commits derrière le retard de PostgreSQL 19, du 8 avril au 25 septembre de chaque année. Je les ai reproduits sur un clone du dépôt de PostgreSQL le 3 novembre 2026, avec la date du committer et le jour UTC. Mes comptes sont proches sans être identiques, et l’écart reste sous 2,5 % environ. Mon hypothèse est que les limites de fuseau horaire et les règles de comptage l’expliquent.
Branche et année
Selon le billet
Mon comptage : atteignables depuis la pointe de la branche
Mon comptage : seulement sur la branche
master, 2025
1 037
1 056
sans objet
master, 2026
1 443
1 477
sans objet
REL_18_STABLE, 2025 (version en préparation)
724
738
279
REL_19_STABLE, 2026 (version en préparation)
1 169
1 182
600
REL_17_STABLE, 2025 (branche précédente)
248
250
250
REL_18_STABLE, 2026 (branche précédente)
433
437
437
Les deux définitions de mon tableau comptent. Un commit est atteignable depuis la pointe d’une branche stable s’il est sur cette branche ou avant le point de création sur master. Les chiffres du billet pour la version en préparation sont proches du comptage « atteignable », pas du comptage « seulement sur la branche », donc ils incluent très probablement des commits de master faits entre le 8 avril et la création de la branche. Les deux branches se sont séparées de master fin juin, en 2025 et en 2026. Les commits présents seulement sur la branche stable passent plutôt de 279 à 600. Dans les deux définitions, le sens se confirme : les branches stables ont reçu beaucoup plus de travail. Pour la branche précédente, qui existait déjà, les deux définitions concordent : de 250 à 437, soit environ 75 % de plus.
Numerama reformule la première paire comme des modifications « reportées » sur la version en préparation. Cette reformulation se comprend, mais les chiffres incluent aussi des commits antérieurs de master. De plus, un nombre de commits mesure un volume de travail, pas sa cause. Il ne confirme ni ne réfute que l’IA en soit la raison. Cette affirmation causale appartient aux auteurs, et je n’ai pas revérifié que les LLM trouvent ces bogues.
Pour reproduire mes comptes, clonez le dépôt sans le contenu des fichiers, puis lancez un petit script depuis ce dépôt.
import datetime as dt, subprocess
W = {2025: (dt.date(2025, 4, 8), dt.date(2025, 9, 25)), 2026: (dt.date(2026, 4, 8), dt.date(2026, 9, 25))}
day = lambda s: dt.datetime.fromisoformat(s).astimezone(dt.timezone.utc).date()
def log(args):
out = subprocess.run(["git", "log", "--format=%H|%cI"] + args, capture_output=True, text=True, check=True).stdout.strip().split("\n")
return [l.split("|") for l in out if l]
def n(rows, y):
a, b = W[y]
return sum(1 for r in rows if a <= day(r[1]) <= b)
for y in (2025, 2026):
print(f"master {y}: {n(log(['master']), y)}")
for br, y in (("REL_18_STABLE", 2025), ("REL_19_STABLE", 2026), ("REL_17_STABLE", 2025), ("REL_18_STABLE", 2026)):
print(f"{br} {y}: atteignables depuis la pointe {n(log([br]), y)}; seulement sur la branche {n(log([br, '^master']), y)}")
Ces résultats valent pour le jour où je les ai obtenus. Le dépôt continue d’avancer : les comptes de ces fenêtres closes devraient rester les mêmes, mais les pointes des branches changeront.
Ce qu’ajoute Numerama, et où les sources divergent
Le 2 novembre 2026, Numerama a résumé le retard de PostgreSQL 19 en français. Il ajoute quelques éléments que j’ai vérifiés. D’abord, il donne un décompte de sécurité : 28 vulnérabilités corrigées dans la mise à jour du 13 août, contre 11 en mai. Les annonces du projet du 13 août et du 14 mai donnent les mêmes deux nombres. Numerama note lui-même que ni ce décompte ni les chiffres de Haas ne disent combien de problèmes l’IA a trouvés.
Il présente aussi Eisentraut comme un membre de la direction du projet, alors que j’ai trouvé les deux ingénieurs sur la page des committers et que je n’ai pas trouvé Eisentraut sur la page de l’équipe centrale. Par ailleurs, il présente sa phrase sur « une quarantaine de rapports » comme une citation simple, alors qu’il l’écrit comme une illustration. Enfin, il liste d’autres réactions de l’open source aux rapports générés par IA : curl a mis fin à ses primes aux bogues en janvier, Linus Torvalds a parlé des doublons en mai, et Google a suspendu ses récompenses open source le 1er octobre. Je n’ai pas vérifié ces éléments, ils restent donc des affirmations de Numerama.
Là où les sources divergent
Le calendrier : Eisentraut écrit le 9 octobre que la communauté a préféré tenir le calendrier. L’annonce du 15 octobre prévoit la disponibilité générale le 19 novembre, et Haas attend novembre. Numerama dit que l’équipe de publication a repoussé la date début octobre, mais l’annonce que j’ai trouvée date du 15 octobre, et je n’ai pas pu dater la décision. La page de la feuille de route disait toujours octobre.
La cause des retraits : Haas relie les retraits tardifs et le retard au flot de bogues trouvés par des LLM. Eisentraut cite plusieurs causes dont la relecture par IA n’est qu’une, et il dit que le code écrit par IA n’est pas en cause.
Les chiffres : mes comptes diffèrent un peu du billet, et les chiffres de la « version en préparation » incluent des commits antérieurs de master.
L’IA et les bases de données au-delà du calendrier
Les moteurs : la charge des mainteneurs
Les deux billets décrivent une charge de travail qui a grossi. Haas s’attend à ce que chaque nouvelle fonctionnalité soit désormais examinée par l’IA. Eisentraut propose du fuzzing assisté par LLM dans le développement des fonctionnalités, et il s’attend à ce que la pression diminue si la relecture par IA cesse de progresser. Aucun des deux ne dit que les versions mineures deviendront plus grosses ou plus fréquentes. La politique de versions promet seulement au moins une tous les trois mois.
Les fonctionnalités : la recherche vectorielle
Les charges de travail d’IA changent aussi ce que les bases offrent. Selon son README, pgvector ajoute à Postgres la recherche exacte et approchée des plus proches voisins, avec des index HNSW et IVFFlat, et prend en charge Postgres 13 et suivants. Dans PostgreSQL 19, les notes de version de développement ne listent aucune fonction vectorielle (j’ai cherché le mot), donc la recherche vectorielle vient toujours de cette extension. MariaDB, au contraire, documente un type et un index vectoriels intégrés : les vecteurs sont arrivés dans MariaDB Community Server 11.7, une version continue, puis sont devenus disponibles de façon générale à partir de la 11.8, la première version à support long à les inclure. Son index initial utilise un algorithme HNSW modifié et prend la distance euclidienne par défaut. De plus, l’OWASP cite les « faiblesses des vecteurs et des embeddings » parmi les dix risques des applications LLM pour 2025.
L’exploitation : le SQL assisté par LLM
Pour les DBA, la question principale est de savoir comment traiter du SQL qu’un modèle écrit. La liste 2025 de l’OWASP nomme l’injection de prompt, la mauvaise gestion des sorties et l’agentivité excessive parmi ses risques. La mauvaise gestion des sorties consiste à passer la sortie du modèle en aval sans validation suffisante. Les points suivants sont des considérations générales, pas des mesures. Relisez le SQL généré avant de l’exécuter. Testez-le d’abord sur une copie. Donnez à tout outil qui l’exécute un rôle avec le moins de droits possible, de préférence en lecture seule sur un réplica.
Mon avis
Sur le retard de PostgreSQL 19, je suis moins inquiète que les titres des articles. Corriger beaucoup de bogues et de vulnérabilités est plutôt une bonne chose, même quand cela coûte du temps. Si la base devient beaucoup plus stable, je pense que la course à l’innovation peut attendre un peu. C’est ma lecture, pas une mesure : rien de ce que j’ai compté ne montre si la base est plus stable aujourd’hui.
Haas lui-même juge le retard modeste et estime toujours très haut la qualité du code. En revanche, il dit aussi que la question du long terme reste ouverte : comment le projet peut-il rattraper son retard de bogues et continuer d’innover ? Il qualifie aussi le code de l’un des plus propres sur lesquels il ait travaillé, et Eisentraut, de son côté, s’attend à ce que beaucoup de fonctionnalités retirées reviennent d’ici peu. Mon avis et les billets vont donc ensemble, mais l’optimisme est le mien, pas le leur.
Que faire en pratique
Traitez les dates comme des prévisions. Après le retard de PostgreSQL 19, le projet prévoit la disponibilité générale le 19 novembre 2026, et son propre texte admet un report.
Gardez la production loin des versions candidates. La page sur la bêta le déconseille.
Gardez la version mineure courante. La politique la recommande, et elle prévient que certaines mises à jour mineures demandent des étapes en plus : lisez donc les notes d’abord.
Testez une mise à jour majeure sur une copie. La politique exige pg_upgrade ou un export-import, et elle conseille de lire les notes de chaque version que vous sautez.
Sauvegardez avant toute mise à jour. C’est une règle générale.
Traitez le SQL généré par un LLM comme une entrée non fiable.
Ce que nous ignorons encore
Si le retard de PostgreSQL 19 s’arrête le 19 novembre, puisque l’annonce admet un autre report.
Combien de bogues viennent de l’IA. Numerama note qu’aucun chiffre ne le dit.
Si les fonctionnalités retirées reviennent vite. Eisentraut s’y attend, mais c’est une attente, pas un engagement.
Comment le projet rattrapera son retard de bogues. Haas écrit qu’il ne sait pas encore.
Si la pression s’estompe ou reste. Eisentraut et Haas n’ont pas la même perspective.
Si le projet a une politique écrite sur les contributions assistées par IA. Je n’en ai trouvé aucune dans la FAQ des développeurs ni dans la page Submitting a Patch, mais je n’ai pas fouillé les listes de diffusion.
CrowdSec protège tous mes serveurs : il lit les journaux, détecte les comportements hostiles (force brute SSH, scans web…) et fait bloquer les adresses fautives par un « bouncer » au niveau du pare-feu. Cinq machines sous Rocky Linux étaient déjà en version 1.8.1, installée depuis le dépôt officiel de CrowdSec. Restait le Raspberry Pi qui fait DNS et DHCP, sous Debian 12, avec la version 1.4.6 des dépôts Debian. Cet article raconte le passage de CrowdSec sur Debian à la 1.8 : pourquoi réinstaller, le piège du postinst, puis la checklist pour vérifier que la machine est réellement protégée.
Le piège, la correction et les sept points à vérifier avant de dire qu’il protège.
En un coup d’œil
Étape
Ce qu’il faut retenir
Pourquoi
La 1.4.6 de Debian a des années de retard, et un parc homogène simplifie le rôle Ansible
Méthode
Désinstaller puis réinstaller depuis le dépôt officiel : les deux paquets de bouncer entrent en conflit
Sauvegarde
/etc/crowdsec et la base SQLite, avec .backup et integrity_check
Piège
Sur une installation neuve, désactiver l’assistant laisse CrowdSec sans source de journaux et il refuse de démarrer
Correction
Déposer l’acquis.yaml avant le premier apt-get install
Vérification
Sept points, de la version jusqu’à la mémoire du Pi
Pourquoi quitter la version de CrowdSec sur Debian
1.4.6 a des années de retard : scénarios, analyseurs et liste communautaire ont beaucoup évolué depuis ;
un parc homogène : un seul rôle Ansible, une seule version, un seul comportement à connaître ;
le bouncer Debian (0.0.25) avait besoin de contournements (type de service systemd, fichier de configuration parasite) que la version officielle n’exige plus.
Réinstaller plutôt que mettre à jour
On ne peut pas simplement ajouter le dépôt officiel et lancer apt upgrade pour mettre à jour CrowdSec sur Debian. Le bouncer Debian s’appelle crowdsec-firewall-bouncer, et celui du dépôt officiel crowdsec-firewall-bouncer-nftables : ce sont deux paquets différents qui fournissent les mêmes fichiers, donc ils entrent en conflit. Et avec state: present, Ansible ne remplacerait pas une version déjà installée. La solution propre est de désinstaller, puis de réinstaller depuis le dépôt officiel.
Étape 1 : sauvegarder
systemctl stop crowdsec-firewall-bouncer crowdsec
tar czf /root/backup/crowdsec-pre18.tgz /etc/crowdsec
sqlite3 /var/lib/crowdsec/data/crowdsec.db ".backup /root/backup/crowdsec-pre18.db"
sqlite3 /root/backup/crowdsec-pre18.db "PRAGMA integrity_check;" # doit répondre ok
La base SQLite contient les décisions locales et l’historique des alertes. .backup utilise l’API de sauvegarde de SQLite, ce qui donne une copie cohérente même si un processus y accède encore. Le integrity_check vérifie que la copie est saine avant d’aller plus loin.
Étape 2 : purger, en gardant de quoi revenir en arrière
DEBIAN_FRONTEND=noninteractive apt-get purge -y crowdsec crowdsec-firewall-bouncer
for d in /etc/crowdsec /etc/systemd/system/crowdsec-firewall-bouncer.service.d; do
[ -e "$d" ] && mv "$d" "$d.old-deb"
done
systemctl daemon-reload
Deux remarques. purge supprime aussi les fichiers de configuration du paquet, contrairement à remove : on repart d’une base propre. Et le purge supprime lui-même/var/lib/crowdsec, d’où l’importance de la sauvegarde de la base à l’étape 1.
Pendant cette fenêtre, le Pi n’a plus de protection CrowdSec. Le DNS, lui, continue de fonctionner : il ne dépend pas de CrowdSec. C’est une affaire de quelques minutes, le temps de l’étape suivante, détaillée dans la section ci-dessous… avec un piège inattendu.
Le piège du postinst
Le paquet crowdsec du dépôt officiel lance, à l’installation, un assistant : cscli setup unattended. Il détecte les services présents (sshd, nginx, postfix…) et crée tout seul les sources de journaux dans /etc/crowdsec/acquis.d/setup.*.yaml, puis installe les collections correspondantes. C’est pratique sur une machine gérée à la main, mais gênant quand Ansible gère déjà tout ça : on se retrouve avec deux sources pour les mêmes journaux, donc des événements comptés deux fois.
Cette variable désactive l’assistant. Sur une machine où CrowdSec a déjà une configuration, c’est parfait. Mais sur une installation neuve, sans assistant, CrowdSec n’a aucune source de journaux. Or il refuse de démarrer sans au moins une source :
FATAL crowdsec init: while loading acquisition config: no datasource enabled
La suite est une réaction en chaîne :
le postinst du paquet essaie de démarrer le service, qui échoue, donc le postinst sort en erreur ;
dpkg laisse le paquet « à moitié configuré » (iF dans dpkg -l) ;
à l’apt-get install suivant (le bouncer, par exemple), dpkg essaie de finir la configuration en attente et relance le postinst de crowdsec. Cette fois, la variable n’est plus dans l’environnement, et l’assistant s’exécute.
Résultat : exactement ce qu’on voulait éviter. Des fichiers setup.*.yaml sont créés, des collections inutiles installées (auditd, une liste blanche « bons robots »…), et en prime le bouncer échoue à son installation, puisque l’API locale de CrowdSec n’était pas encore démarrée au moment où il s’est configuré.
La correction
Il suffit que CrowdSec trouve une source de journaux dès son premier démarrage. Le rôle Ansible dépose donc sa propre configuration avant d’installer le paquet, uniquement si CrowdSec n’est pas encore présent :
- name: Check whether CrowdSec is already installed
ansible.builtin.stat:
path: /usr/bin/crowdsec
register: _crowdsec_binary
- name: Seed acquisition config before the first install
when: not _crowdsec_binary.stat.exists
ansible.builtin.template:
src: acquis.yaml.j2 # auth.log + syslog sur Debian
dest: /etc/crowdsec/acquis.yaml
Avec un acquis.yaml déjà en place, le premier démarrage réussit, le postinst se termine normalement, et l’assistant reste désactivé.
Nettoyer si c’est déjà arrivé
dpkg --configure -a # finir les configurations en attente
mkdir -p /root/backup/setup-leftovers
mv /etc/crowdsec/acquis.d/setup.*.yaml /root/backup/setup-leftovers/
cscli collections remove crowdsecurity/auditd crowdsecurity/whitelist-good-actors
systemctl restart crowdsec crowdsec-firewall-bouncer
Un dernier piège, côté Ansible : si un run échoue en cours de route, les handlers déjà notifiés (« redémarrer crowdsec ») ne s’exécutent pas. Le run suivant voit des fichiers déjà à jour et ne notifie plus rien. Les services tournent alors avec l’ancienne configuration. Après une correction, redémarrez-les vous-même.
Vérifier que ça protège vraiment
Deux services « active » ne prouvent pas grand-chose. CrowdSec peut tourner sans lire le bon journal, et le bouncer peut tourner sans rien bloquer. Voici la checklist que j’applique après chaque installation ou mise à jour, avec ce que chaque vérification démontre.
1. Les versions et les services
cscli version | head -1 # version: v1.8.1…
systemctl is-active crowdsec crowdsec-firewall-bouncer crowdsec-nft-resync.timer
2. Le pare-feu : un seul hook, au bon endroit
nft list ruleset | grep -E "^table|hook"
# table ip crowdsec
# type filter hook input priority filter; policy accept;
# table ip6 crowdsec6
# type filter hook input priority filter; policy accept;
Le bouncer doit filtrer le trafic entrant (hook input) en IPv4 et en IPv6. Une chaîne forward ou output en trop serait un reste d’ancienne configuration. Oublier l’IPv6, c’est laisser une porte grande ouverte.
3. Un seul bouncer enregistré, le bon
cscli bouncers list
# crowdsec-firewall-bouncer 127.0.0.1 ✔️ …
Le paquet du bouncer s’enregistre tout seul sous un nom aléatoire (cs-firewall-bouncer-<horodatage>), et l’installation Ansible ajoute le sien. Il ne doit en rester qu’un, sinon on ne sait plus lequel est vraiment utilisé.
4. Les journaux sont lus et compris
cscli metrics show acquisition # auth.log et syslog avec des lignes lues
cscli explain --type syslog \
--log "Oct 23 10:00:00 pi sshd[1]: Invalid user admin from 203.0.113.9 port 4242"
cscli explain fait passer une ligne de journal dans toute la chaîne d’analyse, et montre quels analyseurs la reconnaissent et quels scénarios elle alimente. Si aucun scénario ssh n’apparaît, la détection de force brute SSH ne fonctionnera jamais, quel que soit l’état des services.
5. La liste communautaire
cscli capi status # « You can successfully interact with Central API »
cscli decisions list -a -o raw | tail -n +2 | wc -l
Juste après une installation neuve, la liste est vide : une machine nouvellement enregistrée met jusqu’à deux heures à recevoir sa première liste de blocage communautaire. Le lendemain, le Pi bloquait plus de 15 000 adresses, contre 3 600 avec l’ancienne version.
6. Les ensembles nftables correspondent aux décisions
Le bouncer de pare-feu a longtemps eu un bug qui pouvait laisser les ensembles nftables se désynchroniser des décisions. Un petit timer de resynchronisation compare les deux et rajoute ce qui manque. Son journal doit indiquer still_missing=0 :
Sur un Raspberry Pi qui fait aussi DNS, je vérifie que CrowdSec n’affame pas le reste : free -m, la mémoire résidente de chaque processus et l’usage du swap. Après une journée, CrowdSec occupait environ 100 Mo, et il restait 350 Mo de libres. Si ça devenait serré, désactiver le support grok de RE2 dans feature.yaml réduit nettement l’empreinte.
Une fois ces sept points au vert, on peut dire que CrowdSec sur Debian protège réellement la machine, et pas seulement que des services tournent.
Ce que j’en retiens
Sauvegardez avant de purger : le purge supprime /var/lib/crowdsec, et la copie SQLite doit passer integrity_check ;
Fournissez une source de journaux dès l’installation si vous désactivez l’assistant, sinon CrowdSec ne démarre pas et dpkg relance le postinst sans la variable ;
Après un run Ansible en échec, redémarrez vous-même les services : les handlers notifiés n’ont pas tourné ;
Deux services « active » ne prouvent rien : contrôlez le hook nftables (IPv4 et IPv6), le bouncer unique, cscli explain, la liste communautaire et still_missing=0 ;
Sur un petit Pi, surveillez la mémoire de CrowdSec sur Debian après une journée de fonctionnement.
Tous mes playbooks Ansible lisent leurs secrets (mots de passe de bases, clés d’API, jetons) dans OpenBao, le fork libre de HashiCorp Vault. Les secrets ne sont donc écrits ni dans le dépôt Git, ni dans des fichiers chiffrés à faire circuler : Ansible les demande au moment du déploiement, avec une identité AppRole qui n’a que le droit de lecture. Cet article explique pourquoi j’ai choisi de ne pas utiliser l’auto-unseal, ce que ça coûte, et la checklist que je suis après chaque redémarrage.
Pourquoi Shamir sans auto-unseal, et la checklist en cinq étapes après chaque redémarrage.
En un coup d’œil
Sujet
Mon choix
Secrets
OpenBao, lu par Ansible via une identité AppRole en lecture seule
Descellement
Shamir, 5 parts nécessaires sur 5 (t = n = 5), aucune stockée sur le serveur
Auto-unseal
Écarté : pas de dépendance à un fournisseur cloud, un seul serveur
Prix à payer
Après chaque redémarrage, le coffre est scellé et aucun déploiement n’est possible
Procédure
Cinq étapes, environ cinq minutes
Prochaine amélioration
Une alerte Prometheus sur l’état de scellement
Scellé, descellé : de quoi parle-t-on
OpenBao chiffre tout ce qu’il stocke avec une clé maîtresse. Au démarrage, il ne connaît pas cette clé : il est scellé. Il tourne et répond aux requêtes, mais refuse de lire ou d’écrire le moindre secret. Pour le desceller, il faut reconstituer la clé. Il existe deux grandes approches :
Shamir : à l’initialisation, la clé est découpée en n parts, dont t suffisent à la reconstituer. Au démarrage, il faut saisir ces parts à la main (bao operator unseal) ;
auto-unseal : la clé est confiée à un service externe (un KMS cloud, un HSM, un autre Vault). OpenBao la récupère tout seul au démarrage.
Ici, les cinq parts sont nécessaires (t = n = 5), et aucune n’est stockée sur le serveur. Elles sont conservées dans un endroit séparé, avec des copies sur deux autres supports physiques. Ce choix a des raisons précises :
Cinq parts, toutes nécessaires : la clé maîtresse n’existe qu’en mémoire, chaque redémarrage laisse OpenBao scellé.
un vol du disque ou d’une sauvegarde ne donne rien : sans les parts, les données d’OpenBao ne sont qu’un bloc chiffré ;
pas de dépendance à un fournisseur cloud : un auto-unseal par KMS ferait dépendre tout le parc d’un compte cloud et de sa disponibilité ;
un seul serveur : l’auto-unseal se justifie surtout pour des grappes qui redémarrent souvent, ce qui n’est pas mon cas.
Le prix à payer
Après n’importe quel redémarrage (mise à jour du noyau, coupure de courant, plantage), OpenBao repart scellé. Et comme tous les playbooks lisent leurs secrets dedans, aucun déploiement n’est possible sur aucune machine tant que quelqu’un n’a pas saisi les parts. Le symptôme côté Ansible est explicite :
fatal: … Vault is sealed
C’est un compromis assumé : un peu d’intervention humaine après un redémarrage, en échange d’un coffre qui ne s’ouvre jamais tout seul. Il faut juste ne pas l’oublier, et c’est l’objet de la checklist ci-dessous.
La checklist après redémarrage
Après chaque redémarrage du serveur qui héberge OpenBao, voici la procédure que je suis, dans l’ordre. Elle tient en cinq minutes.
1. Pointer le client sur le bon serveur
Chez moi, OpenBao écoute en HTTP sur 127.0.0.1, et OpenResty assure le TLS devant. Le client bao essaie HTTPS par défaut, d’où cette erreur trompeuse :
bao status
# Error checking seal status: … http: server gave HTTP response to HTTPS client
export BAO_ADDR=http://127.0.0.1:8200 # en local, sur le serveur
# ou : export BAO_ADDR=https://secrets.example.org (via OpenResty)
Je mets cet export dans le .bashrc de root sur ce serveur, pour ne plus y penser.
2. Vérifier l’état
bao status
# Sealed true
# Total Shares 5
# Threshold 5
# Unseal Progress 0/5
Sealed true confirme le besoin de desceller. Unseal Progress montre combien de parts ont déjà été saisies : la saisie peut se faire en plusieurs fois, et même par plusieurs personnes.
3. Desceller
bao operator unseal # répété 5 fois ; chaque part est demandée sans écho à l'écran
Sans argument, la commande demande la part de façon interactive et ne l’affiche pas. Ne passez jamais une part en argument de la ligne de commande : elle finirait dans l’historique du shell et dans la liste des processus. Et ne la collez jamais dans un outil qui garde un historique, qu’il s’agisse d’une messagerie ou d’un assistant.
4. Vérifier la chaîne complète
Un coffre descellé ne suffit pas : il faut que les déploiements puissent vraiment lire leurs secrets. Je teste donc depuis le nœud de contrôle Ansible, avec la même identité AppRole que les playbooks :
# connexion AppRole, puis lecture des métadonnées d'un secret (pas de sa valeur)
bao write -field=token auth/approle/login role_id=… secret_id=… > /tmp/t
BAO_TOKEN=$(cat /tmp/t) bao kv metadata get -mount=ansible postgresql | head -3
shred -u /tmp/t
Lire les métadonnées (version, dates) prouve que l’authentification et les droits fonctionnent, sans faire transiter la valeur du secret.
5. Reprendre les déploiements
Un ansible-playbook … --check sur n’importe quel playbook qui lit un secret confirme que tout est revenu à la normale.
Ne pas l’oublier
Le plus grand risque, c’est l’oubli : un redémarrage nocturne, et personne ne s’aperçoit que les déploiements sont bloqués avant d’en avoir besoin. Une alerte Prometheus sur l’état de scellement préviendrait dès que ça arrive : le point /v1/sys/health renvoie 503 quand le coffre est scellé, et une sonde blackbox suffit à le surveiller. C’est la prochaine amélioration sur ma liste. Après chaque redémarrage du serveur de la maison, le descellement fait désormais partie de ma checklist, au même titre que la vérification des services.
Ce que j’en retiens
Un coffre qui ne s’ouvre pas tout seul est un choix de sécurité, avec un coût d’exploitation que l’on accepte en connaissance de cause ;
Ne passez jamais une part de clé en argument ni dans un outil qui garde un historique ;
Vérifiez la chaîne complète après le descellement, depuis le nœud Ansible et avec la même identité AppRole que les playbooks ;
Surveillez l’état de scellement (/v1/sys/health) : le risque principal est l’oubli.
Dernier volet de la série, Oracle. Je ne fais pas tourner Oracle chez moi. Ce billet s’est d’abord appuyé sur mon métier de DBA et sur la documentation d’Oracle ; je l’ai depuis vérifié sur un conteneur Oracle AI Database 26ai Free jetable (voir la fin du billet). Oracle est le plus ancien des trois moteurs, et le plus explicite sur sa structure : il distingue soigneusement la vue logique (tablespaces, segments, extents, blocs) des fichiers physiques, et il expose presque tout dans des vues du dictionnaire.
Structure logique et fichiers physiques
Côté logique, la hiérarchie tablespace, segment, extent, bloc ; côté physique, les datafiles.
Une base Oracle se compose de plusieurs familles de fichiers :
les datafiles contiennent les données. Un tablespace regroupe un ou plusieurs datafiles, et un datafile n’appartient qu’à un seul tablespace ;
les control files sont la carte de la base : la liste des datafiles et des journaux, le SCN (le numéro de version global de la base) du dernier checkpoint, l’historique des sauvegardes RMAN. On les multiplexe sur plusieurs disques, car sans eux la base ne démarre pas ;
les online redo logs forment un journal circulaire, organisé en groupes de fichiers identiques (membres). Quand un groupe est plein, Oracle passe au suivant (log switch). En mode ARCHIVELOG, chaque groupe plein est copié en archived log avant d’être réutilisé : c’est ce qui permet une restauration à un instant précis ;
le tablespace d’undo garde les images d’avant des données modifiées ;
les tempfiles servent aux tris et aux jointures qui ne tiennent pas en mémoire ; ils ne sont pas journalisés ni sauvegardés ;
le spfile contient les paramètres de l’instance, et le fichier de mots de passe permet les connexions administrateur à distance.
SELECT tablespace_name, file_name, bytes/1024/1024 AS mb, autoextensible FROM dba_data_files;
SELECT group#, members, bytes/1024/1024 AS mb, status FROM v$log;
SELECT name FROM v$controlfile;
Côté logique, un segment correspond à un objet qui occupe de la place : une table, un index, une partition, un LOB. Un segment est composé d’extents, chacun formé de blocs contigus dans un seul datafile. Un segment peut donc s’étaler sur plusieurs fichiers, extent par extent. Enfin, le bloc est l’unité d’entrée-sortie, 8 Ko par défaut (DB_BLOCK_SIZE, fixé à la création de la base).
SELECT segment_name, segment_type, extents, blocks FROM dba_segments WHERE owner = 'SHOP';
SELECT extent_id, file_id, block_id, blocks FROM dba_extents
WHERE owner = 'SHOP' AND segment_name = 'ORDERS' ORDER BY extent_id;
Dans un datafile Oracle
En-tête de fichier, bitmap des extents, blocs bitmap ASSM, en-tête de segment, blocs de données et High Water Mark.
Le début de chaque datafile contient son en-tête (identité du fichier, SCN du dernier checkpoint, état de sauvegarde), puis, dans un tablespace géré localement (le cas normal aujourd’hui), un bitmap qui indique quels extents sont occupés. Avant, cette information vivait dans des tables du dictionnaire, ce qui créait de la contention lors des allocations. Deux politiques d’allocation existent : UNIFORM (tous les extents ont la même taille) et AUTOALLOCATE, où Oracle choisit des extents de plus en plus grands à mesure que le segment grossit (64 Ko, puis 1 Mo, puis 8 Mo… ; dans le test en fin de billet, un extent de 5 Mo est aussi apparu entre ceux de 1 Mo et de 8 Mo).
Dans un segment en ASSM (Automatic Segment Space Management, le défaut), des blocs bitmap à trois niveaux décrivent le remplissage de chaque bloc de données, par tranches de 25 %. Une session qui insère cherche un bloc assez libre dans ces bitmaps, et des sessions différentes sont orientées vers des blocs différents, ce qui évite qu’elles se battent pour le même bloc. L’en-tête de segment garde la liste des extents et la High Water Mark (HWM), la limite des blocs déjà formatés. Un parcours complet de la table lit tous les blocs jusqu’à la HWM, même vides : après un gros DELETE, un ALTER TABLE orders SHRINK SPACE (ou MOVE) fait redescendre la HWM.
Anatomie d’un bloc Oracle
En-tête cache, ITL, répertoires, espace libre, lignes et queue.
L’en-tête cache (20 octets) identifie le bloc : type, adresse (le RDBA, numéro de fichier et de bloc), SCN de la dernière modification, somme de contrôle. Avec DB_BLOCK_CHECKSUM=TYPICAL, la somme est vérifiée à chaque lecture depuis le disque ;
l’ITL (Interested Transaction List) est propre à Oracle : une entrée de 24 octets par transaction qui modifie le bloc, avec son identifiant (XID), l’adresse de son undo et son état. INITRANS fixe le nombre d’entrées réservées à la création du bloc. Si toutes sont prises et que le bloc n’a plus de place pour en ajouter, une nouvelle transaction attend : c’est l’attente enq: TX - allocate ITL entry, qu’on corrige en augmentant INITRANS sur les tables très concurrentes ;
le répertoire des lignes donne le décalage de chaque ligne dans le bloc, deux octets par ligne. Un ROWID désigne une entrée de ce répertoire, ce qui permet, comme chez PostgreSQL, de déplacer une ligne dans le bloc sans changer son adresse ;
PCTFREE (10 % par défaut) réserve de l’espace libre pour les UPDATE qui allongent les lignes. Sans cette réserve, une ligne qui grandit ne tient plus dans son bloc et doit migrer : elle part dans un autre bloc et laisse à sa place un pointeur. Chaque lecture par ROWID coûte alors deux blocs. Une ligne trop grande pour n’importe quel bloc est chaînée sur plusieurs blocs. La statistique table fetch continued row mesure les deux ;
la queue (4 octets) répète une partie du SCN, du type et de la séquence de l’en-tête : un bloc dont la tête et la queue ne correspondent pas a été écrit à moitié (fractured block). Oracle n’a pas de doublewrite : ce cas est détecté à la lecture, et le bloc se répare depuis une sauvegarde RMAN et les journaux (RECOVER ... BLOCK). Pendant une sauvegarde à chaud manuelle (BEGIN BACKUP), Oracle écrit l’image complète des blocs dans le redo à leur première modification, comme le full_page_writes de PostgreSQL.
Une ligne et son ROWID
Une ligne : en-tête de 3 octets puis longueur et valeur de chaque colonne ; le ROWID en quatre parties.
Une ligne commence par trois octets : des indicateurs, l’octet de verrou et le nombre de colonnes. Viennent ensuite, pour chaque colonne, sa longueur et sa valeur. Les NULL au milieu de la ligne coûtent un octet (0xFF), et les NULL en fin de ligne ne sont pas stockés du tout. Placer les colonnes souvent nulles en dernier économise donc de la place.
L’octet de verrou est le détail le plus élégant du format : quand une transaction modifie une ligne, elle y inscrit le numéro de son entrée dans l’ITL du bloc. Le verrou de ligne est donc stocké dans le bloc lui-même, sans table de verrous en mémoire : verrouiller un million de lignes ne coûte pas plus de mémoire que d’en verrouiller une. Une autre transaction qui veut la même ligne lit l’ITL, trouve la transaction propriétaire et attend la fin de celle-ci. Après le COMMIT, l’octet de verrou n’est pas remis à zéro tout de suite : il continue de pointer vers l’entrée ITL jusqu’à ce qu’une visite ultérieure nettoie le bloc (voir le test en fin de billet).
Le ROWID est l’adresse physique d’une ligne : numéro d’objet, numéro de fichier relatif au tablespace (0 dans un tablespace bigfile, le type par défaut sur Oracle 26ai Free), numéro de bloc, numéro de ligne dans le répertoire. C’est l’accès le plus rapide qui soit, et c’est ce que contiennent les index B-tree. Le paquet DBMS_ROWID le décode :
SELECT rowid,
dbms_rowid.rowid_object(rowid) AS obj,
dbms_rowid.rowid_relative_fno(rowid) AS file#,
dbms_rowid.rowid_block_number(rowid) AS block#,
dbms_rowid.rowid_row_number(rowid) AS row#
FROM shop.orders WHERE id = 7;
Pour voir le contenu brut d’un bloc, ALTER SYSTEM DUMP DATAFILE 4 BLOCK 151; écrit sa description complète (en-têtes, ITL, chaque ligne en hexadécimal) dans le fichier de trace de la session. À réserver à une base de test : c’est très instructif, mais ce n’est pas un outil de production.
Lecture cohérente : l’undo
Comme InnoDB, Oracle modifie les blocs sur place et range l’image d’avant dans l’undo. Quand une requête démarre, elle note le SCN courant. Si elle tombe sur un bloc modifié après ce SCN, elle en fabrique une copie en mémoire et lui applique à rebours les enregistrements d’undo désignés par l’ITL, jusqu’à obtenir le bloc tel qu’il était à son SCN de départ : c’est le consistent read. Les lecteurs ne bloquent jamais les écrivains, ni l’inverse.
Le revers : si l’undo nécessaire a déjà été écrasé par des transactions plus récentes, la requête échoue avec ORA-01555: snapshot too old. UNDO_RETENTION indique combien de temps Oracle doit essayer de garder l’undo, à condition que le tablespace d’undo soit assez grand. Autre phénomène, cousin des hint bits de PostgreSQL : le delayed block cleanout. Après un gros COMMIT, Oracle ne revisite pas tous les blocs modifiés, et c’est le premier SELECT suivant qui nettoie les entrées ITL… et génère du redo, à la surprise générale.
Les trois moteurs côte à côte
Trois réponses à la même question.
InnoDB
PostgreSQL
Oracle
Taille de page
16 Ko
8 Ko
8 Ko (2 à 32 Ko)
Organisation de la table
arbre B+ de la clé primaire
tas (heap)
tas (heap) par défaut, IOT en option
Adresse d’une ligne dans un index
clé primaire (index secondaires)
ctid (page, slot)
ROWID (objet, fichier, bloc, ligne)
Anciennes versions
undo log
dans la table
tablespace d’undo
Nettoyage
purge thread
VACUUM
réutilisation de l’undo
Pages déchirées
doublewrite buffer
full_page_writes (WAL)
contrôle du bloc + restauration RMAN
Verrous de ligne
en mémoire (lock system)
xmax dans le tuple
octet de verrou → ITL du bloc
Aucun de ces choix n’est meilleur dans l’absolu. L’index cluster d’InnoDB rend les recherches par clé primaire imbattables mais pénalise les clés aléatoires. Le tas de PostgreSQL rend les mises à jour simples et les retours en arrière instantanés, au prix de VACUUM. L’undo séparé d’Oracle et d’InnoDB garde les tables compactes, mais impose de dimensionner l’undo. Savoir comment les données sont rangées sur le disque, c’est savoir lequel de ces compromis on paie, et à quel moment.
Ce que j’ai mesuré (mise à jour du 2026-11-04)
J’ai d’abord écrit ce billet à partir de la documentation. Je l’ai ensuite testé sur Oracle AI Database 26ai Free 23.26.3.0.0, l’image container-registry.oracle.com/database/free lancée avec podman 5.8.2 sur Rocky Linux 10.2, WSL2, dans la base pluggable FREEPDB1, avec un utilisateur et un tablespace jetables. C’est une édition Free, pas Enterprise, et pas une 19c : les comportements ci-dessous sont ceux de cette version. Les tests ont corrigé deux points plus haut (le numéro de fichier du ROWID et la cellule du tableau pour InnoDB) et ajouté deux précisions (l’extent de 5 Mo, et l’octet de verrou après un commit).
Fichiers et structure logique. Trois groupes de redo de 200 Mo avec un seul membre, deux fichiers de contrôle, le mode ARCHIVELOG, un bloc de 8 Ko, DB_BLOCK_CHECKSUM = TYPICAL. Le tablespace d’undo et les datafiles sont dans la PDB, les redo logs et les fichiers de contrôle dans la base conteneur. Le type de tablespace par défaut est BIGFILE :
BANNER_FULL
------------------------------------------------------------------------------------------
Oracle AI Database 26ai Free Release 23.26.3.0.0 - Develop, Learn, and Run for Free
Version 23.26.3.0.0
NAME CDB CON_ID
--------- --- ----------
FREE YES 0
### in the PDB FREEPDB1
TABLESPACE_N FILE_NAME MB AUT
------------ ---------------------------------------------------------------------- ---------- ---
SYSTEM /opt/oracle/oradata/FREE/FREEPDB1/system01.dbf 290 YES
SYSAUX /opt/oracle/oradata/FREE/FREEPDB1/sysaux01.dbf 420 YES
UNDOTBS1 /opt/oracle/oradata/FREE/FREEPDB1/undotbs01.dbf 2690 YES
USERS /opt/oracle/oradata/FREE/FREEPDB1/users01.dbf 7 YES
SHOP_TS /opt/oracle/oradata/FREE/FREEPDB1/shop_ts01.dbf 900 YES
DB_BLOCK_SIZE
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
8192
GROUP# MEMBERS MB STATUS
---------- ---------- ---------- ----------------
1 1 200 CURRENT
2 1 200 ACTIVE
3 1 200 ACTIVE
NAME
----------------------------------------------------------------------
/opt/oracle/oradata/FREE/control01.ctl
/opt/oracle/oradata/FREE/control02.ctl
TABLESPACE_N SEGMEN ALLOCATIO BLOCK_SIZE
------------ ------ --------- ----------
SYSTEM MANUAL SYSTEM 8192
SYSAUX AUTO SYSTEM 8192
UNDOTBS1 MANUAL SYSTEM 8192
TEMP MANUAL UNIFORM 8192
USERS AUTO SYSTEM 8192
SHOP_TS AUTO SYSTEM 8192
NAME VALUE
---------------------------------------------------------------------- ------------------------------------------------------------
spfile /opt/oracle/product/26ai/dbhomeFree/dbs/spfileFREE.ora
db_block_checksum TYPICAL
db_block_size 8192
undo_tablespace UNDOTBS1
undo_retention 900
LOG_MODE
------------
ARCHIVELOG
TABLESPACE_N BIG
------------ ---
SYSTEM YES
SYSAUX YES
UNDOTBS1 YES
TEMP NO
USERS YES
SHOP_TS YES
DEFAULT_TABLESPACE_TYPE
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
BIGFILE
FILE_NAME MB
---------------------------------------------------------------------- ----------
/opt/oracle/oradata/FREE/FREEPDB1/temp01.dbf 20
USERNAME SYSDB
---------- -----
SYS TRUE
### redo logs and control files live in the CDB, not in the PDB
GROUP# MEMBER
---------- ----------------------------------------------------------------------
3 /opt/oracle/oradata/FREE/redo03.log
2 /opt/oracle/oradata/FREE/redo02.log
1 /opt/oracle/oradata/FREE/redo01.log
La croissance des extents. 3 millions de lignes dans un nouveau tablespace AUTOALLOCATE (ALLOCATION_TYPE = SYSTEM, ASSM) : 16 extents de 64 Ko, puis 63 de 1 Mo, un de 5 Mo, puis des extents de 8 Mo :
Le high water mark. Après avoir supprimé toutes les lignes d’une table de 500 000 lignes, le segment garde ses 7 936 blocs et un parcours complet de la table vide coûte 7 740 consistent gets. Après SHRINK SPACE, le segment fait 8 blocs et le parcours en coûte 2 :
WHAT BLOCKS MB
---------------------------------- ---------- ----------
after load 7936 62
WHAT BLOCKS MB
---------------------------------- ---------- ----------
after DELETE of all rows 7936 62
NAME VALUE
------------------------------ ----------
consistent gets 35822
ROWS_LEFT
----------
0
NAME VALUE
------------------------------ ----------
consistent gets 43562
WHAT BLOCKS MB
---------------------------------- ---------- ----------
after SHRINK SPACE 8 .0625
NAME VALUE
------------------------------ ----------
consistent gets 44895
ROWS_LEFT
----------
0
NAME VALUE
------------------------------ ----------
consistent gets 44897
Un ROWID décodé avec DBMS_ROWID. Le numéro de fichier vaut 0 parce que le tablespace est de type bigfile ; le numéro de bloc 1302 est celui du bloc vidé plus bas (identifiant de fichier absolu 17) :
ALTER SYSTEM DUMP DATAFILE 17 BLOCK 1302 juste après les insertions et le commit, abrégé aux lignes utiles. Le bloc a 2 entrées ITL (itc: 2). Chaque ligne commence par tl (longueur totale), fb (indicateurs), lb (l’octet de verrou) et cc (le nombre de colonnes). La ligne d’id 2 (tab 0, row 1) a un NULL au milieu (*NULL*) et un NULL final qui n’est pas stocké (cc: 4 pour 5 colonnes) ; la ligne d’id 3 (tab 0, row 2) ne stocke que 2 colonnes. Après le commit, les trois lignes portent encore lb: 0x1 et l’entrée ITL affiche toujours Lck 3 : le commit ne nettoie pas le bloc :
Le même bloc pendant qu’une autre session détient un UPDATE non validé de la ligne d’id 1 (tab 0, row 0). La ligne modifiée porte maintenant lb: 0x2, qui désigne l’entrée ITL 2 (Lck 1, un nouvel identifiant de transaction) ; l’entrée 1 a été nettoyée (C---, Lck 0) et les autres lignes ont lb: 0x0. La ligne a grossi de 29 à 31 octets et s’est déplacée dans le bloc :
Verrous de ligne et lignes migrées. Modifier 1 ligne ou 500 000 lignes prend les deux mêmes verrous dans V$LOCK (un TX, un TM) ; ce qui grossit, c’est l’undo (1 enregistrement, puis 500 000 enregistrements dans 5 815 blocs d’undo). Verrouiller un million de lignes ne coûte donc pas de mémoire dans une table de verrous, mais coûte de l’undo. Pour les lignes migrées, 100 000 lignes ont été insérées avec PCTFREE 0 puis allongées de 1 à 300 caractères : les relire a enregistré 99 733 événements table fetch continued row (des lignes qu’il a fallu suivre vers un autre bloc) :
### locking 1 row versus 500000 rows: v$lock and undo
WHAT TY LOCKS
---------------------- -- ----------
after 1 row TM 1
after 1 row TX 1
WHAT USED_UBLK USED_UREC
---------------------- ---------- ----------
after 1 row 1 1
WHAT TY LOCKS
---------------------- -- ----------
after 500000 rows TM 1
after 500000 rows TX 1
WHAT USED_UBLK USED_UREC
---------------------- ---------- ----------
after 500000 rows 5815 500000
### migrated rows: PCTFREE 0, rows that grow
TABLE_NAME NUM_ROWS BLOCKS CHAIN_CNT
-------------------------------------------------------------------------------------------------------------------------------- ---------- ---------- ----------
MIG 100000 4528 0
COUNT(*)
----------
100000
STAT DELTA
--------------------------------- ----------
table fetch continued row (delta) 99733
La lecture cohérente. Une transaction en lecture seule continue de voir la valeur 10 après qu’une autre session a validé 20 ; les statistiques montrent un enregistrement d’undo appliqué :
WHO V
-------------- ----------
reader, before 10
WHO V
----------------- ----------
writer, committed 20
WHO V
------------------------ ----------
reader, after the commit 10
NAME VALUE
------------------------------------------------------------ ----------
consistent changes 1
data blocks consistent reads - undo records applied 1
Le nettoyage différé des blocs. Après avoir modifié et validé 2 000 000 de lignes, un simple SELECT COUNT(*) a généré environ 5 Mo de redo et 57 046 « cleanouts only – consistent read gets » :
COUNT(*)
----------
2000000
STAT DELTA
--------------------------------- ----------
redo size generated by the SELECT 5020444
STAT DELTA
------------------------------------- ----------
cleanouts only - consistent read gets 57046
L’attente ITL. Une table de 3 000 lignes d’un caractère, créée avec PCTFREE 0, remplit complètement ses blocs : le bloc de la ligne 1500 contient 733 lignes et il lui reste 7 octets (avsp=0x7), avec ses 2 entrées ITL. Deux sessions modifient chacune une ligne différente de ce bloc et restent sans valider. Une troisième session qui modifie une troisième ligne du même bloc ne peut pas obtenir d’entrée ITL, faute de place pour en ajouter : elle attend sur enq: TX - allocate ITL entry, bloquée par l’une des autres sessions, jusqu’à leur annulation (14,7 secondes ici) :
ORA-01555. Une table de 300 000 lignes est chargée normalement, puis le tablespace d’undo est remplacé par un tablespace de 10 Mo qui ne peut pas grandir, avec UNDO_RETENTION = 5. Un lecteur parcourt la table par son index dans une boucle PL/SQL et dort toutes les 20 000 lignes ; pendant ce temps, un écrivain valide 240 petites mises à jour (8 passes sur la table, 10 000 lignes par commit). Le lecteur échoue après 79 971 lignes parce que l’undo dont il avait besoin a été écrasé. Mon premier essai n’a pas échoué, sans doute parce que l’optimiseur avait lu et trié toutes les lignes dès le premier fetch, si bien que le lecteur n’avait plus besoin d’anciens blocs (je n’ai pas regardé le plan). Forcer un parcours d’index avec un hint a fait apparaître l’erreur :
undo_tablespace UNDO_SMALL
undo_retention 5
reader: failed after 79971 rows: ORA-01555: snapshot too old: rollback segment
number with name "" too small
writer: 8 passes of 30 commits done
Non testé : la vérification de la queue de bloc et la réparation d’un bloc fracturé avec RMAN, les images de bloc complètes écrites pendant une sauvegarde à chaud, les tailles d’une entrée ITL (24 octets) et d’une entrée du répertoire des lignes (2 octets), l’octet unique d’un NULL au milieu d’une ligne, les tables organisées en index, et toute édition ou version autre qu’Oracle AI Database 26ai Free.
Ce billet fait partie d’une série de notes d’administration système (tag « carnet d’admin »). Série « Dans les fichiers d’une base de données », partie 3/3. · ← Billet précédent · English version
Après InnoDB, PostgreSQL. Les deux moteurs résolvent les mêmes problèmes (stocker des lignes, les retrouver vite, survivre à un crash, laisser lire pendant qu’on écrit) mais ont fait des choix presque opposés. Les connaître explique le besoin de VACUUM, le gonflement des tables (bloat), ou pourquoi le premier SELECT après un gros chargement écrit sur le disque.
Le répertoire de données
PGDATA : un répertoire par base, un fichier par table ou index, nommés par numéro.
Premier réflexe à perdre : chercher ses tables par leur nom. PostgreSQL désigne tout par des numéros. Chaque base a un OID, et c’est le nom de son répertoire sous base/. Chaque table, index ou séquence a un relfilenode, et c’est le nom de son fichier. Ce numéro change quand la table est réécrite (TRUNCATE, VACUUM FULL, CLUSTER, certains ALTER TABLE) : PostgreSQL crée un nouveau fichier et bascule, ce qui rend ces opérations sûres en cas de crash.
SELECT oid, datname FROM pg_database;
SELECT pg_relation_filepath('orders'); -- base/16384/24576
SELECT pg_size_pretty(pg_total_relation_size('orders')); -- table + index + TOAST
global/ contient les catalogues partagés par toutes les bases (rôles, liste des bases) et pg_control, le petit fichier qui dit où reprendre après un crash ;
pg_wal/ est le journal d’écriture anticipée (WAL), en segments de 16 Mo : l’équivalent du redo d’InnoDB ;
pg_xact/ garde l’état de chaque transaction sur deux bits : en cours, validée, annulée. C’est lui qui permet de savoir si une version de ligne est visible ;
pg_tblspc/ contient des liens symboliques vers les tablespaces créés ailleurs. Chez PostgreSQL, un tablespace n’est qu’un répertoire sur un autre disque.
Une relation : des forks et des segments
Le main fork contient les données ; fsm et vm sont des fichiers auxiliaires.
Chaque table est en réalité un petit ensemble de fichiers. Le main fork contient les données, découpé en fichiers de 1 Go au plus (24576, 24576.1…). Cette limite date d’une époque où tous les systèmes de fichiers ne géraient pas les gros fichiers, et elle simplifie toujours la sauvegarde et la copie. La free space map indique approximativement la place libre de chaque page, pour qu’un INSERT trouve vite une page où écrire. La visibility map garde deux bits par page : « toutes les lignes sont visibles par tout le monde » et « toutes les lignes sont gelées ». Elle permet à VACUUM de sauter les pages déjà propres, et aux index-only scans de ne pas lire la table.
Anatomie d’une page PostgreSQL
Une page de table : en-tête, pointeurs de ligne, espace libre, tuples.
Les pages font 8 Ko (une constante de compilation, BLCKSZ). Leur structure est la même pour les tables et les index :
l’en-tête de 24 octets contient le pd_lsn, la position dans le WAL de la dernière modification. PostgreSQL l’utilise pour une règle simple : une page ne peut pas être écrite sur le disque avant que le WAL qui la décrit le soit. Il contient aussi la somme de contrôle (activée par défaut à l’initialisation depuis PostgreSQL 18, à demander avec initdb -k avant), et deux décalages, pd_lower et pd_upper, qui bornent l’espace libre ;
les line pointers forment un tableau en début de page. Chacun contient le décalage et la longueur d’un tuple. L’adresse d’une ligne, son ctid, vaut (numéro de page, numéro de pointeur), par exemple (0,2). Cette indirection permet de déplacer un tuple dans la page pour la défragmenter sans changer son adresse, donc sans toucher aux index ;
les tuples sont écrits depuis la fin de la page, vers le haut. Quand pd_lower (fin des pointeurs) rejoint pd_upper (début des tuples), la page est pleine ;
l’espace spécial en fin de page est vide pour une table. Les index B-tree y rangent les liens vers les pages voisines et le niveau dans l’arbre.
On peut tout regarder avec l’extension pageinspect, sur une base de test :
Les 23 octets d’en-tête qui précèdent chaque version de ligne.
Chaque version de ligne commence par 23 octets d’en-tête, arrondis à 24 par l’alignement. Les champs essentiels sont t_xmin (la transaction qui a créé cette version), t_xmax (celle qui l’a supprimée ou remplacée) et t_ctid (l’adresse de la version suivante). Trois détails pratiques :
l’alignement : chaque colonne est alignée selon son type (8 octets pour bigint et timestamp). Une colonne boolean suivie d’un bigint laisse 7 octets perdus. Ordonner les colonnes de la plus grande à la plus petite peut réduire une grosse table jusqu’à environ 20 % (19 % sur une table de type commandes dans mon test, voir plus bas) ;
les NULL ne prennent qu’un bit dans le bitmap, présent seulement si la ligne contient au moins un NULL ;
TOAST : quand une ligne dépasse environ 2 Ko, PostgreSQL compresse d’abord les grandes valeurs (pglz, ou lz4 si on le demande), puis, si ça ne suffit pas, les déplace dans une table annexe, pg_toast.pg_toast_<oid>, découpées en morceaux. La ligne ne garde qu’un pointeur de 18 octets. C’est transparent, mais un SELECT * qui lit une colonne TOAST va chercher ces morceaux : ne sélectionnez que les colonnes utiles.
Un UPDATE PostgreSQL ne modifie rien sur place
L’ancienne version reste dans la page, marquée par son xmax, jusqu’au VACUUM.
C’est la différence fondamentale avec InnoDB et Oracle. Chez PostgreSQL, un UPDATE est un DELETE suivi d’un INSERT : la transaction écrit une nouvelle version complète de la ligne, et inscrit son numéro dans le xmax de l’ancienne. Il n’y a pas de journal d’undo. Les anciennes versions sont l’undo, et elles vivent dans la table.
Pour décider si une version est visible, une transaction compare xmin et xmax à son instantané (snapshot) : la version est visible si sa création est validée et antérieure à l’instantané, et si sa suppression n’a pas eu lieu, a été annulée ou est postérieure. Consulter pg_xact à chaque fois coûterait cher. La première transaction qui lit une version note donc le résultat dans des hint bits de l’en-tête (« xmin validé », « xmax annulé »…). Cela explique un phénomène qui surprend souvent : le premier SELECT après un gros chargement écrit sur le disque, parce qu’il pose ces bits sur chaque page.
Ce que ça implique : VACUUM, HOT et le gel
VACUUM retire les versions que plus aucune transaction ne peut voir, rend leur place réutilisable (la free space map est mise à jour) et met à jour la visibility map. Il ne rend pas l’espace au système de fichiers, sauf pour les pages vides en fin de fichier. Un VACUUM simple ne réécrit jamais la table. VACUUM FULL, CLUSTER et l’extension pg_repack (sans verrou long) le font, tout comme TRUNCATE et certaines formes d’ALTER TABLE, comme le changement de type d’une colonne ;
une transaction ouverte trop longtemps bloque le nettoyage de toutes les versions plus récentes qu’elle, dans toute la base. C’est la première chose à vérifier quand une table gonfle : SELECT pid, xact_start, state FROM pg_stat_activity ORDER BY xact_start; ;
les mises à jour HOT (Heap-Only Tuple) : si aucune colonne indexée ne change et que la nouvelle version tient dans la même page, PostgreSQL ne touche pas aux index. L’index pointe toujours vers l’ancien pointeur, qui mène à la nouvelle version par la chaîne de ctid. Laisser de la place dans les pages augmente les chances de HOT : ALTER TABLE orders SET (fillfactor = 90); ;
le gel (freezing) : les identifiants de transaction tiennent sur 32 bits et finissent par boucler : la limite dure est 231, soit environ 2,1 milliards de transactions, mais autovacuum lance le travail anti-bouclage bien plus tôt, à autovacuum_freeze_max_age (200 millions par défaut). Pour qu’une très vieille ligne ne paraisse pas soudain « dans le futur », VACUUM la marque comme gelée, visible par tous. Si autovacuum prend trop de retard, PostgreSQL finit par refuser les écritures pour se protéger : c’est pourquoi on ne désactive jamais autovacuum.
Les index et le WAL
Un index B-tree utilise les mêmes pages de 8 Ko. Ses entrées contiennent la clé et le ctid du tuple, c’est-à-dire une adresse physique, contrairement à InnoDB qui stocke la clé primaire. Une recherche par index est donc une seule descente plus une lecture de page. Mais l’index ne sait rien de la visibilité : il faut lire la table pour savoir si la version trouvée est visible, sauf si la visibility map dit que la page est entièrement visible. C’est ce qui rend les index-only scans efficaces sur une table bien nettoyée.
Enfin, la protection contre les pages déchirées : le problème est le même que chez InnoDB (8 Ko écrits par un disque qui garantit 4 Ko), la solution est différente. Avec full_page_writes = on, la première modification d’une page après chaque checkpoint écrit l’image complète de la page dans le WAL. Après un crash, PostgreSQL repart de cette image intacte. C’est l’équivalent du doublewrite buffer, et la raison pour laquelle le volume de WAL grimpe juste après chaque checkpoint.
Dernier billet de la série : Oracle, qui partage avec InnoDB l’undo séparé, mais pousse la logique plus loin, avec des verrous rangés dans les blocs eux-mêmes.
Ce que j’ai mesuré (mise à jour du 2026-11-04)
J’ai d’abord écrit ce billet à partir de la documentation. Je l’ai ensuite testé sur PostgreSQL 16.15 (le paquet de Rocky Linux 10.2), WSL2, sur un cluster jetable créé avec un simple initdb. Ces tests ont corrigé trois affirmations plus haut (ce qui réécrit une table, le chiffre du bouclage et la fourchette de 10 à 20 %). J’ai aussi testé PostgreSQL 18.6 (paquets PGDG), voir le dernier bloc de cette section : les sommes de contrôle y sont activées par défaut, et désactivées sur la 16.15 après un simple initdb.
Quelles opérations changent le nom du fichier (relfilenode). Un VACUUM simple non ; VACUUM FULL, CLUSTER, ALTER COLUMN TYPE et TRUNCATE oui. ADD COLUMN non, même avec un DEFAULT constant :
start : base/16384/16438
after VACUUM : base/16384/16438
after VACUUM FULL : base/16384/16445
after CLUSTER : base/16384/16451
after ALTER COLUMN TYPE : base/16384/16459
after ADD COLUMN (nullable) : base/16384/16459
after ADD COLUMN DEFAULT 0 : base/16384/16459
after TRUNCATE : base/16384/16466
Les hint bits. HEAP_XMIN_COMMITTED vaut 0x0100. Il n’est pas positionné après l’INSERT et l’est par le premier SELECT, ce qui explique que la première lecture après un chargement massif écrive :
lp | infomask | xmin_committed
----+----------+----------------
1 | 800 | f
2 | 800 | f
3 | 800 | f
(3 rows)
-- after SELECT count(*)
lp | infomask | xmin_committed
----+----------+----------------
1 | 900 | t
2 | 900 | t
3 | 900 | t
(3 rows)
VACUUM contre VACUUM FULL, et un snapshot resté ouvert. 300 000 lignes, 90 % supprimées. Le VACUUM simple retire 270 000 versions mortes et le fichier garde ses 69 Mo ; VACUUM FULL écrit un nouveau fichier de 7 Mo. Avec une transaction REPEATABLE READ laissée ouverte, 100 000 lignes supprimées sont « dead but not yet removable » ; une fois la transaction terminée, VACUUM les retire et tronque les pages vides en fin de fichier :
-- after loading 300000 rows
file base/16384/16470: 72286208 bytes (69 MB), dead tuples (stats): 0, live: 300000
-- after DELETE of 90% of the rows
file base/16384/16470: 72286208 bytes (69 MB), dead tuples (stats): 270000, live: 30000
pages: 0 removed, 8824 remain, 8824 scanned (100.00% of total)
tuples: 270000 removed, 30000 remain, 0 are dead but not yet removable
-- after VACUUM
file base/16384/16470: 72286208 bytes (69 MB), dead tuples (stats): 0, live: 30000
-- after VACUUM FULL
file base/16384/16475: 7233536 bytes (7064 kB), dead tuples (stats): 0, live: 30000
-- VACUUM VERBOSE while the old snapshot is open
pages: 0 removed, 3824 remain, 3824 scanned (100.00% of total)
tuples: 0 removed, 130000 remain, 100000 are dead but not yet removable
-- VACUUM VERBOSE after the transaction ended
pages: 2941 removed, 883 remain, 2942 scanned (76.94% of total)
tuples: 100000 removed, 6931 remain, 0 are dead but not yet removable
L’ordre des colonnes. La première paire est une table construite pour perdre du padding (boolean, bigint, boolean, …), la seconde une table de type commandes (boolean, bigint, smallint, timestamptz, …). 1 000 000 de lignes chacune. Le gain dépend du schéma :
-- cas construit pour perdre du padding : col_bad (boolean, bigint, ...) contre col_good (trié)
relname | bytes | size
----------+----------+-------
col_good | 60235776 | 57 MB
col_bad | 76562432 | 73 MB
(2 rows)
saving: 21.3 %
-- table de type commandes : col_real_a (ordre par défaut) contre col_real_b (trié)
relname | size | bytes
------------+-------+----------
col_real_a | 80 MB | 84377600
col_real_b | 65 MB | 68272128
(2 rows)
saving: 19.1 %
Les segments. Une table de 1 482 Mo est découpée en fichiers de 1 Gio :
1073741824 Nov 4 17:25 /tmp/dp/data/base/16384/16525
479920128 Nov 4 17:26 /tmp/dp/data/base/16384/16525.1
TOAST. Une valeur de 960 caractères reste dans la ligne. Des valeurs de 2 240 et 9 600 caractères vont dans la table TOAST, par morceaux d’environ 2 Ko (2 et 5 morceaux) :
chunk_id | chunks | bytes
----------+--------+-------
16534 | 2 | 2240
16535 | 5 | 9600
(2 rows)
id | chars | stored_bytes
----+-------+--------------
1 | 960 | 964
2 | 2240 | 2240
3 | 9600 | 9600
(3 rows)
(a value with no chunk in the toast table stayed in the heap tuple)
Les images de page complètes. Juste après un CHECKPOINT, le premier UPDATE d’une page écrit environ 26 Ko de WAL dans ce test, surtout des images de page (les enregistrements dont le total avoisine 8 Ko ; « page image » marque ceux dont le total est très supérieur à l’enregistrement lui-même, donc avec une page jointe). L’UPDATE suivant sur la même page écrit 128 octets, hors enregistrement de commit :
-- first UPDATE (start 0/99272A78, end 0/99279350)
Heap2 PRUNE record 60 B, total 7456 B page image
Heap LOCK record 59 B, total 8219 B page image
Heap UPDATE record 73 B, total 3589 B page image
Btree INSERT_LEAF record 53 B, total 7453 B page image
-- second UPDATE (start 0/99279350, end 0/99279400)
Heap2 PRUNE record 57 B, total 57 B
Heap HOT_UPDATE record 71 B, total 71 B
PostgreSQL 18.6 (paquets PGDG, Rocky Linux 10.2, WSL2), simple initdb. data_checksums vaut on, initdb --no-data-checksums le désactive et -k est toujours accepté. La somme de contrôle de la page 0 calculée par page_checksum() est égale à celle stockée dans le fichier, et pg_checksums --check ne trouve aucun bloc défectueux. Les opérations qui réécrivent une table sont les mêmes que sur la 16.15 :
PostgreSQL 18.6 on x86_64-pc-linux-gnu, compiled by gcc (GCC) 14.3.1 20251022 (Red Hat 14.3.1-4), 64-bit
data_checksums | on
server_version | 18.6
Data page checksum version: 1
Blocks scanned: 3842
Bad checksums: 0
### initdb --no-data-checksums, and initdb -k, in two scratch directories
initdb --no-data-checksums -> Data page checksum version: 0
initdb -k -> Data page checksum version: 1
page_checksum() : 15056
on disk pd_checksum: 15056
start : base/16384/16436
after VACUUM : base/16384/16436
after VACUUM FULL : base/16384/16444
after CLUSTER : base/16384/16450
after ALTER COLUMN TYPE : base/16384/16459
after ADD COLUMN (nullable) : base/16384/16459
after ADD COLUMN DEFAULT 0 : base/16384/16459
after TRUNCATE : base/16384/16466
Ce billet fait partie d’une série de notes d’administration système (tag « carnet d’admin »). Série « Dans les fichiers d’une base de données », partie 2/3. · ← Billet précédent · Billet suivant → · English version