Connection pooling — Tại sao app chậm dù database khỏe re?

Phong Hy

Hôm đó production báo lỗi "too many connections" đỏ chót trên dashboard, còn mình thì ngồi mở database lên xem thử. Nhìn SHOW PROCESSLIST mà hết hồn — hơn 150 kết nối treo đó, trong khi service chỉ có tầm 20 request/giây. Database khỏe re, CPU chẳng vượt quá 10%, vậy mà app đơ như cây cơ. Nguyên nhân? Không phải query chậm, mà là tụi connection pool bị cấu hình sai bét.

Cáp mạng kết nối Ảnh: Brett Sayles — Pexels

Mở kết nối database đắt hơn mình nghĩ

Mỗi lần mở một kết nối TCP tới database, phía sau là cả đống việc: TCP handshake (3 bước bắt tay), xác thực user, có khi cả TLS handshake nữa. Tổng cộng dễ mất 50-100ms — đắt hơn cả lúc query nhanh nữa. Giờ tưởng tượng mỗi request của user mà đi mở kết nối mới rồi đóng, thì riêng phần "mở cửa" đã ngốn hết budget latency rồi.

Connection pooling sinh ra để giải quyết chuyện đó: thay vì đóng kết nối sau mỗi query, mình giữ sẵn một rổ kết nối và cho các goroutine mượn. Xài xong trả vô rổ, lần sau mượn tiếp. Nghe đơn giản vậy, nhưng đống bug "too many connections" mình gặp trong nghề đa số đều từ chỗ này.

Cấu hình pool trong Go

Go cho mình kiểm soát pool qua database/sql — khỏi cần thư viện ngoài:

db, _ := sql.Open("postgres", dsn)

// Số kết nối tối đa mở cùng lúc — lá chắn chống quá tải database
db.SetMaxOpenConns(25)
// Số kết nối nhàn rỗi giữ sẵn — tránh mở/đóng liên tục
db.SetMaxIdleConns(10)
// Kết nối sống tối đa 30 phút — chống kết nối cũ, stale
db.SetConnMaxLifetime(30 * time.Minute)
// Kết nối nhàn rỗi quá 5 phút thì đóng — giải phóng tài nguyên
db.SetConnMaxIdleTime(5 * time.Minute)

Ba cái dòng này nhìn nhỏ mà quyết định cả số phận service. Hồi trước mình chỉ set mỗi SetMaxOpenConns, quên mất SetConnMaxLifetime — tới lúc database failover qua replica, mấy kết nối cũ trỏ về IP cũ vẫn nằm trong pool, service lỗi cả buổi mà không hiểu tại sao.

Data center Ảnh: Brett Sayles — Pexels

Kinh nghiệm chọn size pool

Có quy tắc cũ của PostgreSQL wiki khá nổi: pool size = (số core CPU × 2) + 1. Với VPS 4 core thì pool tầm 9-10 là đủ cho phần lớn workload. Đừng thấy "database khỏe" rồi đẩy SetMaxOpenConns(500) — mỗi kết nối đều tốn RAM phía database (Postgres tốn tầm 5-10MB mỗi connection), tới lúc spike traffic là database sập vì hết memory chứ không phải vì query.

Ngoài ra mấy cái này mình đúc kết từ mấy lần ăn hành:

  • Luôn đặt SetMaxOpenConns thấp hơn max_connections của database — chừa khoảng trống cho migration, backup, admin.
  • SetConnMaxLifetime bắt buộc phải có khi database sau proxy/LB (RDS, Cloud SQL, PgBouncer) — mấy cái này hay ngắt kết nối nhàn rỗi lâu.
  • Xem pool khi debug: SHOW PROCESSLIST (MySQL) hay pg_stat_activity (Postgres) — thấy state = idle nhiều là bình thường, còn state = active dồn một chỗ mới là đáng nghi.
  • Pool cho HTTP cũng vậy: client Go dùng lại kết nối qua keep-alive, nhớ chỉnh MaxIdleConnsPerHost cho http.Client khi gọi API nội bộ nhiều — không thì mỗi host lại mở kết nối mới hoài.

Server room Ảnh: panumas nikhomkhai — Pexels

Chốt lại

Connection pooling không phải thứ gì cao siêu, nhưng cấu hình sai thì app mình "chậm như rùa" dù hạ tầng khỏe re. Lần tới gặp lỗi too many connections hay latency tăng vô cớ, đừng vội đổ thừa query — mở pool ra coi thử. Có ai từng gặp vụ kết nối stale sau failover chưa? Kể mình nghe với, chắc chắn mình không phải người duy nhất đau đầu với nó. 😄

📋 Phụ lục thuật ngữ

  • Connection pool — cơ chế giữ sẵn các kết nối database để tái sử dụng, tránh chi phí mở/đóng liên tục
  • SetMaxOpenConns — giới hạn số kết nối tối đa mở đồng thời trong pool
  • SetConnMaxLifetime — thời gian tối đa một kết nối được sống, giúp loại bỏ kết nối stale
  • Keep-alive — cơ chế tái sử dụng kết nối HTTP/TCP giữa các request
  • max_connections — giới hạn kết nối đồng thời của database server