Сегодня про background jobs в.NET: Hangfire, Quartz, Worker Service и тот неловкий момент, когда job “почти точно выполнилась”, но система не уверена.
Снаружи все выглядит спокойно:
BackgroundJob.Enqueue<IInvoiceJob>( x => x.SendAsync(invoiceId, operationId));
Задача ушла в фон. Пользователь получил 200 OK. Где‑то вдалеке worker должен отправить счет, дернуть внешний сервис, пересчитать отчет или закрыть заявку.
А потом worker падает в самый неудобный момент: внешний сервис уже получил запрос, а наша база еще не успела записать Completed. На следующем запуске job уходит в retry. И вот тут начинается любимое продовое гадание: «оно выполнилось или просто красиво притворилось?»

Retry отвечает только на вопрос:
Можно ли попробовать еще раз?
Он не отвечает на вопрос:
Безопасно ли попробовать еще раз?
Если job считает кэш, повтор обычно не страшен. Если job списывает деньги, отправляет письмо, меняет статус договора или вызывает внешний API, повтор может стать не восстановлением, а дублем с хорошими намерениями.
Hangfire умеет автоматически повторять job после исключений. Quartz умеет планировать jobs, а при настройке persistent JobStore — хранить расписание в БД и работать в cluster‑режиме. Worker Service дает удобную базу для фонового процесса. Но ни один из этих инструментов сам не знает, что для вашего бизнеса значит «эта операция уже была выполнена».
Фреймворк видит exception. Бизнес видит второй счет клиенту. Разница небольшая, примерно как между «инцидент» и «созвон через 10 минут».

Я бы очень грубо разделил так:
Инструмент |
Когда уместен |
|---|---|
Hangfire |
Очереди фоновых задач, retries, dashboard, persistent storage |
Расписания, triggers, calendars, сложное планирование |
|
Worker Service |
Свой фоновой процесс, consumer, polling, кастомная логика |
Hangfire удобен, когда надо поставить задачу в очередь и видеть ее жизнь. Quartz хорош, когда важно расписание: «каждый рабочий день в 09:30, но не в праздник». Worker Service хорош, когда вы сами строите цикл обработки: читаете БД, очередь, API или делаете long‑running worker.
Но разница инструментов не отменяет общий принцип:
Background job должна переживать повторный запуск.
Даже если очередь надежная, процесс может умереть между двумя строками вашего кода.
Lock не равен дедупликации
Частая ловушка: «поставим lock, и дублей не будет».
Lock отвечает:
Можно ли двум worker’ам выполнять этот участок одновременно?
Dedup отвечает:
Выполняли ли мы уже этот бизнес‑эффект?
Это разные вопросы.
В Hangfire можно использовать DisableConcurrentExecution, в Quartz есть [DisallowConcurrentExecution] для запрета параллельного выполнения одного JobDetail. Это полезно, когда нельзя одновременно пересчитывать один отчет или запускать тяжелую синхронизацию.
Но lock не спасает от повторного выполнения завтра. И не спасает от сценария, где job выполнила внешний эффект, упала до записи статуса, а потом честно пошла в retry.

Для каждой важной job я бы заводил operationId. Не технический jobId из Hangfire или Quartz, а бизнес‑ключ операции:
public async Task SendAsync(long invoiceId, Guid operationId) { if (await effects.IsCompleted(operationId, "send-invoice")) return; await invoiceSender.SendAsync( invoiceId, idempotencyKey: operationId.ToString()); await effects.MarkCompleted(operationId, "send-invoice"); }
Здесь важная деталь: если есть внешний сервис, idempotency key должен уходить и туда. Локальная таблица effects полезна, но она не закрывает дыру «внешний сервис сделал действие, а мы упали до MarkCompleted».
Если внешний сервис не поддерживает idempotency key, нужен другой способ проверки:
запросить состояние перед повтором;
искать операцию по внешнему correlation id;
переносить спорные jobs в manual review;
делать reconciliation отдельным процессом.
Иногда честный ответ системы должен быть не «повторяю», а «я не знаю, надо проверить». Это неприятно. Зато лучше, чем уверенно продублировать платеж и потом изучать банковскую лексику от поддержки.
Retry policy: куда отправлять безнадежных
У retry должна быть граница.
Например:
Ошибка |
Что делать |
|---|---|
Timeout внешнего API |
retry с backoff |
429 / rate limit |
retry позже |
400 validation error |
не retry, сразу failed |
Неизвестный результат |
проверить статус или отправить в ручной разбор |
Баг в payload |
failed/DLQ до исправления данных |
Бесконечный retry выглядит заботливо, но на деле превращает очередь в стиральную машину для одних и тех же ошибок. Она шумит, греется и делает вид, что работает.

Что логировать и мерить
Для job мало лога «started» и «finished». В проде нужны поля, по которым потом можно понять, что произошло:
operationId;jobId;attempt;jobType;entityId;externalCorrelationId;duration;финальный статус:
completed,failed,unknown,skipped_duplicate.
Минимальные метрики:
сколько jobs ждут выполнения;
возраст самой старой job;
retry count;
failed jobs;
jobs в unknown/manual review;
длительность выполнения по типам;
количество dedup hits.
dedup hits особенно полезны. Если их стало много, система не просто «хорошо защищена от дублей». Возможно, она активно эти дубли производит.
Короткий чек‑лист
Перед тем как выкатить background job в production, я бы спросил:
Что является бизнес‑ключом операции?
Можно ли безопасно выполнить job два раза?
Есть ли idempotency key для внешнего API?
Чем lock отличается от dedup именно в этом сценарии?
Что будет, если worker упадет после внешнего эффекта?
Какие ошибки retry, а какие сразу failed?
Где окажется job с неизвестным результатом?
Можно ли руками переиграть или отменить job?
Есть ли метрики очереди, retry и возраста старых задач?
Можно ли найти все логи по одному
operationId?
Главная мысль:
Background job не обязана выполниться ровно один раз. Она обязана быть спроектирована так, чтобы повтор не превращался в новый бизнес‑эффект.
Hangfire, Quartz и Worker Service — нормальные инструменты. Просто они не знают, где у вас деньги, письма, статусы и пользователь, который нажал кнопку один раз.
В Telegram‑канале «@pro_it_live» отдельно выложу чек‑лист для background jobs, мини‑шаблон таблицы job_effects и decision tree: retry, skip, failed или manual review.
На что опирался
Hangfire: Dealing with exceptions — automatic retry для failed jobs.
Hangfire API: DisableConcurrentExecutionAttribute — атрибут для ограничения конкурентного выполнения.
Quartz.NET: More about jobs —
JobDetail,JobDataMap,[DisallowConcurrentExecution].Quartz.NET: Configuration reference — JobStore и clustering.
Microsoft: Background tasks with hosted services — hosted services и фоновые задачи в ASP.NET Core.
Microsoft: Worker services in.NET — Worker Service как модель фонового процесса.
Комментарии (5)

mogilovo
29.07.2026 05:36Интересное, но не проработанное. Интересно было бы приведение статьи к сагам или acid гарантиям типового функционала для сравнения механизмов. Касательно логгирования есть стандарты и они довольно известные. А вот обсервабилити в разных вариантах использования и их поддержки в инструментах конечно тоже были бы интересны.
Как начинание похвально. Если использовался ИИ то считаю что автору не хватает знаний использования потому что ИИ описал довольно посредственную статью.
P. S. Сейчас было бы интересно и наверное актуально аналоги в РФ которые доступны текущих в рамках. А еще интересен вопрос почему довольно часто пишут свои вокреры а не используют типовый даже от MS

totsamiynixon
29.07.2026 05:36Ответ на не поставленный вопрос - идемпотентность.
Если идемпотентно - ретраить безопасно. Если не идемпотентно - надо думать, лучше заретраить или лучше не ретраить.
Если есть Process Manager, то вероятно есть и Process State - туда можно буквально записывать после каждого шага, какой выполнился, а какой нет, и при ретрае скипать выполненные шаги или {представьте сюда свою логику}.
В общем проблеме лет столько же, сколько программированию и на все вопросы уже есть ответы.
Gromilo
Я всё понимаю, просите нейронку писать покороче, в информационном стиле. Но поиграйте с промтами, невозможно же читать такую рубленную подачу.
На цитате выше я сломался и бросил статью
pabel0071 Автор
Спасибо, замечание принимаю. Смысл фразы был в том, что retry работает с техническим failure, а для бизнеса важен возможный дубль операции. Но подача правда получилась слишком "цитатная". Поправлю текст, чтобы читалось спокойнее и без ощущения сгенерированной вставки.