Die Zahl, auf die man in dieser Fintech-Geschichte starren sollte, sind nicht die achtzehn Stunden. Es sind die acht. Nach dem Wochenende, an dem ein einzelnes UPDATE auf einer transactions-Tabelle mit 1,2 Milliarden Zeilen die Platte des Primary auf 95 % gefüllt, die Replica vier Stunden zurückfallen lassen und danach vier weitere Stunden mit einem Rollback verbracht hat, der exakt null Zeilen geändert hat, kam dasselbe Team zurück und erledigte dieselbe Arbeit in rund acht Stunden: Batch-Größe 1000, 20 ms Sleep zwischen den Batches, die zuletzt verarbeitete id in Redis geparkt und ein Throttle, der auf Replication Lag reagiert. Niemand wurde angepiept. Meine These: Die Schleife, die das möglich gemacht hat, ist das Uninteressanteste an der ganzen Sache.
CREATE TABLE backfill_state (
name TEXT PRIMARY KEY,
last_id BIGINT NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
INSERT INTO backfill_state (name, last_id)
VALUES ('transactions_usd', 12345)
ON CONFLICT (name) DO UPDATE
SET last_id = EXCLUDED.last_id, updated_at = NOW();Schau dir die gemessenen Zahlen an, dann siehst du, warum Geschwindigkeit nie das Argument war. Bei 100K Zeilen war das einzelne UPDATE in 122 ms durch. Dieselbe Arbeit, aufgeteilt in 100 Batches à 1000, brauchte 688 ms Wall Time, davon nur 176 ms echte Query-Zeit, im Schnitt 1,77 ms pro Batch. Die gechunkte Variante ist ungefähr fünfmal langsamer, und genau darum geht es. Du kaufst keinen Durchsatz, du kaufst Transaktionen, die Locks zwei Millisekunden statt zwanzig Minuten halten, damit das WAL abgeschnitten werden kann und die Replica Luft bekommt. Rechne die schnelle Variante linear auf eine Milliarde Zeilen hoch, und du landest bei etwa 1200 Sekunden einer einzigen nicht unterbrechbaren Transaktion, mit der reizenden Eigenschaft, dass der Abbruch teurer ist als das Durchlaufenlassen.
Keyset-Pagination wird am häufigsten zitiert, und die Erwähnung hat sie verdient. Selbst im Spielzeugmaßstab von 100K Zeilen unter PHP 8.3.6 gegen SQLite kostete OFFSET 0 nur 0,6 ms, OFFSET 80000 dagegen 2,0 ms, weil die Engine alles Übersprungene erst durchläuft und dann wegwirft. Die Keyset-Reads lagen zwischen 0,2 ms und 0,5 ms, egal wo in der Tabelle sie gelandet sind. Bei 900 Millionen Zeilen Tiefe ist dieser Abstand kein Faktor drei mehr, sondern Minuten pro Batch. Geschenkt. Aber WHERE id > :last_id ORDER BY id LIMIT :batch_size ist eine Zeile, und die Falle drumherum ist schmal: Dein Schlüssel muss indiziert und monoton sein, und wenn er nicht eindeutig ist, brauchst du so etwas wie (created_at, id), damit die Sortierung stabil bleibt. Wenn OFFSET das schwerste Problem in deinem Backfill ist, hast du die schweren längst gelöst.
Der ehrliche Einwand zuerst, denn er ist ein guter. Die meisten Tabellen haben keine Milliarde Zeilen. Ein Framework mit Checkpoints, Throttling und adaptivem Tempo zu schreiben, um 40.000 Zeilen anzufassen, ist Theater: Das schlichte UPDATE ist committet, bevor dein Deploy-Hook seine Ausgabe fertig gedruckt hat. Und für strukturelle Änderungen chunken, throttlen und tauschen gh-ost, pt-online-schema-change, pg_repack und pg_squeeze seit Jahren Tabellen, und zwar besser als das Skript, das du an einem Dienstag schreibst. Beides stimmt. Anders sehe ich trotzdem die Form des Risikos: Sich zugunsten von Chunking zu verschätzen kostet dich einen Nachmittag, sich in die andere Richtung zu verschätzen kostet dich einen Rollback, den du nicht abbrechen kannst, an einem Samstag, auf dem Primary.
Damit komme ich zu dem Teil, der nichts mit SQL zu tun hat. Ein sicherer Backfill ist eine Abfolge von Releases. Zuerst bringst du Anwendungscode aus, der sowohl total_cents als auch total_usd_cents schreibt, damit jede neue Zeile schon korrekt ankommt. Dann, und wirklich erst dann, startest du den Job, der die historischen Zeilen füllt, bei denen total_usd_cents IS NULL gilt. Später, in einem eigenen Release, stellst du die Lesezugriffe um. Genau dieses Prädikat macht Wiederholungen auch kostenlos: Eine bereits verarbeitete Zeile passt einfach nicht mehr. Drei Deploys und ein Job, der acht Stunden dazwischen läuft, können nicht in einer Migrationsdatei wohnen, auf die deine Deploy-Pipeline wartet. Das gehört in einen Worker oder in einen Queued Job mit Retries unter Symfony Messenger oder Laravel Queue, mit Namen, Runbook und jemandem, der weiß, wie man ihn stoppt.
Zwei Punkte würde ich härter betonen, als es das Playbook tut. Erstens: Das Fertig-Signal, also SELECT COUNT(*) FROM transactions WHERE total_usd_cents IS NULL bei null, ist eine Momentaufnahme, und es kommen ständig neue Zeilen dazu. Prüf das zweimal, bevor du den Sieg ausrufst und anfängst, Spalten zu löschen. Zweitens, und das ist der Punkt, der mir wirklich Angst macht: Chunking schützt die Datenbank und tut für die Korrektheit exakt nichts. Wenn compute_usd falsch rundet oder den Kurs des falschen Tages zieht, besitzt du jetzt 1,2 Milliarden Zeilen selbstbewusst falsches Geld, und wenn du die Originale nicht irgendwo aufgehoben hast, hast du nichts, wogegen du diffen kannst. Teste die Umrechnung an einer Stichprobe, bis es langweilig wird, behalte die Ausgangswerte, und leg ein Pause-Flag in Redis, damit eine Operatorin den Job während eines unabhängigen Incidents einfrieren kann, ohne den Prozess zu killen. Unsere Go-schreibenden Nachbarn zeigen dir dafür einen Worker Pool. Ein PHP-CLI-Skript mit usleep und einem Flag ist völlig in Ordnung, denn der Flaschenhals war nie die Sprache, es war die Platte unter dem WAL.
Wo liegt also deine Grenze? Meine liegt irgendwo bei ein paar Millionen Zeilen, darüber winke ich kein nacktes UPDATE in einer Migration durch. Aufgeschrieben habe ich diese Zahl allerdings nie, und ich vermute, du auch nicht. Setzt du das im Review durch, oder habt ihr eine Basisklasse, von der alle erben? Und die Anschlussfrage, bei der ich ehrlich unentschieden bin: Teilst du bei einer Tabelle dieser Größe den id-Raum tatsächlich auf vier parallele Worker auf, oder ist ein einzelner Worker mit adaptivem Sleep die Variante, bei der du selbst noch schlafen kannst?
Kommentare
Noch keine Kommentare — schreib den ersten.
Starte die Diskussion
Kein Konto, kein Passwort nötig — gib einfach deine E-Mail-Adresse ein, wir senden dir einen einmaligen Anmelde-Link. Beim ersten Mal bist du damit automatisch angemeldet.
Deine Bewertung wird nach der Anmeldung automatisch übernommen.
Schau in dein Postfach
Wir haben einen Anmelde-Link an … gesendet. Öffne ihn auf diesem Gerät — dieser Tab meldet dich automatisch an.
Nichts angekommen? Wirf einen Blick in den Spam-Ordner — und markiere die Mail dort als „Kein Spam“, dann landet sie künftig direkt im Postfach.