Bài trước ta thấy protobuf nhỏ hơn JSON nhiều lần, nhưng chỉ nói "vì nó mã hóa nhị phân". Bài này mở nắp capo: xem chính xác từng byte protobuf sinh ra, và hiểu ba cơ chế làm nên sự nhỏ gọn — tag byte, varint, và zigzag. Quan trọng hơn, ta sẽ gặp một cạm bẫy thật khiến một số âm phình từ 2 byte lên 11 byte nếu chọn sai kiểu. Hiểu wire format không chỉ để tò mò — nó giúp bạn thiết kế schema .proto tối ưu và gỡ lỗi khi dữ liệu trên dây trông lạ.
Tag byte: số trường và wire type
Mỗi trường protobuf bắt đầu bằng một tag byte gộp hai thông tin: số thứ tự trường và kiểu mã hóa (wire type):
tag = (field_number << 3) | wire_type
Wire type có vài giá trị: 0 = varint (số nguyên), 2 = length-delimited (chuỗi, bytes, message con), 1/5 = số cố định 64/32-bit. Đo thật (Go 1.23), proto.Marshal cho ra hex chính xác:
a=1 (field 1, varint): 08 01
08 = (1<<3)|0 -> field 1, wire type 0 (varint); 01 = giá trị 1
s="hi" (field 4, len): 22 02 68 69
22 = (4<<3)|2 -> field 4, wire type 2; 02 = độ dài 2; 68 69 = "hi"
Điểm mấu chốt: tag không mang tên trường — chỉ số thứ tự. Đây là lý do lớn protobuf nhỏ hơn JSON (JSON lặp lại tên trường trong mỗi message). Tên trường sống trong .proto, không đi trên dây.

Hình 1: Tag byte = (field_number << 3) | wire_type, không mang tên trường. Varint co giãn theo giá trị. sint32 dùng zigzag cho số âm; int32 âm mã như int64 varint tốn tối đa. proto3 bỏ trường giá trị zero.
Varint: co giãn theo giá trị
Varint mã số nguyên bằng số byte thay đổi: mỗi byte dùng 7 bit thấp cho dữ liệu, bit cao nhất làm cờ "còn byte nữa". Số nhỏ tốn ít byte:
a=1: 08 01 // 1 <= 127 -> 1 byte value
a=300: 08 ac 02 // 300 > 127 -> 2 byte value
Khác với int cố định (luôn 4 hoặc 8 byte bất kể giá trị), varint làm số nhỏ — thường gặp nhất — cực gọn. Một ID 5 tốn 1 byte thay vì 4.
Cạm bẫy thật: int32 âm tốn 11 byte
Đây là phần quan trọng nhất và ít người biết. Đo thật hai cách mã số -1:

Hình 2: Hex thật từ proto.Marshal. c=-1 kiểu sint32 (zigzag): 2 byte (1801). a=-1 kiểu int32 (không zigzag): 11 byte (08ffffffffffffffffff01) — vì số âm được mã như int64 varint, luôn dùng tối đa 10 byte value + 1 tag.
- c=-1 kiểu sint32 (zigzag):
18 01— 2 byte. - a=-1 kiểu int32 (không zigzag):
08 ff ff ff ff ff ff ff ff ff 01— 11 byte!
Vì sao? Varint chuẩn không hiệu quả với số âm: -1 khi mã như int64 varint có tất cả bit cao là 1, nên tốn tối đa 10 byte. zigzag (dùng bởi sint32/sint64) ánh xạ số âm nhỏ về số dương nhỏ trước khi mã varint: 0→0, -1→1, 1→2, -2→3... — nhờ đó -1 chỉ tốn 2 byte. Bài học thực tế: dùng sint32/sint64 cho trường hay mang giá trị âm (offset, delta, tọa độ); int32/int64 chỉ hợp khi số gần như luôn không âm (ID, count). Chọn sai kiểu làm mỗi số âm phình ~5,5 lần.
proto3 bỏ trường giá trị zero
Một tối ưu nữa: trong proto3, trường có giá trị mặc định (zero) không được ghi lên dây:
M{} (mọi field zero): 0 byte
M{A: 0}: 0 byte
Một message toàn giá trị mặc định mã hóa thành 0 byte. Điều này khiến message thưa (nhiều trường zero) cực nhỏ. Đổi lại: proto3 không phân biệt được "chưa set" với "set = 0" cho kiểu vô hướng — nếu cần phân biệt, dùng optional (từ proto3.15) hoặc wrapper type.
Đánh đổi cần cân nhắc
Số thứ tự trường nhỏ (1-15) tốn tag 1 byte, từ 16 trở lên tốn 2 byte. Vì tag là (field << 3) | wire_type, field 1-15 vừa trong một byte varint, field 16+ cần hai. Đặt các trường dùng thường xuyên nhất vào số 1-15 để tiết kiệm một byte mỗi lần xuất hiện — đáng kể với message lặp nhiều lần.
Không bao giờ đổi số thứ tự trường sau khi phát hành. Số trường là hợp đồng nhị phân — đổi nó là phá vỡ mọi dữ liệu và client cũ. Đổi tên trường thì an toàn (tên không lên dây), nhưng đổi số hay kiểu wire type thì hỏng tương thích. Đây là lý do .proto khuyên "reserved" cho số trường đã bỏ.
Chọn kiểu số đúng ngữ nghĩa dữ liệu. Ngoài int32/sint32, còn fixed32/fixed64 (luôn 4/8 byte, không varint) — hợp khi số thường lớn (hash, số ngẫu nhiên), vì varint của số lớn tốn 5-10 byte còn fixed luôn 4-8. Quy tắc: varint cho số nhỏ, fixed cho số lớn/ngẫu nhiên, sint cho số hay âm.
Ba ý mang về
- Protobuf mã mỗi trường bằng tag byte
(field_number << 3) | wire_type+ giá trị, không mang tên trường (tên sống trong.proto) — đo thậta=1là0801,s="hi"là22026869; đây là lý do lớn protobuf nhỏ hơn JSON. - Varint co giãn theo giá trị (7 bit/byte, số nhỏ ít byte), nhưng số âm là cạm bẫy: đo thật
int32-1tốn 11 byte trong khisint32(zigzag) chỉ 2 byte — dùng sint32/sint64 cho trường hay âm, sai kiểu phình ~5,5 lần. - proto3 bỏ trường giá trị zero (message toàn mặc định = 0 byte, đo thật) — cực gọn cho message thưa; và nhớ đặt trường hay dùng vào số 1-15 (tag 1 byte), không bao giờ đổi số trường sau phát hành.
Phần sau ta xuống tầng vận chuyển mà gRPC dựa lên: Phần sau mổ xẻ HTTP/2 multiplexing — cách nhiều request chia sẻ một kết nối qua các stream song song, và vì sao nó xóa bỏ head-of-line blocking của HTTP/1.1.