{ "index": 245, "slug": "editorial-2021-03-mechanism-queues", "title": "Очереди задач: почему повторная доставка не должна повторять эффект", "excerpt": "Сообщение может прийти повторно после уже выполненной операции. Разбираем границу между delivery и доменным эффектом, идемпотентный ключ, порядок retry и безопасный terminal route.", "contentHtml": "

Симптом заметен не в очереди, а в результате: клиент получил два письма, счёт создался дважды или один заказ перешёл в неверный статус. В логах при этом видны два запуска одного обработчика. Команда часто обвиняет broker и пытается отключить повторную доставку. Это опасное решение. Вместе с повтором можно потерять задачу, если первый consumer успел выполнить часть работы, но не успел подтвердить delivery. Цена ошибки зависит от эффекта: лишнее уведомление можно отменить, второе списание — уже нет.

\n

Тезис статьи простой: подтверждение относится к текущей доставке сообщения, а идемпотентность относится к доменному эффекту. Эти границы нужно проектировать отдельно. Consumer должен переживать повтор одного намерения, хранить ключ эффекта и принимать решение о retry после проверки причины. Выбранная очередь помогает доставить работу, но не делает внешнюю операцию атомарной и не обещает exactly-once для всей системы.

\n

Механизм: три состояния вместо одного флага

\n

У одной задачи есть как минимум три разных идентификатора и состояния. messageId обозначает конкретное сообщение в транспорте. effectKey обозначает доменный эффект, например отправку напоминания по счёту. sequenceKey обозначает сущность, для которой важен порядок: счёт, заказ или профиль.

\n

Broker отвечает за доставку сообщения и его подтверждение. Consumer отвечает за выполнение операции. Доменное хранилище отвечает за факт эффекта. Если процесс упал после записи в хранилище, но до ack, broker имеет право доставить сообщение ещё раз. Если код связывает повтор с новым эффектом, он превращает штатное восстановление в дубль.

\n
delivery: messageId = msg-81, attempt = 2\neffect: effectKey = invoice-417:reminder, state = recorded\norder: sequenceKey = invoice-417, next = 8\nack: acknowledge current delivery after effect decision
\n

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

\n

Где появляется duplicate

\n

Рассмотрим учебный пример без подключения к broker. Сообщение просит отправить одно напоминание по счёту. Первая попытка вызывает временную ошибку и уходит на retry. Вторая попытка записывает эффект в ledger. Сразу после записи процесс теряет соединение. Consumer не знает, дошёл ли ack. Broker считает доставку неподтверждённой и запускает её снова.

\n

На третьем запуске новый код сначала ищет effectKey. Ledger возвращает существующую запись. Consumer не отправляет новое напоминание и завершает текущую доставку как обработанную. В логах остаётся факт duplicate, но в домене появляется одна запись. Это ожидаемый результат recovery, а не доказательство exactly-once.

\n
async function handle(message, ledger) {\n  const key = message.effectKey;\n  const existing = await ledger.find(key);\n\n  if (existing) {\n    await ledger.recordDelivery(message.messageId, 'duplicate-effect-suppressed');\n    return { ack: true, effect: 'not-repeated' };\n  }\n\n  await ledger.recordEffect({\n    effectKey: key,\n    messageId: message.messageId,\n    type: message.type,\n  });\n\n  return { ack: true, effect: 'recorded' };\n}
\n

Код учебный. Он показывает порядок решений, но не заменяет транзакцию, уникальный индекс или API внешнего сервиса. В рабочей системе find и recordEffect должны защищать одну границу состояния. Иначе два параллельных consumer могут одновременно не найти ключ и оба создать эффект. Для базы это обычно означает уникальное ограничение по effectKey и обработку конфликта как duplicate. Для внешнего HTTP-вызова нужен поддержанный внешней системой idempotency key или ручной контроль. Локальный Map не может отменить уже отправленное письмо.

\n
\"Схема
Подтверждение завершает текущую доставку. Ledger защищает доменный эффект от повторной записи.
\n

Симптом → причина → проверка → действие

\n
Диагностика повторов и остановившихся задач
СимптомПричинаПроверкаДействие
Один эффект записан дваждыНет уникального effectKey или проверка не атомарнаСравнить ключи и найти две записи в одном интервалеДобавить уникальное ограничение и трактовать конфликт как duplicate
Задача пропала после падения workerAck отправили до записи эффекта или включили auto-ackСопоставить время ack, запись эффекта и завершение процессаПодтверждать после успешной границы обработки; для неизвестного исхода включить recovery
Очередь быстро растётRetry повторяет постоянную ошибку или consumer не успеваетРазделить transient и permanent причины, посмотреть attempts и latencyЗадать лимит попыток, backoff и terminal route
Статус вернулся назадПараллельные сообщения нарушили порядок для одной сущностиСгруппировать события по sequenceKey и сравнить номераПроверять следующий номер; gap отправлять на разбор, а не угадывать
Оператор повторил опасную задачу вслепуюTerminal запись не содержит причины и ключа эффектаПроверить содержимое ручного маршрутаСохранять messageId, effectKey, attempts, reason и requiredCheck
\n

Retry не исправляет постоянную ошибку

\n

Retry подходит для ограниченного класса отказов: временно недоступна зависимость, закончился connection pool или сработал rate limit. Он не исправляет неправильный формат сообщения, отсутствующий обязательный атрибут или нарушение бизнес-правила. Такой вход будет падать снова. Без лимита consumer создаст requeue loop, нагрузит broker и отложит диагностику.

\n

Политика должна различать причину, число попыток и следующий исход. Backoff снижает плотность повторов, но не сообщает, когда ошибка стала постоянной. После лимита попыток сообщение нужно перевести в явный terminal route: dead-letter queue, quarantine или ручной разбор. Название зависит от продукта. Контракт должен оставаться одинаковым: задача перестала исполняться автоматически, а причина и контекст сохранены.

\n
const policy = {\n  transient: { delaysMs: [1000, 4000, 16000], terminal: 'manual-review' },\n  permanent: { delaysMs: [], terminal: 'quarantine' },\n};\n\nfunction nextAction(error, attempt) {\n  const rule = error.kind === 'transient' ? policy.transient : policy.permanent;\n  if (attempt < rule.delaysMs.length) return { type: 'retry', delayMs: rule.delaysMs[attempt] };\n  return { type: 'terminal', route: rule.terminal };\n}
\n

Значения в примере учебные. Их нельзя переносить в production без проверки SLA зависимости, лимитов broker и допустимого времени ожидания. Отдельно измеряйте число повторов, возраст самой старой задачи и долю terminal исходов. Среднее время обработки может выглядеть нормальным, пока небольшой поток poison messages держит ресурсы и скрывает реальную причину.

\n

Порядок для одной сущности

\n

Очередь не обязана сохранять общий порядок всех задач. Обычно нужен порядок только внутри одного ключа. Для invoice-417 событие с номером 8 можно применить после номера 7. Событие с номером 10 нельзя молча применить раньше 9, если доменная модель не допускает пропуск. Для разных счетов искусственная последовательность только уменьшит параллелизм.

\n

Порядок должен иметь владельца и проверяемое правило. Partition, routing key или один worker могут помочь доставке, но не заменяют проверку состояния. После перезапуска consumer должен снова понять, какой номер уже принят. Если предыдущего события нет, выберите один из исходов: подождать ограниченное время, запросить восстановление или отправить gap в manual route. Бесконечный retry здесь маскирует потерю данных.

\n

Порядок внедрения

\n
  1. Опишите доменный эффект одним предложением и выберите его владельца: запись, уведомление, переход статуса или внешний вызов.
  2. Сформируйте messageId, effectKey и при необходимости sequenceKey. Зафиксируйте, какие сообщения законно создают разные эффекты.
  3. Определите границу записи эффекта. Для базы используйте подходящую транзакцию и уникальное ограничение; для внешнего API проверьте поддержку идемпотентного ключа.
  4. Поставьте проверку существующего эффекта перед новой записью. Конфликт уникальности обработайте как duplicate, а не как бесконечный retry.
  5. Подтверждайте delivery только после принятого решения по эффекту. Не смешивайте ack с обещанием отката внешней операции.
  6. Разделите временные и постоянные ошибки. Добавьте backoff, лимит попыток и terminal route с причиной и контекстом.
  7. Определите правило порядка для каждой сущности, где оно нужно. Для gap задайте ограниченный и наблюдаемый исход.
  8. Проверьте recovery-сценарий: эффект записан, ack неизвестен, сообщение пришло снова. Убедитесь, что повтор не создаёт второй эффект.
\n

Ограничения

\n

Эта схема не делает распределённую систему атомарной. Если запись в локальной базе и вызов внешнего API идут в разных системах, между ними остаётся окно неопределённости. В нём возможны внешний успех без локальной записи, локальная запись без внешнего успеха и повтор после сетевого таймаута. Решение выбирают по доменному риску: outbox, API с идемпотентным ключом, сверка состояния или ручная операция. Ни один вариант не следует объявлять универсальным без проверки конкретных границ.

\n

Порядок тоже ограничен областью ключа. Один partition или один consumer не создаёт общий порядок между независимыми сущностями. Флаг redelivered не доказывает, что сообщение ранее полностью обработали, и отсутствие такого флага не доказывает обратное. Наблюдайте историю delivery, но принимайте решение по состоянию эффекта.

\n

Проверяемый критерий готовности

\n

Считайте контракт готовым, когда контролируемый тест проходит один и тот же сценарий: consumer записывает эффект, теряет знание об ack, получает повторное сообщение и оставляет ровно один доменный эффект; лог и ledger связывают оба запуска с одним effectKey; постоянная ошибка после лимита попадает в terminal route с причиной; gap не меняет состояние раньше времени. Тест должен выполняться на выбранном broker, хранилище и клиенте проекта. Учебный пример выше проверяет только порядок решений и не заменяет эту интеграционную проверку.

\n

Проверяемые источники

" }