Wie behalte ich mit PostgreSQL einen eindeutigen Zähler pro Zeile?

10

Ich muss eine eindeutige (pro Zeile) Revisionsnummer in einer document_revisions-Tabelle behalten, in der die Revisionsnummer für ein Dokument gilt, sodass sie nicht für die gesamte Tabelle, sondern nur für das zugehörige Dokument eindeutig ist.

Ich habe mir anfangs etwas ausgedacht wie:

current_rev = SELECT MAX(rev) FROM document_revisions WHERE document_id = 123;
INSERT INTO document_revisions(rev) VALUES(current_rev + 1);

Aber es gibt eine Rennbedingung!

Ich versuche es mit zu lösen pg_advisory_lock, aber die Dokumentation ist etwas knapp und ich verstehe sie nicht vollständig und ich möchte nicht versehentlich etwas sperren.

Ist das Folgende akzeptabel oder mache ich es falsch oder gibt es eine bessere Lösung?

SELECT pg_advisory_lock(123);
current_rev = SELECT MAX(rev) FROM document_revisions WHERE document_id = 123;
INSERT INTO document_revisions(rev) VALUES(current_rev + 1);
SELECT pg_advisory_unlock(123);

Sollte ich nicht stattdessen die Dokumentzeile (Schlüssel1) für eine bestimmte Operation (Schlüssel2) sperren? Das wäre also die richtige Lösung:

SELECT pg_advisory_lock(id, 1) FROM documents WHERE id = 123;
current_rev = SELECT MAX(rev) FROM document_revisions WHERE document_id = 123;
INSERT INTO document_revisions(rev) VALUES(current_rev + 1);
SELECT pg_advisory_unlock(id, 1) FROM documents WHERE id = 123;

Vielleicht bin ich nicht an PostgreSQL gewöhnt und eine SERIAL kann einen Gültigkeitsbereich haben, oder vielleicht eine Sequenz und nextval()würde den Job besser machen?

Julien Portalier
quelle
Ich verstehe nicht, was Sie mit "für eine bestimmte Operation" meinen und woher "key2" stammt.
Trygve Laugstøl
2
Ihre Sperrstrategie sieht in Ordnung aus, wenn Sie pessimistisches Sperren wünschen, aber ich würde pg_advisory_xact_lock verwenden, damit alle Sperren bei COMMIT / ROLLBACK automatisch freigegeben werden.
Trygve Laugstøl

Antworten:

2

Angenommen, Sie speichern alle Revisionen des Dokuments in einer Tabelle, besteht ein Ansatz darin, die Revisionsnummer nicht zu speichern, sondern sie basierend auf der Anzahl der in der Tabelle gespeicherten Revisionen zu berechnen.

Es ist im Wesentlichen ein abgeleiteter Wert, den Sie nicht speichern müssen.

Eine Fensterfunktion kann verwendet werden, um die Revisionsnummer zu berechnen, so etwas wie

row_number() over (partition by document_id order by <change_date>)

und Sie benötigen eine Spalte change_date, um die Reihenfolge der Revisionen zu verfolgen.


Wenn Sie jedoch nur revisioneine Eigenschaft des Dokuments haben und angeben, "wie oft sich das Dokument geändert hat", würde ich mich für den optimistischen Sperransatz entscheiden, etwa:

update documents
set revision = revision + 1
where document_id = <id> and revision = <old_revision>;

Wenn dies 0 Zeilen aktualisiert, wurde eine Zwischenaktualisierung durchgeführt, und Sie müssen den Benutzer darüber informieren.


Versuchen Sie im Allgemeinen, Ihre Lösung so einfach wie möglich zu halten. In diesem Fall von

  • Vermeidung der Verwendung expliziter Sperrfunktionen, sofern dies nicht unbedingt erforderlich ist
  • mit weniger Datenbankobjekten (keine Sequenzen pro Dokument) und weniger Attributen (speichern Sie die Revision nicht, wenn sie berechnet werden kann)
  • Verwenden einer einzelnen updateAnweisung anstelle einer selectgefolgt von einem insertoderupdate
Colin 't Hart
quelle
In der Tat muss ich den Wert nicht speichern, wenn er berechnet werden kann. Danke, dass du mich erinnert hast!
Julien Portalier
2
Tatsächlich werden in meinem Kontext ältere Revisionen irgendwann gelöscht, so dass ich sie nicht berechnen kann oder die Revisionsnummer sinken würde :)
Julien Portalier
3

SEQUENCE ist garantiert eindeutig und Ihr Anwendungsfall sieht anwendbar aus, wenn Ihre Anzahl von Dokumenten nicht zu hoch ist (ansonsten müssen Sie viele Sequenzen verwalten). Verwenden Sie die RETURNING-Klausel, um den Wert abzurufen, der von der Sequenz generiert wurde. Verwenden Sie beispielsweise 'A36' als document_id:

  • Pro Dokument können Sie eine Sequenz erstellen, um das Inkrement zu verfolgen.
  • Das Verwalten der Sequenzen muss mit einiger Sorgfalt behandelt werden. Sie könnten möglicherweise eine separate Tabelle behalten, die die Dokumentnamen und die damit verbundene Reihenfolge enthält, document_idauf die beim Einfügen / Aktualisieren der document_revisionsTabelle verwiesen wird .

     CREATE SEQUENCE d_r_document_a36_seq;
    
     INSERT INTO document_revisions (document_id, rev)
     VALUES ('A36',nextval('d_r_document_a36_seq')) RETURNING rev;
bma
quelle
Vielen Dank für die Formatierung des Deszo. Ich habe nicht bemerkt, wie schlecht es aussah, als ich meine Kommentare eingefügt habe.
BMA
Eine Sequenz ist ein schlechter Zähler, wenn der nächste Wert vorher + 1 sein soll, da sie nicht innerhalb der Transaktion ausgeführt werden.
Trygve Laugstøl
1
Eh? Sequenzen sind atomar. Deshalb habe ich eine Sequenz pro Dokument vorgeschlagen. Es ist auch nicht garantiert, dass sie lückenlos sind, da Rollbacks die Sequenz nach dem Inkrementieren nicht dekrementieren. Ich sage nicht, dass eine ordnungsgemäße Verriegelung keine gute Lösung ist, nur dass Sequenzen eine Alternative darstellen.
BMA
1
Vielen Dank! Sequenzen sind definitiv der richtige Weg, wenn ich die Revisionsnummer speichern muss.
Julien Portalier
2
Beachten Sie, dass eine große Anzahl von Sequenzen die Leistung stark beeinträchtigt, da eine Sequenz im Wesentlichen eine Tabelle mit einer Zeile ist. Mehr dazu lesen Sie hier
Magnuss
2

Dies wird oft durch optimistisches Sperren gelöst:

SELECT version, x FROM foo;

version | foo
    123 | ..

UPDATE foo SET x=?, version=124 WHERE version=123

Wenn das Update 0 aktualisierte Zeilen zurückgibt, haben Sie Ihr Update verpasst, weil bereits jemand anderes die Zeile aktualisiert hat.

Trygve Laugstøl
quelle
Vielen Dank! Dies ist gut, wenn Sie einen Aktualisierungszähler für ein Dokument aufbewahren müssen! Ich benötige jedoch eine eindeutige Revisionsnummer für jede Zeile in der Tabelle document_revisions, die nicht aktualisiert wird und der Nachfolger der vorherigen Revision sein muss (dh die Revisionsnummer der vorherigen Zeile + 1).
Julien Portalier
1
Hm, warum kannst du diese Technik dann nicht anwenden? Dies ist die einzige Methode (außer pessimistischem Sperren), mit der Sie eine lückenlose Sequenz erhalten.
Trygve Laugstøl
2

(Ich bin auf diese Frage gekommen, als ich versucht habe, einen Artikel zu diesem Thema erneut zu entdecken. Nachdem ich ihn gefunden habe, veröffentliche ich ihn hier, falls andere nach einer alternativen Option für die aktuell ausgewählte Antwort suchen - Fensterung mit row_number())

Ich habe den gleichen Anwendungsfall. Für jeden Datensatz, der in ein bestimmtes Projekt in unserem SaaS eingefügt wird, benötigen wir eine eindeutige, inkrementelle Nummer, die angesichts gleichzeitiger INSERTs generiert werden kann und idealerweise lückenlos ist.

Dieser Artikel beschreibt eine schöne Lösung , die ich hier zur Vereinfachung und Nachwelt zusammenfassen werde.

  1. Haben Sie eine separate Tabelle, die als Zähler dient, um den nächsten Wert bereitzustellen. Es wird zwei Spalten haben, document_idund counter. counterwird DEFAULT 0alternativ, wenn Sie bereits eine documentEntität haben, die alle Versionen gruppiert, counterkönnte dort eine hinzugefügt werden.
  2. Fügen Sie BEFORE INSERTder document_versionsTabelle einen Trigger hinzu, der den Zähler ( UPDATE document_revision_counters SET counter = counter + 1 WHERE document_id = ? RETURNING counter) atomar erhöht und dann NEW.versionauf diesen Zählerwert setzt.

Alternativ können Sie möglicherweise einen CTE verwenden, um dies auf der Anwendungsebene zu tun (obwohl ich es aus Gründen der Konsistenz als Auslöser bevorzuge):

WITH version AS (
  UPDATE document_revision_counters
    SET counter = counter + 1 
    WHERE document_id = 1
    RETURNING counter
)

INSERT 
  INTO document_revisions (document_id, rev, other_data)
  SELECT 1, version.counter, 'some other data'
  FROM "version";

Dies ähnelt im Prinzip dem Versuch, es zu lösen, mit der Ausnahme, dass durch Ändern einer Zählerzeile in einer einzelnen Anweisung Lesevorgänge des veralteten Werts blockiert werden, bis der INSERTfestgeschrieben wird.

Hier ist eine Abschrift davon, psqlwie dies in Aktion gezeigt wird:

scratch=# CREATE TABLE document_revisions (document_id integer, rev integer, other_data text, PRIMARY KEY (document_id, rev));
CREATE TABLE

scratch=# CREATE TABLE document_revision_counters (document_id integer PRIMARY KEY, counter integer DEFAULT 0);
CREATE TABLE

scratch=# WITH version AS (
    INSERT INTO document_revision_counters (document_id) VALUES (2)
      ON CONFLICT (document_id)
      DO UPDATE SET counter = document_revision_counters.counter + 1
      RETURNING counter;
  )
  INSERT 
    INTO document_revisions (document_id, rev, other_data)
    SELECT 2, version.counter, 'doc 1 v1'
    FROM "version";
INSERT 0 1

scratch=# WITH version AS (
    INSERT INTO document_revision_counters (document_id) VALUES (2)
      ON CONFLICT (document_id)
      DO UPDATE SET counter = document_revision_counters.counter + 1
      RETURNING counter;
  )
  INSERT 
    INTO document_revisions (document_id, rev, other_data)
    SELECT 2, version.counter, 'doc 1 v2'
    FROM "version";
INSERT 0 1

scratch=# WITH version AS (
    INSERT INTO document_revision_counters (document_id) VALUES (2)
      ON CONFLICT (document_id)
      DO UPDATE SET counter = document_revision_counters.counter + 1
      RETURNING counter;
  )
  INSERT 
    INTO document_revisions (document_id, rev, other_data)
    SELECT 2, version.counter, 'doc 2 v1'
    FROM "version";
INSERT 0 1

scratch=# SELECT * FROM document_revisions;
 document_id | rev | other_data 
-------------+-----+------------
           2 |   1 | doc 1 v1
           2 |   2 | doc 1 v2
           2 |   1 | doc 2 v1
(3 rows)

Wie Sie sehen können, müssen Sie vorsichtig sein, wie es INSERTpassiert, daher die Trigger-Version, die so aussieht:

CREATE OR REPLACE FUNCTION set_doc_revision()
RETURNS TRIGGER AS $$ BEGIN
  WITH version AS (
    INSERT INTO document_revision_counters (document_id, counter) VALUES (NEW.document_id, 1)
    ON CONFLICT (document_id)
    DO UPDATE SET counter = document_revision_counters.counter + 1
    RETURNING counter
  )

  SELECT INTO NEW.rev counter FROM version; RETURN NEW; END;
$$ LANGUAGE 'plpgsql';

CREATE TRIGGER set_doc_revision BEFORE INSERT ON document_revisions
FOR EACH ROW EXECUTE PROCEDURE set_doc_revision();

Das macht INSERTs viel einfacher und die Integrität der Daten robuster gegenüber INSERTs, die aus beliebigen Quellen stammen:

scratch=# INSERT INTO document_revisions (document_id, other_data) VALUES (1, 'baz');
INSERT 0 1

scratch=# INSERT INTO document_revisions (document_id, other_data) VALUES (1, 'foo');
INSERT 0 1

scratch=# INSERT INTO document_revisions (document_id, other_data) VALUES (1, 'bar');
INSERT 0 1

scratch=# INSERT INTO document_revisions (document_id, other_data) VALUES (42, 'meaning of life');
INSERT 0 1

scratch=# SELECT * FROM document_revisions;
 document_id | rev |   other_data    
-------------+-----+-----------------
           1 |   1 | baz
           1 |   2 | foo
           1 |   3 | bar
          42 |   1 | meaning of life
(4 rows)
Bo Jeanes
quelle