🌐 CDN cho static assets và cache busting

By Better Wise

Bài trước bạn đã đặt Cache-Control, ETag, Vary lên response và biết rằng max-age dài thì nhanh nhưng dễ cũ. Bài này lấy đúng bộ header đó áp cho static assets (JS, CSS, ảnh, font) khi có CDN ở giữa, và trả lời câu hỏi khó chịu nhất: làm sao vừa cache một năm vừa phát hành bản mới ngay khi deploy?

Mục tiêu bài này:

  • Giải thích CDN làm gì với static assets, bằng ví dụ tủ lạnh.
  • Đặt max-age dài kèm immutable cho file có hash trong tên.
  • Đặt hash nội dung vào tên file để mỗi bản phát hành là một URL mới.
  • Biết vì sao index.html phải đi con đường ngược lại.
  • Tránh bẫy ?v=2 và biết thứ tự deploy an toàn.

CDN là tủ lạnh đặt ở mỗi tòa nhà

Origin server là cái chợ. CDN là tủ lạnh đặt ở tầng trệt của mỗi tòa nhà (edge). Lần đầu ai đó cần sữa thì vẫn phải đi chợ, tức cache miss, edge về origin lấy hàng. Những lần sau mở tủ là có, tức cache hit tại edge, và user ở Hà Nội không phải chờ gói tin đi vòng sang Mỹ.

Static assets hợp với mô hình này hơn bất cứ thứ gì: cùng một nội dung cho mọi user (không phụ thuộc cookie hay user id), ít thay đổi, nhưng được đọc rất nhiều lần.

flowchart TD
    Browser["👤<br/>Trình duyệt"] --> Edge{"🧊<br/>Edge cache"}
    Edge -->|"HIT"| Browser
    Edge -->|"MISS"| Origin["🏠<br/>Origin server"]
    Origin --> Edge

Vì cache key ở edge là URL (cộng thêm vài header trong Vary), nên câu hỏi thiết kế thật ra rất gọn: làm sao để nội dung mới có URL mới.

Cache lâu và phát hành nhanh: tách bằng URL

Hai mong muốn này đối nghịch nhau nếu bạn gắn cache vào một URL cố định. max-age dài thì user không thấy bản mới, max-age ngắn thì mất lợi ích của CDN. Cách thoát là đổi chỗ neo: không neo cache vào "file tên gì" mà neo vào "nội dung nào".

Nếu tên file chứa hash của nội dung, ví dụ app.3f9c1a2b.js, thì khi code đổi, hash đổi, URL đổi. File cũ vẫn nằm trong cache nhưng không còn ai trỏ tới. Với URL như vậy bạn được phép đặt:

Cache-Control: public, max-age=31536000, immutable
  • max-age=31536000 là _____ (31.536.000 giây).
  • _____ báo trình duyệt rằng trong khoảng đó đừng revalidate, kể cả khi user bấm F5, vì nội dung sau URL này sẽ không bao giờ đổi.

Đây là điểm khác biệt lớn so với bài trước: ETag vẫn hữu ích, nhưng với asset có hash thì ta cố tình không muốn phát sinh request revalidate nào cả.

Hash trong tên file: cache busting bền vững

Cache busting nghĩa là làm cho URL thay đổi khi nội dung thay đổi, để "đập" cache một cách có chủ đích. Tên file nên chứa _____ chứ không phải số version do người viết tay, vì hash chỉ đổi khi file thật sự đổi.

Một bảng nhỏ đủ dùng cho hầu hết dự án:

Asset Header gợi ý Lý do
index.html no-cache luôn revalidate, phải thấy hash mới
app.<hash>.js, style.<hash>.css public, max-age=31536000, immutable URL bất biến
logo.<hash>.png, font như trên ảnh và font gần như không đổi
favicon.ico (không hash) public, max-age=86400 chấp nhận trễ một ngày
API response no-store hoặc TTL ngắn dữ liệu riêng của từng user

HTML thì phải đi ngược lại

index.html là thứ trỏ tới các hash. Nếu HTML bị cache một năm, user sẽ mãi thấy HTML cũ trỏ tới app.3f9c1a2b.js cũ, và bản deploy mới coi như vô hình. Nên HTML dùng _____, nghĩa là vẫn được lưu nhưng mỗi lần dùng phải hỏi lại server. Nhờ ETag, lần hỏi lại đó thường chỉ là một 304 Not Modified vài chục byte.

Đừng nhầm no-cache với no-store: no-cache cho phép lưu và bắt revalidate, còn no-store là không lưu gì cả, và bạn mất luôn lợi ích 304.

Bẫy ?v=2 và thứ tự deploy

Cách cũ là style.css?v=2. Nó chạy được, nhưng nhiều CDN được cấu hình bỏ qua query string khi tính cache key (để gộp các URL tracking), nên ?v=2 vẫn trả bản cũ. Hash trong path không bị bẫy này.

Thứ tự deploy cũng là một phần của cache busting. Asset phải lên _____, HTML lên sau:

  1. Build sinh ra app.3f9c1a2b.js với hash mới
  2. Upload asset mới lên origin, để CDN kéo về với max-age dài
  3. Kiểm tra asset mới trả 200 từ CDN
  4. Cuối cùng upload index.html mới trỏ tới hash mới
  5. Giữ asset cũ trên CDN thêm vài ngày, đừng xóa ngay

Lý do của thứ tự này rất thực tế: nếu HTML mới lên trước, user tải HTML trỏ tới app.3f9c1a2b.js trong khi file đó chưa tồn tại, kết quả là 404 và trang trắng. Ngược lại thì luôn an toàn vì HTML cũ vẫn trỏ tới file cũ còn đó.

Ví dụ rollback: một user đang mở tab từ sáng, HTML cũ trỏ app.1a2b3c4d.js. Bạn deploy bản mới rồi xóa asset cũ. User F5 và nhận 404. Giữ asset cũ thì rollback chỉ đơn giản là đưa index.html cũ trở lại.

Kiểm tra bằng mắt: đọc header từ CDN

Cache busting sai thường không báo lỗi, nó chỉ lặng lẽ trả bản cũ. Cách nhanh nhất là nhìn header:

$ curl -I https://cdn.example.com/app.3f9c1a2b.js
cache-control: public, max-age=31536000, immutable
x-cache: HIT
age: 84321

x-cache: HIT nghĩa là edge đang phục vụ từ cache, không về origin. age: 84321 là số giây object đã nằm trong cache, so với max-age để biết khi nào nó hết hạn. Nếu bạn thấy age gần bằng max-age trong khi vừa deploy bản mới, gần như chắc chắn bạn đang thiếu hash trong tên file.

Những lỗi khiến CDN vẫn trả bản cũ

  • Đặt immutable cho file không có hash. User kẹt bản cũ suốt một năm và F5 không cứu được, vì immutable bảo trình duyệt đừng hỏi lại. Quy tắc: tên file không đổi thì đừng cho max-age dài.
  • Xóa asset cũ ngay sau khi deploy. Tab đang mở và bản rollback đều cần file cũ. Giữ ít nhất vài ngày, hoặc theo TTL của index.html.
  • Purge toàn bộ CDN mỗi lần deploy. Purge là van xả khẩn cấp, không phải quy trình. Nếu phải purge thường xuyên, vấn đề nằm ở tên file, không nằm ở CDN.
  • Cache HTML quá lâu. Đây là lỗi tốn thời gian debug nhất, vì mọi thứ trên CDN đều đúng, chỉ có danh sách hash là cũ.
  • Đoán thay vì đo. Trước khi kết luận, hãy curl -I một file asset và một file HTML, so cache-control và x-cache với bảng ở trên.

Chốt lại ba dòng

  • Static assets nên có max-age rất dài (một năm) kèm immutable, và điều kiện bắt buộc là hash nội dung nằm trong tên file.
  • index.html dùng no-cache để luôn revalidate, nhờ đó user nhận được danh sách hash mới mà không phải tải lại toàn bộ.
  • Deploy theo thứ tự asset trước, HTML sau, giữ asset cũ vài ngày; cache busting là thiết kế URL, không phải thao tác purge.

Bài tiếp theo là checkpoint của module: bạn sẽ gặp lại TTL, invalidation theo sự kiện, Cache-Control, ETag, Vary và chính cách đặt hash này trong một bộ câu hỏi tình huống.

Front: Vì sao static assets là ứng viên số một cho CDN? Back: Cùng một nội dung cho mọi user, ít thay đổi, nhưng được đọc rất nhiều lần.

Front: max-age=31536000 tương đương bao lâu? Back: Một năm (31.536.000 giây).

Front: Header immutable nói gì với trình duyệt? Back: Đừng revalidate trong thời gian max-age, kể cả khi user bấm F5.

Front: Điều kiện bắt buộc để được đặt max-age một năm kèm immutable? Back: Tên file chứa hash nội dung, nên URL đó không bao giờ đổi nội dung.

Front: Vì sao index.html nên dùng no-cache? Back: Vì nó trỏ tới các hash mới, cần revalidate để user thấy bản deploy mới.

Front: no-cache khác no-store ở điểm nào? Back: no-cache vẫn lưu nhưng bắt revalidate; no-store không lưu gì cả.

Front: Cơ chế nào giúp lần revalidate của HTML thường chỉ tốn một 304? Back: ETag: server trả 304 Not Modified nếu nội dung chưa đổi.

Front: Cache busting bằng hash trong tên file khác ?v=2 thế nào? Back: Hash nằm trong path nên CDN không thể bỏ qua; nhiều CDN cấu hình bỏ query string khi tính cache key.

Front: Vì sao phải upload asset trước rồi mới upload HTML? Back: Tránh HTML mới trỏ tới file chưa tồn tại, gây 404 và trang trắng.

Front: Vì sao giữ asset cũ trên CDN thêm vài ngày? Back: Tab đang mở với HTML cũ vẫn trỏ tới file đó, và rollback cần nó.

Front: x-cache: HIT trong response của CDN nghĩa là gì? Back: Edge trả nội dung từ cache, không phải về origin.

Front: Header age: 84321 cho biết điều gì? Back: Object đã nằm trong cache 84.321 giây, so với max-age để biết khi nào hết hạn.

Front: Đặt immutable cho file không có hash thì hậu quả là gì? Back: User kẹt bản cũ suốt max-age và F5 không cứu được.