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.

Ảnh chụp đoạn mã Go nền tối minh hoạ Protocol Buffers mã hóa varint wire type và vì sao nhỏ, mỗi trường một tag byte cộng giá trị tag bằng field_number dịch trái 3 hoặc wire_type wire type 0 varint 1 64-bit 2 length-delimited 5 32-bit, a bằng 1 field 1 varint 08 01 08 bằng 1 dịch 3 hoặc 0 field 1 wire type 0 01 giá trị 1, s bằng hi field 4 len 22 02 68 69 22 bằng 4 dịch 3 hoặc 2 field 4 wire type 2 length-delimited 02 độ dài 2 rồi hi 68 69, varint 7 bit mỗi byte số nhỏ ít byte a bằng 1 08 01 1 chỉ cần 1 byte value a bằng 300 08 ac 02 300 lớn hơn 127 cần 2 byte value bit cao mỗi byte cờ còn nữa 7 bit thấp dữ liệu số càng nhỏ càng ít byte khác int cố định 4 8 byte, số âm int32 vs sint32 zigzag c bằng âm 1 sint32 zigzag 18 01 2 byte zigzag âm 1 sang 1 1 sang 2 âm 2 sang 3 số âm nhỏ ít byte a bằng âm 1 int32 không zigzag 08 ff ff ff ff ff ff ff ff ff 01 11 byte int32 âm mã như int64 varint luôn tối đa, proto3 trường zero không được ghi M rỗng mọi field zero 0 byte M A 0 0 byte zero mặc định bỏ qua message toàn giá trị mặc định 0 byte 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:

Ảnh chụp bảng kết quả đo thật nền tối từng byte tag cộng varint zigzag cứu số âm 11 byte xuống 2 zero là 0 byte, go run cộng proto Marshal Go 1.23 protobuf 1.34 arm64, mã hóa từng byte proto Marshal hex thật a bằng 1 2 byte 0801 a bằng 300 2 byte varint 3 byte 08ac02 b bằng 1 field 2 2 byte 1001 c bằng âm 1 sint32 zigzag 2 byte 1801 a bằng âm 1 int32 không zigzag 11 byte 08ffffffffffffffffff01 s bằng hi 4 byte 22026869, giải mã tag byte 08 bằng 1 dịch 3 hoặc 0 field 1 varint 10 bằng 2 dịch 3 hoặc 0 field 2 varint 18 bằng 3 dịch 3 hoặc 0 field 3 varint 22 bằng 4 dịch 3 hoặc 2 field 4 length giá trị theo sau tag 01 bằng 1 ac02 bằng 300 026869 bằng len 2 cộng hi, điểm sốc số âm int32 tốn 11 byte c bằng âm 1 sint32 2 byte zigzag âm 1 sang 1 a bằng âm 1 int32 11 byte âm mã như int64 varint ffff 01 dùng sint32 sint64 cho trường hay âm int32 chỉ hợp số không âm chọn sai kiểu phình 5,5x cho mỗi số âm, proto3 giá trị zero 0 byte M rỗng 0 byte M A 0 0 byte trường bằng mặc định không lên dây message thưa cực nhỏ, cốt lõi tag byte field_number dịch 3 hoặc wire_type không mang tên trường varint 7 bit byte số nhỏ ít byte zigzag sint32 64 cho số âm âm 1 2 byte thay vì 11 zero trường mặc định bỏ qua 0 byte vì sao nhỏ bỏ tên trường cộng varint cộng bỏ zero gọn hơn JSON nhiều

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ề

  1. 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ật a=1 là 0801, s="hi" là 22026869; đây là lý do lớn protobuf nhỏ hơn JSON.
  2. 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 -1 tốn 11 byte trong khi sint32 (zigzag) chỉ 2 byte — dùng sint32/sint64 cho trường hay âm, sai kiểu phình ~5,5 lần.
  3. 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.