Реклама
Перетяжка // Коробка 3.0

Идемпотентность и Outbox: как не выполнить одну операцию дважды

Повторный запрос придёт обязательно: клиент не отличает потерянный ответ от невыполненной операции. Разбираем три способа защитить ручку и паттерн, который связывает базу с очередью.

Обложка: Идемпотентность и Outbox: как не выполнить одну операцию дважды

История, с которой начинает автор одного из разборов ниже, звучит буднично. Платёжный шлюз не ответил вовремя, клиентская библиотека повторила запрос, и покупателя списали дважды. Сумма небольшая, а возврат, тикет в поддержку и восстановление доверия заняли недели.

Виноват тут не шлюз. Виноват сервер, который исходил из того, что каждый запрос приходит ровно один раз. В сети, где теряются ответы, это допущение неверно всегда.

Идемпотентность — свойство операции, при котором повторное выполнение приводит к тому же состоянию, что и однократное. Сам ответ при этом может отличаться: повторный DELETE вернёт 404 вместо 200, и метод от этого идемпотентным быть не перестаёт. У HTTP это закреплено на уровне методов: RFC 9110 относит к идемпотентным PUT, DELETE и безопасные методы, а POST идемпотентным по своей природе не является. Бизнес-операции почти всегда отправляют именно через POST.

Ключевые выводы

Клиент не может отличить «сервер не получил запрос» от «сервер выполнил операцию, но ответ потерялся», поэтому повтор придёт в любом случае.

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

Проверка «посмотреть и вставить» неатомарна: два одновременных повтора оба увидят отсутствие ключа. Нужен уникальный индекс или условная запись.

Запись в базу и отправка в очередь в одну транзакцию не помещаются. Обёртывать сетевой вызов в BEGIN и COMMIT вредно вдвойне: корректности не даёт, а пул соединений выедает.

Transactional outbox решает это одной таблицей: событие пишется в той же транзакции, что и бизнес-запись, а отдельный процесс доставляет его в очередь.

Почему повтор неизбежен

Сеть отказывает так, что установить факт доставки невозможно в принципе. Есть три типовых сценария, и внешне они неразличимы:

  • Сервер обработал запрос, но ответ потерялся по дороге назад.
  • Сервер всё ещё считает, а у клиента уже сработал таймаут.
  • Балансировщик повторил запрос сам, никого об этом не уведомив.

Для GET повтор безвреден. Для POST /payments это второе списание. Отсюда и правило: одна логическая операция должна приводить к одному записанному итоговому состоянию, сколько бы раз её ни отправили. Осознанно новая операция обязана прийти с новым ключом.

Два способа сделать ручку идемпотентной и один в довесок

Способ первый: ключ идемпотентности

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

			_store = {}   # в продакшене это Redis или Postgres, не память процесса

def process_payment(payment_id, amount, idempotency_key):
    if idempotency_key in _store:
        return _store[idempotency_key]      # проигрываем сохранённый ответ

    result = charge_card(payment_id, amount)
    _store[idempotency_key] = result
    return result


key = uuid.uuid4().hex                      # генерируется один раз
process_payment("pmt_123", 49.99, key)      # первая попытка
process_payment("pmt_123", 49.99, key)      # повтор, вернётся тот же ответ
		

Три детали, на которых ошибаются чаще всего. Ключ генерирует клиент, а не сервер: смысл в том, что сервер сам по себе не отличит новый запрос от повтора. Ключ должен быть с высокой энтропией, обычно это UUID v4; выводить его из изменяемого или малоразнообразного поля вроде номера клиента нельзя, потому что это повышает риск коллизии и переигрывания чужой операции.

И последнее: повтор должен получить тот же самый ответ. Если первая попытка вернула 201 с идентификатором платежа, то и повтор возвращает те же 201 и тот же идентификатор, а не 409 и не пустую двухсотку. Иначе клиент решит, что операция не прошла, и попробует ещё раз.

Способ второй: сделать операцию идемпотентной по смыслу

Иногда ключ не нужен вовсе, потому что семантику можно спроектировать идемпотентной с самого начала. Классический пример — PUT /users/42, который задаёт полное представление ресурса: отправьте его дважды, и состояние будет одним и тем же.

Для операций создания трюк в том, чтобы выводить идентификатор из самого запроса, а не генерировать случайный на каждый вызов:

			def create_user(email, name):
    user_id = "user_" + hashlib.sha256(email.encode()).hexdigest()[:16]
    # INSERT ... ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name
    return {"id": user_id, "email": email, "name": name}
		

Идентификатор детерминированно выводится из почты, поэтому повторный запрос даёт того же пользователя без дублирующей строки. Плата за простоту — необходимость естественного уникального ключа: почты, артикула, внешнего идентификатора. Хеш при этом считается от точной строки, поэтому почту нужно привести к нижнему регистру и обрезать пробелы до хеширования: иначе один и тот же ящик с заглавной буквы даст второго пользователя. И ключ должен быть неизменным: если пользователь сменит почту, выведенный из неё идентификатор либо останется прежним и перестанет соответствовать данным, либо изменится и оторвётся от всех ссылок на него в соседних таблицах. Нет подходящего ключа, возвращайтесь к первому способу.

Довесок: оптимистичная блокировка

Этот приём идемпотентности не даёт и в списке стоит по другой причине. Ключ защищает от повторов одного и того же запроса, но ничего не делает с двумя разными запросами, которые правят одну запись. Здесь работает проверка версии: клиент присылает версию, которую видел, а сервер отклоняет обновление при несовпадении.

			def update_article(article_id, body, expected_version):
    # проверка версии обязана быть частью самой записи, а не отдельным чтением
    rowcount = db.execute(
        "UPDATE articles SET body = %s, version = version + 1 "
        "WHERE id = %s AND version = %s",
        [body, article_id, expected_version],
    )
    if rowcount == 0:
        current = db.get(article_id)
        return {"error": "conflict", "current_version": current["version"]}, 409
    return {"version": expected_version + 1}, 200
		

Обратите внимание, что проверка версии живёт внутри самого UPDATE. Разнести её на отдельное чтение и последующую запись означало бы воспроизвести ровно ту гонку, о которой пойдёт речь в следующем разделе: два запроса с одной устаревшей версией оба прошли бы проверку и оба записали бы результат. Идемпотентным одиночный запрос этот приём не делает, зато закрывает частый источник задвоенных эффектов: двух писателей, затирающих работу друг друга.

Где наивная реализация ключа разваливается

Гонка в проверке

Последовательность «проверить наличие ключа, потом вставить» содержит зазор, в который помещаются оба одновременных повтора: обе стороны видят, что ключа нет, и обе выполняют работу. Резервировать ключ нужно атомарно, через уникальное ограничение базы, условную запись или транзакционный compare-and-set (атомарное сравнение с записью).

Это же относится и к примеру с платежом выше: замена словаря в памяти на Redis гонку не закрывает, пока проверка и вставка остаются двумя отдельными командами. Нужен атомарный примитив резервирования, вроде SET key value NX или INSERT ... ON CONFLICT DO NOTHING.

Проверяется это только настоящей конкуренцией. Юнит-тест, вызывающий функцию дважды подряд, гонку не поймает никогда. Отправьте полсотни одновременных запросов с одним ключом и убедитесь, что побочный эффект произошёл ровно один раз.

Слепок запроса, а не только ключ

Хранить один ключ недостаточно. Сервер канонизирует поля, определяющие бизнес-смысл операции, и считает от них хеш. Канонизация означает приведение к единому виду порядка полей, форматов чисел и дат, опущенных значений по умолчанию и незначащих пробелов. Если повторный вызов обязан воспроизвести решение, принятое по прежним правилам, в слепок включают ещё и версию политики. Пример: между первой попыткой и повтором поменялись тарифы, и повтор обязан вернуть старую цену, а не пересчитать по новой. Без версии в слепке сервер этого различия не увидит.

Когда тот же ключ приходит с другим слепком, это ошибка на стороне клиента, и отдавать ему старый результат нельзя. Разбор проектирования идемпотентных ручек предлагает отвечать 409 Conflict; автор практического руководства в этом случае возвращает 422. Важно, чтобы выбранный код был задокументирован и никогда не подменяется молчаливой отдачей чужого ответа.

Состояния и коды ответа

Минимальная модель состояний записи выглядит так: PROCESSING, SUCCEEDED, FAILED_RETRYABLE и FAILED_FINAL. Рядом хранятся ключ операции, слепок, отметки времени, идентификатор решения, снимок ответа и версия политики. Клиенту нужна детерминированная карта из состояния в код ответа:

  • 201 или 200 — первый успешно завершённый результат.
  • 200 с явной пометкой о повторе — проигрывание сохранённого ответа.
  • 202 со ссылкой на статус — работу уже выполняет другой обработчик, начинать вторую не нужно.
  • 409 — ключ переиспользован с другим содержимым.
  • Задокументированная финальная ошибка — обработка провалилась, и автоматическое продолжение небезопасно.
Срок жизни ключей:
Хранить их вечно не нужно, это медленная утечка. Сутки покрывают практически любое реальное окно повторов, но выбирать срок стоит от риска предметной области: у платежей и у рекомендаций он разный. И помните, что ключ приходит снаружи: ограничьте длину и набор символов, привяжите его к арендатору и не дайте одному пользователю вытащить результат чужой операции.

Вторая половина задачи: база и очередь

Допустим, ручка стала идемпотентной. Остаётся более коварная проблема: почти всякая бизнес-операция пишет не в одно место. Заказ сохраняется в базу и публикует событие в очередь, чтобы склад начал сборку, почтовый сервис отправил подтверждение, а антифрод посмотрел на транзакцию.

Обе половины статьи растут из одного факта: ни HTTP, ни очередь не обещают доставку ровно один раз, они обещают её хотя бы один раз. Поэтому защищаться приходится дважды, на входе и на выходе.

Это две записи в две разные системы, и общей транзакции у них нет. Упал процесс, моргнула сеть, выкатился деплой между двумя вызовами — одна сторона зафиксирована, вторая нет. Заказ подтверждён на экране покупателя, а склад о нём не знает. Ошибка при этом нигде не залогирована и алерт не сработал.

Почему обернуть это в транзакцию нельзя

Соблазнительный и заведомо неверный вариант выглядит так:

			// ТАК ДЕЛАТЬ НЕЛЬЗЯ
const client = await pool.connect();
await client.query('BEGIN');
await client.query('INSERT INTO orders (customer_id, amount_cents) VALUES ($1, $2)', [customerId, amountCents]);
await sqs.send(new SendMessageCommand({ QueueUrl: QUEUE_URL, MessageBody: body }));
await client.query('COMMIT');
		

Транзакция базы не имеет власти над очередью и умеет откатывать только операции базы. Если отправка прошла, а COMMIT упал, сообщение уже в очереди и забрать его оттуда нельзя.

Есть и вторая беда, чисто эксплуатационная. Такой код держит открытое соединение и блокировки строк всё время сетевого вызова. Обычно очередь отвечает быстро, но под нагрузкой, ретраями или деградацией вызов растягивается на секунды, и все запросы к тем же строкам стоят в очереди. Это надёжный способ исчерпать пул соединений и уронить заодно ни в чём не повинные части сервиса.

Outbox: событие как строка в той же транзакции

Идея паттерна в том, чтобы перестать считать публикацию второй записью. Вместо вызова очереди приложение вставляет строку в таблицу outbox в той же транзакции, что и бизнес-запись. Отдельный процесс читает таблицу и публикует сообщения дальше.

			CREATE TABLE outbox (
  id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  event_type TEXT NOT NULL,
  payload    JSONB NOT NULL,
  status     TEXT NOT NULL DEFAULT 'pending',
  created_at TIMESTAMPTZ DEFAULT now(),
  sent_at    TIMESTAMPTZ
);

CREATE INDEX ON outbox (status, created_at) WHERE status = 'pending';
		

Откатилась транзакция, и вместе с ней исчезла строка outbox: осиротевшего сообщения в очереди не осталось. Упало приложение сразу после фиксации — строка на месте со статусом pending, и доставщик заберёт её на следующем проходе. Паттерн опирается ровно на одну гарантию, которую база и так даёт: атомарность одной транзакции.

Доставщик выбирает пачку необработанных строк и помечает их отправленными только после подтверждения очередью. Ключевая деталь здесь одна:

			SELECT * FROM outbox
WHERE status = 'pending'
ORDER BY created_at
LIMIT 10
FOR UPDATE SKIP LOCKED;   -- два доставщика не возьмут одну строку
		

Этот SELECT и последующая простановка статуса выполняются в одной транзакции: блокировка живёт до фиксации. Благодаря SKIP LOCKED доставщик масштабируется горизонтально из коробки, каждый экземпляр берёт свой набор строк. А если он упадёт посреди пачки, вся пачка откатится и уйдёт повторно, что даёт ещё один довод в пользу идемпотентного потребителя.

Может показаться, что здесь мы делаем ровно то, что осудили выше: держим блокировку на время сетевого вызова. Разница в том, что блокируется не бизнес-таблица и соединение берётся не из пула, обслуживающего пользовательские запросы, а SKIP LOCKED не даёт одной медленной строке задержать остальные.

Потребитель обязан быть идемпотентным

Если доставщик упал посреди прохода, на следующем он переотправит те же строки. Очереди вроде SQS и Kafka в типовой конфигурации гарантируют доставку «хотя бы один раз», поэтому дедупликация на приёмной стороне обязательна, и уникальным ключом служит идентификатор события или решения.

Делает это условная запись. Важная деталь, на которой легко ошибиться: сама по себе она пустой операцией не становится. При несовпадении условия драйвер бросает исключение, и его нужно поймать явно, отличив штатный повтор от настоящей ошибки:

			try {
  await dynamoClient.send(new PutItemCommand({
    TableName: 'fulfillments',
    Item: { orderId: { S: event.orderId }, /* ... */ },
    ConditionExpression: 'attribute_not_exists(orderId)',
  }));
} catch (err) {
  // запись уже есть — это и есть штатный повтор, остальные ошибки пробрасываем
  if (err.name !== 'ConditionalCheckFailedException') throw err;
}
		

Настройки продюсера и подтверждения брокера снижают количество повторов, но не снимают с приложения ответственность за однократность бизнес-эффекта.

Что добавить перед продакшеном

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

Когда задержка опроса становится критичной, полагающийся на периодический опрос доставщик заменяют захватом изменений: инструменты вроде Debezium читают журнал предзаписи PostgreSQL и публикуют изменения без паузы на опрос. Таблица outbox и потребитель при этом не меняются. Но это заметно более тяжёлое эксплуатационное обязательство, поэтому начинать почти всегда стоит с опроса.

Чеклист: привести ручку в порядок
  1. 01
    Найдите ручки с побочными эффектами

    Любой обработчик, который списывает деньги, создаёт сущность или шлёт письмо, обязан переживать повтор. Начните с них, а не со списка всех ручек.

  2. 02
    Введите ключ и слепок

    Принимайте ключ от клиента, храните рядом канонизированный хеш содержимого. Совпали ключ и слепок, отдайте сохранённый ответ; совпал только ключ — верните ошибку по задокументированному коду.

  3. 03
    Сделайте резервирование атомарным

    Замените «проверить и вставить» на уникальный индекс или условную запись. Затем прогоните полсотни параллельных запросов с одним ключом и убедитесь, что эффект случился один раз.

  4. 04
    Проверьте, что повтор отдаёт тот же ответ

    Повторный вызов должен вернуть исходный код и исходный идентификатор. Ответ 409 на честный сетевой ретрай заставит клиента повторять дальше.

  5. 05
    Найдите места с двумя записями

    Пройдитесь по коду в поисках связки «записали в базу, потом отправили в очередь». Каждая такая пара это будущее расхождение данных.

  6. 06
    Заведите таблицу outbox

    Пишите событие в той же транзакции, доставляйте отдельным процессом с выборкой через FOR UPDATE SKIP LOCKED, добавьте статус ошибки и счётчик попыток.

  7. 07
    Сделайте потребителя идемпотентным

    Дедуплицируйте по идентификатору события условной записью или уникальным ключом. Доставка «хотя бы один раз» означает, что дубль придёт обязательно.

Часто задаваемые вопросы
1
Что такое идемпотентность простыми словами?

Это свойство операции, при котором повторное выполнение даёт тот же результат, что и однократное: неважно, вызвали её один раз или пять. Удаление файла идемпотентно, файла как не было, так и нет. Списание денег не идемпотентно: каждый вызов уменьшает баланс заново, если специально не защититься. Поэтому RFC 9110 и относит к идемпотентным PUT и DELETE, но не POST.

2
Кто должен генерировать ключ идемпотентности?

Клиент, причём один раз на логическую операцию, и повторять этот же ключ при каждой следующей попытке, включая ретраи после таймаута. Если ключ выдаёт сервер, задача не решается: сервер как раз и не может отличить новый запрос от повтора без внешнего признака, а значит, не знает, выполнялась ли операция раньше.

3
Почему нельзя просто обернуть запись в базу и отправку в очередь одной транзакцией?

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

4
Как работает transactional outbox и нужна ли при нём дедупликация у потребителя?

Приложение вставляет событие в таблицу outbox в той же транзакции, что и бизнес-запись: либо фиксируется всё, либо ничего. Отдельный процесс читает необработанные строки и публикует их в очередь, помечая отправленными после подтверждения. Но outbox гарантирует доставку, а не её единственность: при сбое доставщика строки уйдут повторно, поэтому потребитель обязан дедуплицировать по идентификатору события.

5
Сколько хранить записи об идемпотентности?

Сутки покрывают практически любое реальное окно повторов, но срок выбирается от риска предметной области: платёжные сценарии требуют одного периода, рекомендательные другого. Хранить вечно нельзя, это превращается в медленно растущую свалку.

Что забрать с собой

Обе части задачи выглядят избыточными ровно до первого инцидента. Тридцать строк кода с ключом идемпотентности стоят дешевле одного тикета с заголовком «вы списали дважды», а таблица outbox дешевле расследования, почему заказ есть у клиента и отсутствует на складе.

Коварство проблемы двух записей в том, что наивная реализация работает правильно почти всегда. Она отказывает в зазоре между двумя системами, и зазор этот становится виден только когда что-то пошло не так в самый неподходящий момент. К моменту, когда расхождение заметят в продакшене, данные уже разъехались, и красивого способа их починить не будет.

Как одно неверное допущение о порядке вызовов обернулось эпидемией задвоенных операций, показано в разборе реального инцидента в финтех-системе.

Материалы, на которых основан разбор: три паттерна идемпотентности с рабочим кодом, проектирование ручек, переживающих реальные повторы и пошаговая сборка outbox на Node.js.

Откройте самый денежный обработчик в своём сервисе и попробуйте отправить в него один и тот же запрос дважды. Если во второй раз что-то произошло, вы уже знаете, с чего начать понедельник.