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}

Ảnh chụp đoạn mã Go nền tối minh hoạ HTTP keep-alive, cơ chế đừng đóng kết nối sau mỗi response không keep-alive mỗi request bắt tay TCP mới gửi đóng N lần bắt tay request 1 bắt tay GET response đóng request 2 bắt tay GET response đóng keep-alive HTTP1.1 mặc định giữ kết nối mở cho request sau bắt tay GET 1 resp GET 2 resp GET 3 bắt tay 1 lần header Connection keep-alive mặc định vs Connection close, đếm kết nối TCP thật qua ConnState của server srv http Server ConnState func c net Conn s http ConnState if s bằng http StateNew atomic AddInt64 newConns 1 mỗi kết nối mới cộng 1, client keep-alive bật mặc định vs tắt bật mặc định Go tái dùng kết nối client http Client tắt mở kết nối mới mỗi request tr http Transport DisableKeepAlives true client http Client Transport tr bẫy Close true trên request hoặc tạo Transport mới mỗi lần mất keep-alive

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:

Ảnh chụp bảng kết quả chạy thật keep-alive output thật, gửi 500 request HTTP tới cùng server keep-alive bật mặc định số kết nối TCP mở ra 1 tổng 9.248 ms 18.5 µs mỗi req keep-alive tắt DisableKeepAlives true số kết nối TCP mở ra 500 tổng 36.477 ms 73.0 µs mỗi req, ý nghĩa keep-alive 500 request dùng 1 kết nối bắt tay đúng 1 lần không keep-alive mở 500 kết nối bắt tay mỗi lần keep-alive nhanh hơn 3.9 lần localhost bắt tay đã rẻ trên mạng thực RTT cao khoảng cách khổng lồ bài 1, kết luận keep-alive là mặc định HTTP1.1 và của Go http Client tận dụng sẵn bẫy mất keep-alive DisableKeepAlives Header Connection close Close true hoặc tạo http Client Transport mới mỗi request dùng chung một http Client Transport cho mọi request giữ kết nối đừng http Get lặp với Client tạm tái dùng 1 Client toàn ứng dụng

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 StateNew cho 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ề

  1. 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.
  2. 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ạo http.Client/Transport mới mỗi request; dùng chung một Client cho toàn ứng dụng.
  3. 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ỉnh MaxIdleConnsPerHost (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

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.