Як зберегти унікальний лічильник у рядку за допомогою PostgreSQL?


10

Мені потрібно зберігати унікальний (за рядком) номер ревізії в таблиці document_reitions, де номер редакції присвоюється документу, тому він не є унікальним для всієї таблиці, лише для відповідного документа.

Я спочатку придумав щось на кшталт:

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, і СЕРІАЛ може бути визначений, або, можливо, послідовність і 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

Дійсно, мені не потрібно зберігати значення, коли воно може бути обчислено. Дякую, що нагадали!
Julien Portalier

2
Насправді, в моєму контексті, старі версії будуть видалені в якийсь момент, тому я не можу його обчислити, або номер редакції зменшиться :)
Julien Portalier

3

SEQUENCE гарантовано унікальний, і ваш випадок використання виглядає застосовно, якщо ваша кількість документів не надто велика (в іншому випадку у вас багато послідовностей для управління). Використовуйте пункт RETURNING, щоб отримати значення, сформоване послідовністю. Наприклад, використання "A36" як документа_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;

Дякую за форматування deszo, я не помітив, що як погано це виглядало, коли я вставляв свої коментарі.
bma

Послідовність є поганим лічильником, якщо ви хочете, щоб наступне значення було попереднім + 1, оскільки вони не виконуються в рамках транзакції.
Trygve Laugstøl

1
Так? Послідовності атомні. Ось чому я запропонував послідовність на документ. Вони також не гарантують, що вони будуть без пропусків, оскільки зворотні звороти не збільшують послідовність після її збільшення. Я не кажу, що правильне блокування не є хорошим рішенням, лише те, що послідовності є альтернативою.
bma

1
Дякую! Послідовності - це безумовно шлях, якщо мені потрібно зберегти номер редакції.
Julien Portalier

2
Зауважте, що наявність величезної кількості послідовностей є головним ударом по продуктивності, оскільки послідовність по суті є таблицею з одним рядком. Більше про це можна прочитати тут
Магнус

2

Це часто вирішується за допомогою оптимістичного блокування:

SELECT version, x FROM foo;

version | foo
    123 | ..

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

Якщо оновлення повертає 0 рядків оновленим, ви пропустили оновлення, оскільки хтось ще оновив рядок.


Дякую! Це добре, коли вам потрібно зберігати лічильник оновлень на документі! Але мені потрібен унікальний номер редакції для кожного рядка таблиці table_reitions, який не оновлюється, і повинен бути послідовником попереднього перегляду (тобто номер редакції попереднього рядка + 1).
Жульєн Портальє

1
Гм, чому тоді ти не можеш використовувати цю техніку? Це єдиний метод (окрім песимістичного блокування), який надасть вам послідовність без розривів.
Trygve Laugstøl

2

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

У мене є такий самий випадок використання. Для кожного запису, вставленого в конкретний проект нашого SaaS, нам потрібен унікальний приріст числа, який може бути згенерований перед обличчям одночасних INSERTs і в ідеалі має безліч.

У цій статті описано приємне рішення , яке я підсумую тут для зручності та нащадків.

  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 набагато більш прямим вперед і цілісність даних більш надійною перед INSERTособами, що походять з довільних джерел:

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)
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.