АрхитектураРаспределённые системыИнженер по распределённым системам

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

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

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

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

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

Исторический контекст

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

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

Постановка проблемы

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

Нельзя решать проблему только анализом тайм-аутов: сервер не знает, получил ли клиент ответ. Нельзя полагаться и на память одного процесса — после его перезапуска сведения о выполненных операциях исчезнут, а разные экземпляры сервиса могут обработать повторы одновременно.

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

Подробное решение

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

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

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

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

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

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

Ситуация из практики

Интернет-магазин отправляет запрос на резервирование товара. Ответ от сервиса резервирования теряется, и заказный сервис повторяет запрос.

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

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

Что кандидаты часто упускают

1. Достаточно ли проверять идемпотентный ключ перед выполнением операции?

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

2. Что должен сделать сервер, если повтор пришёл, пока первая операция ещё выполняется?

Он не должен запускать второе выполнение. Возможны разные контракты: дождаться результата в пределах тайм-аута, вернуть состояние «в обработке» или предоставить клиенту отдельную проверку статуса. Важно, чтобы после восстановления клиент мог однозначно узнать итог, а не был вынужден создавать новую операцию из-за временного ответа.

3. Почему идемпотентность одного сервиса не гарантирует идемпотентность всей цепочки?

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