Как изменить взаимодействие между сервисами, чтобы избежать дублирования заказов при ретраях
Что проверяют
Понимаете ли вы, что ретраи между сервисами неизбежны, а доставка в сети в лучшем случае «хотя бы один раз». Бороться нужно не с повторами, а с их последствиями. Следом спрашивают, что делать, если заказ записан в базу, а событие в брокер не ушло.
Как отвечать
Каркас: каждое звено должно спокойно переживать повтор.
- идентификатор операции от вызывающей стороны: UUID генерируется до первой попытки (
symfony/uidилиramsey/uuid) и передаётся при каждом ретрае, в HTTP какIdempotency-Key, в сообщении какmessage_id; - уникальное ограничение у получателя: сервис заказов вставляет запись с
UNIQUEпо ключу. В MySQL это перехват ошибки дубля (SQLSTATE23000) илиINSERT ... ON DUPLICATE KEY UPDATE, в PostgreSQLON CONFLICT DO NOTHING. При конфликте сервис возвращает уже созданный заказ; - transactional outbox: заказ и событие пишутся одной транзакцией, событие в таблицу
outbox, отдельный воркер публикует его в RabbitMQ или Kafka. Заказа без события не бывает; - inbox у потребителя: таблица обработанных
message_id, запись в неё в той же транзакции, что и побочный эффект. В Symfony Messenger упавшее сообщение уходит на повтор поretry_strategy, и обработчик обязан это выдерживать; - ретраи с умом: экспоненциальная задержка с джиттером, лимит попыток, дальше очередь недоставленных (DLQ). Повторяют сетевые ошибки и
5xx, а не4xx.
Для длинного процесса с оплатой и складом упомяните сагу с компенсирующими действиями вместо распределённой транзакции.
Где спотыкаются
Генерируют UUID внутри сервиса заказов на каждый входящий запрос. Ключ получается разным для каждого ретрая, и дедупликации нет.
Предлагают «сначала проверить статус заказа, потом создать» без уникального индекса или с распределённой блокировкой вместо него. Между проверкой и вставкой успевает пройти второй ретрай, а блокировка с истёкшим TTL отпускает его сама.
Обещают exactly-once от брокера. Kafka даёт такую гарантию только внутри своих транзакций, а побочный эффект во внешней базе всё равно нужно делать идемпотентным.