HTTP/2 mang theo một tính năng nghe rất hấp dẫn: server push. Thay vì chờ client tải HTML, đọc nó, phát hiện cần style.css rồi mới xin, server có thể đẩy luôn style.css kèm HTML — tiết kiệm một vòng khứ hồi. Nghe hoàn hảo, nhưng tính năng này đã bị các trình duyệt khai tử (Chrome gỡ năm 2022). Bài này đo cả cái lợi và cái hại của push để thấy vì sao một ý tưởng đẹp lại thất bại — và tôi vấp đúng cái bẫy đã khiến cả ngành đánh giá nhầm nó.

HTTP/2 server push

Ý tưởng: gửi trước cái client chưa xin

Không có push, chuỗi tải một trang là: client tải HTML (một vòng khứ hồi), phân tích, thấy thẻ <link rel=stylesheet href=/style.css>, rồi mới gửi request thứ hai xin CSS (một vòng nữa). Hai vòng cho một trang đơn giản. Server push cắt bước đó: khi client xin HTML, server gửi cả HTML lẫn CSS trong một lượt, để client có sẵn CSS ngay khi phân tích xong HTML.

Tôi mô phỏng đúng hai chiến lược này trong container — một server trả về HTML rồi để client tự xin CSS (tuần tự), và một server đẩy luôn cả CSS kèm HTML (push) — rồi đo trên một đường có độ trễ giả lập, với một tệp CSS ~30 KB.

Cái lợi: khách mới né được một vòng

Với một khách mới, cache rỗng, chưa có gì:

Cách Thời gian Byte nhận về
Tuần tự (HTML rồi CSS) ~185 ms 33.072 B
Push (HTML + CSS một lượt) ~120 ms 33.072 B

Push né được đúng một vòng khứ hồi (185 xuống 120 mili giây) với cùng số byte truyền. Client không phải chờ tải xong HTML rồi mới đi xin CSS — CSS đã nằm sẵn. Đây là cái lợi mà mọi bài giới thiệu server push đều khoe, và đo ra nó có thật.

Nhìn con số này, tôi suýt kết luận ngay: "push là thắng lợi rõ ràng, nên bật". Nhưng đó là lúc tôi mới đo một tình huống.

Cái hại: khách quay lại lãnh đủ

Đây là chỗ tôi đo hớ, và cũng là chỗ cả ngành từng đo hớ. Tôi đo tiếp tình huống khách quay lại — người đã ghé trang trước đó và đã có style.css trong cache (còn hạn, kèm ETag như bài về đệm HTTP):

Cách Thời gian Byte nhận về
Tuần tự (HTML rồi CSS) ~125 ms 72 B
Push (HTML + CSS một lượt) ~108 ms 33.072 B

Kết quả lật ngược hoàn toàn. Đường tuần tự chỉ tốn 72 byte: client tải HTML, rồi gửi một request điều kiện cho CSS, và vì đã có bản còn hạn nên nhận về 304 Not Modified không kèm thân — gần như miễn phí. Đường push vẫn gửi lại nguyên 33 KB CSS mà client đã có sẵn, để đổi lấy vài mili giây. Push đốt 33 KB băng thông cho một thứ hoàn toàn thừa.

Nguyên nhân gốc: server đẩy mù. Lúc quyết định push, server không biết client đã có style.css trong cache hay chưa — client chưa kịp nói gì. Nên nó push bất kể, và với người đã có sẵn tài nguyên, mỗi lần push là một lần lãng phí. Tệ hơn, dữ liệu push còn tranh băng thông với chính cái HTML mà client cần trước tiên.

Bài học đo lường, và là bài học tôi phải nhắc lại cho chính mình: đo trúng ca thuận lợi rất dễ khiến ta khen nhầm một cơ chế. Cache rỗng là ca đẹp nhất của push, và nếu chỉ đo nó thì push trông tuyệt vời. Nhưng trong thực tế, phần lớn lượt truy cập là khách quay lại với cache ấm — chính ca mà push gây hại. Trung bình trên lưu lượng thật, cái hại (đẩy lại tài nguyên đã cache) thường lớn hơn cái lợi (né một vòng cho khách mới). Muốn đánh giá đúng một tối ưu, phải đo phân bố các tình huống thật, không chỉ ca đẹp nhất.

Vì sao không "vá" được cho biết cache

Có người sẽ hỏi: sao không cho client hủy một push nếu đã có tài nguyên? HTTP/2 đúng là có cơ chế đó — client gửi khung RST_STREAM để từ chối một luồng push. Nhưng nó tới quá trễ để cứu băng thông. Server bắt đầu đẩy CSS ngay khi trả HTML; khung RST_STREAM của client phải đi hết một vòng khứ hồi mới tới server. Trong khoảng một vòng đó, server đã bơm đi một lượng dữ liệu đúng bằng tích băng thông–độ trễ của đường (như đã đo ở bài cửa sổ TCP) — có thể là hàng chục KB trước khi lệnh hủy kịp có hiệu lực.

Nói cách khác, không có cách nào để push "hỏi trước rồi mới gửi" mà vẫn giữ được cái lợi né-một-vòng: nếu server chờ client xác nhận thì đã mất đúng cái vòng khứ hồi mà push định tiết kiệm. Đây là mâu thuẫn nội tại không vá được, và là lý do sâu xa khiến push bị bỏ thay vì được sửa.

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

Hệ quả đầu tiên, rất cụ thể: đừng dùng server push — nó đã bị các trình duyệt lớn gỡ bỏ, đúng vì lý do vừa đo. Nếu bạn thấy cấu hình push trong một dự án cũ, gỡ nó đi thường là cải thiện chứ không phải mất mát.

Hệ quả thứ hai là hiểu cách làm đúng thay thế: gợi ý tải trước (preload), qua header Link: rel=preload hoặc mã trạng thái 103 Early Hints. Khác biệt then chốt là ở đây client vẫn là bên quyết định: server chỉ gợi ý "bạn sắp cần style.css đấy", còn client tự kiểm cache của mình rồi mới xin nếu chưa có. Nhờ vậy nó có được cái lợi của push (biết sớm về tài nguyên tới hạn, khởi động tải sớm) mà không dính cái hại (đẩy lại thứ đã cache), vì quyết định tải nằm ở phía biết rõ cache — client.

Hệ quả thứ ba là một bài học thiết kế rộng hơn: một bên không nên hành động thay bên kia khi nó không biết trạng thái của bên kia. Push thất bại vì server đoán thay client về cache. Cùng nguyên tắc đó giải thích vì sao caching dựa trên ETag/304 (client hỏi, server đáp) bền vững còn push (server tự quyết) thì không. Con số mang theo: server push né được một vòng khứ hồi cho khách mới, nhưng đẩy lại toàn bộ tài nguyên đã cache cho khách quay lại — và vì đa số lưu lượng là khách quay lại, nó thường hại nhiều hơn lợi. Đó là một trong những ví dụ đẹp nhất về việc một tối ưu nghe hợp lý lại sai khi gặp thực tế, và về việc phải đo đúng tình huống thật mới thấy.

Thử ba mươi giây

Bạn khó thử push trực tiếp nữa vì trình duyệt đã gỡ, nhưng thử được cái thay thế của nó. Tìm một trang lớn và mở DevTools tab Network: nếu server dùng preload, bạn sẽ thấy các tài nguyên tới hạn (font, CSS chính) bắt đầu tải rất sớm, gần như cùng lúc với HTML, kèm ghi chú "preload". So với một trang không preload, nơi các tài nguyên đó chỉ bắt đầu tải sau khi HTML tải và phân tích xong. Đó là cái lợi của push — biết sớm về tài nguyên tới hạn — được lấy lại một cách an toàn, do client quyết định thay vì server đẩy mù.