News zu Post­greS­QL Datenbank

Post­greS­QL 19 Beta 3 im Pra­xis­test: Bloat mit dem neuen REPACK-Befehl beseitigen

Wenn du Post­greS­QL-Da­ten­ban­ken über längere Zeit betreibst, kennst du das Thema sicher: Durch UPDATE- und DELETE-Ope­ra­tio­nen entstehen Dead Tuples und Tabellen können mit der Zeit deutlich mehr Spei­cher­platz belegen, als es die tat­säch­lich vor­han­de­ne Da­ten­men­ge vermuten lässt. Ein normales VACUUM schafft hier zwar innerhalb der Relation wieder Platz für neue Daten – die physische Größe der Tabelle auf dem Da­tei­sys­tem reduziert sich dadurch in der Regel jedoch nicht.

Mit Post­greS­QL 19 kommt dafür eine neue Mög­lich­keit direkt in den Core: REPACK schreibt eine Tabelle voll­stän­dig neu und kann dadurch un­ge­nutz­ten Speicher tat­säch­lich wieder an das Be­triebs­sys­tem zurückgeben.

PostgreSQL

Das ist nicht nur aus Sicht des Da­ten­bank­be­triebs in­ter­es­sant. Gerade bei großen oder über Jahre ge­wach­se­nen Da­ten­ban­ken kann das physische Ver­klei­nern stark auf­ge­bläh­ter Tabellen auch den be­nö­tig­ten Storage re­du­zie­ren und damit Res­sour­cen ef­fi­zi­en­ter nutzbar machen. 

Wie groß der Un­ter­schied zwischen VACUUM und dem neuen REPACK in der Praxis sein kann, zeigt das folgende Beispiel mit Post­greS­QL 19 Beta 3.

Inhaltsverzeichnis 

Test­da­ten­bank vorbereiten

Die Test­um­ge­bung verwendet:

PostgreSQL 19beta3

Zunächst wird eine neue Test­da­ten­bank erstellt:

createdb repacktest

An­schlie­ßend werden mit pgbench 10 Millionen Da­ten­sät­ze erzeugt:

pgbench -i -s 100 repacktest

Ver­bin­dung:

psql repacktest

Version:

psql (19beta3)

Da­ten­sät­ze prüfen:

SELECT count(*)
FROM pgbench_accounts;

Ergebnis:

count
----------
 10000000

Aus­gangs­grö­ße

Dieses Statment wird im Folgenden noch häufiger ausgeführt:

SELECT
    pg_size_pretty(pg_relation_size('pgbench_accounts'))       AS table_size,
    pg_size_pretty(pg_indexes_size('pgbench_accounts'))        AS index_size,
    pg_size_pretty(pg_total_relation_size('pgbench_accounts')) AS total_size;

Ergebnis:

 table_size | index_size | total_size
------------+------------+------------
 1281 MB    | 214 MB     | 1495 MB

Damit ergibt sich folgende Ausgangslage:

Kennzahl    |    Wert
------------+---------------
Datensätze  |    10.000.000
Tabelle     |    1281 MB
Indizes     |    214 MB
Gesamt      |    1495 MB

Bloat erzeugen

Nun werden 50 % der Da­ten­sät­ze gelöscht:

DELETE FROM pgbench_accounts
WHERE aid % 2 = 0;

Ergebnis:

DELETE 5000000

Anzahl der ver­blei­ben­den Datensätze:

SELECT count(*)
FROM pgbench_accounts;

Ergebnis:

count
---------
 5000000

Die logische Da­ten­men­ge wurde damit halbiert.

Physische Größe prüfen

Es wird das Statement von oben ausgeführt.

Ergebnis:

table_size | index_size | total_size
------------+------------+------------
 1281 MB    | 214 MB     | 1495 MB

Obwohl nur noch die Hälfte der Da­ten­sät­ze vorhanden ist, bleibt die physische Größe un­ver­än­dert.
Das ist der ge­wünsch­te Bloat-Zustand.

Dead Tuples prüfen

SELECT
    relname,
    n_live_tup,
    n_dead_tup
FROM pg_stat_user_tables
WHERE relname = 'pgbench_accounts';

Ergebnis:

     relname      | n_live_tup | n_dead_tup
------------------+------------+------------
 pgbench_accounts |    4999844 |    5000000

Damit sind ungefähr 5 Millionen Dead Tuples vorhanden.

Physische Datei ermitteln

SELECT pg_relation_filepath('pgbench_accounts');

Ergebnis:

base/16858/16874

Auf Be­triebs­sys­tem­ebe­ne:

du -sh $PGDATA/base/16858/16874

Ergebnis:

1.1G    /opt/postgres/ASPICON/PGDATA/base/16858/16874

Auch auf Da­tei­sys­tem­ebe­ne wurde also noch kein Spei­cher­platz zurückgegeben.

Normales VACUUM

Nun wird ein normales VACUUM mit ANALYZE ausgeführt:

VACUUM (VERBOSE, ANALYZE) pgbench_accounts;

Auszug aus der Ausgabe:

INFO:  vacuuming "repacktest.public.pgbench_accounts"

pages:
0 removed,
163935 remain,
7892 scanned (4.81% of total)

tuples:
44859 removed,
4999913 remain,
0 are dead but not yet removable

index scan bypassed:
1499 pages from table (0.91% of total)
have 45991 dead item identifiers

avg read rate: 126.089 MB/s
avg write rate: 25.898 MB/s

WAL usage:
1500 records,
1452 full page images,
6256905 bytes

elapsed: 0.44 s

An­schlie­ßend wurde noch ANALYZE ausgeführt:

INFO:  analyzing "public.pgbench_accounts"

"pgbench_accounts":
scanned 30000 of 163935 pages,
containing 915067 live rows and 8777 dead rows

5000384 estimated total rows

elapsed: 0.36 s

Größe nach VACUUM

Es wird das Statement von oben ausgeführt.

Ergebnis:

table_size | index_size | total_size
------------+------------+------------
 1281 MB    | 214 MB     | 1495 MB

Die physische Größe bleibt unverändert.

Dead Tuples nach VACUUM

SELECT
    relname,
    n_live_tup,
    n_dead_tup
FROM pg_stat_user_tables
WHERE relname = 'pgbench_accounts';

Ergebnis:

     relname      | n_live_tup | n_dead_tup
------------------+------------+------------
 pgbench_accounts |    5000384 |      47962
Die Dead Tuples wurden damit von ungefähr 5.000.000 auf nur noch etwa 47.962 reduziert. 

Da­tei­sys­tem

du -sh $PGDATA/base/16858/16874

Ergebnis:

1.1G    /opt/postgres/ASPICON/PGDATA/base/16858/16874

Damit bestätigt sich das erwartete Verhalten:

Merkmal                 |  Vor VACUUM   |  Nach VACUUM
------------------------+---------------+----------------
Live Tuples             |  ~5 Mio.      |  ~5 Mio.
Dead Tuples             |  ~5 Mio.      |  ~48.000
Tabellengröße           |  1281 MB      |  1281 MB
Gesamtgröße             |  1495 MB      |  1495 MB
Datei im Filesystem     |  1.1 GB       |  1.1 GB

VACUUM macht den frei­ge­wor­de­nen Platz innerhalb der Relation wie­der­ver­wend­bar, gibt ihn aber nor­ma­ler­wei­se nicht an das Be­triebs­sys­tem zurück.

Klas­si­sches REPACK

An­schlie­ßend wird die Tabelle mit dem neuen Post­greS­QL-19-Befehl voll­stän­dig neu geschrieben:

REPACK (VERBOSE, ANALYZE) pgbench_accounts;

Ausgabe:

INFO:  repacking "public.pgbench_accounts" in physical order

INFO:  "public.pgbench_accounts":
found 0 removable,
5000000 nonremovable row versions
in 163935 pages

DETAIL:
0 dead row versions cannot be removed yet.

CPU:
user: 14.04 s,
system: 17.55 s,
elapsed: 56.52 s

An­schlie­ßend:

INFO:  analyzing "public.pgbench_accounts"

"pgbench_accounts":
scanned 30000 of 81968 pages,
containing 1829952 live rows
and 0 dead rows

4999917 estimated total rows

Auffällig ist bereits hier die Anzahl der Seiten:

  • vor REPACK: 163935 pages
  • nach REPACK: 81968 pages


Die Anzahl der Heap-Seiten wurde damit praktisch halbiert.

Größe nach REPACK

Es wird das Statement von oben ausgeführt.

Ergebnis:

 table_size | index_size | total_size
------------+------------+------------
 640 MB     | 107 MB     | 748 MB

Damit ergibt sich:

Kennzahl  |  Vor REPACK  |  Nach REPACK  |  Veränderung
----------+--------------+---------------+--------------
Tabelle   |  1281 MB     |  640 MB       |  ca. -50 %
Indizes   |  214 MB      |  107 MB       |  ca. -50 %
Gesamt    |  1495 MB     |  748 MB       |  ca. -50 %

Das ent­spricht sehr genau der zuvor hal­bier­ten Datenmenge.

Dead Tuples nach REPACK

SELECT
    relname,
    n_live_tup,
    n_dead_tup
FROM pg_stat_user_tables
WHERE relname = 'pgbench_accounts';

Ergebnis:

     relname      | n_live_tup | n_dead_tup
------------------+------------+------------
 pgbench_accounts |    4999917 |          0

Nach dem Rewrite befinden sich damit keine Dead Tuples mehr in der neuen Relation.

Phy­si­scher Austausch der Relation

Besonders in­ter­es­sant ist die Änderung des Relation-Pfades.

Vor dem REPACK:

base/16858/16874

Nach dem REPACK:

SELECT pg_relation_filepath('pgbench_accounts');

Ergebnis:

base/16858/16884

Post­greS­QL verwendet nach dem Rewrite also eine neue physische Relation.

Die alte Datei:

du -sh $PGDATA/base/16858/16874

Ergebnis:

0    /opt/postgres/ASPICON/PGDATA/base/16858/16874

Die neue Datei:

du -sh $PGDATA/base/16858/16884

Ergebnis:

641M    /opt/postgres/ASPICON/PGDATA/base/16858/16884

Ergebnis des Tests

Der Test zeigt sehr deutlich den Un­ter­schied zwischen VACUUM und REPACK.

Aus­gangs­zu­stand

10.000.000 Rows

Table:   1281 MB
Indexes:  214 MB
Total:   1495 MB

Nach dem Löschen von 50 % der Daten:

5.000.000 Live Tuples
5.000.000 Dead Tuples

Table:   1281 MB
Indexes:  214 MB
Total:   1495 MB

Die physische Größe bleibt unverändert.

Nach VACUUM

~5.000.000 Live Tuples
~48.000 Dead Tuples

Table:   1281 MB
Indexes:  214 MB
Total:   1495 MB

Der Platz ist intern wie­der­ver­wend­bar, wird aber nicht an das Be­triebs­sys­tem zurückgegeben.

Nach REPACK

~5.000.000 Live Tuples
0 Dead Tuples

Table:   640 MB
Indexes: 107 MB
Total:   748 MB

Damit wurden ungefähr 50 % des Spei­cher­plat­zes tat­säch­lich zurückgewonnen.

Zu­sam­men­fas­sung

Zustand        |  Live Tuples  |  Dead Tuples  |  Tabelle  |  Indizes  |  Gesamt
---------------+---------------+---------------+-----------+-----------+----------
Initial        |  10 Mio.      |  ~0           |  1281 MB  |  214 MB   |  1495 MB
nach DELETE    |  5 Mio.       |  5 Mio.       |  1281 MB  |  214 MB   |  1495 MB
nach VACUUM    |  ~5 Mio.      |  ~48 Tsd.     |  1281 MB  |  214 MB   |  1495 MB
nach REPACK    |  ~5 Mio.      |  0            |  640 MB   |  107 MB   |  748 MB

Das prak­ti­sche Ergebnis lässt sich daher sehr einfach zusammenfassen:

DELETE
   ↓
Dead Tuples entstehen
   ↓
VACUUM
   ↓
Platz innerhalb PostgreSQL wiederverwendbar
   ↓
Relation bleibt physisch groß


REPACK
   ↓
kompletter Rewrite
   ↓
neue Heap-Datei
   ↓
neue Indexdateien
   ↓
physischer Platz wird zurückgewonnen

REPACK (CON­CURR­ENT­LY)

Der nächste Schritt besteht darin, denselben Test unter laufendem Workload durchzuführen.

Bei­spiels­wei­se:

pgbench -c 20 -j 4 -P 5 -T 300 repacktest

Parallel dazu:

REPACK (CONCURRENTLY, VERBOSE, ANALYZE) pgbench_accounts;

Während ein normales REPACK die Tabelle durch einen ACCESS EXCLUSIVE Lock blockiert, wird dieser Lock bei CONCURRENTLY im We­sent­li­chen erst für den ab­schlie­ßen­den Austausch benötigt.

Än­de­run­gen während des Neu­auf­baus werden über Logical Decoding erfasst und auf die neue Relation übertragen.

Der Ablauf lässt sich ver­ein­facht fol­gen­der­ma­ßen darstellen:

                  laufender Workload
                         |
                  INSERT / UPDATE / DELETE
                         |
                         v
Originalrelation --------------------------+
       |                                   |
       | bestehende Daten                  |
       v                                   |
Neue Relation                              |
       ^                                   |
       |                                   |
       +---------- Logical Decoding -------+
                         |
                         v
                  Änderungen nachziehen
                         |
                         v
                ACCESS EXCLUSIVE
                   kurzer Swap
                         |
                         v
                  neue Relation

Damit eignet sich REPACK (CONCURRENTLY) ins­be­son­de­re für große Tabellen, bei denen ein voll­stän­di­ger blo­ckie­ren­der Rewrite be­trieb­lich pro­ble­ma­tisch wäre.

Wichtige Ein­schrän­kung: nicht MVCC-safe

Die Post­greS­QL-Do­ku­men­ta­ti­on weist aus­drück­lich darauf hin, dass REPACK (CONCURRENTLY) nicht voll­stän­dig MVCC-safe ist (MVCC = Multi-Version Con­cur­ren­cy Control). Das ist ins­be­son­de­re für bereits laufende Trans­ak­tio­nen mit älteren Snapshots relevant.

Beispiel:

-- Session 1

BEGIN ISOLATION LEVEL REPEATABLE READ;

SELECT *
FROM andere_tabelle;

Damit existiert ein Snapshot, der vor dem Repack ent­stan­den ist. Die Tabelle pgbench_accounts wurde innerhalb dieser Trans­ak­ti­on bislang noch nicht verwendet.

Parallel:

-- Session 2

REPACK (CONCURRENTLY) pgbench_accounts;

Nach Abschluss des Rewrite greift Session 1 erstmals auf die Tabelle zu:

SELECT count(*)
FROM pgbench_accounts;

Da die physische Relation zwi­schen­zeit­lich aus­ge­tauscht wurde, kann die alte Snapshot-Sicht nicht in jedem Fall korrekt auf die neue Relation über­tra­gen werden. In be­stimm­ten Si­tua­tio­nen kann die Tabelle für eine solche Trans­ak­ti­on sogar leer erscheinen.

Besonders beachten sollte man dies bei:

  • langen REPEATABLE READ-Trans­ak­tio­nen
  • SERIALIZABLE-Trans­ak­tio­nen
  • Reporting-Jobs
  • ETL-Prozessen
  • kon­sis­ten­ten Exporten
  • langen Batch-Prozessen


Vor einem REPACK (CONCURRENTLY) sollte daher geprüft werden, ob alte bzw. lang laufende Trans­ak­tio­nen vorhanden sind:

SELECT
    pid,
    usename,
    application_name,
    state,
    xact_start,
    now() - xact_start AS transaction_age
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;

Damit ist REPACK (CONCURRENTLY) zwar weit­ge­hend online, aber nicht voll­stän­dig trans­pa­rent gegenüber allen be­stehen­den MVCC-Snapshots.

Ab­gren­zung zu pg_repack

Der Post­greS­QL-19-Befehl REPACK ist funk­tio­nal sehr ähnlich zur bekannten Extension pg_repack, stellt aber keine einfache Übernahme der Extension in den Post­greS­QL-Core dar. pg_repack bleibt ein ei­gen­stän­di­ges Projekt.

Der we­sent­li­che Un­ter­schied liegt ins­be­son­de­re in der tech­ni­schen Umsetzung der par­al­le­len Änderungen.

Die folgende Tabelle ver­gleicht die Ei­gen­schaf­ten von pg_repack und Post­greS­QL-19 miteinander:

Ei­gen­schaft
pg_repack
Post­greS­QL 19
Bestandteil 
externe Extension 
Post­greS­QL Core 
Installation 
zu­sätz­lich notwendig 
nicht notwendig 
Bloat beseitigen 
ja 
ja 
Tabelle neu schreiben 
ja 
ja 
Online-Variante 
ja 
REPACK (CON­CURR­ENT­LY)
parallele DML erfassen 
Trigger + Log-Tabelle 
Logical Decoding 
finaler ex­klu­si­ver Lock 
ja 
ja 
physische Relation wird ersetzt 
ja 
ja 
Core Progress View 
nein 
pg_stat_progress_repack 

Unsere Ein­ord­nung

Der Pra­xis­test zeigt ziemlich deutlich, welchen Mehrwert REPACK für den Post­greS­QL-Betrieb bringen kann. Nach dem Löschen von 50 Prozent der Da­ten­sät­ze blieb die physische Größe der ge­tes­te­ten Relation zunächst un­ver­än­dert. Auch ein normales VACUUM re­du­zier­te den belegten Speicher nicht. Erst REPACK schrieb die Relation voll­stän­dig neu und ver­rin­ger­te die Ge­samt­grö­ße im Test von rund 1.495 MB auf 748 MB – also nahezu ent­spre­chend der zuvor hal­bier­ten Datenmenge.

Für DBAs und IT-Ad­mi­nis­tra­to­ren bedeutet das: Bloat lässt sich mit Post­greS­QL 19 künftig mit einem nativen Core-Befehl nicht nur logisch be­rei­ni­gen, sondern physisch aus der Datenbank entfernen. Besonders bei großen, über längere Zeit ge­wach­se­nen Tabellen kann das einen spürbaren Un­ter­schied machen.

Und der Effekt endet nicht an der Da­ten­bank­gren­ze. Wenn unnötig belegter Speicher tat­säch­lich an das Be­triebs­sys­tem zu­rück­ge­ge­ben wird, kann – je nach An­wen­dungs­fall – auch der physische Storage-Bedarf der Datenbank sinken. In größeren Um­ge­bun­gen kann das unter Umständen vor­han­de­ne Spei­cher­ka­pa­zi­tä­ten besser ausnutzen und den Storage-Bedarf optimieren.

Mit REPACK (CONCURRENTLY) liefert Post­greS­QL 19 außerdem eine weit­ge­hend online nutzbare Variante für Systeme, bei denen ein länger blo­ckie­ren­der Rewrite be­trieb­lich kaum möglich ist. Al­ler­dings sollten DBAs dabei die be­schrie­be­nen Ein­schrän­kun­gen bei langen be­zie­hungs­wei­se alten MVCC-Snapshots im Blick behalten.

Unterm Strich wird REPACK damit zu einem span­nen­den neuen Werkzeug für alle, die Post­greS­QL-Da­ten­ban­ken nicht nur logisch sauber, sondern auch beim tat­säch­li­chen Res­sour­cen­ver­brauch effizient betreiben wollen.

Bitte beachte, dass REPACK Be­stand­teil der » Post­greS­QL Version 19 ist, welche aktuell nur in der Beta 3 verfügbar ist. Wir in­for­mie­ren dich, sobald die Version 19 final ver­öf­fent­licht wurde.

Hier findest du weitere Infos aus der Welt von Post­greS­QL aus unserem News & Insights Bereich.

icon-arrow_right_medium-violet-blue.svg

Share this article

Facebook 
Twitter 
LinkedIn 
XING 
WhatsApp 
Email