Узнайте про шаблон cron + база данных для запуска плановых фоновых задач с повторными попытками, блокировкой и идемпотентностью — без разворачивания полноценной системы очередей.

Большинству приложений нужно, чтобы действия выполнялись позже или по расписанию: отправка напоминаний по электронной почте, ночная проверка биллинга, очистка старых записей, пересборка отчёта или обновление кеша.
На первых порах легко хочется подключить полноценную систему очередей — кажется, это «правильный» путь для фоновых задач. Но очереди добавляют новых движущихся частей: ещё один сервис для запуска, мониторинга, деплоя и отладки. Для небольшой команды (или одиноко работающего основателя) этот дополнительный вес может замедлить разработку.
Так встаёт реальный вопрос: как запускать плановые задачи надёжно, не поднимая лишнюю инфраструктуру?
Обычная первая попытка проста: добавить cron‑запись, которая вызывает endpoint, и пусть этот endpoint делает работу. Это работает — до первого сбоя. Как только у вас больше одного сервера, деплой в неудачный момент или задача выполняется дольше ожидаемого, начинаются запутанные ошибки.
Плановые задачи обычно ломаются предсказуемыми способами:
Шаблон cron + база данных — это компромисс. Cron по‑прежнему «будит» систему по расписанию, но намерения и состояние задач хранятся в базе данных, чтобы система могла координировать выполнение, делать повторы и фиксировать, что произошло.
Это подходит, когда у вас уже есть одна база данных (обычно PostgreSQL), небольшое количество типов задач и вы хотите предсказуемое поведение с минимальными операционными усилиями. Это также естественный выбор для приложений, быстро собранных на современных стеках (например, React + Go + PostgreSQL).
Это не подойдёт, когда нужна очень высокая пропускная способность, долгие задачи со стримингом прогресса, строгая последовательность между множеством типов задач или сильный фан‑аут (тысячи подзадач в минуту). В таких случаях полноценная очередь и выделенные воркеры обычно окупаются.
Шаблон cron + база данных запускает фоновые задачи по расписанию без разворачивания полноценной системы очередей. Cron (или любой планировщик) по‑прежнему используется, но cron не решает, что запускать. Он просто часто «будит» воркер (обычно раз в минуту). База данных решает, какая работа пора, и гарантирует, что каждая задача будет взята только одним воркером.
Представьте общую доску с чеклистом. Cron — это человек, который заходит в комнату каждую минуту и спрашивает: «Кому что нужно сейчас сделать?» База данных — доска, на которой видно, что запланировано, что уже взято и что выполнено.
Компоненты просты:
Пример: нужно отправлять напоминания по счетам каждое утро, обновлять кеш каждые 10 минут и чистить старые сессии ночью. Вместо трёх отдельных cron‑команд (каждая со своими режимами пересечений и отказов) вы храните записи задач в одном месте. Cron запускает тот же воркер. Воркер спрашивает PostgreSQL: «Что должно выполниться сейчас?» — и PostgreSQL позволяет воркеру безопасно захватить ровно одну задачу за раз.
Это масштабируется постепенно. Можно начать с одного воркера на одном сервере. Позже запустить пять воркеров на нескольких серверах. Контракт остаётся тем же: таблица — это контракт.
Сдвиг в мышлении прост: cron — только сигнал пробуждения. База данных — регулировщик движения, который решает, что разрешено выполнять, записывает результаты и даёт ясную историю при инцидентах.
Паттерн лучше всего работает, когда база данных становится источником правды о том, что должно выполниться, когда это должно выполниться и что произошло в прошлый раз. Схема несложная, но мелкие детали (поля блокировок и правильные индексы) важны по мере роста нагрузки.
Два распространённых подхода:
Если вы ожидаете частую отладку ошибок, храните историю. Если хотите минимальную настройку — начните с одной таблицы и добавьте историю позже.
Ниже макет для PostgreSQL. Если вы пишете на Go с PostgreSQL, эти колонки легко мапятся на структуры.
-- What should exist (the definition)
create table job_definitions (
id bigserial primary key,
job_type text not null,
payload jsonb not null default '{}'::jsonb,
schedule text, -- optional: cron-like text if you store it
max_attempts int not null default 5,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- What should run (each run / attempt group)
create table job_runs (
id bigserial primary key,
definition_id bigint references job_definitions(id),
job_type text not null,
payload jsonb not null default '{}'::jsonb,
run_at timestamptz not null,
status text not null, -- queued | running | succeeded | failed | dead
attempts int not null default 0,
max_attempts int not null default 5,
locked_by text,
locked_until timestamptz,
last_error text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
Пара деталей, которые сэкономят вам силы позже:
send_invoice_emails).jsonb, чтобы его можно было развивать без миграций.Без индексов воркеры будут сканировать слишком много строк. Начните с:
(status, run_at)(locked_until)queued и failed)Это сохраняет быстрый поиск «следующей задачи» даже при росте таблицы.
Цель проста: много воркеров могут работать, но только один должен взять конкретную задачу. Если два воркера обработают одну и ту же строку, вы получите дубли — письма, списания и т.д.
Безопасный подход — рассматривать заявку как «аренду» (lease). Воркер помечает задачу как заблокированную на короткий срок. Если воркер падает, аренда истекает и другую задачу может взять другой воркер. Для этого и нужен locked_until.
Без аренды воркер может заблокировать задачу и никогда её не разблокировать (процесс убит, сервер перезагружен, деплой прерван). С locked_until задача станет снова доступной после истечения времени.
Типичное правило: задачу можно заявить, когда locked_until равен NULL или locked_until <= now().
Ключевая деталь — заявить задачу в одном выражении (или в одной транзакции). Нужно, чтобы база была арбитром.
Вот распространённый паттерн в PostgreSQL: взять одну просроченную задачу, заблокировать её и вернуть воркеру. (Этот пример использует таблицу jobs; тот же принцип применим к job_runs.)
WITH next_job AS (
SELECT id
FROM jobs
WHERE status = 'queued'
AND run_at \u003c= now()
AND (locked_until IS NULL OR locked_until \u003c= now())
ORDER BY run_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
UPDATE jobs j
SET status = 'running',
locked_until = now() + interval '2 minutes',
locked_by = $1,
attempts = attempts + 1,
updated_at = now()
FROM next_job
WHERE j.id = next_job.id
RETURNING j.*;
Почему это работает:
FOR UPDATE SKIP LOCKED позволяет нескольким воркерам соревноваться, не блокируя друг друга.RETURNING отдаёт строку воркеру, который выиграл гонку.Установите аренду длиннее обычного времени выполнения, но достаточно короткой, чтобы при падении процесс быстро восстанавливался. Если большинство задач завершаются за 10 секунд, аренды в 2 минуты обычно достаточно.
Для долгих задач продлевайте аренду по ходу (heartbeat). Простой подход: каждые 30 секунд продлевать locked_until, если вы всё ещё владеете задачей.
WHERE id = $job_id AND locked_by = $worker_idПоследнее условие важно: оно не позволит воркеру продлить аренду на задачу, которой он больше не владеет.
Повторы — то место, где этот паттерн либо работает спокойно, либо превращается в шумный кошмар. Цель проста: при сбое задачи пытаться снова позже так, чтобы можно было объяснить, измерить и остановить повторы.
Начните с явного и конечного состояния задач: queued, running, succeeded, failed, dead. На практике failed означает «упало, но будет повторяться», а dead — «упало и мы сдались». Такое различие предотвращает бесконечные циклы.
Счётчик попыток — второй ограничитель. Храните attempts (сколько раз пробовали) и max_attempts (сколько раз разрешено). Когда воркер ловит ошибку, он должен:
attemptsfailed, если attempts < max_attempts, иначе deadrun_at для следующей попытки (только для failed)Бэкофф — это просто правило, которое решает следующую дату run_at. Выберите одно и документируйте:
Джиттер важен, когда зависимость падёт и восстановится. Без него сотни задач могут повториться в один и тот же момент и снова упасть.
Храните достаточную информацию об ошибке, чтобы видеть и отлаживать падения. Не нужна полноценная система логов, но необходимы базовые поля:
last_error (короткое сообщение, безопасное для админки)error_code или error_type (помогает группировать ошибки)failed_at и next_run_atlast_stack (если контролируете размер)Конкретное правило, которое часто работает: помечать задачи dead после 10 попыток и использовать экспоненциальный бэкофф с джиттером. Это даёт повторную обработку для временных сбоев и останавливает сломанные задачи.
Идемпотентность значит, что ваша задача может выполняться несколько раз, но итог будет один и тот же. В этом паттерне это важно, потому что одна и та же строка может быть взята снова после краха, таймаута или повтора. Если задача «отправить счёт по почте», повторный запуск может быть нежелателен.
Практический подход: разделите задачу на (1) выполнение работы и (2) применение эффекта. Эффект должен произойти один раз, даже если работа выполняется несколько раз.
Ключ идемпотентности должен исходить из того, что представляет задача, а не из попытки воркера. Хорошие ключи устойчивы и понятны: invoice_id, user_id + day, или report_name + report_date. Если две попытки касаются одного и того же реального события, у них должен быть один ключ.
Пример: «Сгенерировать ежедневный отчёт продаж за 2026-01-14» — ключ sales_report:2026-01-14. «Списать платёж по счёту 812» — invoice_charge:812.
Проще всего позволить PostgreSQL отвергнуть дубликаты. Храните ключ идемпотентности в индексируемом поле и добавьте уникальное ограничение.
-- Example: ensure one logical job/effect per business key
ALTER TABLE jobs
ADD COLUMN idempotency_key text;
CREATE UNIQUE INDEX jobs_idempotency_key_uniq
ON jobs (idempotency_key)
WHERE idempotency_key IS NOT NULL;
Это предотвратит существование двух строк с одинаковым ключом одновременно. Если ваша схема хранит историю, накладывайте уникальность на таблицу эффектов, например sent_emails(idempotency_key) или payments(idempotency_key).
Распространённые побочные эффекты, которые стоит защищать:
sent_emails с уникальным ключом перед отправкой или записывайте идентификатор сообщения провайдера после отправки.delivered_webhooks(event_id) и пропускайте, если запись уже есть.file_generated, ключом которой служит (type, date).Если вы строите на стеке с Postgres (например, Go + PostgreSQL), эти проверки уникальности быстры и удобно держать рядом с данными. Главная идея проста: повторы — нормальны, дубликаты — нет.
Выберите один скучный рантайм и придерживайтесь его. Цель паттерна — меньше движущихся частей, поэтому небольшой процесс на Go, Node или Python, который общается с PostgreSQL, обычно достаточно.
Создайте таблицы и индексы. Добавьте таблицу jobs (и дополнительные lookup‑таблицы по необходимости), индексируйте run_at и добавьте индекс, который поможет воркеру быстро находить доступные задачи (например, по (status, run_at)).
Напишите небольшую функцию постановки в очередь. Приложение вставляет строку с run_at = now() или будущим временем. Держите payload маленьким и предсказуемым (ID и тип задачи, не огромные blob'ы).
INSERT INTO jobs (type, payload, status, run_at, attempts, max_attempts)
VALUES ($1, $2::jsonb, 'queued', $3, 0, 10);
running в той же транзакции.WITH picked AS (
SELECT id
FROM jobs
WHERE status = 'queued' AND run_at \u003c= now()
ORDER BY run_at
FOR UPDATE SKIP LOCKED
LIMIT 10
)
UPDATE jobs
SET status = 'running', started_at = now()
WHERE id IN (SELECT id FROM picked)
RETURNING *;
Обработка и финализация. Для каждой заявленной задачи выполните работу, затем обновите статус на done с finished_at. Если упало — запишите сообщение об ошибке и верните в queued с новым run_at (бэкофф). Держите финализационные обновления небольшими и выполняйте их всегда, даже при завершении процесса.
Добавьте понятные правила повторов. Используйте простую формулу, например run_at = now() + (attempts^2) * interval '10 seconds', и останавливайте после max_attempts, выставляя status = 'dead'.
На первый день не нужен полноценный дашборд, но нужно достаточно, чтобы замечать проблемы:
Если вы уже на стеке Go + PostgreSQL, это хорошо мапится на один бинарный воркер плюс cron.
Представьте небольшой SaaS с двумя плановыми задачами:
Держите всё просто: одна таблица в PostgreSQL для задач и один воркер, запускаемый каждую минуту (cron). Воркер захватывает просроченные задачи, выполняет их и фиксирует успех или ошибку.
Вы можете ставить задачи в очередь из нескольких мест:
cleanup_nightly для «сегодня».send_weekly_report для следующего понедельника пользователя.send_weekly_report, который выполнится сразу для конкретного диапазона.Payload — минимально необходимый набор данных. Держите его маленьким, чтобы было легко повторять.
{
"type": "send_weekly_report",
"payload": {
"user_id": 12345,
"date_range": {
"from": "2026-01-01",
"to": "2026-01-07"
}
}
}
Воркер может упасть в самый худший момент: сразу после отправки письма, но до того, как пометит задачу как «done». При рестарте он может снова взять ту же задачу.
Чтобы избежать дублей, дайте работе естественный ключ дедупликации и храните его там, где база может наложить ограничение. Для еженедельных отчётов хороший ключ — (user_id, week_start_date). Перед отправкой воркер записывает «я собираюсь отправить отчёт X». Если запись уже есть — пропускает отправку.
Это может быть простая таблица sent_reports с уникальным ограничением на (user_id, week_start_date) или уникальный idempotency_key в самой задаче.
Предположим, провайдер почты таймаутит. Задача падает, и воркер:
attemptsЕсли продолжает падать за пределами лимита (например, 10 попыток), помечаем как dead и прекращаем повторы. Задача либо однажды выполняется успешно, либо повторяется по понятному графику, а идемпотентность делает повторы безопасными.
Паттерн прост, но мелкие ошибки могут привести к дубликатам, застрявшей работе или неожиданной нагрузке. Большинство проблем проявляются после первого краха, деплоя или всплеска трафика.
Основные инциденты происходят из нескольких ловушек:
locked_until. Если воркер падает после заявки, строка может остаться «в процессе» навсегда. Таймстамп аренды позволит другому воркеру подобрать задачу позже.user_id, invoice_id или ключ файла) и подгружайте остальное при выполнении.Пример: вы отправляете еженедельное письмо по счёту. Если воркер таймаутит после отправки, но до отметки задачи как выполненной, та же задача может быть повторена и отправить дубликат. Это нормально для паттерна, если вы не добавите защитный механизм (например, запись уникального события «письмо отправлено» по invoice_id).
Не смешивайте планирование и исполнение в одной долгой транзакции. Если держать транзакцию открытой во время сетевых вызовов, вы держите блокировки дольше и блокируете других воркеров.
Следите за расхождением часов между машинами. Используйте время базы (NOW() в PostgreSQL) как источник правды для run_at и locked_until, а не время сервера приложения.
Задайте явный максимум времени выполнения. Если задача может занимать 30 минут, делайте аренду длиннее и продлевайте её при необходимости. Иначе другой воркер может подобрать её в середине выполнения.
Держите таблицу задач в порядке. Если завершённые задачи накапливаются бесконечно, запросы замедляются и растёт конкуренция за блокировки. Выберите простое правило хранения/архивации старых строк до того, как таблица станет огромной.
Перед вводом в прод убедитесь, что базовые вещи на месте. Малое упущение часто превращается в застрявшие задачи, неожиданные дубли или воркер, который бьёт по базе.
run_at, status, attempts, locked_until и max_attempts (плюс last_error для видимости).invoice_id).dead.max_attempts.Если это выполнено — шаблон cron + база данных обычно стабилен для реальных рабочих нагрузок.
Когда чек‑лист удовлетворён, уделите внимание повседневной эксплуатации:
run_at = now() и очищает блокировку) и «отменить» (переводит в терминальный статус). Это экономит время при инцидентах.status, run_at).Если хотите быстро собрать такую систему, Koder.ai (koder.ai) может помочь пройти от схемы до деплоя Go + PostgreSQL с меньшим ручным кодированием, пока вы сосредотачиваетесь на правилах блокировок, повторов и идемпотентности.
Если позже вы перерастёте эту схему, вы всё равно поймёте жизненный цикл задач, и те же идеи послужат основой для полноценной системы очередей.