API của bạn có lẽ gửi cùng một JSON hàng nghìn lần mỗi ngày. Một client yêu cầu GET /v1/products/42, nhận lại 18 KB, yêu cầu lại năm phút sau đó và vẫn nhận được 18 KB đó. Không có gì thay đổi. Bạn vẫn phải trả tiền cho băng thông, quá trình serialization và việc đọc cơ sở dữ liệu.
HTTP đã giải quyết vấn đề này. Header Cache-Control cho client biết một phản hồi duy trì trạng thái 'fresh' (còn hiệu lực) trong bao lâu. Header ETag cung cấp cho họ một 'dấu vân tay' để kiểm tra xem phản hồi đã thay đổi hay chưa. Cùng với nhau, chúng biến các yêu cầu lặp lại thành phản hồi 304 Not Modified với phần thân rỗng, và chúng còn có thể bảo vệ các thao tác ghi của bạn khỏi bị mất cập nhật như một lợi ích bổ sung. Những ý tưởng tương tự cũng cung cấp sức mạnh cho các mẫu thiết kế phía client; nếu bạn đã đọc hướng dẫn của chúng tôi về caching API responses in React, thì đây là phần phía server của câu chuyện đó.
Hướng dẫn này sẽ đi sâu vào ba lớp caching HTTP, trình bày chi tiết từng bước quy trình 'round trip' 304, làm rõ sự khác biệt giữa no-cache và no-store, và kết thúc bằng mã Express hoạt động được. Bạn cũng sẽ thấy cách xác minh tất cả những điều này trong Apidog bằng cách gửi các conditional headers và tự mình khẳng định phản hồi 304.
Ba lớp caching HTTP
Caching HTTP cho các API được chia thành ba quyết định riêng biệt. Các nhóm thường gặp rắc rối khi họ làm mờ ranh giới giữa chúng.
Lớp 1: Freshness (Tính mới). Client có thể tái sử dụng một phản hồi trong bao lâu mà không cần hỏi bạn? Đó là Cache-Control: max-age=60. Trong 60 giây, client sẽ phục vụ bản sao được cache cục bộ. Không có lưu lượng mạng. Đây là loại cache hit rẻ nhất có thể và cũng rủi ro nhất, vì client không thể phát hiện thay đổi cho đến khi bộ đếm thời gian hết hạn.
Lớp 2: Validation (Xác thực). Khi phản hồi trở nên cũ (stale), client không cần tải lại toàn bộ. Nó hỏi “cái này đã thay đổi chưa?” bằng cách gửi 'dấu vân tay' mà bạn đã cung cấp trước đó. Nếu tài nguyên không thay đổi, bạn sẽ trả lời bằng 304 Not Modified và không có phần thân. ETag với If-None-Match là phiên bản chính xác của điều này; Last-Modified với If-Modified-Since là phiên bản cũ hơn, dựa trên timestamp với độ chi tiết một giây.
Lớp 3: Invalidation (Vô hiệu hóa). Khi dữ liệu thay đổi, làm thế nào để các bản sao cũ bị 'loại bỏ'? Các cache riêng của client tự động hết hạn thông qua max-age. Các cache chia sẻ và CDN cần các lệnh xóa rõ ràng (explicit purges), TTL ngắn, hoặc các chỉ thị như stale-while-revalidate để giới hạn thời gian cũ (staleness).
Freshness tiết kiệm nhiều nhất, validation bắt kịp mọi thứ freshness bỏ sót, và invalidation giữ cho cả hai trung thực. Hầu hết các API đều cần cả ba.
Cách một quy trình 'round trip' 304 Not Modified hoạt động
Đây là toàn bộ chu trình cho một endpoint sản phẩm, từng bước một.
Yêu cầu đầu tiên. Client chưa có gì được cache:
GET /v1/products/42 HTTP/1.1
Host: api.example.com
Phản hồi đầu tiên. Bạn trả về phần thân cộng với 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 trữ phần thân và ETag. Trong 60 giây tiếp theo, nó sẽ không liên hệ với bạn chút nào.
Yêu cầu thứ hai, sau 60 giây. Bản sao đã cũ, vì vậy client thực hiện revalidate (xác thực lại):
GET /v1/products/42 HTTP/1.1
Host: api.example.com
If-None-Match: "33a64df551425fcc55e4d42a148795d9f2"
Phản hồi thứ hai, tài nguyên không thay đổi. Máy chủ của bạn so sánh ETag đến với ETag hiện tại. Chúng khớp nhau, vì vậy:
HTTP/1.1 304 Not Modified
Cache-Control: private, max-age=60
ETag: "33a64df551425fcc55e4d42a148795d9f2"
Không có phần thân. Thay vì 18 KB, phản hồi chỉ là vài trăm byte headers. Client đánh dấu bản sao được cache của nó là 'fresh' thêm 60 giây nữa và phục vụ nó. Nếu sản phẩm đã thay đổi, bạn sẽ trả về một phản hồi 200 thông thường với phần thân mới và một ETag mới. Chúng tôi đã đề cập đến bản thân mã trạng thái này chi tiết hơn trong bài giải thích 304 Not Modified của chúng tôi; phiên bản ngắn gọn là 304 là một chỉ thị cache, không phải lỗi.
Hiệu quả kinh tế rất đơn giản. Một GET có điều kiện vẫn tốn một 'round trip' cộng với bất kỳ công việc nào để tính toán ETag hiện tại. Điều nó loại bỏ là việc truyền tải payload và phân tích lại phía client. Đối với các endpoint danh sách lớn được các client di động thăm dò, điều này thường xuyên cắt giảm lưu lượng API egress từ 60 đến 90 phần trăm.
Các chỉ thị Cache-Control quan trọng đối với API
Cache-Control có hơn một tá chỉ thị. Đối với các API JSON, năm chỉ thị sau đây là quan trọng nhất.
no-store so với no-cache. Đây là lỗi caching phổ biến nhất trong các API production, và nó hoạt động theo cả hai hướng. no-store có nghĩa là “không bao giờ ghi cái này vào bất kỳ cache nào.” Sử dụng nó cho các payload thực sự nhạy cảm: token, dữ liệu ngân hàng, PII mà bạn không được phép lưu trữ. no-cache có nghĩa gần như ngược lại với những gì tên gọi của nó gợi ý: các cache CÓ THỂ lưu trữ phản hồi, nhưng chúng phải revalidate (xác thực lại) với nguồn gốc trước mỗi lần tái sử dụng. Kết hợp với ETag, no-cache giúp bạn tiết kiệm 304 cho mỗi yêu cầu trong khi đảm bảo client không bao giờ hiển thị dữ liệu cũ. Các nhóm lạm dụng no-store cho mọi thứ “để an toàn” đang vô hiệu hóa hoàn toàn các yêu cầu có điều kiện và phải trả toàn bộ chi phí payload cho mỗi lần gọi.
private. Đánh dấu phản hồi có thể được cache chỉ bởi client của người dùng cuối, không bao giờ bởi các cache chia sẻ hoặc CDN. Bất kỳ phản hồi nào thay đổi theo từng người dùng, mà đó là hầu hết lưu lượng API đã được xác thực, đều nên chứa private. Nếu không có nó, một proxy được cấu hình sai có thể phục vụ 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 hiệu lực (freshness lifetime) tính bằng giây. Đối với API, hãy nghĩ đến con số nhỏ: 30 đến 300 giây bao phủ hầu hết các endpoint đọc. Bạn không cố gắng loại bỏ các yêu cầu trong cả một ngày; bạn đang cố gắng hấp thụ các đợt tăng đột biến và các vòng lặp polling.
stale-while-revalidate. Giải pháp trung hòa thực tế. Cache-Control: max-age=60, stale-while-revalidate=300 cho các cache biết: hãy phục vụ bản sao cũ trong tối đa 5 phút bổ sung, nhưng làm mới nó trong nền. Người dùng nhận được phản hồi tức thì; nguồn gốc của bạn sẽ được cập nhật ngay sau đó. Các CDN như Cloudflare và Fastly đều hỗ trợ nó, cũng như các trình duyệt.
Một giá trị mặc định hợp lý cho một endpoint đọc đã được xác thực trông như thế này:
Cache-Control: private, max-age=60, stale-while-revalidate=120
ETag: "9f8b2c41aa73e0d5"
Đặc tả hành vi đầy đủ nằm trong RFC 9111, tài liệu này đã thay thế RFC 7234 làm tài liệu caching HTTP chuẩn. Khi một CDN hoạt động theo cách khiến bạn ngạc nhiên, RFC đó là nơi có câu trả lời.
ETag mạnh (Strong ETag) so với ETag yếu (Weak ETag)
Một ETag có hai loại, và tiền tố W/ phân biệt chúng.
Một strong ETag (ETag: "33a64df551425fcc") đảm bảo sự tương đồng từng byte một. Hai phản hồi có cùng strong ETag là giống hệt nhau, điều này làm cho strong ETag an toàn cho các yêu cầu byte-range và bắt buộc cho kiểm soát đồng thời với If-Match.
Một weak ETag (ETag: W/"33a64df551425fcc") đảm bảo sự tương đương về mặt ngữ nghĩa. Các byte có thể khác nhau, có thể thứ tự trường đã thay đổi hoặc trường timestamp đã được cập nhật, nhưng ý nghĩa vẫn giống nhau, vì vậy cache có thể giữ bản sao của nó.
Vấn đề phát sinh ở đây: middleware nén. Nginx và một số framework viết lại strong ETag thành weak ETag khi họ nén (gzip) một phản hồi 'on the fly', bởi vì các byte đã nén không còn khớp với bản gốc. Nếu các kiểm tra đồng thời của bạn thất bại một cách bí ẩn phía sau một proxy, hãy tìm tiền tố W/ mà không có khi máy chủ ứng dụng của bạn gửi phản hồi.
Mặc định sử dụng strong ETag được tính toán trên phần thân không nén. Chỉ sử dụng weak ETag khi bạn biết rõ mình đang phục vụ các biểu diễn khác nhau của cùng một dữ liệu.
Tạo ETag: hash của phần thân so với cột phiên bản
Có hai chiến lược chính, và chiến lược phù hợp phụ thuộc vào nơi phát sinh chi phí.
Hash của phần thân phản hồi. Serialize phản hồi, sau đó hash nó (MD5 hoặc SHA-1 là đủ ở đây; đây là một 'dấu vân tay', không phải một ranh giới bảo mật), và đặt nó trong dấu ngoặc kép. Nó chính xác theo cấu trúc và không cần thay đổi schema. Vấn đề: bạn xây dựng toàn bộ phản hồi trên mỗi yêu cầu, bao gồm cả các phản hồi 304. Bạn tiết kiệm băng thông nhưng không tiết kiệm được tài nguyên tính toán 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 mà bạn có thể lấy với chi phí thấp: ETag: "42-v17" từ bộ đếm phiên bản của hàng, hoặc một hash của updated_at. Bây giờ, một yêu cầu có điều kiện chỉ tốn một lần tra cứu chỉ mục thay vì serialization hoàn chỉnh. Vấn đề: phiên bản phải tăng lên mỗi khi có thay đổi ảnh hưởng đến phản hồi, bao gồm cả thay đổi trong các bảng được join. Bỏ sót một thay đổi và bạn sẽ phục vụ các phản hồi 304 cũ, đây là lỗi caching tồi tệ nhất vì nó không thể nhìn thấy được.
Bắt đầu với body hashing. Nó đúng theo mặc định. Di chuyển các endpoint "hot" sang ETag dựa trên phiên bản khi việc profiling cho thấy chi phí serialization là đáng kể.
ETag cho optimistic concurrency: If-Match và 412
'Dấu vân tay' tương tự giúp tiết kiệm băng thông khi đọc cũng ngăn chặn các bản cập nhật bị mất khi ghi.
Vấn đề cập nhật bị mất: hai quản trị viên tải sản phẩm 42 cùng một lúc. Quản trị viên A thay đổi giá và lưu. Quản trị viên B sửa lỗi chính tả và lưu 30 giây sau đó, ghi đè thay đổi giá của A bằng giá cũ mà B đã tải. Không ai thấy lỗi. Dữ liệu bị sai một cách âm thầm.
Cách khắc phục là làm cho mọi cập nhật có điều kiện dựa trên phiên bản mà client đã thấy lần cuối:
PUT /v1/products/42 HTTP/1.1
If-Match: "33a64df551425fcc55e4d42a148795d9f2"
Content-Type: application/json
Máy chủ so sánh If-Match với ETag hiện tại của tài nguyên. Khớp: áp dụng cập nhật, trả về 200 với ETag mới. Không khớp, ai đó đã đến trước: từ chối với 412 Precondition Failed và không chạm vào dữ liệu. Client sau đó tải lại, áp dụng thay đổi của nó trên phiên bản mới, và thử lại. Các API nghiêm ngặt hơn sẽ trả về 428 Precondition Required cho bất kỳ PUT nào bỏ qua If-Match, làm cho kiểm tra an toàn là bắt buộc.
Việc này hầu như không tốn kém gì khi ETag đã tồn tại, và nó biến một lỗi hỏng dữ liệu âm thầm thành một trạng thái HTTP rõ ràng, có thể thử lại.
CDN và proxy làm gì với các headers này
Các cache chia sẻ nằm giữa nguồn gốc của bạn và client của bạn, và chúng đọc các headers tương tự theo quy tắc riêng của chúng.
privateloại trừ hoàn toàn một phản hồi khỏi việc caching của CDN;s-maxage=600đặt một TTL (time-to-live) cụ thể cho CDN dài hơn hoặc ngắn hơn max-age của trình duyệt.- Hầu hết các CDN revalidate với nguồn gốc của bạn bằng cách sử dụng các yêu cầu có điều kiện. Nếu nguồn gốc của bạn trả lời If-None-Match bằng 304, CDN sẽ làm mới bản sao đã lưu mà không cần tải lại phần thân. ETag cũng giúp CDN của bạn rẻ hơn.
- Luôn xác nhận framework của bạn gửi
Varymột cách chính xác. Một API phục vụ cả JSON và CSV từ một URL cầnVary: Accept, nếu không, một cache chia sẻ có thể gửi CSV cho một client yêu cầu JSON. - Hãy chú ý đến các proxy làm yếu ETag thông qua việc nén, như đã đề cập ở trên.
Ví dụ Express: trả về ETag và xử lý If-None-Match
Express tự động đặt weak ETag, nhưng việc xử lý thủ công mang lại cho bạn strong ETag cùng với đường dẫn ghi 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);
});
Lưu ý rằng nhánh 304 vẫn gửi các headers Cache-Control và ETag. Theo RFC 9111, một phản hồi 304 cập nhật metadata của phản hồi đã lưu, vì vậy hãy gửi lại bất cứ điều gì client cần để giữ bản sao của nó là 'fresh' (mới).
Xác minh hành vi caching trong Apidog
Mã có vẻ đúng vẫn có thể cache sai khi middleware và proxy tham gia vào. Hãy kiểm tra ở cấp độ HTTP, không phải cấp độ mã.
Trong Apidog, việc kiểm tra thủ công mất khoảng một phút:
- Gửi
GET /v1/products/42và mở bảng điều khiển response headers. Xác nhậnETagvàCache-Controlcó mặt và ETag được đặt trong dấu ngoặc kép. Sao chép giá trị ETag. - Trên cùng một yêu cầu, thêm header
If-None-Matchvới giá trị đã sao chép và gửi lại. Bạn sẽ nhận được 304 với phần thân rỗng. Nếu bạn vẫn nhận được 200, lớp validation của bạn không so sánh 'dấu vân tay'. - Thay đổi bản ghi, gửi lại và xác nhận bạn nhận lại 200 với một ETag mới.
Để giữ cho việc này hoạt động sau mỗi lần triển khai, hãy tích hợp cùng một luồng vào một kịch bản kiểm thử. Xâu chuỗi hai yêu cầu: yêu cầu đầu tiên trích xuất ETag từ các response headers vào một biến, yêu cầu thứ hai gửi lại nó dưới dạng If-None-Match và khẳng định rằng trạng thái bằng 304 và phần thân rỗng. Thêm một bước thứ ba cho đường dẫn ghi: gửi một PUT với giá trị If-Match cố tình cũ như "deadbeefcafe1234" và khẳng định 412. Hướng dẫn của chúng tôi về các khẳng định API bao gồm cú pháp khẳng định cho các mã trạng thái và headers.
Chạy kịch bản đó trong CI và một bản nâng cấp middleware mà âm thầm loại bỏ ETag của bạn sẽ trở thành một pipeline thất bại thay vì một hóa đơn băng thông. Tải xuống Apidog miễn phí và xây dựng kịch bản chống lại các endpoint của riêng bạn; việc này mất nhiều thời gian đọc hơn là thực hiện bằng cách nhấp chuột.
Các câu hỏi thường gặp (FAQ)
Sự khác biệt giữa no-cache và no-store là gì?
no-store cấm caching hoàn toàn: không có gì được ghi vào đĩa hoặc bộ nhớ, vì vậy mỗi yêu cầu tải xuống toàn bộ phản hồi. no-cache cho phép lưu trữ nhưng buộc phải revalidation trước mỗi lần tái sử dụng, vì vậy khi kết hợp với ETag, nó vẫn mang lại các phản hồi 304 và tiết kiệm payload. Chỉ sử dụng no-store cho dữ liệu nhạy cảm. Sử dụng nó ở khắp mọi nơi là sai lầm tốn kém nhất mà một nhóm API có thể mắc phải về Cache-Control.
ETag có hoạt động với POST không?
Phần lớn là không, và đó là theo thiết kế. ETag mô tả trạng thái của một tài nguyên tại một URL, và POST thường tạo ra cái gì đó mới hơn là đọc trạng thái ổn định. Trong thực tế, các cache không cache các phản hồi POST. Các conditional headers quan trọng cho các thao tác ghi là If-Match trên PUT, PATCH và DELETE, nơi ETag bảo vệ chống lại các bản cập nhật bị mất. Nếu bạn có ý định cache các phản hồi POST, đó thường là dấu hiệu cho thấy thao tác đó nên là GET.
Phản hồi 304 có làm API của tôi nhanh hơn không?
Nó làm cho việc truyền tải nhỏ hơn, điều này không hoàn toàn giống nhau. Máy chủ vẫn nhận yêu cầu, chạy xác thực và tính toán ETag hiện tại, vì vậy việc tiết kiệm CPU ở nguồn gốc phụ thuộc vào việc bạn tạo 'dấu vân tay' đó rẻ đến mức nào. Những lợi ích thể hiện ở băng thông, pin điện thoại di động và thời gian hiển thị trên các mạng chậm. Hãy đo lường trước và sau; hướng dẫn kiểm thử hiệu suất API của chúng tôi chỉ ra cách benchmark độ trễ và thông lượng để bạn có thể chứng minh sự khác biệt thay vì đoán mò.
Tôi nên sử dụng ETag hay Last-Modified?
Hãy gửi cả hai khi có thể. ETag chính xác hơn: nó bắt được các thay đổi dưới một giây và sự khác biệt cấp độ nội dung mà một timestamp có thể bỏ sót, và If-None-Match ưu tiên hơn If-Modified-Since khi cả hai cùng đến. Last-Modified vẫn hữu ích như một phương án dự phòng cho các client cũ hơn và như một heuristic mà một số cache sử dụng để ước tính freshness. Nếu bạn chỉ gửi một, hãy gửi ETag.
