{ "index": 244, "slug": "editorial-2021-03-field-queues", "title": "Poison message: как остановить бесконечный retry и не потерять задачу", "excerpt": "Consumer снова и снова получает одно сообщение, очередь не движется, а команда не знает, был ли уже создан эффект. Разбираем ограниченный retry, идемпотентный ключ и ручной маршрут для poison message.", "contentHtml": "
Consumer получает одно и то же сообщение, пишет одинаковую ошибку и возвращает его в очередь. Полезные задачи ждут за ним, нагрузка растёт, а оператор видит только новый красный лог. Если worker успел создать внешний эффект до сбоя, повтор может отправить письмо, списать деньги или открыть заявку ещё раз. Если сообщение удалить, исчезнет контекст, по которому можно понять, что произошло.
\nЦена ошибки складывается из двух частей. Бесконечный retry расходует ресурсы и маскирует неисправный вход. Без проверки эффекта повтор создаёт дубликат. Поэтому poison message — не любое сообщение с ошибкой. Это сообщение, для которого текущий consumer исчерпал доказанные автоматические действия и должен остановиться, сохранив данные для решения.
\nОчередь не исправляет обработку. Она только отделяет приём работы от её выполнения. Consumer должен явно различать временный сбой, непригодный вход, уже выполненный эффект и неизвестную причину. Для временного сбоя подходит ограниченный retry. Для остальных ветвей нужен сохранённый контекст, а иногда — ручная проверка. Подтверждать доставку можно после того, как обработчик выполнил нужное действие или записал безопасное состояние.
\nВ этой статье используется учебная модель. Она не подключается к RabbitMQ, Kafka, базе, сети или внешнему API. Имена msg-order-417, invoice-417:reminder и задержка 1000 мс нужны для объяснения инвариантов. Они не являются настройками production и не дают измерений пропускной способности.
Сначала broker передаёт delivery consumer-у. Обработчик проверяет payload, читает доменные данные и выполняет эффект. Затем он подтверждает обработку. Если процесс падает до подтверждения, broker может доставить сообщение снова. Это полезно при временном сбое, но опасно, если причина лежит в самом payload или если эффект уже произошёл, а подтверждение потерялось.
\nОдин счётчик попыток не даёт диагноза. Ошибка таймаута может исчезнуть после короткой задержки. Неизвестная версия схемы не станет корректной от десяти повторов. Пропущенный sequence key может требовать ожидания предыдущего сообщения, а может указывать на потерянное состояние. Неправильный подход выглядит так: любое исключение превращают в retry, а после роста очереди увеличивают лимит. Так система дольше повторяет тот же неверный шаг.
У обработки должны быть две независимые проверки. Первая отвечает, можно ли сейчас трактовать вход. Вторая отвечает, был ли уже создан доменный эффект. Только после них выбирают retry, подтверждение дубликата или ручной маршрут.
\nДля безопасного разбора нужны идентификаторы, которые не меняются между доставками. messageId связывает обработку с исходной доставкой. effectKey обозначает доменный эффект и помогает подавить повтор. sequenceKey задаёт порядок, если события нельзя выполнять независимо. Версия payload позволяет отличить известную схему от входа, который consumer не умеет читать.
const message = {\n messageId: 'msg-order-417',\n effectKey: 'invoice-417:reminder',\n sequenceKey: 'invoice-417',\n payload: { schema: '2021-03', invoiceId: '417' },\n};\n\nconst retryPolicy = {\n maxAttempts: 2,\n delaysMs: [1000],\n};\n\nfunction recordEffectOnce(ledger, effectKey) {\n if (ledger.has(effectKey)) return 'duplicate-effect-suppressed';\n ledger.add(effectKey);\n return 'effect-recorded';\n}\nЭтот код — учебный пример в памяти. Set не заменяет транзакционный ledger. В настоящем сервисе ключ должен проверяться в хранилище с гарантией, соответствующей доменному эффекту. Для письма может хватить уникального ключа операции. Для платежа потребуются правила провайдера, статус операции и отдельная сверка. Нельзя переносить этот фрагмент в production без определения владельца состояния и границы записи.
Классификация должна быть маленькой и явной. Известный временный класс получает конечную политику. Известная терминальная причина останавливает автоматический маршрут. Неизвестная причина не становится временной по умолчанию. Это консервативный выбор: он задерживает одну задачу, но сохраняет возможность понять, что с ней случилось.
\nfunction classifyFailure(error) {\n if (error.code === 'dependency-not-ready') return 'temporary';\n if (error.code === 'schema-not-supported') return 'terminal';\n if (error.code === 'sequence-gap') return 'terminal';\n return 'unknown';\n}\n\nconst kind = classifyFailure({ code: 'schema-not-supported' });\n// kind === 'terminal': новый retry не выбирается\nОграниченный backoff нужен для временного класса, а не для успокоения метрики. В учебной policy две попытки и одна задержка. В реальном проекте лимит зависит от timeout зависимости, времени жизни данных, пропускной способности и цены повторного эффекта. Эти значения надо записать рядом с контрактом и проверить на отрицательном пути: зависимость не отвечает, попытки заканчиваются, сообщение не остаётся в бесконечном цикле.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Одна ошибка повторяется с коротким интервалом | Неограниченный retry или requeue | Сравнить messageId, attempts и интервал между доставками | Ограничить попытки и добавить backoff |
| Payload не читается текущим consumer | Неизвестная версия схемы | Сверить schema с поддержанными версиями | Создать manual record и остановить цикл |
| Эффект уже есть, receipt потерян | Сбой между эффектом и подтверждением | Проверить ledger по effectKey | Подтвердить duplicate без нового эффекта |
| Следующее сообщение нельзя выполнить по порядку | Пропущен sequenceKey | Проверить предыдущую последовательность и её статус | Перенести в manual review с причиной sequence-gap |
| Лимит попыток исчерпан | Policy не получила успешный исход | Проверить attempts, reason и применённые задержки | Сохранить terminal record с владельцем решения |
Последняя колонка не обещает, что причина устранена. Она фиксирует следующий безопасный шаг. Manual review не равно dead-letter queue конкретного broker. Это логическая запись или маршрут, который должен хранить ссылку на исходное сообщение, ключ эффекта, причину, число попыток и обязательную проверку перед replay.
\nЗаписывайте terminal record до удаления доставки и до ручного replay. Минимальный набор полей связывает решение с исходным входом: messageId, effectKey, terminalReason, attempts, время наблюдения и requiredCheck. Ссылку на payload храните только в пределах политики данных. Секреты и полные персональные данные не должны попадать в свободный текст ошибки.
const manualRecord = {\n messageId: message.messageId,\n effectKey: message.effectKey,\n terminalReason: 'schema-not-supported',\n attempts: 2,\n requiredCheck: 'confirm-schema-or-cancel-effect',\n state: 'manual-review',\n};\nРучной маршрут должен иметь три разных результата. Отмена фиксирует, что эффект не нужен. Исправление входа создаёт новый контролируемый запуск по правилам домена. Replay разрешён только после проверки, что эффект не был создан, или после явного решения, как избежать второго эффекта. Кнопка «попробовать ещё раз» без этих условий лишь переносит poison message обратно в цикл.
\nmessageId, effectKey, sequenceKey, номер попытки, причину и время до удаления сообщения или нового replay.Подтверждение после записи эффекта не делает всю систему exactly-once. Между хранилищем и внешним API всё ещё может быть сбой. Ledger может быть недоступен. Внешняя система может принять запрос и не вернуть ответ. Поэтому для каждого эффекта нужна собственная стратегия: идемпотентный ключ провайдера, reconciliation, статусная модель или ручная сверка. Queue policy не выбирает её автоматически.
\nПорядок тоже имеет цену. Если сообщения одной сущности должны выполняться последовательно, параллельные consumer-ы могут ускорить независимые задачи, но не должны незаметно обгонять друг друга. Если broker requeue-ит сообщение без ограничения, один poison может блокировать полезную работу или создавать шум. Если dead-letter route не настроен, reject может удалить сообщение. Проверяйте фактический контракт выбранного broker, а не переносите поведение из учебной модели.
\nЭта статья не описывает production-инцидент и не утверждает, что приведённая policy достаточна для платежей, уведомлений или биллинга. Учебный код показывает только три инварианта: retry конечен, duplicate не создаёт второй эффект, а непонятный вход получает сохранённый ручной маршрут. Реальную готовность надо доказывать интеграционным тестом, проверкой прав и наблюдением за фактической очередью.
\nРешение готово к ограниченному внедрению, когда для одной тестовой доставки можно показать полный след: исходный идентификатор, причину, номер попытки, применённую задержку, проверку effectKey и итоговое состояние. На временном сбое появляется не более заданного числа повторов. На неизвестной схеме создаётся manual record. На повторной доставке после успешного эффекта новый эффект не создаётся. При каждом исходе оператор видит, кто и почему может выполнить следующий шаг.
\nЕсли хотя бы один из этих фактов нельзя получить из лога, хранилища или теста, автоматический маршрут ещё не доказан. Остановите расширение retry, восстановите контекст и сначала уточните границу ответственности.
\n