---
title: "Уникальные задачи: Unique"
description: "Asynq Unique: уникальные задачи go с TTL, ключ из типа, payload и очереди, ErrDuplicateTask и гонки постановки. Как не поставить одну задачу дважды."
course: "Asynq в Go: очередь фоновых задач на Redis"
url: "https://golangmaster.ru/courses/asynq/lessons/12-unikalnye-zadachi/"
published: "2026-09-19"
updated: "2026-09-23"
minutes: 18
---

# Уникальные задачи: Unique

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

Вебхук пришёл дважды — и задача на отправку письма поставилась дважды; asynq Unique решает это на постановке: уникальные задачи go живут с TTL, а повторная постановка возвращает ErrDuplicateTask. Разберём ключ уникальности, жизненный цикл замка и гонки.

Классика дубликатов: провайдер повторил вебхук о платеже, ваш HTTP-хендлер отработал оба раза честно, и в очередь улетели две одинаковые задачи «начислить бонус». Или cron из девятого урока пересекся с ручным запуском — и напоминание уйдёт дважды. Уровень базы такое ловит, когда повезёт: constraint на бизнес-ключ спасёт начисление, но не спасёт письмо. Asynq предлагает решение ближе к источнику — опцию Unique на постановке: задача ставится только если такой ещё нет в очереди, иначе клиент получает ошибку ErrDuplicateTask. Уникальность держится на замке в Redis с TTL, и почти все грабли этой опции живут в трёх вопросах: из чего собран ключ, сколько живёт замок и что происходит в гонке между двумя инстансами, ставящими задачу одновременно. В уроке разберём все три плюс разницу с явным TaskID и границы, за которыми уникальность превращается в иллюзию. Источники дублей перечислить легко, но они неистребимы: повторный вебхук, крон и ручной запуск в одну минуту, кнопка «отправить» в приложении, повтор постановки после таймаута HTTP-запроса, два пода, обрабатывающие одно событие из шины. Каждый источник по отдельности редок; вместе — регулярность, которую стоит закрыть механизмом, а не бдительностью.

### Asynq Unique: TTL уникальности

Asynq Unique — опция постановки: client.Enqueue(task, asynq.Unique(10*time.Minute)) ставит задачу, только если её ещё нет, и держит замок уникальности десять минут. Повторная постановка той же задачи в течение TTL вернёт ошибку ErrDuplicateTask — её проверяют через errors.Is, как любую сентинелл-ошибку. Документация формулирует гарантию так: задача уникальна в пределах заданного TTL, и повторная постановка становится возможна после успешной обработки или после истечения замка. TTL задаётся duration и имеет нижнюю границу: меньше секунды постановка не примет — Enqueue вернёт ошибку «Unique TTL cannot be less than 1s» ещё до общения с Redis. Важно понимать, что гарантирует опция, а что нет: Unique защищает постановку, но не заменяет идемпотентность хендлера — задача, поставленная единожды, всё равно может быть исполнена повторно при ретрае после сбоя. Поэтому Unique закрывает дубликаты на входе, а идемпотентность из восемнадцатого урока — повторы на выходе; взрослые системы используют обе скобки. Сравнение с базой проясняет место опции: уникальный индекс защищает данные, Unique защищает постановки. Задача «начислить бонус» может быть уникальной в очереди и при этом начисляться трижды тремя разными задачами с разным payload. Ориентируйтесь на вопрос «может ли быть дважды одна и та же работа», а не «может ли быть дважды один эффект».

### Постановка с обработкой дубликата

```go
info, err := client.Enqueue(task, asynq.Unique(10*time.Minute))
switch {
case errors.Is(err, asynq.ErrDuplicateTask):
    log.Printf("задача уже в очереди, пропускаем")
    return
case err != nil:
    log.Fatal(err)
}
log.Printf("поставлена: %s", info.ID)
```

Unique(10*time.Minute) держит замок уникальности десять минут: повторная постановка той же задачи вернёт ErrDuplicateTask. Ветка errors.Is делает дубликат нормальным исходом, а не сбоем — вебхук-повтор просто тихо выйдет. Замок живёт в Redis и переживает рестарты клиента: даже если процесс постановки упал сразу после успешного Enqueue, дубликат всё равно отсечён. Любая другая ошибка постановки обрабатывается как обычно.

### Из чего собирается ключ уникальности

Ключ уникальности asynq собирает из трёх свойств: тип задачи, payload и имя очереди. Одна и та же по смыслу задача в разных очередях — две независимые уникальности; то же верно для разного payload. Отсюда главное требование к payload: он должен быть каноничным. Лишний time.Now() в структуре делает каждую постановку уникальной — замок честно защищает «задачу с этой секундой», а вы думали, что защищаете «напоминание про запись 42». Собирайте payload из бизнес-полей, даты нормализуйте до дня или часа, слайсы сортируйте перед Marshal, если порядок может гулять. Имя очереди в ключе работает на вас: окружения и тенанты, разнесённые по очередям из седьмого урока, не мешают друг другу ставить одинаковые задачи. Уникальность совместима и с отложенной постановкой, и с группами из десятого урока — замок создаётся тем же механизмом. Единственное исключение — batch-постановка BatchEnqueueContext: уникальные задачи в ней не поддерживаются, и каждая такая задача получает ошибку ещё до Redis — об этом документация говорит прямо. Деталь, которая спасает при отладке: сериализация детерминирована для одного и того же типа структуры — порядок полей фиксирован объявлением, а для карт json.Marshal сортирует ключи. Ломают каноничность обычно time.Time с наносекундами, float-суммы и слайсы в гуляющем порядке: нормализуйте их до постановки, и ключ станет предсказуемым.

### Проверка: состав ключа

**Вопрос:** Две задачи с одинаковым типом и payload поставлены с Unique в очереди payments и в очереди payments-qa. Что вернёт вторая постановка?

- Обе вернут ErrDuplicateTask: тип и payload совпадают
- ✅ Обе поставятся: имя очереди входит в ключ уникальности
- Ошибка постановки: очередь payments-qa не существует
- Вторая перезапишет первую

Ключ уникальности собирается из типа, payload и имени очереди — разные очереди означают разные ключи, и обе задачи поставятся. Это свойство стоит учитывать и в обратную сторону: та же задача в двух окружениях требует разных очередей, если вы не хотите взаимных ErrDuplicateTask.

### Уникальные задачи go: TTL и жизненный цикл

Уникальные задачи go держат замок с момента постановки, и у TTL есть два соседа по времени, которые легко забыть. Первый — жизнь самой задачи: TTL обязан покрывать всё существование постановки, включая очередь ожидания и ретраи. Замок живёт фиксированный срок и не продлевается ретраями: если задача с Unique(5*time.Minute) зависла в retry на полчаса, замок истечёт раньше, чем задача завершится, — и дубликат спокойно поставится. Считайте TTL сверху: максимум ожидания в очереди плюс все ретраи с паузами плюс запас. Второй сосед — момент обработки: при успешном завершении asynq снимает замок, если он всё ещё принадлежит этой задаче, и следующую такую задачу можно ставить сразу, не дожидаясь конца TTL. При неудаче замок остаётся при задаче и живёт до конца TTL — повторная постановка в это время получит ErrDuplicateTask, даже если исходная лежит в архиве. Эта асимметрия — «успех снимает, неудача держит» — и есть то, что защищает от двойной работы после сбоя. Отсюда же цена ошибиться в обе стороны: слишком короткий TTL открывает дверь дубликатам, слишком длинный — запирает очередь, если легальная повторная постановка должна случиться раньше. Когда повтор по бизнесу возможен до конца TTL, Unique не подходит — берите TaskID или проверку в хендлере.

### Отложенная постановка с запасом TTL

```go
delay := 1 * time.Hour
ttl := delay + 6*time.Hour // очередь + все ретраи + запас

info, err := client.Enqueue(task,
    asynq.ProcessIn(delay),
    asynq.Unique(ttl),
)
if errors.Is(err, asynq.ErrDuplicateTask) {
    log.Printf("напоминание уже запланировано")
}
```

TTL обязан покрыть и время ожидания в расписании, и ретраи после сбоев: час задержки плюс шесть часов запаса. Если задача уйдёт в retry с экспоненциальной паузой, замок всё ещё будет держать дубликаты. Успешная обработка снимет замок раньше конца TTL — повторная постановка станет возможна сразу после успеха.

> **Живое место: TTL истёк, а задача ещё жива.** Кейс: напоминания ставили с Unique(15*time.Minute), а воркеры стояли на деплое сорок минут. Замки истекли, ручной повтор вебхука поставил вторые копии — пользователи получили по два письма, потому что и первые задачи никуда не делись. Уникальность — это TTL-замок, а не обещание на жизнь задачи: считайте TTL от худшего сценария, включая простой воркеров, и не кладите time.Now() в payload, иначе каждая постановка уникальна по построению.

### Unique против TaskID: что выбрать

В арсенале два механизма защиты от повторов, и они решают разные вопросы. Unique — содержательная защита: ключ собирается автоматически из типа, payload и очереди, и дубликат ловится даже тогда, когда вы не знаете заранее, каким будет идентификатор. TaskID из четвёртого урока — явная защита: вы сами придумываете идентификатор, например reminder:42:2025-06-01, и конфликт вернёт ErrTaskIDConflict. Явный ID сильнее в двух случаях. Первый — идемпотентность по бизнес-ключу: payload может отличаться (обновилось время приёма), а повтор всё равно нужно отсечь, — содержательный ключ уникальности здесь бессилен, потому что изменился payload. Второй — детерминированные идентификаторы в ретраях внешнего мира: вебхук с event-id превращается в TaskID(event:id), и любые повторные доставки схлопываются в одну задачу. Содержательная уникальность хороша там, где повторяется ровно одна и та же постановка: крон-задача, повтор вебхука с неизменным телом, кнопка «отправить», на которую нажали трижды. Если сомневаетесь — берите TaskID: явный ключ читается в дашборде и не зависит от правил сериализации. Комбинация легальна и иногда полезна: TaskID для идемпотентности события и Unique для отсечения случайных двойных нажатий — механизмы не конфликтуют, потому что живут на разных ключах. Только следите, чтобы явный ID не подменял содержательную уникальность там, где повтором может быть другой payload с тем же смыслом.

### Три способа не поставить дважды

| Способ | Ключ | Граница |
| --- | --- | --- |
| asynq.Unique(ttl) | тип + payload + очередь | TTL должен покрыть всю жизнь задачи; batch не поддерживает |
| asynq.TaskID(id) | ваш явный идентификатор | ID придумываете вы; конфликт — ErrTaskIDConflict |
| Проверка в хендлере или базе | бизнес-ключ | дороже, но переживёт любые гонки постановки |

### Правила уникальности одним списком

- Ключ — тройка «тип, payload, очередь»: канонизируйте payload до постановки.
- TTL покрывает очередь, ретраи и простой воркеров — считайте от худшего сценария.
- Успешная обработка снимает замок, неудача держит его до конца TTL.
- Дубликат — нормальный исход: errors.Is(ErrDuplicateTask), лог и тихий выход.
- Бизнес-ключ меняется — Unique не спасёт: берите TaskID или проверку в базе.
- Batch-постановка уникальные задачи не поддерживает.
- Unique не заменяет идемпотентность хендлера: ретрай той же задачи никто не отменял.

### Что вернёт вторая постановка?

```go
// 10:00 — первая постановка
client.Enqueue(task, asynq.Unique(30*time.Minute))

// воркеры обработали задачу успешно в 10:05

// 10:20 — вторая постановка той же задачи
```

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

- ErrDuplicateTask: TTL ещё не истёк
- ✅ Задача поставится: успех снял замок раньше конца TTL
- Задача поставится только после 10:30
- Ошибка ErrTaskIDConflict

При успешной обработке asynq снимает замок уникальности, если он принадлежит этой задаче, — вторая постановка в 10:20 пройдёт, не дожидаясь конца тридцатиминутного TTL. Держать замок после успеха было бы вредно: следующая порция работы не смогла бы поставиться до конца TTL.

### Проверка: нижняя граница TTL

**Вопрос:** Что произойдёт при постановке с asynq.Unique(500*time.Millisecond)?

- Задача поставится с замком на полсекунды
- Задача поставится, замок будет держаться минимум секунду
- ✅ Enqueue вернёт ошибку: TTL меньше секунды не допускается
- Задача поставится без замка: слишком короткий TTL игнорируется

TTL уникальности обязан быть не меньше секунды — короткое значение клиент отклонит ошибкой «Unique TTL cannot be less than 1s» до всякого Redis. Замок на полсекунды не имел бы смысла: дубликат проскочил бы раньше, чем вы успели проверить.

### Соберите защиту от дубликата

Допишите опцию уникальности и проверку ошибки дубликата.

```go
info, err := client.Enqueue(task, asynq.___(30 * time.Minute))
if errors.___(err, asynq.ErrDuplicateTask) {
    log.Printf("уже поставлена")
}
```

Подсказка: Опция называется Unique, сентинелл-ошибку проверяют errors.Is.

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

### Соберите идемпотентную постановку вебхука

Расставьте строки: каноничный payload, задача, постановка с уникальностью, ветки обработки результата.

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

1. `payload, err := json.Marshal(tasks.WebhookPayload{EventID: evt.ID})`
2. `if err != nil { log.Fatal(err) }`
3. `task := asynq.NewTask(tasks.TypeWebhook, payload)`
4. `info, err := client.Enqueue(task, asynq.Unique(24 * time.Hour))`
5. `switch {`
6. `case errors.Is(err, asynq.ErrDuplicateTask):`
7. `    log.Printf("event %s уже в очереди", evt.ID)`
8. `case err != nil:`
9. `    log.Printf("постановка сломалась: %v", err)`
10. `default:`
11. `    log.Printf("поставлена: %s", info.ID)`
12. `}`

### Гонки постановки: почему проверка не работает

Соблазн дешёвого решения — сперва посмотреть, нет ли задачи, и ставить только если нет: ListPendingTasks или запрос в базу, потом Enqueue. В распределённой системе эта схема ломается гонкой: два инстанса читают пустой список в одну и ту же миллисекунду, оба видят «задачи нет» и оба ставят. Проверка и постановка — два разных шага, и между ними может вклиниться кто угодно, включая перезапуск инстанса. Unique закрывает именно это: замок в Redis ставится атомарной командой SET NX, и из двух одновременных постановок успешна ровно одна, вторая получает ErrDuplicateTask — без отдельной блокировки и без замков на уровне приложения. Остаточные гонки всё же есть, и все они про время: замок истёк, а задача жива — случай из живого места выше; успех снял замок, а нижестоящий сервис ещё обрабатывает прошлый прогон — случай для идемпотентного хендлера. Отсюда зрелая комбинация: Unique или TaskID против дубликатов постановки, проверка бизнес-ключа в хендлере против всего остального. Как страховка последней линии стоит проверка в обработчике: она срабатывает при любом сценарии, включая ручные постановки из скриптов. Заметьте, что гонка не всегда даёт дубль: из двух одновременных постановок одна получит ErrDuplicateTask, и её важно обработать без алерта — это нормальный исход конкуренции, а не сбой. Плохо то, что без Unique ошибку получает никто: две задачи молча живут обе.

> **Отложенная уникальность: окно от старта.** Для задач с ProcessIn или ProcessAt asynq считает TTL не от постановки, а от назначенного момента обработки: замок живёт «срок плюс TTL». Напоминание с ProcessIn(24*time.Hour) и Unique(15*time.Minute) защищено от дубликатов сутки ожидания и ещё пятнадцать минут после старта. Это удобно, но легко принять за баг: в первые сутки дубликат отсечён замком, который ещё даже «не начался» по вашей внутренней арифметике.

### Карточки: уникальные задачи

- **asynq.Unique(ttl)** — Опция постановки: задача ставится, только если её нет. Замок в Redis с TTL; минимум — одна секунда.
- **ErrDuplicateTask** — Сентинелл-ошибка повторной постановки уникальной задачи. Проверяется errors.Is и трактуется как нормальный исход.
- **Ключ уникальности** — Тип задачи + payload + имя очереди. Payload обязан быть каноничным: без time.Now() и без гуляющих порядков.
- **Жизненный цикл замка** — Живёт от постановки до конца TTL; успех снимает его раньше, неудача держит до конца. Ретраи замок не продлевают.
- **Unique против TaskID** — Unique ловит одинаковые постановки автоматически; TaskID защищает по явному бизнес-ключу даже при изменённом payload.
- **Гонка постановки** — Проверка «а есть ли задача» и постановка — два шага; Unique закрывает их атомарным SET NX: успешна ровно одна из одновременных попыток.

Уникальность разобрана: Unique с TTL на постановке, ключ из типа, payload и очереди, снятие замка по успеху и ErrDuplicateTask как штатная ветка. Мини-эксперимент: поставьте одну задачу дважды с Unique(24*time.Hour) и посмотрите в asynqmon — она появится одна, а вторая постановка вернёт ошибку; потом поставьте её же в другую очередь и убедитесь, что обе встали. Формат прежний: код живёт локально с Go и Redis в Docker, песочница сайта показывает тематическую программу на чистом Go. Дальше по курсу: [тринадцатый урок](/courses/asynq/lessons/13-svoi-retrai/) заменяет экспоненциальную паузу своей политикой, а [восемнадцатый](/courses/asynq/lessons/18-idempotentnost/) закрывает вторую половину защиты от повторов — что делать, когда задача, поставленная единожды, всё равно исполняется дважды.

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

- Asynq Unique держит замок уникальности с TTL: повторная постановка вернёт ErrDuplicateTask.
- Ключ — тройка «тип, payload, очередь»: payload канонизируйте, даты нормализуйте, слайсы сортируйте.
- TTL от худшего сценария: очередь, ретраи, простой воркеров; минимум — одна секунда.
- Успешная обработка снимает замок раньше конца TTL, неудача держит его до конца.
- TaskID защищает по явному бизнес-ключу там, где payload меняется, а повтор всё равно нужно отсечь.
- Проверка «есть ли задача» перед постановкой не работает: из одновременных попыток одна — это Unique и делает.
- Batch-постановка уникальные задачи не поддерживает.
- Unique закрывает дубликаты на входе, идемпотентность хендлера — повторы на выходе.

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

Возьмите хендлер напоминаний из пятого урока и добавьте постановку с Unique(24*time.Hour) и веткой errors.Is(ErrDuplicateTask) с логом. Поставьте одну и ту же задачу дважды и убедитесь, что в asynqmon она одна. Затем поставьте её же с ProcessIn(5*time.Minute) и посмотрите, как замок переживает отложенный старт. В финале добавьте time.Now() в payload и убедитесь, что обе постановки прошли, — это и есть цена неканоничного payload.

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

### Гарантирует ли Unique, что задача исполнится ровно один раз?

Нет: Unique гарантирует только одну постановку в пределах TTL. Исполнений может быть больше — при ретрае после сбоя или восстановлении задачи после падения воркера. Ровно-один-раз не даёт никто, поэтому хендлер проектируется идемпотентным, а уникальность остаётся фильтром дубликатов на входе.

### Можно ли снять замок уникальности досрочно?

У клиента такой операции нет: замком управляют только постановка, TTL и снятие при успешной обработке. Если нужно «разрешить повтор прямо сейчас» — поставьте новую задачу с другим payload или явным TaskID. Ключ уникальности можно и подсмотреть: он строится из типа, payload и очереди, но ковырять его в Redis руками — путь к рассинхрону.

### Почему вторая постановка той же задачи в другую очередь прошла?

Потому что имя очереди входит в ключ уникальности: тип и payload совпадают, а очередь разная — ключи разные. Это осознанная механика: окружения и тенанты, разнесённые по очередям, не отсекают задачи друг друга. Если хотите общий замок на весь сервис — держите одну очередь для этого типа задач.
