Cách nhanh nhất để tải một tài nguyên là không tải nó. Bộ nhớ đệm HTTP xây quanh đúng ý đó: nếu client đã có một bản và nó chưa đổi, đừng gửi lại toàn bộ. Có hai cơ chế làm việc này, và chúng tiết kiệm những thứ khác nhau. Bài này dựng một máy chủ hỗ trợ ETag của riêng tôi, đo chính xác một lần revalidate tiết kiệm bao nhiêu byte, và chỉ ra một lỗi tính ETag khiến toàn bộ cơ chế đệm sụp đổ trong im lặng.
Hai tầng: khỏi hỏi, hoặc hỏi rẻ
Đệm HTTP có hai tầng, và phân biệt chúng là chìa khóa. Tầng thứ nhất là độ tươi qua Cache-Control: max-age=N: trong N giây kể từ khi nhận, client coi bản đệm là còn tươi và dùng thẳng — không gửi một request nào. Đây là trường hợp tốt nhất: không gói, không round-trip, không byte.
Tầng thứ hai là kiểm chứng lại (revalidate), dùng khi bản đệm đã hết hạn tươi. Thay vì tải lại mù, client hỏi server "cái tôi đang giữ có còn đúng không?" bằng một request điều kiện. Nếu nội dung không đổi, server trả 304 Not Modified không kèm thân. Client biết bản đệm của mình vẫn dùng được, và cả thân trả lời được bỏ qua.
Khác biệt quan trọng: tầng một tiết kiệm cả round-trip (không hỏi gì cả), tầng hai chỉ tiết kiệm thân (vẫn tốn một round-trip để hỏi). Cả hai đều hữu ích, cho các tình huống khác nhau.
ETag và 304, đo bằng byte
ETag là "dấu vân tay" của nội dung — một chuỗi server gắn vào response, thường là băm của thân. Lần đầu client nhận 200 OK kèm ETag: "abc123" và lưu lại. Lần sau, thay vì tải lại, client gửi If-None-Match: "abc123". Server so: nếu nội dung vẫn cho ra ETag đó, nó trả 304; nếu đã đổi, trả 200 với thân mới và ETag mới.
Tôi đo với một trang 56 KB:
| Tình huống | Trạng thái | Byte trên dây | Thời gian |
|---|---|---|---|
| Lần đầu (chưa đệm) | 200 OK | 56.181 B | ~170 ms |
| Revalidate, không đổi | 304 Not Modified | 152 B | ~55 ms |
Con số nói rõ: lần revalidate chỉ tốn 152 byte trên dây thay vì 56.181 — server không gửi lại thân, chỉ gửi vài dòng header xác nhận "chưa đổi". Với một trang lớn, đó là tiết kiệm hơn 99% băng thông, và vì không phải truyền 56 KB thân nên thời gian cũng giảm mạnh.
Nhưng có một điểm tinh tế đáng ghi nhớ: một 304 vẫn tốn một round-trip. Client phải gửi request điều kiện và chờ câu trả lời — nó tiết kiệm thân, không tiết kiệm chuyến đi mạng. Chỉ tầng một (bản đệm còn tươi trong max-age) mới bỏ được cả round-trip. Nếu bạn đặt max-age quá ngắn, client sẽ revalidate liên tục: mỗi lần né được thân nhưng vẫn trả một round-trip cho mỗi tài nguyên — nhanh hơn tải lại, nhưng chưa phải nhanh nhất.
Một lần tôi đo hớ: ETag đổi mỗi lần
Đây là chỗ cơ chế đệm âm thầm hỏng, và tôi tái hiện đúng cái lỗi rất phổ biến đó. Lần đầu viết server, tôi tính ETag có kèm một bộ đếm tăng mỗi lần trả lời — bắt chước kiểu nhiều framework vô tình chèn timestamp hay một giá trị biến động vào ETag. Đo lại revalidate:
BUG: ETag đổi mỗi lần:
Revalidate với If-None-Match -> 200 OK, nguyên 56.216 byte thân
304 biến mất. Vì ETag server tính lần này khác ETag client đang giữ, If-None-Match không bao giờ khớp, nên server luôn kết luận "đã đổi" và gửi lại toàn bộ 56 KB — mỗi lần. Cơ chế đệm sụp hoàn toàn, mà không có một lỗi nào báo ra: mọi thứ vẫn 200 OK, trang vẫn hiện đúng, chỉ có băng thông bị đốt gấp nhiều lần một cách vô hình.
Cách sửa hiển nhiên khi nhìn lại: ETag phải là một hàm ổn định của nội dung — băm chính thân trả lời — chứ không phụ thuộc thời điểm, tiến trình, hay số lần phục vụ. Cùng nội dung phải luôn cho cùng ETag, kể cả khi phục vụ từ một máy chủ khác trong cụm.
Bài học đo lường: một cơ chế tối ưu hỏng thường không kêu. ETag động vẫn cho ra HTTP hợp lệ, trang vẫn chạy, test chức năng vẫn xanh — chỉ có phép đo byte thật trên dây mới lộ ra rằng mọi revalidate đang tải lại toàn bộ. Đây là kiểu lỗi bạn chỉ bắt được khi đo cái bạn nghĩ đã tiết kiệm, chứ không phải khi kiểm tra "nó có chạy không".
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là đặt Cache-Control cho đúng ý định. Tài nguyên tĩnh có tên chứa dấu vân tay (như app.9f3c2a.js) có thể đặt max-age rất dài (thậm chí immutable) vì khi nội dung đổi thì tên file đổi theo — không bao giờ cần revalidate. Ngược lại, nội dung có thể đổi bất cứ lúc nào (trang HTML chính, một API) nên dùng revalidate (no-cache nghĩa là "luôn hỏi lại", không phải "đừng đệm"). Chọn sai hai thứ này là hoặc phục vụ nội dung cũ, hoặc bỏ phí toàn bộ cơ chế đệm.
Hệ quả thứ hai là giữ ETag ổn định. Như phép đo cho thấy, một ETag động lặng lẽ vô hiệu hóa 304. Băm nội dung là cách an toàn; đừng nhét vào ETag bất cứ thứ gì thay đổi mà nội dung không đổi. Đây cũng là điểm chung với bài về TTL của DNS: cả hai đều là chuyện "bản đệm còn dùng được không", chỉ khác DNS quyết bằng đồng hồ (TTL) còn HTTP có thêm cách quyết bằng dấu vân tay (ETag) — chính xác hơn vì nó phản ánh nội dung chứ không chỉ thời gian.
Hệ quả thứ ba là hiểu đúng 304 tiết kiệm gì và không tiết kiệm gì: nó cắt phần thân (thường là phần lớn nhất), nhưng vẫn trả một round-trip cho câu hỏi điều kiện. Với một trang media 56 KB, tiết kiệm đó khổng lồ. Với một API trả 50 byte, một 304 gần như chẳng tiết kiệm byte nào mà vẫn tốn nguyên round-trip — ở đó, một max-age ngắn (để bỏ hẳn round-trip khi còn tươi) thường tốt hơn revalidate liên tục. Con số mang theo: một revalidate với ETag khớp biến một response 56 KB thành 152 byte, nhưng chỉ khi ETag ổn định theo nội dung; ETag động biến 304 thành 200 và trả lại toàn bộ băng thông một cách im lặng. Đệm HTTP là công cụ mạnh, nhưng nó chỉ hoạt động khi bạn nói đúng cho nó biết "cái gì tươi bao lâu" và "cái gì là dấu vân tay của nội dung".
Thử ba mươi giây
Tải một tài nguyên hai lần bằng curl và xem lần hai có thành 304 không. Lần đầu: curl -sD - -o /dev/null https://mot-trang — tìm dòng etag: trong header. Lần hai, gửi lại chính ETag đó: curl -sD - -o /dev/null -H 'If-None-Match: "<dán etag vào đây>"' https://mot-trang. Nếu server trả HTTP/… 304 Not Modified thì đệm đang hoạt động; nếu vẫn 200 OK dù nội dung không đổi, ETag của nó có thể không ổn định — đúng cái bug bài này đo. Trên trình duyệt, mở tab Network và tìm cột Size ghi "(from disk cache)" hay các dòng 304 để thấy đệm làm việc thật.