Hai phần đầu ta ở với JSON — dạng văn bản, dễ đọc, nhưng dài dòng: tên field lặp lại trong mỗi bản ghi, số viết dạng text, đầy dấu ngoặc và dấu phẩy. Protobuf (Protocol Buffers) đi hướng ngược lại: nhị phân, gọn, nhanh. Nhưng "gọn hơn" nghe chung chung; bài này (phần 3 loạt Serialization) mổ xẻ wire format — định dạng byte thật của Protobuf — tới từng byte để hiểu chính xác nó tiết kiệm ở đâu. Và ta làm điều đó mà không cần protoc: gói google.golang.org/protobuf/encoding/protowire cho phép tự tay ghép từng byte wire format.

Cơ chế: tag, wire type, và varint

Wire format của Protobuf đơn giản đến bất ngờ. Một message là một chuỗi các field, mỗi field gồm một tag rồi payload. Tag ghép hai thông tin vào một số nguyên:

tag = (field_number << 3) | wire_type

Ba bit thấp là wire type (kiểu mã hóa payload), phần còn lại là field number. Có bốn wire type dùng phổ biến:

wire type ý nghĩa dùng cho
0 varint int32/64, uint, bool, enum
1 i64 fixed64, double
2 length-delimited string, bytes, message lồng, packed
5 i32 fixed32, float

Còn varint là cách Protobuf nén số nguyên: mỗi byte dùng 7 bit cho dữ liệu, 1 bit cao nhất (MSB) làm cờ nối — bằng 1 nghĩa "còn byte nữa", bằng 0 là hết. Các nhóm 7 bit ghép theo little-endian (nhóm thấp trước). Hệ quả quan trọng: số nhỏ tốn ít byte — số dưới 128 chỉ 1 byte. Vì id, số đếm, enum thường nhỏ, đây là nguồn tiết kiệm lớn.

Ảnh chụp đoạn mã Go nền tối minh hoạ Protobuf wire format, TAG ghép field number và wire type vào một số tag bằng field_number dịch trái 3 hoặc wire_type 3 bit thấp là wire type wire type 0 varint int bool enum 1 i64 double 2 length-delimited string bytes message lồng 5 i32 float vd field 2 string 2 dịch 3 hoặc 2 bằng 18 bằng 0x12, VARINT số nhỏ tốn ít byte 7 bit dữ liệu cộng 1 bit nối mỗi byte bit cao nhất MSB bằng 1 nghĩa còn byte nữa 0 là hết 7 bit còn lại ghép little-endian nhóm thấp trước 90210 thành ba nhóm 7 bit byte e2 c0 05 số nhỏ hơn 128 chỉ tốn 1 byte id đếm enum nhỏ rất rẻ, mã hóa bằng protowire không cần protoc AppendTag buf 1 VarintType field 1 AppendVarint buf 90210 id AppendTag buf 2 BytesType field 2 AppendString Cà phê length cộng UTF-8 đọc lại ConsumeTag ConsumeVarint ConsumeBytes

Hình 1: Tag ghép field_number << 3 | wire_type (3 bit thấp là wire type); varint nén số bằng nhóm 7 bit với bit nối MSB; protowire.AppendTag/AppendVarint/AppendString mã hóa từng phần mà không cần protoc.

Đo thật: mổ 20 byte của một sản phẩm

Mình mã hóa một sản phẩm {id=90210, ten="Cà phê", gia=120000, con_hang=true} bằng protowire, rồi in từng byte và giải thích:

var buf []byte
buf = protowire.AppendTag(buf, 1, protowire.VarintType)  // field 1
buf = protowire.AppendVarint(buf, 90210)                 // id
buf = protowire.AppendTag(buf, 2, protowire.BytesType)   // field 2
buf = protowire.AppendString(buf, "Cà phê")              // length + UTF-8
// ... field 3 (gia), field 4 (con_hang)

Ảnh chụp bảng kết quả chạy thật Protobuf byte dump output thật, HEX đầy đủ 20 byte 08 e2 c0 05 12 08 43 c3 a0 20 70 68 c3 aa 18 c0 a9 07 20 01, giải thích từng byte 08 TAG field bằng 1 wire bằng 0 varint 1 dịch 3 hoặc 0 e2 c0 05 VARINT bằng 90210 id, 12 TAG field bằng 2 wire bằng 2 length-delim 2 dịch 3 hoặc 2 08 LENGTH bằng 8 byte 43 c3 a0 20 70 68 c3 aa bằng Cà phê à ê mỗi ký tự 2 byte UTF-8, 18 TAG field bằng 3 wire bằng 0 varint c0 a9 07 VARINT bằng 120000 gia, 20 TAG field bằng 4 wire bằng 0 varint 01 VARINT bằng 1 con_hang bằng true, decode lại chứng minh field 1 bằng 90210 field 2 bằng Cà phê field 3 bằng 120000 field 4 bằng 1 khớp, so kích thước JSON bằng 58 byte vs Protobuf bằng 20 byte khoảng 2.9 lần nhỏ hơn JSON tốn byte cho tên field lặp lại dấu ngoặc phẩy số dạng text Protobuf không có tên field chỉ số nén varint gọn

Hình 2: Chạy thật — 20 byte hex với giải thích từng byte: 08 là tag field 1 varint, e2 c0 05 là varint 90210; 12 tag field 2 length-delimited, 08 là độ dài 8, rồi 8 byte UTF-8 "Cà phê"; decode lại khớp mọi field; JSON 58 byte vs Protobuf 20 byte.

Đọc kết quả đo được, byte một:

  • 08 = tag field 1, varint: 1 << 3 | 0 = 8. Theo sau là e2 c0 05 — varint của 90210. Kiểm chứng cờ nối: e2 và c0 có MSB=1 (còn tiếp), 05 có MSB=0 (hết) → ghép 3 nhóm 7 bit ra đúng 90210.
  • 12 = tag field 2, length-delimited: 2 << 3 | 2 = 18 = 0x12. Theo sau là 08 (độ dài = 8 byte) rồi 8 byte UTF-8 của "Cà phê" — chú ý "à" và "ê" mỗi ký tự chiếm 2 byte, nên chuỗi 6 ký tự thành 8 byte.
  • 18 (field 3 varint) → c0 a9 07 = 120000; 20 (field 4 varint) → 01 = true. Một bool true chỉ tốn 2 byte (tag + 1 byte varint).
  • Decode lại khớp hoàn toàn: ConsumeTag/ConsumeVarint/ConsumeBytes đọc ra đúng id=90210, ten="Cà phê", gia=120000, con_hang=1 — chứng minh mã hóa đúng và có thể đảo ngược.

Con số kích thước: cùng dữ liệu, JSON tốn 58 byte, Protobuf chỉ 20 byte — nhỏ hơn ~2,9 lần. Tiết kiệm đến từ hai chỗ rõ ràng khi nhìn byte: (1) Protobuf không lưu tên field ("id", "ten", "gia"...) mà chỉ lưu số thứ tự trong tag; (2) số được nén bằng varint thay vì viết dạng text ("120000" tốn 6 byte text, chỉ 3 byte varint).

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

Gọn đổi lấy không đọc được bằng mắt. JSON mở ra là đọc được ngay; 20 byte hex của Protobuf thì không. Quan trọng hơn: wire format không tự mô tả kiểu. Byte 08 e2 c0 05 chỉ nói "field 1 là một varint 90210" — nó không biết field 1 tên là "id" hay nên hiểu là int32, int64, hay bool. Bên giải mã phải có schema (.proto) mới hiểu đúng. Đây là khác biệt cốt lõi với JSON (tự mô tả) và là chủ đề của bài schema evolution sau.

varint tiết kiệm cho số nhỏ, nhưng phản tác dụng với số âm. Số âm ở dạng bù hai có bit cao bằng 1, nên một int32 giá trị -1 thành varint tốn tận 10 byte. Protobuf giải quyết bằng kiểu sint32/sint64 dùng mã hóa ZigZag — chủ đề bài phần 8. Nếu field có thể âm, chọn sai kiểu là phình byte.

Tự ghép wire format bằng protowire chỉ để học, không để sản xuất. Ở đây mình dùng protowire để thấy từng byte. Trong thực tế, bạn viết file .proto, chạy protoc sinh struct Go, rồi dùng proto.Marshal — an toàn kiểu, đúng schema, ít lỗi. protowire là API bậc thấp cho công cụ và trường hợp đặc biệt, không phải cách mã hóa hằng ngày.

Ba ý mang về

  1. Wire format = chuỗi (tag, payload); tag ghép field number với wire type: đo thật 08 là 1<<3|0 (field 1, varint), 12 là 2<<3|2 (field 2, length-delimited) — chỉ 3 bit cho kiểu, phần còn lại cho số thứ tự field.
  2. Varint nén số bằng nhóm 7 bit + cờ nối, số nhỏ tốn ít byte: đo thật 90210 thành e2 c0 05 (3 byte), bool true chỉ 2 byte cả tag — đây là nguồn tiết kiệm chính cùng với việc không lưu tên field.
  3. Cùng dữ liệu, Protobuf 20 byte vs JSON 58 byte (~2,9x) — nhưng đổi lại không đọc được bằng mắt và bắt buộc có schema để giải mã; varint phình với số âm (dùng ZigZag/sint).

Nguồn

Phần sau ta đặt Protobuf và JSON lên bàn cân đầy đủ: cùng một struct thật, đo cả kích thước byte lẫn tốc độ mã hóa/giải mã (ns/op, allocs/op) — xem con số 2,9x kích thước có đi kèm lợi thế tốc độ tương xứng không.