Назад в блог
Заказное ПО

Cron job: как надёжно запускать задачи по расписанию

Что такое cron job и как читать cron-выражение? Почему задачи по расписанию (ночные отчёты, выставление счетов, бэкапы) молча ломаются: двойной запуск на нескольких серверах, часовые пояса, пропущенные запуски и мониторинг. Идемпотентность через ключ периода и связь с очередями.

Cron JobZamanlanmış GörevBackendÖzel Yazılım

Cron job это задача, которая сама запускается в заданное время или через равные интервалы: каждую ночь в 02:00 делать бэкап, каждое утро отправлять отчёт о продажах, каждый час обновлять статусы доставки, первого числа месяца выставлять счета по подпискам. Название пришло от программы cron в Unix, где расписание задаётся выражением из пяти полей «минута час день месяц день-недели». Запланировать задачу легко; сложно сделать так, чтобы она не запускалась дважды, чтобы пропущенный запуск был замечен и чтобы она не ломалась молча. В статье разбираем cron-выражение, самые частые причины сбоев и безопасную схему, которую мы применяем в проектах заказной разработки.

Как читать cron-выражение?

Слева направо пять полей: минута (0-59), час (0-23), день месяца (1-31), месяц (1-12) и день недели (0-6, воскресенье 0). Звёздочка значит «каждый», запятая задаёт список, дефис диапазон, косая черта шаг. Несколько примеров:

  • 0 2 * * *: каждый день в 02:00 (ночной бэкап, дневной отчёт).
  • */15 * * * *: каждые 15 минут (синхронизация статусов доставки или оплаты).
  • 0 9 * * 1-5: по будням в 09:00 (утренняя сводка).
  • 0 0 1 * *: в полночь первого числа месяца (ежемесячные счета, продление подписок).
  • 30 23 * * 0: каждое воскресенье в 23:30 (еженедельная очистка, архивирование старых записей).

Cron-выражение интерпретируется в каком-то часовом поясе, и обычно это часы сервера. Если сервер работает в UTC, «0 2 * * *» означает 05:00 по Москве. Поэтому часовой пояс планировщика задаётся явно и не оставляется по умолчанию; в странах с переходом на летнее время задачи между 02:00 и 03:00 могут в один день года не запуститься вовсе или запуститься дважды.

Где запускается задача по расписанию?

  • Crontab сервера: классическая схема с одним сервером. Просто, но при смене сервера задачу забывают, и она не версионируется вместе с кодом.
  • Планировщик внутри приложения: библиотеки вроде node-cron, Quartz или Hangfire. Живёт вместе с кодом, но если запущено несколько экземпляров приложения, задача срабатывает в каждом.
  • Планировщик платформы: Kubernetes CronJob, триггеры по расписанию у облачных провайдеров, Vercel Cron. Платформа запускает один раз, а работа остаётся в вашем коде.
  • Повторяющиеся задачи в очереди: очереди вроде BullMQ или Sidekiq сами хранят описание задачи и сразу дают повторы и историю запусков.

Самая частая ошибка: задача запускается дважды

Когда приложение масштабируется до двух серверов, встроенный планировщик тоже удваивается, и ночной отчёт уходит дважды. Для отчёта это досадно, для счетов или платёжных поручений это серьёзная ошибка. Мало того, чтобы задача запускалась один раз; сама работа должна давать тот же результат при повторном выполнении. Надёжнее всего дать каждому запуску ключ периода, например «ежемесячные счета, 2026-10». При старте задача пытается создать запись с этим ключом; если запись уже есть, работу взял другой экземпляр, и этот завершается. Та же таблица хранит, когда задача началась, когда закончилась и была ли ошибка. Это та же идея, что и идентификатор события у получателей вебхуков, о которой мы писали в статье о вебхуках.

// 1-го числа каждого месяца в 03:00 по времени Турции: '0 3 1 * *' (tz: Europe/Istanbul)
export async function monthlyInvoicesJob(now = new Date()) {
  const period = formatInTimeZone(now, 'Europe/Istanbul', 'yyyy-MM');
  const runKey = 'monthly-invoices:' + period;

  // Уникальный индекс на job_runs.run_key: второй экземпляр сюда не попадёт.
  const run = await db.jobRuns.insertIfAbsent({
    runKey,
    startedAt: new Date(),
    status: 'running',
  });
  if (!run) return; // этот период уже обработан или обрабатывается

  try {
    const subs = await db.subscriptions.dueForBilling(period);
    for (const sub of subs) {
      // Счёт уникален по периоду + подписке: прерванный запуск безопасно стартует заново.
      await billing.createInvoiceOnce(sub.id, period);
    }
    await db.jobRuns.finish(runKey, 'succeeded');
  } catch (err) {
    await db.jobRuns.finish(runKey, 'failed');
    throw err; // пусть дойдёт до системы отслеживания ошибок
  }
}
Самое опасное состояние задачи по расписанию не ошибка, а отсутствие запуска: ошибку записывают в лог и видят, а задача, которая не запустилась, молчит, пока кто-нибудь не заметит. Каждой критичной задаче нужен мониторинг, отвечающий на вопрос «когда был последний успешный запуск».

Пропущенные запуски и мониторинг

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

Долгие задачи и очереди

Cron это триггер, а не исполнитель. Выставлять счета десяти тысячам подписчиков в одном цикле значит при ошибке на середине не знать, какие счета уже созданы, и рисковать пересечением со следующим запуском. Правильная схема: cron только кладёт работу в очередь, а воркеры очереди обрабатывают каждый элемент отдельно, с повторами. Тогда ошибка у одного подписчика не останавливает остальных, а нагрузка распределяется по дню. То же правило работает для задач, зависящих от внешнего сервиса, например электронных счетов: когда другая сторона тормозит, очередь ждёт, а задача не падает по таймауту; об этом в статье об интеграции электронных счетов. Где жить задачам, в отдельном сервисе или в том же приложении, решает архитектура; см. микросервисы или монолит.

Чек-лист перед запуском

  • Часовой пояс задан явно, а часы перехода на летнее время не используются?
  • При нескольких экземплярах приложения задача всё равно срабатывает один раз?
  • Повторное выполнение работы даёт тот же результат (ключ периода, уникальный индекс)?
  • Пропущенный период догоняется при следующем запуске?
  • Последний успешный запуск отслеживается и при просрочке вызывает тревогу?
  • Описание задачи версионируется вместе с кодом и проверяется в CI/CD-конвейере?

Итог

Cron job это небольшая часть системы, которая вместо человека делает работу, о которой нельзя забыть; но небольшой размер не повод оставлять её без контроля. Безопасная задача по расписанию задаётся с явным часовым поясом, работает в одном экземпляре, не вредит при повторном запуске, догоняет пропущенные периоды и сообщает, когда не запустилась. Если вы хотите построить задачи выставления счетов, отчётов, синхронизации и бэкапов по этим принципам, посмотрите наше направление заказной разработки или сразу запросите смету.

Let's Build Your Project

Get a free consultation for your website, mobile app, or corporate software project.

Get a Free QuoteExplore our Custom Software service