Latency P99 của API tăng từ 200ms lên 3 giây. Query DB vẫn nhanh (5ms), Redis vẫn nhanh (2ms), nhưng thời gian “chờ lấy connection” tăng vọt. Pool Postgres có max 10 connection, service chạy 20 pod, mỗi pod giữ 10 connection → 200 connection đến Postgres, vượt quá max_connections=100. Một nửa pod chờ connection từ pool, pool chờ Postgres trả connection, Postgres từ chối vì quá limit. Cascade chờ — latency nổ tung.

Connection pool là thứ mà hầu hết framework đã setup sẵn — dev truyền connection string rồi quên. Nhưng khi hệ thống scale (thêm pod, thêm service, thêm traffic), pool config mặc định thường không đủ. Hiểu cách pool hoạt động, biết tune đúng, và nhận diện symptom pool exhaustion là kỹ năng thiết yếu để tránh loại incident “mọi thứ đều nhanh nhưng API lại chậm”.


Connection pool hoạt động thế nào

Mở một TCP connection đến database không miễn phí. Với PostgreSQL, mỗi connection tạo một process mới trên server (fork), tốn vài MB RAM và vài millisecond. TLS handshake thêm 1-2 round trip. Authentication thêm một round trip nữa. Tổng cộng, mở connection mới có thể mất 5-50ms tuỳ network và config — nếu mỗi query đều mở connection mới rồi đóng, overhead này chiếm phần lớn thời gian xử lý request.

Connection pool giải quyết vấn đề này bằng cách giữ sẵn một tập connection đã mở. Khi code cần query, nó “mượn” connection từ pool (gần như instant), dùng xong trả lại pool. Connection không bị đóng — nó nằm đó chờ request tiếp theo mượn. Chi phí mở connection chỉ trả một lần khi pool khởi tạo, không phải mỗi request.

Pool quản lý vòng đời connection qua vài tham số cốt lõi.

min (minimum idle) — số connection giữ sẵn ngay cả khi không có request. Khi pool khởi tạo, nó mở sẵn min connection. Nếu traffic thấp và connection idle quá lâu, pool giữ lại ít nhất min connection thay vì đóng hết. Giá trị này đảm bảo request đầu tiên sau idle period không phải chờ mở connection mới.

max (maximum pool size) — tổng số connection tối đa mà pool mở. Khi tất cả connection đang bận và có request mới, pool sẽ mở thêm connection — nhưng không vượt quá max. Nếu đã đạt max mà vẫn có request chờ, request sẽ đợi trong hàng đợi cho đến khi có connection trả về, hoặc timeout.

idle timeout — connection không được dùng trong bao lâu thì bị đóng. Pool sẽ thu nhỏ về min connection khi traffic giảm, giải phóng resource trên cả client và server.

connection timeout (checkout timeout) — request chờ lấy connection từ pool tối đa bao lâu trước khi nhận lỗi. Nếu pool cạn connection và request chờ quá lâu, tốt hơn là trả lỗi nhanh (fail fast) để user biết, thay vì chờ vô hạn.


Pool exhaustion — symptom và nguyên nhân

Pool exhaustion xảy ra khi tất cả connection trong pool đang bận, request mới phải chờ hoặc bị reject. Đây là vấn đề phổ biến nhất liên quan đến connection pool, và nó thường không được phát hiện cho đến khi traffic tăng.

Symptom

Latency tăng đột ngột nhưng database query vẫn nhanh — thời gian chờ nằm ở “acquire connection from pool”, không phải ở “execute query”. Nếu bạn measure thời gian từ lúc gọi pool.query() đến lúc nhận result, nó bao gồm cả thời gian chờ pool + thời gian query. Tách hai metric này ra sẽ thấy ngay vấn đề nằm ở đâu.

Error log ghi "connection pool exhausted", "timeout waiting for connection", hoặc "too many connections" (từ database server khi tổng connection vượt limit).

Nguyên nhân phổ biến

Connection leak. Code mượn connection từ pool nhưng không trả lại — quên gọi connection.release() hoặc connection.close(). Với ORM thì thường là quên await hoặc exception xảy ra trước khi connection được trả. Pool dần hết connection vì chúng bị “mất” — không ai dùng nhưng pool nghĩ vẫn đang bận.

Mình từng debug connection leak trong Node.js service dùng pg driver: một middleware bắt lỗi nhưng không release connection khi lỗi xảy ra. Mỗi request lỗi “ăn” mất một connection. Sau vài trăm lỗi, pool cạn. Fix bằng try/finally — always release trong finally block, hoặc dùng pool.query() (tự release) thay vì pool.connect() + manual release.

// Leak-prone
const client = await pool.connect();
const result = await client.query('SELECT ...');
// Nếu query throw error, client.release() không được gọi → leak
client.release();

// Safe
const client = await pool.connect();
try {
  const result = await client.query('SELECT ...');
  return result;
} finally {
  client.release(); // Luôn release dù success hay error
}

// Safest — pool tự manage
const result = await pool.query('SELECT ...'); // Tự acquire + release

Slow query giữ connection lâu. Một query chạy 10 giây giữ connection đó 10 giây. Nếu pool có 10 connection và 10 slow query đồng thời, pool cạn. Request tiếp theo chờ cho đến khi slow query xong.

Scale pod không tính pool size. Đây là nguyên nhân mở đầu bài — mỗi pod có pool 10 connection, scale từ 5 pod lên 20 pod = từ 50 lên 200 connection đến database. Nếu database chỉ chấp nhận 100 connection, 100 connection bị reject.


Sizing pool — heuristic thực tế

Không có công thức “đúng” cho pool size vì nó phụ thuộc vào workload, latency query, và khả năng database. Nhưng có vài heuristic giúp bắt đầu.

Bao nhiêu connection là đủ cho một pod?

Quy tắc đơn giản: pool size ≈ số thread/worker xử lý request đồng thời. Nếu Node.js app xử lý 20 request đồng thời (event loop + async), pool 20-25 connection là hợp lý — mỗi request concurrent có thể cần một connection. Nếu Go app có 100 goroutine xử lý request, pool cần lớn hơn — nhưng thường không cần 100 vì không phải request nào cũng query DB.

HikariCP (Java) recommend: pool size = số thread cần DB access đồng thời + vài connection dự phòng. Formula tham khảo từ PostgreSQL wiki: connections = (core_count * 2) + effective_spindle_count. Với SSD thì spindle count = 1, server 4 core → pool 9 connection. Nhỏ hơn nhiều người nghĩ.

Tổng connection trên database

Đây là ràng buộc quan trọng hơn pool size từng pod. PostgreSQL mặc định max_connections = 100. Mỗi connection tốn ~5-10 MB RAM trên server. Tăng max_connections lên 500 nghĩa là Postgres cần thêm 2-5 GB RAM chỉ cho connection overhead, chưa kể shared buffers và work mem.

Tổng connection = pool_size_per_pod × số_pod. Phải nhỏ hơn max_connections của database. Nếu bạn có 20 pod × 10 connection = 200, mà Postgres chỉ cho 100 → vấn đề.

Hai cách giải quyết. Cách thứ nhất: giảm pool size per pod — nếu giảm từ 10 xuống 4, 20 pod × 4 = 80, vừa trong limit 100. Cách thứ hai: dùng connection pooler bên ngoài.


PgBouncer và ProxySQL — pooler bên ngoài

Khi số pod lớn hoặc nhiều service cùng kết nối database, connection pooler bên ngoài giúp multiplexing — nhiều client connection map vào ít server connection.

PgBouncer là pooler phổ biến nhất cho PostgreSQL. Nó ngồi giữa app và Postgres, nhận connection từ app (hàng trăm), dùng pool nhỏ (vài chục) connection thật tới Postgres. App nghĩ mình đang kết nối Postgres, thực tế kết nối PgBouncer.

PgBouncer có ba mode: session (một client connection = một server connection suốt session — không tiết kiệm gì), transaction (server connection chỉ bị giữ trong transaction, giữa các transaction thì release — tiết kiệm nhất), statement (release sau mỗi statement — không dùng được nếu có multi-statement transaction).

Mình dùng transaction mode cho hầu hết use case. 200 app connection qua PgBouncer có thể chỉ cần 20 server connection tới Postgres — giảm 10 lần load trên Postgres.

Cẩn thận với transaction mode: prepared statement, SET command, advisory lock, và LISTEN/NOTIFY không hoạt động đúng vì server connection có thể thay đổi giữa các transaction. Nếu dùng ORM có prepared statement mặc định, cần disable hoặc chuyển sang session mode.

ProxySQL làm tương tự cho MySQL — connection multiplexing, query routing (read/write split), query caching. Nếu dùng MySQL hoặc MariaDB với nhiều service kết nối, ProxySQL là lựa chọn phổ biến.


Pool cho HTTP client và Redis

Connection pool không chỉ cho database — HTTP client và Redis client cũng cần pool.

HTTP connection pool

HTTP/1.1 keep-alive cho phép reuse TCP connection cho nhiều request đến cùng host. HTTP client mặc định giữ pool connection cho mỗi host — Node.js http.Agent giữ tối đa maxSockets connection per host (mặc định Infinity trong Node 19+, trước đó là 5), Go http.TransportMaxIdleConnsPerHost (mặc định 2 — thường quá nhỏ).

Nếu service gọi API bên ngoài với QPS cao mà pool HTTP quá nhỏ, mỗi request phải mở TCP connection mới → TLS handshake mỗi lần → latency tăng 50-100ms. Tăng MaxIdleConnsPerHost (Go) hoặc maxSockets (Node.js) lên giá trị phù hợp — 50-100 cho high-throughput service.

Redis connection pool

Redis đơn giản hơn vì Redis single-threaded xử lý command rất nhanh — một connection có thể xử lý hàng chục nghìn command/giây nhờ pipelining. Nhiều Redis client chỉ dùng 1-2 connection và vẫn đủ hiệu năng.

Nhưng nếu bạn dùng blocking command (BRPOP, BLPOP, SUBSCRIBE) thì connection đó bị giữ cho đến khi có data. Cần connection riêng cho blocking command, không dùng chung pool với command thường.


Pool trong Kubernetes — bẫy khi scale

Kubernetes autoscaler tạo thêm pod khi load tăng. Mỗi pod mới mở pool connection đến database. Nếu không tính trước, autoscaler vô tình DDoS database bằng connection.

Tính toán trước

Nếu HPA set max 30 pod, mỗi pod pool 10 connection, thì max = 300 connection tới database. Postgres max_connections phải ≥ 300 (cộng thêm buffer cho superuser connection, monitoring). Hoặc dùng PgBouncer ở giữa để giới hạn server connection.

Connection storm khi restart

Khi nhiều pod restart đồng thời (rolling deployment), tất cả cùng lúc mở pool connection mới. Database bị spike connection request — có thể timeout hoặc reject. Giải pháp: stagger deployment (không restart tất cả cùng lúc), connection pool với exponential backoff khi mở connection fail, hoặc PgBouncer buffer connection request.

Readiness probe và pool

Readiness probe nên check “pool có connection sẵn sàng không” — nếu pool chưa kết nối được database, pod chưa nên nhận traffic. Mình thường implement readiness check bằng SELECT 1 qua pool — nếu timeout thì pod not ready.


Monitor connection pool

Metric pool cần monitor — phần lớn pool library đều expose metric này nếu bạn bật:

active connections — số connection đang được dùng (đang chạy query). Gần max liên tục = pool quá nhỏ hoặc query quá chậm.

idle connections — số connection rảnh, chờ request. Nhiều idle connection = pool quá lớn, tốn resource trên database server.

waiting requests — số request đang chờ lấy connection từ pool. Lớn hơn 0 nghĩa là pool đang exhausted — request đang bị delay. Alert khi waiting > 0 liên tục.

connection acquire time — thời gian chờ lấy connection. Nếu tăng đột ngột, pool đang cạn.

connection create time — thời gian mở connection mới. Nếu tăng, có thể network issue hoặc database overloaded.

Mình set alert: waiting requests > 0 trong 1 phút, connection acquire time P99 > 100ms, active connections > 80% max liên tục 5 phút.


Anti-pattern hay gặp

Mở connection mỗi request, không dùng pool. Mỗi request mở TCP connection mới, TLS handshake, auth, query, rồi đóng. Latency thêm 10-50ms mỗi request. Database bị storm connection. Luôn dùng pool — mọi database driver và ORM đều hỗ trợ sẵn.

Pool size quá lớn. “Để max 200 cho chắc” — nhưng 200 connection đến Postgres nghĩa là 200 backend process, tốn 1-2 GB RAM chỉ cho connection. Postgres performance thường giảm khi connection > 200-300 vì context switching giữa process. Pool size nhỏ + PgBouncer tốt hơn pool size lớn.

Không set connection timeout. Request chờ connection vĩnh viễn khi pool cạn — user thấy request “treo”. Set checkout timeout 3-5 giây, trả lỗi 503 nếu không lấy được connection. Fail fast tốt hơn chờ vô hạn.

Không validate connection trước khi dùng. Connection trong pool có thể bị database đóng (idle timeout phía server, network issue, failover). Pool trả connection “chết” cho request, request fail. Cấu hình testOnBorrow (HikariCP), pool_pre_ping (SQLAlchemy), hoặc tương đương — pool kiểm tra connection còn sống trước khi trả cho caller.

Không tính tổng connection khi scale. Scale pod mà không tính lại tổng connection đến database. 10 pod × 20 pool = 200, tăng lên 50 pod = 1000 connection → database chết. Luôn tính: max_pods × pool_size_per_pod ≤ database_max_connections.


Tóm tắt

Connection pool giữ sẵn connection đã mở, tránh chi phí mở/đóng connection mỗi request. Hiểu min, max, idle timeout, và checkout timeout để tune đúng cho workload. Pool size nên tương xứng với concurrent request cần DB, không phải “càng lớn càng tốt”.

Pool exhaustion gây latency spike dù database vẫn nhanh — tách metric “acquire connection time” với “query time” để phát hiện nhanh. Connection leak (quên release) là nguyên nhân phổ biến nhất — dùng try/finally hoặc API tự release.

Trong Kubernetes, tổng connection = pool per pod × số pod. Phải nhỏ hơn database max_connections. PgBouncer (Postgres) hoặc ProxySQL (MySQL) cho phép multiplexing — nhiều app connection qua ít server connection, giảm áp lực lên database.

Monitor active connections, waiting requests, và acquire time. Alert khi waiting > 0 hoặc acquire time tăng — đó là dấu hiệu pool sắp cạn, cần giảm pool size per pod, optimize slow query, hoặc thêm connection pooler.


Tham khảo