Storage-Engines: PostgreSQL Heap vs. MariaDB InnoDB Clustered Tables
Erstellt: 08.11.2026 · Zuletzt aktualisiert: 08.11.2026 · Nejat Hakan
Management Summary: Der wichtigste Faktor für den Durchsatz einer relationalen Datenbank unter hoher Lese- und Schreiblast ist ihre physische Storage-Engine-Architektur. PostgreSQL und MariaDB (InnoDB) verfolgen dabei diametral entgegengesetzte Konzepte: PostgreSQL organisiert Tabellendaten als ungeordnete Heap-Dateien aus festen 8 KiB-Blöcken, bei denen alle Indizes sekundäre Zeigermaps (
ctid) sind. InnoDB hingegen implementiert Tabellen als Clustered Indexes (Index-Organized Tables) aus 16 KiB-Blöcken, in denen die Nutzdaten direkt in den Blattknoten des Primärschlüssel-B-Trees eingebettet sind. Dieser Leitfaden analysiert Seitenlayouts, Double-Buffering, Tupel-Header und systemnahe Seiteninspektion mittelspageinspect.
-
PostgreSQL 8 KiB Heap-Anatomie
Den internen Page-Header, das Zeiger-Array (
ItemIdData), Freispeicher-Lücken und 23-Byte-Tupel-Header (xmin,xmax,ctid) auf Bitebene verstehen. -
InnoDB 16 KiB Clustered Index
Index-Organized Tables analysieren: Warum Primärschlüsselabfragen ohne Zeiger-Hops auskommen und wie Sekundärindizes doppelte B-Tree-Lookups erzwingen.
-
Heap-Append vs. Undo-Logs
MVCC-Schreibverstärkung im Detail: PostgreSQLs zeilenbasiertes Append-Only-Verfahren gegenüber InnoDBs In-Place-Updates mit historischen Undo-Logs.
-
Low-Level Forensik-Labor
Physische Datenseiten und Tupel-Zustände im laufenden Datenbankbetrieb mit der offiziellen PostgreSQL-Erweiterung
pageinspectanalysieren.
1. Physische Speicher- & Caching-Hierarchie
Greift die Datenbank auf Daten zu, interagiert sie über zwei separate Caching-Ebenen mit dem Linux-Betriebssystem:
%%{init: {'flowchart': {'wrappingWidth': 350}}}%%
flowchart TB
APP["Client-Anwendungsabfrage"]
DB_POOL["1. Datenbank Buffer-Pool<br/>(shared_buffers /<br/>innodb_buffer_pool)"]
OS_CACHE["2. Linux Page-Cache<br/>(Gepuffertes Datei-I/O im RAM)"]
NVME["3. Physischer Speicher<br/>(8 / 16 KiB Blöcke auf Platte)"]
APP --> DB_POOL
DB_POOL <-->|"Cache-Miss / Page-Flush"| OS_CACHE
OS_CACHE <-->|"read() / write() / fsync()"| NVME
- Das Double-Buffering-Phänomen: In PostgreSQL liegen gelesene Datenblöcke sowohl im Linux-Page-Cache als auch in den
shared_buffers. In der Praxis teilt man den Arbeitsspeicher auf produktiven Servern typischerweise auf: ca. 25 % fürshared_buffersund 75 % für den Page-Cache des Linux-Kernels sowie Abfragespeicher (work_mem).
2. Anatomie einer 8 KiB PostgreSQL Heap-Page
Eine Tabelle in PostgreSQL ist ein Heap: Eine ungeordnete Ansammlung fester 8 KiB-Datenblöcke (BLCKSZ = 8192). Datensätze besitzen keine feste Sortierung auf der Festplatte; neue Zeilen werden in denjenigen Block geschrieben, der ausreichend freien Platz bietet.
+-----------------------------------------------------------------------+
| PageHeaderData (24 Bytes): LSN, Checksumme, pd_lower, pd_upper, Flags |
+-----------------------------------------------------------------------+
| Line-Pointer-Array: ItemIdData [1] [2] [3] ... (je 4 Bytes) --------> |
| (Wächst nach unten in Richtung Seitenende) |
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| FREISPEICHER-LÜCKE |
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| <---------------- Tupel 3 Daten (HeapTupleHeader + Nutzlast) |
| <---------------- Tupel 2 Daten |
| <---------------- Tupel 1 Daten (Wächst von unten nach oben) |
+-----------------------------------------------------------------------+
Der Tupel-Identifier (ctid)
Jeder Datensatz im PostgreSQL-Heap wird über eine physische Koordinate namens ctid adressiert: (block_nummer, zeiger_index).
- Beispiel (0, 3): Block 0, Line Pointer 3.
- Findet ein Index einen passenden Schlüssel, liefert er die ctid. Die Engine springt zu Block 0, liest Line Pointer 3 und findet sofort die Byte-Position des Datensatzes.
Der 23-Byte Heap-Tupel-Header
Jede Zeile auf der Festplatte besitzt einen 23-Byte-Metadaten-Header:
- t_xmin (4 Bytes): Die Transaktions-ID, die diese Zeilenversion eingefügt hat.
- t_xmax (4 Bytes): Die Transaktions-ID, die diese Zeilenversion gelöscht oder aktualisiert hat (0 bei aktiven Zeilen).
- t_cid (4 Bytes): Command-Identifier innerhalb der Transaktion.
- t_ctid (6 Bytes): Zeigt auf sich selbst oder bei einem UPDATE auf die neuere Zeilenversion.
- t_infomask (2 Bytes): Status-Flags (HEAP_XMIN_COMMITTED, HEAP_XMAX_INVALID).
- NULL Bitmap: Dynamische Bitmaske, welche Spalten den Wert NULL enthalten.
3. MariaDB / MySQL InnoDB 16 KiB Clustered Index (IOT)
In MariaDBs InnoDB-Engine sind Tabellen keine Heap-Dateien, sondern Index-Organized Tables (IOT): Die Daten sind physisch sortiert direkt in den Blattknoten des Primärschlüssel-B-Trees gespeichert.
%%{init: {'flowchart': {'wrappingWidth': 350}}}%%
flowchart TB
subgraph PG_MODEL ["1. PostgreSQL (Heap + Sekundärindex)"]
direction TB
PG_IDX["Sekundärer B-Tree Index<br/>(Speichert Key → ctid-Zeiger)"]
PG_HEAP["Tabellen-Heap-Datei<br/>(8 KiB ungeordnete Pages)<br/>Gelesen via ctid-Hop"]
PG_IDX -->|"1. Extrahiere ctid"| PG_HEAP
end
subgraph INNODB_MODEL ["2. MariaDB InnoDB (Clustered Index)"]
direction TB
INNO_SEC["Sekundärer B-Tree Index<br/>(Speichert Key → PK-Wert)"]
INNO_CLUS["Clustered B-Tree Index<br/>(Wurzel- & Verzweigung)"]
INNO_LEAF["Clustered Blattseiten (16 KiB)<br/>Nutzdaten direkt eingebettet"]
INNO_SEC -->|"1. Extrahiere PK"| INNO_CLUS
INNO_CLUS -->|"2. Traversiere Baum"| INNO_LEAF
end
PG_MODEL ==> INNODB_MODEL
Der doppelte Lookup-Aufwand bei InnoDB-Sekundärindizes
- Primärschlüsselabfrage: Sucht eine Abfrage nach dem Primärschlüssel (
WHERE id = 42), traversiert InnoDB den B-Tree direkt bis zur 16 KiB-Blattseite. Es ist kein zusätzlicher Speicher-Hop nötig, die Daten werden sofort gelesen. - Sekundärindexabfrage: Sucht eine Abfrage nach einer indizierten Spalte (
WHERE email = 'user@example.com'), durchsucht InnoDB den Sekundärindex, liest den Primärschlüssel aus und muss anschließend einen zweiten B-Tree-Durchlauf auf dem Clustered Index starten.
4. MVCC-Speichermechanik: Heap-Append vs. Undo-Logs
Aktualisiert eine Transaktion einen Datensatz (UPDATE users SET status = 'ACTIVE' WHERE user_id = 10;), stellt sich die Frage: Wie wird der alte Zustand für parallele Leser vorgehalten?
| Eigenschaft | PostgreSQL (Heap Append) | MariaDB InnoDB (In-Place + Undo) |
|---|---|---|
| Update-Ausführung | Schreibt eine komplett neue Zeile in die 8 KiB-Page; setzt altes t_xmax. |
Überschreibt die Zeile in der 16 KiB-Page in-place; schreibt alte Werte in Undo-Logs. |
| Sekundärindizes | Alle Sekundärindizes müssen auf neue ctid aktualisiert werden (außer bei HOT). |
Sekundärindizes bleiben unberührt, wenn sich die indizierten Spalten nicht ändern. |
| Tabellen-Bloat | Tote Zeilen sammeln sich im Heap; erfordert autovacuum zur Bereinigung. |
Undo-Segmente werden automatisch freigegeben, sobald alte Lese-Transaktionen schließen. |
| Rollback-Kosten | Sofort (O(1)): Setzt Transaktionsstatus in pg_xact auf ABORTED. |
Teurer: Engine muss Undo-Logs rückwärts lesen und In-Place-Änderungen zurückdrehen. |
Heap-Only Tuple (HOT) Optimierung in PostgreSQL
Um den Index-Overhead bei Updates zu minimieren, nutzt PostgreSQL die HOT-Optimierung. Ändert ein UPDATE keine indizierten Spalten und ist auf der 8 KiB-Seite noch freier Platz vorhanden:
1. Wird das neue Tupel auf derselben Speicherseite abgelegt.
2. Verweist der Line Pointer des alten Tupels intern direkt auf das neue Tupel.
3. Kein einziger Sekundärindex muss angefasst werden. Index-Scans lesen weiterhin den alten Zeiger und folgen der block-internen Kette.
5. Low-Level Forensik-Labor: Seiteninspektion mit pageinspect
PostgreSQL liefert das offizielle Contrib-Modul pageinspect, mit dem physische Block-Header und Tupel-Flags direkt via SQL ausgelesen werden können:
-- Datei: /opt/db-ops/inspect_page_forensics.sql
BEGIN;
-- 1. Forensik-Erweiterung aktivieren
CREATE EXTENSION IF NOT EXISTS pageinspect;
-- 2. Test-Tabelle anlegen und Datensatz einfügen
DROP TABLE IF EXISTS forensic_test;
CREATE TABLE forensic_test (
id INT,
payload TEXT
);
INSERT INTO forensic_test VALUES (1, 'Storage Engine Internals');
-- 3. Den 24-Byte Page-Header von Block 0 inspizieren
SELECT lsn, checksum, lower, upper, special, pagesize
FROM page_header(get_raw_page('forensic_test', 0));
-- 4. Line Pointer und Tupel-Header in Block 0 auslesen
SELECT
lp AS line_pointer,
lp_off AS byte_offset,
lp_len AS tuple_length,
t_xmin AS insert_tx_id,
t_xmax AS delete_tx_id,
t_ctid AS physical_coordinate,
t_data AS raw_tuple_data
FROM heap_page_items(get_raw_page('forensic_test', 0));
-- 5. UPDATE ausführen, um MVCC-Tupel-Versionierung live zu beobachten
UPDATE forensic_test SET payload = 'Updated Payload';
-- Erneut prüfen: Line Pointer 1 hat nun gesetztes t_xmax,
-- und Line Pointer 2 enthält die neue aktive Zeilenversion!
SELECT
lp, t_xmin, t_xmax, t_ctid
FROM heap_page_items(get_raw_page('forensic_test', 0));
COMMIT;