8 lines
20 KiB
JSON
8 lines
20 KiB
JSON
{
|
||
"index": 246,
|
||
"slug": "editorial-2021-03-practice-queues",
|
||
"title": "Очередь задач без иллюзий: повтор, порядок и право на эффект",
|
||
"excerpt": "Двойная отправка и застрявшие сообщения начинаются не с выбора брокера, а с неявного контракта. Разбираем идентификаторы, локальный порядок, ограниченный retry, защиту эффекта и ручной маршрут.",
|
||
"contentHtml": "<p>Письмо ушло дважды. Изменение статуса пришло раньше предыдущего. Сообщение с новой схемой возвращается к одному и тому же worker-у. Эти симптомы появляются после первого успешного запуска очереди, когда команда уже умеет принимать работу, но ещё не договорилась, что считать выполнением.</p>\n<p>Цена ошибки — повторный платёж, второй файл, неверный статус или потерянная задача. Ещё дороже ручной разбор без ответа на три вопроса: какой эффект уже создан, кому принадлежит порядок и почему сообщение нельзя обработать автоматически.</p>\n<h2>Тезис: очередь переносит работу, но не задаёт её контракт</h2>\n<p>Очередь отделяет того, кто создаёт задачу, от того, кто выполняет действие. Producer записывает намерение. Consumer читает его позже и создаёт побочный эффект: отправляет письмо, меняет запись, вызывает внешний API или формирует файл. Между этими моментами может оборваться процесс. Consumer может успеть создать эффект, но не успеть подтвердить доставку. Брокер тогда вправе выдать ту же задачу снова.</p>\n<p>Поэтому сообщение нужно связывать не только с payload. В конверте должны быть наблюдаемый <code>id</code>, ключ конкретного эффекта <code>effectKey</code>, ключ объекта, внутри которого важен порядок, и версия схемы. Эти поля отвечают на разные вопросы. Один случайный UUID не заменяет все четыре правила.</p>\n<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><code>id</code></td><td>Как найти историю этой задачи?</td><td>Значение непустое и не меняется при повторной доставке</td><td>Остановить обработку и сохранить вход</td></tr><tr><td><code>effectKey</code></td><td>Какой эффект нельзя создать второй раз?</td><td>Поискать ключ в ledger до внешнего действия</td><td>Подавить duplicate или передать на разбор</td></tr><tr><td><code>sequenceKey</code> и номер</td><td>В каком порядке применяются изменения?</td><td>Сравнить с последним принятым номером этого объекта</td><td>Отложить gap или остановить конфликт</td></tr><tr><td>retry policy</td><td>Какая ошибка действительно временная?</td><td>Сопоставить причину с известным классом и лимитом</td><td>Сделать ограниченный retry или завершить автоматический путь</td></tr><tr><td>manual route</td><td>Куда попадёт непонятный вход?</td><td>Есть причина, попытки и следующий обязательный check</td><td>Сохранить запись, не удалять сообщение молча</td></tr></tbody></table>\n<p><code>id</code> нужен для расследования. <code>effectKey</code> защищает результат. <code>sequenceKey</code> ограничивает область порядка. Retry управляет временем, но не исправляет неправильные данные. Если смешать эти роли, логика начинает принимать решение по полю, которое для него не предназначено.</p>\n<h2>Механизм: сначала признать сообщение, потом создавать эффект</h2>\n<p>Возьмём учебную задачу о напоминании по счёту. Значения ниже вымышлены. Функции работают только с объектами в памяти Node.js. Они не подключаются к RabbitMQ, Kafka, базе или внешнему API. Их задача — сделать границы решения видимыми.</p>\n<pre><code>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};</code></pre>\n<p>Consumer сначала проверяет форму сообщения и версию payload. Затем он проверяет порядок, если домен его требует. После этого он ищет <code>effectKey</code> в ledger. Только если ключ отсутствует, обработчик получает право создать эффект и записать подтверждение. В рабочей системе запись ledger и изменение локального состояния должны иметь согласованную транзакционную границу. Внешний вызов всё равно требует отдельного правила неопределённого ответа.</p>\n<pre><code>function decide(message, state) {\n if (!message?.id || !message.effectKey) {\n return { status: 'manual-review', reason: 'missing-identity' };\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}</code></pre>\n<p>Функция не говорит, что делать с внешним сервисом. Она только разделяет исходы. Повтор с уже записанным эффектом не запускает второе действие. Пропуск номера не разрешает обработать более новое изменение поверх неизвестного состояния. Старая запись не возвращает объект назад. Неизвестная схема не становится временной ошибкой из-за удобства.</p>\n<h2>Порядок принадлежит объекту, а не всей очереди</h2>\n<p>Две независимые задачи можно выполнять параллельно. Но изменения одного счёта или заказа часто имеют порядок. Задача с номером 12 может зависеть от результата 11. Это не означает, что нужно остановить всю очередь. Правило действует внутри <code>sequenceKey</code>. Worker может обрабатывать другие ключи, пока сообщение с gap ждёт пропущенную запись или ручного решения.</p>\n<p>Не называйте порядок гарантированным только потому, что брокер хранит сообщения последовательно. При нескольких consumer-ах доставка и завершение обработки могут пересечься. Даже один consumer может потерять подтверждение после побочного эффекта. Отдельно проверяйте порядок доставки, порядок применения и порядок записи результата. Это три разных свойства.</p>\n<figure><img src=\"/assets/editorial/2021/queue-message-lifecycle-2021.svg\" alt=\"Жизненный цикл задачи: producer создаёт конверт, consumer проверяет его, временная ошибка получает ограниченный retry, duplicate сверяется с ledger, а терминальный случай уходит на ручную проверку\" loading=\"lazy\" /><figcaption>Retry — один из ограниченных исходов обработки. Он не должен быть бесконечным маршрутом по умолчанию.</figcaption></figure>\n<h2>Retry нужен для временной причины</h2>\n<p>Временная причина имеет наблюдаемое условие и предел ожидания. Например, учебная зависимость не ответила на первом вызове, а контракт допускает одну повторную попытку через 1000 мс. Это не доказательство восстановления сервиса и не универсальная настройка. Это только ограниченная ветка модели.</p>\n<p>Неподдерживаемая схема, пропущенная последовательность и отсутствующий ключ не становятся временными от повторения. Если классификация не уверена, сохраните сообщение для проверки. Бесконечный requeue создаёт шум, удерживает worker и скрывает полезные задачи.</p>\n<pre><code>function nextAttempt(attempt, reason) {\n const temporary = reason === 'dependency-not-ready';\n if (!temporary) return { status: 'manual-review', reason };\n if (attempt >= 2) return { status: 'manual-review', reason: 'retry-limit' };\n return { status: 'retry', delayMs: 1000 };\n}</code></pre>\n<p>Лимит выбирают по времени ожидания зависимости, допустимой нагрузке и цене ручного разбора. Число из примера нельзя переносить в рабочую систему без этих проверок. После исчерпания лимита автоматический маршрут заканчивается. Новое решение должно появиться явно: исправить вход, отменить эффект или подготовить контролируемый replay.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<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>Подтверждение потерялось после действия</td><td>Сверить <code>effectKey</code>, ledger и время эффекта</td><td>Подавить duplicate; отдельно расследовать неопределённый первый ответ</td></tr><tr><td>Номер 12 пришёл до 11</td><td>Gap или параллельное завершение</td><td>Найти 11, deferred-запись и владельца порядка</td><td>Не применять 12; сохранить контекст до восстановления порядка</td></tr><tr><td>Одна ошибка повторяется</td><td>Terminal input ошибочно признан временным</td><td>Проверить класс причины и счётчик попыток</td><td>Остановить цикл; создать manual record</td></tr><tr><td>Сообщение исчезло</td><td>Ack отправлен до записи результата</td><td>Сопоставить момент ack с записью эффекта</td><td>Исправить порядок подтверждения; восстановить задачу из журнала</td></tr><tr><td>Старая задача меняет новое состояние</td><td>Нет проверки версии или sequence</td><td>Сверить последний номер объекта и id сообщения</td><td>Отклонить stale input; не перезаписывать состояние назад</td></tr></tbody></table>\n<h2>Отрицательный путь: эффект создан, подтверждения нет</h2>\n<p>Самый неприятный случай не выглядит как обычная ошибка. Consumer вызвал внешний API. API мог принять запрос, но ответ пропал по сети. Consumer не знает результата и не должен автоматически считать его неуспешным. Повтор без ключа создаст второй эффект. Ack без проверки может потерять задачу. У этой ситуации должен быть отдельный статус, например <code>effect-result-unknown</code>, и способ проверить внешний факт.</p>\n<p>Ledger помогает только там, где он действительно связан с эффектом. Локальная запись «мы собирались отправить письмо» не доказывает, что письмо принял внешний провайдер. Для денег, доступа и других дорогих действий нужен provider id, ответ владельца эффекта или ручное решение с журналом. Не подменяйте отсутствие ответа успехом.</p>\n<h2>Порядок действий</h2>\n<ol><li>Зафиксировать <code>id</code>, <code>effectKey</code>, <code>sequenceKey</code>, номер, payload version, попытку и время.</li><li>Остановить рискованный следующий эффект, если состояние задачи или результат внешнего вызова неизвестны.</li><li>Проверить схему и обязательные поля. Непонятный вход не отправлять в бесконечный retry.</li><li>Сверить последний номер конкретного объекта. При gap сохранить сообщение и найти пропущенный переход.</li><li>Проверить ledger до повторного эффекта. Duplicate завершить без нового действия.</li><li>Классифицировать ошибку. Для известной временной причины применить записанный лимит и задержку.</li><li>Для terminal, unknown или исчерпанного retry создать manual record с причиной, попытками и обязательной проверкой.</li><li>После исправления подготовить новый контролируемый запуск. Сохранить ключ эффекта и проверить, что порядок не нарушен.</li></ol>\n<h2>Ограничения и критерий готовности</h2>\n<p>Идемпотентный ключ не делает два независимых сервиса одной транзакцией. Ack не гарантирует, что внешний эффект завершён. Версия не гарантирует доставку следующего события. Dead-letter маршрут не заменяет владельца решения. Гарантия «exactly once» от транспорта не означает, что прикладной эффект невозможно повторить. Эти свойства нужно проверять на границах конкретной системы.</p>\n<p>Пример выше учебный. Он не запускает брокер, базу, сеть или внешний сервис и не сообщает измерений нагрузки. Его можно использовать только для проверки формы контракта: duplicate не создаёт второй эффект, gap не применяется молча, stale input не откатывает состояние, а непонятное сообщение получает конечный маршрут.</p>\n<p>Контракт готов к реализации, когда для одной задачи можно показать цепочку «сообщение → решение consumer-а → запись эффекта → подтверждение» и ответить, что происходит при обрыве после каждого шага. Повторная доставка с тем же <code>effectKey</code> не создаёт новый результат. Gap сохраняется и не меняет состояние. Ошибка вне временного класса прекращает retry. По этим четырём проверкам решение можно обсуждать с владельцем домена и выбирать конкретный broker.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://www.rabbitmq.com/docs/reliability\" target=\"_blank\" rel=\"noopener noreferrer\">RabbitMQ Reliability Guide</a> — официально описывает consumer acknowledgements, redelivery и необходимость идемпотентного поведения при повторной доставке.</li><li><a href=\"https://www.rabbitmq.com/resources/specs/amqp-xml-doc0-9.pdf\" target=\"_blank\" rel=\"noopener noreferrer\">AMQP 0-9 specification</a> — первичная спецификация с параметром <code>redelivered</code> у доставки.</li><li><a href=\"https://kafka.apache.org/41/design/design/\" target=\"_blank\" rel=\"noopener noreferrer\">Apache Kafka: Message Delivery Semantics</a> — официальное объяснение различий at-most-once, at-least-once и exactly-once и их границ.</li></ul>"
|
||
}
|