Phần trước kết thúc bằng câu "đổi type là đổi hợp đồng, phải đi qua phiên bản API". Phần này nói về chính chuyện đó. Mọi API sống đủ lâu đều phải đổi: trường gia từng là một số, giờ cần kèm tiền tệ; trường ten cần tách họ và tên. Trong khi đó vẫn còn app di động phiên bản cũ ngoài kia gọi vào, và bạn không cập nhật được chúng.
Có ba trường phái, và tranh luận giữa chúng thường dừng ở khẩu vị. Bài này đo một thứ mà khẩu vị không đổi được: cả ba đi qua cache HTTP như thế nào.
Ba cách, cùng một dữ liệu

Hình 1: Ba cách trả cùng một sản phẩm. Để ý ở cách 2, phiên bản được chọn bằng header nên cùng một URL có thể trả hai thân khác nhau.
Mô hình nội bộ chỉ có một: sanPhamV2, với gia là object {so_tien, tien_te}. Phiên bản 1 không có code riêng — nó là v2 đi qua hàm v2SangV1 gói giá về một số. Ba cách phơi ra ngoài:
- Phiên bản trong URL:
/v1/products/7và/v2/products/7. Hai URL, hai tài nguyên dưới mắt mọi thứ đứng giữa client và server. - Phiên bản trong header: một URL
/products/7, client ghiAccept: application/json; version=2. Đây là cách "đúng REST" hơn theo nhiều người, vì URL định danh tài nguyên, còn biểu diễn là chuyện thương lượng. - Không đánh phiên bản:
/products/7trảgianhư cũ và thêmgia_chi_tiet. Client cũ bỏ qua trường lạ, client mới dùng trường mới. Gọi là expand/contract: chỉ thêm, không bao giờ đổi nghĩa hay kiểu của trường đã có.
Đo qua cache: 0, 100, 0
Mình đặt nginx 1.27 với proxy_cache trước server, server trả Cache-Control: public, max-age=10 — giống bất kỳ CDN hay reverse proxy nào ngoài đời. Rồi hai client xen kẽ: một đòi v1, một đòi v2, tổng 200 request:

Hình 2: Kết quả thật. Cột "client nhận SAI bản" là số request mà client đòi một phiên bản và nhận về phiên bản kia.
| cách | HIT | MISS | client nhận sai bản |
|---|---|---|---|
URL /v1 /v2 |
198 | 2 | 0 |
header, quên Vary |
199 | 1 | 100 |
header, có Vary: Accept |
198 | 2 | 0 |
Phiên bản trong URL đúng mà không cần làm gì. Cache khoá theo URL; /v1/products/7 và /v2/products/7 là hai khoá, mỗi khoá miss đúng một lần rồi hit 99 lần. Không có cấu hình nào phải nhớ.
Phiên bản trong header mà quên Vary thì sai đúng một nửa. Request đầu tiên là client v1, cache lưu thân v1 dưới khoá /products/7. Từ đó về sau mọi request tới URL đó — kể cả của client v2 với Accept: ...version=2 — đều hit và nhận thân v1. 199 HIT, 1 MISS: hiệu suất cache đẹp nhất bảng, và 100 client nhận sai dữ liệu. Không có lỗi, không có log, chỉ có client v2 nhận "gia": 29000 thay vì object và vỡ ở chỗ nào đó xa server. Nếu client v2 đến trước, ngược lại: toàn bộ client v1 nhận object.
Thêm Vary: Accept thì header hoạt động đúng — cache hiểu "thân này phụ thuộc vào header Accept" và giữ hai bản riêng. Kết quả giống hệt cách URL. Nhưng đó là một dòng phải nhớ ở mọi endpoint, và phép đo cho thấy quên nó không tạo ra lỗi mà tạo ra dữ liệu sai cho 50% người dùng. Vary: Accept còn có cái giá thứ hai: cache phải giữ một bản cho mỗi giá trị Accept khác nhau mà client gửi lên — trình duyệt, curl, SDK Android và iOS mỗi cái gửi một chuỗi Accept khác — nên tỉ lệ hit giảm theo số biến thể, không phải theo số phiên bản.
Client cũ đọc phản hồi mới
Mục 2 của Hình 2 đo phía bên kia: một client v1 với struct cố định {id, ten, gia int64} đọc ba loại phản hồi.
- Không đổi gì: cả hai decoder OK, hiển nhiên.
- Thêm trường
gia_chi_tiet, giữ nguyêngia: decoder thường của Go bỏ qua trường lạ, OK. Decoder chặt (DisallowUnknownFields) vỡ vớiunknown field "gia_chi_tiet". - Đổi kiểu
giathành object: cả hai vỡ vớicannot unmarshal object into Go struct.
Dòng thứ hai là lý do cách "không đánh phiên bản, chỉ thêm trường" hoạt động được trong thực tế: hầu hết decoder mặc định bỏ qua trường lạ. Và cũng là lý do nó không an toàn tuyệt đối: client nào bật chế độ chặt — để "bắt lỗi sớm" — sẽ vỡ ngay khi server thêm một trường vô hại. Quy ước ngầm của API tiến hoá được là client phải khoan dung với trường lạ (Postel), và quy ước ngầm thì có người không biết. Cách chữa là ghi nó thành chữ trong tài liệu API: "server có thể thêm trường bất cứ lúc nào; client không được từ chối trường chưa biết".
Dòng thứ ba là ranh giới cứng: đổi kiểu hoặc nghĩa của trường đã có thì không cách nào tránh được phiên bản mới. Không decoder nào cứu được, và đây là lúc /v2 hoặc version=2 phải xuất hiện.
Lớp chuyển đổi không phải là chi phí
Lo ngại hay gặp khi giữ nhiều phiên bản: "v1 giờ là v2 cộng một bước chuyển đổi, request chậm đi". Mục 3 đo thẳng vào server, bỏ qua cache, làm nóng 500 request rồi đo 2.000: v1 qua lớp chuyển đổi 15–17 µs, v2 trực tiếp 15 µs, bản expand 14 µs, và đo lại v1 lần hai ra 15 µs. Chênh lệch nằm trong nhiễu. Một phép gán ba trường không là gì so với serialize JSON và đi qua socket.
Chi phí thật của việc giữ v1 không nằm ở CPU. Nó nằm ở chỗ: mỗi tính năng mới phải trả lời "v1 có nó không, và nếu có thì diễn đạt thế nào bằng hợp đồng cũ"; mỗi bộ test chạy hai lần; và không ai dám xoá v1 vì không biết còn ai gọi. Phép đo không bắt được những thứ đó, nhưng nó loại bỏ được một lý do sai để tránh chúng.
Đánh đổi cần cân nhắc
URL hay header — chọn theo hạ tầng, không theo lý thuyết. Nếu giữa client và server có bất kỳ cache dùng chung nào (CDN, nginx, Varnish, cache của trình duyệt), phiên bản trong URL là cách không thể cấu hình sai. Header đúng về mặt REST hơn và giữ URL ổn định để chia sẻ, nhưng phép đo ở trên cho thấy nó đúng với điều kiện mọi endpoint nhớ Vary — và tỉ lệ hit của cache giảm theo số biến thể Accept thực tế.
Expand/contract là mặc định tốt, nhưng không phải phiên bản. Thêm trường, thêm endpoint, thêm giá trị enum mới (với client biết bỏ qua giá trị lạ) — tất cả đều làm được không cần phiên bản. Đổi kiểu, đổi nghĩa, bỏ trường, đổi mã lỗi — không. Phần lớn API sống nhiều năm với một phiên bản bằng cách kỷ luật về điều đó, và chỉ lên v2 khi mô hình dữ liệu thật sự đổi.
Mỗi phiên bản cần ngày hết hạn từ lúc sinh ra. Trả Deprecation và Sunset (RFC 8594) trong header của v1 ngay khi v2 ra, và ghi log client nào còn gọi v1 — vì không có số liệu đó thì v1 sống mãi. Có số liệu, bạn biết chính xác 3 client nào cần được liên hệ trước khi tắt.
Số đo cache ở đây là nginx với cấu hình tối thiểu. CDN thương mại có thể cấu hình bỏ qua Vary hoặc khoá cache theo header tuỳ chọn, và trình duyệt cũng có cache riêng với quy tắc gần giống. Điểm chung: tất cả đều khoá theo URL trước tiên, và chỉ tách theo header khi server nói rõ. Con số 100/200 là của một cache mặc định — tức là của trường hợp phổ biến nhất.
Ba ý mang về
- Phiên bản trong URL đúng với mọi cache mà không cần nhớ gì; phiên bản trong header chỉ đúng khi mọi endpoint trả
Vary: Accept— quên là 100/200 client nhận sai bản, không lỗi, không log, chỉ có dữ liệu sai. - Thêm trường không cần phiên bản mới, đổi kiểu trường thì bắt buộc: decoder thường bỏ qua trường lạ, nhưng decoder chặt vỡ ngay — nên "client phải khoan dung với trường lạ" phải được ghi thành chữ trong hợp đồng.
- Lớp chuyển đổi v2→v1 tốn 14–17 µs, trong nhiễu đo — chi phí giữ nhiều phiên bản là chi phí con người (test, tài liệu, không dám xoá), không phải CPU. Đặt ngày
Sunsetvà đo ai còn gọi v1 ngay từ ngày v2 ra.
Nguồn
- RFC 9111 — HTTP Caching, mục 4.1 Calculating Cache Keys with the Vary Header Field: https://www.rfc-editor.org/rfc/rfc9111#section-4.1
- RFC 8594 — The Sunset HTTP Header Field: https://www.rfc-editor.org/rfc/rfc8594
- nginx docs — ngx_http_proxy_module: proxy_cache: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_cache
- Stripe — API versioning (phiên bản theo ngày, chuyển đổi bằng lớp "gates"): https://stripe.com/blog/api-versioning
- Zalando — RESTful API Guidelines, mục Compatibility: https://opensource.zalando.com/restful-api-guidelines/#compatibility
Phần sau đi tiếp vào cache, nhưng ở phía client: ETag và request có điều kiện — khi dữ liệu chưa đổi, server trả 304 không kèm thân thay vì gửi lại cả body, và đo xem băng thông cùng độ trễ tiết kiệm được bao nhiêu.