Кейс: два дублирующих push-уведомления на одно действие пользователя
Пользователь оформлял заказ, получал два одинаковых push-уведомления с разницей в пару секунд. Заказ создавался один раз — дублировалась только отправка уведомления.
Сервис уведомлений слушал топик Kafka и на каждое сообщение о новом заказе отправлял push через внешний провайдер. При расследовании выяснилось: consumer иногда не успевал закоммитить offset до истечения max.poll.interval.ms, происходил rebalance, партиция переезжала к другому инстансу — и то же сообщение читалось второй раз с последнего закоммиченного offset.
Отправка push не была идемпотентной операцией сама по себе — провайдер не знал, что этот заказ уже уведомлялся. Проблема была не в Kafka и не в rebalance как таком — это ожидаемое поведение at-least-once доставки. Проблема была в том, что consumer-логика считала «получил сообщение» равным «обработал ровно один раз».
if (notificationRepo.existsByOrderId(orderId)) { return; } notificationRepo.save(new Notification(orderId)); pushClient.send(userId, message);
Что спросят следом на разборе такого кейса: а что если между save и send упадёт под? Тогда запись о том, что уведомление отправлено, уже есть, а push фактически не ушёл — обратная проблема, потерянное уведомление вместо дублированного. Правильный порядок — либо сначала отправить и потом зафиксировать факт отправки с обработкой частичного отказа, либо использовать outbox-паттерн с отдельным воркером, который гарантирует доставку независимо от rebalance consumer'а.
At-least-once доставка Kafka — это контракт брокера, а не гарантия бизнес-логики. Идемпотентность на стороне обработчика нужна отдельно, и её легко забыть, пока дубль не увидит живой пользователь.
Тренажёр: 600 вопросов, мок с таймером, план повторов
senior·base — что спрашивают на самом деле
В этом посте были ссылки, но мы их удалили по правилам Сетки