Go cron библиотеки: простые планировщики против распределённых задач
Сравните Go cron библиотеки с распределёнными планировщиками, поймите, где должна жить каждая задача, и избегайте дублей, пропущенных запусков и путаницы с владельцем.

Почему это решение часто создаёт проблемы
Проблемы с планированием редко начинаются с библиотеки. Обычно они начинаются тогда, когда одна и та же задача может запускаться из трёх мест сразу: внутри веб-приложения, внутри воркера или через server cron. Команда добавляет одно быстрое исправление, потом ещё одно, а через полгода уже никто не помнит, какая версия — настоящая.
Ситуация становится хуже, когда инструмент выбирают раньше, чем называют цену ошибки. Ночной cleanup, повторная попытка списания и экспорт зарплаты не должны решаться одинаково. Если никто не спрашивает: «Что будет, если это выполнится поздно, не выполнится вообще или выполнится дважды?», выбор превращается в гадание.
Поздний запуск и пропущенный запуск — это разные проблемы. Если отчёт приходит на 15 минут позже, большинство клиентов просто пожмёт плечами. Если продление подписки не произойдёт совсем, компания потеряет деньги. Если экспорт для налоговой выполнится дважды, кому-то придётся в пятницу вручную исправлять записи.
Двойные запуски создают самые неприятные сбои, потому что сначала они выглядят безобидно. Одно лишнее письмо просто раздражает пользователя. Двойное списание по счёту создаёт возвраты и нагрузку на поддержку. Повторная синхронизация данных может перезаписать свежие записи устаревшими, и заметить это гораздо сложнее, чем простой сбой задачи.
Небольшие SaaS-команды часто приходят к одной и той же схеме. Веб-приложение отправляет письма с напоминаниями при старте. Фоновый воркер проверяет те же напоминания каждую минуту. На сервере всё ещё лежит старый cron entry от прежней версии. В итоге владелец задачи размыт. Когда запускать задачи может каждый сервис, никто толком не отвечает за повторы, логи и алерты. Продукт думает, что это зона ответственности инженеров. Инженеры думают, что это задача ops. Ops считает, что всё делает приложение.
Вот почему споры о scheduler'ах кажутся важнее, чем есть на самом деле. Настоящий вопрос не в том, какой Go package лучше. Настоящий вопрос — где должна жить работа по времени, кто имеет право её запускать и какой ущерб может нанести один плохой запуск.
Команды, которые пропускают это решение, обычно получают один и тот же результат: задачи расползаются по стеку, появляются тихие дубли и куча «я думал, это делает другой сервис».
Что хорошо умеет простой scheduler внутри процесса
Большинство scheduler'ов из этой группы работают внутри того же Go-процесса, что и API, воркер или админское приложение. Из-за этого их легко понять. Вы разворачиваете один сервис, и расписание приезжает вместе с ним.
Такой вариант лучше всего подходит для небольших задач, которые остаются близко к самому приложению. Например, очистка просроченных сессий, обновление кэша, удаление временных данных или отправка ежедневного сводного письма. Обычно этим задачам не нужен отдельный сервис. Им просто нужно запускаться вовремя и оставаться простыми в поддержке.
Когда приложение стартует, scheduler тоже стартует. Когда приложение останавливается, задачи останавливаются. На словах это кажется очевидным, но в повседневной работе это помогает. Задача использует ту же конфигурацию, те же логи, те же метрики и тот же цикл деплоя, что и остальной сервис, поэтому у команды меньше частей, за которыми нужно следить.
Это ещё и сокращает путь в коде. Расписание, бизнес-логика и слой доступа к данным живут в одном кодбазе. Если cleanup-задаче нужны настройки базы данных приложения или cache client, не приходится прокидывать всё это через отдельную систему.
Простые scheduler'ы особенно хороши там, где одной запущенной копии явно принадлежит задача. Если у вас один backend-процесс или один воркер, который точно является владельцем, модель остаётся понятной. Нет путаницы в том, кто должен запускать работу.
Хороший сценарий — короткая задача, которую безопасно повторить, которая использует ту же базу данных или cache, что и приложение, и которая причинит лишь небольшой вред, если один запуск пропустится. Такой подход ещё и упрощает локальную разработку. Можно запустить приложение на ноутбуке и увидеть, как задача срабатывает, без дополнительных сервисов.
Компромисс простой. Вы получаете меньше координации, но и гораздо меньше сложности. Для многих background jobs это лучший вариант. Если задача маленькая, связана с одним сервисом и не требует блокировок между экземплярами, in-app scheduler обычно — самое чистое место для неё.
Когда лучше подходит распределённый scheduler
Распределённый scheduler нужен, когда ваше Go-приложение работает в нескольких репликах и для каждой задачи нужен один общий владелец. Если у каждой реплики одна и та же cron entry, каждая может её выполнить. Именно так команды получают дубли писем, повторные биллинговые запуски или cleanup-задачи, которые мешают друг другу.
Рестарты и деплои подталкивают многие команды к одному и тому же решению. Расписание, которое живёт внутри процесса приложения, исчезает, когда этот процесс останавливается. Если pod перезапустится прямо перед запуском задачи, вы можете пропустить запуск и не узнать об этом, пока кто-то не спросит, где отчёт.
Сохранённое состояние задачи помогает. Scheduler может хранить следующее время запуска, отмечать задачу как начатую и восстанавливаться после сбоя. Вам больше не нужно гадать, запускалась ли задача, запускалась наполовину или не стартовала вовсе.
Повторы — ещё одна причина уйти дальше простого cron package. Для неудачных задач часто нужны свои правила: подождать 30 секунд, попробовать ещё раз, затем сделать backoff на пять минут и остановиться после определённого числа попыток. Такая логика не должна лежать рядом с обработкой запросов.
Когда задача важна для клиентов или денег, командам ещё и нужен понятный след каждого запуска. Одних логов обычно недостаточно. Людям важно видеть, когда задача стартовала, сколько она выполнялась, упала ли она и что произошло при последней попытке — без копания в сыром выводе приложения.
Долгие задачи часто становятся последней точкой перелома. Задача, которая импортирует данные 20 минут или перестраивает поисковые индексы, не должна конкурировать с веб-приложением за память, CPU и подключения к базе данных. Вынесите такую работу на воркеры, и приложение сможет спокойно обслуживать запросы без случайных тормозов.
Если задачи маленькие, локальные и их легко повторить, оставьте их простыми. Если им нужны координация, история и надёжность, дайте им scheduler, который будет владеть ими вне веб-приложения.
Как выбрать, где должна жить задача
Начинайте с самой задачи, а не с библиотеки. Многие команды сравнивают пакеты слишком рано, ещё до того, как записали, что делает задача, когда она должна стартовать и как долго может работать.
Часто хватает двух строк: «отправлять счета в 02:00 UTC, обычно занимает 4 минуты, в конце месяца может занять 20». Такой маленький шаг проясняет больше, чем ещё один час чтения документации.
Перед тем как размещать задачу где-либо, ответьте на пять прямых вопросов:
- Что её запускает: часы, действие пользователя или событие из другой системы?
- Сколько она работает в обычных условиях и в худшем случае?
- Что сломается, если она выполнится дважды?
- Что сломается, если она пропустит один запуск?
- Может ли один процесс владеть ею без путаницы во время деплоя, рестарта или масштабирования?
Если повторный запуск почти безвреден, app cron часто подходит. Refresh кэша, ежедневное письмо со сводкой и защитой от дублей или очистка старых временных файлов обычно попадают именно сюда.
Если второй запуск может дважды списать деньги, создать повторную выплату или отправить один и тот же отчёт всем клиентам, относитесь к этому как к реальному риску. В таком случае нужны идемпотентность, блокировка или система, которая даёт одному воркеру чёткое владение.
Пропущенные запуски важны не меньше. Некоторым задачам можно подождать до следующего цикла без особого вреда. Другим — нельзя. Если вы пропустите export для зарплаты или не успеете подтянуть данные партнёра до дедлайна, простой in-process scheduler начнёт выглядеть слишком хрупким.
Одно владение — это граница, которую многие команды не замечают. Если ваше приложение работает в одном процессе и так и остаётся, держать расписание внутри приложения просто и удобно. Если вы запускаете несколько реплик, используете autoscaling или часто перезапускаетесь, нужен более сильный ответ на вопрос: «кто именно запустит эту задачу ровно один раз?»
Небольшой пример SaaS это хорошо показывает. Ночной cleanup просроченных сессий может жить внутри приложения. А вот генерацию счетов клиентам обычно лучше перенести в очередь воркера или workflow system, где проще управлять повторами, блокировками и аудитом.
Хорошо работает простое правило. Используйте app cron для небольших внутренних задач с низким риском, если они пропустятся или повторятся. Используйте очередь воркера, если задачи могут накапливаться, работать подольше или нуждаются в повторах. Используйте workflow tool, если у задачи много шагов, approvals, таймауты или правила восстановления.
Команды часто реально экономят время, если оставляют простые расписания простыми и выносят наружу только рискованные задачи. Такое разделение делает кодовую базу спокойнее и даёт каждой задаче место, соответствующее цене её ошибки.
Простой пример из небольшой SaaS-команды
У SaaS-команды из пяти человек есть одно Go-приложение, база Postgres и две реплики приложения за load balancer'ом. Каждый вечер в 01:00 они отправляют клиентам письма-напоминания о неоплаченных счетах. Сначала они используют один из привычных Go scheduler'ов внутри веб-приложения, потому что это кажется простым, а задача выглядит небольшой.
На staging всё работает. В production всё быстро становится странным.
Каждая реплика запускает один и тот же scheduler, поэтому обе подхватывают задачу на 01:00. Половина клиентов получает два письма. Несколько человек получают одно письмо от replica A и ещё одно от replica B через несколько секунд, и это выглядит неаккуратно и рождает обращения в поддержку.
Команда могла бы попробовать locks внутри приложения, но доставка писем уже является бизнес-процессом, а не внутренней уборкой приложения. Ей нужен один понятный владелец, правила повторов и запись о том, что произошло. Поэтому они переносят «отправку писем по счетам» в worker queue. Scheduler только один раз добавляет работу в очередь, а один воркер забирает каждую задачу на письмо. Если воркер падает, другой может повторить попытку, не гадая, ушло письмо или нет.
При этом они не выносят всё из приложения. Небольшая задача по очистке кэша по-прежнему работает внутри каждой реплики, потому что дублирующиеся запуски не вредят. Если обе реплики очистят старые записи кэша, ничего не сломается. Эта задача локальная, дешёвая и понятная.
В конце месяца у них появляется другая задача: закрыть финансовый период, сформировать отчёты и уведомить бухгалтерию, если что-то пошло не так. Этот процесс связан с деньгами и дедлайнами. Его они помещают не в веб-приложение и не в простую очередь, а в надёжный workflow engine. Workflow хранит состояние, переживает рестарты и показывает, на каком шаге произошёл сбой.
После такого разделения систему становится легче поддерживать. Приложение занимается мелкими локальными делами. Очередь отвечает за повторяемые бизнес-задачи вроде писем. Workflow берёт на себя редкую финансовую работу, которая не должна исчезать или запускаться дважды.
Обычно это и есть практическая граница между простыми in-process scheduler'ами и распределёнными системами задач. Если задача может выполниться дважды и никого это не волнует, держите её ближе к приложению. Если один запуск должен означать ровно один запуск, дайте задаче настоящего владельца.
Чем отличаются популярные Go-варианты
Главное разделение простое: одни scheduler'ы работают внутри одного Go-приложения, а другие работают отдельно, но со shared state. Из-за этого они по-разному ведут себя при рестарте процесса, при выкладке новой версии или когда два сервера пытаются запустить одну и ту же задачу.
Среди локальных scheduler'ов robfig/cron — самый прямой выбор. Он подходит для задач вроде очистки кэша каждый час, отправки одного внутреннего отчёта каждое утро или синхронизации небольшой таблицы по расписанию. Он маленький, знакомый и легко читается. Компромисс такой же очевидный: если приложение остановится, расписание остановится вместе с ним.
go-co-op/gocron находится в той же локальной категории, но многим командам он кажется удобнее в повседневной работе. У него дружелюбнее API для интервалов, тегов и управления задачами. Если вам нужны timed jobs внутри одного сервиса без сложной настройки, с ним часто проще жить, чем с более низкоуровневым cron syntax.
Asynq — это уже другой класс системы. Он использует Redis, очередь, поддерживает повторы и хорошо работает с отложенными задачами. Это удобно для отправки писем, обработки изображений, повторов webhook'ов и других background jobs, которые должны пережить рестарты приложения. Если вам нужны распределённые задачи без собственной очереди, здесь всё начинает казаться безопаснее.
Temporal стоит ещё выше. Это не просто scheduler. Он отслеживает состояние workflow шаг за шагом и может восстанавливать долгие процессы после сбоев. Если задача длится часы или дни, ждёт внешних событий или имеет много развилок, Temporal подходит. Для ночной cleanup-задачи он слишком тяжёлый.
Обычный system cron по-прежнему имеет смысл. Для задач уровня хоста, таких как ротация логов, резервная копия дампа базы данных или продление сертификата, OS cron часто — самый чистый вариант. Он оставляет машинные задачи вне приложения, где им обычно и место.
Коротко о популярных вариантах:
robfig/cron: один процесс, простые расписания, минимум настройкиgo-co-op/gocron: один процесс, более дружелюбный API, локальные задачи по времениAsynq: задачи на Redis, повторы, отложенные задания, много воркеровTemporal: stateful workflows, восстановление, долгие бизнес-процессы- system cron: обслуживание сервера и скрипты на уровне хоста
Если небольшой SaaS-команде нужно отправлять ежедневное сводное письмо, robfig/cron или gocron может быть достаточно. Если той же команде нужны повторные платёжные follow-up'ы через несколько воркеров, лучше подойдёт Asynq. Если процесс включает approvals, таймауты и восстановление после сбоя, используйте Temporal.
Ошибки, которые часто допускают команды
Команды часто относятся к запланированной работе как к второстепенной задаче. Поэтому маленькие решения вокруг scheduler'ов быстро превращаются в дубли писем, пропущенные отчёты и биллинговые задачи, которые срабатывают не вовремя.
Одна частая ошибка — запускать одну и ту же бизнес-критичную задачу в каждой реплике приложения. Scheduler внутри одного процесса в разработке выглядит нормально, а потом production добавляет три-четыре реплики, и каждый экземпляр запускает одну и ту же задачу. Если эта задача списывает деньги, продлевает тарифы или закрывает счета, ущерб появляется мгновенно. Для всего, что должно выполниться один раз, выбирайте одного исполнителя или переносите работу в систему, которая координирует владение.
Ещё одна ошибка — прятать retry logic внутри тела задачи. Команда пишет код, который ловит ошибку, засыпает, делает несколько повторов и потом продолжает работу. На вид это безобидно, но теперь правила повторов живут в случайных функциях, а не в одном понятном месте. Пропадает прозрачность, backoff запутывается, а сбои растягиваются далеко за пределы окна расписания. Повторы должны быть ближе к scheduler'у или политике очереди, а не глубоко внутри бизнес-кода.
Cron ещё и используют для работы, которой на самом деле место в очереди. Если задача рассылает тысячи писем, генерирует PDF или ходит в медленные сторонние API, cron-триггер должен только поставить работу в очередь. Он не должен сам выполнять весь batch. Долгие задачи накладываются на следующий запуск, копятся после инцидентов и делают время выполнения непредсказуемым.
Время создаёт больше багов, чем команды ожидают. «Запускать в 2:00 ночи» звучит ясно, пока переход на летнее время не пропускает этот час или не повторяет его. Глобальные продукты быстро сталкиваются с этим. Отчёт для New York, Berlin и Sydney не может жить в одном чистом midnight. Сохраняйте нужный time zone, решайте, что должно происходить при смене DST, и специально тестируйте такие даты.
Последняя ошибка — пропускать проверки идемпотентности для действий записи. Scheduler'ы делают retry. Процессы перезапускаются. Сети обрываются на середине запроса. Если одна и та же задача может создать один и тот же счёт, отправить одну и ту же выплату или дважды применить один и тот же кредит, задача ещё небезопасна.
Перед тем как добавить любое расписание, запишите три вещи: кто его запускает, что будет, если оно выполнится дважды, и должен ли cron запускать работу сам или только класть её в очередь.
Быстрая проверка перед добавлением нового расписания
Большинство ошибок в расписаниях начинается ещё до первой строки кода. Команда добавляет таймер, выпускает его в работу и только потом замечает, что никто не решил, кто владеет запуском, что происходит при рестарте и как понять, что задача здорова.
Прежде чем выбирать пакет, сначала определите владельца. У одной задачи должен быть один владелец. Если API-сервис, воркер и ручной скрипт могут все запустить одну и ту же работу, повторные запуски перестают быть редкостью и становятся нормой.
Потом задайте прямой вопрос: если эта задача выполнится дважды, что сломается? Некоторые задачи легко повторить, например перестроить кэш или обновить аналитику. Другие — нет, например списать деньги, отправить письмо о продлении или создать один и тот же счёт дважды. Если второй запуск может навредить пользователям или деньгам, добавьте lock, проверку на дубли или правило идемпотентности ещё до того, как добавите расписание.
Наблюдаемость важнее, чем многие думают. В первый день не нужен огромный dashboard, но нужно записывать последний успех, последнюю ошибку и короткое сообщение об ошибке. Без этого пропущенная задача остаётся невидимой, пока клиент не спросит, почему ничего не произошло.
Деплои и рестарты заслуживают отдельной проверки. In-process scheduler останавливается вместе с приложением. Если вы выкатываете новую версию в ту минуту, когда задача должна была стартовать, этот запуск может исчезнуть. Для низкорисковой работы это может быть нормально. Для биллинга, отчётности и всего, что привязано к дате, обычно нужна логика catch-up.
Повторы — это отдельное решение, а не часть самого таймера:
- Используйте расписание, чтобы определить, когда начинается работа.
- Используйте retry logic внутри задачи для кратковременных сбоев.
- Держите неудачный запуск видимым, а не пытайтесь молча бесконечно.
- Поставьте лимит, чтобы одна плохая зависимость не накапливала работу.
Если задаче нужен один владелец, безопасная обработка дублей, видимая история и надёжный catch-up после рестартов, запишите эти правила до начала разработки. Если вы не можете ответить на них в нескольких строках, задаче, скорее всего, нужно больше, чем просто базовый таймер в одном сервисе.
Что делать дальше
Начните с обычной инвентаризации. Большинство команд уже запускают больше расписанных задач, чем думают: cleanup-скрипты, напоминания о счетах, retry-циклы, экспорт отчётов, обновление кэша и тихие «временные» задачи, которые стали постоянными. Запишите каждую задачу в один документ с четырьмя фактами: что она делает, как часто запускается, что ломается при сбое и кто за неё отвечает.
Затем распределите задачи по месту. Оставляйте их в app cron, если задача маленькая, её не страшно пропустить один раз и она тесно связана с одним сервисом. Кладите в worker queue, если задача может накапливаться, нуждается в повторах или выполняется достаточно долго, чтобы мешать другой работе. Переносите в workflow или distributed scheduler, если она затрагивает несколько систем, требует строгого tracking или должна выполниться один раз на многих экземплярах.
Не переписывайте всё сразу. Сначала берите самые рискованные задачи. Хорошие кандидаты — задачи, которые отправляют деньги, меняют данные клиента, вызывают внешние API или запускаются дважды, когда стартуют две копии приложения. Небольшая SaaS-команда может многое исправить, если вынесет из веб-приложения всего две-три такие задачи и оставит безобидные в покое.
Записывайте правила в репозитории, а не в Slack и не в чьей-то памяти. Держите их короткими. Например: «report emails остаются в worker queue», «billing jobs никогда не работают в памяти приложения» и «у каждой запланированной задачи есть один названный владелец». Такие правила экономят время на ревью, потому что инженерам не приходится каждый раз спорить о новом случае с нуля.
Если ваша схема уже кажется запутанной, короткий архитектурный разбор может сэкономить недели уборки позже. Oleg Sotnikov предлагает такую помощь в формате Fractional CTO через oleg.is, с фокусом на практичном системном дизайне и лёгких операциях. Для планирования задач это обычно означает меньше лишних частей, более понятное владение и запуск задач там, где они действительно должны жить.
Часто задаваемые вопросы
Достаточно ли простой Go cron библиотеки для моего приложения?
Да, если один процесс приложения явно владеет задачей и пропущенный или повторный запуск почти не вредит. Такие задачи, как очистка кэша, удаление временных файлов или очистка сессий, обычно хорошо живут внутри приложения, потому что остаются рядом с той же конфигурацией, логами и кодом базы данных.
Когда стоит отказаться от scheduler'а внутри приложения?
Переходите от app cron, когда у вас больше одного экземпляра, когда задача затрагивает деньги или данные клиентов, или когда нужны повторы и история запусков. Тогда нужен один понятный владелец и сохранённое состояние, чтобы рестарты и деплои не заставляли задачи исчезать или запускаться дважды.
Почему дублирующиеся запуски возникают в приложениях с несколькими репликами?
Каждый экземпляр будет пытаться запустить один и тот же schedule, если не добавить блокировку или не вынести работу в другое место. Из-за этого легко получить дубли писем, повторные попытки списания или конкурирующие cleanup-задачи. Если задача должна выполниться один раз, не давайте всем экземплярам приложения быть её владельцами.
Cron должен выполнять всю задачу сам или только ставить её в очередь?
Для долгих или массовых задач лучше, чтобы schedule только ставил работу в очередь, а не выполнял весь batch сам. Очередь даёт повторы, backoff и владение со стороны воркера, а веб-приложение не тратит CPU и память на фоновую работу.
Что нужно спросить перед выбором scheduler'а?
Сначала назовите цену ошибки. Спросите, что будет, если задача выполнится поздно, не выполнится вовсе или выполнится дважды. Этот ответ подскажет гораздо больше, чем документация к пакету, потому что сразу показывает, нужен ли вам простой таймер, очередь или workflow engine.
Как правильно обрабатывать повторы для запланированных задач?
Правила повторов держите рядом с очередью или scheduler'ом, а не прячьте их внутри бизнес-кода. Задайте небольшой лимит повторов, используйте backoff и записывайте каждую ошибку, чтобы было видно, что произошло. Тихие циклы повторов только отнимают время и мешают заметить сбой.
Когда system cron всё ещё имеет смысл?
Используйте system cron для машинных задач, например для ротации логов, продления сертификатов или backup-скриптов. Держите такие задачи вне приложения, если только именно приложение не владеет этой логикой. Так хостовые задачи остаются простыми, а код приложения — чище.
Как избежать ошибок с часовыми поясами и переходом на летнее время?
Сохраните целевую time zone задачи и специально проверьте даты перехода на летнее/зимнее время. Задача, назначенная на 2:00 ночи, может пропустить день или выполниться дважды, когда часы переводятся, поэтому нужна чёткая правило, что должно происходить в такие даты.
Нужна ли мне очередь вроде Asynq или workflow tool вроде Temporal?
Выбирайте очередь, когда задача повторяет одну и ту же единицу работы — например письма, webhooks или обработку изображений — и вам нужны повторы. Выбирайте workflow tool, когда у задачи много шагов, есть ожидание внешних событий или нужно надёжно хранить состояние на протяжении долгого процесса.
Что исправить в первую очередь, если расписания уже выглядят хаотично?
Сначала составьте один список всех запланированных задач. Запишите, что делает каждая задача, как часто она запускается, что ломается при сбое и кто за неё отвечает. Потом в первую очередь переносите из веб-приложения рискованные задачи, особенно те, что связаны с деньгами, изменением данных клиентов или запуском на каждой реплике.