<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>stockage - Quelques pensées</title>
	<atom:link href="https://blog.neurozone.fr/tag/stockage/feed/" rel="self" type="application/rss+xml" />
	<link>https://blog.neurozone.fr/tag/stockage/</link>
	<description>Terres dévastées</description>
	<lastBuildDate>Fri, 06 Nov 2026 19:05:23 +0000</lastBuildDate>
	<language>fr-FR</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	

<image>
	<url>https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2024/03/cropped-2.png?fit=32%2C32&#038;ssl=1</url>
	<title>stockage - Quelques pensées</title>
	<link>https://blog.neurozone.fr/tag/stockage/</link>
	<width>32</width>
	<height>32</height>
</image> 
<site xmlns="com-wordpress:feed-additions:1">154219366</site>	<item>
		<title>Dans les fichiers d&#8217;une base de données (4/3+1) : SQLite</title>
		<link>https://blog.neurozone.fr/2026/11/12/dans-les-fichiers-d-une-base-de-donnees-sqlite/</link>
					<comments>https://blog.neurozone.fr/2026/11/12/dans-les-fichiers-d-une-base-de-donnees-sqlite/#respond</comments>
		
		<dc:creator><![CDATA[Hécate Raven]]></dc:creator>
		<pubDate>Thu, 12 Nov 2026 09:00:00 +0000</pubDate>
				<category><![CDATA[Informatique]]></category>
		<category><![CDATA[carnet d'admin]]></category>
		<category><![CDATA[Français]]></category>
		<category><![CDATA[série : Dans les fichiers d'une base de données]]></category>
		<category><![CDATA[SQLite]]></category>
		<category><![CDATA[stockage]]></category>
		<guid isPermaLink="false">https://blog.neurozone.fr/?p=5455</guid>

					<description><![CDATA[<p>Les fichiers SQLite lus octet par octet sur une vraie base : en-tête, pages d'arbre B, enregistrements, WAL et VACUUM, mesurés sur Rocky Linux 10.2.</p>
<p>L’article <a href="https://blog.neurozone.fr/2026/11/12/dans-les-fichiers-d-une-base-de-donnees-sqlite/">Dans les fichiers d&rsquo;une base de données (4/3+1) : SQLite</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">SQLite est l&rsquo;intrus de cette série : pas de serveur, pas de répertoire de données, pas de processus d&rsquo;arrière-plan. Les fichiers SQLite tiennent dans un seul fichier ordinaire que l&rsquo;on peut lire avec un vidage hexadécimal. Cet article prolonge les trois premiers, <a href="https://blog.neurozone.fr/?p=1787">InnoDB</a>, <a href="https://blog.neurozone.fr/?p=1788">PostgreSQL</a> et <a href="https://blog.neurozone.fr/?p=1789">Oracle avec les trois côte à côte</a>. Le tableau final ne remplace rien : il ajoute une quatrième colonne à la comparaison.</p>



<p class="wp-block-paragraph">J&rsquo;ai rencontré SQLite dans d&rsquo;autres outils, par exemple ProxySQL, qui garde sa configuration dans une base SQLite. Alors j&rsquo;ai voulu voir les octets. Tout ce qui suit vient d&rsquo;une base de laboratoire : <em>testé sur Rocky Linux 10.2, SQLite 3.46.1, WSL2</em>. Je prévois aussi une série plus détaillée en sept parties, &#171;&#160;SQLite from the inside&#160;&#187;, pour 2027. Cet article reste court et suit le même plan que les trois autres.</p>



<h2 class="wp-block-heading">Un fichier, jusqu&rsquo;à trois voisins</h2>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/fichiers-sqlite-journal-wal-voisins_fr.png?ssl=1"><img data-recalc-dims="1" fetchpriority="high" decoding="async" width="629" height="328" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/fichiers-sqlite-journal-wal-voisins_fr.png?resize=629%2C328&#038;ssl=1" alt="Les fichiers SQLite : le fichier principal et ses voisins journal, WAL et mémoire partagée" class="wp-image-5450" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/fichiers-sqlite-journal-wal-voisins_fr.png?w=1800&amp;ssl=1 1800w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/fichiers-sqlite-journal-wal-voisins_fr.png?resize=300%2C157&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/fichiers-sqlite-journal-wal-voisins_fr.png?resize=1024%2C535&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/fichiers-sqlite-journal-wal-voisins_fr.png?resize=768%2C401&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/fichiers-sqlite-journal-wal-voisins_fr.png?resize=1536%2C802&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/fichiers-sqlite-journal-wal-voisins_fr.png?resize=900%2C470&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/fichiers-sqlite-journal-wal-voisins_fr.png?resize=1280%2C668&amp;ssl=1 1280w" sizes="(max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">Une base, c&rsquo;est un fichier. Le journal, le WAL et le shm n&rsquo;existent que lorsque SQLite en a besoin.</figcaption></figure>



<p class="wp-block-paragraph">Une base SQLite est un seul fichier, qu&rsquo;un programme ouvre directement. Dans le mode par défaut, un deuxième fichier n&rsquo;apparaît que pendant une transaction d&rsquo;écriture. C&rsquo;est le journal de retour arrière, et il garde la version d&rsquo;origine de chaque page que la transaction modifie.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">-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</pre>



<p class="wp-block-paragraph">Le journal fait 4 616 octets. Son en-tête l&rsquo;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&rsquo;ai pas testé pourquoi, donc je rapporte seulement ce que j&rsquo;ai vu.</p>



<p class="wp-block-paragraph">En mode WAL, deux autres fichiers apparaissent : le fichier <code>-wal</code>, où s&rsquo;ajoutent les nouvelles versions de pages, et le fichier <code>-shm</code>, 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.</p>



<h2 class="wp-block-heading">L&rsquo;en-tête de 100 octets</h2>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-en-tete-100-octets_fr.png?ssl=1"><img data-recalc-dims="1" decoding="async" width="629" height="361" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-en-tete-100-octets_fr.png?resize=629%2C361&#038;ssl=1" alt="L'en-tête de 100 octets d'un fichier de base SQLite avec les valeurs lues dans le laboratoire" class="wp-image-5451" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-en-tete-100-octets_fr.png?w=1800&amp;ssl=1 1800w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-en-tete-100-octets_fr.png?resize=300%2C172&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-en-tete-100-octets_fr.png?resize=1024%2C587&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-en-tete-100-octets_fr.png?resize=768%2C440&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-en-tete-100-octets_fr.png?resize=1536%2C881&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-en-tete-100-octets_fr.png?resize=900%2C516&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-en-tete-100-octets_fr.png?resize=1280%2C734&amp;ssl=1 1280w" sizes="(max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">L&rsquo;en-tête, champ par champ, avec les valeurs lues dans la base du laboratoire.</figcaption></figure>



<p class="wp-block-paragraph">Chaque fichier de base SQLite commence par un en-tête de 100 octets. Les 16 premiers octets sont le texte <code>SQLite format 3</code> 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&rsquo;encodage du texte et la version de la bibliothèque qui a écrit le fichier. Tous les nombres sont en gros-boutiste.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">$ xxd -l 32 demo.db
00000000: 5351 4c69 7465 2066 6f72 6d61 7420 3300  SQLite format 3.
00000010: 1000 0101 0040 2020 0000 0002 0000 0002  .....@  ........</pre>



<p class="wp-block-paragraph">J&rsquo;ai décodé les champs avec quelques lignes de Python, et le client en ligne de commande est d&rsquo;accord.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">$ sqlite3 demo.db ".dbinfo"
database page size:  4096
file change counter: 2
database page count: 2
freelist page count: 0
schema cookie:       1
text encoding:       1 (utf8)
[...]
software version:    3046001</pre>



<p class="wp-block-paragraph">Deux champs sont à ta disposition. <code>PRAGMA user_version</code> et <code>PRAGMA application_id</code> écrivent directement dans les octets 60 à 71 : après avoir réglé 42 et 1234567890, les octets valent <code>0000 002a</code> et <code>4996 02d2</code>. Beaucoup d&rsquo;outils s&rsquo;en servent pour marquer le format d&rsquo;un fichier.</p>



<h2 class="wp-block-heading">Pages et arbres B dans les fichiers SQLite</h2>



<p class="wp-block-paragraph">Après l&rsquo;en-tête, le fichier est une suite de pages de même taille, ici 4 096 octets. La page 1 contient l&rsquo;en-tête et la table de schéma, <code>sqlite_schema</code>. 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 <code>sqlite_schema</code>. Ce numéro n&rsquo;a jamais changé pendant mes tests, même quand l&rsquo;arbre grandissait. La table virtuelle <code>dbstat</code>, compilée dans le paquet Rocky, compte les pages de chaque arbre.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">name|pagetype|pages|cells|payload|unused
customers|internal|1|21|0|3917
customers|leaf|22|2000|77781|2282
idx_email|internal|1|13|316|3677
idx_email|leaf|14|1987|48449|2822
sqlite_schema|leaf|1|2|186|3794</pre>



<p class="wp-block-paragraph">Avec 2 000 lignes, la table a besoin d&rsquo;une page interne et de 22 feuilles. L&rsquo;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.</p>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-arbres-b-et-debordement_fr.png?ssl=1"><img data-recalc-dims="1" decoding="async" width="629" height="297" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-arbres-b-et-debordement_fr.png?resize=629%2C297&#038;ssl=1" alt="Deux arbres B SQLite, une table et un index, et une chaîne de débordement" class="wp-image-5453" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-arbres-b-et-debordement_fr.png?w=1800&amp;ssl=1 1800w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-arbres-b-et-debordement_fr.png?resize=300%2C142&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-arbres-b-et-debordement_fr.png?resize=1024%2C484&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-arbres-b-et-debordement_fr.png?resize=768%2C363&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-arbres-b-et-debordement_fr.png?resize=1536%2C725&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-arbres-b-et-debordement_fr.png?resize=900%2C425&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-arbres-b-et-debordement_fr.png?resize=1280%2C604&amp;ssl=1 1280w" sizes="(max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">La forme des arbres mesurée avec dbstat, et la règle de débordement vérifiée avec la documentation.</figcaption></figure>



<p class="wp-block-paragraph">Une table déclarée <code>WITHOUT ROWID</code> 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.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">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</pre>



<p class="wp-block-paragraph">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 &#8211; 35 = 4 061 et M = 489. Comme P dépasse X, la partie locale vaut K = M + ((P &#8211; M) % (U &#8211; 4)) = 3 637. C&rsquo;est exactement ce que rapporte <code>dbstat</code>.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">name|pageno|pagetype|ncell|payload|unused|mx_payload
notes|40|leaf|1|3637|441|20005
notes|41|overflow|0|4092|0|0
notes|42|overflow|0|4092|0|0
notes|43|overflow|0|4092|0|0
notes|44|overflow|0|4092|0|0</pre>



<h2 class="wp-block-heading">Anatomie d&rsquo;une page</h2>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-anatomie-page-feuille_fr.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="349" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-anatomie-page-feuille_fr.png?resize=629%2C349&#038;ssl=1" alt="Anatomie d'une page feuille de table SQLite : en-tête, pointeurs, espace libre et cellules" class="wp-image-5452" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-anatomie-page-feuille_fr.png?w=1800&amp;ssl=1 1800w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-anatomie-page-feuille_fr.png?resize=300%2C167&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-anatomie-page-feuille_fr.png?resize=1024%2C569&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-anatomie-page-feuille_fr.png?resize=768%2C427&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-anatomie-page-feuille_fr.png?resize=1536%2C853&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-anatomie-page-feuille_fr.png?resize=900%2C500&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-anatomie-page-feuille_fr.png?resize=1280%2C711&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">La page 2 de la base du laboratoire. Les pointeurs croissent depuis le début, les cellules depuis la fin.</figcaption></figure>



<p class="wp-block-paragraph">Une page d&rsquo;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&rsquo;espace libre se trouve au milieu. Sur la page 2 du laboratoire, le premier octet est <code>0d</code> (une feuille de table), il y a 2 cellules, et le contenu commence au décalage 4 038.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">$ xxd -s 4096 -l 16 demo.db
00001000: 0d00 0000 020f c600 0fe1 0fc6 0000 0000  ................
$ xxd -s 8128 -l 64 demo.db        # the end of page 2, where the cells live
00001fc0: 0000 0000 0000 1902 0500 132b 1142 6f62  ...........+.Bob
00001fd0: 626f 6240 6578 616d 706c 652e 6e65 7444  bob@example.netD
00001fe0: 451d 0105 0017 2f11 416c 6963 6561 6c69  E...../.Aliceali
00001ff0: 6365 4065 7861 6d70 6c65 2e6e 6574 4652  ce@example.netFR</pre>



<p class="wp-block-paragraph">L&rsquo;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&rsquo;en-tête.</p>



<h2 class="wp-block-heading">Un enregistrement, octet par octet</h2>



<p class="wp-block-paragraph">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&rsquo;un octet suit. L&rsquo;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 &#8211; 13) / 2. Le type 0 désigne NULL. Ici la colonne <code>id</code> est le rowid : SQLite stocke NULL et garde la valeur dans la clé de la cellule.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">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)</pre>



<p class="wp-block-paragraph"><code>SELECT rowid, id, name, email, country FROM customers</code> renvoie <code>1|1|Alice|alice@example.net|FR</code> : le décodage est juste.</p>



<h2 class="wp-block-heading">UPDATE et DELETE sur le disque</h2>



<p class="wp-block-paragraph">Rien dans les fichiers SQLite ne fonctionne comme un stock d&rsquo;annulation. SQLite modifie la page elle-même, et il garde l&rsquo;ancienne version dans le journal ou le WAL. Chaque changement validé incrémente le compteur de changements dans l&rsquo;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.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">-- 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</pre>



<p class="wp-block-paragraph">L&rsquo;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.</p>



<p class="wp-block-paragraph">Un DELETE libère la cellule et transforme son espace en bloc libre. Le paquet Rocky est compilé avec <code>SECURE_DELETE</code>, donc <code>PRAGMA secure_delete</code> renvoie 1 et les octets libérés sont mis à zéro. Avec le réglage désactivé, deux occurrences de l&rsquo;adresse e-mail sont restées dans le fichier.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">$ 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     &lt;- 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</pre>



<h2 class="wp-block-heading">Pourquoi les fichiers SQLite ne rétrécissent pas</h2>



<p class="wp-block-paragraph">Quand une page entière se vide, elle rejoint la liste libre, que décrivent les octets 32 à 39 de l&rsquo;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. <code>VACUUM</code> l&rsquo;a reconstruit en 32 768 octets.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="sql">before DELETE         page_count 39   freelist_count 0    size 159744
DELETE ... id &gt; 200   page_count 39   freelist_count 31   size 159744   (header bytes 32-39: 0000 0006 0000 001f)
after VACUUM          page_count 8    freelist_count 0    size 32768</pre>



<p class="wp-block-paragraph">Avec <code>auto_vacuum</code> en mode incrémental, on peut rendre des pages par étapes. L&rsquo;octet 52 de l&rsquo;en-tête contient alors la plus grande page racine, et l&rsquo;octet 64 passe à 1. La page 2 devient une table de pointeurs, faite d&rsquo;entrées de 5 octets : un type et une page parente. <code>dbstat</code> saute cette page.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">PRAGMA auto_vacuum=INCREMENTAL; VACUUM;    -&gt; header byte 52 = 4, byte 64 = 1
DELETE ... id &gt; 200                         page_count 40  freelist_count 31  size 163840
PRAGMA incremental_vacuum(5);               page_count 35  freelist_count 26  size 143360
PRAGMA incremental_vacuum;                  page_count  9  freelist_count  0  size  36864
page 2 (pointer map) starts: 01 00000000 | 01 00000000 | 05 00000003 | 05 00000003 | ...</pre>



<p class="wp-block-paragraph">Un type de page est documenté mais non testé ici : la page d&rsquo;octet verrou, qui n&rsquo;existe que si le fichier dépasse 1 Gio.</p>



<h2 class="wp-block-heading">Le WAL</h2>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-wal-ajout-et-point-de-controle_fr.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="241" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-wal-ajout-et-point-de-controle_fr.png?resize=629%2C241&#038;ssl=1" alt="Comment le WAL de SQLite ajoute des trames et comment un point de contrôle les copie" class="wp-image-5454" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-wal-ajout-et-point-de-controle_fr.png?w=1800&amp;ssl=1 1800w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-wal-ajout-et-point-de-controle_fr.png?resize=300%2C115&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-wal-ajout-et-point-de-controle_fr.png?resize=1024%2C393&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-wal-ajout-et-point-de-controle_fr.png?resize=768%2C294&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-wal-ajout-et-point-de-controle_fr.png?resize=1536%2C589&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-wal-ajout-et-point-de-controle_fr.png?resize=900%2C345&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-wal-ajout-et-point-de-controle_fr.png?resize=1280%2C491&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">Trois trames mesurées dans le WAL, puis des points de contrôle PASSIVE et TRUNCATE.</figcaption></figure>



<p class="wp-block-paragraph">En mode WAL, les fichiers SQLite changent dans un autre ordre : un écrivain ajoute d&rsquo;abord des trames au fichier <code>-wal</code>. Chaque trame est un en-tête de 24 octets suivi d&rsquo;une page. Une trame dont le champ de taille n&rsquo;est pas nul est un commit. Les lecteurs utilisent le dernier commit et l&rsquo;index shm pour trouver la bonne version de chaque page.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">-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</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">WAL magic       : 0x377f0682
format version  : 3007000
page size       : 4096
checkpoint seq  : 0
file size       : 12392 bytes = 32 + 3 frames x (24 + 4096)
  frame 1: page 24, commit-size-after 0 (not a commit frame)
  frame 2: page 39, commit-size-after 39
  frame 3: page 3, commit-size-after 39</pre>



<p class="wp-block-paragraph">Un point de contrôle copie les trames dans le fichier principal. Un point de contrôle PASSIVE l&rsquo;a fait sans toucher au fichier WAL, alors que TRUNCATE l&rsquo;a vidé. J&rsquo;ai désactivé le point de contrôle automatique pour garder les trames visibles ; par défaut, il se déclenche à 1 000 pages.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">PRAGMA wal_checkpoint(PASSIVE);     -&gt; 0|3|3     (busy, frames in the WAL, frames copied)  -wal still 12392 bytes
PRAGMA wal_checkpoint(TRUNCATE);    -&gt; 0|0|0                                              -wal now 0 bytes
last connection closes              -&gt; demo.db-wal and demo.db-shm are gone</pre>



<h2 class="wp-block-heading">Les quatre moteurs côte à côte</h2>



<p class="wp-block-paragraph">Voici comment les fichiers SQLite se comparent aux trois premières parties, avec les valeurs par défaut.</p>



<figure class="wp-block-table"><table><thead><tr><th></th><th>InnoDB</th><th>PostgreSQL</th><th>Oracle</th><th>SQLite</th></tr></thead><tbody><tr><td>Où vivent les données</td><td>fichiers de tablespace, un par table par défaut</td><td>un fichier par table ou index, en segments</td><td>datafiles dans des tablespaces</td><td>un seul fichier pour tout</td></tr><tr><td>Unité d&rsquo;E/S</td><td>page de 16 Ko</td><td>page de 8 Ko</td><td>bloc, 8 Ko par défaut</td><td>page, 4 096 octets ici</td></tr><tr><td>Format d&rsquo;une ligne</td><td>index clusterisé : la table est l&rsquo;arbre de la clé primaire</td><td>tas, les index y pointent</td><td>bloc avec un répertoire de lignes et un ROWID</td><td>arbre B classé par rowid, ou par clé pour WITHOUT ROWID</td></tr><tr><td>Anciennes versions</td><td>journaux d&rsquo;annulation, à part</td><td>dans la table, nettoyées par VACUUM</td><td>tablespace d&rsquo;annulation</td><td>aucune : le journal ou le WAL les garde jusqu&rsquo;au commit</td></tr><tr><td>Sécurité en cas de panne</td><td>journal de reprise et doublewrite buffer</td><td>WAL et écriture de pages complètes</td><td>journaux de reprise</td><td>journal de retour arrière ou WAL</td></tr><tr><td>Récupérer de la place</td><td>reconstruire la table</td><td>VACUUM, VACUUM FULL</td><td>shrink, move</td><td>VACUUM, incremental_vacuum</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Le plus proche cousin de SQLite ici est PostgreSQL pour le WAL, et InnoDB pour l&rsquo;arbre. La grande différence est qu&rsquo;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.</p>



<h2 class="wp-block-heading">Sources</h2>



<ul class="wp-block-list"><li><a href="https://www.sqlite.org/fileformat2.html">SQLite documentation: database file format</a></li><li><a href="https://www.sqlite.org/wal.html">SQLite documentation: write-ahead logging</a></li><li><a href="https://www.sqlite.org/pragma.html">SQLite documentation: PRAGMA statements</a></li><li><a href="https://www.sqlite.org/dbstat.html">SQLite documentation: the dbstat virtual table</a></li><li><a href="https://www.sqlite.org/lang_vacuum.html">SQLite documentation: VACUUM</a></li><li>Les sorties enregistrées viennent de mon propre laboratoire : Rocky Linux 10.2, SQLite 3.46.1, WSL2.</li></ul>



<p class="wp-block-paragraph"><em>Cet article fait partie d&rsquo;une série de notes d&rsquo;admin système (étiquette &#171;&#160;carnet d&rsquo;admin&#160;&#187;).</em><br>Série &#171;&#160;Dans les fichiers d&rsquo;une base de données&#160;&#187;, partie 4/3+1. · <a href="https://blog.neurozone.fr/?p=1789">← Article précédent</a></p>



<h2 class="wp-block-heading">Articles liés</h2>



<ul class="wp-block-list"><li><a href="https://blog.neurozone.fr/?p=1787">Dans les fichiers d&rsquo;une base de données (1/3) : InnoDB, de MySQL à MariaDB</a></li><li><a href="https://blog.neurozone.fr/?p=1788">Dans les fichiers d&rsquo;une base de données (2/3) : PostgreSQL</a></li><li><a href="https://blog.neurozone.fr/?p=1789">Dans les fichiers d&rsquo;une base de données (3/3) : Oracle, et les trois côte à côte</a></li></ul>


<p>L’article <a href="https://blog.neurozone.fr/2026/11/12/dans-les-fichiers-d-une-base-de-donnees-sqlite/">Dans les fichiers d&rsquo;une base de données (4/3+1) : SQLite</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://blog.neurozone.fr/2026/11/12/dans-les-fichiers-d-une-base-de-donnees-sqlite/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">5455</post-id>	</item>
		<item>
		<title>Séquences et colonnes identity dans PostgreSQL : trous, rollbacks et crashs, testés</title>
		<link>https://blog.neurozone.fr/2026/11/10/sequences-postgresql-colonnes-identity-trous-rollbacks-crashs-testes/</link>
					<comments>https://blog.neurozone.fr/2026/11/10/sequences-postgresql-colonnes-identity-trous-rollbacks-crashs-testes/#respond</comments>
		
		<dc:creator><![CDATA[Hécate Raven]]></dc:creator>
		<pubDate>Tue, 10 Nov 2026 09:00:00 +0000</pubDate>
				<category><![CDATA[Informatique]]></category>
		<category><![CDATA[carnet d'admin]]></category>
		<category><![CDATA[Français]]></category>
		<category><![CDATA[InnoDB]]></category>
		<category><![CDATA[MariaDB]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[Oracle]]></category>
		<category><![CDATA[PostgreSQL]]></category>
		<category><![CDATA[série : Dans les fichiers d'une base de données]]></category>
		<category><![CDATA[stockage]]></category>
		<guid isPermaLink="false">https://blog.neurozone.fr/?p=5533</guid>

					<description><![CDATA[<p>Séquences PostgreSQL et colonnes identity en 18.6 : serial ou identity, ALWAYS ou BY DEFAULT, trous après rollback, CACHE et trou après kill -9.</p>
<p>L’article <a href="https://blog.neurozone.fr/2026/11/10/sequences-postgresql-colonnes-identity-trous-rollbacks-crashs-testes/">Séquences et colonnes identity dans PostgreSQL : trous, rollbacks et crashs, testés</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph"><strong>En bref :</strong> j&rsquo;ai fait un <code>kill -9</code> sur les processus postgres : une colonne identity PostgreSQL est passée de l&rsquo;id 9 à 37, parce que les séquences PostgreSQL journalisent 32 valeurs d&rsquo;avance dans le WAL. Rollbacks et insertions en échec brûlent aussi des valeurs, et une colonne <code>BY DEFAULT</code> ignore les id explicites. Préférer <code>GENERATED ... AS IDENTITY</code> à <code>serial</code>, et ne jamais compter sur des id continus.</p>



<p class="wp-block-paragraph"><em>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.</em></p>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sequences-postgresql-colonnes-identity-trous-rollbacks-crashs-testes.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="164" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sequences-postgresql-colonnes-identity-trous-rollbacks-crashs-testes.png?resize=629%2C164&#038;ssl=1" alt="Séquences PostgreSQL après kill -9 : id 1 à 9, puis un trou de 27 valeurs, puis 37" class="wp-image-5532" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sequences-postgresql-colonnes-identity-trous-rollbacks-crashs-testes.png?w=1800&amp;ssl=1 1800w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sequences-postgresql-colonnes-identity-trous-rollbacks-crashs-testes.png?resize=300%2C78&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sequences-postgresql-colonnes-identity-trous-rollbacks-crashs-testes.png?resize=1024%2C267&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sequences-postgresql-colonnes-identity-trous-rollbacks-crashs-testes.png?resize=768%2C201&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sequences-postgresql-colonnes-identity-trous-rollbacks-crashs-testes.png?resize=1536%2C401&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sequences-postgresql-colonnes-identity-trous-rollbacks-crashs-testes.png?resize=900%2C235&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sequences-postgresql-colonnes-identity-trous-rollbacks-crashs-testes.png?resize=1280%2C334&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">Un kill -9 sur PostgreSQL 18.6 : 27 valeurs réservées et jamais distribuées.</figcaption></figure>



<h2 class="wp-block-heading">Trois façons d&rsquo;obtenir une valeur générée</h2>



<pre class="EnlighterJSRAW" data-enlighter-language="sql">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);</pre>



<p class="wp-block-paragraph">Chaque <code>INSERT ... RETURNING id</code> renvoie 1 sur une table neuve. Le catalogue montre la différence :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell"> column_name |            column_default            | is_identity | identity_generation
-------------+--------------------------------------+-------------+---------------------
 id          |                                      | YES         | ALWAYS
 id          | nextval('s1'::regclass)              | NO          |
 id          | nextval('t_serial_id_seq'::regclass) | NO          |</pre>



<p class="wp-block-paragraph">Une colonne identity n&rsquo;a pas d&rsquo;expression par défaut ; la séquence est attachée à la colonne (<code>pg_get_serial_sequence('t_ident','id')</code> renvoie <code>public.t_ident_id_seq</code>). <code>serial</code> est un raccourci : colonne entière plus défaut appelant <code>nextval</code>.</p>



<h2 class="wp-block-heading">ALWAYS ou BY DEFAULT</h2>



<p class="wp-block-paragraph">Avec <code>GENERATED BY DEFAULT</code>, une valeur explicite est acceptée mais ne fait pas avancer le compteur :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="sql">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</pre>



<p class="wp-block-paragraph">L&rsquo;insertion en échec a brûlé la valeur 2 du compteur. Avec <code>GENERATED ALWAYS</code>, une valeur explicite est refusée sauf demande expresse :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="yaml">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.</pre>



<p class="wp-block-paragraph">Après <code>OVERRIDING SYSTEM VALUE VALUES (50, ...)</code>, l&rsquo;insertion par défaut suivante a renvoyé 1 : le compteur est indépendant des données.</p>



<h2 class="wp-block-heading">Rollback et échecs consomment des valeurs</h2>



<pre class="EnlighterJSRAW" data-enlighter-language="sql">BEGIN; INSERT INTO t_ident(v) VALUES ('rolled back') RETURNING id;   -- 2
ROLLBACK;
INSERT INTO t_ident(v) VALUES ('after rollback') RETURNING id;       -- 3</pre>



<p class="wp-block-paragraph">Idem avec une violation d&rsquo;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.</p>



<h2 class="wp-block-heading">Concurrence : l&rsquo;ordre des id n&rsquo;est pas l&rsquo;ordre des commits</h2>



<p class="wp-block-paragraph">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 :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">A got id 1
B got id 2
B: committed 08:18:48.652
A: committed 08:18:51.154</pre>



<p class="wp-block-paragraph">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.</p>



<h2 class="wp-block-heading">CACHE est par session</h2>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">CREATE SEQUENCE s_cache CACHE 10;
same session twice : 1|2
new session        : 11
another new session: 21</pre>



<p class="wp-block-paragraph">Avec les séquences PostgreSQL, chaque session réserve un bloc de 10. Les valeurs de sessions différentes s&rsquo;entrelacent et des trous apparaissent dès qu&rsquo;une session se termine.</p>



<h2 class="wp-block-heading">Limites et CYCLE</h2>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">ERROR:  nextval: reached maximum value of sequence "s_small" (3)</pre>



<p class="wp-block-paragraph">Après <code>ALTER SEQUENCE s_small CYCLE</code>, la valeur suivante est 1. Une identity sur <code>smallint</code> s&rsquo;arrête à 32767 avec la même erreur sur <code>t_small_id_seq</code>. Les identity <code>integer</code> s&rsquo;arrêtent à 2147483647 : prendre <code>bigint</code> pour les tables à longue vie.</p>



<h2 class="wp-block-heading">Redémarrage et crash</h2>



<p class="wp-block-paragraph">Trois lignes insérées, la dernière supprimée, redémarrage propre :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">sequence before the restart: 3|30|t     (last_value | log_cnt | is_called)
sequence after the restart:  3|30|t
new id = 4</pre>



<p class="wp-block-paragraph">Puis cinq lignes de plus (dernier id 9), <code>kill -9</code> de tous les processus postgres, redémarrage :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">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</pre>



<p class="wp-block-paragraph"><code>log_cnt</code> 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&rsquo;id suivant est 37. Un arrêt propre écrit l&rsquo;état exact, sans trou.</p>



<h2 class="wp-block-heading">Où lire l&rsquo;état des séquences PostgreSQL</h2>



<pre class="EnlighterJSRAW" data-enlighter-language="sql">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 &lt;&gt; '';  -- 'a' ALWAYS, 'd' BY DEFAULT</pre>



<h2 class="wp-block-heading">Documentation officielle</h2>



<p class="wp-block-paragraph"><a href="https://www.postgresql.org/" rel="noopener">PostgreSQL</a>, site du projet</p>



<p class="wp-block-paragraph"><a href="https://www.postgresql.org/docs/18/sql-createsequence.html" rel="noopener">CREATE SEQUENCE</a>, documentation PostgreSQL 18</p>



<p class="wp-block-paragraph"><a href="https://www.postgresql.org/docs/18/functions-sequence.html" rel="noopener">Sequence manipulation functions</a>, <code>nextval</code>, <code>currval</code>, <code>setval</code></p>



<p class="wp-block-paragraph"><a href="https://www.postgresql.org/docs/18/sql-createtable.html" rel="noopener">CREATE TABLE</a>, <code>GENERATED ... AS IDENTITY</code> et <code>serial</code></p>



<p class="wp-block-paragraph"><a href="https://www.postgresql.org/docs/18/view-pg-sequences.html" rel="noopener">pg_sequences</a>, la vue système utilisée plus haut</p>



<p class="wp-block-paragraph">*Non testé ici : réplication logique des séquences, <code>pg_upgrade</code>, tables partitionnées, <code>ALTER SEQUENCE ... RESTART</code> en cours d&rsquo;usage.*</p>



<h2 class="wp-block-heading">Toutes les parties de la série</h2>



<p class="wp-block-paragraph"><strong>1/5 PostgreSQL</strong></p>



<p class="wp-block-paragraph"><span class="link-upcoming">2/5 MariaDB (à paraître le 17/11)</span></p>



<p class="wp-block-paragraph"><span class="link-upcoming">3/5 Oracle (à paraître le 24/11)</span></p>



<p class="wp-block-paragraph"><span class="link-upcoming">4/5 SQL Server (à paraître le 1/12)</span></p>



<p class="wp-block-paragraph"><span class="link-upcoming">5/5 TL;DR : les quatre moteurs comparés (à paraître le 8/12)</span></p>



<h2 class="wp-block-heading">Articles liés</h2>



<p class="wp-block-paragraph"><a href="https://blog.neurozone.fr/?p=1787">Dans les fichiers d&rsquo;une base de données (1/3) : InnoDB, de MySQL à MariaDB</a></p>



<p class="wp-block-paragraph"><a href="https://blog.neurozone.fr/?p=1788">Dans les fichiers d&rsquo;une base de données (2/3) : PostgreSQL</a></p>



<p class="wp-block-paragraph"><a href="https://blog.neurozone.fr/?p=1789">Dans les fichiers d&rsquo;une base de données (3/3) : Oracle, et les trois côte à côte</a></p>


<p>L’article <a href="https://blog.neurozone.fr/2026/11/10/sequences-postgresql-colonnes-identity-trous-rollbacks-crashs-testes/">Séquences et colonnes identity dans PostgreSQL : trous, rollbacks et crashs, testés</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://blog.neurozone.fr/2026/11/10/sequences-postgresql-colonnes-identity-trous-rollbacks-crashs-testes/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">5533</post-id>	</item>
		<item>
		<title>Table LanceDB sur disque : 265 versions, 13,4 Gio, un seul fragment vivant</title>
		<link>https://blog.neurozone.fr/2026/11/08/table-lancedb-sur-disque-versions-manifestes-fragments/</link>
					<comments>https://blog.neurozone.fr/2026/11/08/table-lancedb-sur-disque-versions-manifestes-fragments/#respond</comments>
		
		<dc:creator><![CDATA[Hécate Raven]]></dc:creator>
		<pubDate>Sun, 08 Nov 2026 09:00:00 +0000</pubDate>
				<category><![CDATA[Informatique]]></category>
		<category><![CDATA[carnet d'admin]]></category>
		<category><![CDATA[Français]]></category>
		<category><![CDATA[InnoDB]]></category>
		<category><![CDATA[MariaDB]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[Oracle]]></category>
		<category><![CDATA[PostgreSQL]]></category>
		<category><![CDATA[série : Dans les fichiers d'une base de données]]></category>
		<category><![CDATA[stockage]]></category>
		<guid isPermaLink="false">https://blog.neurozone.fr/?p=5572</guid>

					<description><![CDATA[<p>Ce que contient une table LanceDB sur disque : 265 versions, 13,4 Gio, un fragment vivant. Manifestes et transactions décodés sur une vraie table.</p>
<p>L’article <a href="https://blog.neurozone.fr/2026/11/08/table-lancedb-sur-disque-versions-manifestes-fragments/">Table LanceDB sur disque : 265 versions, 13,4 Gio, un seul fragment vivant</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph"><strong>J&rsquo;ai lancé un script en lecture seule sur ma propre machine pour regarder à l&rsquo;intérieur d&rsquo;une table LanceDB, et un assistant IA (Claude, d&rsquo;Anthropic) m&rsquo;a aidée à écrire ce script et à rédiger cet article.</strong> Les chiffres ci-dessous sont la sortie réelle de ce lancement, avec LanceDB 0.41.0 et pyarrow 26.0.1. J&rsquo;ai remplacé le nom de la table et les chemins. C&rsquo;est le premier article d&rsquo;une série sur LanceDB vu par une DBA.</p>



<h2 class="wp-block-heading">Réponse rapide</h2>



<p class="wp-block-paragraph">Une table LanceDB est un répertoire, et rien d&rsquo;autre : des fichiers de données dans <code>data/</code>, un manifeste par version dans <code>_versions/</code>, un fichier de transaction par validation dans <code>_transactions/</code>, et un répertoire <code>_indices/</code> dès qu&rsquo;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&rsquo;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&rsquo;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&rsquo;est ainsi qu&rsquo;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.</p>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/table-lancedb-sur-disque-versions-manifestes-fragments.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="224" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/table-lancedb-sur-disque-versions-manifestes-fragments.png?resize=629%2C224&#038;ssl=1" alt="Table LanceDB sur disque : la version 265 réécrit 226 fragments en un, 5,8 Gio lus sur 13,4 Gio dans data/" class="wp-image-5571" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/table-lancedb-sur-disque-versions-manifestes-fragments.png?w=1800&amp;ssl=1 1800w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/table-lancedb-sur-disque-versions-manifestes-fragments.png?resize=300%2C107&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/table-lancedb-sur-disque-versions-manifestes-fragments.png?resize=1024%2C364&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/table-lancedb-sur-disque-versions-manifestes-fragments.png?resize=768%2C273&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/table-lancedb-sur-disque-versions-manifestes-fragments.png?resize=1536%2C546&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/table-lancedb-sur-disque-versions-manifestes-fragments.png?resize=900%2C320&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/table-lancedb-sur-disque-versions-manifestes-fragments.png?resize=1280%2C455&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">Versions 263 à 265 et part de data/ que lit la version courante.</figcaption></figure>



<h2 class="wp-block-heading">Ce que contient le répertoire d&rsquo;une table LanceDB</h2>



<p class="wp-block-paragraph">La première partie de mon script parcourt le répertoire de la table LanceDB, <code>docs_index.lance</code>, et n&rsquo;a pas besoin de LanceDB. L&rsquo;arborescence contient trois sous-répertoires. La <a href="https://lance.org/format/table/layout/" rel="noopener">documentation de l&rsquo;agencement des fichiers</a> en liste d&rsquo;autres, dont deux, <code>_indices/</code> et <code>_deletions/</code>, sont absents de ma table.</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Répertoire</th><th>Ce que dit la documentation</th><th>Ce que j&rsquo;ai trouvé</th></tr></thead><tbody><tr><td><code>data/</code></td><td>fichiers de données (<code>*.lance</code>)</td><td>260 fichiers, 13,4 Gio, médiane 21,6 Mio, le plus gros 5,8 Gio</td></tr><tr><td><code>_versions/</code></td><td>manifestes (<code>*.manifest</code>) et un <code>latest_version_hint.json</code> optionnel</td><td>265 manifestes, 2,4 Mio au total, plus un fichier d&rsquo;indice de 15 octets</td></tr><tr><td><code>_transactions/</code></td><td>fichiers de transaction (<code>*.txn</code>)</td><td>265 fichiers, 56,7 Kio au total, médiane 132 octets</td></tr><tr><td><code>_indices/</code></td><td>un dossier par index</td><td>absent</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">La table compte 791 fichiers en tout. Les statistiques donnent <code>num_indices: 0</code> et <code>num_deleted_rows: 0</code>, ce qui correspond à l&rsquo;absence de ces deux répertoires : je n&rsquo;ai jamais construit d&rsquo;index et jamais supprimé de ligne.</p>



<h2 class="wp-block-heading">Une version, c&rsquo;est un manifeste et une transaction</h2>



<p class="wp-block-paragraph">Le manifeste est le cœur du format. La <a href="https://lance.org/format/table/" rel="noopener">documentation</a> le décrit comme la description immuable d&rsquo;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&rsquo;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.</p>



<p class="wp-block-paragraph">Les noms portent de l&rsquo;information. Un fichier de transaction s&rsquo;appelle <code>{read_version}-{uuid}.txn</code>, où le nombre est la version à partir de laquelle la transaction a été construite, et le plus gros de ma table est <code>263-45522501-…txn</code>, de 20,1 Kio. Les manifestes déroutent au début, mais la logique est simple : le fichier de la version 265 est <code>18446744073709551350.manifest</code>. Ce nombre est 18446744073709551615, le plus grand entier non signé sur 64 bits, moins 265, et la version 1 est <code>18446744073709551614.manifest</code>. Les noms décroissent donc quand les versions augmentent. Les pages de documentation que j&rsquo;ai lues n&rsquo;expliquent pas ce schéma, donc ma lecture n&rsquo;est qu&rsquo;une hypothèse : la version la plus récente arrive en premier dans un listing de répertoire. Le fichier <code>latest_version_hint.json</code> fait 15 octets. La documentation parle d&rsquo;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.</p>



<h2 class="wp-block-heading">Chaque ajout crée un fragment</h2>



<p class="wp-block-paragraph">Les cinq dernières versions montrent comment la table a grossi. <code>list_versions</code> a renvoyé ces lignes, que j&rsquo;ai réduites aux champs intéressants et alignées en tableau :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">version  timestamp                    rows       data files
261      2026-11-03 22:55:19.615921   1,123,032  224
262      2026-11-03 22:56:55.260015   1,127,160  225
263      2026-11-03 22:58:15.392524   1,130,595  226
264      2026-11-03 22:59:22.144490   1,130,595  226
265      2026-11-03 22:59:22.177593   1,130,595  1</pre>



<p class="wp-block-paragraph">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&rsquo;est l&rsquo;<a href="https://lance.org/format/table/transaction/" rel="noopener">opération Append</a> 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.</p>



<p class="wp-block-paragraph">Les versions 264 et 265 sont séparées de 33 millisecondes, et le nombre de lignes ne change pas. J&rsquo;ai décodé leurs transactions avec les définitions protobuf publiées dans le dépôt Lance. La version 264 est une transaction <code>reserve_fragments</code> de 46 octets : elle réserve un identifiant de fragment, et le <code>max_fragment_id</code> du manifeste passe de 258 à 259 alors que les 226 fragments restent les mêmes. La version 265 est une transaction <code>rewrite</code>, le compactage : elle remplace les 226 anciens fragments par un nouveau fragment, d&rsquo;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&rsquo;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 <code>overwrite</code> de 329 octets.</p>



<h2 class="wp-block-heading">La version courante d&rsquo;une table LanceDB est plus petite que la table</h2>



<p class="wp-block-paragraph">Les statistiques de la version 265 donnent la taille de ce dont la table a réellement besoin :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="yaml">total_bytes: 6,203,819,954   num_rows: 1,130,595   num_deleted_rows: 0
num_indices: 0               num_fragments: 1      num_small_fragments: 0</pre>



<p class="wp-block-paragraph">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 <code>data/</code> 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&rsquo;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.</p>



<h2 class="wp-block-heading">Pourquoi le manifeste d&rsquo;un seul fragment pèse 20,5 Kio</h2>



<p class="wp-block-paragraph">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&rsquo;a qu&rsquo;un fragment et son fichier fait pourtant 21 031 octets, et je l&rsquo;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 <code>LANC</code>. 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 <code>263-45522501-…txn</code>, qui liste les 226 fragments qu&rsquo;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.</p>



<p class="wp-block-paragraph">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&rsquo;appelle <code>transaction_section</code> dans le protobuf. Je n&rsquo;ai pas trouvé cela décrit dans les pages du format que j&rsquo;ai lues.</p>



<h2 class="wp-block-heading">Ce que je n&rsquo;ai pas testé</h2>



<p class="wp-block-paragraph">Je n&rsquo;ai lancé aucune opération d&rsquo;écriture sur la table d&rsquo;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 <code>count_rows</code>, <code>schema</code>, <code>list_versions</code>, <code>list_indices</code> et <code>stats</code>. J&rsquo;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&rsquo;ai pas non plus mesuré de temps de requête, qui dépend de l&rsquo;index et viendra dans un prochain article.</p>



<h2 class="wp-block-heading">Documentation officielle</h2>



<p class="wp-block-paragraph">Les pages <a href="https://lance.org/format/table/" rel="noopener">format de table Lance</a>, <a href="https://lance.org/format/table/layout/" rel="noopener">agencement des fichiers</a>, <a href="https://lance.org/format/table/versioning/" rel="noopener">versions</a> et <a href="https://lance.org/format/table/transaction/" rel="noopener">transactions</a> décrivent ce que j&rsquo;ai mesuré, et les définitions protobuf <a href="https://github.com/lance-format/lance/blob/main/protos/table.proto" rel="noopener">table.proto</a> et <a href="https://github.com/lance-format/lance/blob/main/protos/transaction.proto" rel="noopener">transaction.proto</a> donnent les noms de champs. Le <a href="https://lancedb.com/" rel="noopener">projet LanceDB</a> documente l&rsquo;API Python derrière le script.</p>


<p>L’article <a href="https://blog.neurozone.fr/2026/11/08/table-lancedb-sur-disque-versions-manifestes-fragments/">Table LanceDB sur disque : 265 versions, 13,4 Gio, un seul fragment vivant</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://blog.neurozone.fr/2026/11/08/table-lancedb-sur-disque-versions-manifestes-fragments/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">5572</post-id>	</item>
		<item>
		<title>Dans les fichiers d&#8217;une base de données (3/3) : Oracle, et les trois côte à côte</title>
		<link>https://blog.neurozone.fr/2026/10/27/dans-les-fichiers-dune-base-de-donnees-3-3-oracle-et-les-trois-cote-a-cote/</link>
					<comments>https://blog.neurozone.fr/2026/10/27/dans-les-fichiers-dune-base-de-donnees-3-3-oracle-et-les-trois-cote-a-cote/#respond</comments>
		
		<dc:creator><![CDATA[Hécate Raven]]></dc:creator>
		<pubDate>Tue, 27 Oct 2026 14:00:00 +0000</pubDate>
				<category><![CDATA[Informatique]]></category>
		<category><![CDATA[carnet d'admin]]></category>
		<category><![CDATA[Français]]></category>
		<category><![CDATA[InnoDB]]></category>
		<category><![CDATA[MariaDB]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[Oracle]]></category>
		<category><![CDATA[PostgreSQL]]></category>
		<category><![CDATA[série : Dans les fichiers d'une base de données]]></category>
		<category><![CDATA[stockage]]></category>
		<guid isPermaLink="false">https://blog.neurozone.fr/?p=1789</guid>

					<description><![CDATA[<p>Blocs, extents, segments et ROWID : l'organisation d'Oracle sur disque, la lecture cohérente par l'undo, puis la comparaison avec InnoDB et PostgreSQL.</p>
<p>L’article <a href="https://blog.neurozone.fr/2026/10/27/dans-les-fichiers-dune-base-de-donnees-3-3-oracle-et-les-trois-cote-a-cote/">Dans les fichiers d&rsquo;une base de données (3/3) : Oracle, et les trois côte à côte</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Dernier volet de la série, Oracle. Je ne fais pas tourner Oracle chez moi. Ce billet s&rsquo;est d&rsquo;abord appuyé sur mon métier de DBA et sur la documentation d&rsquo;Oracle ; je l&rsquo;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 <em>logique</em> (tablespaces, segments, extents, blocs) des fichiers <em>physiques</em>, et il expose presque tout dans des vues du dictionnaire.</p>



<h2 class="wp-block-heading">Structure logique et fichiers physiques</h2>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_structure_fr.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="308" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_structure_fr.png?resize=629%2C308&#038;ssl=1" alt="Structure logique et physique d&#x27;une base Oracle" class="wp-image-1781" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_structure_fr.png?w=1800&amp;ssl=1 1800w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_structure_fr.png?resize=300%2C147&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_structure_fr.png?resize=1024%2C501&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_structure_fr.png?resize=768%2C375&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_structure_fr.png?resize=1536%2C751&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_structure_fr.png?resize=900%2C440&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_structure_fr.png?resize=1280%2C626&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">Côté logique, la hiérarchie tablespace, segment, extent, bloc ; côté physique, les datafiles.</figcaption></figure>



<p class="wp-block-paragraph">Une base Oracle se compose de plusieurs familles de fichiers :</p>



<ul class="wp-block-list"><li><strong>les datafiles</strong> contiennent les données. Un tablespace regroupe un ou plusieurs datafiles, et un datafile n&rsquo;appartient qu&rsquo;à un seul tablespace ;</li><li><strong>les control files</strong> 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&rsquo;historique des sauvegardes RMAN. On les multiplexe sur plusieurs disques, car sans eux la base ne démarre pas ;</li><li><strong>les online redo logs</strong> forment un journal circulaire, organisé en groupes de fichiers identiques (membres). Quand un groupe est plein, Oracle passe au suivant (<em>log switch</em>). En mode <code>ARCHIVELOG</code>, chaque groupe plein est copié en <strong>archived log</strong> avant d&rsquo;être réutilisé : c&rsquo;est ce qui permet une restauration à un instant précis ;</li><li><strong>le tablespace d&rsquo;undo</strong> garde les images d&rsquo;avant des données modifiées ;</li><li><strong>les tempfiles</strong> servent aux tris et aux jointures qui ne tiennent pas en mémoire ; ils ne sont pas journalisés ni sauvegardés ;</li><li><strong>le spfile</strong> contient les paramètres de l&rsquo;instance, et le fichier de mots de passe permet les connexions administrateur à distance.</li></ul>



<pre class="EnlighterJSRAW" data-enlighter-language="sql">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;</pre>



<p class="wp-block-paragraph">Côté logique, un <strong>segment</strong> correspond à un objet qui occupe de la place : une table, un index, une partition, un LOB. Un segment est composé d&rsquo;<strong>extents</strong>, chacun formé de blocs contigus <em>dans un seul datafile</em>. Un segment peut donc s&rsquo;étaler sur plusieurs fichiers, extent par extent. Enfin, le <strong>bloc</strong> est l&rsquo;unité d&rsquo;entrée-sortie, 8 Ko par défaut (<code>DB_BLOCK_SIZE</code>, fixé à la création de la base).</p>



<pre class="EnlighterJSRAW" data-enlighter-language="sql">SELECT segment_name, segment_type, extents, blocks FROM dba_segments WHERE owner = &#x27;SHOP&#x27;;
SELECT extent_id, file_id, block_id, blocks FROM dba_extents
WHERE owner = &#x27;SHOP&#x27; AND segment_name = &#x27;ORDERS&#x27; ORDER BY extent_id;</pre>



<h2 class="wp-block-heading">Dans un datafile Oracle</h2>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_datafile_fr.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="231" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_datafile_fr.png?resize=629%2C231&#038;ssl=1" alt="Organisation d&#x27;un datafile et d&#x27;un segment ASSM" class="wp-image-1779" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_datafile_fr.png?w=1800&amp;ssl=1 1800w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_datafile_fr.png?resize=300%2C110&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_datafile_fr.png?resize=1024%2C375&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_datafile_fr.png?resize=768%2C282&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_datafile_fr.png?resize=1536%2C563&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_datafile_fr.png?resize=900%2C330&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_datafile_fr.png?resize=1280%2C469&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">En-tête de fichier, bitmap des extents, blocs bitmap ASSM, en-tête de segment, blocs de données et High Water Mark.</figcaption></figure>



<p class="wp-block-paragraph">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 <strong>géré localement</strong> (le cas normal aujourd&rsquo;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&rsquo;allocation existent : <code>UNIFORM</code> (tous les extents ont la même taille) et <code>AUTOALLOCATE</code>, 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).</p>



<p class="wp-block-paragraph">Dans un segment en <strong>ASSM</strong> (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&rsquo;elles se battent pour le même bloc. L&rsquo;<strong>en-tête de segment</strong> garde la liste des extents et la <strong>High Water Mark</strong> (HWM), la limite des blocs déjà formatés. Un parcours complet de la table lit tous les blocs jusqu&rsquo;à la HWM, même vides : après un gros <code>DELETE</code>, un <code>ALTER TABLE orders SHRINK SPACE</code> (ou <code>MOVE</code>) fait redescendre la HWM.</p>



<h2 class="wp-block-heading">Anatomie d&rsquo;un bloc Oracle</h2>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_block_fr.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="379" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_block_fr.png?resize=629%2C379&#038;ssl=1" alt="Structure d&#x27;un bloc de données Oracle de 8 Ko" class="wp-image-1778" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_block_fr.png?w=1720&amp;ssl=1 1720w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_block_fr.png?resize=300%2C181&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_block_fr.png?resize=1024%2C617&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_block_fr.png?resize=768%2C463&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_block_fr.png?resize=1536%2C925&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_block_fr.png?resize=900%2C542&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_block_fr.png?resize=1280%2C771&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">En-tête cache, ITL, répertoires, espace libre, lignes et queue.</figcaption></figure>



<ul class="wp-block-list"><li><strong>L&rsquo;en-tête cache</strong> (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 <code>DB_BLOCK_CHECKSUM=TYPICAL</code>, la somme est vérifiée à chaque lecture depuis le disque ;</li><li><strong>l&rsquo;ITL</strong> (Interested Transaction List) est propre à Oracle : une entrée de 24 octets par transaction qui modifie le bloc, avec son identifiant (XID), l&rsquo;adresse de son undo et son état. <code>INITRANS</code> fixe le nombre d&rsquo;entrées réservées à la création du bloc. Si toutes sont prises et que le bloc n&rsquo;a plus de place pour en ajouter, une nouvelle transaction attend : c&rsquo;est l&rsquo;attente <code>enq: TX - allocate ITL entry</code>, qu&rsquo;on corrige en augmentant <code>INITRANS</code> sur les tables très concurrentes ;</li><li><strong>le répertoire des lignes</strong> 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 ;</li><li><strong>PCTFREE</strong> (10 % par défaut) réserve de l&rsquo;espace libre pour les <code>UPDATE</code> qui allongent les lignes. Sans cette réserve, une ligne qui grandit ne tient plus dans son bloc et doit <em>migrer</em> : 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 <em>n&rsquo;importe quel</em> bloc est <em>chaînée</em> sur plusieurs blocs. La statistique <code>table fetch continued row</code> mesure les deux ;</li><li><strong>la queue</strong> (4 octets) répète une partie du SCN, du type et de la séquence de l&rsquo;en-tête : un bloc dont la tête et la queue ne correspondent pas a été écrit à moitié (<em>fractured block</em>). Oracle n&rsquo;a pas de doublewrite : ce cas est détecté à la lecture, et le bloc se répare depuis une sauvegarde RMAN et les journaux (<code>RECOVER ... BLOCK</code>). Pendant une sauvegarde à chaud manuelle (<code>BEGIN BACKUP</code>), Oracle écrit l&rsquo;image complète des blocs dans le redo à leur première modification, comme le <code>full_page_writes</code> de PostgreSQL.</li></ul>



<h2 class="wp-block-heading">Une ligne et son ROWID</h2>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_row_fr.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="219" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_row_fr.png?resize=629%2C219&#038;ssl=1" alt="Format d&#x27;une ligne Oracle et structure du ROWID" class="wp-image-1780" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_row_fr.png?w=1800&amp;ssl=1 1800w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_row_fr.png?resize=300%2C105&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_row_fr.png?resize=1024%2C357&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_row_fr.png?resize=768%2C268&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_row_fr.png?resize=1536%2C536&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_row_fr.png?resize=900%2C314&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/oracle_row_fr.png?resize=1280%2C447&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">Une ligne : en-tête de 3 octets puis longueur et valeur de chaque colonne ; le ROWID en quatre parties.</figcaption></figure>



<p class="wp-block-paragraph">Une ligne commence par trois octets : des indicateurs, l&rsquo;<strong>octet de verrou</strong> 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 (<code>0xFF</code>), 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.</p>



<p class="wp-block-paragraph">L&rsquo;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 <em>son</em> entrée dans l&rsquo;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&rsquo;en verrouiller une. Une autre transaction qui veut la même ligne lit l&rsquo;ITL, trouve la transaction propriétaire et attend la fin de celle-ci. Après le <code>COMMIT</code>, l&rsquo;octet de verrou n&rsquo;est pas remis à zéro tout de suite : il continue de pointer vers l&rsquo;entrée ITL jusqu&rsquo;à ce qu&rsquo;une visite ultérieure nettoie le bloc (voir le test en fin de billet).</p>



<p class="wp-block-paragraph">Le <strong>ROWID</strong> est l&rsquo;adresse physique d&rsquo;une ligne : numéro d&rsquo;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&rsquo;est l&rsquo;accès le plus rapide qui soit, et c&rsquo;est ce que contiennent les index B-tree. Le paquet <code>DBMS_ROWID</code> le décode :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">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;</pre>



<p class="wp-block-paragraph">Pour voir le contenu brut d&rsquo;un bloc, <code>ALTER SYSTEM DUMP DATAFILE 4 BLOCK 151;</code> é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&rsquo;est très instructif, mais ce n&rsquo;est pas un outil de production.</p>



<h2 class="wp-block-heading">Lecture cohérente : l&rsquo;undo</h2>



<p class="wp-block-paragraph">Comme InnoDB, Oracle modifie les blocs sur place et range l&rsquo;image d&rsquo;avant dans l&rsquo;<strong>undo</strong>. 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&rsquo;undo désignés par l&rsquo;ITL, jusqu&rsquo;à obtenir le bloc tel qu&rsquo;il était à son SCN de départ : c&rsquo;est le <em>consistent read</em>. Les lecteurs ne bloquent jamais les écrivains, ni l&rsquo;inverse.</p>



<p class="wp-block-paragraph">Le revers : si l&rsquo;undo nécessaire a déjà été écrasé par des transactions plus récentes, la requête échoue avec <code>ORA-01555: snapshot too old</code>. <code>UNDO_RETENTION</code> indique combien de temps Oracle doit essayer de garder l&rsquo;undo, à condition que le tablespace d&rsquo;undo soit assez grand. Autre phénomène, cousin des hint bits de PostgreSQL : le <em>delayed block cleanout</em>. Après un gros <code>COMMIT</code>, Oracle ne revisite pas tous les blocs modifiés, et c&rsquo;est le premier <code>SELECT</code> suivant qui nettoie les entrées ITL… et génère du redo, à la surprise générale.</p>



<h2 class="wp-block-heading">Les trois moteurs côte à côte</h2>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/mvcc_compare_fr.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="231" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/mvcc_compare_fr.png?resize=629%2C231&#038;ssl=1" alt="Où vivent les anciennes versions dans InnoDB, PostgreSQL et Oracle" class="wp-image-1777" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/mvcc_compare_fr.png?w=1800&amp;ssl=1 1800w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/mvcc_compare_fr.png?resize=300%2C110&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/mvcc_compare_fr.png?resize=1024%2C375&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/mvcc_compare_fr.png?resize=768%2C282&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/mvcc_compare_fr.png?resize=1536%2C563&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/mvcc_compare_fr.png?resize=900%2C330&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/mvcc_compare_fr.png?resize=1280%2C469&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">Trois réponses à la même question.</figcaption></figure>



<figure class="wp-block-table"><table><thead><tr><th></th><th>InnoDB</th><th>PostgreSQL</th><th>Oracle</th></tr></thead><tbody><tr><td>Taille de page</td><td>16 Ko</td><td>8 Ko</td><td>8 Ko (2 à 32 Ko)</td></tr><tr><td>Organisation de la table</td><td>arbre B+ de la clé primaire</td><td>tas (heap)</td><td>tas (heap) par défaut, IOT en option</td></tr><tr><td>Adresse d&rsquo;une ligne dans un index</td><td>clé primaire (index secondaires)</td><td>ctid (page, slot)</td><td>ROWID (objet, fichier, bloc, ligne)</td></tr><tr><td>Anciennes versions</td><td>undo log</td><td>dans la table</td><td>tablespace d&rsquo;undo</td></tr><tr><td>Nettoyage</td><td>purge thread</td><td>VACUUM</td><td>réutilisation de l&rsquo;undo</td></tr><tr><td>Pages déchirées</td><td>doublewrite buffer</td><td>full_page_writes (WAL)</td><td>contrôle du bloc + restauration RMAN</td></tr><tr><td>Verrous de ligne</td><td>en mémoire (lock system)</td><td>xmax dans le tuple</td><td>octet de verrou → ITL du bloc</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Aucun de ces choix n&rsquo;est meilleur dans l&rsquo;absolu. L&rsquo;index cluster d&rsquo;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 <code>VACUUM</code>. L&rsquo;undo séparé d&rsquo;Oracle et d&rsquo;InnoDB garde les tables compactes, mais impose de dimensionner l&rsquo;undo. Savoir comment les données sont rangées sur le disque, c&rsquo;est savoir lequel de ces compromis on paie, et à quel moment.</p>



<h2 class="wp-block-heading">Ce que j&rsquo;ai mesuré (mise à jour du 2026-11-04)</h2>



<p class="wp-block-paragraph">J&rsquo;ai d&rsquo;abord écrit ce billet à partir de la documentation. Je l&rsquo;ai ensuite testé sur <strong>Oracle AI Database 26ai Free 23.26.3.0.0</strong>, l&rsquo;image <code>container-registry.oracle.com/database/free</code> lancée avec podman 5.8.2 sur Rocky Linux 10.2, WSL2, dans la base pluggable <code>FREEPDB1</code>, avec un utilisateur et un tablespace jetables. C&rsquo;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&rsquo;extent de 5 Mo, et l&rsquo;octet de verrou après un commit).</p>



<p class="wp-block-paragraph">Fichiers et structure logique. Trois groupes de redo de 200 Mo avec un seul membre, deux fichiers de contrôle, le mode <code>ARCHIVELOG</code>, un bloc de 8 Ko, <code>DB_BLOCK_CHECKSUM = TYPICAL</code>. Le tablespace d&rsquo;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 <code>BIGFILE</code> :</p>



<pre class="wp-block-code"><code>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</code></pre>



<p class="wp-block-paragraph">La croissance des extents. 3 millions de lignes dans un nouveau tablespace <code>AUTOALLOCATE</code> (<code>ALLOCATION_TYPE = SYSTEM</code>, ASSM) : 16 extents de 64 Ko, puis 63 de 1 Mo, un de 5 Mo, puis des extents de 8 Mo :</p>



<pre class="wp-block-code"><code>TABLESPACE_NAME 	       SEGMEN ALLOCATIO
------------------------------ ------ ---------
SHOP_TS 		       AUTO   SYSTEM
SEGMENT_NAME															 SEGMENT_TYPE	       EXTENTS	   BLOCKS	  MB
-------------------------------------------------------------------------------------------------------------------------------- ------------------ ---------- ---------- ----------
ORDERS																 TABLE			    88	    17024	 133
 EXTENT_ID    FILE_ID	BLOCK_ID     BLOCKS
---------- ---------- ---------- ----------
	 0	   17	     776	  8
	 1	   17	     792	  8
	 2	   17	     808	  8
 EXTENT_KB    EXTENTS FIRST_EXTENT LAST_EXTENT
---------- ---------- ------------ -----------
	64	   16		 0	    15
      1024	   63		16	    78
      5120	    1		79	    79
      8192	    8		80	    87</code></pre>



<p class="wp-block-paragraph">Le high water mark. Après avoir supprimé toutes les lignes d&rsquo;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 <code>SHRINK SPACE</code>, le segment fait 8 blocs et le parcours en coûte 2 :</p>



<pre class="wp-block-code"><code>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</code></pre>



<p class="wp-block-paragraph">Un ROWID décodé avec <code>DBMS_ROWID</code>. 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) :</p>



<pre class="wp-block-code"><code>ROWID			     ID        OBJ	FILE#	  BLOCK#       ROW#
-------------------- ---------- ---------- ---------- ---------- ----------
AAAR2tAAAAAAAUWAAA	      1      73133	    0	    1302	  0
AAAR2tAAAAAAAUWAAB	      2      73133	    0	    1302	  1
AAAR2tAAAAAAAUWAAC	      3      73133	    0	    1302	  2</code></pre>



<p class="wp-block-paragraph"><code>ALTER SYSTEM DUMP DATAFILE 17 BLOCK 1302</code> juste après les insertions et le commit, abrégé aux lignes utiles. Le bloc a 2 entrées ITL (<code>itc: 2</code>). Chaque ligne commence par <code>tl</code> (longueur totale), <code>fb</code> (indicateurs), <code>lb</code> (l&rsquo;octet de verrou) et <code>cc</code> (le nombre de colonnes). La ligne d&rsquo;id 2 (<code>tab 0, row 1</code>) a un NULL au milieu (<code>*NULL*</code>) et un NULL final qui n&rsquo;est pas stocké (<code>cc: 4</code> pour 5 colonnes) ; la ligne d&rsquo;id 3 (<code>tab 0, row 2</code>) ne stocke que 2 colonnes. Après le commit, les trois lignes portent encore <code>lb: 0x1</code> et l&rsquo;entrée ITL affiche toujours <code>Lck 3</code> : le commit ne nettoie pas le bloc :</p>



<pre class="wp-block-code"><code>  frmt: 0x02 chkval: 0xa025 type: 0x06=trans data
Block header dump:  0x00000516
 Object id on Block? Y
 seg/obj: 0x11dad  csc:  0x00000000002faa20  itc: 2  flg: E-  typ: 1 - DATA
 Itl           Xid                  Uba         Flag  Lck        Scn/Fsc
0x01   0x0003.01f.000002c5  0x0000af26.0159.25  --U-    3  fsc 0x0000.002faa22
0x02   0x0000.000.00000000  0x00000000.0000.00  ----    0  fsc 0x0000.00000000
bdba: 0x00000516 type: 0x06 SAGE: 0
nrow=3
frre=-1
fsbo=0x18
fseo=0x1f68
avsp=0x1f4f
tosp=0x1f4f
0xe:pti[0]	nrow=3	offs=0
tab 0, row 0, @0x1f7b
tl: 29 fb: --H-FL-- lb: 0x1  cc: 5
col  0: [ 2]  c1 02
col  1: [ 5]  61 6c 70 68 61
col  2: [ 4]  62 65 74 61
col  3: [ 5]  67 61 6d 6d 61
col  4: [ 5]  64 65 6c 74 61
tab 0, row 1, @0x1f70
tl: 11 fb: --H-FL-- lb: 0x1  cc: 4
col  0: [ 2]  c1 03
col  1: [ 1]  78
col  2: *NULL*
col  3: [ 1]  7a
tab 0, row 2, @0x1f68
tl: 8 fb: --H-FL-- lb: 0x1  cc: 2
col  0: [ 2]  c1 04
col  1: [ 1]  78</code></pre>



<p class="wp-block-paragraph">Le même bloc pendant qu&rsquo;une autre session détient un <code>UPDATE</code> non validé de la ligne d&rsquo;id 1 (<code>tab 0, row 0</code>). La ligne modifiée porte maintenant <code>lb: 0x2</code>, qui désigne l&rsquo;entrée ITL 2 (<code>Lck 1</code>, un nouvel identifiant de transaction) ; l&rsquo;entrée 1 a été nettoyée (<code>C---</code>, <code>Lck 0</code>) et les autres lignes ont <code>lb: 0x0</code>. La ligne a grossi de 29 à 31 octets et s&rsquo;est déplacée dans le bloc :</p>



<pre class="wp-block-code"><code> Itl           Xid                  Uba         Flag  Lck        Scn/Fsc
0x01   0x0003.01f.000002c5  0x0000af26.0159.25  C---    0  scn  0x00000000002faa22
0x02   0x0005.00b.0000029d  0x00000184.013f.0b  ----    1  fsc 0x0000.00000000
tab 0, row 0, @0x1f49
tl: 31 fb: --H-FL-- lb: 0x2  cc: 5
tab 0, row 1, @0x1f70
tl: 11 fb: --H-FL-- lb: 0x0  cc: 4
tab 0, row 2, @0x1f68
tl: 8 fb: --H-FL-- lb: 0x0  cc: 2</code></pre>



<p class="wp-block-paragraph">Verrous de ligne et lignes migrées. Modifier 1 ligne ou 500 000 lignes prend les deux mêmes verrous dans <code>V$LOCK</code> (un <code>TX</code>, un <code>TM</code>) ; ce qui grossit, c&rsquo;est l&rsquo;undo (1 enregistrement, puis 500 000 enregistrements dans 5 815 blocs d&rsquo;undo). Verrouiller un million de lignes ne coûte donc pas de mémoire dans une table de verrous, mais coûte de l&rsquo;undo. Pour les lignes migrées, 100 000 lignes ont été insérées avec <code>PCTFREE 0</code> puis allongées de 1 à 300 caractères : les relire a enregistré 99 733 événements <code>table fetch continued row</code> (des lignes qu&rsquo;il a fallu suivre vers un autre bloc) :</p>



<pre class="wp-block-code"><code>### 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</code></pre>



<p class="wp-block-paragraph">La lecture cohérente. Une transaction en lecture seule continue de voir la valeur 10 après qu&rsquo;une autre session a validé 20 ; les statistiques montrent un enregistrement d&rsquo;undo appliqué :</p>



<pre class="wp-block-code"><code>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</code></pre>



<p class="wp-block-paragraph">Le nettoyage différé des blocs. Après avoir modifié et validé 2 000 000 de lignes, un simple <code>SELECT COUNT(*)</code> a généré environ 5 Mo de redo et 57 046 « cleanouts only &#8211; consistent read gets » :</p>



<pre class="wp-block-code"><code>  COUNT(*)
----------
   2000000
STAT				       DELTA
--------------------------------- ----------
redo size generated by the SELECT    5020444
STAT					   DELTA
------------------------------------- ----------
cleanouts only - consistent read gets	   57046</code></pre>



<p class="wp-block-paragraph">L&rsquo;attente ITL. Une table de 3 000 lignes d&rsquo;un caractère, créée avec <code>PCTFREE 0</code>, remplit complètement ses blocs : le bloc de la ligne 1500 contient 733 lignes et il lui reste 7 octets (<code>avsp=0x7</code>), 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&rsquo;entrée ITL, faute de place pour en ajouter : elle attend sur <code>enq: TX - allocate ITL entry</code>, bloquée par l&rsquo;une des autres sessions, jusqu&rsquo;à leur annulation (14,7 secondes ici) :</p>



<pre class="wp-block-code"><code>block 38701, rows with ids 1467, 1468 and 1469 are used
 seg/obj: 0x11de1  csc:  0x000000000040b7b1  itc: 2  flg: E-  typ: 1 - DATA
 Itl           Xid                  Uba         Flag  Lck        Scn/Fsc
0x01   0x0008.005.0000026d  0x0000b5a0.00d7.04  --U-  733  fsc 0x0000.0040b7b3
0x02   0x0000.000.00000000  0x00000000.0000.00  ----    0  fsc 0x0000.00000000
nrow=733
avsp=0x7
       SID USERNAME EVENT				     SECONDS_IN_WAIT BLOCKING_SESSION
---------- -------- ---------------------------------------- --------------- ----------------
       227 SHOP     enq: TX - allocate ITL entry			   2		   58
Elapsed: 00:00:14.73</code></pre>



<p class="wp-block-paragraph">ORA-01555. Une table de 300 000 lignes est chargée normalement, puis le tablespace d&rsquo;undo est remplacé par un tablespace de 10 Mo qui ne peut pas grandir, avec <code>UNDO_RETENTION = 5</code>. 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&rsquo;undo dont il avait besoin a été écrasé. Mon premier essai n&rsquo;a pas échoué, sans doute parce que l&rsquo;optimiseur avait lu et trié toutes les lignes dès le premier fetch, si bien que le lecteur n&rsquo;avait plus besoin d&rsquo;anciens blocs (je n&rsquo;ai pas regardé le plan). Forcer un parcours d&rsquo;index avec un hint a fait apparaître l&rsquo;erreur :</p>



<pre class="wp-block-code"><code>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</code></pre>



<p class="wp-block-paragraph">Non testé : la vérification de la queue de bloc et la réparation d&rsquo;un bloc fracturé avec RMAN, les images de bloc complètes écrites pendant une sauvegarde à chaud, les tailles d&rsquo;une entrée ITL (24 octets) et d&rsquo;une entrée du répertoire des lignes (2 octets), l&rsquo;octet unique d&rsquo;un NULL au milieu d&rsquo;une ligne, les tables organisées en index, et toute édition ou version autre qu&rsquo;Oracle AI Database 26ai Free.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph"><em>Ce billet fait partie d&rsquo;une série de notes d&rsquo;administration système (tag « carnet d&rsquo;admin »).</em><br>Série « Dans les fichiers d&rsquo;une base de données », partie 3/3. · <a href="https://blog.neurozone.fr/?p=1788">← Billet précédent</a> · <a href="https://hecate.neurozone.fr/?p=379">English version</a></p>



<h2 class="wp-block-heading">Articles liés</h2>



<ul class="wp-block-list"><li><a href="https://blog.neurozone.fr/?p=1787">Les fichiers d&rsquo;InnoDB, de MySQL à MariaDB</a></li><li><a href="https://blog.neurozone.fr/?p=1788">Les fichiers de PostgreSQL</a></li><li><a href="https://blog.neurozone.fr/?p=22">Un cluster Oracle réparé</a></li></ul>

<p>L’article <a href="https://blog.neurozone.fr/2026/10/27/dans-les-fichiers-dune-base-de-donnees-3-3-oracle-et-les-trois-cote-a-cote/">Dans les fichiers d&rsquo;une base de données (3/3) : Oracle, et les trois côte à côte</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://blog.neurozone.fr/2026/10/27/dans-les-fichiers-dune-base-de-donnees-3-3-oracle-et-les-trois-cote-a-cote/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1789</post-id>	</item>
		<item>
		<title>Dans les fichiers d&#8217;une base de données (2/3) : PostgreSQL</title>
		<link>https://blog.neurozone.fr/2026/10/25/dans-les-fichiers-dune-base-de-donnees-2-3-postgresql/</link>
					<comments>https://blog.neurozone.fr/2026/10/25/dans-les-fichiers-dune-base-de-donnees-2-3-postgresql/#respond</comments>
		
		<dc:creator><![CDATA[Hécate Raven]]></dc:creator>
		<pubDate>Sun, 25 Oct 2026 14:00:00 +0000</pubDate>
				<category><![CDATA[Informatique]]></category>
		<category><![CDATA[carnet d'admin]]></category>
		<category><![CDATA[Français]]></category>
		<category><![CDATA[InnoDB]]></category>
		<category><![CDATA[MariaDB]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[Oracle]]></category>
		<category><![CDATA[PostgreSQL]]></category>
		<category><![CDATA[série : Dans les fichiers d'une base de données]]></category>
		<category><![CDATA[stockage]]></category>
		<guid isPermaLink="false">https://blog.neurozone.fr/?p=1788</guid>

					<description><![CDATA[<p>Les fichiers de PostgreSQL décortiqués : forks, pages, tuples, pourquoi un UPDATE ne modifie rien sur place, VACUUM, HOT, gel des lignes, index et WAL.</p>
<p>L’article <a href="https://blog.neurozone.fr/2026/10/25/dans-les-fichiers-dune-base-de-donnees-2-3-postgresql/">Dans les fichiers d&rsquo;une base de données (2/3) : PostgreSQL</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">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&rsquo;on écrit) mais ont fait des choix presque opposés. Les connaître explique le besoin de <code>VACUUM</code>, le gonflement des tables (<em>bloat</em>), ou pourquoi le premier <code>SELECT</code> après un gros chargement écrit sur le disque.</p>



<h2 class="wp-block-heading">Le répertoire de données</h2>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_files_fr.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="356" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_files_fr.png?resize=629%2C356&#038;ssl=1" alt="Arborescence du répertoire PGDATA" class="wp-image-1782" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_files_fr.png?w=1800&amp;ssl=1 1800w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_files_fr.png?resize=300%2C170&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_files_fr.png?resize=1024%2C580&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_files_fr.png?resize=768%2C435&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_files_fr.png?resize=1536%2C870&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_files_fr.png?resize=900%2C510&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_files_fr.png?resize=1280%2C725&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">PGDATA : un répertoire par base, un fichier par table ou index, nommés par numéro.</figcaption></figure>



<p class="wp-block-paragraph">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&rsquo;est le nom de son répertoire sous <code>base/</code>. Chaque table, index ou séquence a un <code>relfilenode</code>, et c&rsquo;est le nom de son fichier. Ce numéro change quand la table est réécrite (<code>TRUNCATE</code>, <code>VACUUM FULL</code>, <code>CLUSTER</code>, certains <code>ALTER TABLE</code>) : PostgreSQL crée un nouveau fichier et bascule, ce qui rend ces opérations sûres en cas de crash.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="sql">SELECT oid, datname FROM pg_database;
SELECT pg_relation_filepath(&#x27;orders&#x27;);        -- base/16384/24576
SELECT pg_size_pretty(pg_total_relation_size(&#x27;orders&#x27;));   -- table + index + TOAST</pre>



<ul class="wp-block-list"><li><strong><code>global/</code></strong> contient les catalogues partagés par toutes les bases (rôles, liste des bases) et <code>pg_control</code>, le petit fichier qui dit où reprendre après un crash ;</li><li><strong><code>pg_wal/</code></strong> est le journal d&rsquo;écriture anticipée (WAL), en segments de 16 Mo : l&rsquo;équivalent du redo d&rsquo;InnoDB ;</li><li><strong><code>pg_xact/</code></strong> garde l&rsquo;état de chaque transaction sur deux bits : en cours, validée, annulée. C&rsquo;est lui qui permet de savoir si une version de ligne est visible ;</li><li><strong><code>pg_tblspc/</code></strong> contient des liens symboliques vers les tablespaces créés ailleurs. Chez PostgreSQL, un tablespace n&rsquo;est qu&rsquo;un répertoire sur un autre disque.</li></ul>



<h2 class="wp-block-heading">Une relation : des forks et des segments</h2>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_forks_fr.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="210" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_forks_fr.png?resize=629%2C210&#038;ssl=1" alt="Les forks d&#x27;une relation PostgreSQL" class="wp-image-1783" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_forks_fr.png?w=1800&amp;ssl=1 1800w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_forks_fr.png?resize=300%2C100&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_forks_fr.png?resize=1024%2C341&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_forks_fr.png?resize=768%2C256&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_forks_fr.png?resize=1536%2C512&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_forks_fr.png?resize=900%2C300&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_forks_fr.png?resize=1280%2C427&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">Le main fork contient les données ; fsm et vm sont des fichiers auxiliaires.</figcaption></figure>



<p class="wp-block-paragraph">Chaque table est en réalité un petit ensemble de fichiers. Le <em>main fork</em> contient les données, découpé en fichiers de 1 Go au plus (<code>24576</code>, <code>24576.1</code>…). Cette limite date d&rsquo;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 <strong>free space map</strong> indique approximativement la place libre de chaque page, pour qu&rsquo;un <code>INSERT</code> trouve vite une page où écrire. La <strong>visibility map</strong> garde deux bits par page : « toutes les lignes sont visibles par tout le monde » et « toutes les lignes sont gelées ». Elle permet à <code>VACUUM</code> de sauter les pages déjà propres, et aux <em>index-only scans</em> de ne pas lire la table.</p>



<h2 class="wp-block-heading">Anatomie d&rsquo;une page PostgreSQL</h2>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_page_fr.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="358" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_page_fr.png?resize=629%2C358&#038;ssl=1" alt="Structure d&#x27;une page PostgreSQL de 8 Ko" class="wp-image-1785" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_page_fr.png?w=1720&amp;ssl=1 1720w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_page_fr.png?resize=300%2C171&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_page_fr.png?resize=1024%2C583&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_page_fr.png?resize=768%2C438&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_page_fr.png?resize=1536%2C875&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_page_fr.png?resize=900%2C513&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_page_fr.png?resize=1280%2C729&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">Une page de table : en-tête, pointeurs de ligne, espace libre, tuples.</figcaption></figure>



<p class="wp-block-paragraph">Les pages font 8 Ko (une constante de compilation, <code>BLCKSZ</code>). Leur structure est la même pour les tables et les index :</p>



<ul class="wp-block-list"><li><strong>l&rsquo;en-tête de 24 octets</strong> contient le <code>pd_lsn</code>, la position dans le WAL de la dernière modification. PostgreSQL l&rsquo;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&rsquo;initialisation depuis PostgreSQL 18, à demander avec <code>initdb -k</code> avant), et deux décalages, <code>pd_lower</code> et <code>pd_upper</code>, qui bornent l&rsquo;espace libre ;</li><li><strong>les line pointers</strong> forment un tableau en début de page. Chacun contient le décalage et la longueur d&rsquo;un tuple. L&rsquo;adresse d&rsquo;une ligne, son <code>ctid</code>, vaut (numéro de page, numéro de pointeur), par exemple <code>(0,2)</code>. Cette indirection permet de déplacer un tuple <em>dans</em> la page pour la défragmenter sans changer son adresse, donc sans toucher aux index ;</li><li><strong>les tuples</strong> sont écrits depuis la fin de la page, vers le haut. Quand <code>pd_lower</code> (fin des pointeurs) rejoint <code>pd_upper</code> (début des tuples), la page est pleine ;</li><li><strong>l&rsquo;espace spécial</strong> 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&rsquo;arbre.</li></ul>



<p class="wp-block-paragraph">On peut tout regarder avec l&rsquo;extension <code>pageinspect</code>, sur une base de test :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="sql">CREATE EXTENSION pageinspect;
SELECT lower, upper, special, pagesize FROM page_header(get_raw_page(&#x27;orders&#x27;, 0));
SELECT lp, lp_off, lp_len, t_xmin, t_xmax, t_ctid
FROM heap_page_items(get_raw_page(&#x27;orders&#x27;, 0));</pre>



<h2 class="wp-block-heading">Un tuple</h2>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_tuple_fr.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="155" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_tuple_fr.png?resize=629%2C155&#038;ssl=1" alt="En-tête d&#x27;un tuple PostgreSQL" class="wp-image-1786" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_tuple_fr.png?w=1800&amp;ssl=1 1800w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_tuple_fr.png?resize=300%2C74&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_tuple_fr.png?resize=1024%2C253&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_tuple_fr.png?resize=768%2C189&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_tuple_fr.png?resize=1536%2C379&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_tuple_fr.png?resize=900%2C222&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_tuple_fr.png?resize=1280%2C316&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">Les 23 octets d&rsquo;en-tête qui précèdent chaque version de ligne.</figcaption></figure>



<p class="wp-block-paragraph">Chaque version de ligne commence par 23 octets d&rsquo;en-tête, arrondis à 24 par l&rsquo;alignement. Les champs essentiels sont <code>t_xmin</code> (la transaction qui a créé cette version), <code>t_xmax</code> (celle qui l&rsquo;a supprimée ou remplacée) et <code>t_ctid</code> (l&rsquo;adresse de la version suivante). Trois détails pratiques :</p>



<ul class="wp-block-list"><li><strong>l&rsquo;alignement</strong> : chaque colonne est alignée selon son type (8 octets pour <code>bigint</code> et <code>timestamp</code>). Une colonne <code>boolean</code> suivie d&rsquo;un <code>bigint</code> laisse 7 octets perdus. Ordonner les colonnes de la plus grande à la plus petite peut réduire une grosse table jusqu&rsquo;à environ 20 % (19 % sur une table de type commandes dans mon test, voir plus bas) ;</li><li><strong>les NULL</strong> ne prennent qu&rsquo;un bit dans le bitmap, présent seulement si la ligne contient au moins un NULL ;</li><li><strong>TOAST</strong> : quand une ligne dépasse environ 2 Ko, PostgreSQL compresse d&rsquo;abord les grandes valeurs (pglz, ou lz4 si on le demande), puis, si ça ne suffit pas, les déplace dans une table annexe, <code>pg_toast.pg_toast_&lt;oid&gt;</code>, découpées en morceaux. La ligne ne garde qu&rsquo;un pointeur de 18 octets. C&rsquo;est transparent, mais un <code>SELECT *</code> qui lit une colonne TOAST va chercher ces morceaux : ne sélectionnez que les colonnes utiles.</li></ul>



<h2 class="wp-block-heading">Un UPDATE PostgreSQL ne modifie rien sur place</h2>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_mvcc_fr.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="266" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_mvcc_fr.png?resize=629%2C266&#038;ssl=1" alt="Un UPDATE PostgreSQL crée une nouvelle version" class="wp-image-1784" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_mvcc_fr.png?w=1800&amp;ssl=1 1800w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_mvcc_fr.png?resize=300%2C127&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_mvcc_fr.png?resize=1024%2C432&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_mvcc_fr.png?resize=768%2C324&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_mvcc_fr.png?resize=1536%2C649&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_mvcc_fr.png?resize=900%2C380&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/pg_mvcc_fr.png?resize=1280%2C540&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">L&rsquo;ancienne version reste dans la page, marquée par son xmax, jusqu&rsquo;au VACUUM.</figcaption></figure>



<p class="wp-block-paragraph">C&rsquo;est la différence fondamentale avec InnoDB et Oracle. Chez PostgreSQL, un <code>UPDATE</code> est un <code>DELETE</code> suivi d&rsquo;un <code>INSERT</code> : la transaction écrit une nouvelle version complète de la ligne, et inscrit son numéro dans le <code>xmax</code> de l&rsquo;ancienne. Il n&rsquo;y a pas de journal d&rsquo;undo. Les anciennes versions <em>sont</em> l&rsquo;undo, et elles vivent dans la table.</p>



<p class="wp-block-paragraph">Pour décider si une version est visible, une transaction compare <code>xmin</code> et <code>xmax</code> à son instantané (snapshot) : la version est visible si sa création est validée et antérieure à l&rsquo;instantané, et si sa suppression n&rsquo;a pas eu lieu, a été annulée ou est postérieure. Consulter <code>pg_xact</code> à chaque fois coûterait cher. La première transaction qui lit une version note donc le résultat dans des <strong>hint bits</strong> de l&rsquo;en-tête (« xmin validé », « xmax annulé »…). Cela explique un phénomène qui surprend souvent : le premier <code>SELECT</code> après un gros chargement <em>écrit</em> sur le disque, parce qu&rsquo;il pose ces bits sur chaque page.</p>



<h2 class="wp-block-heading">Ce que ça implique : VACUUM, HOT et le gel</h2>



<ul class="wp-block-list"><li><strong>VACUUM</strong> 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&rsquo;espace au système de fichiers, sauf pour les pages vides en fin de fichier. Un <code>VACUUM</code> simple ne réécrit jamais la table. <code>VACUUM FULL</code>, <code>CLUSTER</code> et l&rsquo;extension <code>pg_repack</code> (sans verrou long) le font, tout comme <code>TRUNCATE</code> et certaines formes d&rsquo;<code>ALTER TABLE</code>, comme le changement de type d&rsquo;une colonne ;</li><li><strong>une transaction ouverte trop longtemps</strong> bloque le nettoyage de toutes les versions plus récentes qu&rsquo;elle, dans toute la base. C&rsquo;est la première chose à vérifier quand une table gonfle : <code>SELECT pid, xact_start, state FROM pg_stat_activity ORDER BY xact_start;</code> ;</li><li><strong>les mises à jour HOT</strong> (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&rsquo;index pointe toujours vers l&rsquo;ancien pointeur, qui mène à la nouvelle version par la chaîne de <code>ctid</code>. Laisser de la place dans les pages augmente les chances de HOT : <code>ALTER TABLE orders SET (fillfactor = 90);</code> ;</li><li><strong>le gel (freezing)</strong> : les identifiants de transaction tiennent sur 32 bits et finissent par boucler : la limite dure est 2<sup>31</sup>, soit environ 2,1 milliards de transactions, mais autovacuum lance le travail anti-bouclage bien plus tôt, à <code>autovacuum_freeze_max_age</code> (200 millions par défaut). Pour qu&rsquo;une très vieille ligne ne paraisse pas soudain « dans le futur », <code>VACUUM</code> 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&rsquo;est pourquoi on ne désactive jamais autovacuum.</li></ul>



<h2 class="wp-block-heading">Les index et le WAL</h2>



<p class="wp-block-paragraph">Un index B-tree utilise les mêmes pages de 8 Ko. Ses entrées contiennent la clé et le <code>ctid</code> du tuple, c&rsquo;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&rsquo;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&rsquo;est ce qui rend les <em>index-only scans</em> efficaces sur une table bien nettoyée.</p>



<p class="wp-block-paragraph">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 <code>full_page_writes = on</code>, la première modification d&rsquo;une page après chaque checkpoint écrit l&rsquo;image complète de la page dans le WAL. Après un crash, PostgreSQL repart de cette image intacte. C&rsquo;est l&rsquo;équivalent du doublewrite buffer, et la raison pour laquelle le volume de WAL grimpe juste après chaque checkpoint.</p>



<p class="wp-block-paragraph">Dernier billet de la série : Oracle, qui partage avec InnoDB l&rsquo;undo séparé, mais pousse la logique plus loin, avec des verrous rangés dans les blocs eux-mêmes.</p>



<h2 class="wp-block-heading">Ce que j&rsquo;ai mesuré (mise à jour du 2026-11-04)</h2>



<p class="wp-block-paragraph">J&rsquo;ai d&rsquo;abord écrit ce billet à partir de la documentation. Je l&rsquo;ai ensuite testé sur <strong>PostgreSQL 16.15</strong> (le paquet de Rocky Linux 10.2), WSL2, sur un cluster jetable créé avec un simple <code>initdb</code>. 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&rsquo;ai aussi testé <strong>PostgreSQL 18.6</strong> (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 <code>initdb</code>.</p>



<p class="wp-block-paragraph">Quelles opérations changent le nom du fichier (<code>relfilenode</code>). Un <code>VACUUM</code> simple non ; <code>VACUUM FULL</code>, <code>CLUSTER</code>, <code>ALTER COLUMN TYPE</code> et <code>TRUNCATE</code> oui. <code>ADD COLUMN</code> non, même avec un <code>DEFAULT</code> constant :</p>



<pre class="wp-block-code"><code>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</code></pre>



<p class="wp-block-paragraph">Les hint bits. <code>HEAP_XMIN_COMMITTED</code> vaut <code>0x0100</code>. Il n&rsquo;est pas positionné après l&rsquo;<code>INSERT</code> et l&rsquo;est par le premier <code>SELECT</code>, ce qui explique que la première lecture après un chargement massif écrive :</p>



<pre class="wp-block-code"><code>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)</code></pre>



<p class="wp-block-paragraph"><code>VACUUM</code> contre <code>VACUUM FULL</code>, et un snapshot resté ouvert. 300 000 lignes, 90 % supprimées. Le <code>VACUUM</code> simple retire 270 000 versions mortes et le fichier garde ses 69 Mo ; <code>VACUUM FULL</code> écrit un nouveau fichier de 7 Mo. Avec une transaction <code>REPEATABLE READ</code> laissée ouverte, 100 000 lignes supprimées sont « dead but not yet removable » ; une fois la transaction terminée, <code>VACUUM</code> les retire et tronque les pages vides en fin de fichier :</p>



<pre class="wp-block-code"><code>-- 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</code></pre>



<p class="wp-block-paragraph">L&rsquo;ordre des colonnes. La première paire est une table construite pour perdre du padding (<code>boolean</code>, <code>bigint</code>, <code>boolean</code>, &#8230;), la seconde une table de type commandes (<code>boolean</code>, <code>bigint</code>, <code>smallint</code>, <code>timestamptz</code>, &#8230;). 1 000 000 de lignes chacune. Le gain dépend du schéma :</p>



<pre class="wp-block-code"><code>-- 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 %</code></pre>



<p class="wp-block-paragraph">Les segments. Une table de 1 482 Mo est découpée en fichiers de 1 Gio :</p>



<pre class="wp-block-code"><code>1073741824 Nov  4 17:25 /tmp/dp/data/base/16384/16525
479920128 Nov  4 17:26 /tmp/dp/data/base/16384/16525.1</code></pre>



<p class="wp-block-paragraph">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&rsquo;environ 2 Ko (2 et 5 morceaux) :</p>



<pre class="wp-block-code"><code>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)</code></pre>



<p class="wp-block-paragraph">Les images de page complètes. Juste après un <code>CHECKPOINT</code>, le premier <code>UPDATE</code> d&rsquo;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&rsquo;enregistrement lui-même, donc avec une page jointe). L&rsquo;<code>UPDATE</code> suivant sur la même page écrit 128 octets, hors enregistrement de commit :</p>



<pre class="wp-block-code"><code>-- 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</code></pre>



<p class="wp-block-paragraph">PostgreSQL 18.6 (paquets PGDG, Rocky Linux 10.2, WSL2), simple <code>initdb</code>. <code>data_checksums</code> vaut <code>on</code>, <code>initdb --no-data-checksums</code> le désactive et <code>-k</code> est toujours accepté. La somme de contrôle de la page 0 calculée par <code>page_checksum()</code> est égale à celle stockée dans le fichier, et <code>pg_checksums --check</code> ne trouve aucun bloc défectueux. Les opérations qui réécrivent une table sont les mêmes que sur la 16.15 :</p>



<pre class="wp-block-code"><code> 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    -&gt; Data page checksum version:           0
initdb -k                     -&gt; 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</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph"><em>Ce billet fait partie d&rsquo;une série de notes d&rsquo;administration système (tag « carnet d&rsquo;admin »).</em><br>Série « Dans les fichiers d&rsquo;une base de données », partie 2/3. · <a href="https://blog.neurozone.fr/?p=1787">← Billet précédent</a> · <a href="https://blog.neurozone.fr/?p=1789">Billet suivant →</a> · <a href="https://hecate.neurozone.fr/?p=378">English version</a></p>



<h2 class="wp-block-heading">Articles liés</h2>



<ul class="wp-block-list"><li><a href="https://blog.neurozone.fr/?p=1787">Les fichiers d&rsquo;InnoDB, de MySQL à MariaDB</a></li><li><a href="https://blog.neurozone.fr/?p=1789">Les fichiers d&rsquo;Oracle, blocs et ROWID</a></li><li><a href="https://blog.neurozone.fr/?p=2044">Les réglages MariaDB qui comptent</a></li></ul>

<p>L’article <a href="https://blog.neurozone.fr/2026/10/25/dans-les-fichiers-dune-base-de-donnees-2-3-postgresql/">Dans les fichiers d&rsquo;une base de données (2/3) : PostgreSQL</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://blog.neurozone.fr/2026/10/25/dans-les-fichiers-dune-base-de-donnees-2-3-postgresql/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1788</post-id>	</item>
		<item>
		<title>Dans les fichiers d&#8217;une base de données (1/3) : InnoDB, de MySQL à MariaDB</title>
		<link>https://blog.neurozone.fr/2026/10/23/dans-les-fichiers-dune-base-de-donnees-1-3-innodb-de-mysql-a-mariadb/</link>
					<comments>https://blog.neurozone.fr/2026/10/23/dans-les-fichiers-dune-base-de-donnees-1-3-innodb-de-mysql-a-mariadb/#respond</comments>
		
		<dc:creator><![CDATA[Hécate Raven]]></dc:creator>
		<pubDate>Fri, 23 Oct 2026 13:00:00 +0000</pubDate>
				<category><![CDATA[Informatique]]></category>
		<category><![CDATA[carnet d'admin]]></category>
		<category><![CDATA[Français]]></category>
		<category><![CDATA[InnoDB]]></category>
		<category><![CDATA[MariaDB]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[Oracle]]></category>
		<category><![CDATA[PostgreSQL]]></category>
		<category><![CDATA[série : Dans les fichiers d'une base de données]]></category>
		<category><![CDATA[stockage]]></category>
		<guid isPermaLink="false">https://blog.neurozone.fr/?p=1787</guid>

					<description><![CDATA[<p>Comment InnoDB range vos données sur disque : pages, extents, segments, doublewrite, lignes et index cluster, et pourquoi un fichier ne rétrécit pas.</p>
<p>L’article <a href="https://blog.neurozone.fr/2026/10/23/dans-les-fichiers-dune-base-de-donnees-1-3-innodb-de-mysql-a-mariadb/">Dans les fichiers d&rsquo;une base de données (1/3) : InnoDB, de MySQL à MariaDB</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Avec InnoDB comme avec les autres moteurs, une base de données relationnelle donne l&rsquo;illusion de tables, de lignes et de colonnes. Sur le disque, il n&rsquo;y a que des fichiers découpés en blocs de taille fixe. Comprendre comment ces blocs sont organisés explique beaucoup de comportements qui semblent mystérieux : pourquoi un fichier ne rétrécit pas après un <code>DELETE</code>, pourquoi une clé primaire en UUID ralentit les insertions, pourquoi un index secondaire coûte plus cher qu&rsquo;on ne le pense, ou ce que protège vraiment le « doublewrite buffer ».</p>



<p class="wp-block-paragraph">Cette série de trois billets ouvre le capot de trois moteurs : <strong>InnoDB</strong> (le moteur de stockage de MySQL et de MariaDB), <strong>PostgreSQL</strong> et <strong>Oracle</strong>. Le même fil conducteur les traverse : les fichiers, les pages, les lignes, et l&rsquo;endroit où vivent les anciennes versions des données. Premier arrêt : InnoDB.</p>



<h2 class="wp-block-heading">Le répertoire de données</h2>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_files_fr.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="315" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_files_fr.png?resize=629%2C315&#038;ssl=1" alt="Arborescence du datadir de MySQL 8" class="wp-image-1773" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_files_fr.png?w=1800&amp;ssl=1 1800w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_files_fr.png?resize=300%2C150&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_files_fr.png?resize=1024%2C512&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_files_fr.png?resize=768%2C384&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_files_fr.png?resize=1536%2C768&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_files_fr.png?resize=900%2C450&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_files_fr.png?resize=1280%2C640&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">Les fichiers d&rsquo;une instance MySQL 8 et leurs équivalents MariaDB.</figcaption></figure>



<p class="wp-block-paragraph">Chaque fichier a un rôle précis :</p>



<ul class="wp-block-list"><li><strong><code>ibdata1</code></strong>, le <em>tablespace système</em>. Historiquement, il contenait tout : tables, index, undo, dictionnaire. Aujourd&rsquo;hui, il ne garde plus que le <em>change buffer</em> (sur MySQL) et, sur MariaDB, le doublewrite et parfois l&rsquo;undo. Il ne rétrécit jamais : un <code>ibdata1</code> de 50 Go gonflé par une vieille transaction le restera, et la seule façon de le réduire est un export complet suivi d&rsquo;une réimportation ;</li><li><strong>un fichier <code>.ibd</code> par table</strong>, grâce à <code>innodb_file_per_table=ON</code> (le défaut depuis MySQL 5.6). C&rsquo;est ce qui permet de récupérer l&rsquo;espace d&rsquo;une table supprimée ou vidée (<code>DROP</code>, <code>TRUNCATE</code>), de déplacer une table seule, ou de restaurer un seul tablespace ;</li><li><strong>les tablespaces d&rsquo;undo</strong> (<code>undo_001</code>…) : les images d&rsquo;avant des lignes modifiées, pour les <code>ROLLBACK</code> et pour la lecture cohérente des autres transactions ;</li><li><strong>le journal redo</strong> : chaque modification de page y est décrite avant que la page elle-même soit écrite sur le disque. Après un crash, InnoDB rejoue ce journal ;</li><li><strong>le doublewrite buffer</strong>, expliqué plus bas ;</li><li><strong>les binlogs</strong>, qui n&rsquo;appartiennent pas à InnoDB mais au serveur : ils décrivent les changements au niveau SQL, pour la réplication et la restauration à un instant donné.</li></ul>



<p class="wp-block-paragraph">Une base de données SQL (<code>CREATE DATABASE shop</code>) n&rsquo;est rien d&rsquo;autre qu&rsquo;un répertoire. Les noms exacts des fichiers globaux varient : MySQL et MariaDB ont divergé sur le dictionnaire (MySQL 8 a remplacé les fichiers <code>.frm</code> par un dictionnaire transactionnel dans <code>mysql.ibd</code>, MariaDB a gardé les <code>.frm</code>), sur le redo et sur le doublewrite. Le format interne des pages, lui, est commun. Pour voir les tablespaces d&rsquo;une instance :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="sql">-- MySQL 8
SELECT name, file_size, allocated_size FROM information_schema.INNODB_TABLESPACES;
-- MariaDB
SELECT name, file_size, allocated_size FROM information_schema.INNODB_SYS_TABLESPACES;</pre>



<h2 class="wp-block-heading">Pages, extents, segments</h2>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_hierarchy_fr.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="287" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_hierarchy_fr.png?resize=629%2C287&#038;ssl=1" alt="Hiérarchie tablespace, segments, extents, pages" class="wp-image-1774" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_hierarchy_fr.png?w=1800&amp;ssl=1 1800w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_hierarchy_fr.png?resize=300%2C137&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_hierarchy_fr.png?resize=1024%2C466&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_hierarchy_fr.png?resize=768%2C350&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_hierarchy_fr.png?resize=1536%2C700&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_hierarchy_fr.png?resize=900%2C410&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_hierarchy_fr.png?resize=1280%2C583&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">Un tablespace est découpé en pages de 16 Ko, regroupées en extents de 1 Mo, attribués aux segments de chaque index.</figcaption></figure>



<p class="wp-block-paragraph">L&rsquo;unité de base est la <strong>page</strong>, de 16 Ko par défaut. Sa taille (<code>innodb_page_size</code>) est fixée à la création de l&rsquo;instance et ne peut plus changer ensuite. Tout ce qu&rsquo;InnoDB lit ou écrit, il le lit ou l&rsquo;écrit par pages entières : lire une ligne de 100 octets, c&rsquo;est charger 16 Ko en mémoire, dans le <em>buffer pool</em>.</p>



<p class="wp-block-paragraph">Les pages sont regroupées en <strong>extents</strong> de 64 pages, soit 1 Mo. Allouer par extents plutôt que page par page garde les pages d&rsquo;un même index physiquement proches, ce qui rend les lectures séquentielles efficaces.</p>



<p class="wp-block-paragraph">Chaque index possède deux <strong>segments</strong> : un pour ses pages feuilles, un pour ses pages intermédiaires. Les séparer garde les feuilles (là où sont les données) contiguës, pour les parcours par plage. Pour ne pas réserver 1 Mo à chaque petite table, un segment reçoit d&rsquo;abord jusqu&rsquo;à 32 pages isolées, et ne passe aux extents complets qu&rsquo;ensuite. C&rsquo;est pourquoi une table vide occupe une centaine de kilo-octets, et pas plusieurs mégaoctets.</p>



<p class="wp-block-paragraph">Les premières pages d&rsquo;un <code>.ibd</code> sont réservées à la gestion de l&rsquo;espace : la page 0 (<code>FSP_HDR</code>) décrit l&rsquo;état des extents, la page 1 le bitmap du change buffer, la page 2 les « inodes » des segments. Sur MySQL 8, la page 3 contient la description de la table en JSON (SDI) et la racine de l&rsquo;index cluster est en page 4. Sur MariaDB, qui garde des <code>.frm</code>, la racine est en page 3.</p>



<h2 class="wp-block-heading">Anatomie d&rsquo;une page InnoDB</h2>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_page_fr.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="402" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_page_fr.png?resize=629%2C402&#038;ssl=1" alt="Structure d&#x27;une page d&#x27;index InnoDB de 16 Ko" class="wp-image-1775" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_page_fr.png?w=1720&amp;ssl=1 1720w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_page_fr.png?resize=300%2C192&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_page_fr.png?resize=1024%2C654&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_page_fr.png?resize=768%2C490&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_page_fr.png?resize=1536%2C981&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_page_fr.png?resize=900%2C575&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_page_fr.png?resize=1280%2C817&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">Une page d&rsquo;index : en-têtes, lignes, espace libre, répertoire et queue.</figcaption></figure>



<p class="wp-block-paragraph">Chaque zone a une raison d&rsquo;être :</p>



<ul class="wp-block-list"><li><strong>Le FIL header</strong> est commun à toutes les pages, quel que soit leur type. Il contient le numéro de page, le type de page et le <strong>LSN</strong> (Log Sequence Number) de la dernière modification. Dans l&rsquo;ancienne disposition, il contient aussi la somme de contrôle, qui détecte une corruption à la lecture ; sur MariaDB 10.11, la disposition par défaut (<code>full_crc32</code>) laisse les octets 0 à 3 à zéro et place la somme de contrôle tout à la fin de la page (voir les tests en fin de billet). Après un crash, InnoDB compare ce LSN à celui de chaque entrée du redo : il ne rejoue que ce que la page n&rsquo;a pas encore reçu. Les pointeurs vers les pages précédente et suivante relient toutes les pages d&rsquo;un même niveau de l&rsquo;arbre ;</li><li><strong>l&rsquo;INDEX header</strong> donne le niveau de la page dans l&rsquo;arbre (0 pour une feuille), le nombre de lignes et l&rsquo;identifiant de l&rsquo;index auquel elle appartient ;</li><li><strong>infimum et supremum</strong> sont deux fausses lignes, la plus petite et la plus grande possibles. Elles servent de début et de fin à la liste chaînée des lignes, ce qui simplifie le code d&rsquo;insertion ;</li><li><strong>les lignes</strong> sont écrites dans l&rsquo;ordre où elles arrivent, mais chacune pointe vers la suivante <em>dans l&rsquo;ordre de la clé</em>. Insérer au milieu ne déplace rien : il suffit de modifier deux pointeurs ;</li><li><strong>le page directory</strong> part de la fin de la page et grandit vers le haut. Il contient un « slot » pour 4 à 8 lignes. Pour trouver une clé, InnoDB fait une recherche dichotomique dans ces slots, puis parcourt au plus quelques lignes de la liste chaînée ;</li><li><strong>le FIL trailer</strong> occupe les 8 derniers octets de la page. Dans l&rsquo;ancienne disposition, il répète la somme de contrôle et les 32 bits de poids faible du LSN : une page dont le début et la fin ne correspondent pas n&rsquo;a été écrite qu&rsquo;à moitié. Sur MariaDB 10.11 (<code>full_crc32</code>), les 4 derniers octets sont la somme de contrôle elle-même, un CRC-32C du reste de la page, et les 4 octets qui les précèdent sont les 32 bits de poids faible du LSN.</li></ul>



<p class="wp-block-paragraph">Quand une page est pleine et qu&rsquo;une ligne doit y entrer, InnoDB la <strong>coupe en deux</strong> (page split) et met à jour la page parente. Quand deux pages voisines descendent sous la moitié de leur remplissage, il peut les fusionner. Ces opérations sont coûteuses et laissent des pages à moitié vides : c&rsquo;est la fragmentation.</p>



<h2 class="wp-block-heading">Le doublewrite buffer : pourquoi écrire deux fois</h2>



<p class="wp-block-paragraph">Un disque garantit l&rsquo;atomicité d&rsquo;écriture de 512 octets ou 4 Ko, pas de 16 Ko. Si le serveur s&rsquo;arrête au milieu de l&rsquo;écriture d&rsquo;une page, celle-ci peut se retrouver à moitié ancienne, à moitié nouvelle : une <em>torn page</em>. Le redo ne peut pas la réparer, car il décrit des modifications à appliquer sur une page cohérente. InnoDB écrit donc chaque page d&rsquo;abord dans le doublewrite buffer, puis à son emplacement définitif. Après un crash, une page déchirée est remplacée par sa copie intacte, puis le redo s&rsquo;applique. Sur un système de fichiers qui garantit lui-même des écritures atomiques de 16 Ko (ZFS, certains SSD), on peut le désactiver.</p>



<h2 class="wp-block-heading">Une ligne</h2>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_record_fr.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="219" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_record_fr.png?resize=629%2C219&#038;ssl=1" alt="Format d&#x27;une ligne InnoDB DYNAMIC" class="wp-image-1776" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_record_fr.png?w=1800&amp;ssl=1 1800w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_record_fr.png?resize=300%2C105&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_record_fr.png?resize=1024%2C357&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_record_fr.png?resize=768%2C268&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_record_fr.png?resize=1536%2C536&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_record_fr.png?resize=900%2C314&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_record_fr.png?resize=1280%2C447&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">Une ligne de l&rsquo;index cluster et le détail de son en-tête.</figcaption></figure>



<ul class="wp-block-list"><li><strong>Les colonnes cachées</strong> : <code>DB_TRX_ID</code> est l&rsquo;identifiant de la dernière transaction qui a modifié la ligne, <code>DB_ROLL_PTR</code> pointe vers l&rsquo;image précédente dans l&rsquo;undo. Une transaction qui ne doit pas voir la modification suit ce pointeur pour reconstruire l&rsquo;ancienne version ;</li><li><strong>sans clé primaire</strong>, InnoDB utilise la première clé unique non nulle, ou à défaut ajoute une colonne cachée <code>DB_ROW_ID</code> de 6 octets, alimentée par un compteur global partagé par toutes ces tables. Définissez toujours une clé primaire ;</li><li><strong>le bit « deleted »</strong> : un <code>DELETE</code> ne fait que marquer la ligne. Elle est réellement retirée plus tard par le <em>purge thread</em>, une fois qu&rsquo;aucune transaction ne peut plus la voir ;</li><li><strong>les NULL</strong> ne coûtent qu&rsquo;un bit dans le bitmap : aucune place dans les données ;</li><li><strong>les colonnes longues</strong> (<code>TEXT</code>, <code>BLOB</code>, gros <code>VARCHAR</code>) : une ligne doit tenir dans environ la moitié d&rsquo;une page, pour qu&rsquo;une page contienne au moins deux lignes. Au format <code>DYNAMIC</code>, une valeur trop longue part entière dans des pages de débordement, et la ligne ne garde qu&rsquo;un pointeur de 20 octets.</li></ul>



<h2 class="wp-block-heading">L&rsquo;index cluster d&rsquo;InnoDB : la table est un arbre</h2>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_btree_fr.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="294" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_btree_fr.png?resize=629%2C294&#038;ssl=1" alt="Index cluster et index secondaire InnoDB" class="wp-image-1772" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_btree_fr.png?w=1800&amp;ssl=1 1800w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_btree_fr.png?resize=300%2C140&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_btree_fr.png?resize=1024%2C478&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_btree_fr.png?resize=768%2C358&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_btree_fr.png?resize=1536%2C717&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_btree_fr.png?resize=900%2C420&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/10/innodb_btree_fr.png?resize=1280%2C597&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">Les feuilles de l&rsquo;index PRIMARY contiennent les lignes ; un index secondaire contient la clé primaire.</figcaption></figure>



<p class="wp-block-paragraph">C&rsquo;est la décision de conception la plus importante d&rsquo;InnoDB : <strong>la table est l&rsquo;index de la clé primaire</strong>. Il n&rsquo;existe pas de « tas » de lignes à côté : les lignes complètes sont stockées dans les feuilles d&rsquo;un arbre B+ trié par clé primaire. Plusieurs conséquences pratiques en découlent :</p>



<ul class="wp-block-list"><li><strong>une recherche par clé primaire</strong> descend l&rsquo;arbre et trouve directement la ligne. Avec des pages de 16 Ko et une clé <code>BIGINT</code>, une page intermédiaire référence plus d&rsquo;un millier de pages filles : trois niveaux suffisent pour des centaines de millions de lignes ;</li><li><strong>une clé primaire croissante</strong> (<code>AUTO_INCREMENT</code>) ajoute toujours à droite de l&rsquo;arbre : les pages se remplissent proprement, sans split au milieu. Une clé aléatoire (UUID v4) insère n&rsquo;importe où, provoque des splits partout et laisse des pages à moitié pleines. Les UUID ordonnés dans le temps (UUID v7) évitent ce problème ;</li><li><strong>un index secondaire</strong> ne contient pas l&rsquo;adresse physique de la ligne mais sa <strong>clé primaire</strong>. Une recherche par index secondaire coûte donc deux descentes d&rsquo;arbre. En contrepartie, déplacer une ligne lors d&rsquo;un split ne demande pas de mettre à jour les index secondaires ;</li><li><strong>une clé primaire longue</strong> est recopiée dans chaque index secondaire : une clé de 36 caractères multiplie la taille de tous les index ;</li><li><strong>un index couvrant</strong>, qui contient toutes les colonnes dont la requête a besoin, évite la seconde descente : <code>EXPLAIN</code> affiche alors <code>Using index</code>.</li></ul>



<h2 class="wp-block-heading">Pourquoi le fichier ne rétrécit pas</h2>



<p class="wp-block-paragraph">Un <code>DELETE</code> massif libère de la place <em>dans</em> les pages, que les insertions suivantes réutiliseront, mais ne rend rien au système de fichiers. Pour récupérer l&rsquo;espace, il faut reconstruire la table :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="sql">OPTIMIZE TABLE shop.orders;          -- équivaut, pour InnoDB, à ALTER TABLE … FORCE
-- data_free n&#x27;est qu&#x27;une estimation, voir les tests plus bas
SELECT table_name, data_free/1024/1024 AS free_mb
FROM information_schema.tables WHERE table_schema=&#x27;shop&#x27; ORDER BY data_free DESC;</pre>



<p class="wp-block-paragraph">N&rsquo;utilisez pas <code>data_free</code> pour décider si une reconstruction vaut le coup : dans mon test, il est resté à 7,0 Mo après la suppression de 90 % d&rsquo;une table dont le fichier faisait 80 Mio, et la reconstruction a ramené le fichier à 9 Mio. C&rsquo;est la taille du fichier sur disque que la reconstruction récupère.</p>



<p class="wp-block-paragraph">La reconstruction copie toutes les lignes dans un nouveau fichier, compact, puis remplace l&rsquo;ancien. Elle se fait en ligne sur les versions récentes, mais demande temporairement l&rsquo;espace d&rsquo;une seconde copie de la table.</p>



<p class="wp-block-paragraph">Prochain billet : PostgreSQL, qui a fait le choix inverse sur presque tous ces points : une table en tas, des pages de 8 Ko, et des anciennes versions gardées dans la table elle-même.</p>



<h2 class="wp-block-heading">Ce que j&rsquo;ai mesuré (mise à jour du 2026-11-04)</h2>



<p class="wp-block-paragraph">J&rsquo;ai d&rsquo;abord écrit ce billet à partir de la documentation. Je l&rsquo;ai ensuite testé sur <strong>MariaDB 10.11.18</strong>, Rocky Linux 10.2, WSL2, sur une instance jetable avec un buffer pool de 256 Mo. Ces tests ont corrigé deux affirmations plus haut (la position de la somme de contrôle et <code>data_free</code>) et confirmé la disposition des pages, les colonnes cachées, le comportement d&rsquo;<code>OPTIMIZE TABLE</code> et l&#8217;emplacement du doublewrite buffer. <strong>Je n&rsquo;ai pas testé MySQL 8</strong> : les numéros de page 3 (SDI) et 4 (racine) pour MySQL viennent de la documentation.</p>



<p class="wp-block-paragraph">Les deux vues de l&rsquo;information schema, et les fichiers d&rsquo;une base d&rsquo;une seule table :</p>



<pre class="wp-block-code"><code>-rw-rw---- 1 mysql mysql  1055 Nov  4 17:21 orders.frm
-rw-rw---- 1 mysql mysql 65536 Nov  4 17:21 orders.ibd
| name             | file_size | allocated_size |
| innodb_system    |  12582912 |       12587008 |
| shop/orders      |     65536 |          65536 |
| innodb_temporary |  12582912 |       12582912 |
ERROR 1109 (42S02) at line 1: Unknown table 'INNODB_TABLESPACES' in information_schema</code></pre>



<p class="wp-block-paragraph">Les premières pages de <code>orders.ibd</code> (4 pages de 16 Kio), après un arrêt propre. La page 0 est l&rsquo;en-tête de l&rsquo;espace, la 1 le bitmap du change buffer, la 2 les inodes de segments, et la page 3 est la racine de l&rsquo;index clustered :</p>



<pre class="wp-block-code"><code>page 0: type=8 (FSP_HDR) page_no=0 space_id=5 lsn=46034 prev=0xffffffff next=0xffffffff
page 1: type=5 (IBUF_BITMAP) page_no=1 space_id=5 lsn=45760 prev=0xffffffff next=0xffffffff
page 2: type=3 (INODE) page_no=2 space_id=5 lsn=46034 prev=0xffffffff next=0xffffffff
page 3: type=17855 (INDEX) page_no=3 space_id=5 lsn=47160 prev=0xffffffff next=0xffffffff</code></pre>



<p class="wp-block-paragraph">Où se trouve la somme de contrôle. Avec <code>innodb_checksum_algorithm = full_crc32</code>, les octets 0 à 3 sont à zéro, et les 4 derniers octets égalent le CRC-32C des 16380 premiers octets de la page, pour chacune des 4 pages :</p>



<pre class="wp-block-code"><code>innodb_checksum_algorithm = full_crc32
page 0: bytes 0-3=0x00000000  last 4 bytes=0x34c1c1a7  crc32c(page without last 4 bytes)=0x34c1c1a7  match=True
page 1: bytes 0-3=0x00000000  last 4 bytes=0x248d385f  crc32c(page without last 4 bytes)=0x248d385f  match=True
page 2: bytes 0-3=0x00000000  last 4 bytes=0x85d98b4a  crc32c(page without last 4 bytes)=0x85d98b4a  match=True
page 3: bytes 0-3=0x00000000  last 4 bytes=0x3e60cc66  crc32c(page without last 4 bytes)=0x3e60cc66  match=True</code></pre>



<p class="wp-block-paragraph">Les colonnes cachées, lues directement dans la page 3. Après la clé primaire sur 4 octets (<code>80000001</code>, bit de signe inversé) viennent <code>DB_TRX_ID</code> sur 6 octets et <code>DB_ROLL_PTR</code> sur 7 octets, puis les colonnes utilisateur :</p>



<pre class="wp-block-code"><code>id=1 at page offset 128: pk=80000001 DB_TRX_ID=000000000013 DB_ROLL_PTR=84000001340110 columns: 616c6963656669727374060300000018
id=2 at page offset 163: pk=80000002 DB_TRX_ID=000000000013 DB_ROLL_PTR=8400000134011c columns: 626f627365636f6e64050500000020ff
id=3 at page offset 197: pk=80000003 DB_TRX_ID=000000000013 DB_ROLL_PTR=84000001340128 columns: 6361726f6c7468697264000000000000</code></pre>



<p class="wp-block-paragraph">Un <code>DELETE</code> ne réduit pas le fichier, <code>data_free</code> ne voit pas l&rsquo;espace libre, et <code>OPTIMIZE TABLE</code> reconstruit la table. <code>ALTER TABLE ... FORCE</code> donne la même taille finale (9437184 octets). <code>ibdata1</code> est passé de 12 à 76 Mio pendant le chargement (les undo logs sont ici dans le tablespace système, <code>innodb_undo_tablespaces = 0</code>) et n&rsquo;a pas diminué ensuite. Dans les lignes de tableau ci-dessous, les colonnes sont <code>table_rows</code>, <code>data_mb</code>, <code>index_mb</code> et <code>data_free_mb</code> :</p>



<pre class="wp-block-code"><code>-- after loading 300000 rows
   big.ibd: 83886080 bytes
   ibdata1: 79691776 bytes
|     299999 |     0.0 |      0.0 |          7.0 |
-- after DELETE of 90% of the rows
   big.ibd: 83886080 bytes
   ibdata1: 79691776 bytes
|      29999 |     0.0 |      0.0 |          7.0 |
| shop.big | optimize | note     | Table does not support optimize, doing recreate + analyze instead |
-- after OPTIMIZE TABLE
   big.ibd: 9437184 bytes
   ibdata1: 79691776 bytes
|      29614 |     8.5 |      0.0 |          0.0 |
-- before ALTER TABLE FORCE
   big.ibd: 146800640 bytes
   ibdata1: 79691776 bytes
|      29614 |     8.5 |      0.0 |          7.0 |
-- after ALTER TABLE FORCE
   big.ibd: 9437184 bytes
   ibdata1: 79691776 bytes
|      29614 |     8.5 |      0.0 |          0.0 |</code></pre>



<p class="wp-block-paragraph">Les clés aléatoires. 500 000 lignes, buffer pool de 256 Mo, tout en mémoire. L&rsquo;<code>UUID()</code> horodaté (version 1) et la clé aléatoire de type version 4 donnent des fichiers plus gros qu&rsquo;un <code>BIGINT</code> séquentiel. Les largeurs de clé diffèrent (8 et 36 octets) : comparez plutôt les deux variantes d&rsquo;UUID entre elles, la clé aléatoire est 40 % plus grosse, ce qui est cohérent avec des pages moins remplies (je n&rsquo;ai pas mesuré le remplissage). Je n&rsquo;ai pas pu montrer de ralentissement des insertions avec tout en mémoire, et la dernière ligne inclut le coût de <code>RANDOM_BYTES()</code> :</p>



<pre class="wp-block-code"><code>insert 500000 rows, sequential bigint : 0.8 s
insert 500000 rows, random UUID (v1)  : 1.1 s
| shop/pk_seq  |    32.0 |
| shop/pk_uuid |    48.0 |
insert 500000 rows, random v4-like UUID : 2.3 s
| shop/pk_rand |    68.0 |</code></pre>



<p class="wp-block-paragraph">Où se trouve le doublewrite buffer. J&rsquo;ai démarré MariaDB 10.11.18 deux fois, avec la valeur par défaut <code>innodb_doublewrite=ON</code> puis avec <code>OFF</code>, chargé 20 000 lignes dans une table et tracé les écritures de pages avec <code>strace</code>. Les deux essais utilisaient <code>innodb_use_native_aio=OFF</code>, sinon les écritures ne passent pas par <code>pwrite64</code> et <code>strace</code> ne les voit pas. Avec le doublewrite activé, <code>ibdata1</code> reçoit deux écritures groupées qui commencent à la page 64 (160 Kio et 64 Kio), et <code>Innodb_dblwr_pages_written</code> passe de 0 à 39. Désactivé, rien n&rsquo;est écrit à la page 64 d&rsquo;<code>ibdata1</code> et le compteur reste à 0. Sur MariaDB 10.11, le doublewrite buffer se trouve donc dans le tablespace système. Je n&rsquo;ai pas regardé sa disposition exacte au-delà de la page de départ.</p>



<pre class="wp-block-code"><code>################ innodb_doublewrite = ON (default), native AIO off
| innodb_doublewrite | ON    |
| Innodb_dblwr_pages_written | 0     |
| Innodb_dblwr_pages_written | 39    |
  ibdata1             4 writes,     262144 bytes, pages 0..316
first alternations (file, consecutive writes): [['dw.ibd', 10], ['ibdata1', 1], ['dw.ibd', 39], ['ibdata1', 1], ['dw.ibd', 1], ['ibdata1', 2]]
ibdata1 writes as (first page, KiB): [(316, 16), (64, 160), (64, 64), (0, 16)]
################ innodb_doublewrite = OFF, native AIO off
| innodb_doublewrite | OFF   |
| Innodb_dblwr_pages_written | 0     |
| Innodb_dblwr_pages_written | 0     |
  ibdata1             2 writes,      32768 bytes, pages 0..49
first alternations (file, consecutive writes): [['dw.ibd', 5], ['ibdata1', 1], ['dw.ibd', 20], ['ibdata1', 1]]
ibdata1 writes as (first page, KiB): [(49, 16), (0, 16)]</code></pre>



<p class="wp-block-paragraph">Les compteurs sont ceux de <code>SHOW GLOBAL STATUS LIKE 'Innodb_dblwr%'</code>, avant et après le chargement.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph"><em>Ce billet fait partie d&rsquo;une série de notes d&rsquo;administration système (tag « carnet d&rsquo;admin »).</em><br>Série « Dans les fichiers d&rsquo;une base de données », partie 1/3. · <a href="https://blog.neurozone.fr/?p=1788">Billet suivant →</a> · <a href="https://hecate.neurozone.fr/?p=377">English version</a></p>



<h2 class="wp-block-heading">Articles liés</h2>



<ul class="wp-block-list"><li><a href="https://blog.neurozone.fr/?p=1788">Les fichiers de PostgreSQL</a></li><li><a href="https://blog.neurozone.fr/?p=1789">Les fichiers d&rsquo;Oracle, blocs et ROWID</a></li><li><a href="https://blog.neurozone.fr/?p=2044">Les réglages MariaDB qui comptent</a></li></ul>

<p>L’article <a href="https://blog.neurozone.fr/2026/10/23/dans-les-fichiers-dune-base-de-donnees-1-3-innodb-de-mysql-a-mariadb/">Dans les fichiers d&rsquo;une base de données (1/3) : InnoDB, de MySQL à MariaDB</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://blog.neurozone.fr/2026/10/23/dans-les-fichiers-dune-base-de-donnees-1-3-innodb-de-mysql-a-mariadb/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1787</post-id>	</item>
	</channel>
</rss>
