Connection Pool — Vì sao mở kết nối DB mỗi lần là chậm ngất?
Kết nối database không rẻ
Ai mới học backend cũng hay dính cái bẫy này: mỗi request thì db.Connect() rồi query rồi db.Close(). Lúc đầu app chạy ngon, vì traffic ít. Tới khi có vài trăm user cùng lúc, app bắt đầu lag, database báo lỗi quá nhiều kết nối. Vậy rốt cuộc chuyện gì xảy ra?
Mỗi lần mở kết nối TCP tới PostgreSQL, máy phải làm: bắt tay 3 bước TCP, rồi xác thực (có khi cả TLS handshake), rồi setup session. Mấy bước này rơi vào từ 3 đến 10ms — không phải 0. Nếu request của bạn chỉ dừng ở query thì cái chi phí mở kết nối đó còn lâu hơn cả chính câu query.
Connection pool là gì?
Connection pool là cục kho những kết nối đã mở sẵn, app tái sử dụng thay vì mở mới. Nghe đơn giản, nhưng quản lý nó mới là chỗ khó.
Trong Go, chuẩn database/sql đã build sẵn pool từ lâu. Bạn chỉ cần chỉnh vài tham số:
db, err := sql.Open("pgx", dsn)
if err != nil {
log.Fatal(err)
}
// Số kết nối mở tối đa cùng lúc
db.SetMaxOpenConns(50)
// Số kết nối nhàn rỗi tối đa giữ lại
db.SetMaxIdleConns(25)
// Thời gian tối đa một kết nối bị giữ
db.SetConnMaxLifetime(30 * time.Minute)
Ba con số này là chìa khoá. Cứ tưởng set càng lớn càng khoẻ là dính ngay.
Những cái bẫy phổ biến
Bẫy 1: MaxOpenConns quá lớn. Máy chủ Postgres mặc định chỉ nhận tối đa max_connections = 100. Nếu app mở 200 kết nối từ nhiều instance, DB sẽ phủ phục. Kết nối nào cũng tốn RAM (~vài MB mỗi cái), query chạy chậm vì tranh CPU. Quy tắc thực tế: MaxOpenConns = (max_connections của DB) / (số instance) - vài con dự phòng.
Bẫy 2: thiếu ConnMaxLifetime. Nhiều connection pool tái sử dụng vô hạn. Nhưng kết nối TCP lâu ngày có thể bị router/load balancer cắt ngầm (idle timeout). App tưởng còn xài được, query gửi ra thì DB đá về lỗi broken pipe. Set ConnMaxLifetime (ví dụ 30 phút) để pool chủ động mở kết nối mới trước khi bị cắt — đổi lấy chút chi phí reconnect, nhưng hết lỗi giật giật khó hiểu.
Bẫy 3: giữ kết nối quá lâu trong một request. Pool đầy mà mọi kết nối đều bị transaction giữ, các request khác phải chờ — gọi là pool exhaustion. Khi đó bạn thấy latency tăng vọt nhưng CPU thì nhàn tênh. Đó là báo hiệu app đang nghẽn I/O, không phải thiếu CPU.
Kinh nghiệm thực tế
Trong một project Go xử lý báo cáo, tôi từng để MaxOpenConns = 200 trên một instance duy nhất. DB bắt đầu thở hổn hển khi traffic lên. Giảm xuống 40, dồn 10 cho việc khác, latency còn thấp hơn trước. Số kết nối không tỉ lệ thuận với performance — nó phải vừa đủ cho mức concurrency thật của app.
Muốn biết mình đang gần hết pool chưa, theo dõi metric wait_count và wait_duration của pool (Go có sẵn qua db.Stats()). Nếu wait_count tăng nhanh, bạn cần chia lại pool hoặc tối ưu query — chứ không nên chạy đi nâng max_connections một cách mù quáng.
Tóm lại: mở kết nối mới mỗi lần là định bại. Xài pool, chỉnh 3 tham số cho vừa mức concurrency, thêm ConnMaxLifetime, và quan sát db.Stats(). App của bạn sẽ trụ được khỏi cú sốc traffic đầu đời.