Phần trước đo cache nằm giữa client và server. Phần này đo cache nằm ở client — và cách client hỏi server "cái tôi đang giữ còn đúng không?" mà không phải tải lại toàn bộ. Cơ chế đó là ETag cùng hai header có điều kiện: If-None-Match cho đọc và If-Match cho ghi.
Hầu hết tài liệu nói về ETag dừng ở "tiết kiệm băng thông". Phép đo ở đây cho thấy băng thông là phần dễ; phần khó là ETag có tiết kiệm được việc của server hay không, và câu trả lời phụ thuộc vào cách bạn sinh ra nó.
Hai cách sinh ETag

Hình 1: Bản B băm thân đã render. Bản C hỏi database một câu rẻ trước. Phần ba dùng lại ETag cho PUT.
Giao thức giống nhau ở cả hai bản: server trả ETag: "..." kèm thân; lần sau client gửi If-None-Match: "..."; nếu khớp, server trả 304 Not Modified không kèm thân và client dùng lại bản đang giữ. Khác nhau ở chỗ server tính ETag từ đâu:
- Bản B băm thân phản hồi đã render. Dễ nhất, không cần thay đổi dữ liệu, và là cách nhiều framework làm tự động bằng middleware. Nhưng để có thân mà băm, server phải chạy query và render JSON — tức là làm toàn bộ việc rồi mới biết là không cần gửi.
- Bản C lấy ETag từ một con số đổi khi dữ liệu đổi — ở đây là phiên bản của các dòng liên quan trong database — bằng một query rẻ, trước khi làm gì khác. Chỉ khi ETag không khớp mới chạy query nặng và render.
Đo thật: cùng 0,1 MB, khác 999 query
Danh sách 200 sản phẩm từ PostgreSQL, thân JSON 62.250 byte. Client poll 1.000 lần, dữ liệu không đổi, mỗi lần gửi If-None-Match với ETag nhận được lần trước:

Hình 2: Kết quả thật. Cột "query nặng" đếm số lần server thật sự lấy 200 dòng và render.
| bản | mã 304 | byte qua dây | query nặng | p50 |
|---|---|---|---|---|
| A: không ETag | 0 | 62,2 MB | 1.000 | 0,240 ms |
| B: ETag băm thân | 999 | 0,1 MB | 1.000 | 0,241 ms |
| C: ETag từ version | 999 | 0,1 MB | 1 | 0,098 ms |
Bản B và C giống hệt nhau nhìn từ client: 999 lần 304, băng thông từ 62,2 MB xuống 0,1 MB — giảm 99,8%. Nếu chỉ đo ở client, hai bản không phân biệt được.
Nhìn từ server, chúng khác nhau hoàn toàn. Bản B chạy 1.000 query nặng, đúng bằng bản không có ETag, và p50 0,241 ms so với 0,240 ms — không nhanh hơn một micro-giây nào. Nó chỉ không gửi 62 KB; mọi thứ trước bước gửi vẫn diễn ra. Với ứng dụng mà nút cổ chai là database hoặc CPU render (hầu hết backend), bản B không giúp gì cho nút cổ chai đó.
Bản C chạy đúng 1 query nặng — lần đầu tiên — và 999 lần còn lại chỉ là một query rẻ lấy version. p50 giảm từ 0,240 xuống 0,098 ms, tức là phần lớn độ trễ còn lại là HTTP, không phải việc. Đây là ETag đúng nghĩa: biết "không đổi" trước khi làm việc.
Khi dữ liệu đổi
Mục 2 của Hình 2 kiểm tra bản C không trả 304 bừa: poll được 304, sửa giá của một sản phẩm, poll lại với ETag cũ → 200 kèm ETag mới và thân đầy đủ 62.268 byte; poll với ETag mới → 304. Đúng như mong đợi.
Để ý một điều: sửa một dòng làm ETag của cả danh sách đổi, và client phải tải lại cả 62 KB dù 199 dòng kia y nguyên. Đó là bản chất của ETag — nó nói "thân này có khác không", không nói "khác chỗ nào". Với danh sách thay đổi liên tục (feed, bảng giá trực tuyến), tỉ lệ 304 sẽ thấp và cách tiết kiệm phải khác: phân trang keyset ở phần 1, hoặc endpoint "cho tôi thay đổi từ mốc X".
Cùng ETag, chiều ngược lại: If-Match
Mục 3 là tình huống kinh điển: An và Bình cùng mở sản phẩm #1, cùng sửa tên, An bấm Lưu trước, Bình bấm sau.
- Không kiểm: An
PUT→ 204, BìnhPUT→ 204, tên cuối cùng là "Bản của Bình". Bản của An biến mất, không lỗi, không log, và An tin là mình đã lưu xong. Đây là lost update — lỗi hay gặp nhất trong mọi màn hình chỉnh sửa có nhiều người dùng. - Có
If-Match: cả hai tải về với cùng ETag"p...". AnPUT→ 204 và ETag của dòng đổi. BìnhPUTvới ETag cũ → 412 Precondition Failed kèm thân lỗi "Dữ liệu đã bị người khác sửa, tải lại rồi sửa tiếp". Tên cuối cùng là "Bản của An", và Bình biết mình cần làm gì.
Đây là khoá lạc quan (optimistic concurrency) được HTTP chuẩn hoá: không khoá gì khi đọc, chỉ kiểm tra lúc ghi. Server không cần bảng khoá, không cần session, chỉ cần so một chuỗi — và chuỗi đó là thứ bạn đã có sẵn nếu làm bản C.
Đánh đổi cần cân nhắc
Bản C cần một "version" đáng tin, và đó là thay đổi schema. Demo này dùng xmin của PostgreSQL — id giao dịch ghi dòng gần nhất — vì bảng có sẵn không có cột version. Nó đủ cho demo nhưng không nên dùng thật: xmin là bộ đếm 32 bit quay vòng, đổi khi VACUUM FREEZE, và max() của nó không đảm bảo tăng đều. Cách đúng là một cột version tăng mỗi lần UPDATE (trigger hoặc ORM), hoặc updated_at với độ phân giải đủ. Cái giá là một cột, một trigger, và một quy ước mà mọi đường ghi phải tuân theo — kể cả script sửa tay trong psql.
ETag của một danh sách là ETag của nhiều dòng. max(version) trên 200 dòng bắt được sửa và thêm, nhưng không bắt được xoá — xoá dòng có version lớn nhất làm max giảm, xoá dòng khác không đổi gì. Danh sách cần thêm count(*), hoặc tốt hơn là một version riêng cho cả tập (tăng khi bất kỳ dòng nào trong tập thay đổi).
ETag băm thân không vô dụng — nó rẻ ở chỗ khác. Khi thân được dựng từ cache sẵn (Redis, L1 ở phần 11 sê-ri caching), chi phí render gần bằng 0 và bản B lấy được gần hết lợi ích mà không đụng schema. Nó cũng là thứ duy nhất làm được cho tài nguyên không có version (file tĩnh, báo cáo tính toán). Chỉ cần biết rõ nó tiết kiệm cái gì và không tiết kiệm cái gì.
Weak ETag và nén. W/"..." nghĩa là "tương đương về nghĩa, không nhất thiết giống từng byte" — cần thiết khi phản hồi đi qua nén (phần 7), vì cùng một thân nén bằng gzip và br cho byte khác nhau. Dùng strong ETag với nén động là mời rắc rối: proxy sẽ từ chối Range và một số cache không khớp được.
Số đo loopback. 62 KB qua loopback gần như miễn phí nên p50 bản A và B bằng nhau; qua mạng thật, 62 KB so với vài trăm byte là chênh lệch hàng mili-giây và bản B có nhanh hơn ở phía client. Điều đó không thay đổi kết luận về server: 1.000 query vẫn là 1.000 query.
Ba ý mang về
- 304 tiết kiệm băng thông bất kể ETag sinh thế nào (62,2 MB → 0,1 MB), nhưng chỉ ETag tính được trước khi render mới tiết kiệm server: băm thân = 1.000 query và p50 không đổi; version từ DB = 1 query và p50 giảm 2,4 lần.
- Version đáng tin là một thay đổi schema, không phải một middleware: cột
versiontăng mỗiUPDATE, và với danh sách thì phải bắt được cả xoá —max(version)không đủ. - Cùng ETag dùng cho
If-Matchchặn lost update miễn phí: không kiểm thì bản của người lưu trước biến mất không dấu vết; có kiểm thì người sau nhận 412 và biết phải tải lại.
Nguồn
- RFC 9110 — HTTP Semantics, mục 8.8.3 ETag và 13.1 Preconditions: https://www.rfc-editor.org/rfc/rfc9110#section-8.8.3
- RFC 9110 — mục 15.4.5 304 Not Modified và 15.5.13 412 Precondition Failed: https://www.rfc-editor.org/rfc/rfc9110#section-15.4.5
- MDN — HTTP conditional requests: https://developer.mozilla.org/en-US/docs/Web/HTTP/Conditional_requests
- PostgreSQL docs — System Columns (xmin và vì sao không dùng làm version): https://www.postgresql.org/docs/16/ddl-system-columns.html
Phần sau ghép hai phần vừa rồi lại: Cache-Control trên HTTP API — max-age, stale-while-revalidate, private và no-store — đo xem mỗi chỉ thị thay đổi số request tới server và độ tươi dữ liệu bao nhiêu khi có cả cache trình duyệt lẫn cache dùng chung.