Фоновые воркеры в Python под нагрузкой: где рвётся первым
Очередь задач копится быстрее, чем воркеры её разбирают, задержка растёт до минут, а память течёт. Разбираем, где Celery и RQ ломаются под нагрузкой и что мерить, пока не поздно.

Типичная картина: сервис принимает 200 задач в секунду на отправку писем и генерацию превью, а пул из восьми воркеров разбирает 120. Очередь в Redis растёт линейно, задержка от постановки до выполнения задачи за полчаса уходит с двух секунд к семи минутам, RSS каждого процесса ползёт вверх, и в какой-то момент OOM killer убивает воркер прямо посреди задачи. Дальше — повтор задачи, дубли писем, жалобы. Разберём, почему фоновые воркеры на Python ведут себя так под нагрузкой и что именно у них рвётся первым.
Речь про связки Celery 5.x с брокером Redis или RabbitMQ и RQ 1.x на Redis — самые ходовые в вебе. Угол узкий: только поведение под нагрузкой и деградация. Выбор брокера, дизайн идемпотентных задач и мониторинг как продукт — смежные темы, их здесь не разворачиваем.
Что рвётся первым: не CPU, а бэклог и память
Первое заблуждение — что упрётесь в процессор. Под реальной нагрузкой первым сдаётся одно из двух: длина очереди или память процессов. CPU обычно ещё есть, а система уже недоступна по времени отклика.
Бэклог: арифметика, которую не обойти
Если задачи приходят со скоростью λ (в секунду), а один воркер обрабатывает μ задач в секунду, то при N воркерах система стабильна, только пока λ < N·μ. Как только входящий поток обгоняет пропускную способность пула хотя бы на несколько процентов, очередь растёт неограниченно, а вместе с ней — время ожидания задачи. Это не деградация «немного медленнее», это разрыв: задержка уходит в бесконечность линейно по времени.
Воркеры под нагрузкой не «тормозят плавно». Пока вы укладываетесь в пропускную способность — задержка стабильна. Стоит её превысить — бэклог растёт линейно, и никакие ретраи ситуацию не чинят, а только усугубляют.
Память: prefetch и долгоживущие процессы
Celery-воркер по умолчанию забирает из брокера пачку задач заранее — это prefetch. Управляется параметром worker_prefetch_multiplier (по умолчанию 4): каждый процесс резервирует до 4 задач на слот конкурентности. При длинных задачах это значит, что часть работы «залипает» в занятом воркере, пока свободные простаивают. Для коротких задач prefetch полезен, для длинных и неравномерных — вреден.
Вторая проблема — процессы Python не отдают память ОС так охотно, как хотелось бы. Задача, которая разово загрузила в память 500 МБ данных, оставляет RSS процесса раздутым. Через сотни задач воркер выглядит как утечка, хотя это фрагментация и удержание арены аллокатора. Отсюда практика периодически перезапускать воркер по числу выполненных задач — параметр worker_max_tasks_per_child.
Модель конкурентности определяет, где вы упрётесь
Celery умеет работать в нескольких пулах, и выбор пула — главное архитектурное решение под нагрузкой.
| Пул (--pool) | Тип нагрузки | Что выигрываете | Чем платите |
|---|---|---|---|
| prefork (по умолчанию) | CPU-bound, изоляция | Обход GIL, падение воркера не роняет соседей | Память на каждый процесс, старт медленный |
| threads | I/O-bound (сеть, БД) | Дёшево по памяти, много одновременных ожиданий | GIL: настоящего параллелизма по CPU нет |
| gevent / eventlet | Массовый I/O, тысячи соединений | Десятки тысяч «зелёных» задач на процесс | Монки-патчинг ломает часть библиотек, отладка сложнее |
Если задачи в основном ждут внешний сервис (HTTP, БД), запуск 8 prefork-процессов — это потолок в 8 одновременных ожиданий и куча памяти впустую. Для того же профиля gevent-пул на одном процессе держит тысячи одновременных задач, потому что во время ожидания I/O управление отдаётся другой корутине. Но монки-патчинг через gevent.monkey.patch_all() перехватывает сокеты и потоки процессно — драйверы, написанные на C и не знающие про gevent, могут блокировать весь цикл. Проверяйте каждую библиотеку.
Настройки Celery, которые реально меняют поведение
# Celery 5.x, файл celeryconfig.py или через app.conf
# Для длинных неравномерных задач: не хватать лишнего наперёд
worker_prefetch_multiplier = 1
# Перезапуск процесса после N задач — лечит рост RSS
worker_max_tasks_per_child = 100
# Жёсткий и мягкий лимит времени задачи (секунды)
task_time_limit = 300 # SIGKILL воркеру
task_soft_time_limit = 270 # SoftTimeLimitExceeded внутри задачи
# Подтверждать задачу только после выполнения, а не при получении.
# Защищает от потери задачи при падении воркера, но требует
# идемпотентности: при падении задача уйдёт другому воркеру
task_acks_late = True
worker_prefetch_multiplier = 1 # обязательно вместе с acks_lateПара task_acks_late = True и worker_prefetch_multiplier = 1 — самая частая рекомендация для задач, которые нельзя терять. Но она означает, что при аварийном падении воркера задача выполнится повторно. Если задача не идемпотентна (списывает деньги, шлёт письмо) — вы получите дубли. Идемпотентность здесь не опция, а требование.
Замеры: что и как мерить
Готовых «правильных» цифр здесь нет — они зависят от профиля задач, железа и брокера. Но набор метрик, без которых вы слепы под нагрузкой, вполне конкретен:
- Длина очереди по каждому имени очереди. Для Redis это
LLENна ключе очереди. Растущая длина — первый и самый честный сигнал, что λ обогнал N·μ. - Время ожидания задачи — от постановки (
apply_async) до старта выполнения. Не путать со временем самого выполнения. Именно ожидание взрывается при бэклоге. - RSS каждого воркер-процесса во времени. Пила с растущими зубцами говорит об удержании памяти между задачами.
- Доля задач, дошедших до ретрая, и число повторов. Всплеск ретраев умножает нагрузку и часто добивает уже перегруженную систему.
Порядок замера под нагрузкой такой: сгенерируйте вход с известной скоростью λ, зафиксируйте железо (число ядер, объём RAM) и брокер, снимите μ на один воркер при пустой очереди, затем поднимайте λ ступенями и смотрите, на какой ступени время ожидания перестаёт быть стабильным. Точка, где оно начинает расти монотонно, и есть ваш реальный потолок — обычно он ниже наивного N·μ из-за prefetch, сериализации и накладных расходов брокера.
Граничные случаи, где всё ломается неожиданно
Ретраи как усилитель отказа
Внешний сервис начал отдавать ошибки. Задачи падают и уходят в ретрай. Если ретрай без экспоненциальной паузы и без джиттера, тысячи задач начинают долбить лежащий сервис синхронно, и он не встаёт даже после починки. Настраивайте retry_backoff и retry_jitter, иначе ваши воркеры устроят DDoS собственной инфраструктуре.
Задача дольше визибилити-таймаута брокера
С брокером Redis Celery использует так называемый visibility timeout: если задача не подтверждена за это время, брокер считает её потерянной и отдаёт другому воркеру. Задача, которая честно выполняется дольше таймаута, будет запущена повторно параллельно — два воркера делают одно и то же. Значение visibility timeout должно превышать максимальное время выполнения любой задачи. С RabbitMQ этой проблемы нет, там подтверждение на уровне протокола.
Одна медленная задача блокирует очередь
При prefetch > 1 воркер, забравший наперёд пачку задач, где одна медленная, держит остальные заблокированными до её завершения — даже если рядом простаивают свободные воркеры. Разносите короткие и длинные задачи по разным очередям и слушайте их разными пулами воркеров. Смешивать 50-миллисекундные и 5-минутные задачи в одной очереди — верный способ получить непредсказуемую задержку.
Backpressure, которого по умолчанию нет
Ни Celery, ни RQ по умолчанию не тормозят продюсера, когда очередь переполнена. Продюсер будет ставить задачи, пока не кончится память брокера. Ограничение нужно строить самим: проверять длину очереди перед постановкой и отбрасывать или задерживать новые задачи, когда бэклог превысил порог. Без этого перегрузка воркеров превращается в падение брокера, а это уже отказ всей системы.
Что делать дальше
Начните с наблюдаемости: повесьте метрику длины очереди и времени ожидания задачи — без них любая настройка вслепую. Дальше по порядку:
- Разделите задачи по очередям: короткие и длинные слушайте разными воркерами.
- Для I/O-bound задач попробуйте пул threads или gevent вместо prefork и замерьте μ на процесс — часто выигрыш в разы при той же памяти.
- Поставьте
worker_max_tasks_per_child, если видите рост RSS, иtask_time_limit, чтобы зависшая задача не держала слот вечно. - Добавьте backpressure на стороне продюсера по порогу длины очереди.
- Настройте ретраи с backoff и jitter, чтобы не добивать внешние сервисы.
Метрика, за которой смотреть в проде постоянно, — время ожидания задачи в очереди, а не загрузка CPU. Именно оно первым покажет, что вы вышли за пропускную способность, задолго до того, как что-то упадёт.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: Celery — Optimizing (официальная документация), Celery — Workers Guide
Частые вопросы
У меня Celery 4.x, а не 5.x — настройки те же?
Поставил acks_late, а задачи стали выполняться дважды — это баг?
Как понять, что я упёрся в GIL, а не в брокер?
Celery или RQ для высокой нагрузки?
Настроил всё по статье, а задержка всё равно растёт — что не так?
Стоит ли крутить worker_prefetch_multiplier в большую сторону для ускорения?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.