Очереди задач: аренда и жизненный цикл джобы

Джоба — не сообщение: у неё есть владелец и срок владения. Как устроен цикл claim → lease → heartbeat → complete, что на самом деле удерживает джобу за воркером (не аренда), что происходит при смерти воркера и почему штатный SIGTERM при деплое способен выполнить работу дважды

Очередь задач выглядит как очередь сообщений, пока воркер не умрёт посреди работы. У сообщения судьба простая: доставлено или нет. У джобы — сложнее: её кто-то взял в работу, и пока он её не доделал, она не свободна и не потеряна. Она в подвешенном состоянии, у которого есть владелец и срок. Именно этот срок — аренда — и отличает очередь задач от очереди сообщений, и именно вокруг него ломаются реализации: воркер умирает, а джоба не возвращается; воркер жив, а джобу у него отбирают; деплой проходит штатно, а работа выполняется дважды.

Эта статья — про механику владения: из чего складывается жизненный цикл джобы, что на самом деле удерживает её за воркером и почему привычные объяснения здесь чаще всего неверны. Всё, что ниже, снято с живого стенда на PostgreSQL, включая три сценария отказа, каждый из которых проверялся обратным экспериментом — не «работает ли», а «сломается ли, если убрать проверяемый механизм».

Это первая статья серии «Фоновые задачи и планировщики». Вторая — про семантику расписаний в кластереготовится, с 19 сентября, третья — про то, что даёт и чего стоит очередь именно на PostgreSQLготовится, с 21 сентября.

Ретрофутуристическая схема в стиле «Полдень. XXI век»: джоба изображена как капсула на конвейере приборной панели, над ней — шкала обратного отсчёта аренды с делениями, механическая рука-манипулятор держит капсулу и периодически подкручивает шкалу обратно вверх; рядом вторая рука тянется к той же капсуле, но упирается в опущенный шлагбаум; поодаль — капсула с отработавшей до нуля шкалой, которую подцепляет возвратный рычаг

В статье

Джоба — не сообщение

Брокер сообщений отвечает на вопрос «доставлено ли». Очередь задач отвечает на другой: «выполнено ли». Разница не терминологическая — из неё вырастает вся механика.

Сообщение живёт коротко: продюсер отправил, консьюмер получил, подтвердил. Даже если обработка занимает время, брокер обычно не обязан знать, что с ней происходит — он знает про доставку и подтверждение. Джоба живёт долго: пережать видео, посчитать отчёт, сходить в чужой API с ретраями — это минуты, иногда часы. Всё это время джоба существует в состоянии, которого у сообщения просто нет: взята, но не завершена.

Такое состояние обязано быть представлено явно. Иначе на любом сбое система встаёт перед неразрешимым вопросом: джоба, которую никто не завершил, — она ещё выполняется или уже потеряна вместе с воркером? Отличить одно от другого по факту «не завершена» невозможно.

Ответ индустрии — срок владения. Воркер, забирая джобу, получает её не навсегда, а на время: visibility timeout в SQS, lease в большинстве DB-backed очередей, «невидимость» сообщения в других системах. Пока срок не истёк, джоба считается чужой. Истёк — считается брошенной и подлежит возврату в очередь.

Отсюда важное следствие, которое стоит проговорить сразу: аренда — это не блокировка. Блокировка удерживается соединением и умирает вместе с ним. Аренда — это запись о намерении с отметкой времени, которая переживает смерть владельца. Именно поэтому она работает там, где блокировка бесполезна: мёртвый воркер не может ни продлить аренду, ни снять её, и система узнаёт о его смерти по тому, что срок истёк и никто его не обновил.

At-least-once исполнения, а не доставки

Про гарантии доставки написано много, и обычно этого хватает: at-least-once означает, что сообщение может прийти повторно, поэтому обработчик должен быть идемпотентен. В очереди задач то же слово означает вещь заметно более неприятную.

At-least-once доставки — сообщение может быть доставлено дважды. Обработка при этом начинается дважды, и обе обработки идут последовательно: первая завершилась, потом пришёл дубль.

At-least-once исполнения — джоба может выполняться дважды одновременно. Не «повторно после», а «параллельно с». Первый воркер жив, работает, считает себя владельцем — а второй уже взял ту же джобу, потому что аренда истекла. Оба пишут результат.

Разница практическая. Идемпотентность по ключу защищает от повтора: вторая вставка с тем же ключом отвергается. От двух одновременных исполнений она защищает хуже — оба обработчика могут пройти проверку «результата ещё нет» до того, как хоть один запишет свой. Гонка check-then-act возникает не в очереди, а внутри вашего обработчика, и очередь про неё ничего не знает.

Именно поэтому аренда — это контракт с двумя обязанностями, а не одна настройка:

  • владелец обязан продлевать аренду, пока работает, — иначе его сочтут мёртвым;
  • система обязана возвращать джобу, чей владелец замолчал, — иначе джоба потеряна навсегда.

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

Жизненный цикл: claim → lease → heartbeat → complete

Минимальная схема очереди с арендой — одна таблица. В стенде к этой серии она выглядит так (полностью — в digital-cookbook/architecture/background-jobs):

CREATE TABLE jobs (
    id           BIGSERIAL PRIMARY KEY,
    kind         TEXT        NOT NULL,
    payload      JSONB       NOT NULL DEFAULT '{}'::jsonb,
    -- queued -> running -> done | failed
    state        TEXT        NOT NULL DEFAULT 'queued',
    run_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
    attempt      INT         NOT NULL DEFAULT 0,
    max_attempts INT         NOT NULL DEFAULT 3,
    -- Аренда: до какого момента джоба принадлежит воркеру leased_by.
    -- NULL у queued/done/failed; заполнены только у running.
    leased_until TIMESTAMPTZ,
    leased_by    TEXT,
    last_error   TEXT,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

Существенное здесь — не набор колонок, а инвариант между ними: поля аренды заполнены только у running. У queued, done и failed они обязаны быть NULL. Это не соглашение, а ограничение схемы:

CONSTRAINT jobs_lease_chk CHECK (
    (state =  'running' AND leased_until IS NOT NULL AND leased_by IS NOT NULL)
 OR (state <> 'running' AND leased_until IS     NULL AND leased_by IS     NULL)
)

Без него в таблицу спокойно пролезают два невозможных состояния: queued с чужой арендой и running без срока. Ни одно из них не нарушает ни типов, ни внешних ключей, поэтому база примет оба без единой жалобы. А ломают они возврат брошенных джоб: он ищет истёкшие аренды среди running и либо пропустит такую строку, либо вернёт в очередь джобу, которую никто не брал.

Цикл жизни джобы — четыре перехода.

Claim. Воркер забирает джобу: переводит queued → running, ставит себе аренду, увеличивает счётчик попыток. Одним запросом, а не «сначала выбрать, потом обновить»:

UPDATE jobs SET state = 'running',
                attempt = attempt + 1,
                leased_by = $1,
                leased_until = now() + $2::interval,
                updated_at = now()
WHERE id = (SELECT id FROM jobs
            WHERE state = 'queued' AND run_at <= now()
            ORDER BY run_at, id
            FOR UPDATE SKIP LOCKED
            LIMIT 1)
RETURNING id, kind, payload, attempt, max_attempts

Атомарность здесь обязательна. Разнесите выборку и обновление на два запроса — и двое воркеров выберут одну строку. Это не теоретическая опасность: в стенде есть тест на восьми одновременно стартующих воркерах, и на неатомарной версии он падает — джоба id=1 выдана 2 раз — двойное исполнение. Проверено заменой захвата на вариант «SELECT, затем отдельный UPDATE»; на исправном коде тест зелёный.

FOR UPDATE SKIP LOCKED в этом запросе отвечает не за корректность, а за пропускную способность — почему именно так, разбирается в третьей статье серииготовится, с 21 сентября.

Lease. Аренда — та самая отметка leased_until. Взяли на 30 секунд — значит, через 30 секунд без продления джоба считается брошенной.

Heartbeat. Пока воркер работает, он периодически двигает leased_until вперёд. В стенде — каждую треть срока аренды. Важная деталь: продлевать может только владелец.

UPDATE jobs SET leased_until = now() + $3::interval, updated_at = now()
WHERE id = $1 AND leased_by = $2 AND state = 'running'

Условие leased_by = $2 выглядит формальностью, но без него воркер, у которого джобу уже отобрали, «вернёт» её себе одним продлением — и владельцами будут считать себя двое.

Complete или fail. Завершение — тоже с проверкой владения. Если воркер доработал джобу, но аренду за это время потерял, он обязан узнать об этом и не затирать чужой результат.

И отдельно — reclaim, возврат брошенных. Это не переход джобы, а фоновая операция: найти running с истёкшей арендой и вернуть их в queued, обнулив поля аренды. Дальше выяснится, что именно эта операция — а не истечение срока само по себе — и возвращает джобу в оборот.

Что на самом деле удерживает джобу

Здесь стоит остановиться, потому что расхожее объяснение неверно, и я сам написал его в первой редакции стенда.

Обычно говорят так: «пока аренда жива, джобу никто не заберёт». Или так: «джоба заперта блокировкой». Оба утверждения ложны, и оба легко проверяются.

Блокировка не удерживает джобу. Захват — один автокоммитный UPDATE. Блокировка строки живёт ровно до конца этого запроса и снимается вместе с ним, задолго до того, как воркер закончит работу. Более того, блокировка умирает вместе с соединением: если бы джобу держала она, смерть воркера освобождала бы джобу мгновенно.

Живая аренда тоже не удерживает джобу. Посмотрите на запрос захвата ещё раз: в его WHERE есть state = 'queued' и run_at <= now(). Поля leased_until там нет вообще. Захват физически не смотрит на срок аренды.

Джобу удерживает состояние running — и только оно. А аренда нужна для другого: чтобы джобу когда-нибудь вернули. Это разные функции, и их постоянно путают:

Что делает Что было бы без этого
state = 'running' не даёт захватить джобу прямо сейчас двойное исполнение немедленно
leased_until даёт основание вернуть джобу, если владелец замолчал джоба зависла бы в running навсегда

Проверка этого различия — не придирка к формулировкам. Она объясняет, почему истечение аренды само по себе ничего не меняет: пока не отработал reclaim, джоба остаётся running и недоступна, хотя срок давно прошёл. Задержка возврата равна не сроку аренды, а сроку аренды плюс интервалу, с которым запускается reclaim. Второе слагаемое в расчётах забывают чаще, чем первое.

Смерть воркера: три точки наблюдения

Проверим это на стенде. Сценарий: аренда 6 секунд, «работа» 60 секунд, воркер убивается через 3 секунды — заведомо до истечения аренды.

=== АРТЕФАКТ 1: смерть воркера посередине джобы ===
--- состояние ПОКА воркер жив (аренда взята ~3с назад из 6с, ещё действует) ---
1|running|dying|t|1
--- воркер убит; джоба ВСЁ ЕЩЁ в state=running (аренда пока не истекла) ---
1|running|dying|t
--- другой воркер получает 0 джоб — но это ПОКА не доказательство: аренда ещё жива ---
воркер other-1: выполнено=0 потеряно_аренд=0
--- ждём истечения аренды; reclaim ПОКА НЕ запускаем ---
--- аренда истекла (lease_alive=f), но state всё ещё running — reclaim ещё не запускался ---
1|running|dying|f
--- КОНТРОЛЬНЫЙ эксперимент: второй воркер СНОВА получает 0 джоб — при УЖЕ ИСТЁКШЕЙ
    аренде и без единой удерживаемой блокировки строки ---
воркер other-2: выполнено=0 потеряно_аренд=0
--- только теперь запускаем reclaim: это ОН, а не сам факт истечения аренды,
    переводит джобу обратно в queued ---
reclaim: возвращено в очередь 1 джоб: [1]
1|queued||1
--- теперь джобу берёт другой воркер, попытка вторая ---
[other-3] завершил id=1
1|done|2

Три точки наблюдения, и только третья доказывает тезис.

Первая — воркер убит, аренда ещё жива, другой воркер получает 0 джоб. Сама по себе она не доказывает ничего: по ней нельзя отличить «держит состояние» от «держит аренда», потому что верны оба условия сразу. Демонстрация, остановившаяся здесь, выглядела бы убедительно и не доказывала бы ничего — так и было в первой редакции стенда.

Вторая — аренда истекла (lease_alive=f), reclaim ещё не запускался. Джоба всё ещё running.

Третья, решающая — при истёкшей аренде и без единой удерживаемой блокировки другой воркер снова получает 0 джоб. На этот момент единственное, что отличает джобу от queued, — состояние. Оба ложных объяснения исключены разом: блокировка снялась вместе с мёртвым процессом, срок аренды прошёл, а джоба недоступна.

И только явный reclaim возвращает её в оборот: возвращено в очередь 1 джоб: [1], дальше её берёт другой воркер со второй попыткой — attempt=2. Счётчик попыток растёт при захвате, а не при ошибке, и это правильно: воркер, умерший молча, попытку израсходовал.

Heartbeat: долгая джоба против чужого reclaim

Обратная задача: джоба честно выполняется дольше срока аренды. Работа 6 секунд, аренда 2 секунды. Без продления её отберут у живого воркера.

Обе ветки идут по одинаковому таймлайну, конкурирующий reclaim запускается в обеих на 4-й секунде — единственная разница между ними в том, включён ли heartbeat.

--- джоба 6с при аренде 2с, heartbeat ВКЛЮЧЁН; конкурирующий -reclaim — на 4-й секунде ---
reclaim: возвращено в очередь 0 джоб: []
[hb-on] завершил id=1
1|done|1
--- то же самое, heartbeat ВЫКЛЮЧЕН (падающий вариант); тот же reclaim на той же 4-й секунде ---
[hb-off] взял джобу id=1 kind=email попытка=1
reclaim: возвращено в очередь 1 джоб: [1]
[hb-off] джоба id=1 завершена, но аренда была потеряна — результат чужой
[hb-off] взял джобу id=1 kind=email попытка=2
[hb-off] завершил id=1
1|done|2

С heartbeat аренда продлевается быстрее, чем истекает: reclaim в тот же момент находит 0 джоб, работа доведена до конца одним воркером, attempt=1.

Без heartbeat аренда (2 секунды) короче работы (6 секунд): тот же reclaim находит 1 джобу, возвращает её в очередь, и джоба уходит на второй круг ещё до того, как первая попытка завершилась. Первый воркер доработал свою копию и честно сообщил: «аренда была потеряна — результат чужой». Итог attempt=2 — работа выполнена дважды.

Строка про потерянную аренду — не косметика. Это и есть та самая проверка владения при завершении: без неё воркер молча записал бы результат поверх чужого, и следов двойного исполнения в данных не осталось бы.

Отдельно стоит отметить, как эта демонстрация выглядела до внутреннего ревью. Конкурирующий reclaim запускался только в ветке без heartbeat — то есть ветки отличались двумя переменными, а не одной. Контрольный прогон показал: если в «правильной» ветке просто переключить флаг, результат не менялся. Демонстрация показывала эффект reclaim, выдавая его за эффект heartbeat, и рассыпалась бы у любого читателя, повторившего её своими руками. Проверка «поменяй одну переменную и убедись, что результат изменился» стоит дёшево и ловит такое сразу.

Деплой: SIGTERM посреди долгой джобы

Самый практичный сценарий из трёх, потому что случается не при аварии, а при штатной работе — каждый деплой.

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

Наивная реализация выглядит естественно: получили сигнал — досыпаем остаток работы и выходим. Проблема в том, что на время этого дренажа перестаёт идти heartbeat. Если работа длиннее аренды, аренда истекает прямо посреди штатного завершения, reclaim возвращает джобу в очередь, её подхватывает другой воркер — и graceful shutdown становится источником двойного исполнения. Ровно того, что он должен предотвращать.

Оба сценария идут по одинаковому таймлайну: SIGTERM через секунду после старта, конкурирующий reclaim — через пять секунд, работа 6 секунд, аренда 3 секунды.

=== СЦЕНАРИЙ A: heartbeat включён, SIGTERM во время работы ===
--- 5с после старта: ... проверяем, продлил ли её heartbeat ---
1|running|deploy-a|f|1
--- конкурирующий reclaim в тот же момент, что и в сценарии B ---
reclaim: возвращено в очередь 0 джоб: []
--- итоговое состояние джобы ---
1|done||1

=== СЦЕНАРИЙ B (падающий вариант): heartbeat выключен, тот же SIGTERM ===
--- 5с после старта: аренда (3с) истекла ещё ДО завершения работы (6с) ---
1|running|deploy-b|t|1
--- тот же конкурирующий reclaim: на этот раз находит истёкшую аренду ---
reclaim: возвращено в очередь 1 джоб: [1]
1|queued||1
--- второй воркер разбирает освободившуюся джобу, пока первый ещё дорабатывает свою копию ---
[rescuer] завершил id=1
--- итоговое состояние джобы: attempt=2 — джоба выполнена ДВАЖДЫ ---
1|done||2

Колонка «аренда_истекла» — прямое наблюдение, а не вывод из поведения reclaim: f в сценарии A, t в сценарии B. Реализация, которая это чинит, сводится к одной идее: сигнал должен снимать воркера с приёма новых джоб, не прерывая обслуживания текущей. В Go это выглядит так — канал сигнала обнуляется после первого срабатывания, а таймер heartbeat и дедлайн работы продолжают жить:

stopping := ctx.Done()
for {
    select {
    case <-deadline:
        return true
    case <-stopping:
        // Сигнал получен и учтён. nil-канал в select блокируется навсегда,
        // поэтому ветка больше не сработает, а heartbeat ниже продолжит
        // продлевать аренду до конца работы.
        stopping = nil
    case <-tick.C:
        // ... продление аренды
    }
}

Практический вывод для эксплуатации: terminationGracePeriodSeconds должен превышать самую долгую джобу, а heartbeat обязан работать всё это время. Первое помнят, второе — почти никогда. При этом первое без второго не спасает: воркер честно доработает джобу в отведённое время, но её у него отберут ещё до истечения этого времени.

Что смотреть в проде

Механика выше даёт готовый список того, что стоит выводить в метрики. Он короткий, и почти каждый пункт — прямое следствие разобранного.

Метрика Что означает Когда тревожно
Глубина очереди — число queued сколько работы ждёт растёт монотонно: воркеры не успевают
Возраст старейшей queued худшая задержка, а не средняя важнее глубины: очередь из тысячи свежих джоб здоровее очереди из десяти вчерашних
Число running с истёкшей арендой сколько джоб осиротело больше нуля надолго — reclaim не работает или не запускается
Частота возвратов в очередь как часто джобы приходится подбирать всплеск = воркеры массово умирают или аренда короче реальной работы
Распределение попыток сколько джоб идёт на второй-третий круг сдвиг вправо — обработчик стал падать
Число failed и глубина DLQ что не удалось совсем любой рост требует разбора: это не самоисправляется

Два акцента, которые не очевидны, пока не столкнёшься.

Возраст старейшей queued информативнее глубины. Глубина отвечает на вопрос «много ли работы», возраст — на вопрос «нарушаем ли мы обещание». Если джоба должна выполняться за минуту, а старейшая ждёт двадцать, глубина очереди уже не важна.

Число running с истёкшей арендой — метрика здоровья самого механизма. В норме оно колеблется около нуля: аренда истекает, reclaim возвращает джобу. Если оно стабильно ненулевое — возврат брошенных джоб не работает, и вы копите зависшие. Это то, что первая версия стенда молча делала неверно, потому что триггер срабатывал только на вставку.

PG-специфичные метрики очереди — мёртвые версии строк и отставание автовакуума — в третьей статьеготовится, с 21 сентября: они относятся не к очереди вообще, а к тому, что она живёт в PostgreSQL.

Что эта статья намеренно не разбирает

Очередь задач тянет за собой большой хвост тем, и почти каждая уже разобрана отдельно. Пересказывать их здесь значило бы делать это хуже:

  • Retry с экспоненциальным backoff и джиттером — в «Устойчивость: таймауты, retry, circuit breaker». Само по себе решение «повторить» ничем не отличается от повтора любого другого сетевого вызова; специфика очереди — только в том, что попытка расходуется при захвате.
  • Идемпотентность обработчика — в «Гарантии доставки и идемпотентность». С поправкой, о которой шла речь выше: в очереди задач нужна идемпотентность, устойчивая к одновременному исполнению, а не только к повтору.
  • Dead-letter и разбор «неберущихся» джоб — в «Гарантии доставки, durability и DLQ в RabbitMQ». Механика та же, меняется только хранилище.
  • Постановка джобы в одной транзакции с бизнес-данными — в «Transactional outbox и inbox». Для очереди на той же СУБД это получается бесплатно, и третья статья серииготовится, с 21 сентября на этом отдельно останавливается.
  • Где заканчивается очередь и начинается durable-воркфлоу — в «Temporal: durable execution». Короткий ориентир: одна джоба с ретраями — очередь; процесс из шагов, который живёт днями и требует компенсаций, — воркфлоу.

Ландшафт библиотек

Свою очередь пишут реже, чем берут готовую, и это правильно. Таблица ниже — не рекомендация по выбору, а способ узнать в своём инструменте механику, разобранную выше: у всех перечисленных есть срок владения, у всех есть возврат брошенных джоб, различается хранилище и то, насколько эти механизмы видны наружу.

Инструмент Экосистема Хранилище Как называется срок владения
Celery Python брокер (RabbitMQ/Redis) task_acks_late; visibility_timeout — только у Redis/SQS, у AMQP владение держит открытый канал
Sidekiq Ruby Redis job reservation, super_fetch в Pro
Asynq Go Redis lease с продлением
BullMQ Node.js Redis lock duration, extendLock
River Go PostgreSQL rescue зависших джоб по таймауту
Quartz JVM СУБД acquire с триггером и восстановление misfire
SQS AWS сервис visibility timeout, ChangeMessageVisibility

River в этом списке стоит особняком для нашей серии: он работает на том же PostgreSQL, что и стенд, и потому годится как прямой контраст. В стенде он есть отдельным модулем — те же принципы, но миграции, пул воркеров и восстановление зависших джоб уже написаны за вас. В его исходниках захват устроен тем же FOR UPDATE ... SKIP LOCKED, а спасение зависших вынесено в отдельный фоновый процесс — то есть механика ровно та, что разобрана выше, просто не ваша забота.

Разница в объёме кода — 412 строк против 92, но честно сравнивать их можно только с двумя оговорками. Первая: в «свои» 412 входит схема таблицы (64 строки), а миграции River в его 92 не входят вовсе — он создаёт свои таблицы сам. Вторая: из этих 412 примерно 16 строк — обработка неудачной попытки, которую ни одна демонстрация стенда не запускает; то есть часть «своего» кода написана впрок и живьём не проверена ни разу.

Вторая оговорка интереснее первой. Она и показывает, чего стоят такие сравнения: строки — не метрика качества и даже не метрика объёма работы, а грубый ориентир того, сколько механики придётся держать в голове самому.

Демо и версии

Стенд: digital-cookbook/architecture/background-jobs — очередь на PostgreSQL с арендой, heartbeat и возвратом брошенных джоб, демонстрации всех трёх сценариев выше, River для контраста и планировщик на advisory-локе (он понадобится второй статьеготовится, с 19 сентября).

Каждая демонстрация печатает не только результат, но и падающий вариант — что именно наблюдалось бы, если бы проверяемый механизм не работал. Это не украшение: два из разобранных выше сюжетов были в первой редакции стенда показаны неверно — демонстрация смерти воркера объясняла результат живой арендой, а демонстрация heartbeat сравнивала ветки, отличавшиеся двумя параметрами вместо одного. Поймал их именно контроль «сломай проверяемое место и убедись, что проверка падает», а не прогон «всё зелено».

Версии на момент прогона (2026-07-18): PostgreSQL 18.4, Go 1.26.3, github.com/jackc/pgx/v5 v5.10.0, github.com/riverqueue/river v0.40.0.

Числа в статье сняты на одной машине (Windows 11 + WSL2 + Docker Desktop) и приведены как иллюстрация механики, а не как ориентир производительности — секунды и сроки здесь заданы параметрами демонстрации, чтобы эффект был виден глазом.

Документация

Обсуждение в Telegram

Присоединиться →

Комментарии