Jak zachować unikalny licznik na wiersz dzięki PostgreSQL?

10

Muszę zachować unikalny (w wierszu) numer rewizji w tabeli rewizji dokumentu, gdzie numer rewizji jest zawarty w dokumencie, więc nie jest unikalny dla całej tabeli, tylko dla powiązanego dokumentu.

Początkowo wymyśliłem coś takiego:

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

Ale jest warunek wyścigu!

Próbuję to rozwiązać pg_advisory_lock, ale dokumentacja jest nieco skąpa i nie do końca ją rozumiem, i nie chcę przez pomyłkę czegoś blokować.

Czy poniższe są dopuszczalne, czy robię to źle, czy jest lepsze rozwiązanie?

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);

Czy zamiast tego nie powinienem blokować wiersza dokumentu (klucz1) dla danej operacji (klucz2)? To byłoby właściwe rozwiązanie:

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;

Może nie jestem przyzwyczajony do PostgreSQL, a SERIAL może mieć zakres, a może sekwencja i nextval()lepiej by to zrobiła?

Julien Portalier
źródło
Nie rozumiem, co masz na myśli przez „dla danej operacji” i skąd pochodzi „key2”.
Trygve Laugstøl
2
Twoja strategia blokowania wygląda OK, jeśli chcesz blokowania pesymistycznego, ale użyłbym pg_advisory_xact_lock, aby wszystkie blokady były automatycznie zwalniane na COMMIT / ROLLBACK.
Trygve Laugstøl

Odpowiedzi:

2

Zakładając, że przechowujesz wszystkie wersje dokumentu w tabeli, podejście polegałoby na tym, aby nie przechowywać numeru wersji, ale obliczać go na podstawie liczby wersji zapisanych w tabeli.

Zasadniczo jest to wartość pochodna , a nie coś, co trzeba przechowywać.

Do obliczenia numeru wersji można użyć funkcji okna, coś w rodzaju

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

a będziesz potrzebować kolumny przypominającej change_dateporządek poprawek.


Z drugiej strony, jeśli masz tylko revisionwłaściwość dokumentu i wskazuje on „ile razy dokument się zmienił”, to wybrałbym optymistyczne podejście do blokowania, takie jak:

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

Jeśli to aktualizuje 0 wierszy, oznacza to, że nastąpiła aktualizacja pośrednia i musisz o tym poinformować użytkownika.


Ogólnie staraj się, aby Twoje rozwiązanie było tak proste, jak to możliwe. W tym przypadku przez

  • unikanie użycia jawnych funkcji blokowania, chyba że jest to absolutnie konieczne
  • posiadanie mniejszej liczby obiektów bazy danych (brak sekwencji na dokument) i przechowywanie mniejszej liczby atrybutów (nie przechowuj wersji, jeśli można ją obliczyć)
  • używając pojedynczego updatewyrażenia zamiast selectznaku insertlubupdate
Colin 't Hart
źródło
Rzeczywiście, nie muszę przechowywać wartości, kiedy można ją obliczyć. Dzięki za przypomnienie!
Julien Portalier
2
Właściwie, w moim kontekście, starsze wersje zostaną w pewnym momencie usunięte, więc nie mogę tego obliczyć, inaczej numer wersji by się zmniejszył :)
Julien Portalier
3

SEKWENCJA gwarantuje, że jest wyjątkowa, a Twój przypadek użycia wygląda na odpowiedni, jeśli liczba dokumentów nie jest zbyt wysoka (w przeciwnym razie masz wiele sekwencji do zarządzania). Użyj klauzuli RETURNING, aby uzyskać wartość wygenerowaną przez sekwencję. Na przykład używając „A36” jako id_dokumentu:

  • Dla każdego dokumentu możesz utworzyć sekwencję do śledzenia przyrostu.
  • Zarządzanie sekwencjami będzie wymagało szczególnej ostrożności. Być może możesz zachować oddzielną tabelę zawierającą nazwy dokumentów i sekwencję z nimi związaną, document_iddo której można się odwoływać podczas wstawiania / aktualizowania document_revisionstabeli.

     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
źródło
Dzięki za formatowanie deszo, nie zauważyłem, jak źle wyglądało, kiedy wkleiłem swoje komentarze.
bma,
Sekwencja jest złym licznikiem, jeśli chcesz, aby następna wartość była poprzednia + 1, ponieważ nie działają one w ramach transakcji.
Trygve Laugstøl
1
Co? Sekwencje są atomowe. Dlatego zaproponowałem sekwencję na dokument. Nie gwarantuje się również, że nie będą zawierać przerw, ponieważ wycofywanie nie powoduje zmniejszenia sekwencji po jej zwiększeniu. Nie twierdzę, że prawidłowe blokowanie nie jest dobrym rozwiązaniem, tylko że sekwencje stanowią alternatywę.
bma,
1
Dzięki! Sekwencje są zdecydowanie najlepszym rozwiązaniem, jeśli muszę zapisać numer wersji.
Julien Portalier
2
Zauważ, że posiadanie ogromnej ilości sekwencji jest głównym hitem wydajności, ponieważ sekwencja jest zasadniczo tabelą z jednym rzędem. Możesz przeczytać więcej na ten temat tutaj
Magnuss,
2

Często rozwiązuje się to poprzez optymistyczne blokowanie:

SELECT version, x FROM foo;

version | foo
    123 | ..

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

Jeśli aktualizacja zwróci 0 zaktualizowanych wierszy, przegapiłeś aktualizację, ponieważ ktoś już ją zaktualizował.

Trygve Laugstøl
źródło
Dzięki! Jest to dobre, gdy trzeba przechowywać licznik aktualizacji dokumentu! Potrzebuję jednak unikalnego numeru wersji dla każdego wiersza w tabeli rewizji dokumentu, który nie będzie aktualizowany i musi być następcą poprzedniej wersji (tj. Numeru wersji poprzedniego rzędu + 1).
Julien Portalier
1
Hm, dlaczego więc nie możesz użyć tej techniki? Jest to jedyna metoda (inna niż pesymistyczne blokowanie), która da ci sekwencję bez przerw.
Trygve Laugstøl,
2

(Przyszedłem do tego pytania, próbując ponownie odkryć artykuł na ten temat. Teraz, gdy go znalazłem, zamieszczam go tutaj, na wypadek, gdyby inni szukali alternatywy dla obecnie wybranej odpowiedzi - okienko z row_number())

Mam ten sam przypadek użycia. Dla każdego rekordu wstawionego do konkretnego projektu w naszym SaaS potrzebujemy unikalnej, rosnącej liczby, która może być wygenerowana w obliczu równoczesnych INSERTs i jest idealnie bez przerwy.

W tym artykule opisano fajne rozwiązanie , które streszczę tutaj dla ułatwienia i potomności.

  1. Mają osobną tabelę, która działa jak licznik, aby podać następną wartość. Będzie miał dwie kolumny document_idi counter. counterbędzie DEFAULT 0Alternatywnie, jeśli masz już documentencję, która grupuje wszystkie wersje, countermożna tam dodać a.
  2. Dodaj BEFORE INSERTwyzwalacz do document_versionstabeli, która atomowo zwiększa licznik ( UPDATE document_revision_counters SET counter = counter + 1 WHERE document_id = ? RETURNING counter), a następnie ustawia NEW.versiontę wartość licznika.

Alternatywnie możesz użyć CTE, aby to zrobić w warstwie aplikacji (chociaż wolę, aby był wyzwalaczem ze względu na spójność):

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";

Zasadniczo jest to podobne do tego, w jaki sposób próbowałeś go rozwiązać na początku, z tą różnicą, że modyfikując wiersz licznika w pojedynczej instrukcji, blokuje odczyty nieaktualnej wartości, aż do zatwierdzenia INSERT.

Oto zapis psqlpokazujący to w akcji:

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)

Jak widać, musisz uważać na INSERTto, co się dzieje, stąd wersja wyzwalacza, która wygląda następująco:

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();

To sprawia, że INSERTs jest znacznie prostszy, a integralność danych bardziej niezawodna w porównaniu z danymi INSERTpochodzącymi z dowolnych źródeł:

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
źródło