Алексей Меленчук
вся записка
узел 03 · Обмен
дата
объём 5 мин чтения

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;

Отдельный процесс читает таблицу и отправляет в брокер, помечая отправленное. Атомарность есть: либо и статус, и событие, либо ничего.

Плата — событие уходит с задержкой, а отправитель гарантирует «хотя бы раз»: между отправкой и отметкой то же самое окно, что и раньше. Круг замкнулся, и замкнулся правильно: исходящий ящик решает потерю, а не дублирование. Дублирование по-прежнему лечится идемпотентностью получателя, и никак иначе.

Что чаще всего забывают

Отметки надо чистить. Таблица обработанных ключей растёт линейно и однажды становится больше рабочих данных. Нужен горизонт: храним неделю, дальше удаляем. Горизонт должен быть заведомо больше максимального времени жизни сообщения в очереди, включая очередь недоставленных.

Порядок не гарантирован. Идемпотентность защищает от дубликата, но не от того, что второе сообщение обгонит первое. Если порядок важен, нужен ключ партиционирования и один обработчик на ключ — либо версии в самих данных.

Побочные эффекты вне базы не откатываются. Письмо отправлено — всё, оно отправлено. Проверка ключа идемпотентности должна стоять до внешнего вызова, а не после.

Когда можно не заморачиваться. Обработчик, который просто пишет в журнал или обновляет счётчик метрики, переживёт дубликаты без последствий. Всё написанное — про эффекты, которые видит пользователь или бухгалтерия.