Как сохранить уникальный счетчик на строку с PostgreSQL?

10

Мне нужно сохранить уникальный (для каждой строки) номер редакции в таблице document_revisions, где номер редакции ограничен документом, поэтому он не уникален для всей таблицы, только для соответствующего документа.

Я изначально придумал что-то вроде:

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

Но есть условие гонки!

Я пытаюсь решить это с помощью pg_advisory_lock, но документации немного, и я не до конца понимаю, и я не хочу что-то блокировать по ошибке.

Допустимо ли следующее, или я делаю это неправильно, или есть лучшее решение?

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

Не должен ли я вместо этого заблокировать строку документа (key1) для данной операции (key2)? Так что это будет правильное решение:

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;

Может быть, я не привык к PostgreSQL, и SERIAL может быть ограничен, или, может быть, последовательность и nextval()сделает работу лучше?

Жюльен Порталье
источник
Я не понимаю, что вы имеете в виду «для данной операции» и откуда взялась «key2».
Trygve Laugstøl
2
Ваша стратегия блокировки выглядит нормально, если вы хотите пессимистическую блокировку, но я бы использовал pg_advisory_xact_lock, чтобы все блокировки автоматически снимались при COMMIT / ROLLBACK.
Trygve Laugstøl

Ответы:

2

Предполагая, что вы сохраняете все редакции документа в таблице, можно было бы не хранить номер редакции, а рассчитать его на основе количества редакций, хранящихся в таблице.

По сути, это производное значение, а не то, что вам нужно хранить.

Функция окна может быть использована для расчета номера ревизии, что-то вроде

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

и вам понадобится колонка, похожая change_dateна то, чтобы отслеживать порядок изменений.


С другой стороны, если у вас просто есть revisionсвойство документа и оно указывает «сколько раз документ изменялся», то я бы выбрал оптимистический подход к блокировке, что-то вроде:

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

Если это обновляет 0 строк, то произошло промежуточное обновление, и вам необходимо сообщить об этом пользователю.


В общем, постарайтесь сделать ваше решение максимально простым. В этом случае

  • избегать использования явных функций блокировки, если это не является абсолютно необходимым
  • иметь меньше объектов базы данных (нет на последовательность документов) и хранить меньше атрибутов (не сохраняйте ревизию, если она может быть рассчитана)
  • используя одно updateутверждение, а не с selectпоследующим insertилиupdate
Колин т Харт
источник
Действительно, мне не нужно хранить значение, когда оно может быть вычислено. Спасибо за напоминание!
Жюльен Порталье
2
На самом деле, в моем контексте, старые версии будут удалены в какой-то момент, поэтому я не могу их вычислить, иначе номер редакции уменьшится :)
Julien Portalier
3

SEQUENCE гарантированно будет уникальным, и ваш сценарий использования будет выглядеть подходящим, если количество ваших документов не слишком велико (иначе вам нужно управлять множеством последовательностей). Используйте предложение RETURNING, чтобы получить значение, сгенерированное последовательностью. Например, используя «A36» в качестве document_id:

  • Для каждого документа вы можете создать последовательность для отслеживания приращения.
  • Управлять последовательностями нужно будет с осторожностью. Возможно, вы могли бы сохранить отдельную таблицу, содержащую имена документов и связанную с ними последовательность, document_idдля ссылки при вставке / обновлении document_revisionsтаблицы.

     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
источник
Спасибо за форматирование десо, я не заметил, как плохо это выглядело, когда я вставил свои комментарии.
BMA
Последовательность является плохим счетчиком, если вы хотите, чтобы следующее значение было предыдущим + 1, поскольку они не выполняются в транзакции.
Trygve Laugstøl
1
А? Последовательности атомарны. Вот почему я предложил последовательность для каждого документа. Они также не гарантируют отсутствие пробелов, поскольку откаты не уменьшают последовательность после ее увеличения. Я не говорю, что правильная блокировка не является хорошим решением, только то, что последовательности представляют альтернативу.
BMA
1
Спасибо! Последовательности, безусловно, способ пойти, если мне нужно сохранить номер ревизии.
Жюльен Порталье
2
Обратите внимание, что огромное количество последовательностей является основным ударом по производительности, поскольку последовательность - это, по сути, таблица с одной строкой. Вы можете прочитать больше об этом здесь
Magnuss
2

Это часто решается с оптимистической блокировкой:

SELECT version, x FROM foo;

version | foo
    123 | ..

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

Если обновление возвращает 0 обновленных строк, вы пропустили обновление, потому что кто-то уже обновил строку.

Trygve Laugstøl
источник
Спасибо! Это хорошо, когда вам нужно вести счетчик обновлений в документе! Но мне нужен уникальный номер редакции для каждой строки в таблице document_revisions, который не будет обновляться и должен быть последователем предыдущей редакции (т. Е. Номер редакции предыдущей строки + 1).
Жюльен Порталье
1
Хм, почему ты не можешь использовать эту технику тогда? Это единственный метод (кроме пессимистической блокировки), который даст вам последовательность без пропусков.
Trygve Laugstøl
2

(Я пришел к этому вопросу, когда пытался заново открыть статью на эту тему. Теперь, когда я нашел ее, я публикую ее здесь на тот случай, если другие будут искать альтернативный вариант для выбранного в данный момент ответа - окно с row_number())

У меня такой же вариант использования. Для каждой записи, вставленной в конкретный проект в нашем SaaS, нам нужен уникальный, увеличивающийся номер, который может быть сгенерирован перед лицом одновременных операций INSERTи в идеале не имеет пробелов.

В этой статье описывается хорошее решение , которое я приведу здесь для простоты и потомства.

  1. Имейте отдельную таблицу, которая действует как счетчик для предоставления следующего значения. У него будет две колонки, document_idи counter. counterбудет DEFAULT 0вариант, если у вас уже есть documentобъект , который группирует все версии, counterмогут быть там добавлены.
  2. Добавьте BEFORE INSERTк document_versionsтаблице триггер, который атомарно увеличивает счетчик ( UPDATE document_revision_counters SET counter = counter + 1 WHERE document_id = ? RETURNING counter), а затем устанавливает NEW.versionзначение этого счетчика.

В качестве альтернативы вы можете использовать CTE для этого на прикладном уровне (хотя я предпочитаю, чтобы он был триггером для согласованности):

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

В принципе это похоже на то, как вы пытались решить его изначально, за исключением того, что, изменяя строку счетчика в одном операторе, он блокирует чтение устаревшего значения до тех пор, пока не INSERTбудет зафиксировано.

Вот расшифровка стенограммы, psqlпоказывающая это в действии:

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)

Как вы можете видеть, вы должны быть осторожны INSERTс тем, как это происходит, отсюда и версия триггера, которая выглядит так:

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

Это делает INSERTs намного более прямым и целостность данных более надежной перед лицом INSERTs, происходящим из произвольных источников:

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)
Бо Жанес
источник