Как предотвратить дублирование бонусов при отправке в внешний сервис?
Что проверяют
Понимаете ли вы, что запись в свою базу и вызов внешнего сервиса нельзя сделать атомарными, и как при этом получить ровно одно начисление. Следом спрашивают, что делать, если внешний сервис ответил таймаутом.
Как отвечать
Честная формулировка: доставка будет «хотя бы один раз», а дубли убирает идемпотентность. Каркас:
- детерминированный ключ: id начисления выводится из бизнес-события, например из id операции, за которую дают бонус. Уникальный индекс на него не даст начислить дважды даже при повторной обработке события;
- transactional outbox: в той же транзакции, где сохраняется начисление, пишется строка в таблицу
outbox. Отдельный процесс забирает её и отправляет во внешний сервис. Начисление и намерение отправить фиксируются вместе или не фиксируются вовсе; - несколько инстансов: строки выбираются через
SELECT ... FOR UPDATE SKIP LOCKED, а@Scheduled-задачу закрывают ShedLock, чтобы два инстанса не взяли одну запись. Вместо поллинга можно читать изменения через Debezium; - ключ во внешний сервис: тот же id уходит в запросе как ключ идемпотентности. Если сервис такого не умеет, перед повтором запрашивают статус по id и регулярно делают сверку;
- статусы и ретраи:
NEW,SENT,CONFIRMED,FAILED, повтор с экспоненциальной задержкой через Spring Retry или Resilience4j и лимит попыток с переводом в ручной разбор.
Таймаут назовите отдельно: это не ошибка, а неизвестный результат. Бонус мог уже начислиться, поэтому повтор допустим только с тем же ключом.
Где спотыкаются
Вызывают внешний сервис внутри @Transactional и надеются на откат. HTTP-запрос не откатывается: база вернулась назад, а бонус на той стороне уже начислен.
Собирают ключ из userId и текущего времени или генерируют UUID на каждую попытку. Повтор получает новый ключ, и защита перестаёт работать.
Хранят отправленные id в HashSet в памяти. После рестарта он пуст, между инстансами не общий и к тому же не потокобезопасен.