Bài 5 đặt cache cạnh app. Bài này đặt cache cạnh người dùng — và lý do phải làm vậy không phải là một quyết định kiến trúc, mà là một giới hạn vật lý không có đoạn code nào vượt qua được.

Lab của bài này dựng một tầng edge thật bằng nginx và đo được 25 lần throughput, với origin chỉ phải xử lý 4 request trong 208.173. Nhưng phát hiện đáng giá hơn là ở phép đo thứ hai: một lỗi cấu hình cache key rất phổ biến làm tải origin tăng 251 lầnmọi số liệu phía client đều không đổi — cùng throughput, cùng p50, cùng p99. Nó hoàn toàn vô hình từ bên ngoài.

ℹ️ Cấu hình máy đo, và một phân biệt quan trọng
Apple M1 Max, 10 lõi, 32 GB RAM, macOS 26.5.2, Docker 29.6.2. Container native arm64 (Node v22.23.2), nginx 1.27-alpine, ba app replica mỗi replica 1 CPU / 256 MB, bộ đo tải 2 CPU. Cache của tầng edge nằm trên tmpfs — có chủ ý, để phép đo không lẫn chi phí bind-mount của Docker Desktop, đúng cái bẫy đã gặp ở Bài 4 mục 4.3.

Phân biệt cần thiết: mục 6.1 nói về khoảng cách địa lý, và lab trên một máy tính không thể đo được điều đó. Mọi con số trong mục 6.1 là tính toán từ tốc độ truyền trong sợi quang, có ghi rõ cách tính, chứ không phải số đo. Từ mục 6.2 trở đi mọi con số đều là đo thật trong lab.

6.1 Giới hạn không code nào vượt được

Trong sợi quang, tín hiệu đi với tốc độ khoảng $200.000$ km/s — chậm hơn tốc độ ánh sáng trong chân không vì chiết suất của thuỷ tinh. Đổi đơn vị cho dễ dùng: 200 km mỗi millisecond. Một request phải đi và về, nên thời gian khứ hồi tối thiểu cho khoảng cách $d$ (km) là:

$$RTT_{min} = \frac{2d}{200} = \frac{d}{100} \text{ ms}$$

Tức là 1 ms cho mỗi 100 km khứ hồi, và đó là con số của một sợi quang thẳng, không thiết bị trung gian, không xếp hàng. Thực tế luôn xấu hơn 2–3 lần vì cáp không đi đường thẳng, vì mỗi router trên đường thêm một chút, và vì đường truyền có tải.

Tuyến Khoảng cách xấp xỉ RTT tối thiểu (tính toán) RTT thực tế thường thấy
Hà Nội → edge trong nước ~20 km 0,2 ms 5–15 ms
Hà Nội → Singapore ~2.500 km 25 ms 40–60 ms
Hà Nội → Frankfurt ~9.000 km 90 ms 180–250 ms
Hà Nội → Virginia (Mỹ) ~13.500 km 135 ms 220–280 ms

Bây giờ ghép với thứ đã đo ở Bài 4 mục 4.2: một kết nối HTTPS mới cần 1 RTT cho bắt tay TCP, cộng 1 RTT nữa cho bắt tay TLS 1.3. Với origin ở Virginia và RTT 250 ms, người dùng ở Hà Nội mất khoảng 500 ms trước khi request đầu tiên được gửi đi. Backend của bạn chưa nhận được gì cả.

⚠️ Cạm bẫy: tối ưu sai đầu bài toán
Tối ưu backend từ 50 ms xuống 20 ms là một thắng lợi thật — nhưng nó cải thiện 30 ms trên tổng 780 ms mà người dùng ở nửa vòng trái đất đang chịu (500 ms bắt tay + 250 ms cho request + 30 ms xử lý). Chưa tới 4%.

Cùng công sức đó, đưa nội dung ra một edge cách người dùng 20 km biến 250 ms thành 10 ms cho mọi round-trip. Đó là lý do mục này nằm ngay đầu bài: câu hỏi đúng không phải "làm sao server nhanh hơn" mà là "làm sao dữ liệu ở gần hơn". Trước khi tối ưu, hãy biết người dùng của bạn ở đâu — con số đó thay đổi thứ tự ưu tiên của mọi việc còn lại.
Cùng một người dùng, hai khoảng cách — 1 ms cho mỗi 100 km khứ hồi 👤 Hà Nội EDGE trong nước ~20 km 0,2 ms ORIGIN · Virginia ~13.500 km 135 ms lý thuyết · 220–280 ms thực tế router cáp biển router Và mỗi kết nối HTTPS mới cần 2 RTT trước khi request được gửi (Bài 4 mục 4.2) Tổng thời gian tới byte đầu tiên, cùng một backend xử lý 30 ms: qua origin: 250 (TCP) + 250 (TLS) + 250 (request) + 30 = ~780 ms qua edge: 10 + 10 + 10 + 3 (cache hit) = ~33 ms Tối ưu backend 50 → 20 ms cải thiện 30 ms trên 780 — chưa tới 4%. Khoảng cách mới là biến chi phối.
Các số RTT ở đây là tính toán từ tốc độ truyền trong sợi quang, không phải số đo của lab. Điểm cần nhớ: khoảng cách là hằng số bạn không sửa được bằng code — chỉ có thể tránh nó.

6.2 Kiến trúc CDN, và tầng edge đo được bao nhiêu

CDN là một mạng gồm nhiều PoP (point of presence) đặt rải rác về mặt địa lý, mỗi PoP là một tầng cache đứng trước origin của bạn. Người dùng luôn nói chuyện với PoP gần nhất, và việc "gần nhất" được quyết định bằng anycast: cùng một địa chỉ IP được quảng bá từ nhiều nơi, định tuyến BGP (Border Gateway Protocol — giao thức các mạng lớn trên Internet dùng để trao đổi với nhau tuyến đường nào tới được đâu) tự đưa gói tin tới nơi gần nhất về mặt mạng, mà bạn không cần cấu hình gì thêm.

Thành phần Vai trò Vì sao cần
PoP / edge Tầng cache gần người dùng nhất Xoá phần lớn khoảng cách địa lý — biến 250 ms thành 10 ms
Cache tier trung gian Tầng cache giữa các PoP và origin Có 200 PoP thì mỗi lần nội dung hết hạn là 200 request về origin. Tầng giữa gom chúng lại còn vài request
Origin shield Một PoP được chỉ định làm cửa duy nhất tới origin Origin chỉ thấy lưu lượng từ một nơi — dễ giới hạn, dễ theo dõi, và giảm mạnh tải đột biến
Origin Hệ thống thật của bạn Nơi duy nhất có dữ liệu gốc; mục tiêu là để nó nhận càng ít request càng tốt

Lab dựng đúng tầng đầu tiên: một nginx thứ hai, nhưng khác hẳn lb.confgw.conf — nó lưu lại phản hồi thay vì chỉ chuyển tiếp. So sánh 32 kết nối, 10 giây, origin có độ trễ xử lý 30 ms:

Đường đi Throughput p50 p99 Origin phải xử lý
Gọi trực tiếp app 826 rps 38,55 ms 48,89 ms 10.756 / 8.257 request
Qua tầng edge có cache 20.815 rps 1,44 ms 3,10 ms 4 / 208.173 request

Throughput gấp 25 lần, p50 giảm 27 lần. Nhưng cột cuối là cột đáng nhìn nhất: origin xử lý 4 request trong 208.173 — tức 0,002%. Toàn bộ phần còn lại được nginx trả từ cache của chính nó, và app hoàn toàn không biết chuyện gì đang diễn ra.

💡 CDN không chỉ để phục vụ ảnh
Quan niệm "CDN dùng cho file tĩnh" là quan niệm của mười năm trước. Phản hồi API đọc-nhiều, HTML đã render, kết quả tìm kiếm phổ biến, thậm chí trang cá nhân hoá một phần — tất cả đều cache được ở edge, chỉ cần phát header cho đúng (mục 6.3) và đặt cache key cho đúng (mục 6.4).

Cách nghĩ hữu ích: bất cứ phản hồi nào giống nhau cho nhiều người dùng đều là ứng viên. Câu hỏi không phải "đây có phải file tĩnh không" mà là "phản hồi này phụ thuộc vào những gì".

6.3 Header caching cho đúng

Tầng edge không tự đoán được nội dung nào cache được bao lâu — nó làm theo header mà origin phát ra. Đây là phần API duy nhất giữa app của bạn và CDN, và nó nhỏ hơn nhiều người tưởng.

Chỉ thị Nghĩa thật Điều dễ hiểu sai
max-age=N Còn tươi trong N giây, với mọi tầng cache kể cả trình duyệt Trình duyệt cũng tuân theo — đặt quá dài thì không cách nào rút lại
s-maxage=N Chỉ dành cho cache chia sẻ (CDN, proxy), ghi đè max-age Đây là cách đặt TTL dài ở CDN nhưng ngắn ở trình duyệt — rất hữu ích
public Cache chia sẻ được phép lưu Cần thiết khi có Authorization, vì mặc định lúc đó là không cache
private Chỉ trình duyệt được lưu; CDN không Đúng cho nội dung cá nhân hoá — và lab đã kiểm chứng: luôn MISS ở edge
no-cache Vẫn lưu, nhưng phải revalidate trước mỗi lần dùng Tên gọi gây nhầm nhất trong HTTP. Nó không có nghĩa "đừng cache"
no-store Không được lưu ở bất cứ đâu Đây mới là "đừng cache". Dùng cho dữ liệu thật sự nhạy cảm
immutable Nội dung sẽ không bao giờ đổi, đừng revalidate Chỉ dùng được khi URL có hash nội dung trong tên (mục 6.5)
⚠️ Cạm bẫy: no-cache không có nghĩa là không cache
no-cache nghĩa là "được lưu, nhưng mỗi lần dùng phải hỏi origin xem còn hạn không". Nội dung vẫn nằm trong cache của CDN và của trình duyệt.

Nếu bạn đặt no-cache cho một phản hồi chứa dữ liệu nhạy cảm và nghĩ mình đã an toàn thì bản đó vẫn đang nằm trên đĩa của một proxy nào đó. Thứ bạn cần là no-store. Đây là loại lỗi không bao giờ xuất hiện trong test vì hệ thống vẫn hoạt động đúng.

Revalidation: ETag và 304

Khi TTL hết, tầng cache không nhất thiết phải tải lại toàn bộ. ETag là một chuỗi định danh cho một phiên bản nội dung cụ thể — trong lab nó là hash của chính body, nên nội dung không đổi thì ETag không đổi. Khi TTL hết, tầng cache gửi lại If-None-Match kèm ETag đã có; nếu ETag đó vẫn khớp (tức nội dung chưa đổi), origin trả 304 Not Modified không kèm body. Đo thật trên lab:

revalidation.sh
E=http://localhost:8082

etag=$(curl -s -D - -o /dev/null "$E/cacheable?key=b&ttl=60" \
  | grep -i '^etag:' | cut -d' ' -f2)
echo "ETag = $etag"                    # ETag = "7e9134f4"

curl -s -o /dev/null -w '%{http_code} %{size_download} byte\n' \
  "$E/cacheable?key=b&ttl=60"
# 200 62 byte                          ← downloads the full body again

curl -s -o /dev/null -H "If-None-Match: $etag" \
  -w '%{http_code} %{size_download} byte\n' "$E/cacheable?key=b&ttl=60"
# 304 0 byte                           ← only confirms "still valid", downloads nothing

Với một payload 62 byte thì tiết kiệm này vô nghĩa. Với một file JavaScript 800 KB thì mỗi lần revalidate tiết kiệm đúng 800 KB. Đó là lý do proxy_cache_revalidate on nằm trong cấu hình edge của lab: nó biến "hết hạn" từ chuyện phải tải lại thành chuyện chỉ cần hỏi một câu.

⚡ ETag phải ổn định theo nội dung, không theo thời điểm
Nếu ETag được sinh từ thời điểm phản hồi (hoặc từ một số ngẫu nhiên, hoặc từ hostname của replica đang phục vụ) thì nó đổi ở mỗi lần trả — và 304 sẽ không bao giờ xảy ra. Hệ thống vẫn đúng, chỉ là toàn bộ cơ chế revalidation lặng lẽ không hoạt động.

Endpoint /cacheable của lab tính ETag bằng hash của chính body, nên ba replica khác nhau vẫn sinh cùng một ETag cho cùng nội dung. Đây là điều kiện bắt buộc khi có nhiều replica — và là chỗ rất dễ sai nếu ETag lấy từ thời điểm sửa file trên đĩa, vì ba máy có ba mốc thời gian khác nhau.
Bốn nhánh của cùng một request — giá trị X-Cache cho biết bạn đang ở nhánh nào client edge (nginx) origin (app) HIT Còn trong cache và còn tươi → trả ngay, KHÔNG chạm origin đo được: 2,3–3,6 ms origin: rảnh MISS origin xử lý, edge LƯU lại đo được: 36–44 ms 304 TTL hết → edge gửi If-None-Match, origin trả 304 KHÔNG body đo được: 0 byte tải về STALE origin SẬP hoàn toàn TTL hết + origin không trả lời → edge trả BẢN CŨ, 200 OK đo được: 200 trong 2,4 ms — người dùng không thấy sự cố Nhưng STALE chỉ cứu được URL ĐÃ TỪNG được cache. URL chưa có trong cache: đo được 504 sau 6,03 giây.
Bốn giá trị này là bốn trạng thái rất khác nhau về tải origin và về trải nghiệm. Nếu không phơi X-Cache ra thì bạn không phân biệt được chúng.

Trả bản cũ khi origin sập — và giới hạn của nó

Nhánh STALE đáng được đo riêng, vì nó là thứ giữ cho site còn sống. Thí nghiệm: nạp cache với TTL 2 giây, rồi dừng toàn bộ ba app, chờ TTL hết, và gọi lại:

origin_down_edge_survives.sh
# 1) warm the cache with TTL 2s
curl -sI "$E/cacheable?key=stale&ttl=2"     # X-Cache=MISS  36.5 ms
curl -sI "$E/cacheable?key=stale&ttl=2"     # X-Cache=HIT    3.6 ms

# 2) stop the whole origin
docker compose stop app1 app2 app3
sleep 3                                      # let the TTL expire

# 3) TTL expired + origin down -> what does edge do?
curl -sI "$E/cacheable?key=stale&ttl=2"     # 200  X-Cache=STALE     2.4 ms
curl -sI "$E/cacheable?key=stale&ttl=2"     # 200  X-Cache=UPDATING  2.5 ms
curl -sI "$E/cacheable?key=stale&ttl=2"     # 200  X-Cache=UPDATING  2.5 ms

# 4) a URL that was NEVER cached, origin still down
curl -sI "$E/cacheable?key=never-cached"    # 504  X-Cache=MISS   6.03 SECONDS

Ba dòng ở bước 3 là kết quả tốt nhất có thể: origin đã chết hoàn toàn mà người dùng vẫn nhận 200 OK trong 2,4 ms. UPDATING nghĩa là nginx đang thử làm mới ở nền trong khi vẫn trả bản cũ cho người dùng — đúng tinh thần proxy_cache_background_update.

Nhưng bước 4 là dòng phải nhớ. Một URL chưa từng nằm trong cache thì edge không có gì để trả: 504 sau 6,03 giây. Và con số 6 giây đó không phải ngẫu nhiên — đó là 3 upstream × proxy_connect_timeout 2s, đúng cơ chế đã gặp ở Bài 3. Người dùng chờ 6 giây rồi nhận lỗi, tệ hơn nhiều so với nhận lỗi ngay.

🔬 Hệ quả thiết kế: cache warm là một phần của kế hoạch chống sự cố
proxy_cache_use_stale chỉ bảo vệ được phần nội dung đang ở trong cache. Nghĩa là mức độ chịu đựng sự cố của bạn phụ thuộc trực tiếp vào độ phủ của cache tại thời điểm sự cố xảy ra — một biến số mà hầu như không ai theo dõi.

Hai hệ quả thực hành. Thứ nhất: đừng flush cache rồi deploy; nếu origin gặp vấn đề ngay sau deploy thì bạn vừa tự tay bỏ đi lớp bảo vệ duy nhất. Thứ hai: với những URL quan trọng nhất — trang chủ, trang đăng nhập, vài API cốt lõi — hãy nạp trước chúng vào cache sau mỗi lần deploy, và giảm proxy_connect_timeout để trường hợp không có bản cũ thì thất bại nhanh thay vì bắt người dùng chờ 6 giây.

6.4 Cache key và Vary — nơi hit ratio âm thầm về 0

Cache key quyết định hai phản hồi có được coi là "cùng một thứ" hay không. Nó gồm URL (mặc định của nginx là $scheme$proxy_host$request_uri, tức bao gồm toàn bộ query string) cộng với những header mà origin khai báo trong Vary.

Hai cơ chế này là hai cách phổ biến nhất để phá hit ratio, và cả hai đều không tạo ra lỗi nào — cấu hình nhìn vào vẫn thấy "đã bật cache".

Cùng MỘT nội dung, bốn bản cache — hai cách băm nhỏ cache 1 · THAM SỐ THEO DÕI trong URL (key thô) /doc?utm_source=facebook /doc?utm_source=twitter /doc?fbclid=abc123 /doc?gclid=xyz789 → 4 cache key · đo được 4/4 MISS nội dung: y hệt y hệt y hệt 2 · KEY CHUẨN HOÁ (danh sách cho phép) proxy_cache_key "$uri|key=$arg_key" utm_source, fbclid, gclid không có trong key ⇒ bốn URL trên thành CÙNG một key → 1 cache key · đo được 1 MISS + 3 HIT 3 · Vary: Cookie — cạm bẫy tốn kém nhất Origin phát "Vary: Cookie" ⇒ cache key gồm cả giá trị cookie. Mà mỗi người dùng có cookie riêng… Cookie: sid=user1 → MISS sid=user2 → MISS sid=user3 → MISS sid=user1 lần 2 → HIT ⇒ MỖI NGƯỜI DÙNG MỘT BẢN CACHE RIÊNG. Hit ratio về gần 0 khi lượng người dùng lớn. Bỏ "Vary" đi, cùng ba cookie đó: đo được 1 MISS + 2 HIT. "Vary: Accept-Encoding" là hợp lý (gzip và br là hai nội dung thật sự khác nhau). "Vary: User-Agent" băm cache thành hàng nghìn bản. "Vary: Cookie" băm thành mỗi-người-một-bản.
Cả ba tình huống đều được kiểm chứng bằng X-Cache trong lab. Không có tình huống nào tạo ra lỗi — đó chính là điều làm chúng nguy hiểm.

Đo mức thiệt hại: 251 lần tải origin, và hoàn toàn vô hình

Kiểm chứng bằng số ở quy mô tải. Cùng một nội dung, nhưng bộ đo tải gắn thêm một tham số utm_source lấy ngẫu nhiên trong 1.000 giá trị — đúng như lưu lượng thật từ các chiến dịch quảng cáo. So hai cấu hình cache key, 32 kết nối, 10 giây:

Cấu hình cache key Throughput p50 p99 Origin phải xử lý
Thô — gồm toàn bộ query string 20.866 rps 1,43 ms 3,10 ms 1.003 request
Chuẩn hoá — chỉ tham số được phép 20.552 rps 1,46 ms 3,15 ms 4 request

Tải origin chênh 251 lần. Và ba cột trước đó — throughput, p50, p99 — giống nhau đến mức không phân biệt được. Từ phía client, hai cấu hình này là một. Không có dashboard latency nào, không có alert nào, không có lỗi nào cho bạn biết.

Con số 1.003 cũng đáng để đọc: 1.000 giá trị utm_source khác nhau tạo ra đúng 1.000 cache key khác nhau, mỗi key một lần MISS. Cache vẫn "hoạt động" — nó chỉ đang lưu 1.000 bản của cùng một nội dung và không bản nào được dùng lại.

⚠️ Điều này chỉ lộ ra ở một chỗ duy nhất
Phân mảnh cache không hiện trong latency của client. Nó chỉ hiện ở tải origin và tỉ lệ HIT/MISS tại tầng edge. Nghĩa là nếu bạn không theo dõi hai số liệu đó thì bạn không có cách nào phát hiện.

Cụ thể cần có: log hoặc metric của $upstream_cache_status ở tầng edge, và số request/giây mà origin thật sự nhận. Khi lưu lượng tăng gấp đôi, số của origin cho bạn biết ngay cache đang che chắn hay chỉ đang giả vờ che chắn. Đây là bản chất giống hệt bài học "đánh giá cache bằng hit ratio, không phải bằng có nhanh hơn không" ở Bài 5 mục 5.1 — nhìn từ tầng edge.

Cấu hình tạo ra hai hành vi trên, khác nhau đúng một dòng:

nginx/edge.conf
location /cacheable {
    # RAW KEY: nginx's default, includes the ENTIRE query string.
    # ?utm_source=facebook => a separate cache entry for the exact same content.
    proxy_cache_key "$scheme$proxy_host$request_uri";
    proxy_pass http://edge_origin;
    include /etc/nginx/snippets/edge-common.conf;
}

location /norm/cacheable {
    # NORMALIZED KEY: only lists the parameters that ACTUALLY decide the content.
    # nginx has no loop, so this "allowlist" is spelling out every $arg_* we accept
    # by hand. Tracking params (utm_*, fbclid, gclid) are ignored simply because
    # they are NOT part of the key.
    proxy_cache_key "norm|$uri|key=$arg_key|ttl=$arg_ttl|vary=$arg_vary";
    proxy_pass http://edge_origin/cacheable$is_args$args;
    include /etc/nginx/snippets/edge-common.conf;
}

6.5 Purge, URL có version, và cách làm trắng trang toàn bộ người dùng

Khi nội dung đổi, có hai cách để người dùng thấy bản mới: xoá bản cũ khỏi cache (purge), hoặc đổi URL để bản mới nằm ở một địa chỉ khác. Cách thứ hai gần như luôn tốt hơn, và lý do rất thực dụng.

Purge URL có version
Cơ chế Gọi API của CDN để xoá một URL khỏi mọi PoP Đặt hash nội dung vào tên file: app.a3f9c1.js
Thời gian có hiệu lực Vài giây tới vài phút, khác nhau theo PoP Tức thì — URL mới chưa từng được cache ở đâu
Kiểm chứng được không Khó: không biết PoP nào đã xoá xong Được: URL khác nhau thì nội dung khác nhau, không có gì phải tin
Quay lại bản cũ Phải deploy lại và purge lại Chỉ cần trỏ về URL cũ — bản cũ vẫn còn nguyên trong cache
TTL đặt được Phải ngắn, vì còn phải sửa được max-age=31536000, immutable — một năm

Từ đó ra một quy tắc rất gọn cho việc đặt header, chia theo loại tài nguyên:

Loại tài nguyên Cache-Control Vì sao
JS/CSS có hash trong tên public, max-age=31536000, immutable Nội dung không bao giờ đổi; đổi nội dung là đổi tên file
Ảnh, font public, max-age=2592000 Đổi rất ít; một tháng là đủ an toàn
HTML public, max-age=0, s-maxage=60, must-revalidate Đây là file trỏ tới các asset khác — phải luôn tươi, nhưng CDN vẫn được giữ 60 giây
API đọc chung cho nhiều người public, s-maxage=30, stale-while-revalidate=60 Edge trả bản cũ trong lúc làm mới; không ai phải chờ
API cá nhân hoá private, no-store Không được để lọt vào cache chia sẻ
⚠️ Cạm bẫy làm trắng trang toàn bộ người dùng
Kịch bản này xảy ra thường xuyên hơn mức nó nên xảy ra. Bạn deploy: asset mới thành app.b7d2e4.js, asset cũ app.a3f9c1.js bị xoá khỏi origin. Nhưng HTML đang được cache ở edge với TTL dài, nên nó vẫn trỏ tới app.a3f9c1.js — file đã không còn tồn tại.

Kết quả: người dùng nhận HTML cũ, HTML đó xin một file 404, JavaScript không tải được, trắng trang. Và purge không cứu kịp vì nó cần vài phút để có hiệu lực trên mọi PoP.

Hai cách phòng, nên làm cả hai: (1) HTML luôn phải có TTL ngắn hoặc bắt buộc revalidate — nó là file trỏ đường, không phải nội dung; (2) giữ lại asset của bản deploy trước ít nhất vài giờ thay vì xoá ngay. Cách (2) rẻ đến mức không có lý do gì để không làm: vài trăm KB trên đĩa đổi lấy việc không bao giờ gặp tình huống này.

Tự làm lại các phép đo

reproduce_measurements.sh
cd blog/sysdesign/sysdesign-lab
docker compose --profile edge up -d
E=http://localhost:8082
purge() { docker compose exec -T edge sh -c 'rm -rf /var/cache/nginx/edge/*'; }

# --- MISS then HIT ---
purge
for i in 1 2 3; do
  curl -s -o /dev/null -D /tmp/h -w "%{time_total}s " "$E/cacheable?key=a&ttl=60"
  grep -i '^x-cache:' /tmp/h
done

# --- Vary: Cookie fragments the cache ---
purge
for c in user1 user2 user3 user1; do
  curl -s -o /dev/null -D /tmp/h -H "Cookie: sid=$c" \
    "$E/cacheable?key=e&ttl=60&vary=cookie"
  echo "sid=$c -> $(grep -i '^x-cache:' /tmp/h)"
done

# --- Origin load: RAW key vs NORMALIZED key (1000 random utm_source values) ---
purge && ./tools/cache-stats.sh reset
docker compose run --rm loadgen loadgen.js \
  --url "http://edge:8082/cacheable?key=a&ttl=60" \
  -c 32 -d 10 -w 3 --key-space 1000 --key-param utm_source --json
./tools/cache-stats.sh          # see how many requests the origin actually processed

purge && ./tools/cache-stats.sh reset
docker compose run --rm loadgen loadgen.js \
  --url "http://edge:8082/norm/cacheable?key=a&ttl=60" \
  -c 32 -d 10 -w 3 --key-space 1000 --key-param utm_source --json
./tools/cache-stats.sh          # compare: 1,003 vs 4

Tóm lại

Khoảng cách địa lý là hằng số vật lý: 1 ms cho mỗi 100 km khứ hồi, và một kết nối HTTPS mới cần 2 RTT trước khi request được gửi đi. Không có cách nào sửa bằng code — chỉ có cách đặt dữ liệu gần hơn. Tầng edge trong lab cho 25 lần throughput và đẩy 99,998% request ra khỏi origin, chỉ bằng nginx với proxy_cache.

Ba điều dễ sai nhất đều nằm ở phần header và cache key, và không cái nào tạo ra lỗi: no-cache vẫn cache (muốn không lưu phải dùng no-store); ETag sinh từ thời điểm thay vì từ nội dung làm 304 không bao giờ xảy ra; và cache key chứa tham số theo dõi làm tải origin tăng 251 lần trong khi mọi số liệu phía client không đổi một chút nào.

Cuối cùng, proxy_cache_use_stale giữ được site sống khi origin sập — nhưng chỉ cho những URL đã ở trong cache. Điều đó biến độ phủ của cache thành một phần của kế hoạch chống sự cố, và biến "flush cache rồi deploy" thành một việc cần nghĩ lại.

Bài 7 rời tầng cache để xuống tầng dữ liệu: replication. Lab sẽ có PostgreSQL primary và replica thật, đo replication lag bằng pg_stat_replication, và tự tay tái tạo bug mà mọi hệ thống có replica đều gặp — người dùng đổi ảnh đại diện rồi reload và thấy ảnh cũ.

📖 Tài liệu tham khảo

Tải mã nguồn thực hành

Cấu hình nginx của tầng edge trong lab, chứa cả hai location cache key (thô và chuẩn hoá) đo trong mục 6.4:

Tải về edge.conf

Bài viết liên quan trong series

Bài 5: Caching — Cache-Aside, TTL & Vô Hiệu Hoá Bài 7: Replication & Scale Tầng Đọc Quay lại Lộ trình Thiết Kế Hệ Thống

Bình luận