Bạn thêm một field vào struct đơn hàng và deploy. Nhưng trong vài phút (hoặc vài giờ) triển khai cuốn chiếu, một nửa số máy chạy code mới, nửa kia còn code cũ — và chúng đang gửi dữ liệu cho nhau. Nếu code cũ sập khi gặp field lạ, hoặc code mới đọc sai dữ liệu cũ, bạn có một sự cố production. Đây là bài toán schema evolution — thay đổi cấu trúc dữ liệu qua thời gian mà không phá vỡ các bên đang chạy phiên bản khác. Bài này (phần 10 loạt Serialization) chạy thật bằng Protobuf để thấy vì sao nó xử lý chuyện này tốt, và những cái bẫy phải tránh.
Hai loại tương thích
Có hai chiều tương thích cần đạt, và cả hai đều quan trọng khi triển khai dần:
- Tương thích lùi (backward): code mới đọc được dữ liệu do code cũ ghi. (Bạn nâng cấp reader trước.)
- Tương thích tiến (forward): code cũ đọc được dữ liệu do code mới ghi. (Writer được nâng cấp trước, reader còn cũ.)
Protobuf đạt cả hai nhờ một quyết định thiết kế: dữ liệu được định danh bằng field number (số thứ tự), không phải tên. Quy tắc đọc đơn giản: gặp field number không biết thì bỏ qua; field mong đợi mà thiếu thì dùng giá trị default (zero).
type OrderV1 struct{ ID int64; Ma string } // field 1,2
type OrderV2 struct{ ID int64; Ma, GhiChu, Khach string } // +field 3,4
// khi đọc, gặp field lạ -> bỏ qua an toàn:
default:
i += protowire.ConsumeFieldValue(num, typ, data[i:]) // skip field không biết
// và LUÔN kiểm wire type khớp trước khi đọc field:
case num == 3 && typ == protowire.BytesType:
v, m := protowire.ConsumeBytes(data[i:]); o.GhiChu = string(v); i += m

Hình 1: Hai loại tương thích (tiến/lùi); Protobuf định danh field bằng số, không tên — gặp field số lạ thì ConsumeFieldValue bỏ qua, field thiếu thì giữ default; luôn kiểm wire type khớp trước khi đọc.
Đo thật: đọc chéo hai chiều
Mình ghi bằng một phiên bản rồi đọc bằng phiên bản kia, cả hai chiều:

Hình 2: Chạy thật — Forward: V2 (4 field, 50 byte) → V1 đọc id=90210, ma khớp, bỏ qua field 3,4 không crash; Backward: V1 (2 field, 18 byte) → V2 đọc id/ma đúng, field mới ghi_chu/khach = default rỗng; Nguy hiểm: đổi kiểu field 3 → wire type lệch → field bị bỏ qua, mất dữ liệu âm thầm.
Đọc kết quả đo được:
- Tương thích tiến hoạt động: code mới ghi V2 với đủ 4 field (50 byte), code cũ V1 đọc lấy đúng
id=90210vàma="DH-2026-0009", bỏ qua field 3 và 4 mà nó không biết — không crash. Code cũ tiếp tục chạy bình thường dù dữ liệu có thêm field mới. - Tương thích lùi hoạt động: code cũ ghi V1 với 2 field (18 byte), code mới V2 đọc lấy đúng id/ma, và các field mới
ghi_chu,khachnhận giá trị default rỗng — không lỗi, không đoán bừa. Code mới xử lý được dữ liệu cũ một cách an toàn. - Cái bẫy đổi kiểu — mất dữ liệu âm thầm: khi mình ghi field 3 dạng số (varint) trong khi V2 mong đợi chuỗi (bytes), wire type lệch nhau. Kết quả: field 3 bị bỏ qua lặng lẽ,
ghi_chuvẫn rỗng — không có lỗi báo. Đây là loại hỏng nguy hiểm nhất: dữ liệu biến mất mà không ai biết. (Trong thực nghiệm, đọc sai wire type còn có thể gây lỗi phân tích hoặc vòng lặp — luôn phải kiểm wire type khớp trước khi đọc.)
Đánh đổi cần cân nhắc
Luật vàng: chỉ THÊM field với số mới, đừng bao giờ đổi số hay kiểu của field cũ. An toàn: thêm field number mới, giữ nguyên field cũ, thậm chí đổi tên field (Protobuf dùng số, không dùng tên — đây là lợi thế so với JSON/gob vốn dựa vào tên). Nguy hiểm: đổi field number của field đang dùng, đổi kiểu/wire type, và đặc biệt tái dùng số của field đã xóa — dữ liệu cũ mang số đó sẽ bị đọc nhầm vào field mới. Protobuf có từ khóa reserved để đánh dấu số đã xóa, cấm tái dùng — hãy dùng nó.
JSON và gob cũng tiến hóa được, nhưng dựa vào tên. JSON tự mô tả nên thêm field mới thì bên cũ bỏ qua, thiếu field thì nhận zero — khá giống Protobuf, nhưng định danh bằng tên field, nên đổi tên field là phá tương thích (ngược với Protobuf). gob cũng linh hoạt tương tự và cũng dựa vào tên. Điểm mạnh riêng của Protobuf là số field ổn định tách khỏi tên, cộng với .proto làm hợp đồng schema rõ ràng giữa các đội.
Tương thích không tự động — phải kỷ luật và kiểm thử. Cơ chế cho phép tiến hóa an toàn nếu bạn tuân luật; nó không ngăn bạn phạm luật. Một lập trình viên đổi kiểu field vì "thấy hợp lý hơn" có thể gây mất dữ liệu âm thầm như demo. Thực hành tốt: kiểm thử tương thích tự động (ghi bằng schema cũ, đọc bằng mới và ngược lại) trong CI, và duyệt kỹ mọi thay đổi .proto.
Ba ý mang về
- Protobuf tiến hóa schema an toàn nhờ field number: đo thật code mới ghi 4 field → code cũ đọc 2 field và bỏ qua field lạ không crash (tiến); code cũ ghi 2 field → code mới đọc, field mới nhận default (lùi) — đọc chéo hai chiều đều đúng.
- Đổi kiểu/số field là mất dữ liệu âm thầm: đo thật ghi field 3 dạng số trong khi reader mong chuỗi → field bị bỏ qua, không lỗi báo; luật vàng là chỉ thêm field số mới, không đổi số/kiểu field cũ, và
reservedsố đã xóa. - JSON/gob cũng tiến hóa được nhưng dựa vào tên: thêm/bớt field ổn, nhưng đổi tên field phá tương thích (ngược Protobuf vốn dùng số) — và mọi cơ chế đều đòi kỷ luật + kiểm thử tương thích trong CI, không tự động an toàn.
Nguồn
- Protocol Buffers — Updating A Message Type / Rules: https://protobuf.dev/programming-guides/proto3/#updating
- Protocol Buffers — reserved fields: https://protobuf.dev/programming-guides/proto3/#fieldreserved
- Martin Kleppmann — Designing Data-Intensive Applications (chương Encoding and Evolution): https://dataintensive.net/
Phần sau ta khám phá một hướng khác hẳn: zero-copy với FlatBuffers và Cap'n Proto — đọc field trực tiếp từ buffer mà không giải mã thành struct; đo thời gian truy cập một field so với cách phải unmarshal toàn bộ.