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ần mà mọ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.
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ù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.
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.conf và
gw.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.
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) |
no-cache không có nghĩa là không cacheno-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:
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.
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.
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:
# 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.
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".
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.
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:
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ẻ |
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
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
-
MDN — Cache-Control (ngữ nghĩa đầy đủ của
max-age,s-maxage,no-cache,no-store,immutable) -
MDN — Header
Vary(dùng ở mục 6.4) -
MDN — Header
ETagvà cơ chế revalidation vớiIf-None-Match(mục 6.3) -
IETF RFC 9111 — HTTP Caching (đặc tả chính thức, kể cả
stale-while-revalidate) - Wikipedia — Anycast (cơ chế định tuyến người dùng tới PoP gần nhất, mục 6.2)
-
nginx — tài liệu chính thức
proxy_cache_use_stalevàproxy_cache_key(mục 6.3, 6.4)
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ình luận