Планировщик задач в Go-сервисе: сравниваем варианты и выбираем
Таймер на горутине, cron-библиотека, очередь на Redis или полноценный оркестратор — четыре способа запускать периодические задачи в Go. Разбираем, чем каждый платит за удобство и когда какой уместен.

Зачем вообще выбирать
Любому сервису рано или поздно нужно делать что-то по расписанию: отправлять отчёт, чистить просроченные сессии, обновлять кеш. Самый частый первый рефлекс — завернуть time.Ticker в горутину и забыть. Это работает ровно до момента, когда сервис масштабируется на два инстанса: задача начинает выполняться дважды, а данные — ломаться. Дальше появляются вопросы про пропущенные запуски, мониторинг и повторные попытки. Ответ на каждый из них зависит от выбранного подхода.
Ниже — четыре варианта, от простого к сложному. У каждого есть цена, и не всегда это деньги: иногда это операционная сложность, иногда — риск потери задачи при рестарте.
Вариант 1: time.Ticker и горутина
Самый лёгкий способ — стандартная библиотека, ноль зависимостей.
// Go 1.22+
package main
import (
"context"
"log"
"time"
)
func runScheduler(ctx context.Context, interval time.Duration, job func()) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Println("scheduler: остановлен")
return
case <-ticker.C:
job()
}
}
}
Горутина живёт, пока жив контекст. Плюс — нулевая стоимость входа: не нужно ни Redis, ни внешних пакетов. Минусы начинаются сразу:
- Нет cron-выражений — только фиксированный интервал. «Каждый день в 03:00» придётся считать руками.
- При рестарте задача теряется: если тик пришёлся на момент деплоя, запуск пропущен, и никто об этом не узнает.
- При горизонтальном масштабировании каждый инстанс запускает свою копию.
Подходит для одноинстансных утилит, CLI-демонов и ситуаций, где пропущенный запуск не критичен.
Вариант 2: cron-библиотека
Пакет github.com/robfig/cron/v3 — де-факто стандарт для cron-подобного планирования в Go. Поддерживает пяти- и шестиполевые выражения, таймзоны и перехват паник.
// Go 1.22+, robfig/cron v3.0.1
package main
import (
"log"
"github.com/robfig/cron/v3"
)
func main() {
c := cron.New(cron.WithSeconds()) // 6-полевой формат
_, err := c.AddFunc("0 0 3 * * *", func() {
log.Println("ежедневная очистка сессий")
})
if err != nil {
log.Fatal(err)
}
c.Start()
select {} // держим процесс
}
Этот подход решает проблему «каждый день в 03:00», но наследует те же два узких места: нет защиты от дублей на нескольких инстансах и нет гарантии выполнения при рестарте. Библиотека работает полностью в памяти — состояние не сохраняется.
Как добавить distributed lock
Типичное решение — взять блокировку в Redis или PostgreSQL перед запуском задачи. Для Redis удобно использовать алгоритм, описанный в документации Redis как «Distributed Locks with Redis» (SET с NX и TTL). Паттерн простой: перед выполнением задачи пытаемся установить ключ с TTL, равным интервалу; если ключ уже существует, инстанс пропускает запуск.
Это работает, но вы сами отвечаете за корректный TTL, обработку сбоев Redis и мониторинг пропущенных запусков.
Вариант 3: очередь задач на Redis
Библиотеки вроде github.com/hibiken/asynq превращают Redis в брокер задач с расписанием, повторами и приоритетами. Asynq предоставляет отдельный компонент asynq.Scheduler, который принимает cron-выражения и сам кладёт задачу в очередь по расписанию.
// Go 1.22+, asynq v0.24+
package main
import (
"log"
"github.com/hibiken/asynq"
)
func main() {
redisOpt := asynq.RedisClientOpt{Addr: "localhost:6379"}
scheduler := asynq.NewScheduler(redisOpt, nil)
task := asynq.NewTask("report:daily", nil)
_, err := scheduler.Register("0 3 * * *", task)
if err != nil {
log.Fatal(err)
}
if err := scheduler.Run(); err != nil {
log.Fatal(err)
}
}
Здесь планировщик и воркер разделены: один процесс кладёт задачи, другой (или несколько) — забирает и выполняет. Asynq обеспечивает at-least-once доставку, автоматические повторы, дедлайны и веб-панель мониторинга (asynqmon). Цена — обязательная зависимость от Redis и ещё один процесс, за которым нужно следить.
Вариант 4: Temporal как оркестратор
Temporal — отдельный сервер для оркестрации долгоживущих workflow. Для периодических задач используется cron-расписание, которое задаётся при старте workflow. Temporal хранит полную историю выполнений, сам обрабатывает повторы, таймауты и сбои воркеров.
Плюсы очевидны: exactly-once семантика выполнения, визуальный UI с историей каждого запуска, версионирование workflow. Минусы — тоже: Temporal требует PostgreSQL или Cassandra под капотом, Elasticsearch для поиска и как минимум четыре собственных микросервиса. Поднять его ради ежедневного отчёта — как арендовать самосвал для перевозки стула.
Если задача умещается в одну функцию и не требует шагов, компенсаций и ветвлений — Temporal, скорее всего, избыточен. Но если у вас цепочка из пяти зависимых действий с откатами, он окупается за первую неделю отладки.
Сравнение подходов
| Критерий | time.Ticker | robfig/cron | Asynq + Redis | Temporal |
|---|---|---|---|---|
| Зависимости | нет | один пакет | Redis | Temporal-сервер + БД |
| Cron-выражения | нет | да | да | да |
| Защита от дублей (несколько инстансов) | нет | нет (нужен внешний lock) | да, на уровне очереди | да, встроенная |
| Гарантия выполнения при рестарте | нет | нет | at-least-once | exactly-once |
| Повторные попытки | вручную | вручную | встроенные, настраиваемые | встроенные, с экспоненциальным backoff |
| Мониторинг | свой логгер | свой логгер | asynqmon (веб-панель) | Temporal UI |
| Операционная сложность | нулевая | низкая | средняя | высокая |
Когда что выбирать
Вот конкретные ориентиры, а не «зависит от задачи»:
- Один инстанс, задача не критична (пропущенный запуск — не проблема):
time.Tickerилиrobfig/cron. Не тратьте время на инфраструктуру. - Несколько инстансов, задача должна выполниться ровно один раз:
robfig/cron+ distributed lock в Redis/PostgreSQL, если у вас уже есть Redis. Если Redis нет — добавлять его только ради блокировки дорого; рассмотрите advisory lock в PostgreSQL. - Задач много, нужны повторы и мониторинг, Redis уже в стеке: Asynq. Он закрывает 90% потребностей типичного бэкенда и не требует нового кластера.
- Цепочки действий, компенсации, долгоживущие процессы (часы/дни): Temporal. Порог входа высокий, но альтернатива — самописный стейт-менеджер, который вы будете отлаживать годами.
Типичные ошибки при выборе
- «Запустим Temporal на старте проекта». Два разработчика, три эндпоинта, одна задача по расписанию — и к ним кластер Temporal с Cassandra. Через месяц кластер падает, чинить некому. Оркестратор оправдан, когда есть что оркестрировать.
- «time.Ticker хватит, мы же не масштабируемся». Через полгода появляется второй инстанс за балансировщиком, и оба начинают слать одно и то же письмо. Проверьте в момент выбора: есть ли шанс горизонтального масштабирования в ближайший год?
- «Redis и так есть, поставим lock». Lock без TTL или с неправильным TTL — рецепт взаимной блокировки или пропущенного запуска. Если берёте distributed lock, убедитесь, что TTL блокировки меньше интервала между запусками, и что задача укладывается в этот TTL.
Сноска: at-least-once — гарантия доставки, при которой задача будет выполнена как минимум один раз, но при сбоях возможен повторный запуск. Код задачи должен быть идемпотентным — повторный вызов не должен ломать данные. exactly-once в контексте Temporal означает, что фреймворк сам отслеживает статус и не допускает дубля, хотя на уровне сети абсолютной exactly-once семантики не существует.
Что дальше
Определитесь с вариантом и сразу настройте наблюдаемость: логируйте начало и конец каждого запуска, время выполнения и статус. Для Asynq подключите asynqmon, для Temporal — его UI. Без мониторинга вы узнаете о пропущенной задаче от пользователей, а не от алертов. Следующий шаг — убедиться, что ваши задачи идемпотентны: какой бы вариант вы ни выбрали, рано или поздно задача выполнится дважды, и к этому лучше быть готовым заранее.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: robfig/cron — документация и примеры на pkg.go.dev, hibiken/asynq — репозиторий и документация на GitHub
Частые вопросы
Можно ли обойтись системным crontab вместо планировщика внутри Go-сервиса?
Что будет, если задача в Asynq не успеет выполниться до следующего запуска по расписанию?
Robfig/cron потокобезопасен? Можно ли добавлять задачи после Start?
Как тестировать задачи, привязанные к расписанию, если не хочется ждать 24 часа?
А если у меня другая версия Go или библиотеки — примеры будут работать?
Есть ли подводные камни с таймзонами в cron-расписаниях?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.