PostgreSQL Antipatterns: Aktualisierung einer großen Tabelle unter Last

Was soll ich tun (und definitiv nicht), wenn Sie eine große Anzahl von DatensĂ€tzen in der aktiv genutzten PostgreSQL-Tabelle "multi-million" aktualisieren mĂŒssen - den Wert des neuen Felds initialisieren oder Fehler in vorhandenen DatensĂ€tzen korrigieren? Gleichzeitig sparen Sie Zeit und verlieren kein Firmengeld durch Ausfallzeiten.



Bereiten Sie die Testdaten vor:

CREATE TABLE tbl(k text, v integer); INSERT INTO tbl SELECT chr(ascii('a'::text) + (random() * 26)::integer) k , (random() * 100)::integer v FROM generate_series(1, 1000000) i; --  ,   ! CREATE INDEX ON tbl(k, v); 

Angenommen, wir möchten nur den Wert von v fĂŒr alle DatensĂ€tze mit k im Bereich 'q' .. 'z' um 1 erhöhen.

Bevor Sie jedoch mit den Experimenten beginnen, speichern Sie den Originaldatensatz, um jedes Mal "saubere" Ergebnisse zu erzielen:

 CREATE TABLE _tbl AS TABLE tbl; 

UPDATE: Einer fĂŒr alle und alle fĂŒr einen


Die einfachste Option, die einem sofort in den Sinn kommt, ist, alles in einem UPDATE zu erledigen:

 UPDATE tbl SET v = v + 1 WHERE k BETWEEN 'q' AND 'z'; 


[siehe EXPLAIN.TENSOR.RU]

Ein recht einfacher Vorgang auf ganz „kurzen“ Leitungen dauerte anscheinend lĂ€nger als 2,5 Sekunden. Und wenn Ihr Ausdruck komplizierter ist, ist die Linie authentischer, es gibt mehr Aufzeichnungen und sogar einige Auslöser greifen ein - die Zeit kann sogar bis zu Minuten, aber bis zu Stunden ansteigen. Angenommen, Sie sind bereit zu warten, und der Rest Ihres Systems ist an diese Basis gebunden, wenn eine aktive OLTP-Last vorliegt.

Das Problem ist, dass UPDATE, sobald es zu einem bestimmten Datensatz gelangt, diesen bis zum Ende der AusfĂŒhrung blockiert . Wenn er gleichzeitig mit demselben Datensatz an einem parallel gestarteten „Spot“ -UPDATE arbeiten möchte, wartet er nach wie vor auf einen Block fĂŒr die Aktualisierungsanforderung und setzt sich bis zum Ende seiner Arbeit durch.


© wumo.com/wumo

Das schlimmste Szenario sind Web-Systeme, bei denen Verbindungen zur Datenbank nach Bedarf hergestellt werden. Schließlich sammeln sich solche "baumelnden" Verbindungen an und verschlingen die Ressourcen sowohl der Datenbank als auch des Clients, wenn Sie keinen davon getrennten Schutzmechanismus einrichten.

Transaktionen aufteilen


Im Allgemeinen ist nicht alles gut, wenn alles auf einmal erledigt wird. Ja, und selbst wenn wir ein großes UPDATE in mehrere kleine UPDATE aufteilen, aber alles in einer Transaktion funktionieren lassen, bleibt das Problem beim Sperren gleich, da verĂ€nderbare DatensĂ€tze bis zum Ende der gesamten Transaktion gesperrt sind.

Wir mĂŒssen also eine große Transaktion in mehrere aufteilen. Dazu können wir entweder externe Mittel verwenden und eine Art Skript schreiben, das separate Transaktionen generiert, oder die Möglichkeiten nutzen, die uns die Datenbank selbst bietet.

CALL und Transaktionsmanagement


Ab PostgreSQL 11 können Transaktionen direkt im prozeduralen Code verwaltet werden:
In Prozeduren, die vom CALL-Befehl aufgerufen werden, sowie in anonymen Codeblöcken (im DO-Befehl) können Sie Transaktionen abschließen, indem Sie COMMIT und ROLLBACK ausfĂŒhren. Nachdem die Transaktion mit diesen Befehlen abgeschlossen ist, wird automatisch eine neue gestartet.
Aber diese Version ist bei weitem nicht jedermanns Sache und die Arbeit mit CALL hat ihre Grenzen. Deshalb werden wir versuchen, unser Problem ohne externe Mittel zu lösen, damit es auf allen aktuellen Versionen und auch mit minimalen Änderungen auf dem Server selbst funktioniert - so dass es nicht notwendig wĂ€re, irgendetwas zu kompilieren und neu zu starten.

Aus dem gleichen Grund werden wir die Möglichkeit, autonome Transaktionen ĂŒber pg_background zu organisieren, nicht in Betracht ziehen .

Verwalten von Verbindungen "innerhalb" der Basis


PostgreSQL hat in der Vergangenheit verschiedene Methoden verwendet, um autonome Transaktionen zu emulieren und separate zusĂ€tzliche Verbindungen zu generieren - ĂŒber zusĂ€tzliche prozedurale Sprachen oder das Standard- Dblink-Modul . Letzteres hat den Vorteil, dass es standardmĂ€ĂŸig in den meisten Distributionen enthalten ist und nur ein Befehl erforderlich ist, um es in der Datenbank zu aktivieren:

 CREATE EXTENSION dblink; 

"... und viele, viele ekelhafte Kinder mitgebracht"


Bevor Sie jedoch eine dblink-Bindung erstellen, sollten Sie zunĂ€chst herausfinden, wie ein „normaler Entwickler“ einen großen Datensatz, den er aktualisieren muss, in kleine aufteilt.

Naive LIMIT ... OFFSET


Die erste Idee besteht darin, eine "Paginierungs" -Suche durchzufĂŒhren: " WĂ€hlen wir jedes Mal die nĂ€chsten tausend DatensĂ€tze aus ", indem Sie den OFFSET-Wert in jeder neuen Anforderung erhöhen:

 UPDATE tbl T SET v = Tv + 1 FROM ( SELECT k , v FROM tbl WHERE k BETWEEN 'q' AND 'z' ORDER BY --       k, v --     ! LIMIT $1 OFFSET $2 * $1 ) S WHERE (Tk, Tv) = (Sk, Sv); 

Vor dem Testen der Leistung dieser Lösung stellen wir den Datensatz wieder her:

 TRUNCATE TABLE tbl; INSERT INTO tbl TABLE _tbl; 

Wie wir im obigen Plan gesehen haben, mĂŒssen wir ungefĂ€hr 384.000 DatensĂ€tze aktualisieren. Lassen Sie uns daher gleich sehen, wie die Aktualisierungen nĂ€her am Ende durchgefĂŒhrt werden - im Bereich der 300. Iteration von 1000 EintrĂ€gen :


[siehe EXPLAIN.TENSOR.RU]

Oh ... Die Aktualisierung des Samples am Ende der gesamten 1K-DatensÀtze kostet uns fast so viel Zeit wie die gesamte Originalversion !

Dies ist nicht unsere Wahl. Es kann trotzdem verwendet werden, wenn Sie nur wenige Iterationen und kleine OFFSET-Werte erhalten. Weil das LIMIT X OFFSET Y fĂŒr die Datenbank gleichbedeutend ist mit " subtrahieren / auswĂ€hlen / bilden von ersten X + Y-DatensĂ€tzen und dann werfen Sie das erste Y in den Papierkorb ", was fĂŒr große Werte von Y tragisch aussieht.

TatsĂ€chlich kann diese Methode ĂŒberhaupt nicht angewendet werden ! Wir verlassen uns nicht nur auf aktualisierte Werte fĂŒr die Auswahl, sondern riskieren auch, einen Teil der DatensĂ€tze zu ĂŒberspringen und den anderen Teil zweimal zu aktualisieren, wenn Blöcke mit denselben SchlĂŒsseln an den Seitenrand gelangen:


In diesem Beispiel haben wir den grĂŒnen Datensatz zweimal und den roten Datensatz nie aktualisiert. Nur weil bei gleichen Werten der SortierschlĂŒssel die Reihenfolge der DatensĂ€tze selbst in einem solchen Block nicht festgelegt ist.

Traurig BESTELLEN BEI ... LIMIT


Lassen Sie uns die Aufgabe etwas modifizieren - fĂŒgen Sie ein neues Feld hinzu, in das wir unseren Wert v + 1 schreiben:

 ALTER TABLE tbl ADD COLUMN x integer; 

Bitte beachten Sie, dass dieses Design fast sofort funktioniert, ohne die gesamte Tabelle neu zu schreiben. Wenn Sie jedoch einen DEFAULT-Wert hinzufĂŒgen, gilt dies - erst ab der 11. Version .

Lassen Sie uns sofort einen Index erstellen, in dem nur nicht initialisierte EintrÀge verbleiben:

 CREATE INDEX CONCURRENTLY ON tbl(k, v) WHERE x IS NULL; 

Der CONCURRENTLY-Index blockiert nicht die Lese- und Schreibarbeit mit der Tabelle, wĂ€hrend er sich langsam sogar auf einen riesigen Datensatz ĂŒbertrĂ€gt.

Nun lautet die Idee: "WÀhlen wir jedes Mal nur die ersten tausend DatensÀtze aus diesem Index aus."

 UPDATE tbl T SET x = Tv + 1 FROM ( SELECT k, v FROM tbl WHERE k BETWEEN 'q' AND 'z' AND x IS NULL ORDER BY k, v LIMIT 1000 --   OFFSET! ) S WHERE (Tk, Tv) = (Sk, Sv) AND Tx IS NULL; 


[siehe EXPLAIN.TENSOR.RU]

Schon viel besser - die Dauer jeder einzelnen Transaktion verkĂŒrzt sich jetzt um das Sechsfache.

Aber lassen Sie uns noch einmal sehen, wie der Plan fĂŒr die 200. Iteration aussehen wird:

 Update on tbl t (actual time=530.591..530.591 rows=0 loops=1) Buffers: shared hit=789337 read=1 dirtied=1 

Die Zeit verschlechterte sich erneut (allerdings nur um 25%), und die Puffer nahmen zu - aber warum?
Tatsache ist, dass MVCC in PostgreSQL "tote Seelen" im Index hinterlĂ€sst - Versionen bereits aktualisierter DatensĂ€tze, die jetzt nicht mehr fĂŒr den Index geeignet sind. Das heißt, wir scannen immer noch die ersten 1000 DatensĂ€tze bei der 200. Iteration, obwohl wir spĂ€ter die vorherigen 199K-Versionen von Tupeln verwerfen, die bereits geĂ€ndert wurden.

Wenn bei uns nicht mehrere Hundert, sondern mehrere Hunderttausend Iterationen erforderlich sind, macht sich die Verschlechterung bei jeder nachfolgenden AbfrageausfĂŒhrung stĂ€rker bemerkbar.

UPDATE nach Segment


Warum hĂ€ngen wir eigentlich so an diesem Wert von „1000 DatensĂ€tzen“? Schließlich haben wir keinen Grund, genau 1000 oder eine andere bestimmte Zahl zu wĂ€hlen . Wir wollten lediglich den gesamten Datensatz in einige, nicht unbedingt gleich große, nicht zusammenhĂ€ngende Segmente "schneiden". Verwenden wir also unseren vorhandenen Index fĂŒr den beabsichtigten Zweck.

Ein indiziertes Paar (k, v) eignet sich hervorragend fĂŒr unsere Aufgabe. Lassen Sie uns eine Abfrage erstellen, damit sie auf dem zuletzt verarbeiteten Paar aufbauen kann:

 WITH kv AS ( SELECT k, v FROM tbl WHERE (k, v) > ($1, $2) AND k BETWEEN 'q' AND 'z' AND x IS NULL ORDER BY k, v LIMIT 1 ) , upd AS ( UPDATE tbl T SET x = Tv + 1 WHERE (Tk, Tv) = (TABLE kv) AND Tx IS NULL RETURNING k, v ) TABLE upd LIMIT 1; 

Bei der ersten Iteration genĂŒgt es, die Abfrageparameter auf den Wert "Null" ('', 0) zu setzen , und bei jeder nĂ€chsten Iteration wird das Ergebnis der vorherigen Abfrage abgerufen .


[siehe EXPLAIN.TENSOR.RU]

Die Transaktions- / Sperrzeit betrĂ€gt weniger als eine Millisekunde. Die Anzahl der Iterationen wird nicht beeintrĂ€chtigt. Ein vollstĂ€ndiger vorlĂ€ufiger Scan aller Daten in der Tabelle ist nicht erforderlich. Großartig!

Setzen Sie die endgĂŒltige Version mit Dblink
 DO $$ DECLARE k text = ''; v integer = 0; BEGIN PERFORM dblink_connect('dbname=' || current_database() || ' port=' || current_setting('port')); --  PREPARED STATEMENT,      PERFORM dblink($q$ PREPARE _q(text, integer) AS WITH kv AS ( SELECT k, v FROM tbl WHERE (k, v) > ($1, $2) AND k BETWEEN 'q' AND 'z' AND x IS NULL ORDER BY k, v LIMIT 1 ) , upd AS ( UPDATE tbl T SET x = Tv + 1 WHERE (Tk, Tv) = (TABLE kv) AND Tx IS NULL RETURNING k, v ) TABLE upd LIMIT 1; $q$); -- ,    LOOP SELECT * INTO k, v FROM dblink($p$EXECUTE _q('$p$ || k || $p$',$p$ || v || $p$)$p$) T(k text, v integer); RAISE NOTICE '(k,v) = (''%'',%)', k, v; --   ,     EXIT WHEN (k, v) IS NULL; END LOOP; PERFORM dblink_disconnect(); END; $$ LANGUAGE plpgsql; 


Ein zusĂ€tzlicher Vorteil dieser Methode ist die Möglichkeit, die AusfĂŒhrung dieses Skripts jederzeit zu unterbrechen und dann an der gewĂŒnschten Stelle fortzusetzen.

Komplexe Berechnungen in UPDATE


Ich werde die Situation mit der schwierigen Berechnung des zugewiesenen Werts separat erwĂ€hnen - wenn Sie etwas aus den verknĂŒpften Tabellen berechnen mĂŒssen.

Die fĂŒr die Berechnung aufgewendete Zeit erhöht auch die Dauer der Transaktion. Daher ist es am besten , diese Werte nach UPDATE zu berechnen .

Zum Beispiel möchten wir unser neues Feld x mit der Anzahl der DatensĂ€tze fĂŒllen, die denselben Wert haben (k, v). Erstellen wir eine "temporĂ€re" Tabelle, deren Generierung keine zusĂ€tzlichen Sperren erfordert:

 CREATE TABLE tmp AS SELECT k, v, count(*) x FROM tbl GROUP BY 1, 2; CREATE INDEX ON tmp(k, v); 

Nun können wir gemĂ€ĂŸ dem oben beschriebenen Modell gemĂ€ĂŸ dieser Tabelle iterieren und das Ziel aktualisieren:

 UPDATE tbl T SET x = Sx FROM tmp S WHERE (Tk, Tv) = (Sk, Sv) AND (Sk, Sv) = ($1, $2); 

Wie Sie sehen, sind keine komplizierten Berechnungen erforderlich.

Denken Sie daran, die Hilfstabelle spÀter zu löschen.

Source: https://habr.com/ru/post/de481610/


All Articles