Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 24 из 25 ~20 мин +50 XP

Грабли продакшена: от утечек до дубликатов

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

Содержание урока · 10 разделов

У queue-систем симптом почти всегда один — «работа не делается», но причин под ним много: от забытого воркера до дубликатов в платёжном хендлере. Каждая из них лечится по-своему, и перебор гипотез наугад стоит дороже, чем готовый чек-лист. В этом уроке — карта типовых инцидентов в формате «симптом, причина, лечение»: таблица для быстрой навигации и разбор трёх самых частых граблей с кодом. Всё, что понадобится по ходу, уже изучено: ретрай-политика, очереди с приоритетами, идемпотентность, graceful shutdown и наблюдаемость. Задача урока другая — собрать это в дежурную инструкцию, по которой можно работать в два ночи, не перечитывая исходники библиотеки. Половина пунктов чек-листа проверяется одной командой через Inspector — подготовьте её заранее. Формат у каждого пункта жёсткий: что видит дежурный, почему так происходит и какое минимальное действие возвращает систему к жизни. Правило чтения: симптом подсказывает, куда смотреть, причина объясняет, почему система так себя повела, а лечение — минимальное действие, после которого работа продолжается. Двойных разборов здесь нет: всё уже пройдено в предыдущих уроках, тут только компоновка.

Чек-лист инцидента: семь граблей#

Таблица ниже — карта диагностики: найдите симптом слева, получите гипотезу и лечение. Три верхних грабля встречаются чаще остальных, поэтому после таблицы разбираем их с кодом: забытый воркер, payload без версии и взрыв ретраев — на них приходится большая часть реальных тикетов. Остальные — дубликаты, короткий таймаут остановки, один Redis без приоритетов и отсутствие мониторинга лага — проходятся короче, но от этого не реже случаются. Держите чек-лист в дежурной документации рядом с алертами из прошлого урока: в разгар инцидента листать исходники библиотеки некогда, а таблица снимает большую часть догадок. И просматривайте таблицу целиком: у соседних граблей симптомы похожи, а лечение разное — копящийся Retry и стоящий Processed говорят о разном.

Семь граблей: симптом, причина, лечение#

ГрабльСимптомЛечение
Забытый воркерPending растёт, Processed равен нулюЗапустить сервер, сверить конфиг очередей, Servers()
Payload без версииХендлер падает на старых сообщениях после релизаПоле версии в payload и ветки для старых форматов
Взрыв ретраевRetry копится, внешний сервис тонет в повторахМеньше MaxRetry, свой RetryDelayFunc, SkipRetry
Нет идемпотентностиДвойные списания и повторные письмаКлюч идемпотентности в хендлере
Короткий ShutdownTimeoutДеплой рвёт попытки, задачи возвращаютсяShutdownTimeout под длительность задач, ctx.Done()
Один Redis без приоритетовКритичные задачи стоят за тяжёлымиОчереди с весами, отдельные воркеры
Нет мониторинга лагаО проблеме узнают пользователиАлерты на Latency из двадцать третьего урока

Забытый воркер и payload без версии#

Грабль первый — классика: клиент ставит задачи, очередь принимает, API отвечает, а работа не делается. Проверяется одним вызовом GetQueueInfo: Pending растёт, Processed стоит, Latency растёт. Причина скучна: сервер asynq не запущен, запущен без нужной очереди в конфиге или не достучался до Redis. Лечение: Servers() из пятнадцатого урока показывает, кто вообще подключён, стартовые логи подскажут конфиг, а алерт «Processed равен нулю при backlog» ловит это без пользователей. Разновидность того же грабля: сервер запущен, но смотрит в Redis соседней среды — staging вместо продакшена. Воркер жив, а задач из вашей очереди он не видит, и картина идентична до последнего процента. Грабль второй коварнее: вы добавили поле в payload и выкатили релиз — и хендлеры начали падать на сообщениях, поставленных старой версией клиента. Задачи в Redis переживают релиз: ретраи, отложенные и периодические задачи были сохранены со старым форматом и теперь декодируются новым кодом. Симптом: волна ошибок после каждого деплоя, которая потом проходит сама — очередь вычерпала старое. Лечение: версионирование payload — поле с номером формата; хендлер проверяет версию и понимает старые форматы или отказывается с осмысленной ошибкой. Полезно помнить асимметрию JSON: старый код новое поле просто игнорирует, а новый код на старом сообщении поле не находит — ломается всегда новая сторона, поэтому версия ставится при постановке и читается при исполнении. Тесты задач из двадцать второго урока обязаны включать старую версию: контракт формата живёт дольше кода, который его выпустил.

Payload с версией формата

GO
var p invoicePayload
if err := json.Unmarshal(t.Payload(), &p); err != nil {
    return fmt.Errorf("payload не разбирается: %w", err)
}
switch p.Version {
case 1:
    return chargeLegacy(p.OrderID)
case 2:
    return charge(p.OrderID, p.Total)
default:
    return fmt.Errorf("payload v%d не поддерживается", p.Version)
}

Версия — обязательное поле нового формата, а хендлер превращается в таблицу маршрутизации: старая версия идёт по legacy-ветке, новая — по основной. Отказ для неизвестной версии даёт осмысленный LastErr в TaskInfo вместо паники декодирования. Старые сообщения живут в очереди до последней попытки — код обязан это помнить.

Проверка: Processed стоит

QueueInfo показывает: Pending растёт, Processed равен нулю, контейнер воркера жив. Первая гипотеза?

Взрыв ретраев: MaxRetry и бэкофф#

Грабль третий — самый дорогой по последствиям. По умолчанию asynq делает двадцать пять попыток, а пауза между ними растёт по формуле из sidekiq: n^4 плюс пятнадцать секунд и джиттер. Для нестабильного внешнего API это выглядит так: сервис деградировал на десять минут, у вас тысячи задач, каждая ждёт своего окна, а после восстановления на него обрушивается отложенный шторм — плюс ретраи задач, поставленных прямо в разгар деградации. Симптом: Retry копится, паузы между попытками уходят в часы, внешний сервис проседает от ваших же повторов. Лечение многослойное: разумный MaxRetry на постановке — три-пять для обычных задач; RetryDelayFunc в Config — свой профиль пауз под тип задачи; SkipRetry из тринадцатого урока — для ошибок, которые повтором не исправятся; circuit breaker перед внешним вызовом — чтобы не добивать умирающий сервис. Помните арифметику: backlog, умноженный на число попыток, — это ваша будущая нагрузка на зависимость, и считать её надо до инцидента, а не после. Классифицируйте ошибки до настройки повторов: сетевой таймаут стоит повторить, а нехватку баланса — нет, и лечатся они разными механизмами: профилем пауз или SkipRetry.

Ограничиваем шторм повторов

GO
// на постановке: не мучить чужой API
client.Enqueue(task, asynq.MaxRetry(5))

// в Config: своя кривая пауз
srv := asynq.NewServer(redisOpt, asynq.Config{
    RetryDelayFunc: func(n int, e error, t *asynq.Task) time.Duration {
        return time.Duration(n*n) * time.Minute
    },
})

MaxRetry на постановке режет число попыток конкретной задачи, а RetryDelayFunc в Config определяет паузу по номеру попытки, ошибке и задаче — квадратичный профиль мягче дефолтной четвёртой степени. Для невосстановимых ошибок хендлер возвращает SkipRetry, и попытки прекращаются немедленно. Пауза возвращается как обычная длительность, и профиль подбирается под скорость восстановления зависимости: быстрым сервисам — секунды, медленным — минуты.

Дубликаты: двойные списания#

Грабль четвёртый — следствие честной семантики at-least-once: asynq гарантирует исполнение как минимум один раз, значит, потенциально — больше одного. Воркер умер посреди исполнения — задача вернётся в pending и исполнится снова; деплой оборвал попытку — то же самое. Если хендлер списывает деньги, второй запуск и есть двойное списание. Защита живёт не в очереди, а в хендлере: ключ идемпотентности — обычно идентификатор бизнес-операции из payload, а не ID задачи — проверяется до работы. Варианты: уникальный индекс в базе по ключу операции, SetNX в Redis с TTL или таблица исполненных операций. Выбор хранилища зависит от цены ошибки: для платежей ключ живёт в базе рядом с транзакцией, Redis — для операций, где потеря ключа не катастрофа. Unique-опция из двенадцатого урока и ErrTaskIDConflict защищают только постановку: две копии не попадут в очередь, но повторное исполнение одной и той же задачи они не отменяют — это разные вещи. Отсюда тест-лист идемпотентности: одна и та же задача дважды, две задачи с одним бизнес-ключом, рестарт посреди списания — все три случая дают ровно одно исполнение. Паттерн из восемнадцатого урока: захватили ключ — работаем; ключ занят — задача уже сделана или делается. Симптом грабля: жалобы на дубликаты после каждого рестарта; лечение ставится один раз и навсегда. Зато после него деплои перестают быть источником финансовых тикетов.

Идемпотентный хендлер

GO
func HandleCharge(ctx context.Context, t *asynq.Task) error {
    var p chargePayload
    if err := json.Unmarshal(t.Payload(), &p); err != nil {
        return fmt.Errorf("payload: %w", err)
    }

    ok, err := idem.Acquire(ctx, "charge:"+p.OrderID, 24*time.Hour)
    if err != nil {
        return err // инфраструктура недоступна: честный ретрай
    }
    if !ok {
        return nil // операция уже исполнена или исполняется
    }
    return charge(ctx, p.OrderID, p.Total)
}

Ключ строится из бизнес-идентификатора заказа, а не из ID задачи: повторная попытка той же задачи и повторная постановка одной операции дают один ключ. Захваченный ключ возвращает nil — очередь считает задачу исполненной; ошибка инфраструктуры возвращается как есть, чтобы ретрай был честным. TTL ключа выбирается дольше окна возможных повторов: сутки — разумный минимум для платёжных операций.

Что произойдёт со второй попыткой?

Прочитайте код и выберите, что он выведет:

// попытка 1: Acquire вернул ok=true, charge упал с io.EOF
// попытка 2: тот же payload, тот же ключ "charge:4711"

ok, err := idem.Acquire(ctx, "charge:"+p.OrderID, 24*time.Hour)
if !ok {
    return nil
}
return charge(ctx, p.OrderID, p.Total)

Деплой, таймауты и один Redis#

Грабль пятый: ShutdownTimeout по умолчанию — восемь секунд. Деплой шлёт сигнал, сервер ждёт восемь секунд и уходит; задачи, которым нужны минуты, рвутся посреди попытки. Дальше включается механика восстановления: задача без живого воркера помечается флагом IsOrphaned и возвращается в pending — исполнение начнётся заново с нуля. Задача не пропадает, но после деплоя возможна минута тишины — объясните это дежурным заранее, чтобы не искали пропажу. Для платежей это снова про идемпотентность, для писем — про дубли в почте. Лечение: ShutdownTimeout в Config под реальную длительность задач, хендлеры, слушающие отмену контекста, и поэтапный выкат. Грабль шестой: один Redis и очередь default на всё — критичные платежи стоят за тяжёлыми отчётами. Очереди с весами из седьмого урока делят трафик: critical, default, low — вес это вероятность выборки, а не строгий порядок, поэтому перегруженная тяжёлая очередь не отбирает всё время у критичной; Concurrency из двадцатого урока задаёт, сколько задач берётся одновременно; отдельные воркеры с разными конфигами дают жёсткую изоляцию, когда отчёты душат всё. Симптом: Latency растёт только у одной очереди, остальные здоровы; лечение — приоритеты и границы, а не больше CPU. И проверьте, что критичная очередь вообще перечислена у каждого воркера: очередь, забытая в конфиге, обслуживается никем.

Правила выживания перед релизом#

  • Алерт «Processed равен нулю при backlog» ставится до первого деплоя, а не после первого инцидента.
  • Каждый формат payload носит версию: старые сообщения в очереди переживут ваш релиз.
  • MaxRetry задаётся осознанно на постановке: дефолт в двадцать пять попыток штормит в худший момент.
  • Хендлер с побочными эффектами обязан иметь ключ идемпотентности: at-least-once — это договор, а не сюрприз.
  • ShutdownTimeout подбирается по самой длинной задаче: восемь секунд по умолчанию — не обещание, а минимум.
  • Очереди с весами и отдельные воркеры дешевле, чем разбор, почему платежи стояли за отчётами.
  • Идемпотентность проверяется тестом: одна и та же задача ставится дважды, исполнение — ровно одно.

Проверка: таймаут остановки

Сколько asynq ждёт работающие задачи по умолчанию, если ShutdownTimeout не задан?

Укоротите шторм повторов

Допишите ограничение попыток и свою кривую пауз.

client.Enqueue(t, asynq.(5)) srv := asynq.NewServer(redisOpt, asynq.Config{ : func(n int, e error, t *asynq.Task) time.Duration { return time.Duration(n*n) * time.Minute }, })

Runbook: работа не делается

Расставьте шаги диагностики от первого взгляда до лечения.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: грабли продакшена

Термин

Pending растёт, Processed ноль

нажмите, чтобы перевернуть
Определение

Забытый или сломанный воркер: проверьте Servers(), конфиг очередей и стартовые логи. Latency растёт первым.

1 / 6

Семь граблей разложены по схеме «симптом, причина, лечение»: три с кодом — забытый воркер, payload без версии и шторм ретраев, остальные — с готовыми решениями. Мини-эксперимент: поставьте задачу с MaxRetry(3) и хендлером, который всегда падает, — посмотрите, как дефолтная формула пауз растянет три попытки на минуты, затем замените RetryDelayFunc и сравните. Формат прежний: код исполняется локально с Go и Redis в Docker, песочница — тематическая программа на чистом Go без Redis. Дальше — итоговый проект курса, где соберётся всё: постановка, ретраи, очереди, идемпотентность и наблюдаемость из двадцать третьего урока. Пройдите чек-лист по своему проекту до первого инцидента — тогда он останется упражнением, а не разбором полётов. Грабли не исчезают — исчезает время на их поиск.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

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

  • Забытый воркер: Pending растёт, Processed ноль — проверьте Servers(), конфиг очередей и стартовые логи.
  • Payload живёт дольше релиза: версия в формате, ветки для старых форматов и тесты на старую версию.
  • Шторм ретраев: MaxRetry 3-5, свой RetryDelayFunc, SkipRetry для невосстановимых, circuit breaker перед внешним API.
  • At-least-once гарантирует минимум одну попытку: дубликаты лечатся ключом идемпотентности, а не настройками очереди.
  • ShutdownTimeout по умолчанию 8 секунд: длинные задачи рвутся деплоем и возвращаются в pending.
  • Один Redis и default-очередь на всё: критичное стоит за тяжёлым — лечится весами и отдельными воркерами.
  • Payload без версии — волна ошибок после каждого релиза: старые сообщения переживают выкат.
  • Вес очереди — вероятность выборки, а не порядок: критичное защищено границами, а не CPU.
  • Без мониторинга лага о проблеме узнают пользователи: алерты на Latency из прошлого урока.
  • Формат «симптом, причина, лечение» держите в дежурной документации рядом с алертами.

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

Соберите чек-лист по своему проекту. Проверьте, что payload каждой задачи несёт версию и хендлер знает старые форматы; поставьте осознанные MaxRetry и RetryDelayFunc вместо дефолтов; добавьте ключ идемпотентности в каждый хендлер с побочными эффектами. Замерьте самую длинную задачу и выставьте ShutdownTimeout с запасом, хендлер — с проверкой отмены контекста. Разделите очереди с весами и убедитесь, что тяжёлая задача не блокирует критичную. Напоследок учения: остановите воркер и проследите за Pending, Latency и своим оповещением — от первого сообщения до починки должно пройти не больше пары минут. Повторите учения после каждой правки таймаутов: ShutdownTimeout проверяется только реальным деплоем.

Личные заметкиПусто
Хранятся только в вашем браузере (Asynq · Грабли продакшена: от утечек до дубликатов)

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

Как узнать дефолтные значения MaxRetry и ShutdownTimeout, если они не заданы?

В исходниках библиотеки: defaultMaxRetry равен 25, defaultShutdownTimeout — 8 секунд. Если дефолт критичен для сервиса, задавайте значения явно: неявные дефолты — источник сюрпризов при обновлении версии библиотеки, когда поведение может измениться. Список дефолтов стоит перечитать при подъёме новой минорной версии: библиотека развивается, и часть значений со временем менялась.

Двойное списание случилось — что делать с задачей?

Сначала остановить кровь: ArchiveTask или DeleteTask по ID через Inspector, чтобы ретраи не продолжились. Потом разбираться: LastErr в TaskInfo покажет последнюю ошибку, логи по ID — обе попытки. Компенсация в виде возврата средств — бизнес-процедура: очередь тут только свидетель, а не участник.

Один Redis на несколько сервисов — это тоже грабль?

Сам по себе нет: Redis спокойно делится между сервисами, если ключи и очереди разнесены по именам. Грабль — когда все задачи одного сервиса падают в одну default-очередь без приоритетов. Разнесение по очередям с весами решает и мульти-сервисный случай, а отдельный Redis нужен уже для отказоустойчивости. Если сервисы чужие, договоритесь об именах очередей заранее: пересечение имён — отдельный вид граблей.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Kafka25 уроков~12 чпрактика25 уроков уже доступны

Kafka в Go: продюсеры и консьюмеры на kafka-go

Курс Kafka для Go: топики и партиции, producer и consumer, группы, оффсеты, гарантии доставки, rebalance, Admin API и мониторинг. 25 уроков.

  • Понимать топики, партиции, оффсеты и группы консьюмеров
  • Писать продюсеры с ключами, батчами и ретраями
  • Читать сообщения группой с коммитами оффсетов
  • Выбирать гарантии: at-most-once, at-least-once