Come mantenere un contatore unico per riga con PostgreSQL?


10

Devo mantenere un numero di revisione univoco (per riga) in una tabella document_revisions, in cui il numero di revisione è limitato a un documento, quindi non è univoco per l'intera tabella, ma solo per il documento correlato.

Inizialmente ho pensato a qualcosa del tipo:

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

Ma c'è una condizione di gara!

Sto cercando di risolverlo con pg_advisory_lock, ma la documentazione è un po 'scarsa e non la capisco completamente, e non voglio bloccare qualcosa per errore.

È accettabile quanto segue o sto sbagliando o esiste una soluzione migliore?

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

Non dovrei invece bloccare la riga del documento (tasto 1) per una determinata operazione (tasto 2)? Quindi quella sarebbe la soluzione corretta:

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;

Forse non sono abituato a PostgreSQL e un SERIAL può essere definito, o forse una sequenza e nextval()farebbe meglio il lavoro?


Non capisco cosa intendi con "per una determinata operazione" e da dove provenga "key2".
Trygve Laugstøl

2
La tua strategia di blocco sembra OK se vuoi il blocco pessimistico, ma vorrei usare pg_advisory_xact_lock in modo che tutti i blocchi vengano automaticamente rilasciati su COMMIT / ROLLBACK.
Trygve Laugstøl

Risposte:


2

Supponendo che tu memorizzi tutte le revisioni del documento in una tabella, un approccio sarebbe quello di non memorizzare il numero di revisione ma calcolarlo in base al numero di revisioni memorizzate nella tabella.

È essenzialmente un valore derivato , non qualcosa che devi archiviare.

Una funzione finestra può essere utilizzata per calcolare il numero di revisione, qualcosa del genere

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

e avrai bisogno di una colonna simile a quella change_dateper tenere traccia dell'ordine delle revisioni.


D'altra parte, se hai solo revisionuna proprietà del documento e indica "quante volte il documento è cambiato", allora preferirei un approccio ottimistico al blocco, qualcosa del tipo:

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

Se questo aggiorna 0 righe, allora c'è stato un aggiornamento intermedio e devi informare l'utente di questo.


In generale, cerca di mantenere la soluzione il più semplice possibile. In questo caso di

  • evitando l'uso di funzioni di blocco esplicito se non assolutamente necessario
  • avere meno oggetti di database (no per sequenze di documenti) e memorizzare meno attributi (non memorizzare la revisione se può essere calcolata)
  • usando una singola updateistruzione anziché una selectseguita da un insertoupdate

In effetti, non ho bisogno di memorizzare il valore quando può essere calcolato. Grazie per avermi ricordato!
Julien Portalier,

2
In realtà, nel mio contesto, le revisioni precedenti verranno eliminate ad un certo punto, quindi non posso calcolarlo o il numero di revisione diminuirà :)
Julien Portalier

3

SEQUENCE è garantito per essere unico e il tuo caso d'uso sembra applicabile se il tuo numero di documenti non è troppo elevato (altrimenti hai molte sequenze da gestire). Utilizzare la clausola RETURNING per ottenere il valore generato dalla sequenza. Ad esempio, usando 'A36' come id_documento:

  • Per documento, è possibile creare una sequenza per tenere traccia dell'incremento.
  • La gestione delle sequenze dovrà essere gestita con una certa cura. Potresti forse tenere una tabella separata contenente i nomi del documento e la sequenza associata a quella document_ida cui fare riferimento durante l'inserimento / aggiornamento della document_revisionstabella.

     CREATE SEQUENCE d_r_document_a36_seq;
    
     INSERT INTO document_revisions (document_id, rev)
     VALUES ('A36',nextval('d_r_document_a36_seq')) RETURNING rev;
    

Grazie per la formattazione deszo, non ho notato quanto brutta fosse quando ho incollato nei miei commenti.
bma

Una sequenza è un contatore errato se si desidera che il valore successivo sia precedente + 1 poiché non vengono eseguiti all'interno della transazione.
Trygve Laugstøl

1
Eh? Le sequenze sono atomiche. Ecco perché ho suggerito una sequenza per documento. Inoltre, non è garantito che siano privi di gap, poiché i rollback non decrementano la sequenza dopo che è stata incrementata. Non sto dicendo che il blocco corretto non sia una buona soluzione, solo che le sequenze presentano un'alternativa.
bma

1
Grazie! Le sequenze sono sicuramente la strada da percorrere se devo memorizzare il numero di revisione.
Julien Portalier,

2
Si noti che avere enormi quantità di sequenze è un grande successo per le prestazioni, poiché una sequenza è essenzialmente una tabella con una riga. Puoi leggere di più qui
Magnuss,

2

Questo è spesso risolto con un blocco ottimistico:

SELECT version, x FROM foo;

version | foo
    123 | ..

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

Se l'aggiornamento restituisce 0 righe aggiornate, hai perso l'aggiornamento perché qualcun altro ha già aggiornato la riga.


Grazie! Questo è utile quando devi tenere un contatore di aggiornamenti su un documento! Ma ho bisogno di un numero di revisione univoco per ogni riga nella tabella document_revisions, che non verrà aggiornata e deve essere il follower della revisione precedente (ovvero il numero di revisione della riga precedente + 1).
Julien Portalier,

1
Hm, perché non puoi usare questa tecnica allora? Questo è l'unico metodo (diverso dal blocco pessimistico) che ti darà una sequenza senza gap.
Trygve Laugstøl,

2

(Sono arrivato a questa domanda quando ho cercato di riscoprire un articolo su questo argomento. Ora che l'ho trovato, lo sto pubblicando qui nel caso in cui altri siano alla ricerca di un'opzione alternativa alla risposta attualmente scelta - finestra con row_number())

Ho questo stesso caso d'uso. Per ogni record inserito in un progetto specifico nel nostro SaaS abbiamo bisogno di un numero univoco e crescente che può essere generato di fronte a messaggi simultanei INSERTed è idealmente gapless.

Questo articolo descrive una bella soluzione , che riassumerò qui per facilità e posterità.

  1. Avere una tabella separata che funge da contatore per fornire il valore successivo. Avrà due colonne document_ide counter. countersarà DEFAULT 0In alternativa, se si dispone già di documentun'entità che raggruppa tutte le versioni, è counterpossibile aggiungere a lì.
  2. Aggiungi un BEFORE INSERTtrigger alla document_versionstabella che incrementa atomicamente il contatore ( UPDATE document_revision_counters SET counter = counter + 1 WHERE document_id = ? RETURNING counter) e quindi imposta il NEW.versionvalore di quel contatore.

In alternativa, potresti essere in grado di utilizzare un CTE per farlo a livello di applicazione (anche se preferisco che sia un trigger per motivi di coerenza):

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

Questo è simile in linea di principio a come stavi cercando di risolverlo inizialmente, tranne per il fatto che modificando una riga del contatore in una singola istruzione blocca le letture del valore non aggiornato fino a quando non INSERTviene eseguito il commit.

Ecco una trascrizione da psqlmostrare questo in azione:

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)

Come puoi vedere, devi stare attento a come INSERTsuccede, quindi alla versione trigger, che assomiglia a questa:

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

Ciò rende INSERTmolto più semplice e l'integrità dei dati più solida rispetto a quella INSERTproveniente da fonti arbitrarie:

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)
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.