Как избежать дублирования уведомлений при отправке пушей через Kafka
Что проверяют
Понимаете ли вы, что Kafka по умолчанию даёт доставку «хотя бы один раз», и дубли в ней не баг, а норма, с которой живут на стороне потребителя. Следом обычно спрашивают: что будет, если сервис упал после отправки пуша, но до коммита оффсета.
Как отвечать
Покажите три места, где рождаются дубли:
- продюсер: повтор после таймаута пишет сообщение дважды. Лечится
enable.idempotence=true, в клиенте Kafka 3.0+ он включён по умолчанию вместе сacks=all. Защищает только от ретраев внутри сессии продюсера, а не от того, что ваш код дважды вызвалsend(); - потребитель: оффсет коммитится после обработки, поэтому при падении или ребалансе пачка прочитается заново. Это главный источник дублей пушей;
- внешний мир: FCM и APNs находятся вне Kafka, и транзакции Kafka (
read_committed, exactly-once в Kafka Streams) до них не дотягиваются.
Рабочая схема: у уведомления есть ключ идемпотентности, который создаётся вместе с событием, а не при отправке (notificationId или пара userId и eventId). Потребитель перед отправкой атомарно занимает ключ: SET key 1 NX EX 86400 в Redis или INSERT ... ON CONFLICT DO NOTHING в таблицу отправленных. Занял, отправляет; не занял, пропускает. Если отправка упала, запись снимают или помечают статусом, иначе уведомление потеряется. Ключ пользователя стоит сделать ключом партиции, тогда его события обработаются по порядку. На стороне источника события надёжно публикуются через transactional outbox. У FCM и APNs есть схлопывание (collapse_key, apns-collapse-id), но это страховка, а не гарантия.
Где спотыкаются
Считают, что enable.idempotence=true решает всё. Он убирает дубли записи в топик, но не повторную обработку в консьюмере.
Проверяют ключ по схеме «прочитал, нет, отправил, записал». Между чтением и записью второй экземпляр успевает сделать то же самое, нужна атомарная операция.
Называют exactly-once в Kafka гарантией для пушей. Внешний HTTP-вызов в транзакцию Kafka не входит, поэтому идемпотентность потребителя обязательна.