Trong một hệ thống microservice thật, bạn không bao giờ nâng cấp mọi service cùng lúc. Service A triển khai schema mới (thêm một trường), trong khi service B vẫn chạy schema cũ — và cả hai phải tiếp tục hiểu nhau. Nếu một thay đổi schema nhỏ làm vỡ giao tiếp, cả hệ thống sập. Đây là lý do tiến hoá schema (schema evolution) là kỹ năng sống còn khi dùng Protobuf. Và chìa khoá để làm đúng chỉ gói trong một câu: Protobuf nhận diện field bằng SỐ, không bằng tên. Hiểu hệ quả của câu này là biết thêm/bớt field an toàn, và biết đâu là thay đổi sẽ phá vỡ mọi thứ. Bài này (phần 4/12) đo thật ba kịch bản trong go-lab.

Field number là danh tính của field

Ở bài grpc-02 ta thấy wire format chỉ chứa field number (1, 2, 3...), không chứa tên trường. Hệ quả trực tiếp cho tiến hoá schema:

  • Thêm field với number mới: an toàn. Bên cũ gặp number lạ thì bỏ qua (nhưng giữ lại — xem dưới), bên mới đọc data cũ thì field mới nhận giá trị default.
  • Đổi number của field đang dùng: thảm hoạ. Bên đọc tìm field theo số, đổi số là nó không tìm thấy dữ liệu cũ nữa.
  • Xoá field rồi tái dùng number đó cho field khác: lẫn lộn — data cũ ở number đó sẽ bị diễn giải sai. Phải reserved number đã xoá để cấm tái dùng.
  • Đổi tên field: vô hại trên wire (tên không nằm trong byte), chỉ ảnh hưởng code.

Ảnh chụp đoạn mã nền tối minh hoạ tiến hoá schema Protobuf field number là bất biến mọi thứ xoay quanh nó, Protobuf nhận diện field bằng số không bằng tên nên thêm bớt field an toàn nhưng đổi number là phá vỡ dữ liệu cũ. V1 và V2 thêm field mới bằng thêm number mới, V1 message Order int64 id 1 string customer 2 double amount 3, V2 thêm email ở number mới 4 không đụng number cũ message Order int64 id 1 string customer 2 double amount 3 string email 4. Quy tắc vàng của tiến hoá schema thêm field mới với number chưa dùng an toàn bên cũ bỏ qua, bên cũ đọc data mới field lạ thành unknown field giữ lại khi re-serialize, bên mới đọc data cũ field thiếu bằng giá trị default, đổi number của field đang dùng đọc sai hoàn toàn bất biến, tái dùng number đã xoá lẫn với data cũ phải reserved number đó. Reserved khoá vĩnh viễn number đã bỏ message Order reserved 3 reserved amount cấm tái dùng number 3 và tên amount int64 id 1 string customer 2

Hình 1: V2 thêm field email ở number MỚI (4), không đụng number cũ. Quy tắc: thêm field mới an toàn, unknown field được giữ lại, field thiếu lấy default; đổi number là phá vỡ, và phải reserved number đã xoá để không tái dùng nhầm.

Đo thật: ba kịch bản

Mình tạo ba schema trong go-lab: V1 (Order{id=1, customer=2, amount=3}), V2 (thêm email=4), và bad (đổi amount sang number 5), rồi chạy cross-version:

Ảnh chụp bảng kết quả đo thật ba kịch bản tiến hoá schema output thật go-lab protobuf v1.34.2 schema V1 V2 bad. Một FORWARD V1 marshal V2 unmarshal thêm field an toàn V1 ghi Order id 1001 customer amount 250000 V2 đọc id 1001 customer nguyen-van-a amount 250000 email rỗng field mới bằng default rỗng bên mới V2 đọc data cũ V1 field email chưa có nhận giá trị default không lỗi. Hai BACKWARD cộng round-trip unknown field được giữ V2 ghi Order email b@example.com V1 đọc V1 không có field email V1 thấy id customer amount không thấy email V1 re-serialize rồi V2 đọc lại email b@example.com unknown field được giữ nguyên bên cũ V1 đọc data mới field lạ thành unknown field V1 không hiểu nhưng giữ lại khi ghi lại nên dữ liệu không mất qua tay service cũ. Ba BREAKING đổi field number amount 3 sang 5 V1 ghi amount ở field 3 schema bad mong amount ở field 5 đọc id 1001 customer nguyen-van-a amount 0 sai amount bị mất đổi field number bằng đọc sai hoàn toàn Protobuf khớp field theo số đổi số là mọi dữ liệu cũ của field đó biến mất field number là bất biến đây là luật quan trọng nhất của Protobuf

Hình 2: Đo thật ba kịch bản. (1) V1→V2: email mới = default rỗng, field cũ nguyên vẹn. (2) V2→V1→re-serialize→V2: email="b@example.com" được giữ qua service cũ nhờ unknown field. (3) Đổi number amount 3→5: amount=0, dữ liệu mất.

Kết quả thật:

  • ① FORWARD (V1 → V2): V1 ghi Order{id:1001, customer, amount:250000}, V2 đọc được đầy đủ, email="" (default rỗng vì data cũ chưa có). Bên mới đọc data cũ không bao giờ lỗi — field thiếu lấy default. Đây là lý do thêm field luôn an toàn.
  • ② BACKWARD + round-trip — unknown field: V2 ghi Order có email="b@example.com". Service V1 cũ (không biết field email) đọc: thấy id/customer/amount, không thấy email. Nhưng điều kỳ diệu: khi V1 ghi lại (re-serialize) rồi V2 đọc lại, email vẫn còn ("b@example.com"). Protobuf lưu field không nhận ra vào unknown fields và giữ nguyên chúng khi serialize lại. Nhờ vậy dữ liệu không mất khi đi qua tay một service phiên bản cũ — cực kỳ quan trọng trong hệ thống nâng cấp dần.
  • ③ BREAKING — đổi field number: V1 ghi amount ở field 3, schema bad mong amount ở field 5. Kết quả: amount=0 — dữ liệu mất hoàn toàn. Vì Protobuf khớp field theo số: byte của field 3 trong data cũ không khớp với field 5 mà bad tìm, nên amount của bad là default (0), còn byte field 3 thành unknown. Đây là minh chứng sống cho luật: field number là bất biến.

Đánh đổi cần cân nhắc

Unknown field cứu dữ liệu, nhưng không phải lúc nào cũng có. Việc giữ unknown field qua re-serialize là mặc định trong hầu hết thư viện Protobuf hiện đại (bao gồm Go), nhưng một số cấu hình/ngôn ngữ hoặc khi chuyển qua JSON trung gian sẽ làm rơi unknown field. Đừng phụ thuộc tuyệt đối vào nó cho dữ liệu quan trọng đi qua nhiều tầng không đồng nhất — nó là lưới an toàn, không phải bảo đảm tuyệt đối.

reserved là kỷ luật bắt buộc khi xoá field. Khi xoá một field, number của nó không tự động bị cấm — một người khác (hoặc chính bạn sau này) có thể vô tình tái dùng number đó cho field mới, và data cũ sẽ bị diễn giải sai lệch âm thầm. Luôn khai reserved <number> (và reserved "<tên>") ngay khi xoá, để protoc chặn việc tái dùng ngay lúc biên dịch.

Thay đổi kiểu cũng có thể là breaking — tuỳ cặp kiểu. Giữ nguyên number nhưng đổi kiểu đôi khi tương thích (int32 ↔ int64 ↔ bool cùng wire type varint nên đọc được, dù giá trị có thể lệch), nhưng đổi giữa các wire type khác nhau (ví dụ int sang string) thì hỏng. Quy tắc an toàn: đừng đổi kiểu của field đang dùng; cần kiểu khác thì thêm field mới với number mới và deprecate field cũ.

Ba ý mang về

  1. Protobuf nhận diện field bằng SỐ — đó là nền của mọi quy tắc tiến hoá. Đo thật: thêm field mới (email=4) thì V2 đọc data V1 lấy default, hoàn toàn an toàn; nhưng đổi number của field đang dùng (amount 3→5) làm dữ liệu mất (amount=0). Field number là bất biến.
  2. Unknown field giữ dữ liệu qua service phiên bản cũ. Đo thật: V2 ghi email, V1 cũ đọc không thấy nhưng re-serialize vẫn giữ, V2 đọc lại thấy "b@example.com". Đây là cơ chế cho phép nâng cấp dần mà dữ liệu không rơi rụng khi đi qua tầng cũ.
  3. Kỷ luật: reserved khi xoá, đừng đổi number/kiểu field đang dùng. Thêm field mới với number mới là cách tiến hoá an toàn; xoá thì reserved number để cấm tái dùng; cần đổi kiểu thì thêm field mới chứ đừng sửa field cũ. Làm đúng là schema tiến hoá được mãi mà không vỡ.

Nguồn

Phần sau ta chuyển sang vận hành: deadline và context trong gRPC — cách một hạn chót lan truyền qua cả chuỗi gọi, và huỷ request đúng cách để không rò rỉ tài nguyên.