Tưởng tượng ứng dụng của bạn đang chạy ngon trên production. Traffic tăng dần, mọi thứ ổn. Rồi một ngày đẹp trời, bạn nhận được alert: response time tăng vọt, một số request bắt đầu timeout. Bạn check log — không có slow query nào cả. Vậy vấn đề ở đâu?
Câu trả lời thường bị bỏ qua: cách app quản lý kết nối tới database.
Mỗi lần ứng dụng cần truy vấn database, nó phải thiết lập một connection — một kênh giao tiếp riêng giữa app và database server. Nếu bạn mở và đóng connection sau mỗi query, chi phí đó cộng dồn rất nhanh. Bài viết này sẽ giải thích tại sao, và cách Connection Pooling giúp bạn giải quyết vấn đề đó.
Database Connection là gì, và tại sao nó “đắt đỏ”?
Một database connection không chỉ đơn giản là một socket TCP.
Với PostgreSQL, mỗi lần client kết nối, database sẽ fork một OS process mới chuyên phục vụ client đó — gọi là backend process.
Mỗi backend process sẽ:
Cấp phát bộ nhớ riêng (thường 5–10 MB mỗi connection) Duy trì phiên xác thực (authentication session) Quản lý transaction state và buffer cache riêng Và để thiết lập được connection đó, phải trải qua một loạt bước:
Nếu một query chỉ mất 2ms để thực thi, nhưng bạn phải mở và đóng connection mỗi lần — chi phí thực tế có thể gấp 10–100 lần con số đó.
Vì một lần mở connection có thể tốn từ 1ms (cùng rack trong data center) đến 200ms (khác data center), thậm chí cả giây nếu cross-region.
Lưu ý:
PostgreSQL còn có giới hạn cứng về số connection đồng thời, mặc định thường là 100 (cấu hình qua max_connections). Vượt qua con số này, connection mới sẽ bị từ chối.
Connection Pooling là gì?
Thay vì mở–đóng connection mỗi lần query, Connection Pool duy trì sẵn một tập hợp các connection đã được thiết lập. Ứng dụng chỉ cần mượn connection từ pool, dùng xong trả lại — connection không bị đóng, mà được reset và chờ request tiếp theo.
Vòng đời của một pool trông như thế này:
Khởi tạo — Khi app start, pool mở sẵn một số connection tối thiểu (ví dụ: 3–5 connections). Mượn — Request đến, pool cấp một connection đang rảnh. Dùng — Request thực thi query trên connection đó. Trả lại — Xong việc, connection được trả về pool (không đóng). Health check — Pool định kỳ kiểm tra các connection còn sống không, thay thế cái nào đã chết. Nên nhớ, Idle connection không miễn phí, mỗi connection dù đang rảnh vẫn chiếm 10–30MB RAM để duy trì trạng thái mở. Đó là lý do tại sao không nên đặt min hoặc max quá cao một cách tùy tiện — bạn đang đánh đổi bộ nhớ để đổi lấy tốc độ.
Với Node.js và PostgreSQL, bạn dùng pg.Pool như sau:
Lưu ý: pool.query() tự động mượn và trả connection — bạn không cần gọi connect() và release() thủ công trừ khi cần chạy nhiều query trong cùng một transaction.
Điều gì xảy ra khi pool hết connection?
Khi tất cả connection đang bận và một request mới đến, nó sẽ được đưa vào hàng đợi. Nếu chờ quá connectionTimeoutMillis, pool sẽ throw error.
Đây là hành vi đúng — fail nhanh còn hơn để queue phình to không kiểm soát, dẫn đến memory exhaustion.
Nếu pool liên tục bị đầy, có hai nguyên nhân chính:
Pool size quá nhỏ so với lượng request đồng thời. Query quá chậm, giữ connection quá lâu. Và điều ngược lại: tăng pool size một cách mù quáng không giải quyết được vấn đề, đôi khi còn làm mọi thứ tệ hơn.
Little's Law — toán học đằng sau connection pool
Connection pooling không chỉ là một software pattern — nó có nền tảng toán học từ lý thuyết hàng đợi, gọi là Little's Law:
Trong đó:
L = số connection cần có tại một thời điểm (= requests being served + waiting) λ = số request đến mỗi giây (requests per second) W = thời gian mỗi request giữ connection (giây) Ví dụ: App nhận 50 req/s, mỗi request giữ connection 20ms (0.02s):
Chỉ cần 1 connection tại bất kỳ thời điểm nào!
Nhưng nếu một slow query kéo thời gian lên 200ms:
Đột nhiên cần gấp 10 lần. Đây là insight quan trọng: slow query không chỉ làm user chờ lâu hơn — nó trực tiếp đẩy nhu cầu connection lên cao, có thể làm cạn kiệt pool.
Từ "cần bao nhiêu connection" suy ra "pool hiện tại chịu được bao nhiêu request":
Ví dụ: Pool có 10 connections, mỗi query mất 10ms (0.01s):
Vượt quá 1000 req/s, request bắt đầu xếp hàng chờ. Đây cũng là lý do tối ưu query (giảm avg_service_time) hiệu quả hơn nhiều so với tăng pool size — giảm query từ 10ms xuống 5ms thì throughput tăng gấp đôi mà không tốn thêm connection nào.
Kingman's Formula — tại sao 100% utilisation nguy hiểm
Dù pool có đủ connection trung bình, bạn vẫn có thể thấy queue phình to và latency tăng vọt.
Kingman's Formula giải thích điều này: queue length tăng theo hàm mũ khi utilisation tiến gần 100%.
Ngưỡng an toàn thực tế: đừng để pool chạy quá 70–80% công suất.
Khi vượt ngưỡng đó, chỉ cần một đợt traffic nhỏ tăng đột biến cũng đủ tạo ra hàng đợi dài không tương xứng. Đây không phải đặc thù của database — CPU core, network bandwidth đều có cùng hành vi.
Vì vậy, khi thấy pool exhaustion, hành động đầu tiên không phải tăng max — mà là tìm và tối ưu query chậm trước.
Pool nên có bao nhiêu connection?
Đây là câu hỏi thực tế nhất khi cấu hình pool, và câu trả lời xuất phát từ cách database thực sự hoạt động.
Database chỉ có thể chạy song song nhiều nhất bằng số CPU core. Có nhiều connection hơn số core không giúp query nhanh hơn — ngược lại, OS phải liên tục context-switch giữa các process, làm tốn thêm tài nguyên.
Công thức thực tế hay được dùng:
Với SSD hoặc cloud database (RDS, Supabase…), coi số ổ đĩa là 1.
Ví dụ: Database server có 4 CPU core, SSD:
Nghe có vẻ ít, nhưng 9 connection đang chạy song song ở tốc độ CPU hoàn toàn đủ phục vụ hàng trăm user — vì phần lớn thời gian user đang chờ I/O, không phải chờ CPU.
Lưu ý thêm: Nếu bạn chạy nhiều instance của ứng dụng (scale horizontally), tổng connection = pool_size × số instance. Với 10 app instance, mỗi cái pool 20 connection → 200 connection tổng, dễ vượt quá max_connections của Postgres.
Best practices & những sai lầm hay gặp
✅ Nên làm:
Dùng connection pool ngay từ đầu, kể cả ứng dụng nhỏ. Cấu hình connectionTimeoutMillis để fail fast thay vì để request treo vô thời hạn. Tối ưu query chậm trước khi nghĩ đến tăng pool size. Theo dõi số connection đang active/idle để phát hiện bottleneck sớm. ❌ Không nên làm:
Tạo new Pool() trong từng request handler — sẽ tạo pool mới mỗi lần, mất hết ý nghĩa của pooling. Tăng max pool size lên rất cao (50, 100…) khi gặp timeout — đây thường là dấu hiệu của slow query, không phải thiếu connection. Giữ connection sau khi transaction kết thúc (quên gọi release() khi dùng thủ công). Chạy pool ở mức utilisation gần 100% — theo lý thuyết hàng đợi (Kingman’s Formula), queue sẽ tăng theo hàm mũ khi utilisation vượt ~70–80%. Tóm tắt
Connection pooling là một trong những thứ bạn nên thiết lập đúng ngay từ đầu.
Database connection là một OS process thực sự — Không phải object nhẹ. Mở một connection tốn 20–100ms và 5–10MB RAM. Luôn dùng connection pool trong production — Raw connection không scale được. Pool giúp tái sử dụng connection, phân bổ chi phí setup trên nhiều query. Pool size không phải "càng lớn càng tốt" — Công thức đúng là (cores × 2) + spindles. Nhiều connection hơn số core chỉ tạo thêm context-switch overhead. Little's Law liên kết tốc độ query với nhu cầu connection — Query chậm giữ connection lâu hơn, đồng nghĩa cần nhiều connection hơn. Tối ưu query trước. Không để pool chạy quá 70–80% công suất — Kingman's Formula chỉ ra rằng latency tăng theo hàm mũ khi vượt ngưỡng này. Chỉ một đợt traffic nhỏ tăng đột biến cũng đủ tạo queue dài. Tính tổng connection khi scale nhiều app instance — total_connections = pool_size × num_app_instances. Đảm bảo con số này không vượt max_connections của Postgres, hoặc dùng proxy như PgBouncer. Serverless thì phải dùng connection proxy — Function cold-start liên tục không được mở direct connection. Dùng PgBouncer, RDS Proxy, hoặc Supabase Pooler.