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

Ảnh chụp đoạn mã nền tối ba cách đánh phiên bản một header quyết định cache đúng hay sai với Go net/http cổng 8084 qua nginx 1.27 proxy_cache max-age 10 tới client, v1 có gia là số còn v2 có gia là object, cách 1 phiên bản trong URL gồm mux.HandleFunc /v1/products/ gọi ghi với v2SangV1 của layV2 7 và phiên bản 1 cho ra gia 29000, mux.HandleFunc /v2/products/ gọi ghi với layV2 7 và phiên bản 2 cho ra gia là object, v1 không có code riêng chỉ là v2 đi qua một hàm chuyển đổi func v2SangV1 trả sanPhamV1 với p.ID p.Ten p.Gia.SoTien, cách 2 phiên bản trong header Accept cùng một URL gồm mux.HandleFunc /hdr/products/ nếu strings.Contains Accept version=2 thì ghi layV2 7 phiên bản 2 ngược lại ghi v2SangV1 phiên bản 1, hàm ghi đặt Cache-Control public max-age 10 và nếu vary thì đặt Vary Accept với chú thích quên dòng này thì cache trả v1 cho client v2 rồi Encode v, cách 3 không đánh phiên bản chỉ thêm không đổi trường cũ expand gồm mux.HandleFunc /products/ lấy p bằng layV2 7 rồi ghi map với id ten gia bằng p.Gia.SoTien là trường cũ giữ nguyên kiểu client v1 vẫn đọc được, gia_chi_tiet bằng p.Gia là trường mới client mới dùng client cũ bỏ qua, ton_kho và phiên bản 1 cộng

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/7 và /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 ghi Accept: 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/7 trả gia như cũ và thêm gia_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:

Ảnh chụp output thật nền tối header quên Vary 100 trên 200 client nhận sai bản với Go 1.23 qua nginx 1.27 proxy_cache tới client 200 request xen kẽ client v1 và client v2, mục 1 đi qua cache HTTP ai nhận nhầm phiên bản cho thấy cách URL /v1 /v2 có 198 HIT 2 MISS 0 client nhận sai bản thân mẫu client v1 nhận được là id 7 ten Cà phê sữa 7 gia 29000, cách header quên Vary có 199 HIT 1 MISS và 100 client nhận sai bản thân mẫu client v1 nhận được vẫn là gia 29000 và v2 cũng nhận cái này, cách header có Vary Accept có 198 HIT 2 MISS 0 sai, mục 2 client v1 struct cố định đọc phản hồi ba kiểu thay đổi phía server cho thấy không đổi gì /v1 thì decoder thường OK decoder chặt DisallowUnknownFields OK, thêm trường giữ trường cũ /products thì decoder thường OK còn decoder chặt lỗi json unknown field gia_chi_tiet, đổi kiểu trường gia /v2 thì cả hai lỗi cannot unmarshal object into Go struct, mục 3 chi phí giữ v1 bằng lớp chuyển đổi v2 sang v1 đo thẳng vào server làm nóng 500 đo 2000 cho thấy /v1/products/7 p50 17 µs p99 58 µs, /v2/products/7 p50 15 µs p99 54 µs, /products/7 p50 14 µs p99 55 µs, /v1/products/7 đo lại lần hai p50 15 µs p99 55 µs chênh lệch nằm trong nhiễu, nói thẳng lớp chuyển đổi v2 sang v1 không tốn CPU đáng kể cái giá của phiên bản không nằm ở đó mà nằm ở cache dùng chung một header quên là sai 50 phần trăm và ở client chặt chẽ quá mức thêm trường thôi đã vỡ

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ên gia: decoder thường của Go bỏ qua trường lạ, OK. Decoder chặt (DisallowUnknownFields) vỡ với unknown field "gia_chi_tiet".
  • Đổi kiểu gia thành object: cả hai vỡ với cannot 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ề

  1. 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.
  2. 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.
  3. 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 Sunset và đo ai còn gọi v1 ngay từ ngày v2 ra.

Nguồn

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.