Como manter um contador único por linha com o PostgreSQL?

10

Preciso manter um número de revisão exclusivo (por linha) em uma tabela document_revisions, em que o número da revisão está no escopo de um documento, para que não seja exclusivo da tabela inteira, apenas do documento relacionado.

Inicialmente, criei algo como:

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

Mas há uma condição de corrida!

Estou tentando resolvê-lo pg_advisory_lock, mas a documentação é um pouco escassa e não a entendo completamente, e não quero bloquear algo por engano.

O seguinte é aceitável, ou estou fazendo errado, ou existe uma solução melhor?

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

Não devo bloquear a linha do documento (chave1) para uma determinada operação (chave2)? Portanto, essa seria a solução adequada:

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;

Talvez eu não esteja acostumado ao PostgreSQL e um SERIAL possa ter um escopo definido, ou talvez uma sequência e nextval()faça o trabalho melhor?

Julien Portalier
fonte
Não entendo o que você quer dizer com "para uma determinada operação" e de onde veio a "chave2".
Trygve Laugstøl 8/08/13
2
Sua estratégia de bloqueio parece boa se você desejar um bloqueio pessimista, mas eu usaria pg_advisory_xact_lock para que todos os bloqueios sejam liberados automaticamente no COMMIT / ROLLBACK.
Trygve Laugstøl 8/08/13

Respostas:

2

Supondo que você armazene todas as revisões do documento em uma tabela, uma abordagem seria não armazenar o número da revisão, mas calculá-lo com base no número de revisões armazenadas na tabela.

É, essencialmente, um valor derivado , não algo que você precisa armazenar.

Uma função da janela pode ser usada para calcular o número da revisão, algo como

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

e você precisará de uma coluna change_datepara acompanhar a ordem das revisões.


Por outro lado, se você tiver apenas revisionuma propriedade do documento e indicar "quantas vezes o documento foi alterado", eu adotaria a abordagem de bloqueio otimista, algo como:

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

Se isso atualizar 0 linhas, houve uma atualização intermediária e você precisará informar o usuário sobre isso.


Em geral, tente manter sua solução o mais simples possível. Nesse caso, por

  • evitando o uso de funções de bloqueio explícitas, a menos que seja absolutamente necessário
  • ter menos objetos de banco de dados (não por sequências de documentos) e armazenar menos atributos (não armazene a revisão se puder ser calculada)
  • usando uma única updateinstrução em vez de uma selectseguida por uma insertouupdate
Colin 't Hart
fonte
Na verdade, não preciso armazenar o valor quando ele puder ser calculado. Obrigado por me lembrar!
Julien Portalier
2
Na verdade, no meu contexto, as revisões mais antigos serão apagados em algum momento, por isso não pode calcular-lo ou o número de revisão diminuiria :)
Julien Portalier
3

É garantido que o SEQUENCE seja exclusivo e seu caso de uso parecerá aplicável se o número de documentos não for muito alto (caso contrário, você terá muitas sequências para gerenciar). Use a cláusula RETURNING para obter o valor que foi gerado pela sequência. Por exemplo, usando 'A36' como um document_id:

  • Por documento, você pode criar uma sequência para acompanhar o incremento.
  • O gerenciamento das seqüências precisará ser tratado com algum cuidado. Talvez você possa manter uma tabela separada contendo os nomes dos documentos e a sequência associada a isso document_idpara referência ao inserir / atualizar a document_revisionstabela.

     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
fonte
Obrigado pela formatação deszo, eu não percebi o quão ruim parecia quando colei nos meus comentários.
BMA
Uma sequência é um contador incorreto se você desejar que o próximo valor seja + 1 anterior, pois ele não é executado na transação.
Trygve Laugstøl 8/08/13
11
Eh? Sequências são atômicas. Por isso, sugeri uma sequência por documento. Também não é garantido que eles não apresentem espaços, pois as reversões não diminuem a sequência após o incremento. Não estou dizendo que o bloqueio adequado não é uma boa solução, apenas que as seqüências apresentam uma alternativa.
BMA
11
Obrigado! Definitivamente, as sequências são o caminho a percorrer se eu precisar armazenar o número da revisão.
Julien Portalier
2
Observe que ter grandes quantidades de sequências é um grande impacto no desempenho, pois uma sequência é essencialmente uma tabela com uma linha. Você pode ler mais sobre isso aqui
Magnuss
2

Isso geralmente é resolvido com o bloqueio otimista:

SELECT version, x FROM foo;

version | foo
    123 | ..

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

Se a atualização retornar 0 linhas atualizadas, você perdeu a atualização porque outra pessoa já atualiza a linha.

Trygve Laugstøl
fonte
Obrigado! Isso é bom quando você precisa manter um contador de atualizações em um documento! Mas preciso de um número de revisão exclusivo para cada linha da tabela document_revisions, que não será atualizada e deve ser o seguidor da revisão anterior (ou seja, o número de revisão da linha anterior + 1).
Julien Portalier
11
Hum, por que você não pode usar essa técnica então? Este é o único método (exceto bloqueio pessimista) que fornecerá uma sequência sem espaços.
Trygve Laugstøl
2

(Cheguei a essa pergunta ao tentar redescobrir um artigo sobre esse tópico. Agora que o encontrei, estou postando aqui caso outras pessoas procurem uma opção alternativa para a resposta atualmente escolhida - dando um row_number())

Eu tenho esse mesmo caso de uso. Para cada registro inserido em um projeto específico em nosso SaaS, precisamos de um número único e incremental que possa ser gerado em face de INSERTs concorrentes e seja idealmente contínuo.

Este artigo descreve uma boa solução , que resumirei aqui para facilitar e posterizar.

  1. Tenha uma tabela separada que atue como contador para fornecer o próximo valor. Ele terá duas colunas document_ide counter. counterserá DEFAULT 0Alternativamente, se você já tiver uma documententidade que agrupa todas as versões, um counterpoderia ser acrescentadas.
  2. Adicione um BEFORE INSERTgatilho à document_versionstabela que incrementa atomicamente o contador ( UPDATE document_revision_counters SET counter = counter + 1 WHERE document_id = ? RETURNING counter) e depois define NEW.versionesse valor do contador.

Como alternativa, você pode usar um CTE para fazer isso na camada de aplicativo (embora eu prefira que seja um gatilho por uma questão de consistência):

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

Isso é semelhante em princípio a como você estava tentando resolvê-lo inicialmente, exceto que, modificando uma linha do contador em uma única instrução, ele bloqueia as leituras do valor obsoleto até que o mesmo INSERTseja confirmado.

Aqui está uma transcrição psqlmostrando isso em ação:

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 você pode ver, você precisa ter cuidado com o que INSERTacontece, daí a versão do acionador, que se parece com isso:

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

Isso torna INSERTs muito mais direto e a integridade dos dados mais robusta diante de INSERTs originários de fontes arbitrárias:

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
fonte