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

Ảnh chụp đoạn mã nền tối ETag hai cách sinh và chỉ một cách tiết kiệm được server với Go net/http và pgx cho GET /products 200 dòng 62 KB JSON, If-None-Match trả 304 và If-Match trả 412, bản B ETag băm từ thân biết không đổi sau khi đã làm hết việc gồm b bằng json.Marshal layDanhSach tức query 200 dòng cộng render JSON vẫn chạy mỗi lần, et bằng hex sha256 của b 8 byte đầu, đặt header ETag, nếu If-None-Match bằng et thì WriteHeader 304 và return tiết kiệm 62 KB qua dây nhưng server đã tốn công, ngược lại Write b, bản C ETag từ phiên bản dữ liệu query rẻ trước query nặng chỉ khi đổi gồm khai ver int64, QueryRow select max version from products where id nhỏ hơn hoặc bằng 200 Scan ver, et bằng v cộng ver không cần render để biết thân có đổi hay chưa, đặt header ETag, nếu If-None-Match bằng et thì 304 và return với 999 trên 1000 request dừng ở đây, còn lại json.Marshal layDanhSach chỉ chạy khi version đổi rồi Write, cùng ETag đó cho PUT If-Match chặn ghi đè mất mát gồm hienTai bằng etagSanPham 1 là version hiện tại của dòng 1, nếu header If-Match khác rỗng và khác hienTai thì WriteHeader 412 Precondition Failed người khác đã sửa trước bạn và ghi thân type /loi/da-bi-sua title Dữ liệu đã bị người khác sửa tải lại rồi sửa tiếp rồi return, ngược lại update products set name where id 1

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:

Ảnh chụp output thật nền tối 304 bớt 62 MB chỉ bản C bớt được việc server với Go 1.23 pgx v5 PostgreSQL 16 200 sản phẩm JSON 62.250 byte poll 1.000 lần, mục 1 1.000 request poll với If-None-Match dữ liệu không đổi cho thấy bản A không ETag 0 mã 304 62.2 MB qua dây 1000 query nặng p50 0.240 ms p99 0.575 ms, bản B ETag băm thân 999 mã 304 0.1 MB 1000 query nặng p50 0.241 ms p99 0.445 ms với ghi chú bớt byte không bớt việc, bản C ETag từ version trong DB 999 mã 304 0.1 MB 1 query nặng p50 0.098 ms p99 0.131 ms, mục 2 bản C khi dữ liệu đổi giữa chừng cho thấy lần 1 200 ETag v857700 lần 2 cùng ETag 304 rồi UPDATE products SET price bằng price cộng 1 WHERE id 50 rồi lần 3 ETag cũ 200 ETag mới v1462647 62.268 byte và lần 4 304 với ghi chú đổi 1 dòng là cả danh sách đổi ETag, mục 3 hai người cùng mở sản phẩm 1 rồi lần lượt bấm Lưu cho thấy không kiểm An PUT 204 Bình PUT 204 tên cuối Bản của Bình bản của An mất không ai biết, có If-Match An PUT 204 Bình PUT 412 title Dữ liệu đã bị người khác sửa tải lại rồi sửa tiếp tên cuối Bản của An, nói thẳng ETag băm từ thân là cách dễ nhất và nó chỉ tiết kiệm băng thông server vẫn query vẫn render p50 y hệt muốn tiết kiệm server thì ETag phải tính được mà không cần dựng thân tức là từ một cột version

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ình PUT → 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...". An PUT → 204 và ETag của dòng đổi. Bình PUT vớ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ề

  1. 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.
  2. Version đáng tin là một thay đổi schema, không phải một middleware: cột version tăng mỗi UPDATE, và với danh sách thì phải bắt được cả xoá — max(version) không đủ.
  3. Cùng ETag dùng cho If-Match chặ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

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.