Учебник веб-разработки
Разделы учебника
На этой странице

VIII. Системы хранения

Транзакции, конкуренция и устойчивый повтор

Оглавление · Идемпотентность

Транзакция ограничивает атомарность своим хранилищем. Потерянный HTTP-ответ не отменяет COMMIT; повтор должен найти тот же результат, а не повторить эффект. Ниже отдельная учебная схема PostgreSQL 17, не внутренние таблицы Strapi. Нужна пустая учебная база и роль с правом создания схемы. Не запускайте на CMS.

Атомарная операция с журналом результатов

Ключ имеет область «пользователь + операция update + key». Сравниваем нормализованные аргументы через JSONB: порядок полей не важен, а другой title/version — конфликт ключа. Сохранённый ответ возвращается даже после рестарта приложения.

CREATE SCHEMA lesson_tx;
CREATE TABLE lesson_tx.publication (
  id integer PRIMARY KEY,
  title text NOT NULL CHECK (length(btrim(title)) BETWEEN 1 AND 120),
  version integer NOT NULL CHECK (version > 0)
);
CREATE TABLE lesson_tx.operation (
  principal text NOT NULL,
  key text NOT NULL,
  request jsonb NOT NULL,
  response jsonb NOT NULL,
  PRIMARY KEY (principal, key)
);
INSERT INTO lesson_tx.publication VALUES (1, 'Урок', 1);

CREATE FUNCTION lesson_tx.update_once(
  actor text, operation_key text, publication_id integer,
  new_title text, expected_version integer
) RETURNS jsonb LANGUAGE plpgsql AS $$
DECLARE
  input jsonb;
  previous lesson_tx.operation%ROWTYPE;
  changed lesson_tx.publication%ROWTYPE;
  output jsonb;
BEGIN
  IF actor IS NULL OR btrim(actor) = '' OR operation_key IS NULL
     OR length(operation_key) NOT BETWEEN 1 AND 120
     OR publication_id IS NULL OR publication_id < 1
     OR expected_version IS NULL OR expected_version < 1
     OR new_title IS NULL OR length(btrim(new_title)) NOT BETWEEN 1 AND 120 THEN
    RAISE EXCEPTION 'BAD_REQUEST';
  END IF;
  input := jsonb_build_object('id', publication_id, 'title', btrim(new_title), 'version', expected_version);
  PERFORM pg_advisory_xact_lock(hashtextextended(jsonb_build_array(actor, 'update', operation_key)::text, 0));
  SELECT * INTO previous FROM lesson_tx.operation WHERE principal = actor AND key = operation_key;
  IF FOUND THEN
    IF previous.request <> input THEN RAISE EXCEPTION 'KEY_CONFLICT'; END IF;
    RETURN previous.response;
  END IF;
  UPDATE lesson_tx.publication SET title = btrim(new_title), version = version + 1
    WHERE id = publication_id AND version = expected_version RETURNING * INTO changed;
  IF NOT FOUND THEN RAISE EXCEPTION 'MISSING_OR_VERSION_CONFLICT'; END IF;
  output := to_jsonb(changed);
  INSERT INTO lesson_tx.operation VALUES (actor, operation_key, input, output);
  RETURN output;
END;
$$;
SELECT lesson_tx.update_once('editor-demo', 'op-1', 1, 'Новый урок', 1);
SELECT lesson_tx.update_once('editor-demo', 'op-1', 1, 'Новый урок', 1);
SELECT version FROM lesson_tx.publication WHERE id = 1;

Оба ответа имеют version 2, в таблице тоже 2. Advisory lock принадлежит транзакции и освобождается при её завершении. Коллизия hash только добавит сериализацию независимых ключей, идентичность проверяет PRIMARY KEY. Все вызывающие операции должны соблюдать этот протокол. Функция не является проверкой авторизации: actor приходит из доверенного server context, права проверяются до вызова. Прямые изменения таблиц должны быть ограничены ролями. У функции обычные invoker-права; не добавляйте SECURITY DEFINER без отдельного анализа.

Неуспешная запись и потерянный ответ

В следующем блоке BEGIN/ROLLBACK моделирует обрыв до COMMIT:

BEGIN;
SELECT lesson_tx.update_once('editor-demo', 'op-2', 1, 'Проба', 2);
ROLLBACK;
SELECT version FROM lesson_tx.publication WHERE id = 1;
SELECT count(*) FROM lesson_tx.operation WHERE key = 'op-2';

Ожидаем version 2 и count 0. Затем вызовите op-2 заново без rollback: version 3. Отбросьте его ответ и повторите тот же запрос — получите сохранённую version 3. Повторите op-2 с другим title: KEY_CONFLICT и без новой записи. Отдельно проверьте новый ключ со старой version: конфликт версии, журнал не дополняется. Не используйте исключение этой функции как готовую HTTP-классификацию: production-контракт различает отсутствие, права и конфликт согласно своей политике.

Две сессии PostgreSQL

Откройте два psql к одной учебной базе. Сначала используйте Read Committed. Установите небольшой lock_timeout, чтобы зависшая учебная сессия не ждала бесконечно.

ШагСессия AСессия B
1BEGIN; вызвать новый op-3 с актуальной version—
2Оставить транзакцию открытойBEGIN; вызвать тот же op-3; ожидает lock
3COMMITПолучает сохранённый ответ; COMMIT
4Проверить одну новую versionПроверить один journal row для op-3

Повторите с ROLLBACK в A: B выполнит операцию самостоятельно. Повторите с другим key и одинаковой version: победит одно обновление, второе после ожидания строки не найдёт совпадающую version. При Repeatable Read/Serializable возможна ошибка сериализации/уникальности вместо сохранённого ответа; нужен ограниченный повтор всей транзакции со свежим snapshot. Не повторяйте только последний SQL внутри уже aborted transaction.

Исходник схемы
sequenceDiagram
  participant A as Запрос A
  participant B as Повтор B
  participant DB as База
  A->>DB: Lock key, UPDATE и journal
  B->>DB: Тот же lock, ожидание
  A->>DB: COMMIT
  DB-->>B: Сохранённый результат
  Note over A: Ответ A может потеряться

Где заканчивается гарантия

Журнал и публикация фиксируются одной транзакцией. Отправка письма, S3 PUT или платёж не входят в эту атомарность. Для передачи эффекта из БД можно сохранить outbox intent в той же транзакции; отправитель повторяет доставку, получатель дедуплицирует по своему контракту. Запись «отправлено» после сетевого ответа всё равно оставляет окно неопределённости. Это не универсальное exactly-once.

Срок хранения ключа — часть API. После удаления journal повтор может стать новой операцией; храните результат достаточно долго для заявленного retry horizon. Размер журнала, очистка, timeout, auth и восстановление требуют отдельной политики.

Приёмка: SQL результата, version и число journal rows для успеха, повтора, конфликта payload, rollback, потерянного ответа и двух сессий. Однопроцессный embedded PostgreSQL может проверить SQL и rollback, но не блокировки двух сетевых сессий, падение сервера и recovery WAL. Для этих опытов нужен настоящий изолированный PostgreSQL 17; успешный разбор схемы их не заменяет.

Источники: изоляция PostgreSQL 17, advisory locks.