Zum Inhalt

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 mittels pageinspect.

  • 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 pageinspect analysieren.


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ür shared_buffers und 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;