---
title: "Периодические задачи по cron"
description: "Asynq периодические задачи по cron: Scheduler и Register, таймзона SchedulerOpts, PeriodicTaskManager с динамическим списком и идемпотентность asynq cron задач."
course: "Asynq в Go: очередь фоновых задач на Redis"
url: "https://golangmaster.ru/courses/asynq/lessons/09-periodicheskie-zadachi/"
published: "2026-09-19"
updated: "2026-09-23"
minutes: 20
---

# Периодические задачи по cron

*Курс «Asynq в Go: очередь фоновых задач на Redis», урок 9 из 25. ~20 минут. Обновлено 23.09.2026. Источник: https://golangmaster.ru/courses/asynq/lessons/09-periodicheskie-zadachi/*

Отложенная задача срабатывает один раз, а отчёт в шесть утра нужен каждый день: asynq периодические задачи строятся на cron-выражениях — Scheduler регистрирует расписание, PeriodicTaskManager синхронизирует его с конфигом. Разберём asynq cron, таймзоны и идемпотентность хендлеров.

Отчёт по продажам — каждый день в 06:00, пересчёт рейтингов — каждую ночь в 03:00, дайджест новостей — по будням в девять. ProcessIn из пятого урока здесь не помощник: он откладывает задачу один раз, а вам нужен повтор по календарю, без конца. Можно вынести расписание в классический cron системы и ставить задачи шелл-скриптом, но тогда часть логики уезжает из кода. Asynq предлагает встроенный ответ: Scheduler — планировщик, который живёт в вашем процессе и в моменты тика сам ставит задачи в очередь через Redis. Ключевая мысль, которая экономит час чтения документации: планировщик ничего не исполняет. Он только постановщик — после тика в очереди лежит обычная задача, и её подхватывают обычные воркеры с обычными ретраями и очередями. В уроке — регистрация расписания, таймзоны, динамический список задач через PeriodicTaskManager и главный подводный камень периодики: что делать, когда задача выполняется дольше, чем длится интервал. И одна деталь топологии, к которой вернёмся с кодом: планировщик — отдельная роль в деплое, и при масштабировании воркеров она не должна плодить копии автоматически.

### Asynq cron: Scheduler и Register

Asynq cron начинается с двух строк: scheduler := asynq.NewScheduler(redisConnOpt, opts) и Register. Метод Register принимает cron-выражение, задачу и любые опции постановки — те же, что у Enqueue: очередь, MaxRetry, Unique — и возвращает идентификатор записи. По этому идентификатору расписание можно снять методом Unregister, когда задача перестала быть нужна. Формат выражений знакомый: пять полей «минуты часы день-месяца месяц день-недели» вроде 0 6 * * *, плюс интервальные записи @every 30s и @every 1h для случаев, когда привязка к часам суток не нужна. Один планировщик держит сколько угодно записей: Register возвращает новый идентификатор на каждый вызов, и повторная регистрация того же выражения ошибкой не считается — дубликатом она становится уже в рантайме, когда два тика ставят две задачи. Невалидное выражение, напротив, ловится сразу: Register разбирает его и возвращает ошибку, не добавляя запись. Запускать планировщик можно двумя способами: Run блокирует выполнение до сигнала остановки — отдельный процесс-планировщик, — а Start возвращает управление сразу, если планировщик встраивается в существующий сервис, где блокироваться нельзя. Каждая постановка по тику проходит через Redis как обычная клиентская: если Redis в момент тика недоступен, планировщик не потеряет молча — ошибку можно обработать через EnqueueErrorHandler в опциях.

### Планировщик с двумя расписаниями

```go
loc, err := time.LoadLocation("Europe/Moscow")
if err != nil {
    log.Fatal(err)
}

scheduler := asynq.NewScheduler(redisConnOpt, &asynq.SchedulerOpts{
    Location: loc,
})

task := asynq.NewTask(tasks.TypeDailyReport, nil)
entryID, err := scheduler.Register("0 6 * * *", task, asynq.Queue("reports"))
if err != nil {
    log.Fatal(err)
}
log.Printf("запись расписания: %s", entryID)

scheduler.Run()
```

Register принимает cron-выражение, задачу и опции постановки — здесь очередь reports. Возвращённый entryID пригодится для Unregister, когда расписание перестанет быть нужным. Run блокируется до сигнала остановки, поэтому такой main-код живёт в отдельном процессе; для встройки в сервис есть неблокирующий Start. Location решает, какое именно 06:00 имеется в виду.

### Cron-выражения и таймзона по умолчанию

Выражение из пяти полей читается слева направо: минуты, часы, день месяца, месяц, день недели; звёздочка — «любое значение», дефис — диапазон, запятая — перечисление, слэш — шаг. Если синтаксис вам незнаком целиком, у сайта есть отдельный [курс по cron](/courses/cron/) — здесь остановимся на главном сюрпризе. По умолчанию SchedulerOpts.Location не задана, и расписание живёт в UTC: выражение 0 6 * * * сработает в шесть утра по Гринвичу, то есть в девять по Москве — и почти каждая команда ловит это на первом деплое. Лечение две строки: LoadLocation в инициализации и Location в SchedulerOpts, как в коде выше. Проверка после деплоя бесплатная: планировщик печатает свою таймзону в лог при старте, так что строка «Scheduler timezone is set to ...» — быстрый способ убедиться, что вы в правильной зоне. Для минимальных Docker-образов напоминание из пятого урока остаётся в силе: без tzdata LoadLocation вернёт ошибку, лечится пакетом tzdata или импортом time/tzdata. Отдельно про @every: он не привязан к часам суток и отсчитывает интервал от запуска планировщика, поэтому после рестарта в 10:47 тики пойдут в 11:47, 12:47 — удобно для «раз в час», но не для «каждый день в шесть».

### Проверка: таймзона по умолчанию

**Вопрос:** Scheduler создан с SchedulerOpts по умолчанию, выражение 0 6 * * *. Во сколько по Москве (UTC+3) сработает первая постановка?

- В 06:00 — asynq берёт локальную таймзону сервера
- ✅ В 09:00 — по умолчанию расписание живёт в UTC
- В 03:00 — asynq вычитает смещение
- Зависит от таймзоны Docker-контейнера

Без SchedulerOpts.Location планировщик работает в UTC: 06:00 UTC равно 09:00 по Москве. Явная Location с LoadLocation в инициализации снимает вопрос двумя строками и не зависит от таймзон сервера и контейнеров.

### Кто ставит задачу при тике

В момент тика планировщик делает постановку через Redis — и дальше задача живёт своей обычной жизнью: попадает в очередь, забирается воркером, при сбое повторяется с экспонентой из шестого урока. Для задач в расписании работают и опции постановки: очередь из Register определяет класс обслуживания, MaxRetry задаёт потолок повторов. У SchedulerOpts есть и хуки: PreEnqueueFunc вызывается перед каждой постановкой по тику, PostEnqueueFunc — после, уже с TaskInfo и ошибкой на руках; через них удобно считать реальные постановки и отдавать метрику «тик случился». EnqueueErrorHandler из ранних версий делает то же для сбоев и помечен устаревшим. Из этой механики следует два неудобных факта, о которых лучше знать заранее. Первый: пропущенный тик не догоняется — если планировщик стоял в 06:00, постановки не было, и после подъёма задача не появится задним числом; расписание смотрит только вперёд. Второй: планировщики не координируются между процессами — asynq не держит лидера и не проверяет, кто уже зарегистрировал это выражение. Два живых планировщика с одинаковым Register ставят задачу дважды в каждый тик. Поэтому правило топологии простое: на каждое расписание — ровно один живой планировщик в кластере, и этому правилу посвящён коллаут ниже.

> **Живое место: две реплики — два письма.** Воркеры масштабируются репликами — а в main.go одной из реплик остался тот же Register, что и у первой. Итог: каждое утро дайджест уходит пользователям дважды, половина подписчиков отписывается, а в логах всё чисто — обе постановки честно успешны. Планировщики не координируются между собой, Redis-замков нет. Решения: выделенный однореплиочный процесс-планировщик, выбор лидера между репликами или вынос регистрации за флаг роли в деплое.

> **Расписания видны в дашборде.** Планировщики пишут в Redis записи о себе, и asynqmon показывает их на вкладке Scheduler Entries: какие выражения зарегистрированы, кем и когда был предыдущий тик. Отсюда дешёвые проверки после деплоя: ровно одна запись на каждое расписание, а не две, и записи живы — значит, процесс-планировщик жив. Мёртвый планировщик пропадает из списка через несколько десятков секунд после остановки: записи пишутся с коротким сроком жизни.

### Периодические задачи asynq: PeriodicTaskManager

Пока периодических задач пять, Register в инициализации — самый честный код. Когда их десятки и список меняется — расписание выгружается из базы, добавляется клиентами, отличается по окружениям, — в игру вступают периодические задачи asynq в динамическом режиме: PeriodicTaskManager. Менеджер создаётся через asynq.NewPeriodicTaskManager с опциями PeriodicTaskManagerOpts: в них передаётся PeriodicTaskConfigProvider — ваш источник конфигов, RedisConnOpt для соединения и вложенные SchedulerOpts. Провайдер реализует интерфейс с одним методом GetConfigs, который возвращает срез значений PeriodicTaskConfig — это структура с полями Cronspec, Task и опциональными Opts. Раз в SyncInterval — по умолчанию три минуты — менеджер опрашивает провайдера, сравнивает полученный список с зарегистрированными записями по хэшу «выражение, тип задачи, payload, опции» и сам доводит реальность до желаемого: новые регистрирует, исчезнувшие снимает. Ваш код меняет строки в базе — планировщик меняет расписание без перезапуска. Детали, которые экономят отладку: первая синхронизация происходит сразу при Start, до старта планировщика, — расписание из базы подхватывается без ожидания трёх минут; вместо RedisConnOpt менеджеру можно отдать готовый универсальный клиент Redis, если он уже заведён для других нужд.

### Провайдер конфигов и менеджер

```go
type DBProvider struct{ DB *sql.DB }

func (p *DBProvider) GetConfigs() ([]*asynq.PeriodicTaskConfig, error) {
    // читаем cronspec и тип из таблицы periodic_jobs
    return []*asynq.PeriodicTaskConfig{
        {Cronspec: "0 3 * * *", Task: asynq.NewTask(tasks.TypeRecalc, nil)},
    }, nil
}

mgr, err := asynq.NewPeriodicTaskManager(asynq.PeriodicTaskManagerOpts{
    RedisConnOpt:               redisConnOpt,
    PeriodicTaskConfigProvider: &DBProvider{DB: db},
})
if err != nil {
    log.Fatal(err)
}

mgr.Start()
```

Провайдер реализует PeriodicTaskConfigProvider: метод GetConfigs возвращает срез PeriodicTaskConfig — структур с Cronspec, Task и опциональными Opts. Менеджер раз в SyncInterval опрашивает провайдера, сравнивает конфиги по хэшу «выражение, тип, payload, опции» и сам регистрирует новые записи и снимает исчезнувшие. Start запускает и планировщик, и цикл синхронизации; Shutdown останавливает оба.

### Проверка: опечатка в выражении

**Вопрос:** Scheduler.Register вызвали с опечаткой в выражении — 0 6* * * без пробела после шестёрки. Что произойдёт?

- Паника: Register падает и валит процесс
- ✅ Register вернёт ошибку, а entryID будет пустым
- Регистрация пройдёт, тики пойдут каждый день в 06:00
- Регистрация пройдёт, но задача никогда не будет поставлена

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

### Идемпотентность периодических задач

Каждый тик ставит задачу независимо от прошлого: планировщик не проверяет, кончился ли ночной пересчёт, упала ли вчерашняя выгрузка, держится ли блокировка. Отсюда главный эксплуатационный навык темы — хендлер периодической задачи обязан быть идемпотентным и уметь переживать наложение смен: прошлый прогон ещё идёт, а новый уже поставлен. Три приёма уровня хендлера: проверка «не выполняется ли уже» по служебной записи в базе или Redis; деление работы на окна, чтобы каждый прогон обрабатывал свой кусок и пропускал чужой; отказ от догоняющей логики — пропущенный пересчёт не важнее сегодняшнего. Если наложение принципиально недопустимо, поможет фиксированный идентификатор задачи и опция Unique с TTL на интервал: постановка с живым дублем вернёт ErrTaskIDConflict, механику которого разбирает [двенадцатый урок](/courses/asynq/lessons/12-unikalnye-zadachi/). Полная дисциплина идемпотентных хендлеров — своя территория [восемнадцатого урока](/courses/asynq/lessons/18-idempotentnost/); здесь достаточно правила: cron ставит задачу, не глядя на прошлое, — значит, хендлер смотрит на прошлое сам. Практическая проверка идемпотентности простая: запустите хендлер дважды на одних данных — второй прогон должен либо ничего не сделать, либо проделать то же без побочных эффектов; всё остальное — баг, который cron однажды превратит в двойную отправку.

### Когда приедет первая постановка?

```go
// планировщик стартовал в 10:47
entryID, err := scheduler.Register("0 6 * * *", reportTask)
```

**Что выведет программа?**

- Немедленно: Register ставит первую задачу сразу
- ✅ Завтра в 06:00: ближайший момент, подходящий под выражение
- Ровно через 24 часа после Register
- Сегодня в 06:00: прошлое расписание доисполнится после старта

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

### Кандидаты в периодические задачи

- Ночные пересчёты: рейтинги, агрегаты, тёплые кэши к началу рабочего дня.
- Дайджесты и сводки: письмо по утрам, выгрузка раз в час, сводка по будням.
- Синхронизация с внешними системами: каталог поставщика раз в полчаса.
- Очистка: устаревшие сессии, временные файлы, забытые черновики.
- Проверки здоровья: ping зависимостей с постановкой алерт-задачи.
- Календарные напоминания: вынести дедлайн из данных в расписание не выйдет — для событий остаётся ProcessAt.

### Разовая отложка против периодики

| Инструмент | Кто ставит | Повтор | Кейс |
| --- | --- | --- | --- |
| ProcessIn / ProcessAt | ваш код на событии | один раз | напоминание за час до приёма |
| Scheduler | планировщик по тику | по cron-выражению | отчёт в 06:00 ежедневно |
| PeriodicTaskManager | менеджер по провайдеру | по cron-выражению | расписание живёт в базе |
| классический cron | планировщик ОС | по cron-выражению | постановка из шелла без Go |

### Соберите регистрацию расписания

Допишите конструктор планировщика, структуру опций с таймзоной и метод регистрации выражения.

```go
scheduler := asynq.___(redisConnOpt, &asynq.___{
    Location: loc,
})

entryID, err := scheduler.___("0 6 * * *", reportTask)
```

Подсказка: Планировщик создаёт NewScheduler, опции — SchedulerOpts, а выражение регистрирует метод Register.

Правильные ответы в пропусках: `NewScheduler`, `SchedulerOpts`, `Register`

### Соберите процесс-планировщик

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

Правильный порядок строк:

1. `loc, err := time.LoadLocation("Europe/Moscow")`
2. `scheduler := asynq.NewScheduler(redisConnOpt, &asynq.SchedulerOpts{Location: loc})`
3. `task := asynq.NewTask(tasks.TypeDailyReport, nil)`
4. `entryID, err := scheduler.Register("0 6 * * *", task, asynq.Queue("reports"))`
5. `if err != nil { log.Fatal(err) }`
6. `log.Printf("расписание зарегистрировано: %s", entryID)`
7. `scheduler.Run()`

### Карточки: периодические задачи

- **Scheduler** — Планировщик asynq: живёт в вашем процессе и по тикам cron-выражений ставит задачи в Redis. Только постановщик — исполняют обычные воркеры.
- **Register** — Метод Scheduler: принимает cron-выражение, задачу и опции постановки, возвращает entryID для последующего Unregister.
- **SchedulerOpts.Location** — Таймзона расписания. По умолчанию UTC; задаётся LoadLocation-ом в инициализации и требует tzdata в минимальных образах.
- **@every** — Интервальная запись в Register: тики каждые N от запуска планировщика. После рестарта фаза сдвигается — для «в шесть утра» не годится.
- **PeriodicTaskManager** — Менеджер динамической периодики: раз в SyncInterval (3 мин) опрашивает провайдер GetConfigs и сводит зарегистрированные записи с полученным списком.
- **PeriodicTaskConfig** — Структура конфига периодической задачи: Cronspec, Task и опциональные Opts. Возвращается провайдером из GetConfigs.

Периодика собрана: Scheduler регистрирует выражения и ставит задачи по тикам, Location держит таймзону под контролем, PeriodicTaskManager синхронизирует расписание с вашим источником конфигов, а идемпотентность хендлера закрывает наложения смен. Мини-эксперимент: поднимите процесс-планировщик с выражением на минуту вперёд и остановите воркеры — постановка случится, а задача дождётся в очереди, как и любая другая; затем убейте планировщик на следующий тик и убедитесь, что постановки не было: пропущенный тик не догоняется. С той же схемой проверьте таймзону: выражение 0 6 * * * без Location сработает по UTC, и лог планировщика честно это покажет. Формат прежний: локальный Go и Redis в Docker, песочница сайта — тематическая программа на чистом Go. Дальше по курсу: [десятый урок](/courses/asynq/lessons/10-grupy-agregaciya/) покажет противоположный приём — не ставить задачи по расписанию, а собирать уже поставленные в пачки; разовые отложки ProcessIn из пятого урока остаются вашим инструментом для событий, а cron — для календаря.

## Коротко: что запомнить

- Scheduler регистрирует cron-выражения: Register(spec, task, opts...) возвращает entryID для Unregister.
- Планировщик только ставит задачи: исполнение — обычные воркеры с очередями и ретраями.
- Одно расписание — один живой планировщик на кластер: два Register дают двойные постановки.
- Cron по умолчанию живёт в UTC: таймзону задаёт SchedulerOpts.Location через LoadLocation.
- Пропущенный тик не догоняется: не было планировщика — не было постановки.
- PeriodicTaskManager синхронизирует расписание с провайдером GetConfigs раз в SyncInterval.
- Конфиг периодической задачи — структура PeriodicTaskConfig: Cronspec, Task, Opts.
- Хендлер периодической задачи обязан быть идемпотентным: наложение смен — норма жизни.

## Домашнее задание

Соберите отдельный процесс-планировщик с двумя задачами: отчёт по выражению 0 6 * * * с Location Europe/Moscow и пересчёт @every 1h с очередью low. Убедитесь в asynqmon, что постановки появляются как обычные задачи в нужной очереди, и что воркеры исполняют их с обычными ретраями. Затем поднимите вторую копию планировщика и посчитайте дубликаты за день — почините, оставив одну реплику, и зафиксируйте правило в README сервиса. Всё локально: Go и Redis из Docker; песочница сайта сетевой код не исполняет.

## Вопросы и ответы по уроку

### Scheduler или PeriodicTaskManager — что выбрать?

Пять статичных расписаний — Register в инициализации: код прозрачен, расписание видно в одном файле. Десятки задач, изменения без деплоя, расписание в базе — PeriodicTaskManager с провайдером: он сам добавляет и снимает записи по списку от GetConfigs. Менеджер внутри использует тот же Scheduler, так что миграция между ними дешёвая. Промежуточная точка — Register в инициализации плюс хранение выражений в базе на будущее: статичные расписания не требуют инфраструктуры менеджера, а переезд останется делом одного коммита.

### Что случится с расписанием при падении процесса-планировщика?

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

### Можно ли ставить периодическую задачу в конкретную очередь с ретраями?

Да: Register принимает те же опции, что и Enqueue — asynq.Queue("reports") отправит постановки в нужную очередь, MaxRetry ограничит повторы при сбоях, TaskID даст фиксированный идентификатор. Постановка из планировщика ничем не отличается от клиентской — просто автором выступает ваш процесс.
