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

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

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

Урок 12 из 25 ~18 мин +50 XP

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

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

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

Классика дубликатов: провайдер повторил вебхук о платеже, ваш 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. Что вернёт вторая постановка?

Уникальные задачи 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 — повторная постановка станет возможна сразу после успеха.

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 не заменяет идемпотентность хендлера: ретрай той же задачи никто не отменял.

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

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

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

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

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

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

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

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

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

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

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

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

Строки

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

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

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

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

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

Термин

asynq.Unique(ttl)

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

Опция постановки: задача ставится, только если её нет. Замок в Redis с TTL; минимум — одна секунда.

1 / 6

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

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

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

Вывод

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

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

  • 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.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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