Comment conserver un compteur unique par ligne avec PostgreSQL?

10

J'ai besoin de conserver un numéro de révision unique (par ligne) dans une table document_revisions, où le numéro de révision est limité à un document, il n'est donc pas unique à la table entière, seulement au document associé.

Au départ, j'ai trouvé quelque chose comme:

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

Mais il y a une condition de course!

J'essaie de le résoudre avec pg_advisory_lock, mais la documentation est un peu rare et je ne la comprends pas complètement, et je ne veux pas verrouiller quelque chose par erreur.

Est-ce que ce qui suit est acceptable, ou est-ce que je me trompe, ou y a-t-il une meilleure solution?

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

Ne devrais-je pas verrouiller la ligne de document (clé1) pour une opération donnée (clé2) à la place? Ce serait donc la bonne solution:

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;

Peut-être que je ne suis pas habitué à PostgreSQL et qu'un SERIAL peut être délimité, ou peut-être une séquence et nextval()ferait mieux le travail?

Julien Portalier
la source
Je ne comprends pas ce que vous voulez dire par «pour une opération donnée» et d'où vient «key2».
Trygve Laugstøl
2
Votre stratégie de verrouillage semble OK si vous voulez un verrouillage pessimiste, mais j'utiliserais pg_advisory_xact_lock pour que tous les verrous soient automatiquement libérés sur COMMIT / ROLLBACK.
Trygve Laugstøl

Réponses:

2

En supposant que vous stockez toutes les révisions du document dans un tableau, une approche serait de ne pas stocker le numéro de révision mais de le calculer en fonction du nombre de révisions stockées dans le tableau.

C'est, essentiellement, une valeur dérivée , pas quelque chose que vous devez stocker.

Une fonction de fenêtre peut être utilisée pour calculer le numéro de révision, quelque chose comme

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

et vous aurez besoin d'une colonne quelque chose comme change_datepour garder une trace de l'ordre des révisions.


D'un autre côté, si vous avez juste revisionune propriété du document et qu'il indique "combien de fois le document a changé", alors j'opterais pour l'approche de verrouillage optimiste, quelque chose comme:

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

Si cela met à jour 0 lignes, il y a eu une mise à jour intermédiaire et vous devez en informer l'utilisateur.


En général, essayez de garder votre solution aussi simple que possible. Dans ce cas par

  • éviter l'utilisation de fonctions de verrouillage explicites sauf en cas d'absolue nécessité
  • ayant moins d'objets de base de données (pas de séquences par document) et stockant moins d'attributs (ne stockez pas la révision si elle peut être calculée)
  • en utilisant une seule updatedéclaration plutôt qu'un selectsuivi d'un insertouupdate
Colin 't Hart
la source
En effet, je n'ai pas besoin de stocker la valeur lorsqu'elle peut être calculée. Merci de me le rappeler!
Julien Portalier
2
En fait, dans mon contexte, les révisions plus anciennes seront supprimées à un moment donné, donc je ne peux pas le calculer ou le numéro de révision diminuerait :)
Julien Portalier
3

SEQUENCE est garanti d'être unique, et votre cas d'utilisation semble applicable si votre nombre de documents n'est pas trop élevé (sinon vous avez beaucoup de séquences à gérer). Utilisez la clause RETURNING pour obtenir la valeur générée par la séquence. Par exemple, en utilisant 'A36' comme document_id:

  • Par document, vous pouvez créer une séquence pour suivre l'incrément.
  • La gestion des séquences devra être manipulée avec soin. Vous pouvez peut-être conserver un tableau séparé contenant les noms des documents et la séquence associée à celui-ci document_idà référencer lors de l'insertion / mise à jour du document_revisionstableau.

     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
la source
Merci pour la mise en forme deszo, je n'ai pas remarqué à quel point c'était mauvais quand j'ai collé dans mes commentaires.
bma
Une séquence est un mauvais compteur si vous voulez que la valeur suivante soit précédente + 1 car ils ne s'exécutent pas dans la transaction.
Trygve Laugstøl
1
Eh? Les séquences sont atomiques. C'est pourquoi j'ai proposé une séquence par document. Ils ne sont pas non plus garantis sans espace, car les restaurations ne désincrémentent pas la séquence après son incrémentation. Je ne dis pas qu'un verrouillage correct n'est pas une bonne solution, mais seulement que les séquences présentent une alternative.
bma
1
Merci! Les séquences sont certainement la voie à suivre si j'ai besoin de stocker le numéro de révision.
Julien Portalier
2
Notez qu'avoir d'énormes quantités de séquences est un impact majeur sur les performances, car une séquence est essentiellement une table avec une ligne. Vous pouvez en savoir plus à ce sujet ici
Magnuss
2

Cela est souvent résolu avec un verrouillage optimiste:

SELECT version, x FROM foo;

version | foo
    123 | ..

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

Si la mise à jour renvoie 0 lignes mises à jour, vous avez manqué votre mise à jour car quelqu'un d'autre a déjà mis à jour la ligne.

Trygve Laugstøl
la source
Merci! C'est une bonne chose lorsque vous devez conserver un compteur de mises à jour sur un document! Mais j'ai besoin d'un numéro de révision unique pour chaque ligne de la table document_revisions, qui ne sera pas mis à jour, et doit être le suiveur de la révision précédente (c'est-à-dire le numéro de révision de la ligne précédente + 1).
Julien Portalier
1
Hm, pourquoi ne pouvez-vous pas utiliser cette technique alors? C'est la seule méthode (autre que le verrouillage pessimiste) qui vous donnera une séquence sans espace.
Trygve Laugstøl
2

(Je suis venu à cette question en essayant de redécouvrir un article sur ce sujet. Maintenant que je l'ai trouvé, je le poste ici au cas où d'autres chercheraient une option alternative à la réponse actuellement choisie - fenêtrage avec row_number())

J'ai ce même cas d'utilisation. Pour chaque enregistrement inséré dans un projet spécifique dans notre SaaS, nous avons besoin d'un nombre incrémentiel unique qui peut être généré face à des INSERTs concurrents et est idéalement sans espace.

Cet article décrit une belle solution , que je résumerai ici pour plus de facilité et de postérité.

  1. Avoir une table séparée qui fait office de compteur pour fournir la valeur suivante. Il aura deux colonnes document_idet counter. countersera DEFAULT 0Alternativement, si vous avez déjà une documententité qui regroupe toutes les versions, un counterpourrait y être ajouté.
  2. Ajoutez un BEFORE INSERTdéclencheur à la document_versionstable qui incrémente atomiquement le compteur ( UPDATE document_revision_counters SET counter = counter + 1 WHERE document_id = ? RETURNING counter), puis définit NEW.versioncette valeur de compteur.

Alternativement, vous pourriez être en mesure d'utiliser un CTE pour le faire au niveau de la couche application (bien que je préfère que ce soit un déclencheur pour des raisons de cohérence):

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

Cela est similaire en principe à la façon dont vous tentiez de le résoudre initialement, sauf qu'en modifiant une ligne de compteur dans une seule instruction, il bloque les lectures de la valeur périmée jusqu'à ce que le INSERTsoit validé.

Voici une transcription psqlmontrant cela en action:

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)

Comme vous pouvez le voir, vous devez faire attention à la façon dont INSERTcela se produit, d'où la version de déclenchement, qui ressemble à ceci:

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

Cela rend INSERTs beaucoup plus simple et l'intégrité des données plus robuste face à INSERTs provenant de sources arbitraires:

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
la source