Bài trước đo được protobuf gọn hơn JSON tới 63%, nhưng "gọn hơn" là một con số, không phải hiểu biết. Để thật sự nắm protobuf — và để debug khi có sự cố, để biết thay đổi nào an toàn — bạn cần hiểu từng byte nó tạo ra. Tin tốt: wire format của protobuf đơn giản đến mức giải mã tay được trong vài phút. Nó chỉ là một chuỗi lặp của (tag, giá trị), không có gì huyền bí. Bài này (phần 2 loạt gRPC nâng cao) serialize một message thật rồi giải mã từng byte để bạn thấy chính xác protobuf lưu dữ liệu thế nào — và từ đó hiểu vì sao field number, chứ không phải tên, mới là hợp đồng.

Ba quy tắc của wire format

Toàn bộ protobuf wire format gói trong ba ý:

  • Mỗi field = một tag byte + giá trị. Tag mã hoá hai thứ trong một byte: tag = (field_number << 3) | wire_type. Ba bit thấp là wire type, phần còn lại là field number.
  • Wire type (3 bit) cho biết cách đọc giá trị tiếp theo: 0 = varint (số nguyên, bool, enum), 1 = 64-bit (double, fixed64), 2 = length-delimited (string, bytes, message con — có độ dài đứng trước), 5 = 32-bit (float, fixed32).
  • Varint là số nguyên độ dài thay đổi: mỗi byte dùng 7 bit cho dữ liệu và 1 bit cao (MSB) báo "còn byte nữa"; các nhóm 7 bit xếp little-endian. Số nhỏ tốn ít byte, số lớn tốn nhiều.
// serialize rồi in hex
b, _ := proto.Marshal(&Demo{Id: 1001, Name: "abc", Big: 7})
fmt.Printf("%x", b)   // → 08e9071a03616263a00107

Ảnh chụp đoạn mã nền tối protobuf wire format tag cộng giá trị lặp lại, tag byte bằng field_number dịch trái 3 hoặc wire_type 3 bit thấp là wire type phần còn lại là field number, wire types 0 varint int bool enum 1 64-bit fixed64 double 2 length-delimited string bytes message 5 32-bit fixed32 float, varint số nguyên độ dài thay đổi mỗi byte 7 bit dữ liệu cộng 1 bit MSB còn nữa little-endian số nhỏ ít byte số lớn nhiều byte, demo proto Marshal Demo Id 1001 Name abc Big 7 in hex ra 08e9071a03616263a00107

Hình 1: Ba quy tắc wire format. Mỗi field là một tag byte (field_number << 3 | wire_type) rồi tới giá trị; wire type (3 bit) quyết định cách đọc giá trị; varint mã hoá số nguyên bằng các nhóm 7 bit với bit MSB báo còn byte tiếp. Message serialize ra chuỗi hex 08e907....

Đo thật: giải mã 11 byte

Mình serialize {id:1001, name:"abc", big:7} trên go-lab và giải mã từng byte của kết quả 08e9071a03616263a00107:

Ảnh chụp output thật nền tối giải mã từng byte protobuf Go proto.Marshal 11 byte, id 1001 name abc big 7 wire 11 byte 08 e907 1a 03 616263 a001 07, 08 bằng tag 1 dịch 3 hoặc 0 field 1 wire 0 varint, e9 07 bằng varint 1001 0x07 dịch 7 hoặc 0xe9 and 0x7f bằng 896 cộng 105, 1a bằng tag 3 dịch 3 hoặc 2 field 3 wire 2 length, 03 bằng độ dài 3, 61 62 63 bằng abc ASCII, a0 01 bằng tag field 20 2 byte vì field lớn hơn 15, 07 bằng varint 7, varint số càng lớn càng nhiều byte đo thật id 5 2 byte 0805 id 300 3 byte 08ac02 id 1000000 4 byte 08c0843d id 10 tỷ 6 byte 0880c8afa025, wire chỉ có số field không có tên đổi tên không phá đổi field number mới phá field nhỏ hơn bằng 15 rẻ hơn tag 1 byte

Hình 2: Giải mã thật 11 byte. 08=tag field 1 varint; e9 07=varint 1001 (ghép 0x07<<7 với 0xe9&0x7f = 896+105); 1a=tag field 3 length-delimited; 03=độ dài 3; 61 62 63="abc"; a0 01=tag field 20 (2 byte vì field>15); 07=varint 7. Varint co giãn: id=5 tốn 2 byte, id=10 tỷ tốn 6 byte.

Đọc từng phần:

  • 08 → field 1, varint: 0x08 = 0b00001000. Ba bit thấp = 000 = wire type 0 (varint). Phần còn lại 00001 = 1 = field number. Vậy đây là field 1, kiểu varint.
  • e9 07 → 1001: hai byte varint. 0xe9 có MSB=1 (còn byte nữa), 7 bit dữ liệu = 0x69 = 105. 0x07 có MSB=0 (hết), dữ liệu = 7. Ghép little-endian: (7 << 7) | 105 = 896 + 105 = 1001. Đúng giá trị id.
  • 1a 03 61 62 63 → name="abc": 0x1a = 0b00011010, wire type 010=2 (length-delimited), field number 00011=3. Byte tiếp 03 = độ dài 3. Ba byte 61 62 63 = ASCII "abc". Length-delimited luôn có độ dài đứng trước để biết đọc bao nhiêu byte.
  • a0 01 07 → field 20 = 7: đây là điểm thú vị. Field 20 → tag = (20<<3)|0 = 160. Nhưng 160 > 127 nên chính tag cũng phải varint 2 byte: a0 01. Rồi 07 = giá trị 7. Field number > 15 làm tag tốn 2 byte thay vì 1 — lý do protobuf khuyên dùng số 1-15 cho các trường hay xuất hiện nhất.

Và phần đo varint co giãn khẳng định quy tắc: id=5 → 2 byte, id=300 → 3 byte, id=1000000 → 4 byte, id=10 tỷ → 6 byte. Số càng lớn, varint càng dài — ngược với JSON (số nào cũng là chuỗi ký tự dài theo số chữ số).

Vì sao điều này giải thích schema evolution

Nhìn vào wire format, một sự thật quan trọng hiện ra: wire chỉ chứa số field (1, 3, 20), tuyệt đối không chứa tên field (id, name, big). Tên chỉ tồn tại trong file .proto cho con người và code đọc. Hệ quả trực tiếp cho việc tiến hoá schema (liên hệ bài schema của loạt Message Queue):

  • Đổi tên field là AN TOÀN cho wire: đổi id thành order_id trong .proto, byte serialize không đổi (vẫn field 1) — consumer cũ/mới đọc được của nhau.
  • Đổi field number là PHÁ VỠ: đổi id = 1 thành id = 2, byte đổi hoàn toàn (tag 08 → 10), và một reader dùng số cũ sẽ đọc sai field hoặc bỏ qua.
  • Reader bỏ qua field number lạ: gặp một tag field nó không biết, reader dùng wire type để bỏ qua đúng số byte và đọc tiếp — đây là nền tảng của tương thích xuôi.

Nói cách khác: với protobuf, field number là hợp đồng, tên chỉ là nhãn. Đây là đảo ngược hoàn toàn so với JSON nơi tên là tất cả.

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

Varint tiết kiệm cho số nhỏ nhưng phản tác dụng với số lớn ngẫu nhiên. Varint tối ưu cho số nhỏ và thường gặp (id tăng dần, đếm, enum). Nhưng với số lớn ngẫu nhiên như hash 64-bit hay timestamp nano, varint có thể tốn tới 10 byte — nhiều hơn fixed64 (luôn 8 byte). Với trường như vậy, dùng fixed64/sfixed64 thay int64 sẽ gọn hơn và ổn định hơn. Hiểu wire format giúp chọn đúng kiểu, không mặc định int64 cho mọi số.

Số âm là bẫy varint kinh điển. int64 mã hoá số âm bằng varint luôn tốn 10 byte (vì bù hai biến số âm thành số rất lớn ở biểu diễn không dấu). Nếu trường của bạn có thể âm (delta, toạ độ), dùng sint64 — nó dùng zigzag encoding ánh xạ số âm nhỏ về varint ngắn. Đây là lỗi hiệu năng âm thầm: dùng int64 cho dữ liệu hay âm làm message phình mà không ai để ý nếu không nhìn wire.

Đọc được byte không có nghĩa nên dựa vào nó. Giải mã tay hữu ích để học và debug, nhưng đừng viết code parse protobuf thủ công trong production — luôn dùng thư viện sinh từ .proto. Wire format có thể đổi chi tiết giữa các phiên bản (packed repeated, group đã deprecated...), và tự parse là nguồn bug. Hiểu để debug, không phải để tự implement.

Ba ý mang về

  1. Wire format chỉ là (tag, giá trị) lặp lại: đo thật 08e9071a03616263a00107 giải mã thành field 1 varint 1001, field 3 string "abc", field 20 varint 7 — tag = (field<<3)|wire_type, varint ghép nhóm 7 bit little-endian.
  2. Varint co giãn theo giá trị, field number ≤15 rẻ hơn: đo thật id=5 tốn 2 byte, id=10 tỷ tốn 6 byte; field 20 tốn tag 2 byte (>15) — chọn số nhỏ cho trường hay dùng, và dùng fixed64/sint64 cho số lớn/âm.
  3. Wire chứa số field, không chứa tên → số là hợp đồng: đo thật byte không có tên field nào; nên đổi tên an toàn còn đổi field number thì phá vỡ, và reader bỏ qua field lạ bằng wire type — nền tảng của schema evolution.

Nguồn

Phần sau ta khám phá bốn kiểu RPC của gRPC — unary, server streaming, client streaming, bidirectional streaming — chạy thật cả bốn và đo khác biệt giữa chúng.