Bài 1 đo được rằng mỗi kết nối TCP mới tốn một vòng bắt tay, và tái dùng kết nối nhanh hơn nhiều. HTTP keep-alive chính là cơ chế đưa lợi ích đó lên tầng HTTP: thay vì mở một kết nối TCP mới cho mỗi request rồi đóng, ta giữ kết nối mở để các request sau dùng lại. Nghe đơn giản, nhưng một client cấu hình sai — hoặc vô tình tạo http.Client mới mỗi lần gọi — sẽ mở hàng trăm kết nối thay vì một, làm chậm hệ và cạn tài nguyên. Bài này (phần 5 loạt Mạng) chạy thật, đếm số kết nối TCP thực sự mở ra để thấy keep-alive tiết kiệm bao nhiêu.
Cơ chế: đừng đóng kết nối sau mỗi response
Không có keep-alive, mỗi request là một vòng đời đầy đủ: bắt tay → gửi → nhận response → đóng. N request = N lần bắt tay. Với keep-alive (mặc định của HTTP/1.1), sau khi trả response server giữ kết nối mở, và request tiếp theo dùng luôn kết nối đó — bắt tay chỉ một lần cho cả loạt.
Không keep-alive: [bắt tay][GET 1][resp][đóng] [bắt tay][GET 2][resp][đóng] ...
Keep-alive: [bắt tay][GET 1][resp][GET 2][resp][GET 3]... -> bắt tay 1 LẦN
Trong Go, để đo điều này, mình dùng ConnState của http.Server đếm mỗi kết nối TCP mới:
srv := &http.Server{ ConnState: func(c net.Conn, s http.ConnState) {
if s == http.StateNew { atomic.AddInt64(&newConns, 1) } // mỗi kết nối mới +1
}}
Phía client, keep-alive bật mặc định; tắt bằng Transport{DisableKeepAlives: true}:
client := &http.Client{} // keep-alive BẬT (mặc định)
tr := &http.Transport{DisableKeepAlives: true} // TẮT
client := &http.Client{Transport: tr}

Hình 1: Không keep-alive mỗi request bắt tay riêng; keep-alive giữ kết nối mở cho request sau (bắt tay một lần); đo bằng ConnState/StateNew phía server, bật/tắt bằng Transport.DisableKeepAlives phía client.
Đo thật: đếm kết nối và thời gian
Mình gửi 500 request HTTP tới cùng một server, đếm số kết nối TCP mở ra và tổng thời gian, với keep-alive bật vs tắt:

Hình 2: Chạy thật — 500 request: keep-alive bật mở 1 kết nối (9,248ms, 18,5µs/req), keep-alive tắt mở 500 kết nối (36,477ms, 73µs/req) — keep-alive nhanh hơn ~3,9 lần ngay trên localhost.
Đọc kết quả đo được:
- Keep-alive: 500 request → 1 kết nối: server chỉ thấy một
StateNewcho cả 500 request. Bắt tay TCP diễn ra đúng một lần, sau đó mọi request tái dùng kết nối đó. Đây chính xác là điều bài 1 dự đoán: tái dùng bỏ được bắt tay lặp lại. - Không keep-alive: 500 request → 500 kết nối: mỗi request mở một kết nối mới, bắt tay lại từ đầu, dùng xong đóng. Server thấy 500
StateNew. Ngoài chậm hơn, việc này còn để lại nhiều socket TIME_WAIT (như đã thấy ở bài 1) và tốn tài nguyên cả hai phía. - Nhanh hơn ~3,9 lần: 9,248ms so với 36,477ms cho cùng 500 request. Con số này đo trên localhost, nơi bắt tay đã rất rẻ (RTT~0). Trên mạng thực với RTT hàng chục ms, mỗi bắt tay tốn cả RTT — khoảng cách giữa 1 và 500 lần bắt tay sẽ khổng lồ hơn nhiều.
Đánh đổi cần cân nhắc
Bẫy lớn nhất là vô tình tắt keep-alive. Keep-alive là mặc định, nên vấn đề thường không phải "quên bật" mà là "vô tình tắt". Ba cách phổ biến làm mất nó: đặt DisableKeepAlives: true, gửi header Connection: close (hoặc Request.Close = true), và — tinh vi nhất — tạo một http.Client/Transport mới cho mỗi request. Vì kho kết nối idle nằm trong Transport, một Transport mới không có kết nối cũ để tái dùng. Quy tắc: dùng chung một http.Client cho toàn ứng dụng.
Đọc hết và đóng body, nếu không kết nối không được tái dùng. Một cái bẫy Go rất hay gặp: nếu bạn không đọc hết resp.Body rồi Close(), kết nối không thể trả về kho idle để tái dùng — nó bị bỏ và một kết nối mới sẽ mở cho request sau. Luôn io.Copy(io.Discard, resp.Body) (hoặc đọc hết) rồi resp.Body.Close(), kể cả khi bạn không cần nội dung.
Keep-alive không miễn phí về tài nguyên — cần giới hạn. Giữ kết nối mở tiêu tốn file descriptor và bộ nhớ ở cả hai phía. Vì vậy có MaxIdleConns, MaxIdleConnsPerHost, và IdleConnTimeout để giới hạn số kết nối idle và tự đóng cái để lâu. Mặc định MaxIdleConnsPerHost của Go chỉ là 2 — với client gọi nhiều request song song tới cùng host, nên tăng con số này, nếu không các request thừa vẫn phải mở kết nối mới (dẫn tới chủ đề connection pool ở bài sau).
Ba ý mang về
- Keep-alive để nhiều request dùng chung một kết nối TCP: đo thật 500 request với keep-alive mở đúng 1 kết nối (9,2ms) so với tắt keep-alive mở 500 kết nối (36,5ms) — nhanh hơn ~3,9 lần ngay trên localhost, và khổng lồ hơn trên mạng RTT cao.
- Keep-alive là mặc định — đừng vô tình tắt: bẫy phổ biến là
DisableKeepAlives,Connection: close, và nhất là tạohttp.Client/Transportmới mỗi request; dùng chung một Client cho toàn ứng dụng. - Phải đọc hết và đóng body để kết nối được tái dùng: quên
io.Copy+Body.Close()khiến kết nối bị bỏ; và nhớ chỉnhMaxIdleConnsPerHost(mặc định Go chỉ 2) khi gọi nhiều request song song — dẫn tới connection pool ở bài sau.
Nguồn
- RFC 9112 — HTTP/1.1 (Persistent Connections): https://www.rfc-editor.org/rfc/rfc9112#name-persistence
- Go docs — net/http.Transport (keep-alive, MaxIdleConns): https://pkg.go.dev/net/http#Transport
- Go docs — net/http.Server.ConnState: https://pkg.go.dev/net/http#Server
Phần sau ta đào sâu connection pool — vì sao mở kết nối mỗi request là đắt và pool giải quyết ra sao; đo thật throughput có và không có pool khi nhiều request chạy song song.