Một dòng GET /trang trông đơn giản, nhưng trên dây nó kéo theo cả một chuỗi: bắt tay TCP, một khối header, rồi câu trả lời cũng kèm header của nó. Bài này dựng một máy chủ HTTP của riêng tôi trong container, bắt trọn một GET bằng tcpdump, rồi đếm chính xác nó tốn bao nhiêu byte và bao nhiêu vòng khứ hồi — cùng một con số khiến tôi nhận ra client tự viết đã đánh lừa mình.
Số vòng khứ hồi: kết nối mới tốn hai
Tôi thêm độ trễ giả lập để mỗi vòng khứ hồi (RTT) khoảng 50 mili giây, rồi bắt gói của một GET trên một kết nối mới. Bản bắt vẽ ra rõ ràng từng bước:
[S] client -> server SYN \
[S.] server -> client SYN-ACK } bắt tay = 1 vòng
[.] client -> server ACK /
[P.] client -> server GET /t ... \ trao đổi = 1 vòng
[.] server -> client ACK |
[P.] server -> client 200 OK ... /
Đọc theo thời gian: ba gói đầu (SYN, SYN-ACK, ACK) là bắt tay TCP, tốn một vòng khứ hồi trước khi byte dữ liệu nào kịp đi. Chỉ sau đó client mới gửi được GET, và câu trả lời 200 OK quay về sau một vòng nữa. Tổng cộng: hai vòng khứ hồi trước khi bạn nhìn thấy thân trả lời. Đo bằng đồng hồ cũng khớp — connect() mất ~1 RTT, rồi từ lúc gửi GET tới byte đầu của trả lời thêm ~1 RTT.
Bây giờ gửi một GET thứ hai trên cùng kết nối đó (keep-alive):
GET#2 trên cùng kết nối: ~1 RTT (không có bắt tay)
Chỉ một vòng. Không có SYN/SYN-ACK/ACK nữa vì kết nối đã mở sẵn. Tái dùng kết nối tiết kiệm đúng một vòng khứ hồi mỗi request — chính là cái giá của bắt tay ba bước đã đo ở bài về bắt tay TCP. Với một trang web tải hàng chục tài nguyên, khác biệt giữa "mở kết nối mới mỗi lần" và "tái dùng" là hàng chục vòng khứ hồi — trên đường xa, là cả giây.
Với HTTPS, cộng thêm vòng
Phép đo trên là HTTP trần. Trong thực tế gần như mọi thứ chạy trên HTTPS, và bắt tay TLS chèn thêm vòng khứ hồi giữa bắt tay TCP và request đầu tiên. Với TLS 1.2 là thêm hai vòng; với TLS 1.3 (phổ biến hiện nay) là thêm một vòng. Cộng vào bảng trên:
GET đầu tiên trên HTTPS mới (TLS 1.3):
bắt tay TCP ~1 vòng
bắt tay TLS ~1 vòng
GET -> 200 OK ~1 vòng
=> ~3 vòng trước khi có thân trả lời
Nghĩa là một request HTTPS tới một máy chủ chưa từng kết nối tốn khoảng ba vòng khứ hồi — trên đường RTT 100 ms là ~300 ms chỉ để bắt đầu nhận dữ liệu. Đây là lý do tái dùng kết nối càng quan trọng với HTTPS: giữ kết nối sống tránh được cả hai lần bắt tay (TCP lẫn TLS), đưa mỗi request sau về đúng một vòng. Nó cũng là động lực của các tối ưu như TLS session resumption và TLS 1.3 "0-RTT", vốn sinh ra để cắt bớt chính những vòng bắt tay này.
Kích thước: header lấn át khi tài nguyên nhỏ
Đây là kích thước đo được của một trao đổi, với thân trả lời 40 byte:
| Phần | Kích thước |
|---|---|
| Request (tối thiểu) | 72 byte |
| Response headers | 147 byte |
| Thân trả lời | 40 byte |
Đọc kỹ: request 72 byte còn lớn hơn cả thân trả lời 40 byte, và riêng phần header của câu trả lời (147 byte) chiếm tới ~78% tổng response. Với một tài nguyên nhỏ — một lời gọi API trả về vài chục byte JSON, một pixel theo dõi, một mẩu cấu hình — thì phần "khung" HTTP (dòng trạng thái, Date, Server, Content-Type, Content-Length, ...) nặng hơn hẳn dữ liệu thật. Đây là lý do gộp nhiều lời gọi API nhỏ thành một lời gọi lớn, hoặc dùng các giao thức nhị phân gọn hơn, lại giúp nhiều đến vậy: bạn cắt được phần khung lặp đi lặp lại.
Một lần tôi đo hớ về kích thước request
Con số "request 72 byte" suýt dẫn tôi tới một kết luận sai. Nhìn nó, tôi định viết "request HTTP nhỏ xíu, không đáng lo về băng thông". Nhưng client đo của tôi là một client tối thiểu — nó chỉ gửi bốn header (Host, User-Agent ngắn, Accept). Đó không phải thứ đi trên Internet thật.
Tôi dựng lại một request đúng kiểu trình duyệt gửi — với User-Agent đầy đủ, Accept, Accept-Language, Accept-Encoding, một khối Cookie, Referer — và đo lại:
request tối thiểu : 72 byte
request trình duyệt: 524 byte (gấp ~7 lần)
Bảy lần lớn hơn, cho cùng một cái GET. Và điều tệ hơn: trong HTTP/1.1, khối header đó được gửi không nén và lặp lại nguyên vẹn ở mọi request trên cùng kết nối. Tải một trang với 50 tài nguyên nghĩa là gửi cái khối Cookie + User-Agent đó 50 lần. Đây chính là động lực sinh ra HPACK trong HTTP/2 (và QPACK trong HTTP/3): nén header và không gửi lại những header không đổi, cắt phần lặp khổng lồ này.
Bài học đo lường: một client tự viết dễ khiến bạn đánh giá thấp kích thước thật của lưu lượng. Con số 72 byte đúng với client của tôi nhưng sai với thực tế cả bậc độ lớn. Khi đo chi phí của một giao thức, phải đo với đầu vào đại diện cho cái chạy thật, không phải cái tối giản mình dựng cho tiện.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là tái dùng kết nối gần như luôn đáng giá. Mỗi kết nối mới trả một vòng khứ hồi cho bắt tay TCP trước khi request đi được — và nếu là HTTPS thì cộng thêm một tới hai vòng cho bắt tay TLS nữa. Với dịch vụ gọi nhiều request nhỏ (microservice gọi nhau, client gọi API liên tục), giữ kết nối sống bằng keep-alive hoặc connection pool cắt được phần lớn độ trễ. Đây là cùng một bài học đã gặp ở nhiều phần trước, giờ đo được trực tiếp ở tầng HTTP: 2 vòng thành 1.
Hệ quả thứ hai là để ý khối header. Cookie phình to, hàng tá header tùy chỉnh, token dài — tất cả đi cùng mọi request trong HTTP/1.1. Trên một API gọi hàng nghìn lần mỗi giây, vài trăm byte header thừa mỗi lần cộng lại thành băng thông thật. Nâng lên HTTP/2 để có nén header, và cắt cookie không cần thiết, là những cải thiện đo được.
Hệ quả thứ ba là hiểu đúng "một request tốn gì" khi ước lượng hiệu năng. Một GET tới một tài nguyên nhỏ trên kết nối mới không phải "một lần đi mạng" — nó là hai vòng khứ hồi (hoặc bốn với HTTPS lần đầu), cộng một khối header có thể lớn hơn dữ liệu. Con số mang theo: một GET trên kết nối mới tốn 2 vòng khứ hồi và một khối framing thường nặng hơn thân khi tài nguyên nhỏ; tái dùng kết nối đưa về 1 vòng, và nén header đưa phần khung về gần 0. Ba đòn bẩy đó — keep-alive, gộp request, và HTTP/2 — đều tấn công đúng những chi phí vừa đo được.
Thử ba mươi giây
Chạy curl -sv -o /dev/null https://mot-trang-web 2>&1 | grep -E '^[<>]' — dấu > là các dòng bạn gửi (request line + headers), dấu < là các dòng nhận (status + response headers). Đếm số dòng > để thấy request của bạn dày cỡ nào. Rồi thêm -w 'connect=%{time_connect}s ttfb=%{time_starttransfer}s\n' để thấy hai mốc: thời gian bắt tay xong và thời gian byte đầu tiên về — hiệu giữa chúng chính là vòng khứ hồi request/response mà bài này đo.