Как ключи идемпотентности помогают гарантировать уникальность бонусов?
Что проверяют
Понимаете ли вы, что повторные запросы неизбежны: клиент ретраит после таймаута, пользователь жмёт кнопку дважды, Kafka доставляет сообщение хотя бы один раз. Вопрос в том, как начислить бонус ровно однажды, даже если два одинаковых запроса пришли одновременно. Следом спрашивают, где хранить ключ и что отвечать на повтор.
Как отвечать
Ключ идемпотентности это идентификатор операции, а не запроса. Каркас такой:
- откуда ключ: его создаёт клиент (UUID в заголовке
Idempotency-Key, один и тот же при всех ретраях) или он выводится из бизнес-смысла, напримерuserId + campaignId + orderId. Для бонусов бизнес-ключ надёжнее: он защищает и от двух разных запросов с одинаковым смыслом; - уникальное ограничение:
UNIQUEна ключ в таблице начислений. Это единственная защита от гонки, проверка «сначалаSELECT, потомINSERT» пропустит два параллельных запроса; - атомарность: запись ключа и начисление в одной транзакции (
@Transactional). В PostgreSQL удобноINSERT ... ON CONFLICT DO NOTHINGс проверкой числа вставленных строк, в Spring нарушение ограничения приходит какDataIntegrityViolationException, часто в виде подклассаDuplicateKeyException; - ответ на повтор: сохранить результат первой операции и вернуть его же. Если первый запрос ещё выполняется, отдать
409 Conflict, клиент повторит позже. Тот же ключ с другим телом запроса стоит отклонять, для этого хранят хэш тела; - асинхронный путь: консьюмер Kafka проверяет тот же ключ перед начислением, а событие о начислении публикуется через transactional outbox, чтобы не потерять его между коммитом в базу и отправкой.
Где спотыкаются
Проверяют ключ в Redis через GET, а потом делают SET. Между двумя командами успевает пройти второй запрос. Нужен атомарный SET key value NX EX ... или ограничение в базе.
Сохраняют ключ и бонус в разных транзакциях. Сбой между ними оставляет либо ключ без бонуса, либо бонус без ключа, и повтор начислит второй раз.
Генерируют ключ на сервере при каждом запросе или на клиенте заново при ретрае. Формально ключ есть, но дубли он не ловит.