perf(backend): явный пул соединений БД и несколько воркеров uvicorn

Пул создавался с дефолтом SQLAlchemy (5 + 10) и под нагрузкой выгребался
за секунды. Теперь параметры заданы явно и вынесены в настройки:
DB_POOL_SIZE=10, DB_MAX_OVERFLOW=10, DB_POOL_TIMEOUT=10. Таймаут снижен с
дефолтных 30 секунд намеренно — пусть запрос падает быстро и показывает
проблему, а не висит полминуты.

Backend запускался одним процессом uvicorn: любой блокирующий вызов
останавливал и параллельные запросы, и WS-чат всех участников. Добавлен
UVICORN_WORKERS с дефолтом 2 — не по числу ядер, потому что на
четырёхъядерном сервере ядра делятся с LiveKit, а медиа важнее API.

Бюджет соединений считается на весь инстанс: каждый воркер держит свой
пул, поэтому UVICORN_WORKERS × (DB_POOL_SIZE + DB_MAX_OVERFLOW) должно
оставаться заметно ниже max_connections у Postgres.

Многопроцессность безопасна: бутстрап настроек в lifespan идемпотентен
(INSERT ... ON CONFLICT DO NOTHING), а WS-чат разносит сообщения через
Redis pub/sub и состояния в памяти процесса не держит.
This commit is contained in:
2026-07-28 19:00:05 +03:00
parent 32949ebc66
commit 6d65b620fe
5 changed files with 66 additions and 4 deletions

View File

@@ -81,6 +81,20 @@ LIVEKIT_NODE_IP=127.0.0.1
# с точкой монтирования тома в обоих сервисах. # с точкой монтирования тома в обоих сервисах.
RECORDINGS_DIR=/recordings RECORDINGS_DIR=/recordings
# --- Производительность backend ---
# Число процессов uvicorn. Один процесс означает, что любой блокирующий вызов
# в обработчике останавливает весь event loop: параллельные входы в конференцию
# и WS-чат всех участников встают в очередь. Дефолт 2 рассчитан на 4-ядерный
# сервер, где ядра делятся с LiveKit (медиа важнее API).
UVICORN_WORKERS=2
# Пул соединений с БД НА КАЖДЫЙ воркер. Общий расход инстанса —
# UVICORN_WORKERS × (DB_POOL_SIZE + DB_MAX_OVERFLOW), плюс соединения Celery
# и alembic. Держите сумму заметно ниже max_connections у Postgres (по
# умолчанию 100), иначе вместо понятной ошибки приложения получите отказ БД.
DB_POOL_SIZE=10
DB_MAX_OVERFLOW=10
DB_POOL_TIMEOUT=10
# --- Email (рассылка саммари + .ics-приглашения) --- # --- Email (рассылка саммари + .ics-приглашения) ---
# `console` — дефолт для dev (письмо только логируется, ссылка подтверждения # `console` — дефолт для dev (письмо только логируется, ссылка подтверждения
# email берётся из логов); `smtp` — реальная отправка через aiosmtplib. # email берётся из логов); `smtp` — реальная отправка через aiosmtplib.
@@ -98,7 +112,7 @@ SMTP_TIMEOUT_S=30
# --- Версия инстанса (релиз v0.0.1) --- # --- Версия инстанса (релиз v0.0.1) ---
# install.sh копирует значение из корневого файла VERSION при каждой # install.sh копирует значение из корневого файла VERSION при каждой
# установке/обновлении — руками менять не нужно. # установке/обновлении — руками менять не нужно.
VIDCONF_VERSION=0.0.11 VIDCONF_VERSION=0.0.12
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного # --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг, # `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,

View File

@@ -37,4 +37,13 @@ EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=5s --retries=10 --start-period=15s \ HEALTHCHECK --interval=10s --timeout=5s --retries=10 --start-period=15s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" || exit 1 CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" || exit 1
CMD ["uv", "run", "uvicorn", "main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"] # Число воркеров — из окружения (`UVICORN_WORKERS`, см. docker-compose.yml).
# Один процесс означает, что любой блокирующий вызов в обработчике
# останавливает весь event loop: параллельные запросы и WS-чат всех
# участников встают в очередь. Дефолт 2, а не «по числу ядер»: медиа
# важнее API, и на 4-ядерном сервере LiveKit в пике забирает 1.6 ядра.
#
# `sh -c` нужен ради подстановки переменной (exec-форма её не делает),
# `exec` — чтобы uvicorn получил PID 1 и корректно принимал SIGTERM.
CMD ["sh", "-c", \
"exec uv run uvicorn main:create_app --factory --host 0.0.0.0 --port 8000 --workers ${UVICORN_WORKERS:-2}"]

View File

@@ -20,6 +20,24 @@ class Settings(BaseSettings):
redis_url: str = "redis://localhost:6379/0" redis_url: str = "redis://localhost:6379/0"
plugins_config_path: str = "../config/plugins.yaml" plugins_config_path: str = "../config/plugins.yaml"
# --- Пул соединений с БД ---
# Дефолт SQLAlchemy (5 + 10) на нагрузочном тесте 28.07.2026 выгребался
# за секунды: 226 ошибок `QueuePool limit of size 5 overflow 10 reached`
# и 37 ответов 500 на путях входа в конференцию.
#
# ⚠️ Бюджет соединений считается на ВЕСЬ инстанс, а не на процесс: каждый
# воркер uvicorn (`UVICORN_WORKERS`) держит собственный пул, плюс
# соединения нужны Celery-воркерам и alembic при миграциях. При
# `max_connections=100` у Postgres и двух воркерах 2 × (10 + 10) = 40
# оставляет запас. Поднимая значения на более крупном сервере, поднимайте
# и `max_connections` — иначе вместо понятной ошибки приложения получите
# отказ Postgres, который диагностируется куда хуже.
db_pool_size: int = 10
db_max_overflow: int = 10
# 10 секунд вместо дефолтных 30 — сознательно: пусть запрос падает быстро
# и показывает проблему, а не висит полминуты, делая вид, что всё живо.
db_pool_timeout: int = 10
# --- Версия инстанса (релиз v0.0.1) --- # --- Версия инстанса (релиз v0.0.1) ---
# install.sh копирует значение из файла `VERSION` (корень репозитория) в # install.sh копирует значение из файла `VERSION` (корень репозитория) в
# `.env` при каждой установке/обновлении — здесь только чтение готового # `.env` при каждой установке/обновлении — здесь только чтение готового
@@ -59,6 +77,13 @@ class Settings(BaseSettings):
# (см. `deploy/docker-compose.yml`); в тестах переопределяется на `tmp_path`. # (см. `deploy/docker-compose.yml`); в тестах переопределяется на `tmp_path`.
recordings_dir: str = "/recordings" recordings_dir: str = "/recordings"
# Таймаут запуска Track Egress. Когда egress-сервиса в деплое нет (профиль
# `transcribe` не поднят), LiveKit ждёт ответа воркера через Redis до
# собственного таймаута psrpc — на тесте 28.07.2026 это давало по 2025
# секунд на каждый вызов. Ждать столько бессмысленно: если egress жив, он
# отвечает за доли секунды.
egress_start_timeout_s: float = 3.0
# --- Email (SMTP-бэкенд) --- # --- Email (SMTP-бэкенд) ---
# `console` — дефолт для dev (письмо только логируется); `smtp` — реальная # `console` — дефолт для dev (письмо только логируется); `smtp` — реальная
# отправка через aiosmtplib. Секреты SMTP — только в `.env` (инвариант №6), # отправка через aiosmtplib. Секреты SMTP — только в `.env` (инвариант №6),

View File

@@ -13,7 +13,16 @@ from core.config import get_settings
settings = get_settings() settings = get_settings()
engine: AsyncEngine = create_async_engine(settings.database_url, pool_pre_ping=True) engine: AsyncEngine = create_async_engine(
settings.database_url,
pool_pre_ping=True,
# Параметры пула — в настройках (`core/config.py`, там же расчёт бюджета
# соединений на инстанс). Дефолт SQLAlchemy 5 + 10 под нагрузкой
# выгребался за секунды.
pool_size=settings.db_pool_size,
max_overflow=settings.db_max_overflow,
pool_timeout=settings.db_pool_timeout,
)
async_session_maker = async_sessionmaker(engine, expire_on_commit=False) async_session_maker = async_sessionmaker(engine, expire_on_commit=False)

View File

@@ -82,7 +82,12 @@ services:
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media} MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение # Версия инстанса (релиз v0.0.1) — install.sh копирует значение
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health. # из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.11} VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.12}
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
# проверьте бюджет соединений с БД: каждый воркер держит свой пул
# (DB_POOL_SIZE + DB_MAX_OVERFLOW), а у Postgres есть max_connections.
UVICORN_WORKERS: ${UVICORN_WORKERS:-2}
# config/ лежит в корне репозитория и не попадает в образ (контекст сборки — # config/ лежит в корне репозитория и не попадает в образ (контекст сборки —
# только backend/), поэтому plugins.yaml монтируется отдельно. # только backend/), поэтому plugins.yaml монтируется отдельно.
volumes: volumes: