Connection pool sizing: vì sao tăng pool size lại làm app chậm hơn — hoatq.dev

cat blog/.md

Connection pool sizing: vì sao tăng pool size lại làm app chậm hơn

date: tags: backend, database, postgresql, performance, pgbouncer

Có một sự cố tôi nhớ mãi vì nó trái với trực giác hoàn toàn. API checkout của hệ thống đang chậm, p95 lên 800ms. Pool size đang để 20. Một bạn senior đề nghị: “Tăng pool lên 200 đi, app đang chờ connection đấy”. Deploy xong, p95 vọt lên 1.5s. Tăng tiếp lên 400 — p95 thành 2.2s. Càng tăng càng chậm.

Cuối cùng giảm pool size xuống 10 thì p95 về 180ms.

Đó là lần đầu tôi hiểu rằng connection pool không phải càng to càng tốt. Bài này là những gì tôi đã học sau lần đó.

Connection pool đang giấu cái gì

Khi app gọi session.execute(...), có vài thứ xảy ra:

  1. Lấy một connection từ pool (chờ nếu hết)
  2. Gửi query qua TCP đến PostgreSQL
  3. PostgreSQL fork một backend process xử lý query đó
  4. Backend chạy query, trả kết quả
  5. Connection được trả về pool

Mấu chốt ở bước 3: mỗi connection PostgreSQL = một process riêng trên server DB. Mỗi process chiếm khoảng 5-10MB RAM, một share của work_mem, và quan trọng nhất — cạnh tranh CPU với tất cả backend khác.

Nếu DB server có 8 CPU core, mà bạn có 400 backend process cùng muốn chạy, thì:

  • Mỗi query bị context switch liên tục
  • L1/L2 cache CPU bị invalidate
  • Lock contention tăng (cùng đụng vào page table, WAL buffer, shared buffer)
  • Disk I/O bị queued
  • Latency từng query tăng → connection giữ lâu hơn → pool càng bị siết → app tăng pool tiếp → vòng lặp tử thần

Đây gọi là thundering herd ở tầng DB. App nghĩ “thiếu connection, tăng pool”, còn DB thì đang ngộp.

Công thức huyền thoại của HikariCP

Brett Wooldridge (tác giả HikariCP — connection pool nổi tiếng của Java) có một bài viết mà tôi nghĩ mọi backend dev nên đọc một lần. Công thức của ông:

pool_size = ((core_count * 2) + effective_spindle_count)

Với SSD/NVMe (gần như mọi server hiện đại), effective_spindle_count coi như 1. Vậy với DB server 8 core:

pool_size = (8 * 2) + 1 = 17

Mười bảy. Không phải hai trăm.

Lý do: khi một query đang chờ I/O (đọc disk, network), CPU có thể switch sang query khác. Hệ số * 2 cover overhead này. Vượt quá ngưỡng đó, context switch tốn nhiều hơn lợi.

Đây là công thức per database server, không phải per app instance. Quan trọng cực kỳ — sẽ nói tiếp dưới.

Little’s Law — góc nhìn từ queue theory

Một cách nhìn khác từ Little’s Law:

concurrent_requests = throughput * latency

Hay viết ngược lại — số connection cần thiết:

pool_size = peak_qps * avg_query_time

Ví dụ: peak 500 query/s, mỗi query trung bình 20ms (0.02s):

pool_size = 500 * 0.02 = 10

Mười là đủ. Tăng lên 100 không giúp gì — nó chỉ cho phép chờ trong pool app thay vì chờ trong queue DB, mà chờ trong queue DB còn tệ hơn vì có lock và context switch.

Trường hợp nhiều app instance — bài toán thật sự

Lý thuyết trên áp dụng cho một app. Nhưng production thường có 10-20 instance behind load balancer. Mỗi instance giữ pool riêng:

total_db_connections = num_instances * pool_size_per_instance

20 instance, mỗi cái pool 10, tổng là 200 connection vào DB. Nếu DB chỉ có 8 core, đã vượt ngưỡng core * 2 + 1 = 17 rất xa.

Đây là lúc PgBouncer vào.

PgBouncer ngồi giữa app và Postgres, làm “connection multiplexer”:

[20 app instances]  →  [PgBouncer: 200 client conn]  →  [Postgres: 17 server conn]
   (pool 10 each)        (transaction pooling)             (đúng theo công thức)

App nghĩ là nó có 200 connection. PostgreSQL chỉ thấy 17. PgBouncer ghép request từ nhiều client lên cùng một server connection (mode transaction hoặc statement).

Config mẫu PgBouncer:

[databases]
mydb = host=postgres dbname=mydb

[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 17
reserve_pool_size = 5
reserve_pool_timeout = 3

default_pool_size là số connection thực sự đến Postgres. max_client_conn là số connection app có thể mở tới PgBouncer.

Tune cho FastAPI + SQLAlchemy

Stack tôi hay dùng (FastAPI + async SQLAlchemy + asyncpg + PgBouncer):

from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import NullPool

# Trỏ tới PgBouncer, không phải Postgres trực tiếp
engine = create_async_engine(
    "postgresql+asyncpg://user:pass@pgbouncer:6432/mydb",
    pool_size=10,
    max_overflow=5,
    pool_pre_ping=True,
    pool_recycle=3600,
)

Vài thứ quan trọng:

  • pool_size=10 — số connection cố định mỗi instance giữ tới PgBouncer. Với 20 instance là 200 client conn, PgBouncer multiplex xuống còn 17 vào Postgres.
  • max_overflow=5 — burst tối đa thêm 5 conn khi peak.
  • pool_pre_ping=True — kiểm tra conn còn sống trước khi dùng (tránh dùng conn đã bị PgBouncer đóng).
  • pool_recycle=3600 — đóng và mở lại conn sau 1h, tránh stale.

Lưu ý: nếu PgBouncer dùng pool_mode=transaction, đừng dùng prepared statement SQLAlchemy hoặc Postgres-specific session state (advisory lock cross-transaction, SET LOCAL…). Transaction pooling không guarantee cùng server connection giữa các transaction.

Cách đo và biết bạn đang sizing sai

Vài dấu hiệu sai:

Pool quá nhỏ:

  • App log có QueuePool limit of size X overflow Y reached
  • Latency dao động mạnh, p99 cao bất thường
  • pg_stat_activity luôn dưới mức pool_size

Pool quá lớn:

  • pg_stat_activity có nhiều conn idle in transaction
  • CPU DB cao nhưng query đơn lẻ vẫn nhanh khi test riêng
  • Tăng pool → latency tăng
  • pg_locks nhiều, wait_event_type = Lock thường xuyên

Query monitor hữu ích:

-- Số connection theo state
SELECT state, count(*)
FROM pg_stat_activity
WHERE datname = 'mydb'
GROUP BY state;

-- Query đang bị wait
SELECT pid, wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE wait_event IS NOT NULL
  AND datname = 'mydb';

Nếu nhiều conn idle in transaction — app đang BEGIN nhưng quên COMMIT/ROLLBACK, đó là bug logic, không phải vấn đề pool.

Một số sai lầm hay gặp

1. “Tăng pool size để fix timeout”

Nếu query timeout vì DB chậm, tăng pool chỉ làm DB chậm hơn. Đi tìm query nào chậm trước (xem bài về index và query plan).

2. “Mỗi service một pool riêng, không cần PgBouncer”

Đúng cho hệ thống nhỏ. Khi scale ra 10+ instance, PgBouncer cứu cả Postgres lẫn ví bạn (RAM DB rẻ hơn nhiều khi không phải fork 500 process).

3. “Pool size lớn thì không bao giờ phải chờ”

Sai. Pool to nghĩa là chờ ở DB thay vì chờ ở app — và chờ ở DB tệ hơn vì có lock, context switch, và bạn không control được.

4. “Set pool_size = max_connections của Postgres

Cực kỳ tệ. max_connections chỉ là hard limit, không phải mục tiêu. Mục tiêu là core * 2 + 1.

5. “Dùng session pooling thay vì transaction pooling cho an toàn”

Session pooling gần như mất hết lợi ích của PgBouncer. Hãy dùng transaction pooling và sửa code cho phù hợp — sẽ ép bạn viết code stateless hơn, vốn là điều tốt.

Quy trình tôi hay làm khi tune

  1. Đo peak_qpsavg_query_time từ production metric (Prometheus, OpenTelemetry — xem bài về distributed tracing).
  2. Tính theo Little’s Law: pool_size = peak_qps * avg_query_time. Đó là per app instance.
  3. Đếm số instance: total = pool_size * num_instances. Nếu vượt core_db * 2, thêm PgBouncer.
  4. PgBouncer default_pool_size = core_db * 2 + 1.
  5. Load test với pool size đã chọn. Tăng/giảm 20% xem latency thay đổi thế nào.
  6. Monitor pg_stat_activity ở production tuần đầu sau khi đổi.

Đọc thêm

Nếu chỉ đọc một thứ về pool sizing, đọc bài “About Pool Sizing” của HikariCP. Ngắn, có benchmark thật, và sẽ thay đổi cách bạn nghĩ về connection pool. Tôi đọc đi đọc lại vài lần.

Còn nếu vẫn không tin “ít hơn lại nhanh hơn”, cứ thử một lần ở staging. Đặt pool size về 10. Chạy load test. Nhìn p95 giảm. Lúc đó bạn sẽ tin.

// reactions


cat comments.log


hoatq@dev : ~/blog $