Содержание урока · 10 разделов
Сервис записей к специалистам: пациент выбрал slot на 14:00, администратор поставил напоминание «за час до приёма». Логика на HTTP-хендлере невозможна: запрос закончится через миллисекунды, а напоминание должно сработать через час. Таймер-горутина не спасает — рестарт сервиса его уничтожит. Нужен способ сказать очереди: «вот задача, возьмись за неё не сейчас, а тогда-то». В asynq для этого существуют две опции постановки — ProcessIn для задержки от текущего момента и ProcessAt для точного времени. Устройство под ними общее: Redis хранит расписание, воркеры его опрашивают, задачи переживают рестарты всех участников. В этом уроке обе опции в деле: напоминание через час и отчёт в полночь, плюс детали, которые отличают «поставил на потом» от «сделал вовремя».
Asynq ProcessIn: задача с задержкой#
Asynq ProcessIn — опция постановки: client.Enqueue(task, asynq.ProcessIn(1time.Hour)) кладёт задачу не в очередь ожидания, а в расписание. Всё остальное вы уже знаете из уроков про первую задачу: NewTask с типом и payload, Enqueue возвращает TaskInfo, хендлер регистрируется в mux как обычно — меняется только момент исполнения. Поле info.NextProcessAt показывает расчётное время запуска: удобно логировать при постановке, чтобы потом сверить с фактом. Задача при этом переходит в состояние scheduled и живёт в расписании Redis; в дашборде её видно среди отложенных. Задержка принимается любым duration — от секунды до недель, но границы разумного стоит держать в голове: напоминание через минуту лучше ставить как immediate, а расписание на месяцы — повод посмотреть в сторону периодических задач. Механизм одинаков для любых типов: это не отдельный API, а обещание при постановке. Одна деталь, о которой стоит договориться сразу: ProcessIn задаёт нижнюю границу, а не точный момент. Когда срок истёк, задача переходит из scheduled в pending — но исполняется она тогда, когда её заберёт живой воркер с свободным слотом. На здоровом сервисе задержка сверх назначенного времени измеряется секундами; на сервисе с остановленными воркерами — до их возвращения, и это фича, а не баг: задача не потеряна. Отсюда правильные ожидания: напоминания, дедлайны и отложенные письма прощают секунды опоздания, а вот торговые заявки с точностью до миллисекунды — не для очереди с опросом расписания. Отсчёт начинается в момент постановки, а не в момент создания payload: лаг между ними сдвинет задачу целиком. Практика, которая делает код читаемым: держите duration-константы рядом с типами задач — asynq.ProcessIn(24time.Hour) говорит сам за себя, без магических чисел. И постановка с ProcessIn до старта воркеров абсолютно легальна: задача дождётся в расписании, потому что Redis живёт своей жизнью.
Напоминание через час: ProcessIn
GOpayload, err := json.Marshal(tasks.ReminderPayload{
UserID: 42,
Message: "приём у врача через час",
})
if err != nil {
log.Fatal(err)
}
task := asynq.NewTask("reminder:send", payload)
info, err := client.Enqueue(task, asynq.ProcessIn(1*time.Hour))
if err != nil {
log.Fatal(err)
}
log.Printf("напоминание отправится: %v", info.NextProcessAt)От простой постановки отличается ровно одна строка: опция ProcessIn у Enqueue. Задача с типом reminder:send уйдёт в расписание Redis и станет pending через час, когда воркер заберёт её из расписания. NextProcessAt из TaskInfo — расчётный момент запуска: логируйте его, чтобы сверять с фактом при разборе жалоб.
ProcessAt: запуск в точное время#
ProcessAt принимает time.Time вместо длительности: ProcessAt(when) — «запусти не раньше этого момента». Математически ProcessIn(d) эквивалентен ProcessAt(time.Now().Add(d)), и выбирать стоит по смыслу задачи: длительность — для «через час после события», момент времени — для «в полночь», «в 09:00 по будням», «когда заканчивается триал». Ночной отчёт строится на time.Date: собираем год, месяц и день плюс один, часы-минуты-секунды нулевые — и получаем первую полночь после текущего момента. Таймзона здесь главный источник ошибок: time.Now() живёт в таймзоне сервера, которая в контейнерах почти всегда UTC, и «полночь» без указания места окажется полуднем по Москве. Лечение — time.LoadLocation и явный .In(loc) для всех расчётов времени. Приятный бонус: time.Date нормализует переполнения — день 32 декабря честно превратится в 1 января, и код конца года не потребует особых случаев. Ещё две привычки: держите LoadLocation в инициализации — чтение зоны с диска не бесплатное, и в горячем пути оно вам не нужно; и покройте расчёт тестом с фиксированным now, иначе таймзонные ошибки проявятся только на проде в неправильный час. Напоминание про контейнеры: минимальные образы без tzdata вернут ошибку из LoadLocation — лечится пакетом tzdata в образе или импортом time/tzdata.
Отчёт в полночь: ProcessAt
GOloc, err := time.LoadLocation("Europe/Moscow")
if err != nil {
log.Fatal(err)
}
now := time.Now().In(loc)
midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, loc)
info, err := client.Enqueue(reportTask, asynq.ProcessAt(midnight))
if err != nil {
log.Fatal(err)
}
log.Printf("отчёт уйдёт: %v", info.NextProcessAt)LoadLocation с явной таймзоной защищает от UTC-сервера и его «полуночи». time.Date с днём плюс один нормализует переполнение: 31 декабря даст 1 января следующего года. Постановка через ProcessAt переводит задачу в scheduled с баллом-временем в расписании Redis, а NextProcessAt покажет расчётную полночь.
Отложенные задачи redis: sorted set внутри#
Отложенные задачи redis хранит не в списках, как immediate, а в сортированном множестве: член — сериализованная задача, балл — время запуска. Отсюда дешёвые операции расписания: постановка записывает член с баллом, а воркер опрашивает расписание и переводит созревшие задачи в pending-очередь, откуда их забирают хендлеры. Опрос идёт постоянно с коротким интервалом — порядка нескольких секунд, поэтому точность срабатывания в спокойной системе измеряется секундами, а не миллисекундами. Устройство переживает всё, что переживает Redis: рестарты воркеров, рестарты клиентов, перезапуск контейнера — расписание остаётся в хранилище, а не в памяти процессов. Имена ключей менялись между версиями asynq, но принцип один: несколько zset-ов с баллами-временами для scheduled, retry и архива. Если вы прошли курс по go-redis, вы уже встречали сортированные множества с баллами-временами — asynq использует ровно этот приём. Тот же приём обслуживает и повторные: retry-задачи лежат в таком же множестве с баллом-временем следующей попытки, так что ретрай после сбоя и отложенная постановка — близнецы внутри библиотеки. Практический вывод: включённая персистентность Redis — обязательное условие для расписаний, на которые кто-то надеется.
Кейс: напоминания о записи#
Соберём сценарий из первого блока урока целиком. При создании записи сервис ставит задачу reminder:send с ProcessIn(1*time.Hour) и payload из ID пользователя и текста. В час X воркер выполняет хендлер: достаём пользователя, шлём сообщение, return nil. Всё работает до первого отмененного приёма: напоминание-то уже в расписании, и в час X придёт сообщение о приёме, которого нет. Asynq не умеет «удалить задачу по бизнес-ключу» из клиента — постановка однонаправленна. Два рабочих решения: проверка актуальности в хендлере — смотрим в базу, если приёма нет, return nil молча; или управление задачей через Inspector, который умеет отменять по ID — ему посвящён пятнадцатый урок. Первый вариант дешевле и надёжнее: хендлер-проверка срабатывает и после сбоев, и после ручных правок в базе, тогда как отмена Inspector-ом требует хранить ID каждой поставленной задачи. Проверку в хендлере пишите как обычный guard: достали приём по идентификатору, не нашли или приём отменён — логируете пропуск и возвращаете nil. Ошибка тут была бы вредной: несуществующий приём — нормальный исход, а не сбой, и повтор задачи его не исправит.
Четыре способа отложить работу#
| Инструмент | Когда ставит | Пример |
|---|---|---|
| ProcessIn | через N от момента события | напоминание через час |
| ProcessAt | в конкретный момент времени | отчёт в полночь |
| cron и периодика | повторяющееся расписание | пересчёт в 03:00 ежедневно |
| Ретраи | после сбоя с растущей паузой | вторая попытка письма |
Сценарии, которые просятся в расписание#
- Напоминания о записи, встрече, дедлайне — за час или за сутки.
- Письмо номер два: повторное касание клиента через три дня после первого.
- Ночной отчёт в полночь или утренняя выгрузка к началу рабочего дня.
- Проверка статуса платежа через пять минут, если вебхук не пришёл.
- Награда или письмо через сутки после регистрации — дозревание онбординга.
- Отложенное закрытие сессий и охлаждение после неудачных попыток входа.
Классика поставленных на потом задач: забыть про отмену. Что сломалось бы: пользователи получают напоминания о приёмах, которых нет, доверие к рассылкам падает, а саппорт называет вас спамерами. Лечение из кейса выше: хендлер обязан проверять актуальность перед отправкой, return nil для неактуальных — и никакого «удалить из расписания» через клиент: этой операции нет.
Полночь сервера и полночь клиента живут в разных местах. time.Now() в контейнере — почти всегда UTC; time.Date без таймзоны возьмёт локальную и подарит отчёт в неожиданный час. Явный LoadLocation в конфиге и .In(loc) на каждом расчёте времени снимают вопрос целиком; для Alpine-контейнеров не забудьте про пакет tzdata.
ProcessIn против cron: где граница#
Отложенные задачи и cron решают разные вопросы: ProcessIn и ProcessAt отвечают на «когда наступит момент, связанный с событием», cron — на «когда наступит очередной час по календарю». Напоминание о записи привязано к событию, случившемуся сейчас, и живёт в asynq: постановка из кода, payload с данными, ретраи при сбое. Ночной пересчёт рейтингов привязан к календарю и повторяется всегда — это территория cron или периодических задач asynq, которые построены на cron-выражениях и разобраны в девятом уроке. Взрослые схемы используют обе половины: cron ставит периодические задачи, а те, в свою очередь, ставят отложенные — «пересчитать в полночь, а отчёт отправить в девять утра». Если расписание у вас уже живёт в классическом cron, как описано в курсе по cron, asynq спокойно соседствует с ним: процесс-планировщик ставит задачи через client.Enqueue, и отдельного моста не нужно. Гибридный сценарий, где обе половины встречаются в одном предложении: «напомнить за сутки до дедлайна заказа». Дедлайн — дата из базы, значит, это ProcessAt с рассчитанным моментом, а не cron; зато ежедневный пересчёт дедлайнов — уже календарь. Правильный вопрос к себе всегда один: привязано время к событию или к календарю.
Проверка: момент активации
Задача поставлена с ProcessIn(30*time.Minute). Когда она попадёт в состояние active?
Проверка: расписание переживает простой
Воркеры остановлены на два часа, а scheduled-задача «созрела» в это время. Что с ней станет?
Что вернёт второй Enqueue?
Прочитайте код и выберите, что он выведет:
client.Enqueue(task1, asynq.TaskID("reminder-42"), asynq.ProcessIn(1*time.Hour))
client.Enqueue(task2, asynq.TaskID("reminder-42"), asynq.ProcessIn(10*time.Minute))Какая дата попадёт в расписание?
Прочитайте код и выберите, что он выведет:
now := time.Date(2025, 12, 31, 15, 0, 0, 0, loc)
midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, loc)Соберите отложенную постановку
Допишите опции: задержку в минутах и собственный идентификатор напоминания.
Соберите постановку на полночь
Расставьте строки в порядке работы: таймзона, расчёт времени, payload, постановка, лог.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: отложенные задачи
Расписание освоено: ProcessIn для «через час после события», ProcessAt для «в полночь», sorted set внутри Redis как хранилище расписания, проверки актуальности в хендлере как страховка от отменённых событий. Пятый урок закрывает первую часть курса: у вас есть окружение, первая задача, payload с ID и отложенные постановки. Дальше курс разбирает поведение задач во времени: шестой урок — ретраи и экспоненциальная задержка, седьмой — очереди с приоритетами, восьмой — маршрутизация ServeMux. В шестом вы встретите DefaultRetryDelay и экспоненциальный рост паузы — и поймёте, зачем существует потолок в 25 попыток, который мы упоминали дважды. Попробуйте и мини-эксперимент: поставьте задачу на минуту вперёд и остановите воркеры — созревшая задача дождётся в расписании, и в этом весь смысл хранить время в Redis, а не в памяти процесса. Запускайте примеры локально и попробуйте кейс целиком: поставьте напоминание на две минуты, отмените приём в базе и посмотрите, как хендлер молча пропустит задачу. Формат прежний: код живёт на вашей машине с Go и Redis в Docker, песочница сайта показывает только тематическую программу на чистом Go. Это тот случай, когда пять минут практики стоят всей теории о проверках актуальности.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- ProcessIn(d) и ProcessAt(t) — опции постановки: задача уходит в состояние scheduled и ждёт срока.
- Истечение срока переводит задачу в pending; исполняет её живой воркер — сверх срока уходят секунды.
- Расписание живёт в сортированном множестве Redis с баллом-временем и переживает рестарты всех участников.
- Для «в полночь» используйте time.Date с явной таймзоной; time.Now() сервера почти всегда UTC.
- time.Date нормализует переполнения: день 32 декабря превращается в 1 января без особых случаев.
- Клиент не удаляет задачи из расписания: актуальность проверяет хендлер, отменяет — Inspector.
- Retry-задачи лежат в тех же сортированных множествах с баллом-временем: ретрай и отложка — близнецы внутри asynq.
- ProcessIn — для событий, cron — для календаря; периодические задачи asynq разбирает девятый урок.
Домашнее задание
Реализуйте кейс урока целиком: задача reminder:send ставится с ProcessIn(2*time.Minute), хендлер проверяет флаг актив в payload или в базе и молча выходит, если запись отменена. Поставьте задачу, отмените запись и убедитесь, что в логе появился пропуск без отправки. Повторите с ProcessAt на следующую полночь и распечатайте NextProcessAt обеих постановок. А потом закомментируйте проверку актуальности и убедитесь, что напоминание ушло по отменённому приёму: эту боль лучше пережить на тестовом Redis, а не в проде.
Личные заметкиПусто
Вопросы и ответы по уроку
Насколько точно срабатывает ProcessIn?
Задача исполняется не раньше срока; опоздание в спокойной системе — секунды, которые уходят на опрос расписания и свободный воркер. Если вам нужны миллисекунды, очередь задач — неправильный инструмент: смотрите в сторону реального времени, а asynq оставьте задачам, где секунда не видна.
Можно ли поменять время запуска после постановки?
Прямой опции «перенести» у клиента нет: постановка однонаправленна. Варианты — поставить новую задачу и пренебречь старой, проверяя актуальность в хендлере, либо работать с задачей через Inspector, который умеет запускать и отменять по ID; оба подхода сравнивает кейс этого урока.
Что произойдёт с расписанием при падении Redis?
Расписание живёт в Redis, значит, переживает ровно столько, сколько живёт хранилище. При сбое Redis задачи возвращаются после его восстановления, если настроена персистентность, и теряются последние секунды данных в противном случае; поэтому прод-установки включают снимки или журнал и следят за доступностью инстанса. Снимки с коротким интервалом обычно достаточны: потерянные секунды расписания лечатся повторной постановкой, а не бесконечной гарантией.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Kafka в Go: продюсеры и консьюмеры на kafka-go
Курс Kafka для Go: топики и партиции, producer и consumer, группы, оффсеты, гарантии доставки, rebalance, Admin API и мониторинг. 25 уроков.
- Понимать топики, партиции, оффсеты и группы консьюмеров
- Писать продюсеры с ключами, батчами и ретраями
- Читать сообщения группой с коммитами оффсетов
- Выбирать гарантии: at-most-once, at-least-once