Транзакции, конкуренция и устойчивый повтор
Транзакция ограничивает атомарность своим хранилищем. Потерянный 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 |
|---|---|---|
| 1 | BEGIN; вызвать новый op-3 с актуальной version | — |
| 2 | Оставить транзакцию открытой | BEGIN; вызвать тот же op-3; ожидает lock |
| 3 | COMMIT | Получает сохранённый ответ; 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.