Кнопка "Сформировать отчёт за квартал" нажата, и соединение с сервером висит уже минуту. Прогресс не двигается, пользователь щёлкает ещё раз, обновляет страницу, пишет в поддержку. На сервере в это время занят воркер приложения, занято соединение с базой, а балансировщик отсчитывает свои секунды до обрыва. Кончается всё предсказуемо: клиент получает тайм-аут, сервер продолжает работать вхолостую, а второй запрос запускает ту же операцию заново. Лечится история не увеличением тайм-аутов, а сменой формы сервиса: приём запроса и исполнение тяжёлой работы разводятся по разным процессам, между ними встаёт очередь задач. Разберём, как это устроено, что даёт на практике и в каких случаях синхронный ответ всё ещё оправдан.

Медленный эндпоинт выносит из строя весь веб-сервис

У долгого HTTP-запроса нет шансов дожить до конца в спокойной обстановке. Браузеры и промежуточные сети обрывают соединения по своим правилам, nginx по умолчанию ждёт ответ от приложения только шестьдесят секунд, у балансировщиков нагрузки похожие настройки, а отдельные облачные платформы режут ожидание уже на тридцатой секунде. Даже если работа на сервере благополучно завершится через пять минут, её результат некому отдать: соединение давно закрыто, ответ уйдёт в пустоту.

Вторая беда сидит глубже и бьёт по всему сервису, а не по одному эндпоинту. Синхронный воркер приложения обслуживает один запрос за раз: у gunicorn с четырьмя или восьмью воркерами восемь застрявших отчётов означают, что приложение целиком перестало отвечать, включая быструю выдачу профиля и проверку авторизации. Асинхронные фреймворки выручают слабо, потому что сборка отчёта упирается в процессор, а не в ожидание сети. Очередь из ожидающих запросов растёт, пул соединений с базой исчерпан висящими операциями, тайм-ауты посыпаются по всему периметру, и мониторинг показывает деградацию там, где формально всё работает.

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

Разделение на приём и исполнение меняет контракт API

Идея разделения проста до примитивности. Обработчик запроса делает только дешёвую часть: проверяет аргументы, права доступа, ставит сообщение в очередь и мгновенно отвечает. Всю дорогую работу подхватывает отдельный фоновый процесс, воркер, который читает очередь и исполняет задачи одну за другой или параллельно. Клиенту возвращается код 202 Accepted с идентификатором задачи, а дальше он либо опрашивает статус, либо получает уведомление по готовности.

На Celery, самом распространённом инструменте этой категории в мире Python, разделение выглядит так:

from celery import Celery

app = Celery("tasks", broker="redis://localhost:6379/0",
             backend="redis://localhost:6379/1")

@app.task
def build_report(user_id, quarter):
    rows = collect_rows(user_id, quarter)   # тяжёлые вычисления
    url = upload(rows)
    return {"report_url": url}
@router.post("/reports")
def create_report(payload: ReportIn):
    task = build_report.delay(payload.user_id, payload.quarter)
    return JSONResponse(status_code=202, content={"task_id": task.id})

Вызов build_report.delay() ставит задачу в очередь и сразу возвращает объект AsyncResult с идентификатором и методом ready() для проверки готовности. Метод delay() это краткая запись для apply_async(), где настраиваются очередь, приоритет и отложенный запуск. Нюанс, о который спотыкаются новички: хранение результатов в Celery по умолчанию выключено, для статусов нужен бэкенд, например Redis или таблица в базе. Обработчик при такой схеме отвечает за миллисекунды, соединения освобождаются, и падение воркера больше не рвёт пользовательский запрос.

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

Задачи, которые просятся в очередь с первого дня

Перечень кандидатов стабилен от проекта к проекту. Отправка почты и push-уведомлений: внешний почтовый сервер может тормозить или отказывать, и держать за него ответ пользователю расточительно. Обработка медиа: сжатие изображений, нарезка видео, генерация превью, всё это секунды и минуты процессорного времени. Импорт и экспорт: выгрузка десяти тысяч строк в электронную таблицу не должна блокировать интерфейс. Обмен с внешними API: синхронизация заказов с товарным сервисом переживает его сбои спокойнее, когда повторные попытки живут в очереди, а не в браузере пользователя. Инференс моделей и генерация PDF-документов по тем же причинам: тяжёлое вычисление не должно жить внутри пользовательского запроса. Отдельный пласт это периодические задания, ночные пересчёты агрегатов и чистка устаревших записей, у Celery для них есть собственный планировщик beat, добавляющий задачи в очередь по расписанию.

Общий признак у всех перечисленных операций один: пользователь не ждёт результат сию секунду, а система выигрывает от отложенного, контролируемого и повторяемого исполнения.

Выбор брокера определяет судьбу задачи при падении сервера

Очередь живёт в брокере, и выбор брокера это выбор гарантий. RabbitMQ, брокер по умолчанию в Celery, хранит сообщения на диске, требует подтверждения доставки и умеет переносить необработанные сообщения в отдельную очередь отказов. Redis подключается одной командой:

docker run -d -p 6379:6379 redis

и в небольших проектах работает отлично, но документация Celery прямо предупреждает: Redis тяжелее переживает внезапное завершение или отключение питания, часть сообщений при таком сбое теряется. Управляемая очередь Amazon SQS снимает заботу о серверах и предлагает собственный механизм видимости: сообщение, которое воркер не подтвердил за отведённое время, снова становится доступным другим воркерам, а безнадёжные экземпляры после заданного числа попыток уходят в очередь отказов.

Практическое правило напрашивается само. Для внутренних отчётов и писем хватает Redis, его простота окупает редкие потери. Для задач, где потеря означает деньги, платёжные выгрузки, биллинг, берут RabbitMQ или SQS и настраивают подтверждения всерьёз. Цена ошибки несимметрична: лишние полчаса на настройку брокера дешевле одного молча пропавшего сообщения.

Идемпотентность и поздние подтверждения защищают от двойной работы

Самый коварный момент асинхронной схемы это падение воркера посреди задачи. По умолчанию Celery подтверждает получение сообщения до начала исполнения, и если процесс умрёт в середине, задача исчезнет бесследно. Опция acks_late меняет порядок: подтверждение уходит после завершения задачи, и упавший воркер возвращает сообщение в очередь, где его подхватит следующий. Плата за честность двойное исполнение, поэтому правило звучит жёстко: acks_late включают только для идемпотентных задач, которые безболезненно переносят повторный запуск с теми же аргументами.

Повторные попытки при сбоях собираются декоратором:

@app.task(bind=True, acks_late=True,
          autoretry_for=(requests.RequestException,),
          retry_kwargs={"max_retries": 5},
          retry_backoff=True, retry_jitter=True)
def sync_orders(self, shop_id):
    resp = requests.get(api_url, timeout=(5.0, 30.0))
    resp.raise_for_status()
    save_orders(shop_id, resp.json())

Параметр retry_backoff включает экспоненциальные паузы между попытками, а retry_jitter добавляет случайность, чтобы тысяча воркеров не билась в упавший внешний сервис синхронным строем. Тайм-ауты ввода-вывода задаются явно, в примере пять секунд на соединение и тридцать на чтение ответа: зависший сетевой вызов иначе способен остановить воркер навсегда. Жёсткие лимиты времени в Celery существуют, но работают грубо, по истечении лимита процесс задачи убивается силой, поэтому их роль страховка от забытых тайм-аутов, а не основной механизм. Для задач, гибнущих от нехватки памяти, отдельно смотрят параметр task_reject_on_worker_lost, возвращающий сообщение в очередь при гибели воркера.

Раздельные очереди и наблюдаемость удерживают порядок в эксплуатации

Как только задач становится больше трёх, выясняется, что медленная генерация отчётов забивает очередь и задерживает быстрые уведомления. Лечение известно: раздельные очереди и отдельные воркеры. Документация Celery прямо рекомендует направлять долгие и короткие задачи на выделенные воркеры, а стратегия планирования, справедливая к длинным задачам, стала поведением по умолчанию ещё в четвёртой версии библиотеки. Запуск выглядит так:

celery -A tasks worker -Q reports --concurrency=4
celery -A tasks worker -Q notifications --concurrency=8

Второй столп эксплуатации видимость. Глубина очереди и возраст самого старого сообщения говорят о здоровье системы больше, чем загрузка процессора: рост глубины при стабильном числе воркеров означает, что задачи прибывают быстрее, чем исполняются. Для контроля годится и веб-панель Flower, показывающая активные задачи и очередь в реальном времени. Не помешает и настройка worker_max_tasks_per_child: воркер перезапускается после заданного числа задач, и утечки памяти в кривых задачах перестают накапливаться до аварийного завершения. Отдельного порядка требуют отравленные сообщения, которые падают всегда и по одной причине: их переносят в очередь отказов и разбирают руками, не позволяя крутить бесконечные повторные попытки.

Минимальный чек-лист перед выводом очередей в продукт выглядит так:

  1. Каждая задача объявлена идемпотентной, повторный запуск с теми же аргументами не создаёт дублей;
  2. Для сетевых вызовов внутри задач заданы тайм-ауты, а жёсткие лимиты времени остаются страховкой;
  3. Быстрые и медленные задачи разведены по разным очередям с отдельными воркерами;
  4. Глубина очереди и возраст самого старого сообщения выведены на дашборд с алертами;
  5. Отказы обрабатываются отложенным повтором с экспоненциальной паузой, безнадёжные сообщения уходят в очередь отказов;
  6. Перед включением acks_late проверено, что гибель воркера посреди задачи не ломает учёт и отчётность.

Синхронный ответ остаётся верным решением для быстрых операций

Очередь не бесплатна. Она добавляет брокера в перечень того, что способно сломаться, размазывает исполнение по процессам, усложняет отладку: один запрос теперь живёт в двух местах, и трассировка требует сквозных идентификаторов. Семантика доставки хотя бы раз означает, что про идемпотентность нельзя забывать нигде. В маленьком сервисе с сотней пользователей и операциями на десятки миллисекунд вся эта инфраструктура решает несуществующую проблему.

Ориентир по времени простой. Если операция стабильно укладывается в двести-триста миллисекунд, синхронный ответ честнее и проще: пользователь получил результат, транзакция закрыта, ничего не потерялось. Операции со строгой согласованностью, например списание денег с немедленным подтверждением, комфортнее держать синхронными, в одной транзакции базы. Как только появляется работа на секунды и минуты, внешние сервисы с собственными сбоями, расписание, пакетная обработка, очередь окупается с лихвой.

Разделение приёма и исполнения это в первую очередь честность контракта, а не погоня за производительностью. Синхронный эндпоинт обещает результат и обязан его выдать, асинхронный обещает только принятие задания, и его не мучают тайм-ауты. Сервис, который быстро отвечает "принято, идентификатор такой-то" и действительно доводит работу до конца, приятнее для клиента, дешевле в эксплуатации и предсказуемее под нагрузкой, чем герой, держащий соединение пять минут в надежде всё-таки успеть. Очередь задач выглядит лишней инфраструктурой ровно до первого квартального отчёта, после которого возвращаться к блокирующим запросам уже не хочется.