Một trang web hiện đại tải hàng chục, có khi hàng trăm tài nguyên. Cách HTTP/1.1 và HTTP/2 xử lý chuyện đó khác nhau căn bản, và khác biệt ấy đo được rõ ràng. Bài này dựng một máy chủ nói được cả hai giao thức trong container, bắn 20 request qua từng cách, và đo thời gian — rồi thử phô điểm yếu nổi tiếng của HTTP/2 và phát hiện phép đo của mình chưa đủ để nó lộ ra.
HTTP/1.1: một request mỗi lần trên một kết nối
Giới hạn cốt lõi của HTTP/1.1 là: trên một kết nối, các request phải nối đuôi nhau — hỏi, đáp, rồi mới hỏi tiếp. Có một cơ chế gọi là pipelining cho phép gửi dồn nhiều request, nhưng response vẫn phải trả về đúng thứ tự, nên một response chậm chặn tất cả những cái sau nó (chặn đầu hàng ở tầng HTTP) — và vì lý do đó pipelining gần như không được dùng trong thực tế.
Cách trình duyệt lách qua giới hạn này suốt nhiều năm là mở nhiều kết nối song song — thường tối đa 6 kết nối tới mỗi máy chủ. Sáu kết nối cho phép sáu request chạy cùng lúc. Nhưng đó là một cách chữa vụng: mỗi kết nối trả riêng thuế bắt tay (TCP, và TLS), mỗi kết nối có cửa sổ tắc nghẽn riêng phải khởi động lại từ đầu, và bạn vẫn bị chặn ở đúng sáu việc đồng thời.
HTTP/2: nhiều luồng trên một kết nối
HTTP/2 giải quyết tận gốc bằng ghép luồng (multiplexing). Trên một kết nối TCP duy nhất, nó chạy nhiều luồng (stream) đồng thời — mỗi request/response là một luồng, và dữ liệu của các luồng được cắt thành khung nhị phân đan xen nhau trên đường. Hàng chục request có thể đang bay cùng lúc; một response chậm không giữ chân các response khác, vì chúng là những luồng độc lập. HTTP/2 cũng nén header bằng HPACK (giải cho vấn đề header lặp không nén đã đo ở bài một GET).
Tôi đo với 20 request, mỗi cái server mất 100 mili giây để xử lý, trên một đường có độ trễ giả lập:
| Cách | Kết nối | Thời gian | So với h2 |
|---|---|---|---|
| HTTP/1.1, 1 kết nối (nối đuôi) | 1 | 3396 ms | 13,6× |
| HTTP/1.1, 6 kết nối (kiểu trình duyệt) | 6 | 823 ms | 3,3× |
| HTTP/2, ghép luồng | 1 | 249 ms | 1× |
Con số nói rõ. HTTP/1.1 trên một kết nối serialize hoàn toàn: 20 request nối đuôi, mỗi cái chờ cái trước, thành 3,4 giây. Mở sáu kết nối cắt xuống còn 823 mili giây — nhanh hơn nhưng vẫn phải chia 20 request thành các đợt sáu-một. HTTP/2 trên một kết nối duy nhất xong trong 249 mili giây, vì cả 20 request bay đồng thời như những luồng song song. HTTP/2 trên một kết nối nhanh hơn HTTP/1.1 trên sáu kết nối 3,3 lần — và không cần mở thêm kết nối nào.
Một giả thuyết của tôi không đúng khi đo
Tôi tự tin có thể phô ngay điểm yếu nổi tiếng của HTTP/2, và định biến nó thành phần hay nhất bài. Lập luận thế này: vì mọi luồng HTTP/2 chung một kết nối TCP, một gói rơi đáng lẽ chặn tất cả các luồng — vì TCP giao dữ liệu đúng thứ tự, gói sau phải chờ gói rơi được phát lại (chặn đầu hàng ở tầng TCP, đúng cơ chế đã đo ở bài TCP so với UDP). Trong khi đó, sáu kết nối của HTTP/1.1 cô lập được mất mát: một gói rơi chỉ chặn một trong sáu. Vậy thêm mất gói vào, HTTP/2 phải tệ đi tương đối.
Tôi đo thật ở mức mất gói 4%. Kết quả trái với dự đoán: HTTP/2 vẫn nhanh hơn — khoảng 250 tới 475 mili giây, so với ~1200 mili giây của HTTP/1.1 sáu kết nối. Điểm yếu tôi định phô không hề xuất hiện.
Lý do, khi nhìn kỹ, nằm ở hình dạng phép đo của tôi. Mỗi response trong bài chỉ có 200 byte — gọn trong một gói. Khi mỗi luồng chỉ là một gói, một gói rơi chỉ chặn đúng luồng của nó; không có dữ liệu của luồng khác nằm phía sau gói rơi đó trong dòng byte TCP để mà bị kẹt. Chặn đầu hàng ở tầng TCP chỉ thực sự lộ ra khi các luồng dài nhiều gói và đan xen nhau, để một gói rơi của luồng này chặn cả những gói đã tới của luồng khác. Điểm yếu là có thật, nhưng phép đo của tôi sai hình dạng để bộc lộ nó.
Bài học đo lường: một nhược điểm có thật vẫn có thể không hiện ra nếu bài đo không đúng dạng để lộ nó. Tôi suýt viết "HTTP/2 chẳng hề yếu trước mất gói" — một kết luận đúng với dữ liệu của tôi nhưng sai với bản chất giao thức. Đây cũng chính là lý do HTTP/3 ra đời: nó chạy trên QUIC (nền UDP) với mỗi luồng có thứ tự riêng, nên một gói rơi của một luồng không còn chặn các luồng khác — gỡ đúng cái chặn đầu hàng ở tầng vận chuyển mà HTTP/2 vẫn dính khi luồng dài.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: bật HTTP/2 gần như luôn đáng. Với một trang nhiều tài nguyên, ghép luồng cho tốc độ tải nhanh hơn hẳn mà chỉ tốn một kết nối — ít bắt tay hơn, ít bộ nhớ hơn ở cả hai đầu, một cửa sổ tắc nghẽn chung được hâm nóng thay vì sáu cửa sổ lạnh. Nó cũng khiến vài mẹo tối ưu cũ của thời HTTP/1.1 trở nên thừa hoặc phản tác dụng: "chia tên miền" (domain sharding) để lách giới hạn sáu kết nối giờ chỉ làm hại vì nó cắt vụn lợi ích ghép luồng; gộp nhiều ảnh vào một sprite hay gộp nhiều file JS làm một cũng bớt cần thiết khi từng tài nguyên đi song song rẻ.
Hệ quả thứ hai là hiểu đúng chặn đầu hàng còn lại. HTTP/2 gỡ chặn đầu hàng ở tầng HTTP, nhưng vẫn ngồi trên một kết nối TCP duy nhất, nên chặn đầu hàng ở tầng TCP vẫn còn — và trên một đường mất gói nhiều với các tài nguyên lớn, một kết nối HTTP/2 có thể thua nhiều kết nối HTTP/1.1. Đây không phải lý thuyết suông; nó là động lực trực tiếp của HTTP/3. Khi chọn giao thức cho một đường truyền tệ (mạng di động chập chờn, vệ tinh), đây là yếu tố cần cân nhắc thật.
Con số mang theo: HTTP/2 ghép nhiều request thành nhiều luồng song song trên một kết nối, nhanh hơn cả cách sáu-kết-nối của HTTP/1.1 nhiều lần; nó xóa chặn đầu hàng ở tầng HTTP nhưng vẫn để lại chặn đầu hàng ở tầng TCP, thứ mà HTTP/3 mới gỡ hẳn. Ghép luồng là một trong những cải tiến hiệu năng lớn nhất của web trong thập kỷ qua, và giờ bạn đã thấy nó bằng số đo thật.
Thử ba mươi giây
Xem một trang dùng giao thức nào: mở DevTools của trình duyệt, tab Network, thêm cột "Protocol" — bạn sẽ thấy h2 (HTTP/2), h3 (HTTP/3) hay http/1.1 cho từng tài nguyên. Hoặc bằng dòng lệnh: curl -sI --http2 https://mot-trang | head -1 — nếu thấy HTTP/2 200 thì server nói HTTP/2. So một trang tải nhiều tài nguyên nhỏ trên h2 với một trang cũ trên h1.1, nhìn cột thời gian trong tab Network để thấy các request h2 chồng lên nhau (song song) thay vì xếp thành bậc thang (nối đuôi) như h1.1.