Как спроектировать сервис с высокой нагрузкой и долгими задачами
Что проверяют
Отделяете ли вы приём запроса от выполнения работы и понимаете ли, где у Python узкие места: GIL, блокирующий код внутри async, пул соединений к базе. Следом просят оценить нагрузку в цифрах и спрашивают, что будет, если воркер упал посреди задачи.
Как отвечать
Начните с оценки: RPS на входе, длительность задачи, объём данных. От этого зависит, нужна ли очередь вообще. Дальше каркас:
- быстрый вход: API на FastAPI или Django отвечает за миллисекунды, кладёт задачу в брокер и возвращает
202с идентификатором. Веб-процессы без состояния масштабируются горизонтально за балансировщиком; - брокер и исполнитель: RabbitMQ или Redis как брокер, Celery,
arqилиdramatiqкак исполнитель. Celery это фреймворк задач, а не очередь, ему нужен брокер. Приtask_acks_late=Trueзадача вернётся в очередь после падения воркера, поэтому задачи обязаны быть идемпотентными; - тип нагрузки: CPU-bound задачи идут в отдельные процессы (пул
preforkв Celery,ProcessPoolExecutor), I/O-bound хорошо ложатся на asyncio. Блокирующий вызов внутриasync defостанавливает весь event loop; - обратное давление:
worker_prefetch_multiplier=1для долгих задач, ограничение длины очереди, rate limit на входе; - состояние: статус и результат в PostgreSQL или объектном хранилище, горячие чтения из Redis, соединения через PgBouncer;
- наблюдаемость: длина очереди, время ожидания задачи и доля ошибок в Prometheus, автоскейлинг воркеров по длине очереди, например через KEDA.
Длинные задачи режьте на шаги с чекпоинтами: упавший воркер продолжит с последнего шага, а не с нуля.
Где спотыкаются
Называют Celery очередью и не могут объяснить, где физически лежат задачи и что с ними станет при перезапуске брокера.
Лечат нагрузку потоками, забывая про GIL: в стандартной сборке CPython потоки почти не ускоряют вычисления.
Начинают с микросервисов и Kubernetes, не посчитав нагрузку. Часто монолит с отдельным пулом воркеров закрывает задачу целиком.