{ "index": 276, "slug": "editorial-2020-05-practice-background-jobs", "title": "Фоновые задачи без потерь: запись намерения, повтор и результат", "excerpt": "Как вынести долгую операцию из HTTP-запроса и не потерять её между базой, брокером и worker: состояние задачи, outbox, идемпотентность, ack и карантин.", "contentHtml": "
Пользователь запускает экспорт и получает 202 Accepted. Через десять минут файла нет. В журнале виден входящий запрос, но непонятно, создали ли задачу, отправили ли сообщение и дошёл ли worker до записи результата. В другом варианте файл уже создан, worker падает до ack, а повторная доставка создаёт второй файл или повторно отправляет письмо. Ошибка стоит дорого: поддержка не может назвать состояние операции, разработчик не отличает потерю сообщения от дубля, а клиент повторяет опасный запрос.
Тезис простой: очередь не хранит бизнес-результат. Она переносит delivery от publisher к consumer. Смысл операции должен жить в записи приложения с устойчивым jobId. HTTP фиксирует намерение, dispatcher публикует сообщение, worker выполняет работу, сохраняет результат и только потом подтверждает delivery. После этого повтор считается обычной веткой, а не аварией, которую можно исключить настройкой.
У фоновой операции есть несколько границ. Запись задачи означает, что система приняла намерение. Запись outbox означает, что публикацию можно возобновить после перезапуска. Delivery означает, что брокер передал сообщение конкретному consumer. Сохранённый результат означает, что бизнес-действие завершилось. ack подтверждает только конкретное delivery. Он не доказывает, что файл существует, письмо ушло или строка в базе обновилась.
У каждой записи должен быть один идентификатор операции. В учебном примере это export-42. В строке задачи удобно хранить state, attempt, входной тип, ключ результата и последнюю безопасную для журнала ошибку. Секреты и полный payload в журнал не попадают. Пользователь получает jobId, а затем читает статус по нему. Ответ «принято» не должен маскироваться под «готово».
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
Есть queued, но нет delivery | Dispatcher не прочитал outbox или не получил подтверждение publisher | Найти outbox по jobId, проверить publishedAt и ошибку отправки | Повторить публикацию; не создавать новую задачу |
| Одно сообщение приходит несколько раз | Процесс упал после результата и до ack либо соединение закрылось | Сравнить attempt, state и ключ результата | Вернуть сохранённый результат и подтвердить повтор; повторить работу только при отсутствии результата |
| Очередь быстро растёт | Worker медленнее входящего потока или удерживает слишком много delivery | Сопоставить время обработки, backlog, prefetch и число активных worker | Ограничить параллелизм, добавить worker или принять backpressure |
| Одна задача бесконечно возвращается | Невалидный payload или постоянная ошибка зависимости | Посчитать попытки и сгруппировать lastErrorCode | Ограничить retry и направить сообщение в карантин |
Задача в succeeded, но результата нет | Состояние записали раньше внешнего эффекта или ключ результата не проверяется | Проверить объект по стабильному ключу и порядок транзакций | Не ставить успех до durable result; добавить восстановление или ручной разбор |
ack стоит после результата.Наивная последовательность выглядит так: сначала вставить задачу в базу, затем отправить сообщение. Сбой между двумя действиями оставляет строку queued без delivery. Обратный порядок создаёт другую дыру: worker получает сообщение, пока транзакция с задачей ещё не зафиксирована. Распределённая транзакция между базой и брокером решает не каждый сценарий и усложняет маленький сервис.
Практичный компромисс — сохранить задачу и outbox в одной транзакции приложения. Отдельный dispatcher выбирает outbox без отметки публикации. Он отправляет короткое сообщение { jobId, type, version } и ставит publishedAt только после подтверждения выбранного клиента брокера. Если dispatcher умер после отправки, но до отметки, он отправит сообщение снова. Поэтому consumer обязан выдерживать duplicate. Уникальность результата обеспечит не брокер, а контракт бизнес-операции.
// Учебный псевдокод. Это не готовый клиент брокера.\nasync function requestExport(input, db) {\n const jobId = `export-${input.accountId}-${input.period}`;\n\n await db.transaction(async (tx) => {\n await tx.insertJob({\n id: jobId,\n type: 'report.export',\n state: 'queued',\n attempt: 0,\n resultKey: null\n });\n await tx.insertOutbox({\n type: 'report.export.requested',\n jobId,\n version: 1,\n publishedAt: null\n });\n });\n\n return { accepted: true, jobId };\n}\nЗдесь jobId намеренно стабилен для выбранного набора входных данных. Реальный проект должен решить, допустимы ли два экспорта одного периода. Если допустимы, идентификатор включает уникальный request key. Если нет, уникальный индекс или условная вставка должны остановить второй запуск. Нельзя получить идемпотентность только из названия очереди.
Worker читает запись задачи по jobId, а не доверяет payload как единственному источнику состояния. Он проверяет финальные состояния. Для succeeded достаточно подтвердить повторное delivery. Для quarantined нужно подтвердить delivery и оставить причину доступной оператору. Для активной задачи worker выполняет действие с устойчивым ключом результата.
Безопасный порядок для учебного экспорта такой: взять задачу, пометить попытку, создать файл по ключу reports/export-42.csv, проверить, что запись устойчива, перевести задачу в succeeded, отправить ack. Сбой после сохранения файла и до ack вызовет повтор. Повтор увидит существующий ключ, не создаст второй файл и подтвердит новое delivery. Сбой до сохранения результата оставит задачу для повторной попытки.
// Учебный обработчик. API jobs и broker абстрактен.\nasync function handleDelivery(delivery, jobs, broker) {\n const job = await jobs.findForUpdate(delivery.jobId);\n\n if (job.state === 'succeeded' || job.state === 'quarantined') {\n await broker.ack(delivery.tag);\n return;\n }\n\n try {\n await jobs.markRunning(job.id, delivery.attempt);\n const resultKey = `reports/${job.id}.csv`;\n await writeReportOnce(resultKey, job.payload);\n await jobs.markSucceeded(job.id, resultKey);\n await broker.ack(delivery.tag);\n } catch (error) {\n const nextAttempt = delivery.attempt + 1;\n await jobs.markRetryOrQuarantine(job.id, nextAttempt, error.code);\n await broker.nack(delivery.tag, { requeue: nextAttempt < 3 });\n }\n}\nФункция writeReportOnce здесь обозначает контракт, а не готовую библиотеку. Она может использовать уникальный ключ объекта, условную вставку или идемпотентный endpoint внешнего сервиса. Если внешний сервис не поддерживает повтор безопасно, состояние задачи не может в одиночку отменить уже отправленное письмо или платёж. Для такого эффекта нужен ключ идемпотентности на внешней стороне либо отдельный статусный протокол.
Повтор подходит для временной ошибки: короткого сетевого сбоя, временной недоступности зависимости или превышения лимита. Он не исправляет неверную схему сообщения. Для постоянной ошибки нужен предел попыток, задержка между ними и отдельная очередь карантина. Иначе poison message снова попадает в начало очереди, worker тратит время на одну и ту же ошибку, а полезные задачи ждут.
\nНе ставьте минимальную задержку без причины. Три мгновенных повтора могут усилить аварию зависимости. Для временной ошибки задайте ограниченное число попыток и возрастающую задержку. Для ошибки валидации отправляйте задачу сразу в карантин. В записи сохраняйте код причины и номер последней попытки, но не полный ответ внешней системы с персональными данными.
\nОчередь не заменяет ограничение нагрузки. Если worker получает новые delivery быстрее, чем завершает старые, растут backlog и память. Ограничение prefetch уменьшает число незавершённых delivery на consumer, но не ускоряет обработку. Если одна задача блокирует worker надолго, отделите её очередь или задайте лимит времени. Таймаут должен переводить задачу в понятное состояние, а не просто обрывать функцию без записи.
\njobId, состоянием, попыткой, ключом результата и безопасной причиной ошибки.202 и jobId, а не обещание готового результата.jobId или ключу результата. Сначала сохраните бизнес-результат и состояние, затем отправьте ack.Эта схема не даёт exactly-once. Она даёт повторяемость и место, где проверить результат. Между сохранением результата и ack всегда остаётся окно, поэтому бизнес-действие должно выдерживать duplicate. Outbox не делает публикацию атомарной с брокером. Publisher confirm подтверждает взаимодействие publisher с узлом брокера, а не обработку сообщения worker.
Пример не содержит настоящего подключения к RabbitMQ, настройки очереди, TLS, прав, транзакций конкретной СУБД или измеренных значений latency. Имена export-42, ключ файла и число попыток — учебные данные. Их нельзя выдавать за результат нагрузочного теста. В реальной системе отдельно проверяют срок хранения outbox, рост карантина, восстановление после рестарта, размер payload, таймаут внешнего API и удаление чувствительных данных.
Если действие необратимо, например платёж или отправка письма, запись результата после вызова может не решить двойное выполнение. Нужен idempotency key, который принимает внешний сервис, или промежуточный статус с ручным подтверждением. Если контракт отсутствует, безопаснее остановить автоматический retry и передать операцию в разбор, чем обещать автоматическую надёжность.
\nРешение готово, когда по одному jobId можно определить: записано ли намерение, опубликован ли outbox, сколько было delivery, какой результат сохранён и почему задача остановилась. Тестовый повтор после сбоя между результатом и ack не создаёт второй результат. Невалидная задача после заданного числа попыток попадает в карантин. Сбой dispatcher не теряет outbox. Эти проверки должны видеть состояние базы, сообщения и результат, а не только код ответа HTTP.
ack, nack и requeue.