Стриминг в FastAPI: SSE и файлы под нагрузкой — что ломается первым
StreamingResponse в FastAPI выглядит просто, пока сервис обслуживает десять клиентов. На трёхстах параллельных подключениях начинаются сюрпризы: от зависших SSE-соединений до OOM при отдаче файлов. Разбираем механику, узкие места и способы с ними жить.

Как устроен StreamingResponse изнутри
FastAPI делегирует стриминг Starlette, а та — ASGI-серверу. Когда вы возвращаете StreamingResponse, Starlette итерирует переданный генератор (синхронный или асинхронный) и отправляет каждый чанк через ASGI-интерфейс send. Ключевой момент: один запрос держит одну корутину на протяжении всей отдачи. Пока генератор не завершился или клиент не отключился, корутина живёт в event loop.
Для короткого JSON-ответа это не имеет значения. Для SSE-потока, который висит минуты, или для файла на сотни мегабайт — имеет: каждое такое соединение занимает слот в asyncio event loop, а при синхронном генераторе ещё и поток из пула anyio.
SSE: отдельный случай
Server-Sent Events — это HTTP-ответ с Content-Type: text/event-stream, который не закрывается. Клиент читает построчно, сервер пушит данные по мере появления. FastAPI не имеет встроенной абстракции для SSE — вы сами формируете строки data: ...\n\n внутри генератора и оборачиваете в StreamingResponse. Библиотека sse-starlette (версия 1.x–2.x) добавляет удобную обёртку EventSourceResponse, но внутри делает ровно то же: держит генератор открытым.
Стриминг в ASGI — это не магия мультиплексирования. Это одна корутина на одно соединение, и event loop обязан дойти до каждой из них, чтобы отправить очередной чанк.
Что ломается на SSE под нагрузкой
1. Исчерпание корутин и рост латентности
Допустим, к SSE-эндпоинту подключились 500 клиентов. Каждое соединение — корутина, которая спит на await до появления нового события, затем просыпается и вызывает send. Когда событие приходит одновременно для всех (типичная ситуация: обновление цены, статус заказа), event loop должен последовательно пробежать 500 send. На Uvicorn (0.29–0.30) с одним воркером задержка между первым и последним клиентом легко достигает сотен миллисекунд, а при тяжёлой сериализации — секунд.
2. Мёртвые соединения и утечка ресурсов
Клиент закрыл вкладку, но TCP-соединение не разорвалось мгновенно (NAT, прокси, мобильная сеть). Генератор продолжает работать: он вызывает yield, ASGI-сервер пытается отправить данные, получает ошибку записи — но не всегда сразу. До тех пор корутина числится живой.
Uvicorn выбрасывает ClientDisconnect только при следующей попытке записи. Если ваш генератор отправляет данные раз в 30 секунд, мёртвая корутина живёт до 30 секунд после реального отключения. При 500 соединениях с коротким TTL клиентов (мобильные) вы можете держать десятки зомби-корутин постоянно.
3. Обратное давление (backpressure) — его нет
Стандартный StreamingResponse не реализует backpressure. Если генератор производит данные быстрее, чем клиент забирает, чанки копятся в буферах: сначала в asyncio-транспорте, потом в TCP send buffer ядра. На медленном клиенте это рост потребления памяти процесса. Десять медленных SSE-клиентов, получающих по мегабайту событий в минуту, способны раздуть RSS воркера на десятки мегабайт.
Что ломается на файлах
Синхронный генератор — блокировка event loop
Частая ошибка: читать файл с диска обычным open() внутри синхронного генератора. Starlette оборачивает синхронный генератор в run_in_executor, но выделяет для этого поток из пула anyio. По умолчанию anyio использует пул в 40 потоков. Сорок одновременных скачиваний файлов — и новые запросы встают в очередь, включая лёгкие API-вызовы.
Размер чанка и OOM
Генератор, читающий файл чанками по 1 МБ, при 200 параллельных скачиваниях держит в памяти до 200 МБ только в буферах чтения — не считая TCP-буферов. Чанк в 64 КБ снижает эту цифру до ~12 МБ, но увеличивает количество итераций и переключений контекста. Баланс зависит от профиля нагрузки.
| Параметр | SSE (долгое соединение) | Файл (конечная отдача) |
|---|---|---|
| Время жизни корутины | Минуты — часы | Секунды — минуты |
| Главный ресурс под давлением | Event loop (количество корутин) | Потоки пула / дисковый I/O |
| Риск OOM | При отсутствии backpressure и медленных клиентах | При большом чанке × много параллельных скачиваний |
| Зомби-соединения | Высокий риск (долгий idle) | Низкий (соединение короткое) |
| Типичный первый симптом | Рост латентности остальных эндпоинтов | Таймауты или 503 от обратного прокси |
Как мерить, прежде чем чинить
Без замеров оптимизация — гадание. Вот что стоит отслеживать.
- Количество активных корутин —
len(asyncio.all_tasks())по таймеру, вывод в метрики Prometheus. Рост без роста реальных клиентов — признак зомби. - RSS процесса воркера — через
/proc/[pid]/statusили библиотекуpsutil. Мониторьте не среднее, а максимум за интервал. - Размер очереди пула потоков — при синхронных генераторах. В
anyioнапрямую не экспортируется, но можно обернуть executor и считать ожидающие задачи. - Время отклика p99 на обычных эндпоинтах — если стриминг деградирует event loop, это видно здесь первым.
Для нагрузочного теста SSE подходит не wrk, а инструменты, умеющие держать долгие соединения: k6 (с расширением для SSE), locust с кастомным клиентом или простой скрипт на aiohttp/httpx с асинхронным чтением потока.
Способы снизить давление
SSE: heartbeat и принудительное закрытие
Отправляйте пустой комментарий (: ping\n\n) каждые 15–20 секунд. Это делает две вещи: не даёт промежуточным прокси закрыть соединение по idle-таймауту и быстрее обнаруживает мёртвых клиентов — запись в закрытый сокет вызовет исключение на следующем heartbeat, а не через минуту.
Устанавливайте серверный TTL на SSE-соединение. Через 5–10 минут закрывайте поток, клиент переподключится автоматически (в спецификации SSE это штатное поведение, браузерный EventSource делает reconnect сам). Это не костыль — это способ не копить зомби.
SSE: вынести fan-out из event loop
Вместо того чтобы каждая корутина по отдельности ходила в базу или очередь за событиями, создайте один фоновый таск-продюсер, который читает источник и раскладывает события в asyncio.Queue для каждого подписчика, — или используйте broadcast-канал (broadcaster, Redis Pub/Sub через aioredis). Это убирает дублирование работы и снижает время, которое event loop тратит на генерацию данных.
Файлы: асинхронный I/O и правильный чанк
Используйте aiofiles (или anyio.open_file) для чтения — это снимает нагрузку с пула потоков. Размер чанка 64–128 КБ — разумный старт; если отдаёте файлы в гигабайтах, поднимите до 256 КБ и замерьте пропускную способность.
Файлы: отдайте Nginx
Для статических и сгенерированных файлов, лежащих на диске, механизм X-Accel-Redirect (Nginx) или X-Sendfile (Apache) разгружает Python-процесс полностью: FastAPI отвечает заголовком, а файл отдаёт веб-сервер. Это не подходит для генерируемых на лету потоков, но для готовых файлов — лучшее решение.
Масштабирование воркеров
Uvicorn с --workers N или Gunicorn с uvicorn.workers.UvicornWorker распределяет соединения между процессами. Каждый процесс — свой event loop. Для SSE-тяжёлых сервисов количество воркеров стоит увеличивать не по числу CPU, а по допустимому количеству долгих соединений на один loop. Точную границу покажет только нагрузочный тест.
Термин: Backpressure — механизм, при котором медленный потребитель данных сигнализирует быстрому производителю «притормози». В HTTP-стриминге через ASGI встроенного backpressure нет: если клиент не читает, данные копятся в буферах сервера.
Граничные случаи и где подход ломается
- HTTP/2 и мультиплексирование. Uvicorn на момент версии 0.30 не поддерживает HTTP/2. Если перед ним стоит Nginx с HTTP/2 к клиенту и HTTP/1.1 к бэкенду, каждое SSE-соединение — отдельное TCP-соединение до Uvicorn. Мультиплексирование не спасает.
- Kubernetes и load balancer. Многие L7-балансировщики имеют таймаут idle-соединения (AWS ALB — 60 секунд по умолчанию). SSE без heartbeat будет разрываться балансировщиком, а клиент получит переподключение каждую минуту.
- Reverse proxy буферизация. Nginx по умолчанию буферизирует ответ (
proxy_buffering on). Для SSE это означает, что события копятся в буфере Nginx и уходят клиенту пачкой — теряется смысл реального времени. Нужноproxy_buffering offили заголовокX-Accel-Buffering: noв ответе. - Генераторы с побочными эффектами. Если генератор внутри SSE-потока держит курсор базы данных или соединение из пула, зомби-корутина не отпустит соединение. Пул исчерпается быстрее, чем память.
Что делать дальше
Настройте мониторинг активных стриминговых соединений до того, как нагрузка вырастет. Добавьте heartbeat и серверный TTL для SSE. Для файловой отдачи — проверьте, можно ли переложить на X-Accel-Redirect. Проведите нагрузочный тест с количеством долгих соединений, равным вашему пиковому × 2, и смотрите не на throughput, а на p99 латентности обычных API-эндпоинтов — именно они расскажут, насколько стриминг давит на event loop.
Автоэксперт / механик: Расшифровка OBD2-кодов, выбор б/у авто, диагностика по симптомам.
Спросить за 12 ₽Источники: Starlette — Responses (официальная документация), Uvicorn — Deployment (официальная документация)
Частые вопросы
Почему SSE-соединения замедляют обычные API-эндпоинты?
StreamingResponse поддерживает backpressure?
Что будет, если использовать синхронный генератор для отдачи файла?
Как обнаружить, что клиент SSE отключился?
Можно ли использовать HTTP/2, чтобы мультиплексировать SSE-потоки?
Что если у меня другая версия FastAPI или Uvicorn — поведение будет таким же?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.