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

История, с которой начинает автор одного из разборов ниже, звучит буднично. Платёжный шлюз не ответил вовремя, клиентская библиотека повторила запрос, и покупателя списали дважды. Сумма небольшая, а возврат, тикет в поддержку и восстановление доверия заняли недели.
Виноват тут не шлюз. Виноват сервер, который исходил из того, что каждый запрос приходит ровно один раз. В сети, где теряются ответы, это допущение неверно всегда.
Идемпотентность — свойство операции, при котором повторное выполнение приводит к тому же состоянию, что и однократное. Сам ответ при этом может отличаться: повторный DELETE вернёт 404 вместо 200, и метод от этого идемпотентным быть не перестаёт. У HTTP это закреплено на уровне методов: RFC 9110 относит к идемпотентным PUT, DELETE и безопасные методы, а POST идемпотентным по своей природе не является. Бизнес-операции почти всегда отправляют именно через POST.
Ключевые выводы
Клиент не может отличить «сервер не получил запрос» от «сервер выполнил операцию, но ответ потерялся», поэтому повтор придёт в любом случае.
Ключ идемпотентности генерирует клиент, а сервер хранит связку ключа со слепком запроса и с готовым ответом, чтобы повтор получил тот же результат, а не новый.
Проверка «посмотреть и вставить» неатомарна: два одновременных повтора оба увидят отсутствие ключа. Нужен уникальный индекс или условная запись.
Запись в базу и отправка в очередь в одну транзакцию не помещаются. Обёртывать сетевой вызов в BEGIN и COMMIT вредно вдвойне: корректности не даёт, а пул соединений выедает.
Transactional outbox решает это одной таблицей: событие пишется в той же транзакции, что и бизнес-запись, а отдельный процесс доставляет его в очередь.
Почему повтор неизбежен
Сеть отказывает так, что установить факт доставки невозможно в принципе. Есть три типовых сценария, и внешне они неразличимы:
- Сервер обработал запрос, но ответ потерялся по дороге назад.
- Сервер всё ещё считает, а у клиента уже сработал таймаут.
- Балансировщик повторил запрос сам, никого об этом не уведомив.
Для GET повтор безвреден. Для POST /payments это второе списание. Отсюда и правило: одна логическая операция должна приводить к одному записанному итоговому состоянию, сколько бы раз её ни отправили. Осознанно новая операция обязана прийти с новым ключом.
Два способа сделать ручку идемпотентной и один в довесок
Способ первый: ключ идемпотентности
Самый распространённый вариант: клиент генерирует уникальный идентификатор на одну логическую операцию и повторяет его при каждой попытке. Сервер хранит соответствие ключа и ответа, а на дубликате возвращает сохранённый результат вместо повторного выполнения работы.
Три детали, на которых ошибаются чаще всего. Ключ генерирует клиент, а не сервер: смысл в том, что сервер сам по себе не отличит новый запрос от повтора. Ключ должен быть с высокой энтропией, обычно это UUID v4; выводить его из изменяемого или малоразнообразного поля вроде номера клиента нельзя, потому что это повышает риск коллизии и переигрывания чужой операции.
И последнее: повтор должен получить тот же самый ответ. Если первая попытка вернула 201 с идентификатором платежа, то и повтор возвращает те же 201 и тот же идентификатор, а не 409 и не пустую двухсотку. Иначе клиент решит, что операция не прошла, и попробует ещё раз.
Способ второй: сделать операцию идемпотентной по смыслу
Иногда ключ не нужен вовсе, потому что семантику можно спроектировать идемпотентной с самого начала. Классический пример — PUT /users/42, который задаёт полное представление ресурса: отправьте его дважды, и состояние будет одним и тем же.
Для операций создания трюк в том, чтобы выводить идентификатор из самого запроса, а не генерировать случайный на каждый вызов:
Идентификатор детерминированно выводится из почты, поэтому повторный запрос даёт того же пользователя без дублирующей строки. Плата за простоту — необходимость естественного уникального ключа: почты, артикула, внешнего идентификатора. Хеш при этом считается от точной строки, поэтому почту нужно привести к нижнему регистру и обрезать пробелы до хеширования: иначе один и тот же ящик с заглавной буквы даст второго пользователя. И ключ должен быть неизменным: если пользователь сменит почту, выведенный из неё идентификатор либо останется прежним и перестанет соответствовать данным, либо изменится и оторвётся от всех ссылок на него в соседних таблицах. Нет подходящего ключа, возвращайтесь к первому способу.
Довесок: оптимистичная блокировка
Этот приём идемпотентности не даёт и в списке стоит по другой причине. Ключ защищает от повторов одного и того же запроса, но ничего не делает с двумя разными запросами, которые правят одну запись. Здесь работает проверка версии: клиент присылает версию, которую видел, а сервер отклоняет обновление при несовпадении.
Обратите внимание, что проверка версии живёт внутри самого 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, ни очередь не обещают доставку ровно один раз, они обещают её хотя бы один раз. Поэтому защищаться приходится дважды, на входе и на выходе.
Это две записи в две разные системы, и общей транзакции у них нет. Упал процесс, моргнула сеть, выкатился деплой между двумя вызовами — одна сторона зафиксирована, вторая нет. Заказ подтверждён на экране покупателя, а склад о нём не знает. Ошибка при этом нигде не залогирована и алерт не сработал.
Почему обернуть это в транзакцию нельзя
Соблазнительный и заведомо неверный вариант выглядит так:
Транзакция базы не имеет власти над очередью и умеет откатывать только операции базы. Если отправка прошла, а COMMIT упал, сообщение уже в очереди и забрать его оттуда нельзя.
Есть и вторая беда, чисто эксплуатационная. Такой код держит открытое соединение и блокировки строк всё время сетевого вызова. Обычно очередь отвечает быстро, но под нагрузкой, ретраями или деградацией вызов растягивается на секунды, и все запросы к тем же строкам стоят в очереди. Это надёжный способ исчерпать пул соединений и уронить заодно ни в чём не повинные части сервиса.
Outbox: событие как строка в той же транзакции
Идея паттерна в том, чтобы перестать считать публикацию второй записью. Вместо вызова очереди приложение вставляет строку в таблицу outbox в той же транзакции, что и бизнес-запись. Отдельный процесс читает таблицу и публикует сообщения дальше.
Откатилась транзакция, и вместе с ней исчезла строка outbox: осиротевшего сообщения в очереди не осталось. Упало приложение сразу после фиксации — строка на месте со статусом pending, и доставщик заберёт её на следующем проходе. Паттерн опирается ровно на одну гарантию, которую база и так даёт: атомарность одной транзакции.
Доставщик выбирает пачку необработанных строк и помечает их отправленными только после подтверждения очередью. Ключевая деталь здесь одна:
Этот SELECT и последующая простановка статуса выполняются в одной транзакции: блокировка живёт до фиксации. Благодаря SKIP LOCKED доставщик масштабируется горизонтально из коробки, каждый экземпляр берёт свой набор строк. А если он упадёт посреди пачки, вся пачка откатится и уйдёт повторно, что даёт ещё один довод в пользу идемпотентного потребителя.
Может показаться, что здесь мы делаем ровно то, что осудили выше: держим блокировку на время сетевого вызова. Разница в том, что блокируется не бизнес-таблица и соединение берётся не из пула, обслуживающего пользовательские запросы, а SKIP LOCKED не даёт одной медленной строке задержать остальные.
Потребитель обязан быть идемпотентным
Если доставщик упал посреди прохода, на следующем он переотправит те же строки. Очереди вроде SQS и Kafka в типовой конфигурации гарантируют доставку «хотя бы один раз», поэтому дедупликация на приёмной стороне обязательна, и уникальным ключом служит идентификатор события или решения.
Делает это условная запись. Важная деталь, на которой легко ошибиться: сама по себе она пустой операцией не становится. При несовпадении условия драйвер бросает исключение, и его нужно поймать явно, отличив штатный повтор от настоящей ошибки:
Настройки продюсера и подтверждения брокера снижают количество повторов, но не снимают с приложения ответственность за однократность бизнес-эффекта.
Что добавить перед продакшеном
Двух статусов мало. Нужен статус failed и счётчик попыток: после N неудач строка помечается провалившейся и перестаёт крутиться в цикле вечно. На стороне очереди настраивается очередь недоставленных сообщений, чтобы то, что потребитель не смог обработать, оседало в наблюдаемом месте, а не исчезало молча.
Когда задержка опроса становится критичной, полагающийся на периодический опрос доставщик заменяют захватом изменений: инструменты вроде Debezium читают журнал предзаписи PostgreSQL и публикуют изменения без паузы на опрос. Таблица outbox и потребитель при этом не меняются. Но это заметно более тяжёлое эксплуатационное обязательство, поэтому начинать почти всегда стоит с опроса.
Чеклист: привести ручку в порядок
- 01Найдите ручки с побочными эффектами
Любой обработчик, который списывает деньги, создаёт сущность или шлёт письмо, обязан переживать повтор. Начните с них, а не со списка всех ручек.
- 02Введите ключ и слепок
Принимайте ключ от клиента, храните рядом канонизированный хеш содержимого. Совпали ключ и слепок, отдайте сохранённый ответ; совпал только ключ — верните ошибку по задокументированному коду.
- 03Сделайте резервирование атомарным
Замените «проверить и вставить» на уникальный индекс или условную запись. Затем прогоните полсотни параллельных запросов с одним ключом и убедитесь, что эффект случился один раз.
- 04Проверьте, что повтор отдаёт тот же ответ
Повторный вызов должен вернуть исходный код и исходный идентификатор. Ответ 409 на честный сетевой ретрай заставит клиента повторять дальше.
- 05Найдите места с двумя записями
Пройдитесь по коду в поисках связки «записали в базу, потом отправили в очередь». Каждая такая пара это будущее расхождение данных.
- 06Заведите таблицу outbox
Пишите событие в той же транзакции, доставляйте отдельным процессом с выборкой через FOR UPDATE SKIP LOCKED, добавьте статус ошибки и счётчик попыток.
- 07Сделайте потребителя идемпотентным
Дедуплицируйте по идентификатору события условной записью или уникальным ключом. Доставка «хотя бы один раз» означает, что дубль придёт обязательно.
Часто задаваемые вопросы
Что такое идемпотентность простыми словами?
Это свойство операции, при котором повторное выполнение даёт тот же результат, что и однократное: неважно, вызвали её один раз или пять. Удаление файла идемпотентно, файла как не было, так и нет. Списание денег не идемпотентно: каждый вызов уменьшает баланс заново, если специально не защититься. Поэтому RFC 9110 и относит к идемпотентным PUT и DELETE, но не POST.
Кто должен генерировать ключ идемпотентности?
Клиент, причём один раз на логическую операцию, и повторять этот же ключ при каждой следующей попытке, включая ретраи после таймаута. Если ключ выдаёт сервер, задача не решается: сервер как раз и не может отличить новый запрос от повтора без внешнего признака, а значит, не знает, выполнялась ли операция раньше.
Почему нельзя просто обернуть запись в базу и отправку в очередь одной транзакцией?
Транзакция базы управляет только операциями базы и не может отменить уже отправленное сообщение. Вдобавок такой код держит соединение и блокировки строк на всё время сетевого вызова, что под нагрузкой исчерпывает пул соединений.
Как работает transactional outbox и нужна ли при нём дедупликация у потребителя?
Приложение вставляет событие в таблицу outbox в той же транзакции, что и бизнес-запись: либо фиксируется всё, либо ничего. Отдельный процесс читает необработанные строки и публикует их в очередь, помечая отправленными после подтверждения. Но outbox гарантирует доставку, а не её единственность: при сбое доставщика строки уйдут повторно, поэтому потребитель обязан дедуплицировать по идентификатору события.
Сколько хранить записи об идемпотентности?
Сутки покрывают практически любое реальное окно повторов, но срок выбирается от риска предметной области: платёжные сценарии требуют одного периода, рекомендательные другого. Хранить вечно нельзя, это превращается в медленно растущую свалку.
Что забрать с собой
Обе части задачи выглядят избыточными ровно до первого инцидента. Тридцать строк кода с ключом идемпотентности стоят дешевле одного тикета с заголовком «вы списали дважды», а таблица outbox дешевле расследования, почему заказ есть у клиента и отсутствует на складе.
Коварство проблемы двух записей в том, что наивная реализация работает правильно почти всегда. Она отказывает в зазоре между двумя системами, и зазор этот становится виден только когда что-то пошло не так в самый неподходящий момент. К моменту, когда расхождение заметят в продакшене, данные уже разъехались, и красивого способа их починить не будет.
Как одно неверное допущение о порядке вызовов обернулось эпидемией задвоенных операций, показано в разборе реального инцидента в финтех-системе.
Материалы, на которых основан разбор: три паттерна идемпотентности с рабочим кодом, проектирование ручек, переживающих реальные повторы и пошаговая сборка outbox на Node.js.
Откройте самый денежный обработчик в своём сервисе и попробуйте отправить в него один и тот же запрос дважды. Если во второй раз что-то произошло, вы уже знаете, с чего начать понедельник.










