Django mặc định mở connection mới cho mỗi request rồi đóng (CONN_MAX_AGE = 0).
Mỗi connection Postgres tốn handshake TLS + auth + allocate session — vài chục ms cho mỗi request, và khi worker nhiều thì DB nhanh chóng ngộp.
# settings.py — giữ connection sống giữa các request
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
# ...
'CONN_MAX_AGE': 60, # giây; None = persistent
'CONN_HEALTH_CHECKS': True, # 4.1+: ping trước khi reuse
}
}Nhưng CONN_MAX_AGE không giải được 2 vấn đề lớn hơn. Một là quá nhiều worker: 10 server × 8 Gunicorn worker × 4 thread = 320 connection liên tục, vượt Postgres max_connections mặc định (100). Hai là connection idle giữa các spike traffic, Postgres giữ memory cho chúng dù không dùng tới.
Giải pháp là đặt PgBouncer giữa app và Postgres (transaction pooling mode):
Django (320 conn logical) → PgBouncer (pool 20 conn thật) → PostgresPgBouncer ghép nhiều connection logic sang ít connection thực và reuse cực nhanh. Đây là production essential cho mọi Django app từ 1 server trở lên — và managed Postgres như Neon hay Supabase thực ra đã có sẵn pooler tương đương PgBouncer.
PgBouncer ở transaction mode không hỗ trợ session feature (LISTEN/NOTIFY, prepared statement, advisory lock). Với psycopg3, set DISABLE_SERVER_SIDE_CURSORS = True để tránh lỗi "cursor does not exist". Và khi đã có pooler bên ngoài, thường set CONN_MAX_AGE = 0 ở Django để mỗi request mượn từ pool rồi trả lại ngay — không cần giữ connection.
By default Django opens a new connection per request and closes it (CONN_MAX_AGE = 0).
Each Postgres connection = TLS handshake + auth + session allocation — wasted tens of milliseconds, and many workers can drown the DB.
# settings.py — keep the connection alive between requests
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
# ...
'CONN_MAX_AGE': 60, # seconds; None = persistent
'CONN_HEALTH_CHECKS': True, # 4.1+: ping before reuse
}
}Two problems CONN_MAX_AGE does not solve:
1. Too many workers (e.g. 10 servers × 8 Gunicorn workers × 4 threads = 320 persistent connections) → exceeds Postgres max_connections (default 100).
2. Idle connections between spikes → Postgres holds memory it does not need.
→ Put PgBouncer between app and Postgres (transaction pooling mode):
Django (320 logical conn) → PgBouncer (pool 20 real conn) → PostgresPgBouncer multiplexes many logical connections onto fewer real ones + reuses them fast. It is a production essential for any Django app spanning more than one server (managed Postgres like Neon/Supabase ship with a PgBouncer-like pooler built in).
Pitfall: PgBouncer in transaction mode does not support session features (LISTEN/NOTIFY, prepared statements, advisory locks). With psycopg3, set DISABLE_SERVER_SIDE_CURSORS = True to avoid "cursor does not exist" errors. When there is already an external pooler (managed Postgres), it is common to set CONN_MAX_AGE = 0 in Django so each request borrows from the pool and returns it quickly.