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

Повторная доставка — не сбой очереди, а часть её контракта
Обработчик списал деньги или создал заказ, затем процесс завершился до подтверждения сообщения. Очередь не знает, успел ли выполниться бизнес-эффект, поэтому доставляет сообщение снова. При семантике «хотя бы раз» это штатный сценарий: ваша система должна переживать повтор, а не надеяться, что его не будет.
Ниже — практический разбор для разработчика сервиса, который читает сообщения и меняет данные в PostgreSQL 16. Сравним три способа защиты: идемпотентную операцию, таблицу обработанных сообщений и транзакционную запись вместе с результатом. Ключевой принцип у всех один: подтверждать сообщение только после того, как эффект надёжно зафиксирован.
Как возникает дубликат
Получение сообщения и подтверждение его обработки — отдельные события. Между ними есть окно сбоя. Если сервис сделал работу, но не успел отправить подтверждение, брокер может считать сообщение необработанным. После восстановления он выдаст его снова.
Обратный порядок опаснее: сервис подтверждает сообщение, а затем падает до записи результата. Очередь удаляет сообщение, но бизнес-операция не состоялась. Это уже потеря работы, а не дубликат.
Считайте, что каждое сообщение может прийти повторно в любой момент, пока ваша система не доказала обратное на уровне бизнес-операции.
«Ровно один раз» часто оказывается не свойством отдельной очереди, а результатом совместной работы брокера, хранилища и обработчика. Даже если брокер задаёт определённую семантику внутри своей системы, внешний вызов — например, запрос к платёжному сервису — может повториться независимо от неё.
Термин: идемпотентность — свойство операции, при котором повтор одного и того же запроса не меняет итог после первого успешного выполнения. Например, установка статуса заказа в «оплачен» может быть идемпотентной; списание фиксированной суммы при каждом вызове — обычно нет.
Три способа защитить обработчик
1. Сделать саму операцию идемпотентной
Если сообщение задаёт желаемое состояние, а не команду «сделай ещё раз», повтор часто можно сделать безопасным. Запись статуса по идентификатору заказа или установка значения настройки лучше подходят для повторов, чем безусловное увеличение счётчика.
Цена подхода — необходимость точно определить, что значит «тот же запрос». Используйте стабильный идентификатор события или бизнес-операции. Не генерируйте новый ключ при каждой доставке: тогда повтор станет неотличим от новой команды. Для внешнего API применяйте его механизм идемпотентности, если он документирован; не предполагайте, что такой механизм есть.
2. Хранить обработанные идентификаторы
Для событий с уникальным message_id можно завести таблицу-предохранитель в PostgreSQL 16. Уникальное ограничение не даст двум обработкам одновременно записать один идентификатор. Важно выполнять запись идентификатора и бизнес-изменение в одной транзакции.
BEGIN;
INSERT INTO processed_messages (message_id)
VALUES ('event-123')
ON CONFLICT (message_id) DO NOTHING;
-- Если вставка добавила строку, выполните бизнес-изменение.
-- Если идентификатор уже был, пропустите его.
COMMIT;
Это схема, а не самодостаточный обработчик: приложение должно проверить, добавилась ли строка, и только тогда выполнять эффект. Таблицу создают с уникальным ключом по message_id. Если обработка завершилась ошибкой, транзакция должна откатить и предохранитель, и бизнес-изменение. Иначе повтор будет ошибочно считаться уже обработанным.
Цена — рост таблицы и необходимость решить, как долго хранить ключи. Если удалить запись слишком рано, старое сообщение при повторной доставке снова выполнит эффект. Срок хранения выбирают по правилам повторов, сроку жизни сообщений и требованиям бизнеса.
3. Записывать событие и эффект одной транзакцией
Если обработчик меняет ту же базу, запись идентификатора и изменение предметных данных должны коммититься вместе. Тогда возможны два устойчивых исхода: не сохранилось ничего — сообщение можно повторить; сохранилось всё — повтор распознаётся и пропускается.
Это наиболее надёжная схема для эффекта внутри одной базы, но она не делает атомарными изменения в стороннем сервисе. Если обработчик обновляет PostgreSQL, а затем вызывает платёжный API, сбой между этими действиями снова оставляет неопределённость. Для внешних эффектов нужны ключ идемпотентности на стороне получателя, отдельная машина состояний или повторяемая команда с проверкой результата.
| Подход | Подходит, когда | Цена и ограничение |
|---|---|---|
| Идемпотентная операция | Повтор можно выразить как установку состояния | Нужна чёткая семантика операции; не подходит для безусловного списания или начисления |
| Таблица обработанных сообщений | У события есть стабильный уникальный идентификатор | Растёт хранилище; эффект и отметка должны быть в одной транзакции |
| Транзакционная запись эффекта | Сообщение меняет данные в той же базе | Не охватывает внешние системы и сетевые вызовы |
Где чаще всего ломается защита
- Подтверждение до коммита. Сервис сообщает брокеру об успехе, а затем база откатывает транзакцию. Очередь уже не вернёт сообщение.
- Ключ не уникален между повторами. Если идентификатор генерируется внутри обработчика, каждая доставка выглядит новым событием. Ключ должен приходить вместе с сообщением или однозначно вычисляться из бизнес-идентификатора.
- Предохранитель и эффект разнесены. Отдельная транзакция для отметки «обработано» создаёт окно, в котором отметка есть, а результата нет — или наоборот.
- Успех внешнего вызова неизвестен. Тайм-аут не доказывает, что удалённая система ничего не сделала. Повторяйте запрос только с поддерживаемым получателем ключом идемпотентности или сначала выясняйте состояние операции.
- Повторы маскируют постоянную ошибку. Невалидное сообщение может бесконечно возвращаться в очередь. Настройте ограничение повторов и отдельную обработку сообщений, которые не удалось обработать; точные механизмы зависят от брокера.
Для Kafka и классической очереди с подтверждением сообщения детали отличаются: положение смещения, подтверждение и повторная выдача устроены по-разному. Но граница между внешним эффектом и подтверждением остаётся тем же источником риска. Не переносите настройки одного брокера на другой без проверки его документации.
Что выбрать и что проверить в проде
Если вы записываете состояние в одну базу, начните с уникального идентификатора события и общей транзакции для дедупликации и бизнес-изменения. Если операция естественно идемпотентна, закрепите это свойство в модели данных и тестах. Если затронут внешний сервис, выясните, поддерживает ли он стабильный ключ идемпотентности и как долго хранит результат по этому ключу.
Перед запуском проверьте обработчик искусственным сбоем: завершите процесс после записи в базу, но до подтверждения сообщения. После повторной доставки в базе должен остаться один бизнес-эффект. Затем проверьте сбой до коммита: сообщение должно обработаться заново, а не пропасть.
В проде измеряйте число повторных доставок, долю сообщений в очереди ошибок, задержку обработки и количество конфликтов по уникальному идентификатору. Резкий рост конфликтов может означать не только исправную дедупликацию, но и зацикливание повторов или замедление потребителя.
Следующий шаг — зафиксировать контракт сообщения: стабильный идентификатор, допустимые повторы, порядок событий и срок хранения дедупликационных ключей. Инвалидация и восстановление данных после ошибок — отдельная тема; здесь речь только о безопасной обработке повторной доставки.
Аналитик звонков: Разбор записи звонка по этапам сделки: где сорвалось, что упущено, три исправляемые рекомендации с цитатами.
Спросить за 90 ₽Источники: Apache Kafka: Message Delivery Semantics, PostgreSQL 16: INSERT
Частые вопросы
Можно ли гарантировать, что сообщение обработается ровно один раз?
Что делать, если процесс упал после записи в базу, но до подтверждения сообщения?
Нужно ли подтверждать сообщение до или после записи в базу?
Подойдёт ли таблица обработанных сообщений для вызова платёжного API?
Что если у меня другая база или другая очередь?
Нужно ли удалять старые идентификаторы обработанных сообщений?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.