Files
progcode/editorial/agent-rewrites/245.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 lines
19 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 245,
"slug": "editorial-2021-03-mechanism-queues",
"title": "Очереди задач: почему повторная доставка не должна повторять эффект",
"excerpt": "Сообщение может прийти повторно после уже выполненной операции. Разбираем границу между delivery и доменным эффектом, идемпотентный ключ, порядок retry и безопасный terminal route.",
"contentHtml": "<p>Симптом заметен не в очереди, а в результате: клиент получил два письма, счёт создался дважды или один заказ перешёл в неверный статус. В логах при этом видны два запуска одного обработчика. Команда часто обвиняет broker и пытается отключить повторную доставку. Это опасное решение. Вместе с повтором можно потерять задачу, если первый consumer успел выполнить часть работы, но не успел подтвердить delivery. Цена ошибки зависит от эффекта: лишнее уведомление можно отменить, второе списание — уже нет.</p>\n<p>Тезис статьи простой: подтверждение относится к текущей доставке сообщения, а идемпотентность относится к доменному эффекту. Эти границы нужно проектировать отдельно. Consumer должен переживать повтор одного намерения, хранить ключ эффекта и принимать решение о retry после проверки причины. Выбранная очередь помогает доставить работу, но не делает внешнюю операцию атомарной и не обещает exactly-once для всей системы.</p>\n<h2>Механизм: три состояния вместо одного флага</h2>\n<p>У одной задачи есть как минимум три разных идентификатора и состояния. <code>messageId</code> обозначает конкретное сообщение в транспорте. <code>effectKey</code> обозначает доменный эффект, например отправку напоминания по счёту. <code>sequenceKey</code> обозначает сущность, для которой важен порядок: счёт, заказ или профиль.</p>\n<p>Broker отвечает за доставку сообщения и его подтверждение. Consumer отвечает за выполнение операции. Доменное хранилище отвечает за факт эффекта. Если процесс упал после записи в хранилище, но до ack, broker имеет право доставить сообщение ещё раз. Если код связывает повтор с новым эффектом, он превращает штатное восстановление в дубль.</p>\n<pre><code>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</code></pre>\n<p>Такая модель не говорит, что повтор всегда произойдёт. Она говорит, что код не должен ломаться, если подтверждение потерялось, соединение закрылось или consumer завершился в неудобный момент. При ручном подтверждении неподтверждённая доставка обычно возвращается в работу после закрытия канала или соединения. Поэтому запись эффекта должна предшествовать ack, а проверка повторного эффекта — предшествовать новой записи.</p>\n<h2>Где появляется duplicate</h2>\n<p>Рассмотрим учебный пример без подключения к broker. Сообщение просит отправить одно напоминание по счёту. Первая попытка вызывает временную ошибку и уходит на retry. Вторая попытка записывает эффект в ledger. Сразу после записи процесс теряет соединение. Consumer не знает, дошёл ли ack. Broker считает доставку неподтверждённой и запускает её снова.</p>\n<p>На третьем запуске новый код сначала ищет <code>effectKey</code>. Ledger возвращает существующую запись. Consumer не отправляет новое напоминание и завершает текущую доставку как обработанную. В логах остаётся факт duplicate, но в домене появляется одна запись. Это ожидаемый результат recovery, а не доказательство exactly-once.</p>\n<pre><code>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}</code></pre>\n<p>Код учебный. Он показывает порядок решений, но не заменяет транзакцию, уникальный индекс или API внешнего сервиса. В рабочей системе <code>find</code> и <code>recordEffect</code> должны защищать одну границу состояния. Иначе два параллельных consumer могут одновременно не найти ключ и оба создать эффект. Для базы это обычно означает уникальное ограничение по <code>effectKey</code> и обработку конфликта как duplicate. Для внешнего HTTP-вызова нужен поддержанный внешней системой idempotency key или ручной контроль. Локальный Map не может отменить уже отправленное письмо.</p>\n<figure><img src=\"/assets/editorial/2021/queue-delivery-guarantees-2021.svg\" alt=\"Схема обработки доставки: message id проходит через consumer, effect key проверяется в ledger, после чего выбирается ack, retry или terminal manual route\" loading=\"lazy\" /><figcaption>Подтверждение завершает текущую доставку. Ledger защищает доменный эффект от повторной записи.</figcaption></figure>\n<h2>Симптом → причина → проверка → действие</h2>\n<div class=\"table-scroll\"><table><caption>Диагностика повторов и остановившихся задач</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Один эффект записан дважды</td><td>Нет уникального <code>effectKey</code> или проверка не атомарна</td><td>Сравнить ключи и найти две записи в одном интервале</td><td>Добавить уникальное ограничение и трактовать конфликт как duplicate</td></tr><tr><td>Задача пропала после падения worker</td><td>Ack отправили до записи эффекта или включили auto-ack</td><td>Сопоставить время ack, запись эффекта и завершение процесса</td><td>Подтверждать после успешной границы обработки; для неизвестного исхода включить recovery</td></tr><tr><td>Очередь быстро растёт</td><td>Retry повторяет постоянную ошибку или consumer не успевает</td><td>Разделить transient и permanent причины, посмотреть attempts и latency</td><td>Задать лимит попыток, backoff и terminal route</td></tr><tr><td>Статус вернулся назад</td><td>Параллельные сообщения нарушили порядок для одной сущности</td><td>Сгруппировать события по <code>sequenceKey</code> и сравнить номера</td><td>Проверять следующий номер; gap отправлять на разбор, а не угадывать</td></tr><tr><td>Оператор повторил опасную задачу вслепую</td><td>Terminal запись не содержит причины и ключа эффекта</td><td>Проверить содержимое ручного маршрута</td><td>Сохранять messageId, effectKey, attempts, reason и requiredCheck</td></tr></tbody></table></div>\n<h2>Retry не исправляет постоянную ошибку</h2>\n<p>Retry подходит для ограниченного класса отказов: временно недоступна зависимость, закончился connection pool или сработал rate limit. Он не исправляет неправильный формат сообщения, отсутствующий обязательный атрибут или нарушение бизнес-правила. Такой вход будет падать снова. Без лимита consumer создаст requeue loop, нагрузит broker и отложит диагностику.</p>\n<p>Политика должна различать причину, число попыток и следующий исход. Backoff снижает плотность повторов, но не сообщает, когда ошибка стала постоянной. После лимита попыток сообщение нужно перевести в явный terminal route: dead-letter queue, quarantine или ручной разбор. Название зависит от продукта. Контракт должен оставаться одинаковым: задача перестала исполняться автоматически, а причина и контекст сохранены.</p>\n<pre><code>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 &lt; rule.delaysMs.length) return { type: 'retry', delayMs: rule.delaysMs[attempt] };\n return { type: 'terminal', route: rule.terminal };\n}</code></pre>\n<p>Значения в примере учебные. Их нельзя переносить в production без проверки SLA зависимости, лимитов broker и допустимого времени ожидания. Отдельно измеряйте число повторов, возраст самой старой задачи и долю terminal исходов. Среднее время обработки может выглядеть нормальным, пока небольшой поток poison messages держит ресурсы и скрывает реальную причину.</p>\n<h2>Порядок для одной сущности</h2>\n<p>Очередь не обязана сохранять общий порядок всех задач. Обычно нужен порядок только внутри одного ключа. Для <code>invoice-417</code> событие с номером 8 можно применить после номера 7. Событие с номером 10 нельзя молча применить раньше 9, если доменная модель не допускает пропуск. Для разных счетов искусственная последовательность только уменьшит параллелизм.</p>\n<p>Порядок должен иметь владельца и проверяемое правило. Partition, routing key или один worker могут помочь доставке, но не заменяют проверку состояния. После перезапуска consumer должен снова понять, какой номер уже принят. Если предыдущего события нет, выберите один из исходов: подождать ограниченное время, запросить восстановление или отправить gap в manual route. Бесконечный retry здесь маскирует потерю данных.</p>\n<h2>Порядок внедрения</h2>\n<ol><li>Опишите доменный эффект одним предложением и выберите его владельца: запись, уведомление, переход статуса или внешний вызов.</li><li>Сформируйте <code>messageId</code>, <code>effectKey</code> и при необходимости <code>sequenceKey</code>. Зафиксируйте, какие сообщения законно создают разные эффекты.</li><li>Определите границу записи эффекта. Для базы используйте подходящую транзакцию и уникальное ограничение; для внешнего API проверьте поддержку идемпотентного ключа.</li><li>Поставьте проверку существующего эффекта перед новой записью. Конфликт уникальности обработайте как duplicate, а не как бесконечный retry.</li><li>Подтверждайте delivery только после принятого решения по эффекту. Не смешивайте ack с обещанием отката внешней операции.</li><li>Разделите временные и постоянные ошибки. Добавьте backoff, лимит попыток и terminal route с причиной и контекстом.</li><li>Определите правило порядка для каждой сущности, где оно нужно. Для gap задайте ограниченный и наблюдаемый исход.</li><li>Проверьте recovery-сценарий: эффект записан, ack неизвестен, сообщение пришло снова. Убедитесь, что повтор не создаёт второй эффект.</li></ol>\n<h2>Ограничения</h2>\n<p>Эта схема не делает распределённую систему атомарной. Если запись в локальной базе и вызов внешнего API идут в разных системах, между ними остаётся окно неопределённости. В нём возможны внешний успех без локальной записи, локальная запись без внешнего успеха и повтор после сетевого таймаута. Решение выбирают по доменному риску: outbox, API с идемпотентным ключом, сверка состояния или ручная операция. Ни один вариант не следует объявлять универсальным без проверки конкретных границ.</p>\n<p>Порядок тоже ограничен областью ключа. Один partition или один consumer не создаёт общий порядок между независимыми сущностями. Флаг redelivered не доказывает, что сообщение ранее полностью обработали, и отсутствие такого флага не доказывает обратное. Наблюдайте историю delivery, но принимайте решение по состоянию эффекта.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Считайте контракт готовым, когда контролируемый тест проходит один и тот же сценарий: consumer записывает эффект, теряет знание об ack, получает повторное сообщение и оставляет ровно один доменный эффект; лог и ledger связывают оба запуска с одним <code>effectKey</code>; постоянная ошибка после лимита попадает в terminal route с причиной; gap не меняет состояние раньше времени. Тест должен выполняться на выбранном broker, хранилище и клиенте проекта. Учебный пример выше проверяет только порядок решений и не заменяет эту интеграционную проверку.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://www.rabbitmq.com/docs/4.1/consumers\" target=\"_blank\" rel=\"noopener noreferrer\">RabbitMQ: Consumers</a> — режимы подтверждения, prefetch и тайм-аут подтверждения доставки.</li><li><a href=\"https://www.rabbitmq.com/docs/3.13/confirms\" target=\"_blank\" rel=\"noopener noreferrer\">RabbitMQ: Consumer Acknowledgements and Publisher Confirms</a> — redelivery, requeue, reject и необходимость готовить consumer к повторной доставке.</li><li><a href=\"https://kafka.apache.org/41/design/design/\" target=\"_blank\" rel=\"noopener noreferrer\">Apache Kafka: Design</a> — порядок внутри partition и базовые delivery semantics.</li></ul>"
}