Содержание урока · 10 разделов
- Asynq payload: байты, json.Marshal и структура
- Общий пакет типов: контракт клиента и воркера
- Task id asynq: собственные идентификаторы
- TaskInfo: что возвращает постановка задачи
- Опции Enqueue: MaxRetry, Queue и остальные
- Опции Enqueue, которые пригодятся сразу
- Правила payload одним списком
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Начну с код-ревью, которое я не забыл до сих пор. В задаче отчёта payload выглядел как строка «100500» — просто идентификатор заказа, собранный fmt.Sprintf. Через месяц понадобился признак «отчёт для теста»: код постановки изменили, а воркеры старой версии ещё неделю читали старый формат. Плюс появились задачи с payload «100500» и «100500, dry» — парсить это в хендлере стало отдельным жанром. История закончилась структурой с json-тегами и собственным ID задачи, и после неё я переписываю payload на первом же ревью. В этом уроке — как правильно упаковывать данные задачи, где живёт структура, зачем задаче собственный ID и какие опции Enqueue меняют поведение постановки.
Asynq payload: байты, json.Marshal и структура#
Asynq payload устроен предельно честно: второй аргумент asynq.NewTask — это []byte, и библиотека не делает предположений о его содержимом. Задача без данных передаёт nil; задача с данными чаще всего несёт JSON, который клиент собрал через json.Marshal из структуры с тегами. Стандарт сериализации не навязан, но JSON стал дефолтом по трём причинам: он читается в дашборде и redis-cli, выдерживает эволюцию полей и не требует ничего сверх encoding/json. Практика, которая никогда не подводит: рядом с конструктором задачи жить декодер — функция, принимающая *asynq.Task и возвращающая распакованную структуру с ошибкой. Конструктор и декодер образуют пару, которая прячет сериализацию от обеих сторон: клиент не знает про байты, воркер не знает про структуру постановки. Для задач без параметров это всё тоже работает — просто структура пустая, а теги не нужны. Задача в очереди — это тип и байты, больше ничего: t.Payload() у воркера вернёт ровно те же байты, что вернул Marshal у клиента, и никакая типизация не переносится между процессами сама — только через сериализацию.
Пакет tasks: конструктор и декодер рядом
GOpackage tasks
import (
"encoding/json"
"github.com/hibiken/asynq"
)
const TypeOrderReport = "report:order"
type OrderReportPayload struct {
OrderID int64 `json:"order_id"`
Emails []string `json:"emails,omitempty"`
DryRun bool `json:"dry_run,omitempty"`
}
func NewOrderReportTask(p OrderReportPayload) (*asynq.Task, error) {
payload, err := json.Marshal(p)
if err != nil {
return nil, err
}
return asynq.NewTask(TypeOrderReport, payload), nil
}
func DecodeOrderReport(t *asynq.Task) (OrderReportPayload, error) {
var p OrderReportPayload
err := json.Unmarshal(t.Payload(), &p)
return p, err
}Контракт задачи целиком в одном месте: тип, структура, конструктор и декодер. Срез строк сериализуется JSON-массивом, а omitempty выкидывает пустые поля — новые ключи добавим позже без поломки старых воркеров. Теги пишем в user_id-стиле: ключи читаются в дашборде и redis-cli без привычки к горбикам. DecodeOrderReport возвращает ошибку: хендлер обязан её обработать, а не игнорировать, — декодер живёт рядом с конструктором, чтобы контракт менялся одним коммитом.
Правила payload, которые я записал себе после первого года с очередями, помещаются в шесть строк. Данные задачи — идентификаторы, а не сущности: userID, а не весь профиль; order_id, а не заказ с позициями. Всё, что можно достать в хендлере по идентификатору, достаётся в хендлере — база всё равно свежее Redis. Блобов не будет: картинка живёт в S3, в payload едет её ключ. Изменение структуры — только добавление полей, лучше с omitempty; переименование поля без плана миграции ломает задачи, которые ещё лежат в очереди. Ошибка Unmarshal проверяется всегда — нулевая структура проходит все проверки тише, чем ошибка. И последний пункт — тест на раундтрип: Marshal, Unmarshal, сравнение. Такой тест занимает пять строк и ловит переименования тегов раньше продакшена.
Общий пакет типов: контракт клиента и воркера#
Пакет tasks из предыдущего урока здесь работает на полную: клиент и воркер импортируют одни и те же структуры, и расхождение становится невозможным на уровне компилятора. Почему это принципиально, лучше всего объясняет rolling-деплой: воркеры старой версии ещё крутятся, клиент уже поставил задачи новым форматом. Если структуры общие и поля только добавлялись, старый воркер молча пропустит незнакомые ключи и выполнит задачу по старым правилам. Если же структуры дублировались в двух репозиториях, задача разваливается: клиент маршалит одно, воркер ждёт другое, и на дашборде растёт колонка ошибок Unmarshal. Отсюда рабочее правило: тип задачи, payload-структура, конструктор и декодер переезжают вместе; если клиент и воркер в разных сервисах, общий пакет выносят в отдельный модуль. Теги json — часть этого контракта, и их изменение проверяется тестом, как публичный API, потому что это он и есть. На практике общий пакет тянет за собой дисциплину: новое поле добавляется с omitempty и старый воркер его молча пропустит, а вот смена типа существующего поля — уже новый контракт, требующий согласованного релиза обеих сторон.
Task id asynq: собственные идентификаторы#
По умолчанию asynq генерирует ID задачи сам — непрозрачную строку, которая хороша для логов и дашборда, но не несёт бизнес-смысла. Опция asynq.TaskID("...") позволяет задать собственный идентификатор: строку, которую придумали вы. Главное свойство: ID уникален среди живых задач — тех, что лежат в pending, active, scheduled или retry. Повторная постановка с тем же ID, пока первая задача жива, вернёт ошибку ErrTaskIDConflict. Ошибку эту проверяют через errors.Is(err, asynq.ErrTaskIDConflict) и обрабатывают как штатную ветку дедупликации: первая задача уже живёт, ставить заново не нужно, — но залогировать конфликт стоит, чтобы отличать задуманную защиту от случайной коллизии ID. Это превращает TaskID в примитивный инструмент дедупликации: ID вида report-100500 гарантирует, что двойной клик в интерфейсе, повторный вебхук или два воркера, обрабатывающих одно событие, не поставят отчёт дважды. Важная оговорка: после завершения задачи её ID освобождается, и TaskID — не вечный замок, а защита на время жизни задачи. Для «не ставить одинаковые задачи в принципе» существует отдельная опция Unique со сроком годности — ей посвящён урок про уникальные задачи.
Постановка с опциями: TaskID в деле
GOp := tasks.OrderReportPayload{OrderID: 100500, DryRun: true}
task, err := tasks.NewOrderReportTask(p)
if err != nil {
log.Fatal(err)
}
info, err := client.Enqueue(task,
asynq.MaxRetry(3),
asynq.Queue("reports"),
asynq.TaskID("report-100500"),
)
if err != nil {
log.Fatal(err)
}
log.Printf("id=%s queue=%s retry=%d", info.ID, info.Queue, info.MaxRetry)Опции Enqueue — вариативные аргументы после задачи: каждая меняет один аспект постановки. TaskID с бизнес-ключом делает повторную постановку невозможной, пока жива первая задача, Queue уводит отчёты из default, MaxRetry урезает стандартные 25 попыток до трёх. TaskInfo возвращает фактические значения постановки — их удобно логировать сразу.
TaskInfo: что возвращает постановка задачи#
TaskInfo — это отчёт о постановке, а не о выполнении, и его полезно понимать поле за полем. ID — строка-идентификатор: ваша, если задали TaskID, или сгенерированная asynq; собственный ID и TaskInfo.ID при этом совпадают, и эта проверка бесплатна. Queue — куда задача попала фактически, а не куда вы надеялись. MaxRetry — число попыток, зафиксированное опцией или дефолтом: сразу видно, что осталось от стандартных 25 после ваших настроек. Retried показывает, сколько попыток уже сделано: после постановки он нулевой и растёт по мере жизни задачи, если читать её через Inspector; рост Retried при живой задаче — самый дешёвый способ увидеть, что хендлер падает системно, а не разово. LastErr и LastFailedAt сообщают последнюю ошибку и время сбоя — по ним воркерные логи связываются с задачей без grep по миллионам строк. Есть и State — текущее состояние вроде pending, scheduled или active; словарь состояний пригодится в уроках про Inspector и asynqmon. Логировать info при постановке я приучил всю команду: три поля в логе постановки стоят дешевле часа расследований «а поставилась ли задача вообще». Единственное, чего TaskInfo не умеет, — предсказывать будущее: успешность выполнения в нём не появится, потому что постановка об исполнении не знает ничего.
Опции Enqueue: MaxRetry, Queue и остальные#
Вариативные опции — второй способ говорить с asynq после payload: задача описывает «что делать», опции описывают «как обещаем». MaxRetry(n) задаёт число попыток: по умолчанию их 25, что для письма после невалидного адреса — расточительство, а MaxRetry(3) или даже 0 обрезает лишнее; весь механизм повторов разбирается в шестом уроке. Queue(name) отправляет задачу в именованную очередь — critical, default, low; воркер читает их с весами, о которых рассказывает урок про очереди и приоритеты. ProcessIn(d) и ProcessAt(t) откладывают запуск — им посвящён следующий, пятый урок. Unique(dur) делает постановку уникальной на срок — тема урока про уникальные задачи. Timeout и Deadline ограничивают жизнь хендлера, Retention удерживает завершённую задачу в дашборде. Опции комбинируются свободно, а их перечисление в коде читается как обещание: «три попытки, в очередь reports, отложить на полчаса». Порядок опций в вызове не имеет значения — asynq собирает их в вариантный срез и применяет по смыслу, поэтому читайте перечисление как список характеристик, а не как конвейер шагов.
Опции Enqueue, которые пригодятся сразу#
| Опция | Что делает | Пример |
|---|---|---|
| MaxRetry(n) | число попыток после сбоя | asynq.MaxRetry(3) |
| Queue(name) | именованная очередь вместо default | asynq.Queue("critical") |
| ProcessIn(d) | запуск через заданную задержку | asynq.ProcessIn(30*time.Minute) |
| ProcessAt(t) | запуск в точное время | asynq.ProcessAt(midnight) |
| TaskID(s) | собственный ID, уникальный среди живых задач | asynq.TaskID("report-100500") |
| Unique(d) | уникальность постановки на срок | asynq.Unique(10*time.Minute) |
Типичная ошибка, которую я вылавливаю до сих пор: err := json.Unmarshal(...) без if. Формально задача «выполнена»: хендлер вернул nil, письма нет, отчёт нулевой. Сломанный payload даёт нулевую структуру: UserID равен нулю, а дальше — письмо на пустой адрес или отчёт по заказу 0. Что сломалось бы: тихая потеря данных, которую находят по жалобам, а не по логам. Правило: ошибка декода — это ошибка задачи, возвращайте её наверх. Дальше валидируйте семантику: UserID меньше единицы или пустой email — повод вернуть ошибку сразу, пока задача не поехала по ретраям с бессмысленной работой.
Соблазн положить в payload готовый документ — HTML письма, сгенерированный отчёт, картинку base64 — велик и вреден. Redis начнёт есть память, чтение задачи замедлится, а каждая повторная постановка дублирует блоб. В payload едет ключ: URL шаблона, ID документа, путь в S3; сам документ достаётся хендлером, и при ретрае он будет уже свежим. Ориентир, до которого я пропускаю payload без вопросов, — пара килобайт; дальше — хранилище и ключ.
Правила payload одним списком#
- Идентификаторы вместо сущностей: userID и order_id, а не целые объекты.
- Общие структуры с json-тегами в пакете, который импортируют клиент и воркер.
- Никаких блобов: документ живёт в хранилище, в payload — его ключ.
- Новые поля добавляйте с omitempty; переименование поля — это миграция.
- Ошибка Unmarshal проверяется всегда: нулевая структура — тихая потеря.
- Раундтрип-тест Marshal затем Unmarshal ловит поломку контракта в CI.
Проверка: конфликт ID
Клиент дважды ставит задачу с asynq.TaskID("report-100500"), первая ещё жива. Что вернёт второй Enqueue?
Проверка: payload без данных
Задача создана как asynq.NewTask("tick", nil). Каков её payload?
Что окажется в поле UserID?
Прочитайте код и выберите, что он выведет:
// клиент
type TaskPayload struct {
UserID int `json:"userID"`
}
// воркер
type TaskPayload struct {
UserID int `json:"user_id"`
}Восстановите постановку с опциями
Допишите три опции: число попыток, очередь и собственный идентификатор.
Соберите путь payload от клиента до воркера
Расставьте строки в порядке данных: структура, маршалинг, постановка, лог, разбор у воркера.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: payload и ID
Данные и идентификаторы под контролем: payload едет структурами из общего пакета, у задачи есть имя, а опции Enqueue позволяют обещать очереди, попытки и сроки. Следующий урок — первый из тех, где опции становятся главным героем: отложенные задачи с ProcessIn и ProcessAt. Напоминание через час, отчёт в полночь, повторное касание клиента через три дня — всё это те же Enqueue и NewTask плюс одна опция времени. Там же разберём, как Redis хранит расписание и что происходит с задачей, когда назначенный час наступает, а воркеры приостановлены. И проверьте в своём коде ErrTaskIDConflict: двойной клик по кнопке — самый частый способ поставить одну задачу дважды, и ветка обработки конфликта должна существовать до продакшена, а не после первого инцидента. Если клиент и воркер живут в разных репозиториях, не ждите беды: выделите общий модуль с задачами в первый же спринт — дешевле, чем разбирать колонку ошибок Unmarshal на дашборде. Формат запуска не меняется: локальный Go, Redis в Docker, песочница показывает тематическую программу на чистом Go.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Payload — это []byte: JSON из структуры с тегами стал дефолтом, а nil допустим для задач без данных.
- Конструктор и декодер задачи живут рядом в общем пакете — контракт, который не разойдётся при деплое.
- Идентификаторы вместо сущностей, ключи вместо блобов, добавление полей вместо переименования.
- asynq.TaskID даёт задаче имя; повтор с тем же ID, пока задача жива, вернёт ErrTaskIDConflict.
- TaskInfo возвращает факты постановки: ID, очередь, MaxRetry — логируйте их всегда.
- errors.Is(err, asynq.ErrTaskIDConflict) — штатная ветка дедупликации: логируйте конфликт, а не глотайте молча.
- Опции Enqueue — обещания задачи: MaxRetry, Queue, ProcessIn, ProcessAt, Unique и другие комбинируются свободно.
- Ошибка Unmarshal — ошибка задачи: нулевая структура тише любой ошибки и дороже её по последствиям.
Домашнее задание
Перепишите payload одной своей задачи по образцу урока: структура с json-тегами, конструктор и декодер в общем пакете, постановка с asynq.TaskID по бизнес-ключу. Добавьте раундтрип-тест: Marshal, Unmarshal, сравнение структур — и сломайте тег специально, чтобы увидеть, как тест ловит поломку контракта. Проверьте и конфликт: вторая постановка с тем же ID обязана вернуть ErrTaskIDConflict, и код должен отреагировать осознанно. Запуск и проверка — локально с живым Redis.
Личные заметкиПусто
Вопросы и ответы по уроку
Можно ли сериализовать payload не в JSON?
Да: NewTask принимает любые байты, и protobuf или msgpack встречаются в проектах с требованиями к размеру и скорости. Но JSON читается в дашборде, эволюционирует без схем и не тянет зависимостей — для старта и большинства сервисов это весомее.
TaskID или Unique — что использовать против дубликатов?
TaskID защищает на время жизни конкретной задачи: как только она завершена, ID освобождается. Unique держит «не ставить то же самое» заданный срок, даже если старая задача давно закрыта. Для защиты от двойного клика часто хватает TaskID, для дедупликации событий — Unique.
Сколько полей выдерживает payload?
Технический потолок — лимит значения Redis, до которого дело не дойдёт никогда при разумном дизайне. Практический потолок наступает раньше: payload длиннее пары килобайт — повод перенести данные в хранилище и оставить идентификатор, иначе ретраи начнут дублировать блобы. Лимит значения Redis — 512 мегабайт на строку: потолок, о котором стоит знать, но не бояться.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Kafka в Go: продюсеры и консьюмеры на kafka-go
Курс Kafka для Go: топики и партиции, producer и consumer, группы, оффсеты, гарантии доставки, rebalance, Admin API и мониторинг. 25 уроков.
- Понимать топики, партиции, оффсеты и группы консьюмеров
- Писать продюсеры с ключами, батчами и ретраями
- Читать сообщения группой с коммитами оффсетов
- Выбирать гарантии: at-most-once, at-least-once