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

Ảnh chụp đoạn mã Go nền tối minh hoạ schema evolution, hai loại tương thích cần đạt tương thích lùi backward code mới đọc được dữ liệu code cũ ghi tương thích tiến forward code cũ đọc được dữ liệu code mới ghi hệ phân tán triển khai dần cũ và mới chạy song song một thời gian phải đọc chéo được cả hai chiều không crash không mất dữ liệu cũ, hai phiên bản schema chỉ khác field mới type OrderV1 struct ID int64 Ma string field 1 2 type OrderV2 struct ID int64 Ma GhiChu Khach string cộng field 3 4, cơ chế Protobuf dựa vào field number không phải tên đọc gặp field number không biết bỏ qua an toàn default i cộng bằng protowire ConsumeFieldValue num typ skip field lạ field mong đợi mà thiếu trong dữ liệu giữ giá trị default zero luôn kiểm wire type khớp trước khi đọc case num bằng 3 và typ bằng protowire BytesType đúng kiểu mới đọc v m bằng protowire ConsumeBytes o.GhiChu bằng string v

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:

Ảnh chụp bảng kết quả chạy thật schema evolution output thật, một FORWARD code mới ghi V2 4 field code cũ đọc V1 V2 ghi id 90210 ma DH-2026-0009 ghi_chu giao gio hanh chinh khach Tran Minh 50 byte V1 đọc id 90210 ma DH-2026-0009 bỏ qua field 3 4 lạ không crash id ma khớp V2 true, hai BACKWARD code cũ ghi V1 2 field code mới đọc V2 V1 ghi id 90211 ma DH-2026-0010 18 byte V2 đọc id 90211 ma DH-2026-0010 ghi_chu rỗng khach rỗng field mới nhận default rỗng true, ba nguy hiểm đổi kiểu field 3 số thay vì chuỗi ghi field 3 bằng varint 999 V2 mong field 3 bằng chuỗi bytes V2 đọc id 5 ghi_chu rỗng wire type lệch field 3 bị bỏ qua mất dữ liệu âm thầm không lỗi rõ ràng, luật an toàn thêm field với số mới field cũ để nguyên số đổi tên field Protobuf dùng số không dùng tên nguy hiểm đổi field number của field cũ đổi kiểu wire type field tái dùng số của field đã xóa đọc nhầm dữ liệu cũ mẹo đánh dấu reserved số đã xóa

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=90210 và 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, khach nhậ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_chu vẫ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ề

  1. 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.
  2. Đổ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à reserved số đã xóa.
  3. 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

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ộ.