Содержание урока · 10 разделов
Наблюдаемость очереди держится на трёх слоях. Первый — логи хендлера: что происходило с конкретной задачей в конкретной попытке. Второй — логи самого сервера asynq: старт, остановка, исчерпанные ретраи; они уже пишутся за вас, нужно лишь выставить уровень. Третий — метрики: числа, по которым видна динамика очереди, а не история одного сообщения. Ошибка новичка — пытаться вытащить всё из одного слоя: логи не покажут, что очередь буксует уже час, а метрика не скажет, почему упала конкретная задача. В этом уроке пройдём все три: что писать в лог из хендлера, что настроить у сервера, откуда взять счётчики очереди и какие из них превратить в алерты. Библиотека не решает это за вас: asynq отдаёт сырье — контекст исполнения, статистику очереди, точки для обёрток, — а логи, экспорт и пороги остаются вашей работой. Слои дополняют друг друга и читаются в порядке: сначала метрика показывает, где болит, затем логи по ID задачи объясняют, почему именно там.
Логи хендлера: пять полей на попытку#
Диагностика начинается с хендлера: именно здесь происходит работа, и здесь лог ценнее всего. Минимальный набор — пять полей на каждую попытку. Первое: тип задачи t.Type() — чтобы по строке лога сразу найти нужный код. Второе: идентификатор — asynq.GetTaskID(ctx) достаёт ID постановки из контекста; строка уникальна для каждой постановки, по ней связаны все попытки одной задачи, и дежурный отвечает на вопрос «что стало с задачей из заказа 4711» одним поиском по логам. Третье: номер попытки — asynq.GetRetryCount(ctx) возвращает, сколько попыток уже было; ноль означает первый заход, а растущий счётчик при одной и той же ошибке — прямой сигнал, что повтор не поможет. Четвёртое: длительность — time.Since(start) вокруг полезной работы; внезапный рост при неизменном коде чаще означает деградацию внешнего сервиса, чем баг. Длительность удобно сравнивать между попытками: резкий скачок на первой и на сотой попытке — разные истории. Пятое: сама ошибка — вместе с ретрай-политикой она объясняет, ушла ли задача в повтор или в архив. Пишите структурно: key=value, а не художественную прозу — парсер системы логов скажет спасибо. И держите одну строку на попытку, без дублей по пути — иначе при ретраях получится шум, в котором пропадает сигнал.
Хендлер с логом попытки
GOfunc HandleInvoice(ctx context.Context, t *asynq.Task) error {
id, _ := asynq.GetTaskID(ctx)
attempt, _ := asynq.GetRetryCount(ctx)
start := time.Now()
err := chargeCustomer(ctx, string(t.Payload()))
log.Printf("task=%s id=%s attempt=%d took=%s err=%v",
t.Type(), id, attempt, time.Since(start), err)
return err
}GetTaskID и GetRetryCount читают контекст исполнения: ID привязывает все попытки одной постановки к одной строке поиска, attempt отличает первый заход от повторов. Ошибка логируется до возврата и попадает в ретрай-политику без изменений. Время замеряется вокруг полезной работы, а не вокруг лога: разница между попытками видна сразу.
Логи сервера и уровень LogLevel#
Второй слой — логи самого asynq: сервер пишет их без вашего участия, и стоит знать какие. На старте появляется «Starting processing», на остановке — «Starting graceful shutdown», затем сообщения про остановку процессора и «Exiting»: по этим строкам проверяют, что деплой прошёл чисто и серверы действительно завершились. Когда попытки задачи заканчиваются, сервер пишет предупреждение «Retry exhausted for task id=...» — полезный хвост для поиска задач, которые упали все двадцать пять раз. Эту строку легко алертить: она означает, что задача закончила жизнь в архиве и пора смотреть LastErr. Управляет этим поле Config.LogLevel со знакомой шкалой: DebugLevel, InfoLevel, WarnLevel, ErrorLevel, FatalLevel. Нюанс, о который спотыкаются: если LogLevel не задан, asynq логирует на DebugLevel — в продакшене это стены отладочных сообщений при каждом опросе очередей. Поэтому в боевом конфиге ставьте InfoLevel, а на шумных инсталляциях WarnLevel: останутся старт и остановка, исчерпанные ретраи и предупреждения, остальное уйдёт. Уровень задаётся в Config, а не переменной окружения: меняется вместе с деплоем, и это честная цена явного конфигурационного кода. В локальной разработке оставьте DebugLevel: подробности выборки задач из очередей экономят часы при непонятных зависаниях.
Config с уровнем логирования
GOsrv := asynq.NewServer(
asynq.RedisClientOpt{Addr: "localhost:6379"},
asynq.Config{
Concurrency: 10,
LogLevel: asynq.InfoLevel,
},
)LogLevel — обычное поле Config, значение — константа пакета asynq: InfoLevel оставит старт, остановку и предупреждения об исчерпанных ретраях, WarnLevel уберёт и информационные сообщения. Явный уровень спасает от дефолтного DebugLevel, который в продакшене превращает лог в сплошной шум опросов Redis. Уровень один на весь сервер: его применяют и внутренние компоненты, поэтому тише станут сразу все внутренние сообщения.
Проверка: уровень по умолчанию
Какой уровень логирования работает у asynq, если поле LogLevel в Config не задано?
Метрики очереди: Inspector как источник#
Логи отвечают на вопрос «что случилось с этой задачей», метрики — «как поживает очередь». Источник чисел — Inspector из пятнадцатого урока: GetQueueInfo возвращает QueueInfo со счётчиками по состояниям — Pending, Active, Scheduled, Retry, Archived, Completed, Aggregating — и общим Size, суммой всех состояний. Счётчики обработок живут рядом: Processed и Failed считаются с начала суток и сбрасываются ежедневно, ProcessedTotal и FailedTotal — накопительные с рождения очереди. Отдельного счётчика «retried» нет: число задач в retry-состоянии — это поле Retry, и растёт оно, когда попытки откладываются. Дубликаты Redis тоже не считает: уникальные задачи из двенадцатого урока отсекаются ещё на постановке, клиент возвращает ErrDuplicateTask — их можно посчитать только в своей клиентской обёртке, инкрементируя метрику при этой ошибке. Состояние Completed появляется, когда задачи оставляются с CompletedTTL, а поле Groups считает группы агрегации из десятого урока. Для истории есть метод History(queue, n): он возвращает DailyStats за последние n суток — Processed, Failed и дату, сырьё для графиков пропускной способности. И главное число для алертов — Latency, возраст самой старой pending-задачи. Обновлять срез удобно периодической задачей из девятого урока: раз в минуту читаете QueueInfo и экспортируете значения в свою систему метрик. Тот же срез печатается в лог и руками при инциденте: дежурный скрипт и экспортер используют один вызов GetQueueInfo.
Поле Processed в QueueInfo включает и успешные, и упавшие задачи — доля ошибок считается у вас: Failed делённый на Processed. Вторая ловушка: MemoryUsage помечен как приближение — значение вычисляется сэмплированием и годится для сравнения очередей между собой, но не для отчёта по байтам. И третья: Latency растёт раньше, чем падает Processed, — ставьте алерт именно на Latency, это самый ранний сигнал затыка. Наконец, счётчики за сутки сбрасываются в полночь: график Processed выглядит как пила, для гладких рядов есть накопительные ProcessedTotal и FailedTotal.
Prometheus-паттерн: middleware в mux#
Метрики хендлеров удобно собирать не в каждом хендлере, а одной обёрткой: у ServeMux из восьмого урока есть метод Use, принимающий MiddlewareFunc — функцию, которая получает Handler и возвращает Handler. Внутри оборачиваете ProcessTask: замеряете длительность через гистограмму с меткой по типу задачи и инкрементируете счётчик результата — успех или ошибка. Такой middleware добавляется одним вызовом mux.Use и работает для всех зарегистрированных типов: хендлеры остаются чистыми, а метрика появляется у новых задач автоматически. Значения в контекст кладутся тем же способом, что в семнадцатом уроке: middleware идёт первым и обогащает контекст до начала работы хендлера. Ошибку middleware обязан вернуть дальше — проглотить её значит соврать очереди, что попытка удалась: задача будет засчитана успешной и ретрая не получит. Порядок Use имеет значение: обёртки применяются в порядке добавления, и метрика, добавленная раньше логирующей, увидит тот же самый код хендлера. Типы задач — отличная метка для метрик: «упало три процента» малоинформативно, а «после деплоя упали все задачи типа email_invoice» — уже начало расследования. Кардинальность меток держите в узде: тип задачи — хорошая метка, ID задачи — нет, каждая уникальная метка рождает новый ряд в хранилище и убивает память.
Middleware с метриками
GOvar taskDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "asynq_task_seconds",
Buckets: prometheus.ExponentialBuckets(0.01, 2, 12),
},
[]string{"type"},
)
func metrics(next asynq.Handler) asynq.Handler {
return asynq.HandlerFunc(func(ctx context.Context, t *asynq.Task) error {
start := time.Now()
err := next.ProcessTask(ctx, t)
taskDuration.WithLabelValues(t.Type()).Observe(time.Since(start).Seconds())
taskResult.WithLabelValues(t.Type(), resultLabel(err)).Inc()
return err
})
}
mux.Use(metrics)MiddlewareFunc принимает Handler и возвращает Handler: внутри строится замыкание, которое замеряет время, пишет гистограмму по типу задачи и счётчик результата, а ошибку возвращает как есть. mux.Use подключает обёртку ко всем задачам сразу; ProcessTask — то самое место, где работа реально происходит.
Проверка: тип middleware
Как выглядит тип MiddlewareFunc, который принимает mux.Use?
Что запишет middleware?
Прочитайте код и выберите, что он выведет:
func loud(next asynq.Handler) asynq.Handler {
return asynq.HandlerFunc(func(ctx context.Context, t *asynq.Task) error {
log.Println("before", t.Type())
err := next.ProcessTask(ctx, t)
log.Println("after, err =", err)
return err
})
}
// loud подключён через mux.Use(loud)
// хендлер вернул ошибку io.EOF, MaxRetry равен 3Что алертить: сигналы вместо графиков#
Метрики без алертов — просто красивые графики, на которые смотрят после инцидента. Работающих сигналов немного. Первый — Latency из QueueInfo: возраст самой старой pending-задачи; пока воркеры успевают, он близок к нулю, и рост начинается раньше, чем очередь превратится в пробку. Второй — соотношение Retry и Archived к Processed: растущий Retry при плоском Processed означает, что зависимый сервис деградировал и попытки копятся; растущий Archived — что пошли фатальные ошибки, которые повтор не исправит. Третий — ошибки по типам задач: счётчик из middleware с меткой t.Type() покажет, что сломался конкретный тип, а не вся система. Четвёртый — Processed, упавший до нуля при непустом Size: воркеры не запущены, стоят на паузе или не видят Redis — классика первого инцидента. Для каждого сигнала нужны порог и окно, и пороги задаёт бизнес: письма и платежи терпят разное. И не алертите на всё сразу: три работающих сигнала полезнее десяти шумных. Дашборд asynqmon из шестнадцатого урока — отличный смотровой пункт, но алерт должен приходить сам, а не ждать, когда кто-то заглянет. Когда Latency растёт без видимых ошибок, проверяйте настройки конкурентности из двадцатого урока. А под Latency держите runbook: шаги из таблицы ниже должны выполняться одной рукой, не заглядывая в документацию.
Правила алертов очереди#
- Алерт на Latency важнее алерта на длину очереди: длинная, но движущаяся очередь не инцидент.
- Долю ошибок считайте сами: Processed включает и неудачи, готовой доли библиотека не отдаёт.
- Метку типа задачи добавляйте в middleware-счётчик: без неё ошибки ищутся только вручную.
- Processed равен нулю при backlog — первая гипотеза: воркеры не запущены или очередь на паузе.
- Пороги задаёт бизнес: письма терпят минуты, платежи — секунды.
- Алерт без первого действия — шум: рядом с каждым правилом пишите, что делать.
Сигнал, источник, первое действие#
| Сигнал | Источник | Первое действие |
|---|---|---|
| Latency превышает порог | GetQueueInfo → Latency | Servers(): живы ли воркеры, не на паузе ли очередь |
| Retry растёт, Processed плоский | QueueInfo → Retry | ListRetryTasks и LastErr: какой сервис деградировал |
| Archived растёт | QueueInfo → Archived | ListArchivedTasks: фатальные ошибки в коде хендлера |
| Рост failed по типу задачи | Middleware-счётчик | Лог попытки по ID задачи: последняя ошибка |
| Processed стоит при backlog | QueueInfo → Processed и Size | Стартовые логи сервера, LogLevel, конфиг очередей |
Соберите лог-строку попытки
Допишите чтение контекста исполнения в хендлере.
Соберите дежурный алерт-скрипт
Расставьте строки: коннект, срез очереди, проверка Latency, обход retry, итоговый лог.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: наблюдаемость
Песочница сайта запускает тематическую программу на чистом Go без Redis: она эмулирует middleware с логом попыток и счётчиками результата — можно менять ошибки хендлера и смотреть, как растут счётчики попыток и меняется доля ошибок. Локально всё по классике: Go и Redis в Docker, и срез QueueInfo, и middleware-метрики исполняются как есть.
Наблюдаемость собрана: пять полей в логе попытки, LogLevel в конфиге сервера, счётчики QueueInfo, middleware для экспорта и четыре сигнала для алертов. Мини-эксперимент: запустите два воркера с разным Concurrency и следите за Latency и Processed — разница покажет, как нагрузка размазывается по конкурентности, а веб-морда очереди из двадцать первого урока поможет смотреть на то же глазами. Формат прежний: код исполняется локально с Go и Redis в Docker, песочница показывает тематическую программу на чистом Go без Redis. Следующий урок — грабли продакшена: там эти метрики превращаются в скорую помощь.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Лог хендлера: тип задачи, ID из GetTaskID, попытка из GetRetryCount, длительность, ошибка.
- GetTaskID и GetRetryCount читают контекст: ID связывает попытки, счётчик отличает первый заход.
- Сервер asynq пишет старт и остановку сам; LogLevel в Config задаёт минимум, по умолчанию DebugLevel.
- QueueInfo: счётчики состояний, Processed и Failed за сутки, ProcessedTotal и FailedTotal, Latency.
- Processed включает и упавшие задачи: долю ошибок считает ваш скрипт.
- Счётчика retried нет: рост повторов виден по полю Retry, дубликаты — только на клиенте через ErrDuplicateTask.
- MiddlewareFunc через mux.Use даёт метрики всем хендлерам: длительность и статус по типу задачи.
- Latency — возраст самой старой pending-задачи и самый ранний алерт-сигнал очереди.
- Middleware-счётчик с меткой по типу задачи заменяет поиск по логам одним графиком.
- Периодическая задача из девятого урока выносит срез QueueInfo на дашборд раз в минуту.
- Алерты: Latency, Retry и Archived против Processed, ошибки по типам задач, Processed равен нулю при backlog.
Домашнее задание
Добавьте наблюдаемость в проект из прошлых уроков. В хендлер — пять полей попытки: тип, ID из GetTaskID, номер попытки, длительность и ошибка. В Config — InfoLevel, и проверьте, что отладочные стены исчезли из логов. Через Inspector выводите в лог срез очереди — Pending, Retry, Archived, Latency, Processed и Failed — и посчитайте долю ошибок за сутки через History. Оберните mux в middleware с гистограммой длительности по типу задачи и счётчиком результата. Напоследок устроите дрейл: остановите воркер и убедитесь, что Latency растёт, а алерт-условие срабатывает. Гистограмму проверьте на реальных длительностях: экспоненциальные buckets должны покрывать и быстрые, и зависшие попытки.
Личные заметкиПусто
Вопросы и ответы по уроку
Нужен ли отдельный экспортер метрик или всё есть в asynq?
Встроенного Prometheus-эндпоинта у asynq нет: библиотека отдаёт числа через Inspector, а экспорт — ваша работа. Самый короткий путь — периодическая задача или отдельный процесс, которые раз в минуту читают QueueInfo и History и обновляют gauge-метрики; пер-хендлерные метрики собираются middleware в mux. Кардинальность меток ограничьте типом задачи и очередью: ID задачи в метки не попадает никогда.
Логировать тело payload в логах хендлера?
Только с осторожностью: в payload живут персональные данные — адреса, суммы, содержимое писем. В лог достаточно ID и типа: по ним payload достанется из очереди или из хранилища задач. Если очень нужно — логируйте проекцию из разрешённых полей и помните про политики хранения логов.
Что выбрать для алертов: asynqmon или свои метрики?
Asynqmon отлично закрывает взгляд глазами: что происходит прямо сейчас. Активные алерты на порогах — свои метрики: Inspector даёт числа программно, а ваш алертер умеет писать в чат и звонить дежурному. Рабочая комбинация: свои метрики для сигналов, asynqmon для разбора инцидента.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Kafka в Go: продюсеры и консьюмеры на kafka-go
Курс Kafka для Go: топики и партиции, producer и consumer, группы, оффсеты, гарантии доставки, rebalance, Admin API и мониторинг. 25 уроков.
- Понимать топики, партиции, оффсеты и группы консьюмеров
- Писать продюсеры с ключами, батчами и ретраями
- Читать сообщения группой с коммитами оффсетов
- Выбирать гарантии: at-most-once, at-least-once