Engineering Note

PgBouncer + Django/FastAPI/Flask — настройка, производительность и бенчмарк на 5000 запросов

Практическая статья про PgBouncer для PostgreSQL в связке с Django, FastAPI и Flask: режимы пуллинга, реальные эффекты на производительность, примеры конфигов и бенчмарк на 5000 запросов.

TL;DR

  • Режим по умолчанию для API: pool_mode = transaction
  • Django: CONN_MAX_AGE = 0
  • SQLAlchemy: часто NullPool, чтобы избежать двойного пула
  • Смотрите p95/p99, а не только среднюю задержку

1) Что такое PgBouncer и зачем он нужен

PgBouncer — это легковесный прокси-пуллер соединений для PostgreSQL. Приложения (Django/FastAPI/Flask) подключаются к PgBouncer, а он уже держит ограниченное количество реальных соединений к Postgres.

Он помогает, когда:

  • много воркеров и соединений;
  • на пиках появляются too many connections;
  • соединения создаются/закрываются слишком часто.

Важно: PgBouncer не ускоряет сам SQL-запрос. Он стабилизирует работу при высокой конкуренции за соединения.

2) Режимы пуллинга: session / transaction / statement

pool_mode = transaction обычно лучший выбор для API:

  • выше плотность пула;
  • лучше масштабирование на коротких транзакциях;
  • нужно избегать session-state зависимостей.

pool_mode = session:

  • более совместим с состоянием сессии;
  • обычно хуже по плотности пула под нагрузкой.

statement используется редко, часто конфликтует с многокомандными транзакциями.

3) Что ждать по производительности

Реальный эффект обычно такой:

  • меньше connect timeout и ошибок по соединениям;
  • ровнее p95/p99 на пиках;
  • выше throughput при большом concurrency.

Тестируйте на p95/p99 и при росте concurrency, а не только по average latency.

4) Пример конфигурации PgBouncer

[databases]
appdb = host=POSTGRES_HOST port=5432 dbname=appdb user=appuser password=apppass

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432

auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt

pool_mode = transaction

max_client_conn = 5000
default_pool_size = 50
min_pool_size = 10

reserve_pool_size = 20
reserve_pool_timeout = 5

server_idle_timeout = 30
server_lifetime = 3600

ignore_startup_parameters = extra_float_digits,search_path

log_connections = 0
log_disconnections = 0
stats_period = 60

Пример userlist.txt:

"appuser" "md5<md5passwordhash>"

Ключевые параметры:

  • max_client_conn — сколько клиентских соединений примет PgBouncer;
  • default_pool_size — размер реального пула к Postgres для пары user/db;
  • reserve_pool_* — запас на кратковременные пики.

5) Django + PgBouncer

Для pool_mode=transaction обычно ставят CONN_MAX_AGE = 0, чтобы пуллингом управлял PgBouncer.

# settings.py
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": "appdb",
        "USER": "appuser",
        "PASSWORD": "apppass",
        "HOST": "pgbouncer",
        "PORT": "6432",
        "CONN_MAX_AGE": 0,
        "OPTIONS": {
            "connect_timeout": 5,
        },
    }
}

Главное правило: транзакции должны быть короткими.

6) FastAPI + SQLAlchemy + PgBouncer

При pool_mode=transaction часто используют NullPool, чтобы не делать двойной пул (SQLAlchemy + PgBouncer).

from fastapi import FastAPI
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import NullPool

app = FastAPI()

DATABASE_URL = "postgresql+asyncpg://appuser:apppass@pgbouncer:6432/appdb"

engine = create_async_engine(
    DATABASE_URL,
    poolclass=NullPool,
    pool_pre_ping=True,
)

@app.get("/ping-db")
async def ping_db():
    async with engine.connect() as conn:
        await conn.execute(text("SELECT 1"))
    return {"ok": True}

Если используете session pooling, SQLAlchemy pool можно оставить, но ограничивайте его размер.

7) Flask + SQLAlchemy + PgBouncer

# config.py
SQLALCHEMY_DATABASE_URI = "postgresql+psycopg2://appuser:apppass@pgbouncer:6432/appdb"
SQLALCHEMY_ENGINE_OPTIONS = {
    "poolclass": __import__("sqlalchemy").pool.NullPool,
    "pool_pre_ping": True,
}

8) Бенчмарк: 5000 запросов с/без PgBouncer

Сравнение:

  • без PgBouncer: приложение ходит напрямую в Postgres:5432;
  • с PgBouncer: приложение ходит в PgBouncer:6432.

Измеряйте: RPS, avg, p50, p95, p99.

# bench_http.py
import asyncio
import time
import statistics
import httpx

TOTAL = 5000
CONCURRENCY = 200  # попробуйте 50 / 200 / 500
URL = "http://127.0.0.1:8000/ping-db"

async def worker(client: httpx.AsyncClient, n: int, latencies: list[float]):
    for _ in range(n):
        t0 = time.perf_counter()
        r = await client.get(URL, timeout=10.0)
        r.raise_for_status()
        latencies.append((time.perf_counter() - t0) * 1000.0)

async def main():
    per_worker = TOTAL // CONCURRENCY
    remainder = TOTAL % CONCURRENCY

    latencies: list[float] = []
    start = time.perf_counter()

    limits = httpx.Limits(
        max_connections=CONCURRENCY,
        max_keepalive_connections=CONCURRENCY
    )
    async with httpx.AsyncClient(limits=limits) as client:
        tasks = []
        for i in range(CONCURRENCY):
            n = per_worker + (1 if i < remainder else 0)
            tasks.append(asyncio.create_task(worker(client, n, latencies)))
        await asyncio.gather(*tasks)

    elapsed = time.perf_counter() - start
    rps = TOTAL / elapsed

    latencies.sort()
    p50 = latencies[int(0.50 * len(latencies))]
    p95 = latencies[int(0.95 * len(latencies))]
    p99 = latencies[int(0.99 * len(latencies))]
    avg = statistics.mean(latencies)

    print(f"TOTAL={TOTAL}  CONCURRENCY={CONCURRENCY}")
    print(f"Elapsed: {elapsed:.2f}s  RPS: {rps:.1f}")
    print(f"Latency ms: avg={avg:.2f}  p50={p50:.2f}  p95={p95:.2f}  p99={p99:.2f}")

if __name__ == "__main__":
    asyncio.run(main())
pip install httpx
python bench_http.py

Для честного сравнения меняйте только endpoint подключения к БД, остальные параметры нагрузки оставляйте одинаковыми.

9) Практические советы

  • Смотрите p95/p99, не только среднее.
  • Для pool_mode=transaction держите транзакции короткими.
  • Django: CONN_MAX_AGE = 0.
  • SQLAlchemy: часто NullPool.
  • Подбирайте default_pool_size так, чтобы Postgres оставался стабильным.

Быстрый checklist:

  • PgBouncer: SHOW POOLS;, SHOW STATS;
  • Postgres: pg_stat_activity, длительность транзакций, число коннектов
  • Нагрузочный тест: 50 -> 200 -> 500 concurrency, сравнение p95/p99