bài về HTTP/2, tôi để lại một việc dang dở: HTTP/2 gỡ chặn đầu hàng ở tầng HTTP nhưng vẫn dính chặn đầu hàng ở tầng TCP, và tôi không đo được điều đó vì response quá nhỏ. HTTP/3 sinh ra để giải nốt: nó chạy trên QUIC (nền UDP thay vì TCP), với mỗi luồng có thứ tự riêng nên một gói rơi không chặn luồng khác. Bài này định đo hai thế mạnh của HTTP/3 — bắt tay nhanh hơn và chịu mất gói tốt hơn — và trở thành ví dụ rõ nhất cả sê-ri về việc phép đo có thể đánh lừa chính người đo.

HTTP/3 QUIC

QUIC được thiết kế để làm gì

QUIC là một giao thức vận chuyển mới chạy trên UDP, tự làm phần tin cậy và kiểm soát tắc nghẽn ở tầng ứng dụng thay vì dựa vào TCP. Nó có ba thế mạnh cấu trúc so với HTTP/2 trên TCP:

  • Bắt tay ít vòng hơn. QUIC gộp bắt tay vận chuyển và bắt tay TLS 1.3 làm một, xong trong một vòng khứ hồi; khi nối lại một máy chủ đã gặp, có thể gửi dữ liệu ngay từ vòng đầu (0-RTT). TCP cộng TLS cần khoảng hai vòng.
  • Không chặn đầu hàng ở tầng vận chuyển. Mỗi luồng QUIC có không gian thứ tự riêng, nên một gói rơi của luồng này không giữ chân dữ liệu đã tới của luồng khác — đúng cái mà HTTP/2 trên một kết nối TCP không làm được.
  • Di trú kết nối. Một kết nối QUIC được định danh bằng một số nhận dạng chứ không bằng bộ bốn IP/cổng, nên điện thoại đổi từ wifi sang 4G vẫn giữ nguyên kết nối, không phải bắt tay lại.

Đó là lý thuyết. Tôi dựng một máy chủ HTTP/3 thật bằng thư viện aioquic và một máy chủ HTTP/2 thật, rồi đo.

Phép đo — và cú lật ngược

Phép đo (response 30 KB, RTT ~60 ms) HTTP/2 (nhân) HTTP/3 (aioquic)
Bắt tay + 1 request (kết nối mới) 176 ms 554 ms
20 request đồng thời, không mất gói 310 ms 164 ms
20 request đồng thời, mất gói 3% 0,4 – 4,0 s 1,5 – 2,7 s

Nhìn dòng đầu: bắt tay HTTP/3 đo ra 554 mili giây, chậm gấp ba lần HTTP/2. Điều này trái ngược hoàn toàn với "QUIC bắt tay nhanh hơn" mà tôi định chứng minh. Dòng cuối cũng vậy: dưới mất gói, HTTP/3 thường chậm hơn chứ không nhanh hơn, và rất nhiễu. Nếu tin vào bảng này, tôi phải kết luận "HTTP/3 tệ hơn HTTP/2 ở đúng hai thứ nó được cho là giỏi".

Kết luận đó sai, và cái sai không nằm ở giao thức mà ở công cụ đo.

Một lần tôi đo hớ: đua Python với nhân

Vấn đề là tôi đang so hai thứ không cùng hạng cân. HTTP/2 trong phép đo chạy trên ngăn xếp TCP của nhân Linux với phần mã hóa TLS bằng thư viện C đã tối ưu hàng chục năm. Còn HTTP/3 chạy bằng aioquic — một cài đặt QUIC viết thuần Python, nơi mỗi gói UDP được xử lý bằng vòng lặp Python và mỗi bước bắt tay TLS 1.3 (tạo khóa, ký, xác thực) chạy bằng code Python chậm chạp. Con số 554 mili giây của "bắt tay QUIC" phần lớn là thời gian CPU của Python, không phải thời gian mạng. Ở RTT 60 ms, một bắt tay một-vòng đáng lẽ chỉ tốn hơn 60 ms; 554 ms là dấu hiệu CPU đang nuốt trọn phần còn lại.

Chi phí CPU của thư viện lớn tới mức nó che khuất hoàn toàn khác biệt cấp giao thức. Bắt tay QUIC ít vòng khứ hồi hơn thật, nhưng mỗi vòng đó bị kéo dài bởi công Python xử lý mã hóa, nên tổng thời gian lại dài hơn. Dưới mất gói, aioquic phải phát lại và tính toán lại bằng Python, cũng chậm và nhiễu. Cái tôi đo được không phải "QUIC so với TCP" mà là "Python so với nhân".

Bài học đo lường, và là bài học lớn nhất của cả sê-ri này: công cụ đo cũng là một biến, và đôi khi nó là biến lớn nhất. Suốt các phần trước tôi đã nhắc "máy chủ mình tự dựng là một biến", "đường thí nghiệm là một biến"; đây là ca cực đoan, nơi cài đặt của một bên chênh lệch hiệu năng với bên kia hàng bậc độ lớn, khiến mọi so sánh wall-clock trở nên vô nghĩa. Muốn đo giao thức, phải hoặc so hai cài đặt trưởng thành ngang nhau (ví dụ HTTP/3 trong nginx hay trong nhân, đã tối ưu bằng C), hoặc đo cấu trúc — đếm số vòng khứ hồi của bắt tay chẳng hạn — chứ không phải đua đồng hồ giữa một thư viện đồ chơi và một ngăn xếp sản xuất.

Điểm sáng duy nhất tương đối sạch là dòng giữa: 20 request đồng thời không mất gói, HTTP/3 xong trong 164 ms so với 310 ms của HTTP/2. Nhưng ngay cả con số đó tôi cũng không dám khoe to, vì nó vẫn có thể lẫn hiệu ứng cài đặt; tôi chỉ dám nói HTTP/3 cạnh tranh được khi kết nối đã ấm và CPU được phân bổ song song.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên là về cách đánh giá công nghệ mới. Rất dễ chạy một benchmark nhanh, thấy HTTP/3 (qua một thư viện non trẻ) chậm hơn HTTP/2, và kết luận "HTTP/3 chưa đáng dùng". Kết luận đó có thể đúng cho thư viện đó ở thời điểm đó, nhưng nói lên rất ít về giao thức. Khi đo một protocol, hãy hỏi: mình đang đo giao thức, hay đang đo cài đặt? Câu trả lời thường là cái sau, và biết vậy giúp không bác bỏ nhầm một thiết kế tốt vì một cài đặt chưa chín.

Hệ quả thứ hai là về HTTP/3 trong thực tế. Thế mạnh cấu trúc của nó — bắt tay ít vòng, không chặn đầu hàng tầng vận chuyển, di trú kết nối — thật sự tỏa sáng trên đúng những đường mà bài này không mô phỏng tốt: mạng di động chập chờn với mất gói cao và người dùng chuyển vùng liên tục. Ở đó, các cài đặt QUIC trưởng thành (của Google, Cloudflare, các CDN lớn) cho cải thiện đo được. Điểm yếu TCP head-of-line mà tôi không phô được ở bài HTTP/2 là có thật, và QUIC gỡ đúng nó — chỉ là bạn cần một cài đặt tương xứng với ngăn xếp nhân để thấy, không phải một thư viện Python.

Con số mang theo từ bài này không phải một con số, mà là một nguyên tắc: trước khi so sánh hai giao thức bằng đồng hồ, hãy chắc chắn hai cài đặt cùng hạng cân — nếu không, bạn đang đo thư viện chứ không đo giao thức. Thế mạnh của QUIC là thật và mang tính cấu trúc; phép đo của tôi chỉ chứng minh rằng một cài đặt thuần Python không đủ để lộ ra chúng. Đôi khi kết quả trung thực nhất của một phép đo là nhận ra chính phép đo đã bị hỏng.

Thử ba mươi giây

Xem một trang có dùng HTTP/3 không: mở DevTools tab Network, cột Protocol, tìm h3. Hoặc dòng lệnh với một curl có hỗ trợ HTTP/3: curl -sI --http3 https://cloudflare.com | head -1 — nếu thấy HTTP/3 200 thì kết nối chạy trên QUIC. Muốn thấy khác biệt bắt tay một cách công bằng, đừng đo bằng thư viện đồ chơi; thay vào đó bắt gói bằng tcpdumpđếm số vòng khứ hồi từ gói đầu tiên tới byte dữ liệu đầu tiên — đó là con số thuộc về giao thức, không phụ thuộc CPU của cài đặt.