---
title: "Фоновые задачи в веб-приложении"
description: "API отправляет, воркер делает: полная связка. Хендлер ставит задачу через Enqueue и отвечает 202 с ID, воркер исполняет; ProcessIn, TaskID, ErrTaskIDConflict."
course: "Asynq в Go: очередь фоновых задач на Redis"
url: "https://golangmaster.ru/courses/asynq/lessons/21-zadachi-v-web/"
published: "2026-09-19"
updated: "2026-09-23"
minutes: 20
---

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

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

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

К этому уроку у вас уже есть всё по отдельности: постановка задач с 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. Кто и когда исполнит её?

- Тот же процесс gin — в фоновой горутине после ответа
- ✅ Воркер-процесс, забравший задачу из очереди в Redis
- Redis исполняет задачу сам по расписанию
- Клиент, когда следующей страницей запросит статус

Enqueue лишь кладёт задачу в Redis; исполняет её воркер — процесс с asynq.Server и зарегистрированным mux, забирающий задачи из той же очереди. Фоновая горутина в web-процессе умрёт на деплое и не получит ретраев, а Redis хранит данные, но не исполняет код.

### Идемпотентный 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 и мобильный клиент уйдёт в бесконечные повторные попытки.

> **Живое место: клиент на процесс, а не на запрос.** Грабли связки на живом проекте. Первое: asynq.NewClient создаётся один раз при старте приложения и живёт до shutdown — конструктор на каждый HTTP-запрос плодит соединения с Redis и мусор в пуле. Второе: не отвечайте 202 на постановку, упавшую с ошибкой, «чтобы не пугать клиента» — молчаливое съедание ошибки постановки теряет задачи навсегда, потому что в очереди их нет. Третье: task_id из ответа стоит логировать вместе с request_id — иначе разбор инцидента «а что случилось с заказом 42» начнётся с гадания. И четвёртое: постановке нужен таймаут — подвисший Redis задержит Enqueue, а за ним и HTTP-ручки; ограничивайте время постановки и следите за пингом Redis отдельно.

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

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 распадается на два без изменений в постановке задач.

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

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

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

- Ошибка: отложенные задачи нельзя ставить из HTTP-хендлеров
- ✅ 202 мгновенно, state будет scheduled, исполнение через полчаса
- Ответ придёт через 30 минут, когда задача исполнится
- 202, но state будет pending — отложенность видна только воркеру

Enqueue с ProcessIn кладёт задачу в scheduled-состояние Redis и возвращается немедленно: клиент получает 202 за миллисекунды, а TaskInfo.State отражает scheduled. Через полчаса forwarder переведёт задачу в pending, и воркер её заберёт — ожидание живёт в очереди, а не в web-процессе.

> **Про песочницу: связка без Redis.** Песочница этого урока показывает программу на чистом Go: постановка «задачи» в локальную структуру и обработка её фейковым воркером — формы те же, что с asynq, но без сети. Локально запускайте полный вариант: Redis в Docker, воркер с mux из восьмого урока и тестовый HTTP-сервер; повторите POST дважды с тем же TaskID и убедитесь, что второй ответ — already_accepted.

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

Контракт асинхронной ручки — три ответа. Первый: 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 должен быть уникален только среди scheduled
- ✅ Enqueue вернёт ошибку, errors.Is распознает asynq.ErrTaskIDConflict
- Старая задача будет заменена новой с тем же ID
- Постановка заблокируется, пока первая задача не завершится

Пока задача с этим ID жива в очереди, повторная постановка возвращает sentinel asynq.ErrTaskIDConflict, проверяемый через errors.Is. Это штатный сигнал «уже принято»: идемпотентная ручка отвечает 200, а не 500. Замены и блокировки нет — поведение определяется вашими ветками обработки ошибки.

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

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

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

Подсказка: Конструктор задачи — NewTask с типом и payload, постановка — метод Enqueue у клиента.

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

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

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

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

1. `func handlePayment(c *gin.Context) {`
2. `payload, _ := json.Marshal(PaymentPayload{OrderID: req.OrderID})`
3. `info, err := client.Enqueue(task, asynq.TaskID(req.OrderID))`
4. `switch {`
5. `case errors.Is(err, asynq.ErrTaskIDConflict):`
6. `    c.JSON(http.StatusOK, gin.H{"status": "already_accepted"})`
7. `case err != nil:`
8. `    c.JSON(http.StatusInternalServerError, gin.H{"error": "queue unavailable"})`
9. `default:`
10. `    c.JSON(http.StatusAccepted, gin.H{"task_id": info.ID})`
11. `}`

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

- **Паттерн «API публикует»** — HTTP-хендлер валидирует вход, ставит задачу через Enqueue и отвечает 202 с task_id. Работу делает воркер-процесс по ретрай-политике.
- **Статус 202** — Accepted: запрос принят к исполнению, результат будет позже. 200 оставляют для идемпотентного повтора already_accepted.
- **ProcessIn и ProcessAt** — Опции постановки для отложенности: интервал и конкретный момент. Таймер живёт в Redis в scheduled-состоянии, а не в памяти web-процесса.
- **asynq.TaskID(id)** — Опция с явным идентификатором задачи. Пока задача жива в очереди, повторная постановка с тем же ID вернёт asynq.ErrTaskIDConflict.
- **Монолит против двух процессов** — Start(mux) рядом с HTTP-сервером — один бинарь и общие ресурсы; два контейнера — независимое масштабирование и изоляция нагрузки за счёт второй конфигурации.

Связка собрана целиком: хендлер ставит задачу и мгновенно отвечает 202 с task_id, воркер исполняет по политике ретраев, повторный POST с TaskID превращается в already_accepted через ErrTaskIDConflict, а отложенность живёт в Redis через ProcessIn и ProcessAt. Мини-эксперимент: поднимите Redis и воркера, запустите пример с монолитным main и снимите нагрузку два раза подряд на одну ручку — первый ответ даст 202, второй уже 200. Формат прежний: код запускается локально с Go и Redis в Docker, песочница сайта показывает тематическую программу на чистом Go без Redis. Ключевая проверка зрелости связки: ни один продовый путь не завершается внутри HTTP-хендлера — всё тяжёлое уехало в очередь. Дальше по курсу: [двадцать второй урок](/courses/asynq/lessons/22-testy-zadach/) покажет, как тестировать обработчики без живого Redis, [девятнадцатый](/courses/asynq/lessons/19-graceful-shutdown/) уже давал правила тихого завершения для этой связки, а payload и идентификаторы задач разобраны в [четвёртом уроке](/courses/asynq/lessons/04-payload-i-id/).

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

- 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 обоих — по логам из девятнадцатого урока.

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

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

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

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

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

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

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