Ở bài về một GET tôi đo được một kết nối tái dùng cắt được một vòng khứ hồi so với kết nối mới. Nhưng một trang web hay một dịch vụ không gửi một request — nó gửi hàng chục. Bài này đo cái lợi cộng dồn của keep-alive khi tái dùng một kết nối cho nhiều request, và chỉ ra một cái bẫy đo lường suýt khiến tôi kết luận keep-alive là thứ vô dụng.
Keep-alive: một kết nối, nhiều request
Mặc định, HTTP/1.1 giữ kết nối TCP mở sau mỗi request để dùng lại cho request kế tiếp — đó là keep-alive. Không có nó (HTTP/1.0 đời đầu, hoặc khi một bên gửi header Connection: close), mỗi request phải mở một kết nối mới, và mỗi kết nối mới phải trả một vòng khứ hồi cho bắt tay TCP trước khi request đầu tiên kịp đi.
Để đo cái giá đó cộng dồn, tôi dựng một máy chủ HTTP của riêng mình, rồi cho một client lấy 20 tài nguyên nhỏ theo hai cách: mở kết nối mới mỗi lần (đóng sau mỗi request), và tái dùng một kết nối cho cả 20. Tôi đo tổng thời gian, và làm ở hai mức độ trễ khác nhau.
Đo: gấp đôi tốc độ ở RTT thật
Kết quả tách làm hai câu chuyện rất khác nhau tùy độ trễ đường truyền:
| Đường truyền | Cách | Bắt tay | Tổng thời gian | Mỗi request |
|---|---|---|---|---|
| RTT ~0 (localhost) | mở mới | 20 | 2 ms | 0,1 ms |
| RTT ~0 (localhost) | tái dùng | 1 | 1 ms | 0,1 ms |
| RTT ~55 ms | mở mới | 20 | ~2360 ms | ~118 ms (2 RTT) |
| RTT ~55 ms | tái dùng | 1 | ~1195 ms | ~60 ms (1 RTT) |
Nhìn hai dòng dưới. Trên một đường có độ trễ thật (~55 ms một vòng), cách "mở mới mỗi lần" tốn ~118 mili giây mỗi request — đúng hai vòng khứ hồi: một cho bắt tay, một cho trao đổi. Cách "tái dùng" chỉ tốn ~60 mili giây mỗi request — một vòng, vì bắt tay đã trả một lần duy nhất ở đầu và không lặp lại. Tổng cho 20 request: 2360 mili giây so với 1195 — tái dùng nhanh gấp đôi, tiết kiệm hơn một giây.
Con số khớp với phép tính đơn giản: mở mới tốn 20 × 2 = 40 vòng; tái dùng tốn 1 (bắt tay) + 20 (trao đổi) = 21 vòng. Chênh lệch 19 vòng chính là 19 lần bắt tay mà keep-alive né được — đúng (N − 1) với N request. Càng nhiều request, càng nhiều bắt tay tránh được; đường càng xa, mỗi bắt tay tránh được càng đắt.
Một lần tôi đo hớ trên localhost
Đây là chỗ tôi suýt sai. Lần chạy đầu tiên tôi đo trên chính máy — client và server nói chuyện qua loopback, RTT gần như bằng 0. Kết quả: mở mới 20 kết nối tốn 2 mili giây, tái dùng tốn 1 mili giây. Chênh nhau đúng một mili giây cho cả 20 request. Nhìn con số đó, tôi định viết "keep-alive là tối ưu vặt, tiết kiệm không đáng kể, đừng bận tâm".
May là tôi kịp nhận ra vấn đề nằm ở môi trường đo. Toàn bộ cái lợi của keep-alive là né các vòng khứ hồi bắt tay — nhưng trên loopback, một vòng khứ hồi tốn cỡ micro giây, gần như bằng 0. Cái lợi có ở đó, chỉ là nó đo bằng RTT, mà RTT trên localhost quá nhỏ để thấy. Khi tôi thêm độ trễ ~55 mili giây cho giống một đường truyền thật, cùng phép đo đó lập tức cho thấy tái dùng nhanh gấp đôi.
Bài học đo lường, và lần này là một bài học tôi phải nhắc lại cho chính mình: đo hiệu năng mạng trên localhost giấu đi mọi chi phí tính bằng RTT. Keep-alive, độ trễ thiết lập kết nối, thuế bắt tay, thời gian phân giải DNS — tất cả gần như tàng hình trên loopback, nơi mọi vòng khứ hồi đều miễn phí. Một benchmark "chứng minh" keep-alive vô dụng gần như chắc chắn đã chạy trên localhost. Muốn con số phản ánh thực tế, phải mô phỏng RTT thật của đường mà dịch vụ sẽ chạy.
Keep-alive không phải vô hạn
Trong phép đo của tôi, một kết nối phục vụ trọn 20 request vì máy chủ tự viết của tôi không đặt giới hạn nào. Máy chủ thật thì có, và biết chúng giúp tránh ngộ nhận. Có hai giới hạn phổ biến. Thứ nhất là thời gian chờ rảnh (idle timeout): nếu kết nối không có request nào trong một khoảng (nginx mặc định 75 giây), máy chủ đóng nó để khỏi ôm hàng vạn kết nối ngủ. Thứ hai là số request tối đa mỗi kết nối (nginx mặc định 1000): sau ngần ấy request, máy chủ chủ động đóng và buộc client mở kết nối mới, để dọn dẹp bộ nhớ và phân bố lại tải.
Hệ quả: một connection pool phải chịu được việc kết nối bị đóng giữa chừng — mượn một kết nối "còn sống" từ pool, gửi request, và nhận về một kết nối đã bị máy chủ lặng lẽ đóng. Thư viện pool tốt phát hiện điều này và mở lại trong suốt. Đây cũng là lý do việc để client hay server chủ động đóng lại nối với chuyện TIME_WAIT ở phần trước: nếu server đóng sau mỗi 1000 request, chính server (không phải client) gánh TIME_WAIT cho những kết nối đó.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên và trực tiếp nhất: dùng connection pool, đừng mở kết nối mới cho mỗi lần gọi. Đa số thư viện HTTP client hiện đại đã gộp kết nối sẵn, nhưng không phải tất cả — một số cách dùng (tạo một client mới cho mỗi request, tắt keep-alive vô tình) làm mất hết cái lợi vừa đo. Với một dịch vụ gọi API nội bộ hàng nghìn lần mỗi giây qua một mạng có RTT dù chỉ vài mili giây, việc gộp kết nối cắt được một nửa độ trễ và một nửa số gói.
Hệ quả thứ hai: với HTTPS, cái lợi còn lớn hơn. Kết nối mới không chỉ trả bắt tay TCP mà cả bắt tay TLS (thêm một tới hai vòng nữa, như đã tính ở bài trước). Keep-alive né được cả hai, nên trên HTTPS mỗi request tái dùng có thể tiết kiệm hai tới ba vòng khứ hồi thay vì một. Đây là lý do các trình duyệt và HTTP/2 dồn mọi thứ vào càng ít kết nối càng tốt.
Hệ quả thứ ba nối với bài về TIME_WAIT: mở kết nối mới liên tục không chỉ chậm mà còn tích socket ở trạng thái TIME_WAIT, và ở tốc độ cao có thể làm cạn cổng tạm. Keep-alive tránh được cả hai vấn đề cùng lúc — vừa nhanh hơn, vừa không đốt cổng. Con số mang theo: tái dùng một kết nối cho N request né được (N−1) lần bắt tay, và trên đường có độ trễ thật, điều đó có thể nhanh gấp đôi trở lên. Cái lợi tỉ lệ thuận với RTT và số request — hai thứ mà một benchmark localhost đặt về 0, nên đừng bao giờ đánh giá keep-alive bằng cách đo trên chính máy mình.
Thử ba mươi giây
Lấy vài đường dẫn cùng một host trong một lệnh curl — curl -s -o /dev/null -w '%{time_total}\n' https://may-chu/a https://may-chu/b https://may-chu/c — curl tự tái dùng một kết nối cho cả ba, và in thời gian từng cái. Bạn sẽ thấy cái đầu tiên (phải bắt tay TCP, và TLS nếu là HTTPS) chậm hơn hẳn hai cái sau vốn dùng lại kết nối. Giờ thêm cờ --no-keepalive để buộc mỗi đường dẫn mở một kết nối mới, chạy lại, và so: tổng thời gian tăng lên đúng bằng những vòng bắt tay mà keep-alive vừa cắt cho bạn.