HTTP caching cho API: Cache-Control, ETag và optimistic concurrency
API của bạn có thể gửi cùng một JSON hàng nghìn lần mỗi ngày. Client gọi GET /v1/products/42, nhận 18 KB, gọi lại sau năm phút và vẫn nhận đúng 18 KB dù dữ liệu không thay đổi. Bạn vẫn phải trả chi phí băng thông, serialization và truy vấn cơ sở dữ liệu.
HTTP cung cấp sẵn cơ chế giải quyết vấn đề này:
-
Cache-Controlcho biết phản hồi còn fresh trong bao lâu. -
ETagcung cấp “dấu vân tay” để kiểm tra phản hồi có thay đổi hay chưa. -
304 Not Modifiedbiến các yêu cầu lặp lại thành phản hồi không có phần thân. -
If-Matchdùng cùngETagđể ngăn các bản cập nhật bị ghi đè.
Nếu bạn đã đọc hướng dẫn caching API responses trong React, bài viết này trình bày phần caching phía server.
Ba lớp caching HTTP
Caching HTTP cho API gồm ba quyết định riêng biệt.
1. Freshness — tính mới
Client có thể tái sử dụng phản hồi trong bao lâu mà không cần gọi server?
Cache-Control: max-age=60
Trong 60 giây, client phục vụ bản sao cục bộ mà không tạo lưu lượng mạng. Đây là cache hit rẻ nhất, nhưng client không thể phát hiện thay đổi cho đến khi max-age hết hạn.
2. Validation — xác thực
Khi phản hồi trở nên cũ, client không nhất thiết phải tải lại toàn bộ dữ liệu. Nó gửi lại “dấu vân tay” trước đó để hỏi dữ liệu đã thay đổi chưa.
-
ETagkết hợp vớiIf-None-Matchlà cách chính xác nhất. -
Last-Modifiedkết hợp vớiIf-Modified-Sincedựa trên timestamp, thường có độ chính xác một giây.
Nếu tài nguyên không thay đổi, server trả 304 Not Modified với phần thân rỗng.
3. Invalidation — vô hiệu hóa
Khi dữ liệu thay đổi, các bản sao cũ được loại bỏ như thế nào?
- Cache riêng của client tự hết hạn qua
max-age. - Cache dùng chung và CDN cần explicit purge, TTL ngắn hoặc
stale-while-revalidate.
Freshness tiết kiệm nhiều nhất, validation phát hiện thay đổi sau khi freshness hết hạn, còn invalidation giới hạn thời gian dữ liệu cũ tồn tại. Hầu hết API nên kết hợp cả ba.
Quy trình 304 Not Modified
Giả sử endpoint là /v1/products/42.
Bước 1: Client gọi lần đầu
GET /v1/products/42 HTTP/1.1
Host: api.example.com
Bước 2: Server trả dữ liệu và metadata caching
HTTP/1.1 200 OK
Cache-Control: private, max-age=60
ETag: "33a64df551425fcc55e4d42a148795d9f2"
Content-Type: application/json
Content-Length: 18432
Client lưu phần thân và ETag. Trong 60 giây tiếp theo, client không cần gọi lại server.
Bước 3: Client revalidate sau 60 giây
GET /v1/products/42 HTTP/1.1
Host: api.example.com
If-None-Match: "33a64df551425fcc55e4d42a148795d9f2"
Bước 4: Server trả 304 nếu dữ liệu chưa đổi
HTTP/1.1 304 Not Modified
Cache-Control: private, max-age=60
ETag: "33a64df551425fcc55e4d42a148795d9f2"
Phản hồi không có phần thân. Thay vì truyền 18 KB, server chỉ gửi vài trăm byte header. Client đánh dấu bản sao local là fresh thêm 60 giây và tiếp tục phục vụ bản sao đó.
Nếu sản phẩm đã thay đổi, server trả 200 OK cùng phần thân và ETag mới. 304 là chỉ thị cho cache, không phải lỗi; xem thêm giải thích về 304 Not Modified.
Một GET có điều kiện vẫn cần một round trip và có thể phải tính toán ETag. Đổi lại, nó loại bỏ việc truyền payload và parse lại dữ liệu ở client. Với các endpoint danh sách lớn được client di động polling, cách này thường giảm 60–90% lưu lượng API egress.
Các chỉ thị Cache-Control quan trọng
Bạn có thể xem đầy đủ các chỉ thị trong tài liệu Cache-Control của MDN. Với API JSON, năm chỉ thị sau thường quan trọng nhất.
no-store và no-cache
Đây là điểm dễ bị hiểu sai nhất:
-
no-store: không lưu phản hồi vào bất kỳ cache nào. Dùng cho token, dữ liệu ngân hàng hoặc PII nhạy cảm. -
no-cache: vẫn cho phép cache lưu phản hồi, nhưng cache phải revalidate trước mỗi lần sử dụng lại.
Kết hợp no-cache với ETag, bạn vẫn nhận được phản hồi 304 và tiết kiệm payload. Dùng no-store cho mọi endpoint sẽ vô hiệu hóa conditional requests và buộc client tải toàn bộ phản hồi mỗi lần.
private
Chỉ cho phép cache phía client của người dùng cuối, không cho cache dùng chung hoặc CDN lưu phản hồi.
Các phản hồi phụ thuộc vào người dùng hiện tại, đặc biệt là API đã xác thực, nên chứa private. Nếu thiếu chỉ thị này, proxy cấu hình sai có thể gửi dữ liệu tài khoản của người dùng này cho người dùng khác.
max-age
Thời gian fresh, tính bằng giây. Với API đọc, khoảng 30–300 giây thường là điểm khởi đầu hợp lý. Mục tiêu là hấp thụ các đợt polling và traffic spike, không phải loại bỏ mọi yêu cầu trong cả ngày.
stale-while-revalidate
Cho phép cache phục vụ bản sao cũ trong khi revalidate ở nền:
Cache-Control: max-age=60, stale-while-revalidate=300
Cache có thể trả dữ liệu cũ trong tối đa năm phút bổ sung, đồng thời làm mới bản sao từ origin. Người dùng nhận phản hồi gần như tức thì, còn origin được cập nhật ngay sau đó.
Một mặc định hợp lý cho endpoint đọc đã xác thực:
Cache-Control: private, max-age=60, stale-while-revalidate=120
ETag: "9f8b2c41aa73e0d5"
Đặc tả đầy đủ nằm trong RFC 9111, tài liệu đã thay thế RFC 7234 cho HTTP caching.
Strong ETag và weak ETag
Tiền tố W/ phân biệt hai loại ETag.
Strong ETag
ETag: "33a64df551425fcc"
Strong ETag đảm bảo hai phản hồi giống nhau từng byte. Nó phù hợp cho byte-range requests và bắt buộc khi dùng If-Match để kiểm soát đồng thời.
Weak ETag
ETag: W/"33a64df551425fcc"
Weak ETag chỉ đảm bảo tương đương về mặt ngữ nghĩa. Thứ tự field hoặc timestamp có thể khác, nhưng nội dung vẫn được xem là tương đương.
Middleware nén như Nginx có thể chuyển strong ETag thành weak ETag khi gzip phản hồi on the fly, vì các byte sau nén không còn giống bản gốc. Nếu kiểm tra đồng thời thất bại phía sau proxy, hãy kiểm tra xem tiền tố W/ có bị thêm vào hay không.
Mặc định, hãy dùng strong ETag được tính trên phần thân chưa nén. Chỉ dùng weak ETag khi bạn thực sự phục vụ các biểu diễn khác nhau của cùng một dữ liệu. Xem thêm tài liệu ETag của MDN.
Tạo ETag: hash phần thân hay cột phiên bản?
Hash phần thân phản hồi
Serialize phản hồi rồi hash kết quả:
const hash = crypto.createHash("sha1")
.update(JSON.stringify(payload))
.digest("hex");
MD5 hoặc SHA-1 đủ dùng cho mục đích tạo fingerprint, vì đây không phải ranh giới bảo mật. Cách này chính xác và không yêu cầu thay đổi schema.
Nhược điểm là server vẫn phải xây dựng toàn bộ phản hồi và thực hiện serialization ở mỗi request, kể cả request trả 304. Bạn tiết kiệm băng thông nhưng chưa chắc tiết kiệm CPU hoặc tải cơ sở dữ liệu.
Cột phiên bản hoặc updated_at
Tạo ETag từ dữ liệu rẻ hơn để truy vấn:
ETag: "42-v17"
Một conditional request có thể chỉ cần tra cứu index thay vì serialize toàn bộ payload. Tuy nhiên, phiên bản phải tăng mỗi khi bất kỳ dữ liệu nào ảnh hưởng đến phản hồi thay đổi, bao gồm cả bảng được join.
Bỏ sót một thay đổi sẽ khiến server trả 304 cho dữ liệu cũ — loại lỗi caching nguy hiểm nhất vì rất khó phát hiện.
Hãy bắt đầu bằng body hashing để có hành vi đúng theo mặc định. Chỉ chuyển các endpoint “hot” sang ETag dựa trên version sau khi profiling cho thấy serialization là nút thắt.
Dùng ETag cho optimistic concurrency
Cùng fingerprint giúp giảm băng thông khi đọc cũng có thể ngăn lost update khi ghi.
Ví dụ:
- Hai quản trị viên cùng tải sản phẩm 42.
- A thay đổi giá và lưu.
- B sửa lỗi chính tả rồi lưu sau đó, ghi đè thay đổi giá của A bằng dữ liệu cũ.
- Không ai nhận được lỗi, nhưng dữ liệu bị sai âm thầm.
Giải pháp là yêu cầu mọi cập nhật chứa version mà client đã thấy:
PUT /v1/products/42 HTTP/1.1
If-Match: "33a64df551425fcc55e4d42a148795d9f2"
Content-Type: application/json
Server so sánh If-Match với ETag hiện tại:
- Khớp: áp dụng cập nhật và trả
200cùng ETag mới. - Không khớp: trả
412 Precondition Failed, không sửa dữ liệu. - Thiếu
If-Match: API nghiêm ngặt có thể trả428 Precondition Required.
Client có thể tải lại phiên bản mới, áp dụng lại thay đổi và thử lại. Xem thêm 412 Precondition Failed.
CDN và proxy xử lý các header này thế nào?
Cache dùng chung nằm giữa origin và client, đồng thời tuân theo các header caching với một số quy tắc riêng:
-
privateloại trừ phản hồi khỏi CDN.s-maxage=600đặt TTL riêng cho CDN, khác vớimax-agecủa trình duyệt. - Hầu hết CDN revalidate với origin bằng conditional request. Nếu origin trả
304, CDN làm mới metadata của bản sao mà không tải lại phần thân. - Đảm bảo framework gửi
Varychính xác. Nếu cùng URL trả JSON và CSV, cần cóVary: Accept; nếu không, cache có thể trả CSV cho client yêu cầu JSON. - Kiểm tra các proxy có thể làm yếu ETag khi nén phản hồi.
Ví dụ Express
Express tự động đặt weak ETag. Ví dụ dưới đây xử lý thủ công để dùng strong ETag và bảo vệ đường dẫn ghi bằng 412:
import crypto from "node:crypto";
import express from "express";
const app = express();
app.use(express.json());
function etagFor(payload) {
const hash = crypto.createHash("sha1")
.update(JSON.stringify(payload))
.digest("hex");
return `"${hash}"`;
}
app.get("/v1/products/:id", async (req, res) => {
const product = await db.products.find(req.params.id);
const etag = etagFor(product);
res.set("Cache-Control", "private, max-age=60, stale-while-revalidate=120");
res.set("ETag", etag);
if (req.get("If-None-Match") === etag) {
return res.status(304).end(); // fingerprint matches: no body
}
res.json(product);
});
app.put("/v1/products/:id", async (req, res) => {
const product = await db.products.find(req.params.id);
const currentEtag = etagFor(product);
const ifMatch = req.get("If-Match");
if (!ifMatch) {
return res.status(428).json({ error: "If-Match header required" });
}
if (ifMatch !== currentEtag) {
return res.status(412).json({ error: "Resource changed since you fetched it" });
}
const updated = await db.products.update(req.params.id, req.body);
res.set("ETag", etagFor(updated));
res.json(updated);
});
Nhánh 304 vẫn phải gửi lại Cache-Control và ETag. Theo RFC 9111, phản hồi 304 cập nhật metadata của phản hồi đã lưu, vì vậy client cần nhận đủ header để tiếp tục xem bản sao là fresh.
Xác minh caching trong Apidog
Mã ứng dụng có thể đúng nhưng middleware hoặc proxy vẫn làm sai hành vi. Hãy kiểm tra ở cấp HTTP bằng Apidog:
- Gửi
GET /v1/products/42. Trong response headers, xác nhận cóETagvàCache-Control, đồng thời ETag được đặt trong dấu ngoặc kép. - Thêm header
If-None-Matchvới giá trị ETag vừa sao chép rồi gửi lại. Kết quả mong đợi là304với phần thân rỗng. - Thay đổi bản ghi, gửi lại request và xác nhận server trả
200cùng ETag mới.
Để kiểm tra tự động sau mỗi lần triển khai, hãy tạo một test flow:
- Request đầu tiên trích xuất ETag từ response headers vào biến.
- Request thứ hai gửi biến đó trong
If-None-Match, rồi kiểm tra status304và body rỗng. - Request thứ ba gửi
PUTvới ETag cố tình cũ, chẳng hạn"deadbeefcafe1234", rồi kiểm tra status412.
Xem hướng dẫn về API assertions để biết cú pháp kiểm tra status code và headers.
Chạy flow này trong CI để phát hiện middleware âm thầm loại bỏ ETag trước khi vấn đề biến thành hóa đơn băng thông. Bạn có thể tải Apidog miễn phí và áp dụng flow cho các endpoint của mình.
FAQ
no-cache khác no-store như thế nào?
no-store cấm lưu phản hồi vào cache, vì vậy mỗi request phải tải toàn bộ dữ liệu.
no-cache cho phép cache lưu phản hồi nhưng bắt buộc revalidate trước khi tái sử dụng. Khi kết hợp với ETag, nó vẫn tạo được 304 và tiết kiệm payload.
Chỉ dùng no-store cho dữ liệu nhạy cảm. Dùng nó cho mọi endpoint là một sai lầm tốn kém.
ETag có hoạt động với POST không?
Thông thường là không. ETag mô tả trạng thái tài nguyên tại một URL, trong khi POST thường tạo tài nguyên mới thay vì đọc một trạng thái ổn định. Cache cũng thường không cache response POST.
Conditional headers quan trọng cho thao tác ghi là If-Match trên PUT, PATCH và DELETE. Nếu bạn muốn cache response POST, hãy kiểm tra xem thao tác đó có thực sự nên là GET hay không.
304 có làm API nhanh hơn không?
304 làm giảm kích thước truyền tải, nhưng server vẫn nhận request, chạy xác thực và tính toán ETag. Mức tiết kiệm CPU phụ thuộc vào cách tạo fingerprint.
Lợi ích rõ nhất thường nằm ở băng thông, pin thiết bị di động và thời gian hiển thị trên mạng chậm. Hãy đo trước và sau bằng hướng dẫn kiểm thử hiệu suất API.
Nên dùng ETag hay Last-Modified?
Nếu có thể, hãy gửi cả hai.
ETag chính xác hơn, phát hiện thay đổi dưới một giây và thay đổi cấp nội dung. Khi cả hai conditional header cùng xuất hiện, If-None-Match được ưu tiên hơn If-Modified-Since.
Last-Modified vẫn hữu ích như fallback cho client cũ và như một heuristic để cache ước tính freshness. Nếu chỉ chọn một, hãy chọn ETag.
Top comments (0)