Exactly-once не существует
Доставку ровно один раз продают как свойство очереди. Разбор того, почему это свойство получателя, и что делать вместо надежды на брокера.
Коротко. Брокер не может гарантировать однократную обработку, потому что не знает, чем закончилась ваша работа после доставки. Однократность достигается идемпотентностью получателя, и ничем другим.
Разговор про очереди рано или поздно доходит до вопроса: а как обеспечить, чтобы сообщение обработалось ровно один раз?
Правильный ответ звучит неудобно. Никак. Со стороны брокера — никак.
Где именно ломается
Возьмём момент после того, как обработчик сделал свою работу и собирается подтвердить сообщение.
Работа выполнена: деньги списаны, письмо отправлено, строка вставлена. Осталось сказать брокеру «принято». И тут процесс умирает. Или сеть рвётся. Или подтверждение уходит, но не доезжает.
Брокер видит: сообщение выдано, подтверждения нет. У него два варианта, и третьего не существует.
Выдать снова — получим повторную обработку. Не выдавать — потеряем сообщение, если работа на самом деле не была сделана.
Брокер не может выбрать правильно, потому что не знает, что произошло на вашей стороне между доставкой и обрывом. Эта информация ему недоступна в принципе. Не «пока не реализовано» — недоступна.
Поэтому выбор всегда между «хотя бы раз» и «не больше раза». Первое — дубликаты, второе — потери. Отрасль почти единогласно выбрала первое, потому что дубликат можно обработать, а потерю нельзя.
А как же exactly-once в документации
Он там есть, и это не обман — это про другой участок.
Kafka умеет транзакции: прочитать из одного топика, записать в другой и сдвинуть смещение — атомарно. Работает. Ограничение в том, что это однократность внутри Kafka. Как только обработчик делает запрос к платёжному шлюзу или пишет в чужую базу, транзакция брокера этот внешний эффект не покрывает.
То же с транзакционным исходящим ящиком: он даёт атомарность между вашей базой и фактом отправки, но не между отправкой и тем, что случилось у получателя.
Формулировка, которую стоит держать в голове: однократность достижима внутри одной транзакционной границы. Как только эффект выходит за неё, обещание превращается в «хотя бы раз» плюс ваша работа.
Что делать вместо
Принять дубликаты как норму и сделать обработку идемпотентной. Идемпотентность здесь — не абстракция, а три конкретных приёма.
Ключ идемпотентности. У сообщения есть идентификатор, устойчивый между повторами. Не сгенерированный получателем — присвоенный отправителем. Перед работой получатель проверяет, не обрабатывал ли он этот ключ, и результат проверки хранит в той же транзакции, что и сам эффект.
Ключевое слово — в той же. Если отметка о ключе пишется отдельной транзакцией, вы просто передвинули окно отказа, а не закрыли его.
-- эффект и отметка об обработке — одной транзакцией
INSERT INTO processed (message_id) VALUES ($1); -- упадёт на дубликате
UPDATE accounts SET balance = balance - $2 WHERE id = $3;
Уникальное ограничение на message_id делает вторую попытку невозможной,
а не маловероятной. Ловим нарушение уникальности — значит, обработали
раньше, подтверждаем и идём дальше.
Естественная идемпотентность. Иногда ключ не нужен, потому что
операция и так безопасна при повторе. «Установить статус в paid» —
безопасна. «Прибавить к балансу сто» — нет. Разница между присваиванием
и приращением здесь решает всё, и часто операцию можно переформулировать
из второго вида в первый.
Условная запись. UPDATE ... WHERE version = $expected. Повтор
не найдёт строку в ожидаемом состоянии и ничего не сделает. Тот же приём,
что и оптимистическая блокировка, применённый к другой задаче.
Исходящий ящик — там, где всё обычно и ломается
Обратная сторона задачи: не «как не обработать дважды», а «как не потерять то, что должны были отправить».
Обработчик меняет данные в своей базе и отправляет событие в очередь. Две операции, два разных хранилища, между ними — окно.
Записали в базу, упали до отправки — событие потеряно, и никто об этом не узнает. Отправили, упали до фиксации транзакции — событие ушло про изменение, которого не было.
Распределённая транзакция между базой и брокером эту задачу решает и почти нигде не применяется: двухфазная фиксация требует поддержки с обеих сторон и превращает недоступность одного участника в блокировку другого.
Рабочее решение — исходящий ящик. Событие пишется в таблицу той же транзакцией, что и данные:
BEGIN;
UPDATE orders SET status = 'paid' WHERE id = $1;
INSERT INTO outbox (topic, payload) VALUES ('order.paid', $2);
COMMIT;
Отдельный процесс читает таблицу и отправляет в брокер, помечая отправленное. Атомарность есть: либо и статус, и событие, либо ничего.
Плата — событие уходит с задержкой, а отправитель гарантирует «хотя бы раз»: между отправкой и отметкой то же самое окно, что и раньше. Круг замкнулся, и замкнулся правильно: исходящий ящик решает потерю, а не дублирование. Дублирование по-прежнему лечится идемпотентностью получателя, и никак иначе.
Что чаще всего забывают
Отметки надо чистить. Таблица обработанных ключей растёт линейно и однажды становится больше рабочих данных. Нужен горизонт: храним неделю, дальше удаляем. Горизонт должен быть заведомо больше максимального времени жизни сообщения в очереди, включая очередь недоставленных.
Порядок не гарантирован. Идемпотентность защищает от дубликата, но не от того, что второе сообщение обгонит первое. Если порядок важен, нужен ключ партиционирования и один обработчик на ключ — либо версии в самих данных.
Побочные эффекты вне базы не откатываются. Письмо отправлено — всё, оно отправлено. Проверка ключа идемпотентности должна стоять до внешнего вызова, а не после.
Когда можно не заморачиваться. Обработчик, который просто пишет в журнал или обновляет счётчик метрики, переживёт дубликаты без последствий. Всё написанное — про эффекты, которые видит пользователь или бухгалтерия.