HTTP caching: ai được lưu response, và lưu bao lâu? Trung cấp
Thời gian đọc ước tính: 11 phút
Vấn đề
Phần tiêu đề “Vấn đề”Một shop online phục vụ ba loại response qua CDN:
app.3f9a1c.jsvà ảnh sản phẩm: giống nhau cho mọi người, chỉ đổi khi deploy;GET /products: cùng một danh sách cho mọi khách, cập nhật vài lần mỗi giờ;GET /account: tên, địa chỉ và đơn hàng của một người đang đăng nhập.
Mỗi response đi qua tối đa hai cache: trình duyệt (cache private, một người dùng) và CDN (cache shared, mọi người dùng). Không có header rõ ràng, mỗi cache tự áp default của nó.
Câu hỏi: header nào để CDN gánh được traffic sản phẩm, giữ dữ liệu của một người ngoài shared cache, và bản deploy mới hiện ra trong vài giây?
Chọn header nào? (tóm tắt trước)
Phần tiêu đề “Chọn header nào? (tóm tắt trước)”| Tình huống | Gửi | Điều kiện |
|---|---|---|
Asset có fingerprint (app.3f9a1c.js) |
public, max-age=31536000, immutable |
Nội dung đổi thì tên file đổi |
| Trang HTML gốc tham chiếu các asset đó | no-cache + ETag |
Mỗi lần dùng đều validate; trang không đổi chỉ tốn một 304 |
| API danh sách dùng chung, sau CDN | public, s-maxage=60, stale-while-revalidate=30 |
Chấp nhận cũ 60–90 s |
| Dữ liệu của một người | private, max-age=60 hoặc private, no-cache |
Không bao giờ public; CDN không được lưu |
| Dữ liệu bí mật (PDF sao kê, token dùng một lần) | no-store |
Chấp nhận mỗi lần xem đều về origin |
| Ghi có nguy cơ lost update | ETag khi đọc, If-Match khi ghi |
Trả 412 khi không khớp |
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”Freshness và validation
Phần tiêu đề “Freshness và validation”Response đã lưu còn fresh khi tuổi (age) nhỏ hơn freshness lifetime (RFC 9111 §4.2), và được dùng lại mà không cần gọi mạng.
Khi đã stale, cache phải validate: gửi conditional request, origin trả 304 Not Modified (giữ bản đang có) hoặc 200 kèm body mới.
Freshness tiết kiệm round trip; validation tiết kiệm byte. Phần lớn chính sách kết hợp cả hai.
Các directive của Cache-Control
Phần tiêu đề “Các directive của Cache-Control”| Directive | Ý nghĩa (RFC 9111 §5.2.2 nếu không ghi khác) |
|---|---|
max-age=N |
Fresh trong N giây ở mọi cache |
s-maxage=N |
Thay max-age cho riêng shared cache; bản stale phải validate |
no-cache |
Được lưu, nhưng phải validate trước mỗi lần dùng lại |
no-store |
Không được lưu |
private |
Shared cache không được lưu; trình duyệt thì được |
public |
Mọi cache được lưu, kể cả khi lẽ ra bị cấm (ví dụ request có Authorization) |
must-revalidate |
Đã stale thì không bao giờ trả khi chưa validate; origin không với tới thì trả 504 |
immutable |
Không đổi trong lúc còn fresh: bỏ qua conditional request khi reload (RFC 8246) |
stale-while-revalidate=N |
Được trả bản stale tối đa N s trong khi revalidate ở nền (RFC 5861) |
stale-if-error=N |
Được trả bản stale tối đa N s khi origin trả 500, 502, 503 hoặc 504 (RFC 5861) |
Chỗ hay nhầm nhất: no-cache không có nghĩa “đừng cache”. Nó nghĩa là “hỏi tôi trước”. no-store mới là directive cấm lưu.
Validator: ETag và Last-Modified
Phần tiêu đề “Validator: ETag và Last-Modified”ETag là tag phiên bản không cần hiểu nội dung (opaque) cho một representation (RFC 9110 §8.8.3). Last-Modified là mốc thời gian độ phân giải một giây, nên hai lần sửa trong cùng giây trông như nhau.
Nên dùng ETag; request có cả hai thì If-None-Match thắng, If-Modified-Since bị bỏ qua (RFC 9110 §13.1.3).
If-None-Match dùng weak comparison (W/"1" khớp "1"); If-Match dùng strong comparison vì nó bảo vệ thao tác ghi (RFC 9110 §13.1.1–13.1.2).
Vary: khoá của cache
Phần tiêu đề “Vary: khoá của cache”Mặc định cache dùng method và URI làm khoá. Vary: Accept-Encoding thêm một request header vào khoá: body gzip chỉ được dùng lại cho request cũng nhận gzip (RFC 9111 §4.1). Encoding khác nhau là representation khác nhau, nên cần ETag khác nhau (RFC 9110 §8.8.3.3).
Vary: Authorization là thừa: RFC 9110 §12.5.5 đã cấm dùng lại giữa các người dùng. Vary: Cookie đúng về kỹ thuật nhưng chia cache theo từng session; private thường là lựa chọn rõ ràng hơn.
Cách hoạt động
Phần tiêu đề “Cách hoạt động”GET /products trả Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=30 kèm ETag strong. Trình duyệt revalidate mỗi lần; CDN giữ danh sách.
GET → 200 + ETagt = 0 s: hai cache đều trống. CDN chuyển GET /products về API; API trả 200 kèm ETag. Cả hai cache lưu lại.
| Thời điểm | Trình duyệt | Bản ở CDN | Traffic tới origin |
|---|---|---|---|
| 0 s | Lưu, stale ngay | Đã lưu, age 0 | Một 200 đầy đủ |
| 20 s | Gửi If-None-Match |
Fresh: tự trả 304 |
Không |
| 75 s | Nhận ngay danh sách stale | Stale, trong cửa sổ 30 s | 304 ở nền, không body |
| > 90 s không có traffic | Chờ | Stale hẳn | Validate chặn request |
304 phải lặp lại ETag, Cache-Control, Vary và Date (RFC 9110 §15.4.5), nên nó cũng gia hạn freshness cho bản đã lưu.
Heuristic freshness
Phần tiêu đề “Heuristic freshness”Không có max-age, s-maxage hay Expires, cache được phép tự đoán lifetime cho các status heuristically cacheable: 200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501 (RFC 9110 §15.1). RFC 9111 §4.2.2 gợi ý khoảng 10% thời gian kể từ Last-Modified.
Vậy một response /products sửa lần cuối 10 ngày trước có thể được trả khoảng một ngày mà không kiểm tra gì. 302 không có trong danh sách; 302 muốn được cache cần header rõ ràng (URL shortener). Luôn gửi Cache-Control.
Response cho request có xác thực
Phần tiêu đề “Response cho request có xác thực”RFC 9111 §3.5: shared cache không được dùng lại response cho request có Authorization, trừ khi response có public, s-maxage hoặc must-revalidate. Thêm s-maxage vào endpoint theo từng người dùng là mở cửa cho CDN trả nó.
Cookie không được bảo vệ như vậy. Response /account dùng session cookie với max-age=60 mà thiếu private có thể bị CDN lưu và trả cho khách kế tiếp.
ETag cho thao tác ghi, không chỉ đọc
Phần tiêu đề “ETag cho thao tác ghi, không chỉ đọc”Cùng tag đó chống lost update. Client gửi lại ETag trong If-Match; không khớp thì origin không được thực hiện method và trả 412 (RFC 9110 §13.1.1).
Hai admin cùng đọc sản phẩm 42 và nhận ETag "v7".
- Cả hai admin giữ
"v7"; API đang ở"v7". - Tag của A khớp; API ghi và chuyển sang
"v8". "v7"của B không qua strong comparison; không có gì được ghi, B nhận412với body problem+json.- B đọc lại
"v8"rồi quyết định lại.
If-Match kiểm tra phiên bản; nó không chống trùng khi retry POST. Đó là việc của Idempotency-Key.
Chi tiết vận hành
Phần tiêu đề “Chi tiết vận hành”Đang tải diagram…
- Cache busting: build tool gắn hash nội dung vào tên file. HTML phải được revalidate (
no-cache) để bản deploy trỏ ngay tới tên mới; asset vẫn được cache một năm. - Purge CDN hay TTL ngắn: purge là API của nhà cung cấp, không thuộc HTTP, và chỉ tới CDN. Bản ở trình duyệt sống đến hết
max-age. Giữmax-agecho trình duyệt ngắn,s-maxagedài hơn; purge khi cần đổi gấp. - Method không an toàn (
POST,PUT,DELETE) chỉ invalidate URI đích ở những cache mà request đi qua (RFC 9111 §4.4); node CDN khác vẫn giữ bản cũ. no-storekhông phải biện pháp bảo mật: RFC 9111 §5.2.2.5 ghi rõ cache bị xâm nhập có thể bỏ qua nó. Dữ liệu nhạy cảm vẫn cần TLS và phân quyền.stale-while-revalidatecần có traffic: refresh ở nền chỉ chạy khi có request tới trong cửa sổ (RFC 5861 §3.1).- HTTP cache đứng trước API; Redis đứng sau. CDN miss vẫn tới API, nơi cache-aside và chống stampede lo các key nóng.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Chạy demo (Node 24, chỉ dùng node:http):
npx --prefix examples ts-node examples/src/http-caching/server.tsbash examples/src/http-caching/curl.sh # ở terminal khácOutput rút gọn của một lần chạy (hash thay đổi theo dữ liệu):
== 1. First GET: 200 with ETagHTTP/1.1 200 OKCache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=30ETag: "0WXDeSn-DbFvuJpn"Vary: Accept-Encoding== 2. Conditional GET with If-None-Match: "0WXDeSn-DbFvuJpn"HTTP/1.1 304 Not ModifiedETag: "0WXDeSn-DbFvuJpn"== 3. Same URL, Accept-Encoding: gzipHTTP/1.1 200 OKETag: "uVgFOdYlm87-bwSG"Content-Encoding: gzip== 4. Identity ETag sent with Accept-Encoding: gzip -> full 200== 5. admin A If-Match "v7" -> 200, admin B If-Match "v7" -> 412Bước 4 cho thấy vì sao Vary và ETag riêng cho từng encoding phải đi cùng nhau: tag của bản không nén không validate được bản gzip, nên server gửi body đầy đủ thay vì một 304 sai.
Trade-offs
Phần tiêu đề “Trade-offs”Freshness dài
- Không round trip nào: cache tự trả lời.
- CDN hấp thụ đỉnh lượt đọc trước khi chúng tới API.
- Có
stale-if-error, origin sập ngắn thì người dùng không thấy.
Validate mỗi lần
- Thay đổi chỉ hiện ra khi hết lifetime, trừ khi URL đổi.
- Không purge được bản ở trình duyệt.
- Lỡ gắn
publiccho dữ liệu cá nhân là lộ cho người khác.
Validation (no-cache + ETag) thì ngược lại: luôn đúng, nhưng mỗi lần dùng tốn một round trip, và tính ETag có thể vẫn cần chạm database.
Checklist production
Phần tiêu đề “Checklist production”- Mọi response gửi
Cache-Controlrõ ràng; không endpoint nào dựa vào heuristic freshness. - Response cá nhân có
private(hoặcno-store), kể cả endpoint dùng session cookie và trang lỗi có in dữ liệu người dùng. - Static asset có hash nội dung trong tên và
immutable; trang HTML gốc dùngno-cache. - Response có body phụ thuộc request header thì liệt kê header đó trong
Vary, và mỗi representation có ETag riêng. - Response
304lặp lạiETag,Cache-ControlvàVary. - Thao tác ghi trên tài nguyên dùng chung bắt buộc
If-Matchvà trả412kèm problem body. - Runbook ghi lệnh purge và mức cũ tối đa của từng endpoint (
max-age+stale-while-revalidate).
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Dữ liệu session bị đánh dấu cache được
Hay gặp
GET /account (Cookie: sid=...)Cache-Control: max-age=60Nên viết
GET /account (Cookie: sid=...)Cache-Control: private, max-age=60Vì sao
Response đầu có thể bị CDN lưu và trả cho khách kế tiếp. Authorization được RFC 9111 §3.5 bảo vệ; cookie thì không.
Lỗi này thường lộ ra khi đặt CDN trước một API trước đây chỉ phục vụ trình duyệt:
| Cách hiểu sai | Hệ quả | Cần làm |
|---|---|---|
| Không có header là không bị cache | Heuristic freshness trả dữ liệu cũ | Luôn gửi Cache-Control |
s-maxage “chỉ dành cho CDN” |
Nó còn mở shared cache cho request có Authorization |
Chỉ dùng cho dữ liệu không cá nhân |
| Purge CDN là xoá hết mọi bản | Trình duyệt giữ bản của nó đến hết max-age |
max-age ngắn cho trình duyệt |
Tự kiểm tra
Phần tiêu đề “Tự kiểm tra”- Vì sao CDN trả
304choIf-None-Matchcủa trình duyệt mà không cần hỏi origin? - Một response có
publicvàLast-Modifiednhưng không cómax-age. Cache có được dùng lại mà không validate không? - Vì sao
If-Matchdùng strong comparison cònIf-None-Matchdùng weak?
Tóm tắt
Phần tiêu đề “Tóm tắt”Tham khảo
Phần tiêu đề “Tham khảo”- RFC 9111 — HTTP Caching: quy tắc lưu (§3), response có xác thực (§3.5), heuristic (§4.2.2), các directive (§5.2).
- RFC 9110 — HTTP Semantics: so sánh ETag (§8.8.3),
Vary(§12.5.5), precondition và thứ tự đánh giá (§13),304(§15.4.5). - RFC 5861: ngữ nghĩa chính xác của
stale-while-revalidatevàstale-if-error. - RFC 8246:
immutablevà mô hình URL có phiên bản mà nó giả định.