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

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

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

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

Фоновые задачи в веб-приложении

Оплата прошла, пользователь ждёт письмо, а хендлер ждёт SMTP — так рождаются медленные ручки и таймауты. Связка web плюс воркер решает это разделением ролей: HTTP-ручка только публикует задачу и мгновенно отвечает, воркер-процесс делает работу. Соберём полный путь задачи через веб-приложение: постановка из gin, отложенность, идемпотентный POST и выбор топологии — один процесс или два.

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

К этому уроку у вас уже есть всё по отдельности: постановка задач с payload и ID из четвёртого урока, маршруты mux из восьмого, уникальность из двенадцатого, корректное завершение из девятнадцатого. Осталось собрать из частей типовой веб-сценарий — и он строится на одном правиле: API не делает работу, API публикует намерение её сделать. HTTP-хендлер собирает payload, ставит задачу в Redis через Client и отвечает клиенту за миллисекунды; всю тяжёлую часть — письма, картинки, внешние API — исполняет воркер, когда успеет и сколько раз нужно по ретрай-политике. Пользователь получает мгновенный ответ, сервер освобождается от долгих запросов, а упавшая отправка письма не превращается в 500 на ручке — она превращается в retry, который переживёт перезапуск. Разберём эту связку по шагам: постановка из хендлера, отложенность через ProcessIn и ProcessAt, идемпотентные повторные POST через TaskID и, наконец, топология — держать ли web и воркера в одном процессе. Заодно зафиксируем контракт ответов: 202 для принятой задачи, already_accepted для повтора и 5xx только тогда, когда постановка не удалась.

Паттерн: API публикует, воркер исполняет#

Минимальная связка выглядит так: в приложении создаётся один asynq.NewClient на тот же Redis, куда смотрит воркер-процесс, и хендлер внутри HTTP-запроса вызывает Enqueue. Ответ клиенту строится на TaskInfo, которую вернула постановка: info.ID — идентификатор задачи, info.State — её состояние на момент постановки. Статус ответа — 202 Accepted, а не 200 OK: код честно говорит, что запрос принят к исполнению, но результат ещё не готов. Собирать payload лучше из проверенных данных запроса, а не из сырого тела: хендлер валидирует вход, сериализует в JSON структуру и кладёт её в задачу — ровно как в четвёртом уроке. Важно, что между HTTP-ответом и исполнением нет никакой связи: клиент уходит с task_id, а узнать судьбу задачи сможет позже — через админ-ручку на Inspector из пятнадцатого урока или через собственную таблицу статусов, которую воркер обновляет после исполнения. Ошибка постановки — отдельный случай: Redis недоступен или очередь не существует, и это 5xx, потому что намерение сохранить не удалось. Правило хорошо масштабируется и по объёму: маленький проект ставит одну-две задачи из ручки, зрелый — десятки, но форма не меняется: хендлер всегда короткий, воркер всегда отдельно. Если в ручке появился HTTP-вызов к внешнему сервису, это кандидат на вынос в задачу: внешний сервис рано или поздно зависнет, и ручка зависнет вместе с ним.

Постановка из gin-хендлера

GO
func handlePayment(c *gin.Context) {
    var req PaymentRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    payload, _ := json.Marshal(PaymentPayload{OrderID: req.OrderID})
    info, err := client.Enqueue(asynq.NewTask("payment:capture", payload))
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "queue unavailable"})
        return
    }
    c.JSON(http.StatusAccepted, gin.H{"task_id": info.ID, "state": info.State})
}

Хендлер валидирует вход, сериализует payload и ставит задачу — вся ручка укладывается в миллисекунды, потому что ничего не исполняет. Ответ 202 с task_id даёт клиенту ссылку на принятое намерение. Ошибка Enqueue трактуется как недоступность инфраструктуры: пользовательские данные уже проверены, так что виновата очередь, и это 500 с нейтральным текстом без деталей.

ProcessIn и ProcessAt: отложенность из ручки#

Отложенность задаётся опциями постановки прямо в HTTP-хендлере — теми же, что в пятом уроке. ProcessIn(30*time.Minute) говорит «исполни не раньше, чем через полчаса»: так ставятся напоминания, повторные попытки оплаты через время, отложенная рассылка. ProcessAt(t) принимает конкретный момент времени — удобно, когда срок приходит из запроса, например «отправить отчёт к концу рабочего дня в часовом поясе клиента». Для веб-связки это особенно сильный приём, потому что таймер живёт не в приложении, а в Redis: клиент мог закрыть браузер, деплой мог перезапустить контейнеры — задача всё равно дождётся своего часа, потому что scheduled-состояние хранится в очереди, а не в памяти процесса. Никаких goroutine с time.Sleep в хендлере: спящий горутин умрёт вместе с процессом на первом деплое, а задача с ProcessIn переживёт что угодно. Комбинируйте опции свободно — ProcessIn с MaxRetry, TaskID с Queue: они складываются так же, как при обычной постановке. Практический нюанс: ProcessIn честно означает «не раньше», а не «ровно через» — задача станет pending в срок, но исполнится, когда дойдёт очередь и свободный воркер; закладывайте это в ожидания продукта и в тексты писем.

Проверка: кто исполняет задачу

HTTP-хендлер поставил задачу и вернул 202. Кто и когда исполнит её?

Идемпотентный POST: TaskID и ErrTaskIDConflict#

Пользователь дважды нажал «оплатить», сеть дёрнулась и мобильный клиент повторил запрос — и в обоих случаях платеж должен быть один. Обычная постановка каждый раз создаст новую задачу, поэтому для идемпотентных ручек задачу ставят с опцией asynq.TaskID(id): библиотека гарантирует, что в очереди живёт только одна задача с этим идентификатором, пока она не завершится и не будет удалена из completed. Повторная постановка с тем же ID вернёт sentinel-ошибку asynq.ErrTaskIDConflict — и это не сбой, а сигнал «такая задача уже принята». Хендлер проверяет её через errors.Is и отвечает клиенту как на успех: 200 со статусом already_accepted и тем же task_id. Повторный POST становится безопасным: первый запрос вернул 202 и запустил работу, второй — узнал, что работа уже в очереди. Такой подход дополняет уникальность из двенадцатого урока: Unique защищает от дубликатов по содержимому, TaskID — по явному идентификатору, который вы выбираете сами, чаще всего это ID заказа или платежа. Выбор идентификатора — решение архитектора: он должен быть осмысленным для бизнеса и достаточно долгим, чтобы не конфликтовать, а срок жизни совпадает с жизнью задачи в очереди, включая completed-хранение, о котором подробнее в двенадцатом уроке.

Повторный POST без дубликата

GO
info, err := client.Enqueue(
    asynq.NewTask("payment:capture", payload),
    asynq.TaskID(req.OrderID),
)
switch {
case errors.Is(err, asynq.ErrTaskIDConflict):
    c.JSON(http.StatusOK, gin.H{"status": "already_accepted", "task_id": req.OrderID})
case err != nil:
    c.JSON(http.StatusInternalServerError, gin.H{"error": "queue unavailable"})
default:
    c.JSON(http.StatusAccepted, gin.H{"status": "accepted", "task_id": info.ID})
}

TaskID привязывает задачу к ID заказа: вторая постановка с тем же идентификатором упрётся в ErrTaskIDConflict, и хендлер переведёт её в честный ответ «уже принято». Порядок веток важен: sentinel проверяется через errors.Is до общей ошибки, иначе конфликт постановки ответит 500 и мобильный клиент уйдёт в бесконечные повторные попытки.

Один процесс или два#

Web и воркер можно держать в одном процессе или развести. Монолитный вариант: asynq.Server запускается через Start(mux) рядом с HTTP-сервером в том же main — одним бинарем, одной конфигурацией, одним деплоем. Разделённый вариант: API-контейнер с Client и воркер-контейнер с Server общаются только через Redis. Монолит проще стартовать и отлаживать, но он связывает судьбы: тяжёлая фоновая работа делит CPU и память с ручками, а масштабировать придётся всё вместе — больше воркеров означает и больше без дела стоящих HTTP-инстансов. Разделённая топология дороже в эксплуатации — два деплоя, две конфигурации, два набора метрик, — зато воркеры масштабируются независимо: удвоение реплик воркера не трогает API, а релиз, ломающий только фоновые задачи, не отдаёт ручки. Graceful shutdown при этом одинаково важен в обоих вариантах: в монолите сигналы termination получают оба компонента сразу, в разделённом — каждый отдельно, и правила девятнадцатого урока действуют на каждый процесс свои. Практичный путь развития — начать с монолита и распасться на два процесса, когда фоновые задачи станут заметны в метриках: постановка задач при этом не меняется вообще, меняется только сборка main, потому что Client и Server общаются исключительно через Redis.

Топология web и воркера#

АспектОдин процессДва процесса
Деплой и артефактОдин бинарь, один контейнерДва контейнера с разными образами
МасштабированиеВоркеры растут вместе с APIРеплики воркера независимо от API
Изоляция нагрузкиФоновая работа делит CPU с ручкамиНагрузка разделена по машинам
Graceful shutdownСигнал общий на оба компонентаКаждый процесс обрабатывает сигнал сам
Первый запуск проектаБыстрее: меньше движущихся частейЧуть позже: но меньше переделок на росте

Монолит: web и воркер в одном main

GO
func main() {
    client = asynq.NewClient(asynq.RedisClientOpt{Addr: "localhost:6379"})
    defer client.Close()

    mux := asynq.NewServeMux()
    mux.HandleFunc("payment:capture", HandleCapture)

    srv := asynq.NewServer(asynq.RedisClientOpt{Addr: "localhost:6379"}, asynq.Config{Concurrency: 10})
    if err := srv.Start(mux); err != nil {
        log.Fatal(err)
    }
    defer srv.Shutdown()

    r := gin.Default()
    r.POST("/payments", handlePayment)
    r.Run(":8080") // блокирующий вызов; сигналы и shutdown — урок 19
}

Один main запускает воркер через Start(mux) и блокируется на HTTP-сервере: один бинарь делает обе работы, и Redis общий. Для продакшена сюда добавляется перехват SIGTERM, вызов srv.Shutdown и корректное завершение из девятнадцатого урока. Как только фоновые задачи начнут съедать ресурсы ручек, этот main распадается на два без изменений в постановке задач.

Что увидит клиент?

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

// client смотрит на тот же Redis, что и воркер
info, err := client.Enqueue(task, asynq.ProcessIn(30*time.Minute))
if err == nil {
    c.JSON(http.StatusAccepted, gin.H{"task_id": info.ID, "state": info.State})
}

Что отвечать пользователю#

Контракт асинхронной ручки — три ответа. Первый: 202 с task_id при успешной постановке; тело минимальное — идентификатор и, при желании, время ожидания, если стоит ProcessIn. Второй: 200 с already_accepted при ErrTaskIDConflict — для клиента это тоже успех, просто работа была начата раньше. Третий: 5xx при ошибке постановки — Redis лежит или очередь не существует; клиенты и ретраи на уровне HTTP справятся с этим сами. Синхронный ответ с результатом работы из асинхронной ручки не получается: задача исполняется позже и в другом процессе, поэтому результат либо пишется в базу воркером, либо доступен через GetTaskInfo из пятнадцатого урока, либо доставляется webhook'ом. Отдельный вопрос — авторизация постановки: ручка, ставящая задачи, — это запись в вашу очередь, и защищать её нужно так же, как запись в базу; открытая ручка постановки превращается в насос чужих задач с вашими ретраями. То же касается ручки статуса: статус заказа отдаётся по правам на заказ, а не по знанию идентификатора из очереди — task_id не является паролем и в авторизации не участвует.

Правила связки web и воркера#

  • API публикует намерение, воркер исполняет: тяжёлая работа не живёт в HTTP-хендлере.
  • Один asynq.NewClient на процесс — создаётся на старте, закрывается на shutdown.
  • Успешная постановка — 202 с task_id, повтор по TaskID — 200 с already_accepted.
  • Ошибка Enqueue — 5xx: молчаливое съедание теряет задачу навсегда.
  • Payload собирается из валидированных данных запроса, а не из сырого тела.
  • Отложенность — ProcessIn или ProcessAt: таймер хранится в Redis, а не в горутине.
  • Постановка и исполнение не связаны: статус задачи — Inspector или своя таблица статусов.

Проверка: повторная постановка

Задача с TaskID(orderID) ещё исполняется воркером. Хендлер ставит задачу с тем же ID. Что произойдёт?

Соберите постановку из ручки

Допишите создание задачи и её постановку с идемпотентным ID.

task := asynq.("payment:capture", payload) info, err := client.(task, asynq.TaskID(orderID))

Соберите идемпотентный хендлер

Расставьте строки постановки с TaskID и обработки повторного POST.

Строки

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

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

Карточки: web и воркер

Термин

Паттерн «API публикует»

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

HTTP-хендлер валидирует вход, ставит задачу через Enqueue и отвечает 202 с task_id. Работу делает воркер-процесс по ретрай-политике.

1 / 5

Связка собрана целиком: хендлер ставит задачу и мгновенно отвечает 202 с task_id, воркер исполняет по политике ретраев, повторный POST с TaskID превращается в already_accepted через ErrTaskIDConflict, а отложенность живёт в Redis через ProcessIn и ProcessAt. Мини-эксперимент: поднимите Redis и воркера, запустите пример с монолитным main и снимите нагрузку два раза подряд на одну ручку — первый ответ даст 202, второй уже 200. Формат прежний: код запускается локально с Go и Redis в Docker, песочница сайта показывает тематическую программу на чистом Go без Redis. Ключевая проверка зрелости связки: ни один продовый путь не завершается внутри HTTP-хендлера — всё тяжёлое уехало в очередь. Дальше по курсу: двадцать второй урок покажет, как тестировать обработчики без живого Redis, девятнадцатый уже давал правила тихого завершения для этой связки, а payload и идентификаторы задач разобраны в четвёртом уроке.

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

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

Вывод

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

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

  • API не делает работу: хендлер ставит задачу через Enqueue и отвечает 202 с task_id за миллисекунды.
  • TaskInfo из постановки несёт ID и State — их отдаём клиенту, судьбу задачи он узнает позже.
  • ProcessIn и ProcessAt дают отложенность: таймер живёт в scheduled-состоянии Redis, а не в горутине.
  • TaskID + errors.Is(asynq.ErrTaskIDConflict) — идемпотентный POST: повтор отвечает already_accepted.
  • Один asynq.NewClient на процесс; ошибка постановки — честный 5xx, а не молчаливое съедание.
  • Монолит: srv.Start(mux) рядом с HTTP-сервером — один бинарь, общие ресурсы, общий сигнал shutdown.
  • Два процесса: API и воркер масштабируются и деплоятся независимо, нагрузка изолирована.
  • Статус задачи — Inspector из пятнадцатого урока или собственная таблица, обновляемая воркером.
  • Ручка статуса проверяет ErrTaskNotFound и отдаёт статус по правам на заказ, а не по знанию task_id.

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

Соберите ручку POST /payments на gin: постановка payment:capture с payload из валидированного запроса, TaskID по ID заказа, ответы 202, 200 already_accepted и 5xx по веткам. Рядом — GET /payments/{id}/status на Inspector с проверкой errors.Is(ErrTaskNotFound). Запустите воркера отдельно от API и погоните нагрузку: убедитесь, что ручки не проседают, пока воркер спит на имитации SMTP. Затем соберите монолитный вариант с Start(mux) и сравните graceful shutdown обоих — по логам из девятнадцатого урока.

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

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

Можно ли исполнить работу прямо в хендлере, если она быстрая?

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

Как клиент узнает результат задачи?

Прямой связи из очереди обратно в HTTP-ответ нет: задача исполняется позже и в другом процессе. Результат либо пишется воркером в базу, откуда читает GET-ручка, либо запрашивается через Inspector по task_id, либо доставляется webhook'ом — выбирайте по тому, кто и как часто смотрит статус.

202 или 200 для идемпотентного повтора?

Оба ответа успешные; разница в семантике. Первая постановка — 202: принят к исполнению. Повтор с ErrTaskIDConflict — 200: ресурс в запрошенном состоянии уже есть. Ключевое — стабильность ответа: клиент по любому из двух кодов должен считать платеж запущенным ровно один раз.

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

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

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

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

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

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

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