Bỏ qua để đến nội dung
Knowledge Hub

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

Một shop online phục vụ ba loại response qua CDN:

  • app.3f9a1c.js và ả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?

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

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.

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.

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).

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.

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.

CDN tự trả lời revalidation của trình duyệt và làm mới bản của mình bằng 304 không body
CDN tự trả lời revalidation của trình duyệt và làm mới bản của mình bằng 304 không bodyBước 1/5: t = 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.Cache trình duyệtprivate, max-age=0CDNshared, s-maxage=60đã lưuProduct APIETag "0WXD…"GET → 200 + ETag
CDN → Product APIGET → 200 + ETag
Bước 1/5

t = 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.

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.

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.

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).

If-Match biến một lost update âm thầm thành 412 mà admin B phải xử lý
If-Match biến một lost update âm thầm thành 412 mà admin B phải xử lýBước 1/4: Hai admin cùng đọc sản phẩm 42 và nhận ETag "v7".thời gianGET /products/42ETag "v7"GET /products/42ETag "v7"Admin Ađặt 1.100.000Admin Bđặt 990.000Product APIsản phẩm 42
Bước 1/4

Hai admin cùng đọc sản phẩm 42 và nhận ETag "v7".

  1. Cả hai admin giữ "v7"; API đang ở "v7".
  2. Tag của A khớp; API ghi và chuyển sang "v8".
  3. "v7" của B không qua strong comparison; không có gì được ghi, B nhận 412 với body problem+json.
  4. 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.

Đ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-age cho trình duyệt ngắn, s-maxage dà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-store khô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-revalidate cầ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.

Chạy demo (Node 24, chỉ dùng node:http):

Terminal window
npx --prefix examples ts-node examples/src/http-caching/server.ts
bash examples/src/http-caching/curl.sh # ở terminal khác

Output rút gọn của một lần chạy (hash thay đổi theo dữ liệu):

== 1. First GET: 200 with ETag
HTTP/1.1 200 OK
Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=30
ETag: "0WXDeSn-DbFvuJpn"
Vary: Accept-Encoding
== 2. Conditional GET with If-None-Match: "0WXDeSn-DbFvuJpn"
HTTP/1.1 304 Not Modified
ETag: "0WXDeSn-DbFvuJpn"
== 3. Same URL, Accept-Encoding: gzip
HTTP/1.1 200 OK
ETag: "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" -> 412

Bướ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.

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 public cho 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.

  1. Mọi response gửi Cache-Control rõ ràng; không endpoint nào dựa vào heuristic freshness.
  2. Response cá nhân có private (hoặc no-store), kể cả endpoint dùng session cookie và trang lỗi có in dữ liệu người dùng.
  3. Static asset có hash nội dung trong tên và immutable; trang HTML gốc dùng no-cache.
  4. Response có body phụ thuộc request header thì liệt kê header đó trong Vary, và mỗi representation có ETag riêng.
  5. Response 304 lặp lại ETag, Cache-Control và Vary.
  6. Thao tác ghi trên tài nguyên dùng chung bắt buộc If-Match và trả 412 kèm problem body.
  7. Runbook ghi lệnh purge và mức cũ tối đa của từng endpoint (max-age + stale-while-revalidate).

Dữ liệu session bị đánh dấu cache được

Hay gặp

GET /account (Cookie: sid=...)
Cache-Control: max-age=60

Nên viết

GET /account (Cookie: sid=...)
Cache-Control: private, max-age=60

Vì 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
  1. Vì sao CDN trả 304 cho If-None-Match của trình duyệt mà không cần hỏi origin?
  2. Một response có public và Last-Modified nhưng không có max-age. Cache có được dùng lại mà không validate không?
  3. Vì sao If-Match dùng strong comparison còn If-None-Match dùng weak?
  • 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-revalidate và stale-if-error.
  • RFC 8246: immutable và mô hình URL có phiên bản mà nó giả định.