Выпуск новостей завершён 15 сентября 2026. Материалы сохранены на дату публикации. Перейти к инструкциям →

Архив · Полный материал · Tproger

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

Как сделать ручку идемпотентной, где ломается наивная проверка ключа и зачем нужен transactional outbox. Забирайте код на Python, SQL и Node.js и чеклист. — Читать дальше « Идемпотентность и Outbox: как не выполнить одну операцию дважды »

Разработка · TprogerМарат Ильясов13 минут
Перейти к тексту ↓
Идемпотентность и Outbox: как не выполнить одну операцию дважды
Tproger
Коротко о главномРазвернутьСвернуть
  1. История, с которой начинает автор одного из разборов ниже, звучит буднично.
  2. Платёжный шлюз не ответил вовремя, клиентская библиотека повторила запрос, и покупателя списали дважды.
  3. Сумма небольшая, а возврат, тикет в поддержку и восстановление доверия заняли недели.

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

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

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

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

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

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

Для 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 Сделайте потребителя идемпотентным Дедуплицируйте по идентификатору события условной записью или уникальным ключом. Доставка «хотя бы один раз» означает, что дубль придёт обязательно.

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

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

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

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

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

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

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

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

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

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

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