¿Cómo mantener un contador único por fila con PostgreSQL?

10

Necesito mantener un número de revisión único (por fila) en una tabla document_revisions, donde el número de revisión está limitado a un documento, por lo que no es exclusivo de toda la tabla, solo del documento relacionado.

Inicialmente se me ocurrió algo como:

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

¡Pero hay una condición de carrera!

Estoy tratando de resolverlo pg_advisory_lock, pero la documentación es un poco escasa y no lo entiendo completamente, y no quiero bloquear algo por error.

¿Es aceptable lo siguiente, o lo estoy haciendo mal, o hay una mejor solución?

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

¿No debería bloquear la fila de documentos (clave1) para una operación dada (clave2)? Entonces esa sería la solución adecuada:

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;

¿Tal vez no estoy acostumbrado a PostgreSQL y una SERIAL puede tener un alcance, o tal vez una secuencia y funcionaría nextval()mejor?

Julien Portalier
fuente
No entiendo lo que quieres decir con "para una operación determinada" y de dónde vino "key2".
Trygve Laugstøl
2
Su estrategia de bloqueo se ve bien si desea un bloqueo pesimista, pero usaría pg_advisory_xact_lock para que todos los bloqueos se liberen automáticamente en COMMIT / ROLLBACK.
Trygve Laugstøl

Respuestas:

2

Suponiendo que almacene todas las revisiones del documento en una tabla, un enfoque sería no almacenar el número de revisión sino calcularlo en función del número de revisiones almacenadas en la tabla.

Es, esencialmente, un valor derivado , no algo que necesita almacenar.

Se puede usar una función de ventana para calcular el número de revisión, algo así como

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

y necesitará una columna similar change_datea la de realizar un seguimiento del orden de las revisiones.


Por otro lado, si solo tiene revisioncomo propiedad del documento e indica "cuántas veces ha cambiado el documento", entonces optaría por el enfoque de bloqueo optimista, algo como:

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

Si esto actualiza 0 filas, ha habido una actualización intermedia y debe informar al usuario de esto.


En general, trate de mantener su solución lo más simple posible. En este caso por

  • evitando el uso de funciones de bloqueo explícitas a menos que sea absolutamente necesario
  • tener menos objetos de base de datos (sin secuencias por documento) y almacenar menos atributos (no almacene la revisión si se puede calcular)
  • usando una sola updatedeclaración en lugar de una selectseguida de una insertoupdate
Colin 't Hart
fuente
De hecho, no necesito almacenar el valor cuando se puede calcular. ¡Gracias por recordarme!
Julien Portalier
2
En realidad, en mi contexto, las revisiones anteriores se eliminarán en algún momento, por lo que no puedo calcularlo o el número de revisión disminuiría :)
Julien Portalier
3

SEQUENCE garantiza que será único, y su caso de uso parece aplicable si su número de documentos no es demasiado alto (de lo contrario, tiene muchas secuencias para administrar). Use la cláusula RETURNING para obtener el valor generado por la secuencia. Por ejemplo, usando 'A36' como document_id:

  • Por documento, puede crear una secuencia para seguir el incremento.
  • La gestión de las secuencias deberá manejarse con cuidado. Tal vez podría mantener una tabla separada que contenga los nombres de los documentos y la secuencia asociada con eso document_idpara hacer referencia al insertar / actualizar la document_revisionstabla.

     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
fuente
Gracias por el formato deszo, no me di cuenta de lo mal que se veía cuando pegué mis comentarios.
bma
Una secuencia es un contador incorrecto si desea que el siguiente valor sea anterior + 1, ya que no se ejecutan dentro de la transacción.
Trygve Laugstøl
1
Eh? Las secuencias son atómicas. Por eso sugerí una secuencia por documento. Tampoco se garantiza que estén libres de espacios, ya que los retrocesos no disminuyen la secuencia después de que se ha incrementado. No digo que el bloqueo adecuado no sea una buena solución, solo que las secuencias presentan una alternativa.
bma
1
¡Gracias! Las secuencias son definitivamente el camino a seguir si necesito almacenar el número de revisión.
Julien Portalier
2
Tenga en cuenta que tener grandes cantidades de secuencias es un gran éxito en el rendimiento, ya que una secuencia es esencialmente una tabla con una fila. Puede leer más sobre eso aquí
Magnuss el
2

Esto a menudo se resuelve con un bloqueo optimista:

SELECT version, x FROM foo;

version | foo
    123 | ..

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

Si la actualización devuelve 0 filas actualizadas, ha perdido su actualización porque alguien más ya la actualizó.

Trygve Laugstøl
fuente
¡Gracias! ¡Esto es bueno cuando necesita mantener un contador de actualizaciones en un documento! Pero necesito un número de revisión único para cada fila en la tabla document_revisions, que no se actualizará, y debe ser el seguidor de la revisión anterior (es decir, el número de revisión de la fila anterior + 1).
Julien Portalier
1
Hm, ¿por qué no puedes usar esta técnica entonces? Este es el único método (aparte del bloqueo pesimista) que le dará una secuencia sin espacios.
Trygve Laugstøl
2

(Llegué a esta pregunta cuando intenté volver a descubrir un artículo sobre este tema. Ahora que lo he encontrado, lo estoy publicando aquí en caso de que otros busquen una opción alternativa a la respuesta elegida actualmente, haciendo una ventana con row_number())

Tengo este mismo caso de uso. Para cada registro insertado en un proyecto específico en nuestro SaaS, necesitamos un número incremental único que se pueda generar frente a mensajes concurrentes INSERTy que, idealmente, no tenga espacios.

Este artículo describe una buena solución , que resumiré aquí por facilidad y posteridad.

  1. Tenga una tabla separada que actúe como el contador para proporcionar el siguiente valor. Tendrá dos columnas, document_idy counter. counterserá DEFAULT 0Alternativamente, si ya tiene una documententidad que agrupa a todas las versiones, una counterpodrían añadirse allí.
  2. Agregue un BEFORE INSERTdisparador a la document_versionstabla que incremente atómicamente el contador ( UPDATE document_revision_counters SET counter = counter + 1 WHERE document_id = ? RETURNING counter) y luego se establezca NEW.versionen ese valor de contador.

Alternativamente, es posible que pueda usar un CTE para hacer esto en la capa de aplicación (aunque prefiero que sea un desencadenante por razones de coherencia):

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

Esto es similar en principio a cómo intentaba resolverlo inicialmente, excepto que al modificar una fila de contador en una sola declaración, bloquea las lecturas del valor obsoleto hasta que INSERTse confirma.

Aquí hay una transcripción que psqlmuestra esto en acción:

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)

Como puede ver, debe tener cuidado con cómo INSERTsucede, de ahí la versión de activación, que se ve así:

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

Eso hace que sea INSERTmucho más sencillo y la integridad de los datos más robusta frente a INSERTlos que se originan en fuentes arbitrarias:

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
fuente