{ "index": 246, "slug": "editorial-2021-03-practice-queues", "title": "Очередь задач без иллюзий: повтор, порядок и право на эффект", "excerpt": "Двойная отправка и застрявшие сообщения начинаются не с выбора брокера, а с неявного контракта. Разбираем идентификаторы, локальный порядок, ограниченный retry, защиту эффекта и ручной маршрут.", "contentHtml": "

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

\n

Сначала он проверил только размер очереди и доступность процесса. После этого повторил чтение и обнаружил цену такой проверки: второй платёж, лишний файл, неверное состояние записи или потерянная задача. Одной метрики «очередь не пустая» недостаточно. Нужно знать, какой эффект уже создан, кому принадлежит порядок и почему конкретное сообщение нельзя обрабатывать автоматически.

\n

Сценарий: подтверждение пропало после эффекта

\n

Представим короткий запуск на Redis Streams. Producer добавляет задачу в поток, consumer читает её через группу и после обработки отправляет XACK. Если процесс завершился после внешнего действия, но до подтверждения, запись останется в Pending Entries List (PEL) — списке выданных, но ещё не подтверждённых сообщений. Новый consumer сможет подобрать её для повторной обработки. Поэтому XACK — не защита от повторного эффекта, а отметка о завершённой работе.

\n
# Redis 5+; команды выполняются в redis-cli\nXADD jobs * type reminder invoice_id 417\nXGROUP CREATE jobs reminders $ MKSTREAM\nXREADGROUP GROUP reminders worker-1 COUNT 1 BLOCK 5000 STREAMS jobs >\n# Подставьте ID из ответа XREADGROUP\nXACK jobs reminders <message-id>
\n

Знак > в XREADGROUP просит новые записи группы. В реальном ответе Redis будет ID сообщения; его и передают в XACK. Для восстановления после падения нужно отдельно читать собственные pending-записи и проверять, не был ли внешний эффект создан до сбоя. Эти команды демонстрируют маршрут сообщения, но не делают отправку напоминания идемпотентной.

\n

Тезис: очередь переносит работу, но не задаёт её контракт

\n

Producer записывает намерение, consumer читает его позже и создаёт побочный эффект: отправляет письмо, меняет запись, вызывает внешний API или формирует файл. Между этими моментами может оборваться процесс. Consumer может успеть создать эффект, но не успеть подтвердить обработку. Брокер или поток тогда вправе выдать ту же задачу снова.

\n

Поэтому сообщение нужно связывать не только с payload. В конверте должны быть наблюдаемый id, ключ конкретного эффекта effectKey, ключ объекта, внутри которого важен порядок, и версия схемы. Эти значения отвечают на разные вопросы. Один UUID не заменяет весь контракт, а транспортный ID не обязан совпадать с ключом бизнес-эффекта.

\n
Минимальный контракт задачи
Поле или правилоВопросПроверкаДействие при нарушении
idКак найти историю этой задачи?Значение непустое и не меняется при повторной доставкеОстановить обработку и сохранить вход
effectKeyКакой эффект нельзя создать второй раз?Поискать ключ в журнале эффектов до внешнего действияПодавить повтор или передать на разбор
sequenceKey и номерВ каком порядке применяются изменения?Сравнить с последним принятым номером этого объектаОтложить пропуск или остановить конфликт
retry policyКакая ошибка действительно временная?Сопоставить причину с известным классом и лимитомСделать ограниченный повтор или завершить автоматический путь
manual routeКуда попадёт непонятный вход?Есть причина, попытки и следующий обязательный шагСохранить запись, не удалять сообщение молча
\n

id нужен для расследования. effectKey защищает результат. sequenceKey ограничивает область порядка. Политика retry управляет временем, но не исправляет неправильные данные. Если смешать эти роли, логика начинает принимать решение по полю, которое для него не предназначено.

\n

Механизм: сначала признать сообщение, потом создавать эффект

\n

Возьмём учебную задачу о напоминании по счёту. Значения вымышлены. Функции работают только с объектами в памяти Node.js: они не подключаются к RabbitMQ, Kafka, Redis, базе или внешнему API. Их задача — сделать границы решения видимыми.

\n
const message = {\n  id: 'msg-order-417',\n  kind: 'invoice.reminder',\n  effectKey: 'invoice-417:reminder',\n  sequenceKey: 'invoice-417',\n  sequence: 4,\n  payload: { invoiceId: '417', schema: '2021-03' }\n};\n\nconst state = {\n  lastSequence: { 'invoice-417': 3 },\n  effects: new Set()\n};
\n

В этом состоянии номер 3 уже принят, поэтому сообщение с номером 4 можно рассматривать как следующий шаг. Если начать с пустого состояния, тот же пример корректно попадёт в ветку sequence-gap: функция не угадывает, что произошло с номерами 1–3.

\n

Consumer сначала проверяет форму сообщения и версию payload. Затем проверяет порядок, если домен его требует. После этого ищет effectKey в журнале эффектов. Только если ключ отсутствует, обработчик получает право создать эффект и записать подтверждение. В рабочей системе запись журнала и изменение локального состояния должны иметь согласованную транзакционную границу. Внешний вызов всё равно требует отдельного правила неопределённого ответа.

\n
function decide(message, state) {\n  if (\n    !message?.id\n    || !message.effectKey\n    || !message.sequenceKey\n    || !Number.isInteger(message.sequence)\n  ) {\n    return { status: 'manual-review', reason: 'invalid-contract' };\n  }\n\n  if (message.payload?.schema !== '2021-03') {\n    return { status: 'manual-review', reason: 'unsupported-schema' };\n  }\n\n  const last = state.lastSequence[message.sequenceKey] ?? 0;\n  if (message.sequence <= last) {\n    return state.effects.has(message.effectKey)\n      ? { status: 'duplicate-suppressed' }\n      : { status: 'stale-or-conflicting' };\n  }\n\n  if (message.sequence > last + 1) {\n    return { status: 'manual-review', reason: 'sequence-gap' };\n  }\n\n  if (state.effects.has(message.effectKey)) {\n    return { status: 'duplicate-suppressed' };\n  }\n\n  return { status: 'apply-and-record' };\n}\n\nconsole.log(decide(message, state));\n// { status: 'apply-and-record' }
\n

Функция разделяет исходы, но не вызывает внешний сервис. Повтор с уже записанным эффектом не запускает второе действие. Пропуск номера не разрешает обработать более новое изменение поверх неизвестного состояния. Старая запись не возвращает объект назад. Неизвестная схема не становится временной ошибкой из-за удобства.

\n

Порядок принадлежит объекту, а не всей очереди

\n

Две независимые задачи можно выполнять параллельно. Но изменения одного счёта или заказа часто имеют порядок. Задача с номером 12 может зависеть от результата 11. Это не означает, что нужно остановить всю очередь. Правило действует внутри sequenceKey. Worker может обрабатывать другие ключи, пока сообщение с gap ждёт пропущенную запись или ручного решения.

\n

Не называйте порядок гарантированным только потому, что брокер хранит сообщения последовательно. При нескольких consumer-ах доставка и завершение обработки могут пересечься. Даже один consumer может потерять подтверждение после побочного эффекта. Отдельно проверяйте порядок доставки, порядок применения и порядок записи результата. Это три разных свойства.

\n
\"Жизненный
Retry — один из ограниченных исходов обработки. Он не должен быть бесконечным маршрутом по умолчанию.
\n

Retry нужен для временной причины

\n

Временная причина имеет наблюдаемое условие и предел ожидания. Например, учебная зависимость не ответила на первом вызове, а контракт допускает одну повторную попытку через 1000 мс. Это не доказательство восстановления сервиса и не универсальная настройка. Это только ограниченная ветка модели.

\n

Неподдерживаемая схема, пропущенная последовательность и отсутствующий ключ не становятся временными от повторения. Если классификация не уверена, сохраните сообщение для проверки. Бесконечный requeue создаёт шум, удерживает worker и скрывает полезные задачи.

\n
function nextAttempt(completedAttempts, reason) {\n  const temporary = reason === 'dependency-not-ready';\n\n  if (!temporary) {\n    return { status: 'manual-review', reason };\n  }\n\n  if (completedAttempts >= 1) {\n    return { status: 'manual-review', reason: 'retry-limit' };\n  }\n\n  return { status: 'retry', delayMs: 1000 };\n}\n\nconsole.log(nextAttempt(0, 'dependency-not-ready'));\n// { status: 'retry', delayMs: 1000 }\nconsole.log(nextAttempt(1, 'dependency-not-ready'));\n// { status: 'manual-review', reason: 'retry-limit' }
\n

Здесь completedAttempts — число уже завершившихся неуспешных попыток. Лимит выбирают по времени ожидания зависимости, допустимой нагрузке и цене ручного разбора. Число из примера нельзя переносить в рабочую систему без этих проверок. После исчерпания лимита автоматический маршрут заканчивается. Новое решение должно появиться явно: исправить вход, отменить эффект или подготовить контролируемый replay.

\n

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

\n
Диагностика одной задачи до нового запуска
СимптомПричинаПроверкаДействие
Эффект появился дваждыПодтверждение потерялось после действияСверить effectKey, журнал и время эффектаПодавить повтор; отдельно расследовать неопределённый первый ответ
Номер 12 пришёл до 11Пропуск или параллельное завершениеНайти 11, отложенную запись и владельца порядкаНе применять 12; сохранить контекст до восстановления порядка
Одна ошибка повторяетсяТерминальный вход ошибочно признан временнымПроверить класс причины и счётчик попытокОстановить цикл; создать запись для ручного разбора
Сообщение исчезлоAck отправлен до записи результатаСопоставить момент ack с записью эффектаИсправить порядок подтверждения; восстановить задачу из журнала
Старая задача меняет новое состояниеНет проверки версии или sequenceСверить последний номер объекта и id сообщенияОтклонить устаревший вход; не перезаписывать состояние назад
\n

Отрицательный путь: эффект создан, подтверждения нет

\n

Самый неприятный случай не выглядит как обычная ошибка. Consumer вызвал внешний API. API мог принять запрос, но ответ пропал по сети. Consumer не знает результата и не должен автоматически считать его неуспешным. Повтор без ключа создаст второй эффект. Ack без проверки может потерять задачу. У этой ситуации должен быть отдельный статус, например effect-result-unknown, и способ проверить внешний факт.

\n

Журнал помогает только там, где он действительно связан с эффектом. Локальная запись «мы собирались отправить письмо» не доказывает, что письмо принял внешний провайдер. Для денег, доступа и других дорогих действий нужен идентификатор провайдера, ответ владельца эффекта или ручное решение с журналом. Не подменяйте отсутствие ответа успехом.

\n

Порядок действий

\n
  1. Зафиксировать id, effectKey, sequenceKey, номер, версию payload, попытку и время.
  2. Остановить рискованный следующий эффект, если состояние задачи или результат внешнего вызова неизвестны.
  3. Проверить схему и обязательные поля. Непонятный вход не отправлять в бесконечный retry.
  4. Сверить последний номер конкретного объекта. При gap сохранить сообщение и найти пропущенный переход.
  5. Проверить журнал до повторного эффекта. Повтор завершить без нового действия.
  6. Классифицировать ошибку. Для известной временной причины применить записанный лимит и задержку.
  7. Для terminal, unknown или исчерпанного retry создать запись для ручного разбора с причиной, попытками и обязательной проверкой.
  8. После исправления подготовить новый контролируемый запуск. Сохранить ключ эффекта и проверить, что порядок не нарушен.
\n

Ограничения и критерий готовности

\n

Идемпотентный ключ не делает два независимых сервиса одной транзакцией. Ack не гарантирует, что внешний эффект завершён. Версия не гарантирует доставку следующего события. Dead-letter маршрут не заменяет владельца решения. Даже если транспорт предоставляет exactly-once для своей записи, это не означает, что внешний прикладной эффект невозможно повторить: для другой системы нужна её кооперация и собственный ключ.

\n

Пример выше учебный. Он не запускает брокер, базу, сеть или внешний сервис и не сообщает измерений нагрузки. Redis-команды показывают PEL и подтверждение, а JavaScript — только решение до внешнего действия. Фрагменты можно использовать для проверки формы контракта: повтор не создаёт второй эффект, gap не применяется молча, устаревший вход не откатывает состояние, а непонятное сообщение получает конечный маршрут.

\n

Контракт готов к реализации, когда для одной задачи можно показать цепочку «сообщение → решение consumer-а → запись эффекта → подтверждение» и ответить, что происходит при обрыве после каждого шага. Повторная доставка с тем же effectKey не создаёт новый результат. Gap сохраняется и не меняет состояние. Ошибка вне временного класса прекращает retry. По этим четырём проверкам решение можно обсуждать с владельцем домена и выбирать конкретный брокер.

\n

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

\n" }