Bài trước, keep-alive giúp nhiều request tuần tự dùng chung một kết nối. Nhưng thực tế hiếm khi tuần tự: một service thường xử lý nhiều request song song, và khi 50 luồng cùng gọi một dịch vụ, chúng cần 50 kết nối cùng lúc — một kết nối không đủ. Đây là lúc connection pool vào cuộc: một tập kết nối mở sẵn, tái dùng cho các luồng song song, với giới hạn để không mở vô hạn. Cấu hình pool sai — đặc biệt là để mặc định — có thể khiến bạn âm thầm mở hàng trăm kết nối thay vì vài chục. Bài này (phần 6 loạt Mạng) chạy thật để thấy điều đó.

Cơ chế: mượn – dùng – trả, với giới hạn

Pool giữ tối đa N kết nối idle (rảnh). Mỗi request mượn một kết nối rảnh, dùng, rồi trả lại pool. Nếu pool đã đủ N idle, kết nối trả về vượt mức bị đóng thay vì giữ — và lần sau cần lại phải mở mới (bắt tay lại). Việc mở-đóng liên tục này gọi là churn, và nó xóa sạch lợi ích của keep-alive.

Trong Go, http.Transport chính là connection pool. Tham số quan trọng nhất là MaxIdleConnsPerHost:

tr := &http.Transport{
    MaxIdleConnsPerHost: 100,  // giữ tối đa 100 kết nối idle mỗi host
    MaxIdleConns:        1000, // tổng idle toàn Transport
    // MaxConnsPerHost: giới hạn CỨNG số kết nối mỗi host (0 = không giới hạn)
}
client := &http.Client{Transport: tr}
// CẢNH BÁO: mặc định MaxIdleConnsPerHost = 2 -> quá nhỏ cho tải song song cao

Ảnh chụp đoạn mã Go nền tối minh hoạ connection pool, vấn đề keep-alive 1 kết nối chỉ phục vụ tuần tự bài 5 1 kết nối tái dùng cho request nối tiếp nhưng 50 request chạy song song cần 50 kết nối cùng lúc cần một tập kết nối bằng connection pool, cơ chế pool mượn dùng trả pool giữ tối đa N kết nối idle request lấy 1 kết nối rảnh từ pool dùng trả lại pool pool đầy idle kết nối thừa bị đóng không giữ lần sau cần lại phải mở mới bắt tay lại churn, trong Go http Transport chính là pool tr http Transport MaxIdleConnsPerHost 100 giữ tối đa 100 kết nối idle host MaxIdleConns 1000 tổng idle toàn Transport MaxConnsPerHost giới hạn cứng số kết nối host client http Client Transport tr mặc định MaxIdleConnsPerHost 2 quá nhỏ cho tải song song cao, đo 2000 request song song 50 luồng pool nhỏ vs lớn sem make chan struct 50 giới hạn song song 50 for i 2000 go client Get url đếm kết nối TCP mở qua ConnState StateNew đo throughput req s

Hình 1: Pool giữ N kết nối idle; request mượn-dùng-trả; pool đầy thì kết nối thừa bị đóng và phải mở lại (churn); trong Go http.Transport là pool, chỉnh MaxIdleConnsPerHost (mặc định chỉ 2).

Đo thật: pool nhỏ churn, pool đủ tái dùng

Mình gửi 2000 request song song 50 luồng, so MaxIdleConnsPerHost=2 (mặc định) với 100:

Ảnh chụp bảng kết quả chạy thật connection pool output thật, pool nhỏ vs đủ lớn cho mức song song MaxIdleConnsPerHost 2 mặc định Go số kết nối TCP mở 546 tổng 80 ms throughput 25048 req s MaxIdleConnsPerHost 100 đủ cho song song 50 số kết nối TCP mở 50 tổng 67 ms throughput 29749 req s, ý nghĩa pool 2 50 luồng song song nhưng chỉ giữ 2 idle 48 kết nối thừa bị đóng mỗi vòng lần sau mở lại 546 kết nối churn 11x pool 100 giữ đủ tái dùng gần hết chỉ 50 kết nối bằng song song throughput nhanh hơn 19 phần trăm localhost bắt tay rẻ mạng thực chênh lớn hơn, kết luận pool quá nhỏ mặc định 2 churn kết nối phí bắt tay throughput thấp pool đủ mức song song tái dùng tối đa ít kết nối nhanh hơn pool quá lớn tốn file descriptor RAM với pool DB còn đè database quy tắc MaxIdleConnsPerHost lớn hơn hoặc bằng mức song song thực tế tới mỗi host pool DB database sql SetMaxOpenConns SetMaxIdleConns cùng nguyên lý

Hình 2: Chạy thật — 2000 request song song 50 luồng: MaxIdleConnsPerHost=2 mở 546 kết nối (churn ~11x), 25048 req/s; =100 mở đúng 50 kết nối (= mức song song), 29749 req/s (~19% nhanh hơn trên localhost).

Đọc kết quả đo được:

  • Pool mặc định (2) gây churn nặng: 50 luồng chạy song song, nhưng pool chỉ giữ 2 kết nối idle. Khi mỗi luồng xong, 48 kết nối "thừa" không được giữ lại nên bị đóng; luồng sau cần lại phải mở mới. Kết quả: 546 kết nối được mở cho 2000 request — gấp ~11 lần con số cần thiết. Mỗi kết nối mở là một lần bắt tay lãng phí.
  • Pool đủ lớn (100) tái dùng gần hết: giữ đủ kết nối idle cho 50 luồng, nên chúng được tái dùng — chỉ 50 kết nối mở ra, đúng bằng mức song song. Không churn, không bắt tay thừa.
  • Throughput nhanh hơn ~19% (trên localhost): 29749 vs 25048 req/s. Con số này khiêm tốn vì localhost có bắt tay rất rẻ. Trên mạng thực, nơi mỗi bắt tay thừa tốn cả RTT, chênh lệch throughput giữa churn và không-churn sẽ lớn hơn nhiều — và tín hiệu rõ nhất luôn là số kết nối mở (546 vs 50).

Đánh đổi cần cân nhắc

Kích cỡ pool nên khớp mức song song thực tế tới mỗi host. Quy tắc thực dụng: MaxIdleConnsPerHost nên ≥ số request song song bạn thường gửi tới một host. Nếu client gọi tối đa 50 request song song tới một API, đặt pool ~50–100. Đặt bằng mặc định 2 sẽ churn; đặt quá cao thì lãng phí. Với http.Client mặc định của Go, luôn cân nhắc chỉnh MaxIdleConnsPerHost khi tải song song cao — đây là một trong những tinh chỉnh hiệu năng bị bỏ quên nhiều nhất.

Pool quá lớn không miễn phí — và với database thì nguy hiểm. Mỗi kết nối trong pool tốn file descriptor và bộ nhớ ở cả hai phía. Với connection pool database (database/sql: SetMaxOpenConns/SetMaxIdleConns), pool quá lớn còn tệ hơn: hàng trăm client mỗi cái mở 100 kết nối tới một Postgres có giới hạn max_connections sẽ đè sập database. Ở đó, pool phải được giới hạn cứng (SetMaxOpenConns) và tính tổng trên toàn cụm, không chỉ một instance.

MaxIdleConnsPerHost khác MaxConnsPerHost. MaxIdleConnsPerHost giới hạn số kết nối idle được giữ, không giới hạn số kết nối đang hoạt động — bạn vẫn có thể mở nhiều hơn tạm thời rồi đóng bớt. MaxConnsPerHost (mặc định 0 = không giới hạn) mới là trần cứng: vượt thì request phải chờ. Với dịch vụ cần bảo vệ backend khỏi quá tải, đặt MaxConnsPerHost để tạo áp lực ngược (backpressure) thay vì mở vô hạn.

Ba ý mang về

  1. Pool phục vụ request song song, không chỉ tuần tự như keep-alive: mỗi luồng song song cần một kết nối cùng lúc; pool giữ một tập kết nối idle để mượn-dùng-trả, giới hạn để không mở vô hạn — trong Go http.Transport chính là pool.
  2. Pool quá nhỏ gây churn, đo thật rất rõ: 2000 request song song 50 luồng với pool mặc định (2) mở 546 kết nối (churn ~11x), pool=100 chỉ mở 50 (= mức song song) và nhanh hơn ~19% — số kết nối mở là tín hiệu rõ nhất.
  3. Chọn pool khớp mức song song, cẩn thận với pool DB: MaxIdleConnsPerHost ≥ số request song song mỗi host; pool quá lớn tốn tài nguyên và với database/sql còn đè sập database — giới hạn cứng bằng SetMaxOpenConns/MaxConnsPerHost.

Nguồn

Phần sau ta thêm một lớp lên trên kết nối: TLS handshake — cái giá thời gian của HTTPS so với HTTP thường, và cách session resumption giảm chi phí bắt tay TLS cho các kết nối sau.