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
reservednumber đã 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.

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:

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
Ordercó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, schemabadmongamountở 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àbadtìm, nênamountcủabadlà 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ề
- 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.
- 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ũ.
- Kỷ luật:
reservedkhi 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ìreservednumber để 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
- Protocol Buffers — Proto3: Updating A Message Type: https://protobuf.dev/programming-guides/proto3/#updating
- Protocol Buffers — Unknown Fields: https://protobuf.dev/programming-guides/proto3/#unknowns
- Protocol Buffers — Reserved Fields: https://protobuf.dev/programming-guides/proto3/#reserved
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.