
Das Problem des Bloat von Tabellen und Indizes ist weit verbreitet und betrifft nicht nur Postgres. Es gibt integrierte Möglichkeiten, damit umzugehen, wie VACUUM FULL oder CLUSTER, allerdings blockieren diese die Tabellen wÀhrend des Betriebs und können daher nicht immer verwendet werden.
In diesem Artikel wird ein wenig Theorie darĂŒber behandelt, wie Bloat entsteht, wie man damit umgehen kann, ĂŒber deferierte Constraints und ĂŒber die Probleme, die sie bei der Nutzung der Erweiterung pg_repack mit sich bringen.
Dieser Artikel basiert auf auf der PgConf.Russia 2020.

Warum entsteht Bloat?
Postgres basiert auf einem mehrversionellen Modell (). Das Grundprinzip besteht darin, dass jede Zeile in einer Tabelle mehrere Versionen haben kann, wobei Transaktionen nicht mehr als eine dieser Versionen sehen, jedoch nicht unbedingt dieselbe. Dies ermöglicht mehreren Transaktionen gleichzeitig zu arbeiten, ohne sich gegenseitig zu beeintrÀchtigen.
Es ist offensichtlich, dass all diese Versionen gespeichert werden mĂŒssen. Postgres arbeitet speicherseitig seitenweise, und eine Seite ist das kleinste Datenvolumen, das vom Speicher gelesen oder geschrieben werden kann. Lassen Sie uns ein kleines Beispiel betrachten, um zu verstehen, wie das geschieht.
Angenommen, wir haben eine Tabelle, in die wir mehrere DatensĂ€tze hinzugefĂŒgt haben. Auf der ersten Seite der Datei, in der die Tabelle gespeichert ist, sind neue Daten aufgetaucht. Dies sind die Live-Versionen der Zeilen, die anderen Transaktionen nach dem Commit verfĂŒgbar sind (vereinfachend nehmen wir an, dass das Isolationsniveau Read Committed ist).

AnschlieĂend haben wir einen der DatensĂ€tze aktualisiert und damit die alte Version als nicht mehr aktuell markiert.

Schritt fĂŒr Schritt, wĂ€hrend wir Zeilenversionen aktualisieren und löschen, erhalten wir eine Seite, auf der etwa die HĂ€lfte der Daten âMĂŒllâ sind. Diese Daten sind fĂŒr keine Transaktion sichtbar.

In Postgres gibt es einen Mechanismus , der nicht mehr aktuelle Versionen bereinigt und Platz fĂŒr neue Daten schafft. Wenn er jedoch nicht aggressiv genug konfiguriert ist oder mit der Arbeit in anderen Tabellen beschĂ€ftigt ist, bleiben die âMĂŒlldatenâ bestehen, und wir mĂŒssen zusĂ€tzliche Seiten fĂŒr neue Daten verwenden.
So wird unsere Tabelle in unserem Beispiel zu einem bestimmten Zeitpunkt aus vier Seiten bestehen, aber nur die HÀlfte davon sind aktive Daten. In der Folge werden wir beim Zugriff auf die Tabelle viel mehr Daten lesen, als tatsÀchlich erforderlich sind.

Selbst wenn VACUUM jetzt alle veralteten Zeilenversionen entfernt, wird sich die Situation nicht grundlegend verbessern. Wir erhalten zwar freien Platz auf den Seiten oder sogar ganze Seiten fĂŒr neue Zeilen, aber wir mĂŒssen weiterhin mehr Daten ablesen, als nötig ist.
Ăbrigens, wenn eine vollstĂ€ndig leere Seite (die zweite in unserem Beispiel) am Ende der Datei wĂ€re, könnte VACUUM sie kĂŒrzen. Aber da sie sich derzeit in der Mitte befindet, kann nichts damit gemacht werden.

Wenn die Anzahl solcher leeren oder stark fragmentierten Seiten zu groĂ wird, was als Bloat bezeichnet wird, wirkt sich das negativ auf die Leistung aus.
Die oben beschriebene Mechanik fĂŒhrt zu Bloat in Tabellen. In Indizes passiert dies Ă€hnlich.
Habe ich Bloat?
Es gibt mehrere Möglichkeiten, um festzustellen, ob Sie Bloat haben. Die Idee des ersten Ansatzes ist die Nutzung der internen Statistik von Postgres, die ungefĂ€hre Informationen ĂŒber die Anzahl der Zeilen in den Tabellen, die Anzahl der "lebenden" Zeilen usw. enthĂ€lt. Im Internet finden Sie zahlreiche Variationen bereits fertiger Skripte. Wir haben als Grundlage genommen von PostgreSQL-Experten, der die Bloat von Tabellen zusammen mit Toast und Bloat von Btree-Indizes bewertet. Nach unserer Erfahrung liegt seine Ungenauigkeit bei 10-20%.
Eine andere Möglichkeit ist die Verwendung der Erweiterung , die Einblick in Seiten ermöglicht und sowohl einen geschÀtzten als auch einen genauen Bloat-Wert liefert. Im zweiten Fall muss die gesamte Tabelle jedoch gescannt werden.
Ein geringer Bloat-Wert von bis zu 20% wird als akzeptabel angesehen. Er kann als analog zum Fillfactor fĂŒr und . Bei 50% und mehr kann es zu Leistungsproblemen kommen.
Methoden zur BekÀmpfung von Bloat
In Postgres gibt es mehrere Möglichkeiten, Bloat âout of the boxâ zu bekĂ€mpfen, jedoch sind diese nicht immer fĂŒr jeden geeignet.
AUTOVACUUM zu konfigurieren, damit kein Bloat entsteht. Und um genauer zu sein, um ein fĂŒr Sie akzeptables Niveau zu halten. Es mag wie ein âKapitĂ€neratâ klingen, doch in der RealitĂ€t ist dies nicht immer leicht umzusetzen. Beispielsweise kann es vorkommen, dass Sie aktiv an Entwicklungen arbeiten, bei denen sich das Datenschema regelmĂ€Ăig Ă€ndert, oder dass eine Datenmigration stattfindet. In der Folge kann sich Ihr Lastprofil hĂ€ufig Ă€ndern und variiert in der Regel fĂŒr verschiedene Tabellen. Das bedeutet, dass Sie stĂ€ndig etwas vorausplanen und AUTOVACUUM an das sich Ă€ndernde Profil jeder Tabelle anpassen mĂŒssen. Offensichtlich ist das jedoch keine einfache Aufgabe.
Ein weiteres hĂ€ufiges Problem, warum AUTOVACUUM nicht in der Lage ist, Tabellen rechtzeitig zu verarbeiten, sind lang laufende Transaktionen, die ihm das Bereinigen der Daten verwehren, da diese Transaktionen darauf zugreifen. Die Empfehlung hier ist ebenfalls klar â befreien Sie sich von "hĂ€ngenden" Transaktionen und minimieren Sie die Dauer aktiver Transaktionen. Wenn Ihre Anwendung jedoch sowohl OLAP- als auch OLTP-Lasten verarbeitet, kann es gleichzeitig sowohl viele hĂ€ufige Updates und kurze Abfragen als auch langwierige Operationen geben â beispielsweise die Erstellung eines bestimmten Berichts. In solch einem Fall sollten Sie in Betracht ziehen, die Last auf verschiedene Datenbanken zu verteilen, um eine feinere Anpassung jeder einzelnen zu ermöglichen.
Ein weiteres Beispiel â selbst wenn das Profil homogen ist, aber die Datenbank unter extrem hoher Last steht, kann selbst das agilste AUTOVACUUM nicht mithalten, und es kann zu Bloat kommen. Skalierung (vertikal oder horizontal) ist die einzige Lösung.
Was tun, wenn Sie AUTOVACUUM konfiguriert haben, aber der Bloat weiterhin wÀchst?
Der Befehl VACUUM FULL rekonstruiert den Inhalt von Tabellen und Indizes und belĂ€sst nur die aktuellen Daten darin. Sie arbeitet perfekt zur Beseitigung von Bloat, jedoch wird wĂ€hrend ihrer AusfĂŒhrung eine exklusive Sperre auf die Tabelle (AccessExclusiveLock) gesetzt, die die AusfĂŒhrung von Abfragen auf dieser Tabelle, selbst SELECT-Abfragen, verhindert. Wenn Sie es sich leisten können, Ihren Dienst oder Teile davon fĂŒr eine Weile (von mehreren Minuten bis hin zu mehreren Stunden, abhĂ€ngig von der GröĂe der Datenbank und Ihrer Hardware) auszusetzen, ist diese Option die beste. Leider schaffen wir es nicht, VACUUM FULL innerhalb der geplanten Wartung durchzufĂŒhren, weshalb diese Methode fĂŒr uns nicht geeignet ist.
Der Befehl CLUSTER rekonstruiert ebenfalls den Inhalt von Tabellen wie VACUUM FULL, ermöglicht es jedoch, einen Index anzugeben, nach dem die Daten physisch auf der Festplatte geordnet werden (zukunftsweisend wird jedoch keine Ordnung fĂŒr neue Zeilen garantiert). In bestimmten Situationen ist dies eine gute Optimierung fĂŒr einige Abfragen â insbesondere fĂŒr das Lesen mehrerer DatensĂ€tze ĂŒber den Index. Der Nachteil des Befehls ist derselbe wie bei VACUUM FULL â wĂ€hrend der AusfĂŒhrung wird die Tabelle gesperrt.
Der Befehl REINDEX Ă€hnelt den beiden vorherigen, fĂŒhrt jedoch eine Neuordnung eines bestimmten Index oder aller Indizes einer Tabelle durch. Die Sperren sind etwas schwĂ€cher: ShareLock auf die Tabelle (verhindert Ănderungen, erlaubt jedoch SELECT) und AccessExclusiveLock auf den neuordnenden Index (blockiert Abfragen, die diesen Index verwenden). In der 12. Version von Postgres wurde jedoch ein Parameter eingefĂŒhrt, , der es ermöglicht, den Index neu zu ordnen, ohne paralleles HinzufĂŒgen, Ăndern oder Löschen von DatensĂ€tzen zu blockieren.
In frĂŒheren Versionen von Postgres kann ein Ă€hnliches Ergebnis wie REINDEX CONCURRENTLY mit erzielt werden. Es ermöglicht die Erstellung eines Index ohne strenge Sperrung (ShareUpdateExclusiveLock, das parallele Abfragen nicht behindert), dann den alten Index durch den neuen zu ersetzen und den alten Index zu löschen. Dies ermöglicht die Beseitigung von Index-Bloat, ohne den Betrieb Ihrer Anwendung zu stören. Es ist wichtig zu beachten, dass bei der Neuordnung von Indizes zusĂ€tzliche Belastungen auf das Datensystem anfallen.
Somit gibt es, wenn es Möglichkeiten zur Beseitigung von Bloat "im laufenden Betrieb" fĂŒr Indizes gibt, fĂŒr Tabellen keine solchen Methoden. Hier kommen verschiedene externe Erweiterungen ins Spiel: (frĂŒher pg_reorg), , und andere. In diesem Artikel werde ich diese nicht vergleichen und nur ĂŒber pg_repack sprechen, das wir nach einigen Anpassungen bei uns verwenden.
Wie pg_repack funktioniert

Angenommen, wir haben eine ganz gewöhnliche Tabelle â mit Indizes, EinschrĂ€nkungen und leider mit Bloat. Im ersten Schritt erstellt pg_repack eine Log-Tabelle, um Informationen ĂŒber alle Ănderungen wĂ€hrend der AusfĂŒhrung zu speichern. Ein Trigger wird diese Ănderungen bei jedem Insert, Update und Delete replizieren. Danach wird eine Tabelle erstellt, die in ihrer Struktur der ursprĂŒnglichen Tabelle Ă€hnelt, jedoch ohne Indizes und EinschrĂ€nkungen, um den Prozess der DateneinfĂŒgung nicht zu verlangsamen.
AnschlieĂend ĂŒbertrĂ€gt pg_repack die Daten aus der alten Tabelle in die neue und filtert automatisch alle irrelevanten Zeilen heraus, bevor Indizes fĂŒr die neue Tabelle erstellt werden. WĂ€hrend der AusfĂŒhrung all dieser Operationen werden in der Log-Tabelle Ănderungen gesammelt.
Der nĂ€chste Schritt besteht darin, die Ănderungen in die neue Tabelle zu ĂŒbertragen. Der Transfer erfolgt in mehreren Iterationen, und wenn im Log-Table weniger als 20 EintrĂ€ge verbleiben, erfasst pg_repack eine feste Sperre, ĂŒbertrĂ€gt die letzten Daten und ersetzt die alte Tabelle in den Systemtabellen von Postgres durch die neue. Dies ist der einzige, sehr kurze Moment, in dem Sie nicht mit der Tabelle arbeiten können. Danach werden die alte Tabelle und die Log-Tabelle gelöscht, und es wird Platz im Dateisystem freigegeben. Der Prozess ist abgeschlossen.
In der Theorie sieht alles gut aus, aber wie sieht es in der Praxis aus? Wir haben pg_repack sowohl unter Last als auch ohne Last getestet und geprĂŒft, wie es sich bei einem vorzeitigen Abbruch verhĂ€lt (einfach gesagt, bei Ctrl+C). Alle Tests waren positiv.
Wir sind in die Produktion gegangen â und plötzlich lief alles anders als erwartet.
Der erste Versuch in der Produktion
Bereits im ersten Cluster erhielten wir einen Fehler wegen eines VerstoĂes gegen die eindeutige EinschrĂ€nkung:
$ ./pg_repack -t tablename -o id
INFO: Tabelle "tablename" wird neu organisiert
ERROR: Abfrage fehlgeschlagen:
ERROR: Doppelter SchlĂŒsselwert verletzt eindeutige EinschrĂ€nkung "index_16508"
DETAIL: SchlĂŒssel (id, index)=(100500, 42) existiert bereits.
Diese EinschrĂ€nkung hatte den automatisch generierten Namen index_16508 â erstellt von pg_repack. Anhand der Attribute, die sie umfasst, haben wir unser entsprechendes Limit identifiziert. Das Problem war, dass dies keine ganz gewöhnliche EinschrĂ€nkung ist, sondern eine aufgeschobene (), d.h. ihre ĂberprĂŒfung erfolgt spĂ€ter als der SQL-Befehl, was zu unerwarteten Konsequenzen fĂŒhrt.
Aufgeschobene EinschrÀnkungen: Warum sie wichtig sind und wie sie funktionieren
Ein wenig Theorie zu aufgeschobenen EinschrÀnkungen.
Betrachten wir ein einfaches Beispiel: Wir haben eine Referenztabelle fĂŒr Autos mit zwei Attributen â dem Namen und der Reihenfolge des Autos in der Tabelle.

create table cars
(
name text constraint pk_cars primary key,
ord integer not null constraint uk_cars unique
);
Angenommen, wir mĂŒssen das erste und das zweite Auto vertauschen. Die naive Lösung wĂ€re, den ersten Wert durch den zweiten zu ersetzen und umgekehrt:
begin;
update cars set ord = 2 where name = 'audi';
update cars set ord = 1 where name = 'bmw';
commit;
Aber wenn wir diesen Code ausfĂŒhren, werden wir wie erwartet eine Verletzung der EinschrĂ€nkung erhalten, weil die Reihenfolge der Werte in der Tabelle einzigartig ist:
[23305] FEHLER: doppelter SchlĂŒsselwert verletzt die einzigartige EinschrĂ€nkung âuk_carsâ
Detail: SchlĂŒssel (ord)=(2) existiert bereits.
Wie kann man es anders machen? Erste Möglichkeit: FĂŒgen Sie eine zusĂ€tzliche Reihenfolgeersetzung hinzu, die garantiert nicht in der Tabelle vorhanden ist, zum Beispiel â-1â. In der Programmierung nennt man das âWertetausch zweier Variablen ĂŒber eine dritteâ. Der einzige Nachteil dieser Methode ist das zusĂ€tzliche Update.
Zweite Möglichkeit: Entwerfen Sie die Tabelle neu, um fĂŒr die Reihenfolge einen Datentyp mit Gleitkomma anstelle von Ganzzahlen zu verwenden. Wenn Sie dann beispielsweise den Wert von 1 auf 2,5 aktualisieren, wird der erste Datensatz automatisch zwischen den zweiten und dritten eingeordnet. Diese Lösung funktioniert, hat jedoch zwei EinschrĂ€nkungen. Erstens ist sie ungeeignet, wenn der Wert irgendwo in der BenutzeroberflĂ€che verwendet wird. Zweitens haben Sie je nach Genauigkeit des Datentyps nur eine begrenzte Anzahl möglicher EinfĂŒgungen, bis alle Werte neu berechnet werden.
Dritte Möglichkeit: Machen Sie die EinschrĂ€nkung verzögert, damit sie nur zum Zeitpunkt des Commits ĂŒberprĂŒft wird:
create table cars
(
name text constraint pk_cars primary key,
ord integer not null constraint uk_cars unique deferrable initially deferred
);Da die Logik unserer ursprĂŒnglichen Anfrage garantiert, dass bis zum Commit alle Werte eindeutig sind, wird sie erfolgreich ausgefĂŒhrt.
Das oben genannte Beispiel ist zwar sehr synthetisch, verdeutlicht aber das Konzept. In unserer Anwendung verwenden wir verzögerte EinschrÀnkungen, um die Logik zu implementieren, die Konflikte löst, wenn mehrere Benutzer mit gemeinsamen Objekten-Widgets auf dem Board arbeiten. Der Einsatz solcher EinschrÀnkungen ermöglicht es uns, den Anwendungscode etwas einfacher zu gestalten.
Insgesamt gibt es je nach Art der EinschrĂ€nkung in Postgres drei PrĂŒfungsstufen: Zeilenebene, Transaktionsebene und Ausdrucksebene.

Quelle:
CHECK und NOT NULL werden immer auf Zeilenebene ĂŒberprĂŒft, wĂ€hrend fĂŒr die anderen EinschrĂ€nkungen, wie aus der Tabelle ersichtlich, verschiedene Optionen existieren. Weitere Informationen finden Sie in der Dokumentation. .
Zusammenfassend lĂ€sst sich sagen, dass verzögerte EinschrĂ€nkungen in bestimmten Situationen einen besser lesbaren Code und weniger Befehle bieten. Allerdings muss man dafĂŒr mit einem komplizierteren Debugging-Prozess rechnen, da der Zeitpunkt des Fehlerschutzes und der Moment, in dem man davon erfĂ€hrt, zeitlich auseinanderfallen. Ein weiteres potentielles Problem besteht darin, dass der Planer nicht immer einen optimalen Plan erstellen kann, wenn eine verzögerte EinschrĂ€nkung in der Anfrage beteiligt ist.
Verbesserung von pg_repack
Wir haben verstanden, was verzögerte EinschrÀnkungen sind, aber wie hÀngen sie mit unserem Problem zusammen? Erinnern wir uns an den Fehler, den wir zuvor erhalten haben:
$ ./pg_repack -t tablename -o id
INFO: Tabelle "tablename" wird neu organisiert
ERROR: Abfrage fehlgeschlagen:
ERROR: Doppelter SchlĂŒsselwert verletzt eindeutige EinschrĂ€nkung "index_16508"
DETAIL: SchlĂŒssel (id, index)=(100500, 42) existiert bereits.Er tritt beim Kopieren von Daten aus der Log-Tabelle in eine neue Tabelle auf. Das wirkt seltsam, da die Daten in der Log-Tabelle zusammen mit den Daten der ursprĂŒnglichen Tabelle festgeschrieben werden. Wenn sie die EinschrĂ€nkungen der ursprĂŒnglichen Tabelle erfĂŒllen, wie können sie dann dieselben EinschrĂ€nkungen in der neuen Tabelle verletzen?
Wie sich herausstellte, liegt die Wurzel des Problems im vorherigen Schritt der Arbeit von pg_repack, in dem nur Indizes, jedoch keine EinschrÀnkungen erstellt werden: In der alten Tabelle gab es eine eindeutige EinschrÀnkung, und in der neuen wurde stattdessen ein eindeutiger Index erstellt.

Es ist wichtig zu beachten, dass, wenn die EinschrĂ€nkung normal und nicht verschoben ist, der erstellte eindeutige Index dieser EinschrĂ€nkung entspricht, da eindeutige EinschrĂ€nkungen in Postgres durch die Erstellung eines eindeutigen Indexes umgesetzt werden. Im Fall einer verschobenen EinschrĂ€nkung jedoch verhĂ€lt es sich anders, da der Index nicht verschoben sein kann und immer zum Zeitpunkt der AusfĂŒhrung des SQL-Befehls ĂŒberprĂŒft wird.
Das Hauptproblem liegt also in der âVerschobeneâ ĂberprĂŒfung: In der ursprĂŒnglichen Tabelle findet sie zum Zeitpunkt des Commits statt, in der neuen hingegen bei der AusfĂŒhrung des SQL-Befehls. Daher mĂŒssen wir sicherstellen, dass die ĂberprĂŒfungen in beiden FĂ€llen gleich ablaufen: entweder immer verschoben oder immer sofort.
Also, welche Ideen hatten wir?
Einen Index erstellen, der dem verschobenen entspricht.
Die erste Idee ist, beide ĂberprĂŒfungen im sofortigen Modus durchzufĂŒhren. Dies könnte einige False Positives bei den EinschrĂ€nkungen verursacht, aber wenn es nur wenige sind, sollte das die Benutzer nicht beeintrĂ€chtigen, da solche Konflikte fĂŒr sie normal sind. Sie treten beispielsweise auf, wenn zwei Benutzer gleichzeitig dasselbe Widget zu bearbeiten versuchen und der Client des zweiten Benutzers nicht rechtzeitig die Information erhĂ€lt, dass das Widget bereits vom ersten Benutzer fĂŒr die Bearbeitung gesperrt ist. In dieser Situation antwortet der Server dem zweiten Benutzer mit einer Ablehnung, und sein Client macht die Ănderungen rĂŒckgĂ€ngig und sperrt das Widget. Ein wenig spĂ€ter, wenn der erste Benutzer die Bearbeitung abgeschlossen hat, erhĂ€lt der zweite die Information, dass das Widget nicht mehr gesperrt ist und dass er seine Aktion wiederholen kann.

Um sicherzustellen, dass die ĂberprĂŒfungen immer im sofortigen Modus erfolgen, haben wir einen neuen Index erstellt, der dem ursprĂŒnglichen verzögerten Limit entspricht:
CREATE UNIQUE INDEX CONCURRENTLY uk_tablename__immediate ON tablename (id, index);
-- run pg_repack
DROP INDEX CONCURRENTLY uk_tablename__immediate;In der Testumgebung haben wir nur einige erwartete Fehler festgestellt. Erfolg! Wir haben pg_repack erneut in der Produktion gestartet und innerhalb der ersten Stunde fĂŒnf Fehler im ersten Cluster festgestellt. Das ist ein akzeptables Ergebnis. Allerdings hat sich die Anzahl der Fehler im zweiten Cluster erheblich erhöht, weshalb wir pg_repack stoppen mussten.
Warum ist das passiert? Die Fehlerwahrscheinlichkeit hĂ€ngt davon ab, wie viele Benutzer gleichzeitig mit denselben Widgets arbeiten. Anscheinend gab es zu diesem Zeitpunkt bei den Daten im ersten Cluster viel weniger konkurrierende Ănderungen als in den anderen, d.h. wir hatten einfach âGlĂŒckâ.
Die Idee hat nicht funktioniert. Zu diesem Zeitpunkt waren uns zwei andere LösungsansĂ€tze bekannt: unseren Anwendungscode so umzuschreiben, dass wir auf verzögerte EinschrĂ€nkungen verzichten oder pg_repack âbeizubringenâ, mit ihnen umzugehen. Wir haben uns fĂŒr Letzteres entschieden.
Die Indizes in der neuen Tabelle durch verzögerte EinschrĂ€nkungen aus der ursprĂŒnglichen Tabelle ersetzen.
Das Ziel der Anpassung war offensichtlich â wenn die ursprĂŒngliche Tabelle eine verzögerte EinschrĂ€nkung hat, muss fĂŒr die neue eine solche EinschrĂ€nkung und nicht ein Index erstellt werden.
Um unsere Ănderungen zu ĂŒberprĂŒfen, haben wir einen einfachen Test geschrieben:
- Tabelle mit einer verzögerten EinschrÀnkung und einem Eintrag;
- Wir fĂŒgen in einer Schleife Daten ein, die mit dem vorhandenen Datensatz in Konflikt stehen;
- Wir fĂŒhren ein Update durch â die Daten stehen nicht mehr in Konflikt;
- Wir committen die Ănderungen.
CREATE TABLE test_table
(
id SERIAL,
val INT,
CONSTRAINT uk_test_table__val UNIQUE (val) DEFERRABLE INITIALLY DEFERRED
);
INSERT INTO test_table (val) VALUES (0);
FOR i IN 1..10000 LOOP
BEGIN
INSERT INTO test_table VALUES (0) RETURNING id INTO v_id;
UPDATE test_table SET val = i WHERE id = v_id;
COMMIT;
END;
END LOOP;Die ursprĂŒngliche Version von pg_repack schlug beim ersten Insert fehl, die ĂŒberarbeitete Version funktionierte fehlerfrei. Ausgezeichnet.
Wir gehen in die Produktion und erhalten erneut einen Fehler in derselben Phase, beim Kopieren der Daten aus der Log-Tabelle in die Neue:
$ ./pg_repack -t tablename -o id
INFO: Tabelle "tablename" wird neu organisiert
ERROR: Abfrage fehlgeschlagen:
ERROR: Doppelter SchlĂŒsselwert verletzt eindeutige EinschrĂ€nkung "index_16508"
DETAIL: SchlĂŒssel (id, index)=(100500, 42) existiert bereits.Klassische Situation: In den Testumgebungen funktioniert alles, in der Produktion nicht?!
APPLY_COUNT und der Ăbergang zwischen zwei Batches
Wir haben den Code buchstĂ€blich Zeile fĂŒr Zeile analysiert und einen wichtigen Punkt entdeckt: Der DatenĂŒbertrag von der Log-Tabelle in die Neue erfolgt in Batches. Die Konstante APPLY_COUNT gab die GröĂe des Batches an:
for (;;)
{
num = apply_log(connection, table, APPLY_COUNT);
if (num > MIN_TUPLES_BEFORE_SWITCH)
continue; /* Es könnten noch einige Tupel vorhanden sein, wiederholen. */
...
}Das Problem besteht darin, dass die Daten der ursprĂŒnglichen Transaktion, in der mehrere Operationen potenziell die Begrenzung verletzen können, beim Transfer an die Schnittstelle zweier Batch-Prozesse gelangen können â die eine HĂ€lfte der Befehle wird im ersten Batch festgeschrieben, die andere im zweiten. Hier spielt das GlĂŒck eine Rolle: Wenn die Befehle im ersten Batch nichts verletzen, ist alles gut. Wenn sie jedoch eineverletzen, tritt ein Fehler auf.
APPLY_COUNT betrĂ€gt 1000 DatensĂ€tze, was erklĂ€rt, warum unsere Tests erfolgreich waren â sie deckten nicht den Fall des âBatch-Schnittsâ ab. Wir verwendeten zwei Befehle â insert und update, wobei genau 500 Transaktionen mit zwei Befehlen immer in den Batch passten und wir keine Probleme hatten. Nach dem HinzufĂŒgen des zweiten Updates funktionierte unsere Anpassung nicht mehr:
FOR i IN 1..10000 LOOP
BEGIN
INSERT INTO test_table VALUES (1) RETURNING id INTO v_id;
UPDATE test_table set val = i where id = v_id;
UPDATE test_table set val = i where id = v_id; -- ein weiteres Update
COMMIT;
END;
END LOOP;Die nĂ€chste Aufgabe besteht darin, sicherzustellen, dass die Daten aus der ursprĂŒnglichen Tabelle, die in einer Transaktion geĂ€ndert wurden, auch innerhalb einer Transaktion in die neue Tabelle gelangen.
Abandonment of Batching
Und wieder standen uns zwei LösungsansĂ€tze zur VerfĂŒgung. Die erste: Lassen Sie uns die Aufteilung in Batches ganz vermeiden und die Daten in einer einzigen Transaktion ĂŒbertragen. Der Vorteil dieser Lösung liegt in ihrer Einfachheit â die erforderlichen CodeĂ€nderungen sind minimal (ĂŒbrigens funktionierte in Ă€lteren Versionen pg_reorg genau so). Aber es gibt ein Problem â wir schaffen eine langfristige Transaktion, und das stellt, wie bereits erwĂ€hnt, eine Gefahr fĂŒr die Entstehung eines neuen Bloat dar.
Die zweite Lösung ist zwar komplizierter, aber vermutlich die sinnvollere: EinfĂŒgen einer Spalte in die Protokolltabelle, die die ID der Transaktion enthĂ€lt, die die Daten in die Tabelle eingefĂŒgt hat. Dadurch können wir beim Kopieren der Daten diese nach diesem Attribut gruppieren und sicherstellen, dass miteinander verbundene Ănderungen gemeinsam ĂŒbertragen werden. Das Batch wird aus mehreren Transaktionen (oder einer groĂen) bestehen, und seine GröĂe wird variieren, abhĂ€ngig davon, wie viele Daten in diesen Transaktionen geĂ€ndert wurden. Wichtig ist, dass die Daten verschiedener Transaktionen in zufĂ€lliger Reihenfolge in die Protokolltabelle gelangen, sodass wir sie nicht mehr sequenziell lesen können wie zuvor. Ein sequenzieller Scan bei jeder Abfrage mit Filterung nach tx_id ist zu kostspielig; ein Index wĂ€re notwendig, wĂŒrde jedoch die Methode aufgrund der damit verbundenen Aktualisierungskosten verlangsamen. Insgesamt muss wie immer irgendwo Opfer gebracht werden.
Wir haben uns also entschieden, mit der ersten Variante zu beginnen, da sie einfacher ist. ZunĂ€chst mussten wir herausfinden, ob eine lĂ€ngere Transaktion ein wirkliches Problem darstellen wĂŒrde. Da der Hauptdatenumzug von der alten Tabelle in die neue ebenfalls in einer langen Transaktion erfolgt, hat sich die Frage zu "wie sehr werden wir diese Transaktion verlĂ€ngern?" gewandelt. Die Dauer der ersten Transaktion hĂ€ngt hauptsĂ€chlich von der GröĂe der Tabelle ab. Die Dauer der neuen wiederum davon, wie viele Ănderungen in der Tabelle wĂ€hrend des Datenumzugs anfallen, also von der IntensitĂ€t der Last. Der pg_repack-Lauf fand bei minimaler Auslastung des Dienstes statt, und das Volumen der Ănderungen war im Vergleich zur ursprĂŒnglichen TabellengröĂe vernachlĂ€ssigbar gering. Wir sind zu dem Schluss gekommen, dass wir die Zeit der neuen Transaktion (im Durchschnitt etwa 1 Stunde und 2-3 Minuten) vernachlĂ€ssigen können.
Die Ergebnisse der Experimente waren positiv. Der Start in der Produktion verlief ebenfalls erfolgreich. Zur Veranschaulichung â hier ist ein Bild der GröĂe einer der Datenbanken nach dem Repack:

Da wir mit dieser Lösung vollstĂ€ndig zufrieden sind, haben wir nicht versucht, eine zweite zu realisieren, aber wir ziehen in Betracht, sie mit den Entwicklern des Plugins zu besprechen. Unsere aktuelle Ăberarbeitung ist leider noch nicht bereit zur Veröffentlichung, da wir nur das Problem mit den einzigartigen verzögerten EinschrĂ€nkungen gelöst haben und fĂŒr einen vollstĂ€ndigen Patch auch die UnterstĂŒtzung anderer Typen erforderlich ist. Wir hoffen, dass wir das in Zukunft umsetzen können.
Vielleicht fragen Sie sich, warum wir uns ĂŒberhaupt auf diese Geschichte mit der Ăberarbeitung von pg_repack eingelassen haben, anstatt beispielsweise seine Alternativen zu nutzen? Irgendwann haben wir auch darĂŒber nachgedacht, aber die positiven Erfahrungen mit der Nutzung in der Vergangenheit, bei Tabellen ohne verzögerte EinschrĂ€nkungen, motivierten uns, die Grundlagen des Problems zu verstehen und es zu beheben. AuĂerdem benötigt die Verwendung anderer Lösungen ebenfalls Zeit fĂŒr Tests, weshalb wir beschlossen haben, zunĂ€chst zu versuchen, das Problem hierin zu beheben, und falls wir feststellen, dass wir dies in einem angemessenen Zeitraum nicht leisten können, werden wir Alternativen in Betracht ziehen.
Fazit
Was wir aus eigener Erfahrung empfehlen können:
- Ăberwachen Sie Ihr Bloat. Anhand von Monitoring-Daten können Sie verstehen, wie gut Autovacuum konfiguriert ist.
- Konfigurieren Sie AUTOVACUUM, um Bloat auf einem akzeptablen Niveau zu halten.
- Wenn Bloat dennoch wĂ€chst und Sie ihn nicht mit den Standardwerkzeugen bekĂ€mpfen können, scheuen Sie sich nicht, externe Erweiterungen zu verwenden. Testen Sie alles grĂŒndlich.
- Scheuen Sie sich nicht, externe Lösungen an Ihre BedĂŒrfnisse anzupassen â manchmal kann dies effektiver und sogar einfacher sein als Ănderungen an Ihrem eigenen Code.
Quelle: habr.com
