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.

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)

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:e2vàc0có MSB=1 (còn tiếp),05có 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ộtbool truechỉ 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ề
- Wire format = chuỗi (tag, payload); tag ghép field number với wire type: đo thật
08là1<<3|0(field 1, varint),12là2<<3|2(field 2, length-delimited) — chỉ 3 bit cho kiểu, phần còn lại cho số thứ tự field. - 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 truechỉ 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. - 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
- Protocol Buffers — Encoding (đặc tả wire format): https://protobuf.dev/programming-guides/encoding/
- Go docs — google.golang.org/protobuf/encoding/protowire: https://pkg.go.dev/google.golang.org/protobuf/encoding/protowire
- Protocol Buffers — Overview: https://protobuf.dev/overview/
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.