HTTP cache headers: Cache-Control, ETag, Vary
Header cache: để trình duyệt tự quyết định thay server
Bài trước bạn đã chọn TTL cho cache nội bộ (Redis, cache-aside): dữ liệu này cũ bao lâu thì chấp nhận được. Bài này mang đúng câu hỏi đó ra khỏi server của bạn: trình duyệt, proxy và CDN được phép giữ response bao lâu, và làm sao chúng biết bản đang giữ còn đúng. Nhắc lại một dòng: TTL là hạn dùng của một bản copy, hết hạn thì phải xác minh lại hoặc tải lại.
Sau bài này bạn sẽ:
- Viết được
Cache-Controlvớimax-age,s-maxage,private/public,no-store. - Phân biệt rõ
no-cachevàno-store. - Giải thích luồng
ETag/Last-Modifiedvới304 Not Modified. - Dùng
Varyđể cache không trả nhầm phiên bản. - Chỉ ra được thời điểm trình duyệt trả lời mà không gọi server.
Khi nào trình duyệt trả lời từ cache
Trước khi đọc từng header, hãy nhìn luồng quyết định. Mọi directive bên dưới chỉ là cách bạn chỉnh các nhánh rẽ trong sơ đồ này.
flowchart TD
Req["🌐<br/>Request tới URL"] --> Q1{"Có bản trong cache?"}
Q1 -- "Không" --> Net["Gọi server"]
Q1 -- "Có" --> Q2{"Còn hạn max-age?"}
Q2 -- "Còn" --> Fresh["✅<br/>Dùng luôn"]
Q2 -- "Hết hạn" --> Q3{"Có ETag?"}
Q3 -- "Có" --> Reval["Gửi If-None-Match"]
Q3 -- "Không" --> Net
Reval --> R{"Server trả gì?"}
R -- "304" --> Reuse["♻️<br/>Dùng bản cũ"]
R -- "200" --> New["📥<br/>Nhận bản mới"]
Net --> New
Hai nhánh quan trọng nhất: còn hạn (không gọi server, nhanh nhất) và hết hạn nhưng còn ETag (gọi server nhưng gần như không tải dữ liệu).
Cache-Control: bảng điều khiển chính
Cache-Control là nơi bạn đặt hạn dùng giống như hạn in trên hộp sữa trong tủ lạnh. Hết hạn không có nghĩa là sữa hỏng, chỉ là phải ngửi lại trước khi uống (revalidate).
| Directive | Ý nghĩa | Ai nghe |
|---|---|---|
max-age=600 |
Response tươi trong 600 giây | Mọi cache, gồm trình duyệt |
s-maxage=86400 |
TTL riêng cho shared cache | CDN, proxy |
public |
Shared cache được phép lưu | CDN, proxy |
private |
Chỉ cache riêng của người dùng được lưu | Trình duyệt |
no-store |
Cấm lưu ở mọi nơi | Tất cả |
no-cache |
Được lưu nhưng phải revalidate trước khi dùng | Tất cả |
must-revalidate |
Hết hạn thì cấm dùng bản stale | Tất cả |
stale-while-revalidate=30 |
Trả bản cũ tối đa 30 giây, làm mới ngầm | Tất cả |
Cái bẫy kinh điển: no-cache không có nghĩa là không lưu. Nó chỉ có nghĩa là "lưu thì lưu, nhưng lần nào dùng cũng phải hỏi lại server". Header cấm lưu thật sự là no-store.
Ví dụ một response sản phẩm:
HTTP/1.1 200 OK
Content-Type: text/html
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=30
Đọc thành lời: trình duyệt giữ 60 giây (_____ là hạn tươi, không gọi server), CDN giữ 300 giây, và trong 30 giây sau khi hết hạn thì được trả bản cũ kèm việc làm mới ngầm. s-maxage chỉ áp dụng cho _____, không áp dụng cho trình duyệt.
ETag và Last-Modified: xác minh rẻ hơn tải lại
Sau khi hết max-age, trình duyệt chưa chắc phải tải lại toàn bộ. Nếu response có ETag, nó gửi kèm giá trị đó trong If-None-Match.
GET /api/products/42 HTTP/1.1
If-None-Match: "a1b2c3"
Server so với bản hiện tại. Chưa đổi thì trả:
HTTP/1.1 304 Not Modified
ETag: "a1b2c3"
Mã 304 không có body. Bạn tiết kiệm gần như toàn bộ băng thông mà client vẫn chắc dữ liệu đúng. Last-Modified là người anh em cũ hơn, hoạt động theo cặp với If-Modified-Since và chỉ chính xác tới mức giây, nên ETag được ưu tiên khi có thể.
ETag yếu viết là W/"a1b2c3": chấp nhận hai bản khác byte nhưng tương đương về ngữ nghĩa.
Vary: cùng URL nhưng khác phiên bản
Một URL không phải lúc nào cũng cho ra một response. Nếu response phụ thuộc vào request header nào, bạn phải khai báo header đó trong Vary, nếu không cache sẽ trả nhầm.
Vary: Accept-Encoding, Accept-Language
Thiếu Accept-Encoding mà bạn bật nén: client không hỗ trợ gzip có thể nhận file nén và hỏng. Thiếu Accept-Language: người dùng tiếng Việt nhận trang tiếng Anh. Header Vary cho cache biết response phụ thuộc vào _____ nào.
Với dữ liệu cá nhân, đừng chỉ dựa vào Vary. Response của /api/me phụ thuộc Authorization hay cookie thì nên chặn shared cache hẳn:
Cache-Control: private, no-cache
ETag: "user42-v7"
Ở đây private cấm CDN lưu, no-cache buộc trình duyệt hỏi lại mỗi lần, và ETag biến lần hỏi đó thành 304 rẻ. Lưu ý Vary: * gần như đồng nghĩa với không cache được.
Ghép lại: ba response mẫu
Trang chi tiết sản phẩm (đổi giá, nhưng không cần tức thời):
Cache-Control: public, max-age=60, stale-while-revalidate=30
ETag: "prod-42-v9"
Vary: Accept-Encoding
60 giây là mức "cũ chấp nhận được" cho giá. Không ai phải chờ revalidate vì có stale-while-revalidate.
File JS có hash trong tên: public, max-age=31536000, immutable. Nội dung đổi thì tên file đổi, nên cache dài hạn là an toàn. Phần này bài sau mở rộng.
Webhook hay dữ liệu tài chính: no-store. Không có lý do gì để bất kỳ cache nào giữ lại.
Năm lỗi header hay gặp trên production
- Nhầm
no-cachevớino-store, rồi tưởng đã cấm lưu trong khi CDN vẫn giữ. - Đặt
max-agevài giờ cho HTML, hậu quả là user thấy giá cũ sau khi deploy. - Bật nén nhưng quên
Vary: Accept-Encoding. - Quên
privatecho response cá nhân, CDN cache dữ liệu người này rồi trả cho người khác. - Sinh ETag từ thời điểm build thay vì từ nội dung, khiến 304 không bao giờ xảy ra.
Quy tắc của tôi: mỗi response, tự hỏi ba câu. Cái này cũ bao lâu thì chấp nhận được? Ai được phép giữ? Nó phụ thuộc header nào?
Chốt lại và bài tiếp theo
Cache-Controlquyết định hạn tươi, phạm vi (public/private) và cách xử lý khi hết hạn.- ETag biến lần hết hạn thành một lần xác minh rẻ với
304, không có body. Varylà bắt buộc khi response thay đổi theo request header, còn dữ liệu cá nhân thì đi kèmprivate.
Bài tiếp theo đi sâu vào CDN cho static assets: vì sao file có hash trong tên cho phép bạn đặt max-age cả năm, và cache busting hoạt động ra sao khi bạn không đổi được tên file.
Front: Cache-Control: max-age=600 nghĩa là gì? Back: Response được coi là tươi trong 600 giây kể từ lúc nhận, không cần gọi server.
Front: s-maxage khác max-age ở đâu? Back: s-maxage chỉ áp cho shared cache như CDN/proxy, max-age áp cho mọi cache gồm trình duyệt.
Front: no-store khác no-cache thế nào? Back: no-store cấm lưu ở mọi nơi, no-cache vẫn lưu nhưng phải revalidate trước mỗi lần dùng.
Front: private trong Cache-Control có tác dụng gì? Back: Cấm shared cache (CDN) lưu, chỉ cache riêng của người dùng được giữ.
Front: ETag dùng để làm gì? Back: Đánh dấu phiên bản nội dung để client hỏi lại bằng If-None-Match và nhận 304 nếu chưa đổi.
Front: Response 304 khác 200 ở điểm nào? Back: 304 không có body, chỉ báo bản client đang giữ vẫn dùng được.
Front: Last-Modified đi cặp với request header nào? Back: If-Modified-Since.
Front: Vary: Accept-Encoding để làm gì? Back: Buộc cache phân biệt bản gzip và bản không nén của cùng một URL.
Front: Thiếu Vary cho response phụ thuộc Accept-Language thì sao? Back: Cache có thể trả bản tiếng Anh cho người dùng tiếng Việt.
Front: stale-while-revalidate=30 cho phép gì? Back: Trả bản cũ trong tối đa 30 giây và làm mới ngầm ở background.
Front: Vary: * gần như có nghĩa gì? Back: Gần như không cache được, vì mọi request header đều có thể tạo phiên bản khác.
Front: ETag yếu được viết thế nào? Back: W/"a1b2c3", dùng khi hai bản khác byte nhưng tương đương về ngữ nghĩa.
Front: Response cá nhân như /api/me nên đặt header gì? Back: Cache-Control: private, no-cache kèm ETag để vẫn nhận được 304.
Front: must-revalidate có tác dụng gì? Back: Cấm dùng bản stale khi đã hết hạn, buộc phải revalidate với origin.