Phần trước nói về ETag — cách client hỏi "còn đúng không" trước khi tải lại. Nhưng hỏi vẫn là một request. Cache-Control là cách origin nói trước: "cái này dùng được 5 giây, đừng hỏi lại". Nó là một dòng header, và nó quyết định toàn bộ hành vi của mọi tầng cache giữa origin và người dùng: CDN, reverse proxy, service mesh, trình duyệt.

Phần lớn API JSON không đặt header này, hoặc đặt no-cache vì "dữ liệu động". Bài này đo xem từng chỉ thị làm gì khi có một cache dùng chung thật đứng trước, và cái giá chính xác của mỗi lựa chọn tính bằng giây dữ liệu cũ.

Một origin, một nginx, bảy chỉ thị

Ảnh chụp đoạn mã nền tối Cache-Control trên API origin chỉ cần nói một dòng với Origin Go 40 ms mỗi request qua nginx 1.27 proxy_cache cache dùng chung tới client không đặt proxy_cache_valid, origin thân kèm mốc render header do từng endpoint quyết gồm func handler tăng origin đếm số lần origin thật sự làm việc, Sleep 40 ms giả lập query cộng render, đặt header Cache-Control bằng cc như max-age 5 hoặc private max-age 60, Encode map gia 29000 và render_luc_ns bằng time.Now UnixNano để client đo dữ liệu cũ bao nhiêu từ mốc này, nginx để Cache-Control của origin quyết bật cập nhật nền gồm location /cc/ proxy_pass http go-lab 8086, proxy_cache api, proxy_cache_key scheme host request_uri, không có proxy_cache_valid nên chỉ cache khi origin cho phép max-age hoặc s-maxage, proxy_cache_background_update on là stale-while-revalidate trả bản cũ làm mới ở nền, add_header X-Cache-Status upstream_cache_status always, bảy chỉ thị đem đo nghĩa của từng cái với cache dùng chung gồm không có header thì không có gì để tin nên không cache, no-store cấm lưu ở mọi tầng, private max-age 60 chỉ cache riêng trình duyệt được giữ cache dùng chung bỏ qua, max-age 1 hoặc 5 tươi trong N giây hết hạn thì request kế phải chờ origin, max-age 5 stale-while-revalidate 30 hết hạn vẫn trả bản cũ ngay làm mới ở nền trong 30 giây, s-maxage 5 max-age 0 cache dùng chung giữ 5 giây trình duyệt không giữ

Hình 1: Origin cố ý chậm 40 ms và ghi mốc thời gian render vào thân. nginx không có proxy_cache_valid, nên chỉ cache khi origin cho phép.

Hai chi tiết quan trọng trong cách dựng:

  • nginx không có proxy_cache_valid. Nhiều hướng dẫn đặt proxy_cache_valid 200 10s, tức là ép cache mọi thứ 10 giây bất kể origin nói gì. Ở đây mình bỏ dòng đó để nginx hành xử như một cache dùng chung tuân thủ RFC 9111: origin quyết. Đó cũng là cách CDN hoạt động mặc định.
  • Thân có render_luc_ns. Client so mốc đó với đồng hồ của mình để biết dữ liệu nó nhận cũ bao nhiêu — con số mà hầu hết phép đo cache bỏ qua, trong khi nó chính là cái giá phải trả.

Đo thật: 300 request đều đặn

Mỗi chỉ thị nhận 300 request trong 12 giây (25 request/giây). Origin đếm số lần nó thật sự làm việc:

Ảnh chụp output thật nền tối hết hạn 50 client max-age lọt 50 SWR lọt 1 với Go 1.23 origin 40 ms qua nginx 1.27 proxy_cache tới client 300 request trên 12 giây mỗi chỉ thị, mục 1 300 request đều đặn 25 req mỗi giây từng chỉ thị Cache-Control cho thấy không có header 300 tới origin p50 44 ms p99 46 ms cũ tối đa 0.00 s MISS nhân 300, no-store 300 tới origin 44 ms 46 ms 0.00 s MISS nhân 300, private max-age 60 300 tới origin 45 ms 47 ms 0.00 s MISS nhân 300 với ghi chú bỏ qua private, max-age 1 có 7 tới origin p50 0.9 ms p99 43.8 ms cũ tối đa 1.93 s EXPIRED nhân 6 HIT nhân 293 MISS nhân 1, max-age 5 có 3 tới origin p50 0.8 ms p99 2.1 ms cũ tối đa 5.93 s EXPIRED nhân 2 HIT nhân 297 MISS nhân 1, max-age 5 stale-while-revalidate 30 có 3 tới origin p50 0.7 ms p99 1.4 ms cũ tối đa 5.94 s HIT nhân 297 MISS nhân 1 STALE nhân 2, s-maxage 5 max-age 0 có 3 tới origin p50 0.7 ms p99 1.8 ms cũ tối đa 5.93 s EXPIRED nhân 2 HIT nhân 297 MISS nhân 1, mục 2 cache vừa hết hạn 50 client gọi cùng lúc bao nhiêu lọt tới origin cho thấy max-age 1 tới origin 50 p50 58.1 ms p99 64.2 ms EXPIRED nhân 50 còn max-age 1 stale-while-revalidate 30 tới origin 1 p50 4.0 ms p99 5.8 ms HIT nhân 1 STALE nhân 1 UPDATING nhân 48, nói thẳng max-age 5 cắt 99 phần trăm request tới origin với giá là dữ liệu cũ tối đa 5,93 giây con số đó là lựa chọn không phải lỗi, thứ max-age không làm được là lúc hết hạn mọi client đến đúng khoảnh khắc đó đều chờ origin stale-while-revalidate chữa đúng chỗ ấy

Hình 2: Kết quả thật. Cột "cũ tối đa" là khoảng cách lớn nhất giữa lúc origin render và lúc client nhận.

Cache-Control tới origin p50 p99 cũ tối đa
(không có header) 300 44 ms 46 ms 0 s
no-store 300 44 ms 46 ms 0 s
private, max-age=60 300 45 ms 47 ms 0 s
max-age=1 7 0,9 ms 43,8 ms 1,93 s
max-age=5 3 0,8 ms 2,1 ms 5,93 s
max-age=5, stale-while-revalidate=30 3 0,7 ms 1,4 ms 5,94 s
s-maxage=5, max-age=0 3 0,7 ms 1,8 ms 5,93 s

Không có header thì không có gì xảy ra. 300 request, 300 lần origin làm việc, 44 ms mỗi lần. Cache dùng chung không đoán — RFC cho phép dùng heuristic khi có Last-Modified, nhưng API JSON hiếm khi có header đó, và nginx mặc định không đoán. Một API không nói gì về cache là một API không được cache.

private, max-age=60 cũng là 300. Đây là chỗ hay bị hiểu sai: private không có nghĩa "cache 60 giây một cách riêng tư", nó có nghĩa chỉ cache riêng của người dùng cuối (trình duyệt) được giữ — cache dùng chung phải bỏ qua. Đúng cho dữ liệu cá nhân (/me/orders), nhưng nếu bạn đặt nó cho danh mục sản phẩm công khai vì nghe có vẻ an toàn, CDN sẽ không giúp gì.

max-age=5 cắt từ 300 xuống 3 — 99%. Cái giá ghi rõ ở cột cuối: dữ liệu cũ tối đa 5,93 giây. Không phải 5, vì nginx tính tuổi theo giây tròn từ header Date, nên hạn thực tế trượt gần một giây. Với bảng giá sản phẩm, 6 giây là chuyện không ai nhận ra; với số ghế còn trống, là một đơn bị từ chối ở bước thanh toán. Con số đó là lựa chọn thiết kế, không phải lỗi, và nó phải do người hiểu nghiệp vụ chọn.

max-age=1 có p99 43,8 ms dù p50 chỉ 0,9 ms. Sáu request EXPIRED phải chờ origin 40 ms, và chúng đủ nhiều để kéo p99 lên bằng không cache. TTL ngắn mua được độ tươi (1,93 giây) bằng độ trễ đuôi.

s-maxage=5, max-age=0 cho kết quả giống max-age=5 ở cache dùng chung — vì s-maxage là hạn riêng cho nó — trong khi max-age=0 bảo trình duyệt đừng giữ. Đây là cách để CDN gánh tải mà người dùng bấm F5 vẫn thấy bản mới nhất CDN có.

Khoảnh khắc hết hạn

Mục 2 là phép đo mình thấy đáng giá nhất. Bảng trên là lưu lượng đều; ngoài đời lưu lượng dồn cục, và cục đó hay rơi đúng lúc cache hết hạn — vì mọi client đều thấy cùng một bản hết hạn cùng lúc.

50 client gọi đồng thời ngay sau khi max-age=1 hết hạn:

  • max-age=1: 50 request lọt tới origin, tất cả EXPIRED, p50 58 ms. Cache có đó mà không giúp gì, và origin chịu đúng cú đấm nó được cache để tránh. Đây là cache stampede — đã gặp ở phần 5 sê-ri caching với Redis, giờ nó xuất hiện lại ở tầng HTTP.
  • max-age=1, stale-while-revalidate=30: 1 request tới origin. 48 client nhận trạng thái UPDATING — bản cũ, trả ngay trong 4 ms — trong khi nginx lấy bản mới ở nền. Cái giá: 49 client đó nhận dữ liệu cũ hơn hạn một chút, trong cửa sổ 30 giây cho phép.

stale-while-revalidate (RFC 5861) là chỉ thị ít được dùng nhất trong bảng và là chỉ thị thay đổi hành vi nhiều nhất ở p99: trong bảng đều đặn nó kéo p99 từ 2,1 xuống 1,4 ms, còn lúc dồn cục nó đổi 50 lượt chờ 58 ms thành 1 lượt. nginx cần proxy_cache_background_update on để làm việc này; CDN lớn đều hỗ trợ sẵn.

Đánh đổi cần cân nhắc

Cũ bao nhiêu giây là quyết định nghiệp vụ, không phải kỹ thuật. Bảng đo cho đúng con số (5,93 s với max-age=5), nhưng không nói con số đó chấp nhận được hay không. Cách làm thực dụng: liệt kê từng endpoint với câu hỏi "nếu người dùng thấy bản cũ N giây thì chuyện gì xảy ra", rồi đặt max-age theo câu trả lời — và dùng no-store cho những endpoint mà câu trả lời là "có người mất tiền".

private và dữ liệu cá nhân là ranh giới không được sai. Phản hồi có Authorization mặc định không được cache dùng chung lưu, nhưng API dùng cookie hoặc token trong query thì không có lưới an toàn đó. Một max-age=60 đặt nhầm lên /me/profile là người dùng A thấy hồ sơ của người dùng B qua CDN. Quy tắc an toàn: endpoint trả dữ liệu theo người dùng luôn private hoặc no-store, và kiểm tra bằng cách gọi qua cache với hai tài khoản.

no-cache không phải no-store. no-cache nghĩa là "được lưu, nhưng phải hỏi lại origin trước khi dùng" — ghép với ETag của phần trước thì mỗi request là một lần 304 rẻ thay vì tải lại. nginx mặc định coi no-cache là không lưu (cần proxy_cache_revalidate on và cấu hình thêm), nên bảng trên không có dòng đó; với CDN thì nó là một lựa chọn thật giữa "tươi tuyệt đối" và "rẻ tuyệt đối".

Cache dùng chung + Vary — bài học của phần 4 áp dụng nguyên vẹn: đặt max-age lên endpoint có thân phụ thuộc header (ngôn ngữ, phiên bản, nén) mà thiếu Vary là trả nhầm bản cho một nửa người dùng, giờ với hạn 5 giây thay vì 10.

Số đo của một nginx với proxy_cache_lock tắt. Bật proxy_cache_lock on là cách khác để chữa stampede: chỉ một request đi tới origin, 49 cái kia chờ nó thay vì nhận bản cũ. Đổi "dữ liệu cũ vài giây" lấy "chờ 40 ms". Hai cách bù nhau, và chọn cách nào lại quay về câu hỏi nghiệp vụ ở trên.

Ba ý mang về

  1. Không nói gì là không được cache: không header, no-store, và private (với cache dùng chung) đều là 300/300 request tới origin. max-age=5 còn 3, với giá dữ liệu cũ tối đa 5,93 giây — con số phải do người hiểu nghiệp vụ chọn.
  2. max-age không chữa được khoảnh khắc hết hạn: 50 client đến đúng lúc đó là 50 request lọt tới origin. stale-while-revalidate đổi thành 1 request và 49 client nhận bản cũ trong 4 ms; p99 lưu lượng đều cũng giảm từ 2,1 xuống 1,4 ms.
  3. s-maxage tách hạn của cache dùng chung khỏi trình duyệt, private tắt hẳn cache dùng chung — và đặt nhầm max-age lên endpoint cá nhân là lộ dữ liệu giữa người dùng qua CDN. Ranh giới đó phải được kiểm bằng cách gọi thật với hai tài khoản.

Nguồn

Phần sau đo một thứ nằm ngay cạnh cache trong chuỗi phản hồi: nén. gzip và brotli cắt được bao nhiêu byte của JSON, tốn bao nhiêu CPU mỗi request, và ở kích thước nào thì nén lại làm chậm hơn là không nén.