{ "index": 276, "slug": "editorial-2020-05-practice-background-jobs", "title": "Фоновые задачи: как не терять намерение и переживать повторы", "excerpt": "Как вынести долгую операцию из HTTP-запроса и сохранить проверяемое состояние между базой, брокером и worker: outbox, идемпотентность, ack и карантин.", "contentHtml": "

Пользователь запускает экспорт и получает 202 Accepted. Через десять минут файла нет. В журнале виден входящий запрос, но непонятно, создали ли задачу, отправили ли сообщение и дошёл ли worker до записи результата. В другом варианте файл уже создан, worker падает до ack, а повторная доставка создаёт второй файл или повторно отправляет письмо. Ошибка стоит дорого: поддержка не может назвать состояние операции, разработчик не отличает потерю сообщения от дубля, а клиент повторяет опасный запрос.

\n

Рабочая граница такая: очередь не хранит бизнес-результат. Она переносит delivery от publisher к consumer. Смысл операции должен жить в записи приложения с устойчивым jobId. HTTP фиксирует намерение, dispatcher публикует сообщение, worker выполняет работу, сохраняет результат и только потом подтверждает delivery. После этого повтор считается обычной веткой, а не аварией, которую можно исключить настройкой.

\n

Сценарий: проверяем один экспорт

\n

Вернёмся к исчезнувшему файлу. Сначала по jobId проверяем запись задачи: если её нет, запрос не зафиксировал намерение; если состояние queued, смотрим outbox; если есть publishedAt, ищем delivery и журнал worker. Предположение «сломался брокер» подтверждается только тогда, когда outbox есть, публикации нет, а worker не получал сообщение. Если resultKey уже записан, проблема находится не в доставке: нужно проверить выдачу файла и статус задачи. Такая последовательность отделяет потерю публикации от повтора после результата и возвращает расследованию один наблюдаемый идентификатор.

\n

Механизм: четыре разных факта

\n

У фоновой операции есть несколько границ. Запись задачи означает, что система приняла намерение. Запись outbox означает, что публикацию можно возобновить после перезапуска. Delivery означает, что брокер передал сообщение конкретному consumer. Сохранённый результат означает, что бизнес-действие завершилось. ack подтверждает только конкретное delivery. Он не доказывает, что файл существует, письмо ушло или строка в базе обновилась.

\n

У каждой записи должен быть один идентификатор операции. В учебном примере это export-42. В строке задачи удобно хранить state, attempt, входной тип, ключ результата и последнюю безопасную для журнала ошибку. Секреты и полный payload в журнал не попадают. Пользователь получает jobId, а затем читает статус по нему. Ответ «принято» не должен маскироваться под «готово».

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Есть queued, но нет deliveryDispatcher не прочитал outbox или не получил подтверждение publisherНайти outbox по jobId, проверить publishedAt и ошибку отправкиПовторить публикацию; не создавать новую задачу
Одно сообщение приходит несколько разПроцесс упал после результата и до ack либо соединение закрылосьСравнить attempt, state и ключ результатаВернуть сохранённый результат и подтвердить повтор; повторить работу только при отсутствии результата
Очередь быстро растётWorker медленнее входящего потока или удерживает слишком много deliveryСопоставить время обработки, backlog, prefetch и число активных workerОграничить параллелизм, добавить worker или принять backpressure
Одна задача бесконечно возвращаетсяНевалидный payload или постоянная ошибка зависимостиПосчитать попытки и сгруппировать lastErrorCodeОграничить retry и направить сообщение в карантин
Задача в succeeded, но результата нетСостояние записали раньше внешнего эффекта или ключ результата не проверяетсяПроверить объект по стабильному ключу и порядок транзакцийНе ставить успех до durable result; добавить восстановление или ручной разбор
\n
Жизненный цикл фоновой задачи: HTTP сохраняет задачу и outbox, dispatcher публикует сообщение, worker сохраняет результат, затем отправляет ack; ошибка уходит в повтор или карантин.
У операции есть отдельные границы: запись намерения, публикация, delivery, результат и подтверждение. Схема показывает, где искать потерю и почему ack стоит после результата.
\n

Запись и публикация

\n

Наивная последовательность выглядит так: сначала вставить задачу в базу, затем отправить сообщение. Сбой между двумя действиями оставляет строку queued без delivery. Обратный порядок создаёт другую дыру: worker получает сообщение, пока транзакция с задачей ещё не зафиксирована. Распределённая транзакция между базой и брокером решает не каждый сценарий и усложняет маленький сервис.

\n

Практичный компромисс — сохранить задачу и outbox в одной транзакции приложения. Отдельный dispatcher выбирает outbox без отметки публикации. Он отправляет короткое сообщение { jobId, type, version } и ставит publishedAt только после подтверждения выбранного клиента брокера. Если dispatcher умер после отправки, но до отметки, он отправит сообщение снова. Поэтому consumer обязан выдерживать duplicate. Уникальность результата обеспечит не брокер, а контракт бизнес-операции.

\n
// Учебный псевдокод. Это не готовый клиент брокера.\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      params: {\n        accountId: input.accountId,\n        period: input.period\n      },\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. Если нет, уникальный индекс или условная вставка должны остановить второй запуск. Нельзя получить идемпотентность только из названия очереди.

\n

Worker и порядок ack

\n

Worker читает запись задачи по jobId, а не доверяет payload как единственному источнику состояния. Он проверяет финальные состояния. Для succeeded достаточно подтвердить повторное delivery. Для quarantined нужно подтвердить delivery и оставить причину доступной оператору. Для активной задачи worker выполняет действие с устойчивым ключом результата.

\n

Безопасный порядок для учебного экспорта такой: взять задачу, пометить попытку, создать файл по ключу reports/export-42.csv, проверить, что запись устойчива, перевести задачу в succeeded, отправить ack. Сбой после сохранения файла и до ack вызовет повтор. Повтор увидит существующий ключ, не создаст второй файл и подтвердит новое delivery. Сбой до сохранения результата оставит задачу для повторной попытки.

\n
// Учебный обработчик. 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  const attempt = job.attempt + 1;\n\n  try {\n    await jobs.markRunning(job.id, attempt);\n    const resultKey = `reports/${job.id}.csv`;\n    await writeReportOnce(resultKey, job.params);\n    await jobs.markSucceeded(job.id, resultKey);\n    await broker.ack(delivery.tag);\n  } catch (error) {\n    await jobs.markRetryOrQuarantine(job.id, attempt, error.code);\n    await broker.nack(delivery.tag, { requeue: attempt < 3 });\n  }\n}
\n

Функция writeReportOnce здесь обозначает контракт, а не готовую библиотеку. Она может использовать уникальный ключ объекта, условную вставку или идемпотентный endpoint внешнего сервиса. Если внешний сервис не поддерживает повтор безопасно, состояние задачи не может в одиночку отменить уже отправленное письмо или платёж. Для такого эффекта нужен ключ идемпотентности на внешней стороне либо отдельный статусный протокол.

\n

Повтор, backpressure и карантин

\n

Повтор подходит для временной ошибки: короткого сетевого сбоя, временной недоступности зависимости или превышения лимита. Он не исправляет неверную схему сообщения. Для постоянной ошибки нужен предел попыток, задержка между ними и отдельная очередь карантина. Иначе poison message снова попадает в начало очереди, worker тратит время на одну и ту же ошибку, а полезные задачи ждут.

\n

Не ставьте минимальную задержку без причины. Три мгновенных повтора могут усилить аварию зависимости. Для временной ошибки задайте ограниченное число попыток и возрастающую задержку. Для ошибки валидации отправляйте задачу сразу в карантин. В записи сохраняйте код причины и номер последней попытки, но не полный ответ внешней системы с персональными данными.

\n

Очередь не заменяет ограничение нагрузки. Если worker получает новые delivery быстрее, чем завершает старые, растут backlog и память. Ограничение prefetch уменьшает число незавершённых delivery на consumer, но не ускоряет обработку. Если одна задача блокирует worker надолго, отделите её очередь или задайте лимит времени. Таймаут должен переводить задачу в понятное состояние, а не просто обрывать функцию без записи.

\n

Порядок внедрения

\n
  1. Назовите одну операцию и её границу. Запишите, что пользователь считает «принято», «выполнено» и «отклонено».
  2. Создайте модель задачи с устойчивым jobId, состоянием, попыткой, ключом результата и безопасной причиной ошибки.
  3. Сохраните задачу и outbox в одной транзакции. Верните клиенту 202 и jobId, а не обещание готового результата.
  4. Настройте dispatcher, который повторяет неопубликованный outbox и отмечает публикацию только после подтверждения клиента брокера.
  5. Сделайте worker идемпотентным по jobId или ключу результата. Сначала сохраните бизнес-результат и состояние, затем отправьте ack.
  6. Разделите временные и постоянные ошибки. Для временных задайте лимит и задержку, для постоянных — карантин с причиной.
  7. Добавьте статусные поля и журнал переходов: received, running, retry, succeeded, quarantined. Не записывайте секреты и лишний payload.
  8. Проверьте четыре сбоя отдельно: падение до публикации, после публикации, после результата до ack и на третьей неудачной попытке.
\n

Ограничения

\n

Эта схема не даёт exactly-once. Она даёт повторяемость и место, где проверить результат. Между сохранением результата и ack всегда остаётся окно, поэтому бизнес-действие должно выдерживать duplicate. Outbox не делает публикацию атомарной с брокером. Publisher confirm подтверждает взаимодействие publisher с узлом брокера, а не обработку сообщения worker.

\n

Пример не содержит настоящего подключения к RabbitMQ, настройки очереди, TLS, прав, транзакций конкретной СУБД или измеренных значений latency. Имена export-42, ключ файла и число попыток — учебные данные. Их нельзя выдавать за результат нагрузочного теста. В реальной системе отдельно проверяют срок хранения outbox, рост карантина, восстановление после рестарта, размер payload, таймаут внешнего API и удаление чувствительных данных.

\n

Если действие необратимо, например платёж или отправка письма, запись результата после вызова может не решить двойное выполнение. Нужен idempotency key, который принимает внешний сервис, или промежуточный статус с ручным подтверждением. Если контракт отсутствует, безопаснее остановить автоматический retry и передать операцию в разбор, чем обещать автоматическую надёжность.

\n

Критерий готовности

\n

Решение готово, когда по одному jobId можно определить: записано ли намерение, опубликован ли outbox, сколько было delivery, какой результат сохранён и почему задача остановилась. Тестовый повтор после сбоя между результатом и ack не создаёт второй результат. Невалидная задача после заданного числа попыток попадает в карантин. Сбой dispatcher не теряет outbox. Эти проверки должны видеть состояние базы, сообщения и результат, а не только код ответа HTTP.

\n

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

" }