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
