Một máy chủ HTTP có hai cách nói cho client biết thân trả lời dài bao nhiêu và kết thúc ở đâu. Cách thứ nhất: ghi tổng số byte vào header Content-Length. Cách thứ hai: không ghi tổng, mà gửi thân thành từng khối với Transfer-Encoding: chunked. Lựa chọn này nghe như chi tiết kỹ thuật vụn vặt, nhưng nó quyết định một điều rất thực tế: với nội dung sinh dần, client phải chờ bao lâu mới thấy byte đầu tiên. Bài này dựng máy chủ của riêng tôi, đo hai cách bằng số thật, và chỉ ra một cái bẫy khiến "dùng chunked" mà chẳng stream được gì.
Content-Length buộc biết tổng cỡ trước
Content-Length đơn giản và tốt khi bạn đã biết thân dài bao nhiêu: một tệp tĩnh, một chuỗi có sẵn trong bộ nhớ. Server ghi số byte tổng vào header, client đọc đúng ngần ấy byte rồi biết đã hết.
Vấn đề nảy sinh khi cỡ chưa biết cho tới khi sinh xong: kết quả một truy vấn cơ sở dữ liệu đang chảy về, một trang kết xuất động, một luồng nén tại chỗ, hay câu trả lời của một mô hình sinh từng chữ. Để điền được Content-Length, server buộc phải sinh hết toàn bộ thân, đếm byte, rồi mới gửi được header — và trong suốt thời gian sinh đó, client ngồi chờ, không nhận được gì.
Tôi đo trực tiếp. Máy chủ của tôi tạo một thân gồm 5 khối 1000 byte, mỗi khối mất 100 mili giây để "sinh ra" (tổng ~500 ms). Với Content-Length:
Content-Length: byte đầu tới client ở ~530 ms, byte cuối ~530 ms
Client không thấy gì suốt 500 mili giây, rồi nhận cả 5000 byte một lúc. Thời gian tới byte đầu tiên (TFB) bằng đúng thời gian sinh toàn bộ, vì server không gửi được gì cho tới khi biết tổng cỡ.
Chunked: gửi từng khối khi vừa có
Transfer-Encoding: chunked gỡ đúng ràng buộc đó. Server không cần biết tổng cỡ; nó gửi thân thành các khối, mỗi khối tự khai độ dài của mình. Định dạng trên dây rất gọn:
<cỡ khối viết hệ 16> CRLF
<dữ liệu khối> CRLF
...
0 CRLF CRLF <- khối cỡ 0 báo hết
Mỗi khối mang một dòng cỡ (viết hệ 16), rồi dữ liệu, rồi xuống dòng; một khối cỡ 0 đánh dấu kết thúc. Nhờ vậy server gửi được từng khối ngay khi sinh ra, không cần chờ biết tổng. Đo lại đúng thân 5 khối đó với chunked, gửi mỗi khối ngay khi tạo:
Chunked (stream): byte đầu tới client ở ~105 ms, byte cuối ~530 ms
Byte đầu tiên tới sau ~105 mili giây — đúng lúc khối đầu vừa sinh xong — thay vì 530. Nhanh gấp năm lần về thời gian tới byte đầu, trong khi tổng thời gian gần như y hệt. Client bắt đầu xử lý, hiển thị, hoặc chuyển tiếp dữ liệu ngay khi khối đầu về, thay vì ngồi chờ cả nửa giây.
Cái giá của chunked là một chút khung: mỗi khối tốn thêm dòng cỡ và hai lần CRLF. Trong phép đo, thân 5 khối chiếm 5040 byte trên dây thay vì 5000 — thêm ~40 byte, tức khoảng 8 byte mỗi khối. Nhỏ ở đây, nhưng nếu bạn chia thành hàng nghìn khối tí hon thì khung này cộng lại đáng kể, nên gộp dữ liệu thành khối đủ lớn là hợp lý.
Một lần tôi đo hớ: chunked mà không stream
Đây là chỗ tôi vấp, và nó là một cái bẫy rất phổ biến. Lần đầu viết máy chủ chunked, theo thói quen tôi dựng cả thân trong bộ nhớ trước — ghép đủ 5 khối vào một chuỗi (kèm khung chunked) rồi mới sendall một lần. Đo ra:
Chunked nhưng dựng hết rồi gửi: byte đầu ~527 ms
Đúng bằng Content-Length. Streaming biến mất hoàn toàn, dù header vẫn ghi Transfer-Encoding: chunked và định dạng khối vẫn đúng chuẩn. Lý do rõ ràng khi nhìn lại: chunked encoding chỉ cho phép streaming, nó không tự động streaming. Nếu bạn vẫn sinh hết thân trước khi gửi byte đầu tiên, thì bạn đã bỏ phí đúng cái mà chunked sinh ra để làm. Cái lợi chỉ xuất hiện khi server thật sự gửi từng khối ngay khi có — flush sau mỗi khối.
Bài học đo lường: dùng đúng header không đồng nghĩa với có được hành vi mình muốn. Tôi có thể đã kết luận "chunked chẳng nhanh hơn Content-Length" chỉ vì cách tôi viết server vô tình buffer hết. Hành vi của server — nó gửi tăng dần hay gửi một cục — là biến số quyết định, không phải cái tên trên header. Và có một cái bẫy anh em cùng họ (đã gặp ở bài về Nagle): ngay cả khi bạn flush từng khối, thuật toán Nagle hoặc đệm socket có thể gộp các khối nhỏ lại trước khi chúng rời máy — bật TCP_NODELAY để chắc chắn mỗi khối đi ngay.
Streaming cần cả hai đầu cùng không buffer
Phép đo trên cho thấy server phải gửi tăng dần thì mới stream được. Nhưng còn một nửa nữa: client cũng phải xử lý tăng dần. Nếu client gọi một hàm kiểu "đọc toàn bộ thân trả lời rồi trả về một chuỗi", nó sẽ chờ tới khối cuối rồi mới giao dữ liệu cho code của bạn — dù server đã stream hoàn hảo. Byte đầu về sớm 100 ms chẳng ích gì nếu thư viện client của bạn ngồi gom hết mới bàn giao.
Trong đo lường của tôi, client đọc từng khối và ghi mốc thời gian ngay khi khối đầu về, nên nó thấy được cái lợi. Nhưng rất nhiều thư viện HTTP mặc định đọc hết thân rồi mới trả — muốn hưởng streaming, bạn phải chủ động dùng chế độ đọc theo dòng dữ liệu (stream/iter). Nói cách khác, streaming là một hợp đồng ba bên: server gửi dần, mạng không gộp lại (TCP_NODELAY), và client đọc dần. Thiếu bất kỳ mắt nào, cái lợi TFB thấp bốc hơi.
Vì sao điều này quan trọng khi lập trình
Hệ quả lớn nhất là về trải nghiệm với nội dung sinh dần. Bất cứ khi nào server tạo dữ liệu theo thời gian — kết xuất một báo cáo lớn, đẩy log trực tiếp, trả kết quả tìm kiếm chảy về từ nhiều nguồn, hay stream từng token của một mô hình ngôn ngữ — chunked cho phép client thấy và dùng phần đầu ngay, thay vì chờ toàn bộ. Với một câu trả lời mất vài giây để sinh, khác biệt giữa "byte đầu sau 100 ms" và "byte đầu sau vài giây" là khác biệt giữa giao diện phản hồi ngay và giao diện đứng hình rồi bung một lúc.
Hệ quả thứ hai là hiểu đúng khi nào nên dùng cái nào. Nếu bạn đã có sẵn toàn bộ nội dung và biết cỡ (tệp tĩnh, chuỗi trong bộ nhớ), Content-Length gọn hơn và còn cho client biết trước tổng cỡ để hiện thanh tiến độ. Chunked dành cho khi cỡ chưa biết hoặc bạn muốn stream. Đừng chunked một tệp tĩnh chỉ vì nghe hiện đại — bạn thêm khung mà chẳng được gì.
Hệ quả thứ ba là một điểm về nền tảng: HTTP/2 và HTTP/3 không dùng Transfer-Encoding: chunked — chúng có cơ chế đóng khung nhị phân riêng — nhưng khái niệm streaming thì vẫn nguyên, và cùng nguyên tắc "gửi khi có, đừng buffer nếu muốn TFB thấp" vẫn đúng. Con số mang theo: với nội dung sinh dần, Content-Length buộc chờ trọn thời gian sinh mới có byte đầu, còn chunked stream cho byte đầu tới sớm gấp nhiều lần — nhưng chỉ khi server thật sự gửi từng khối ngay, chứ không phải chỉ dán đúng cái header. Streaming là một quyết định về cách gửi, không phải một dòng cấu hình.
Thử ba mươi giây
Chạy curl -N -s https://mot-may-chu-stream với cờ -N (tắt buffer của curl) tới một endpoint stream — ví dụ một API trả server-sent events, hay một endpoint log trực tiếp — và bạn sẽ thấy dữ liệu hiện ra dần dần theo thời gian thay vì bung một lúc. So với curl -sv https://... tới một trang tĩnh: trong header trả về, tìm dòng Content-Length: (biết trước cỡ) hay Transfer-Encoding: chunked (stream). Hai header đó cho bạn biết ngay server đang chọn cách nào để đẩy thân xuống.