---
title: "Грабли продакшена: от утечек до дубликатов"
description: "Семь граблей продакшена: забытый воркер, payload без версии, взрыв ретраев, дубликаты, рвущий деплой и один Redis на всё. Симптом, причина, лечение."
course: "Asynq в Go: очередь фоновых задач на Redis"
url: "https://golangmaster.ru/courses/asynq/lessons/24-grabli-prod/"
published: "2026-09-19"
updated: "2026-09-23"
minutes: 20
---

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

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

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

У 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: старый код новое поле просто игнорирует, а новый код на старом сообщении поле не находит — ломается всегда новая сторона, поэтому версия ставится при постановке и читается при исполнении. Тесты задач из [двадцать второго урока](/courses/asynq/lessons/22-testy-zadach/) обязаны включать старую версию: контракт формата живёт дольше кода, который его выпустил.

### 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 равен нулю, контейнер воркера жив. Первая гипотеза?

- ✅ Сервер asynq запущен без этой очереди в конфиге или не подключился к Redis
- Redis потерял данные, и очередь пуста
- Задачи поставлены с ProcessIn и ждут отложки
- Хендлеры падают на декодировании payload

Ноль Processed при растущем Pending означает, что задачи никто не забирает: очередь не зарегистрирована в конфиге сервера, воркер не видит Redis или стоит на паузе. Проверяется Servers() и стартовыми логами; отложенные задачи лежали бы в Scheduled, а не в Pending. Сработавший алерт на ноль Processed сокращает диагностику до одной проверки.

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

Грабль третий — самый дорогой по последствиям. По умолчанию asynq делает двадцать пять попыток, а пауза между ними растёт по формуле из sidekiq: n^4 плюс пятнадцать секунд и джиттер. Для нестабильного внешнего API это выглядит так: сервис деградировал на десять минут, у вас тысячи задач, каждая ждёт своего окна, а после восстановления на него обрушивается отложенный шторм — плюс ретраи задач, поставленных прямо в разгар деградации. Симптом: Retry копится, паузы между попытками уходят в часы, внешний сервис проседает от ваших же повторов. Лечение многослойное: разумный MaxRetry на постановке — три-пять для обычных задач; RetryDelayFunc в Config — свой профиль пауз под тип задачи; SkipRetry из [тринадцатого урока](/courses/asynq/lessons/13-svoi-retrai/) — для ошибок, которые повтором не исправятся; 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, и попытки прекращаются немедленно. Пауза возвращается как обычная длительность, и профиль подбирается под скорость восстановления зависимости: быстрым сервисам — секунды, медленным — минуты.

> **Живое место: двадцать пять попыток по умолчанию.** Дефолт в двадцать пять попыток взят с запасом, но в связке с деградирующим API он превращается в дробовик: каждая задача вернётся ещё двадцать четыре раза. Арифметика проста: тысяча задач в backlog — это до двадцати пяти тысяч вызовов чужого сервиса. Есть и тихий грабль рядом: хендлеры, игнорирующие отмену контекста, при деплое оставляют висеть горутины — та самая утечка из заголовка урока; лечится слушанием ctx.Done() из [девятнадцатого урока](/courses/asynq/lessons/19-graceful-shutdown/). Считайте оба счётчика при планировании ёмкости: повторное исполнение — это тот же трафик, который уже проходил однажды.

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

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

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

```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 ключа выбирается дольше окна возможных повторов: сутки — разумный минимум для платёжных операций.

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

```go
// попытка 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)
```

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

- ✅ Ключ ещё занят, хендлер вернёт nil, задача засчитается исполненной
- Ключ освободился после ошибки, charge запустится заново
- Хендлер упадёт: ключ уже существует
- Задача уйдёт в архив без ретрая

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

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

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

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

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

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

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

- ✅ 8 секунд
- 30 секунд
- 5 минут
- Пока все задачи не завершатся

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

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

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

```go
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
    },
})
```

Подсказка: Опция постановки ограничивает число попыток, поле Config задаёт функцию паузы между ними.

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

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

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

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

1. `Откройте QueueInfo: Pending, Processed, Latency`
2. `Processed стоит, Latency растёт — проверьте Servers()`
3. `Servers пуст — воркер не запущен или не видит Redis`
4. `Смотрите стартовые логи сервера и LogLevel`
5. `Processed растёт — ищите ошибку по ID задачи в логах`
6. `После починки запустите застрявшие через RunTask`

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

- **Pending растёт, Processed ноль** — Забытый или сломанный воркер: проверьте Servers(), конфиг очередей и стартовые логи. Latency растёт первым.
- **MaxRetry по умолчанию** — 25 попыток. Для обычных задач задавайте 3-5 на постановке: при деградации зависимости это разница между ретраем и штормом.
- **DefaultRetryDelayFunc** — Пауза n^4 плюс 15 секунд с джиттером: между первой и пятой попыткой разница — часы. Своя RetryDelayFunc делает профиль мягче.
- **At-least-once** — Задача исполняется как минимум один раз: дубликаты возможны после смерти воркера и деплоя. Лечение — ключ идемпотентности в хендлере.
- **ShutdownTimeout** — По умолчанию 8 секунд: деплой рвёт длинные попытки, и они возвращаются в pending. Задайте под свои задачи и слушайте отмену контекста.
- **IsOrphaned** — Флаг TaskInfo для задач, чей воркер умер посреди исполнения: они возвращаются в pending и исполняются заново с нуля.

> **Про песочницу: разбор граблей без Redis.** Песочница показывает программу на чистом Go без Redis: она прогоняет payload через версионный switch и имитирует идемпотентный ключ — меняйте версию и результат захвата, чтобы увидеть обе ветки хендлера. Локально всё воспроизводится с Go и Redis в Docker: поставьте задачу, уроните воркер посреди попытки и найдите её по IsOrphaned после рестарта.

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

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

- Забытый воркер: 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 проверяется только реальным деплоем.

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

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

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

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

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

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

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