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

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

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

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

Идемпотентность обработчиков

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

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

Очередь с ретраями живёт по контракту at-least-once: задача доставляется обработчику минимум один раз, а значит — иногда и больше. Это не особенность asynq, а свойство любой распределённой системы: воркер может умереть посреди работы, соединение с Redis может оборваться, ответ может не дойти до адресата. Шестой урок уже показал половину картины: хендлер вернул ошибку — задача ушла в retry и вернётся снова. Вторая половина жёстче: воркер успел сделать работу и не успел сообщить об этом — процесс убит деплоем, контейнер упал по OOM, соединение оборвалось на самом интересном месте. Для очереди задача по-прежнему числится активной: её lease истекает, recoverer возвращает её в игру, и хендлер исполняется повторно — над уже изменёнными данными. Никакой настройкой повторы не выключаются: MaxRetry(0) не гарантирует «ровно один раз», он лишь превращает любой сбой в потерю задачи. Работающая стратегия одна — идемпотентный обработчик: повторный запуск с тем же ключом даёт тот же результат, что и однократный. В этом уроке — три источника повторов и хендлер списания, которому ретрай не страшен.

Почему задача исполняется дважды#

Перечислим источники повторов по механике процессора. Первый — ошибка хендлера: процессор ловит возвращённую ошибку и перемещает задачу из active в retry с паузой по политике из шестого урока; следующая попытка начинает всю работу с нуля. Второй — таймаут или дедлайн из одиннадцатого урока: контекст отменяется, попытка засчитывается неудачей, и задача уходит в тот же retry, при этом хендлер к моменту отмены мог успеть списать деньги или отправить письмо — частичный эффект уже в мире. Третий источник — смерть воркера между «сделал» и «отметил»: успех фиксируется записью задачи в Redis, и если процесс умер раньше, задача остаётся в active до истечения lease; recoverer собирает задачи с истёкшим lease и возвращает их в retry, а при исчерпании попыток — в архив. Порог в тридцать секунд закладывает запас на рассинхрон часов между машинами. Тот же сценарий разворачивается на каждом деплое: девятнадцатый урок покажет, как плавная остановка возвращает недоделанные задачи в очередь. Все три источника объединяет одно: повтор — не исключение, а штатный маршрут, и проектировать хендлер нужно от него, а не от счастливого пути.

Хендлер, который платит дважды

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)
    }
    if err := wallet.Debit(p.UserID, p.Amount); err != nil {
        return fmt.Errorf("debit: %w", err)
    }
    return mailer.SendInvoice(p.UserID) // упало — задача уйдёт в retry
}

Хендлер делает два эффекта подряд: списание и письмо. Письмо упало — процессор перемещает задачу в retry, и следующая попытка исполняет списание заново: wallet.Debit сработает второй раз. Порядок «эффект, затем ещё эффект, затем выход» ничем не защищён — каждая попытка повторяет все эффекты с начала. Ниже этот же хендлер переписан так, чтобы повтор не был страшен.

Проверка: краш после списания

Хендлер списал деньги, но воркер умер до возврата из функции и фиксации успеха. Что сделает asynq?

Идемпотентность на бытовом примере#

Идемпотентной называется операция, которую можно повторить сколько угодно раз с тем же результатом, что и один раз. Бытовые примеры откалибруют интуицию. Кнопка вызова лифта идемпотентна: хоть десять нажатий, лифт приедет один. «Списать тысячу рублей» — не идемпотентно: каждое исполнение уменьшает баланс, и двойной ретрай даёт двойное списание. «Довести баланс до минус тысячи, если он ещё не там» — уже идемпотентно: второй вызов видит сделанное и выходит. «Отправить письмо» — не идемпотентно: письмо уйдёт дважды, и пользователь заметит. «Убедиться, что письмо про заказ 42 отправлено» — идемпотентно, если факт отправки где-то фиксируется. Обратите внимание на форму: идемпотентность почти всегда означает проверку «уже сделано?» перед эффектом, а значит — место, где этот факт хранится. В asynq идемпотентность не добавляется опцией: библиотека честно доставляет задачу и честно ретраит, а гарантию «эффект один раз» строит только ваш хендлер — базой, ключом и порядком действий. Хорошая новость в том, что конструкция одна и та же для любых эффектов, и дальше мы её соберём на примере платежа. Заметьте и цену вопроса: проверка «уже сделано?» — это лишний запрос к базе в каждой попытке, включая первую. На нагрузках очереди это незаметно: попытка и так пишет в базу, ещё один SELECT EXISTS не меняет картину. Дешевле обойтись без проверки нельзя: альтернатива — «сначала сделать, потом узнать о повторе», и это ровно тот сценарий, ради которого всё затевалось.

Ключ идемпотентности и таблица processed_tasks#

Идемпотентность начинается с ключа — того, по чему повторная попытка узнаёт «свою» работу. Честных варианта два. Первый — task ID: он выдаётся на постановке и сохраняется у задачи на все ретраи, а достаётся в хендлере из контекста функцией GetTaskID; если ID задан явно опцией TaskID из четвёртого урока, он к тому же осмыслен для бизнеса. Второй — бизнес-ключ в payload: event_id вебхука, номер заказа, идентификатор записи; требования к нему те же, что к каноничному payload из двенадцатого урока, — детерминированность и нормализация без time.Now() и гуляющих порядков. Ключ сам по себе ничего не делает: нужен факт «эта работа уже сделана». Обычно это таблица processed_tasks или прямая запись в бизнес-таблице — платежи со столбцом task_id — плюс уникальный индекс на ключе. Проверка «есть ли запись», сам эффект и вставка отметки выполняются в одной транзакции: тогда две параллельные попытки не разъедутся — вторая либо увидит отметку, либо упрётся в уникальный индекс. Транзакция заодно даёт атомарность внутри попытки: упали между списанием и отметкой — откатилось всё, и повтор начнёт с чистого листа.

Идемпотентный хендлер списания по task ID

GO
func HandleCharge(ctx context.Context, t *asynq.Task) error {
    id, _ := asynq.GetTaskID(ctx) // один и тот же на всех ретраях

    var p ChargePayload
    if err := json.Unmarshal(t.Payload(), &p); err != nil {
        return fmt.Errorf("payload: %w", err)
    }

    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()

    var exists bool
    err = tx.QueryRowContext(ctx,
        "SELECT EXISTS (SELECT 1 FROM payments WHERE task_id = $1)", id,
    ).Scan(&exists)
    if err != nil {
        return err
    }
    if exists { // повтор: списание уже прошло в прошлую попытку
        log.Printf("платёж по задаче %s уже есть, выходим", id)
        return nil
    }

    if err := wallet.DebitTx(tx, p.UserID, p.Amount); err != nil {
        return fmt.Errorf("debit: %w", err)
    }
    if _, err := tx.ExecContext(ctx,
        "INSERT INTO payments (task_id, user_id, amount) VALUES ($1, $2, $3)",
        id, p.UserID, p.Amount,
    ); err != nil {
        return fmt.Errorf("insert payment: %w", err)
    }
    return tx.Commit()
}

GetTaskID достаёт ID из контекста: у одной постановки он не меняется от ретрая к ретраю, поэтому отметка в payments сшивает все попытки воедино. Проверка, эффект и отметка живут в одной транзакции: повторная попытка находит запись и закрывается с nil, а незакоммиченный мусор при падении откатывается. Уникальный индекс на payments.task_id страхует даже разные задачи с совпавшим ключом.

Идемпотентный хендлер: разбор по шагам#

Пройдём по решению и разберём, почему оно именно такое. Проверка стоит до эффекта: сначала смотрим payments, потом списываем — обратный порядок сначала сделает работу, а потом узнает о повторе. Повторная попытка возвращает nil, а не ошибку: найденный платёж означает, что работа сделана, и задача честно закрывается как успешная; ошибка здесь отправила бы уже исполненную работу в вечный ретрай до исчерпания попыток и в архив. Транзакция покрывает три шага сразу: чтение, списание, вставку отметки — если попытка умерла в середине, база откатится целиком, и следующая попытка увидит чистое состояние; половинных эффектов не бывает. defer tx.Rollback() подстраховывает ранние выходы: при успешном Commit он сработает вхолостую и безвредно. Уникальный индекс — последний барьер: даже если две разные задачи несут один и тот же ключ, вторая вставка упадёт с ошибкой constraint, и её стоит трактовать как «уже сделано», а не как сбой. Этот же каркас закрывает и таймауты: хендлер, отменённый посреди работы, оставит после себя откат — и повтор начнёт заново, безопасно.

Правила идемпотентного хендлера#

  • Повтор — штатный маршрут: хендлер проектируется под повторный запуск, а не под «повторов не бывает».
  • Один ключ на всю работу: task ID постановки или бизнес-ключ из payload без time.Now() и гуляющих порядков.
  • Факт «сделано» — в базе: таблица processed_tasks или запись в бизнес-таблице с уникальным индексом.
  • Проверка, эффект и отметка — в одной транзакции: параллельные попытки не разминутся.
  • Найденный эффект — return nil: задача закрывается успешно, а не ретраится до архива.
  • Внешние эффекты, которые нельзя пометить, — на дедупликацию провайдера с Idempotency-Key.
  • Таймаут и отмена из одиннадцатого урока закрываются тем же каркасом: откат и повтор с чистого листа.

Четыре скобки защиты от повторов#

ТехникаЧто закрываетГраница
Проверка по task IDповторы одной постановки: ретраи, краши, дожимне видит две разные задачи с одним смыслом
Бизнес-ключ в payloadдубликаты постановок: два вебхука, два кликаключ обязан быть детерминированным и нормализованным
asynq.Unique из двенадцатого урокапостановку дубликата в очередьTTL-замок на входе; исполнение всё равно может повториться
Idempotency-Key провайдеравнешние эффекты: письма, платежи, SMSтолько если API провайдера поддерживает

Проверка: повтор нашёл платёж

Повторная попытка хендлера нашла запись в payments по этому task ID. Что должен вернуть хендлер?

Что увидит вторая попытка?

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

// попытка 1: wallet.DebitTx прошёл, процесс убит до tx.Commit()
// попытка 2: тот же task ID, ретрай через две минуты

exists := paymentExists(tx, id) // SELECT ... WHERE task_id = $1

Соберите скелет проверки

Допишите получение task ID из контекста и обёртку ошибки списания.

id, _ := asynq.(ctx) if err := wallet.DebitTx(tx, p.UserID, p.Amount); err != nil { return fmt.("debit: %w", err) }

Расставьте шаги безопасного списания

Соберите хендлер по порядку: от task ID до коммита транзакции.

Строки

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

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

Unique и идемпотентность задач: разные скобки#

Двенадцатый урок обещал эту встречу: Unique и идемпотентность выглядят как одно и то же, но закрывают разные двери. Unique — защита на входе: дубликат постановки не попадёт в очередь, повторный вебхук и двойное нажатие кнопки отсекаются замком с TTL. Идемпотентность — защита на выходе: даже единственная постановка исполняется повторно при ретрае после сбоя, и хендлер обязан это пережить. Система, у которой есть только Unique, теряет деньги на первом же краше после списания; система только с проверкой в хендлере захламляет очередь дубликатами, которые честно исполняются и выходят по nil. Взрослый сервис ставит обе скобки: Unique или TaskID с ErrTaskIDConflict против дубликатов постановки, проверка по ключу в хендлере против повторов исполнения. Заметьте, что TaskID участвует в обеих: детерминированный ID отсекает повторную постановку ещё на Enqueue и одновременно служит ключом проверки в базе. Какая скобка важнее? Идемпотентность: она единственная стоит между ретраем и двойным списанием, а Unique — оптимизация входа, без которой можно начать.

Карточки: идемпотентность

Термин

at-least-once

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

Контракт очереди: задача доставляется минимум один раз, значит иногда больше. Повтор — штатный маршрут, а не авария.

1 / 6

Идемпотентность собрана целиком: три источника повторов, ключ идемпотентности, таблица отметок, транзакция «проверь, сделай, отметь» и nil на повторной попытке. Мини-эксперимент: запустите хендлер списания из урока, верните ошибку после wallet.DebitTx и посмотрите, как ретрай находит пустую таблицу; затем убейте процесс после списания — повторная попытка найдёт запись и закроется с nil. Формат прежний: код живёт локально с Go и Redis в Docker, песочница сайта показывает тематическую программу на чистом Go. Дальше по курсу: девятнадцатый урок покажет главный управляемый источник повторов — остановку воркеров при деплое, а двадцать четвёртый соберёт идемпотентность и остальное в чек-лист продакшена. Прогоните мини-эксперимент руками хотя бы один раз: слова «идемпотентность» и «повтор безопасен» после этого начинают проверяться логами, а не верой.

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

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

Вывод

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

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

  • Asynq живёт по at-least-once: задача может исполниться больше одного раза, и это норма очереди с ретраями.
  • Повторы приходят тремя путями: ошибка хендлера, таймаут или дедлайн, смерть воркера между «сделал» и «отметил».
  • Идемпотентность — свойство хендлера: повторный запуск с тем же ключом даёт тот же результат, что однократный.
  • Ключ — task ID постановки или бизнес-ключ из payload; требования к нему — детерминированность и нормализация.
  • Факт «сделано» хранится в базе: processed_tasks или бизнес-таблица с уникальным индексом на ключе.
  • Проверка, эффект и отметка — в одной транзакции: упавшая попытка откатывается целиком.
  • Повторная попытка возвращает nil: найденный эффект закрывает задачу как успешную.
  • Unique отсекает дубликаты постановки, идемпотентность — повторы исполнения: взрослый сервис ставит обе скобки.

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

Возьмите хендлер списания из урока и доведите до рабочего: таблица payments с уникальным индексом на task_id, проверка, списание и отметка в одной транзакции. Сымитируйте сбой — пусть wallet.DebitTx вернёт ошибку — и убедитесь, что ретрай проходит без дубликата. Затем убейте воркер kill -9 сразу после лога «списано» и подождите: recoverer вернёт задачу, повторная попытка найдёт платёж и закроется с nil. В финале поставьте две задачи с одним event_id в payload и разными ID — обе дойдут до хендлера, и только уникальный индекс не даст списать дважды: это и есть нижняя скобка защиты.

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

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

Что если эффект внешний и в моей базе его не пометить?

Делегируйте дедупликацию провайдеру: у платёжных и почтовых API есть Idempotency-Key — повторный запрос с тем же ключом возвращает тот же ответ вместо нового эффекта. Хендлер генерирует ключ из task ID или бизнес-ключа и передаёт его при каждом вызове, включая повторные попытки: провайдер становится второй таблицей отметок, которой вы управляете не сами.

Почему не отключить ретраи и не избежать повторов?

MaxRetry(0) убирает повтор исполнения, но вместе с ним и отказоустойчивость: первый сетевой сбой отправит задачу в архив недоделанной. Повтор — это ремонтопригодность очереди, и отказ от него дороже двойного эффекта. Повторы не отключают — проектируют хендлер, который повтор переживёт, и это ровно идемпотентность из этого урока.

Как проверить идемпотентность тестами?

Два уровня. Юнит-тест: запустите хендлер дважды на одних данных и проверьте, что эффект один — в payments одна запись, баланс уменьшился один раз. Интеграционный: убейте процесс посреди работы kill -9 после лога «списано», поднимите воркер и убедитесь, что ретрай нашёл отметку и закрылся с nil. Параллельные попытки тоже проверяются: два одновременных вызова с одним ключом — уникальный индекс разрешит спор за вас.

Чем ключ идемпотентности отличается от task ID?

Task ID стабилен внутри одной постановки: он закрывает повторные попытки той же задачи. Бизнес-ключ из payload — шире: он одинаковый у разных постановок с одним смыслом, поэтому закрывает и дубликаты, которые Unique не отсёк. Практика: помечайте эффект бизнес-ключом, а task ID используйте как запасной идентификатор в логах и отметках.

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

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

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

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

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

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

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