Ở phần 1 ta thấy cùng một message, Protobuf chỉ chiếm nửa kích thước JSON. Nhưng "nhỏ hơn" không phải phép màu — nó đến từ cách Protobuf mã hoá dữ liệu ở mức byte, và hiểu cơ chế này giúp bạn thiết kế schema tốt hơn (đánh số field thông minh, chọn kiểu phù hợp) và debug được khi cần. Thay vì lưu tên trường bằng text như JSON, Protobuf lưu một field number nhỏ, mã hoá số bằng varint, và bỏ qua hoàn toàn các field mang giá trị mặc định. Bài này (phần 2/12) dùng proto.Marshal rồi hexdump từng byte trong go-lab để thấy chính xác chuyện gì xảy ra trên dây.

Cấu trúc wire: tag + value, lặp lại

Mỗi field trong một message Protobuf được mã hoá thành một tag (một byte, với field nhỏ) rồi tới value. Tag gộp hai thông tin:

tag = (field_number << 3) | wire_type

Ba bit thấp là wire_type (cách đọc value), phần còn lại là field_number. Có vài wire type chính:

wire_type Dùng cho
0 = varint int32/int64/bool/enum (số nguyên)
1 = 64-bit double, fixed64
2 = length-delimited string, bytes, message con
5 = 32-bit float, fixed32

Điểm then chốt: trên dây không có tên trường ("customer", "amount"...). Chỉ có số field. Đây là nguồn tiết kiệm lớn nhất so với JSON — và cũng là lý do bài grpc-04 sẽ nhấn mạnh: field number là bất biến, đổi nó là phá vỡ dữ liệu cũ.

Ảnh chụp đoạn mã nền tối minh hoạ Protobuf wire format mỗi field là một cặp tag cộng value không có tên trường, JSON lưu tên trường bằng text Protobuf chỉ lưu field number cộng kiểu wire mã hoá số bằng varint nhỏ hơn nhiều. Tag bằng field_number dịch trái 3 bit hoặc wire_type mỗi field mã hoá 1 byte tag gộp số field cộng kiểu wire rồi tới value field 1 varint bằng 1 dịch 3 hoặc 0 bằng 0x08 field 2 length-delimited bằng 2 dịch 3 hoặc 2 bằng 0x12. Bảng wire_type 0 varint dùng cho int32 int64 bool enum số nguyên, 1 64-bit double fixed64, 2 length-delimited string bytes message con, 5 32-bit float fixed32. Varint số nhỏ tốn ít byte mỗi byte varint dùng 7 bit cho dữ liệu 1 bit báo còn byte nữa 1 1 byte 300 2 byte ac 02 1.048.576 4 byte số nhỏ id đếm cờ gần như luôn 1 byte rất tiết kiệm. Field default không được mã hoá proto3 field bằng 0 chuỗi rỗng false default bỏ qua hoàn toàn trên dây 0 byte Demo id 1 name rỗng big 0 active false chỉ 2 byte cho id message rỗng toàn default 0 byte JSON vẫn phải ghi mọi khoá

Hình 1: Tag = (field_number << 3) | wire_type gộp số field và kiểu wire vào một byte. Varint mã hoá số nguyên bằng 7 bit mỗi byte nên số nhỏ chỉ 1 byte. Field mang giá trị mặc định không được ghi lên dây.

Varint: số nhỏ tốn ít byte

Protobuf mã hoá số nguyên bằng varint (variable-length integer): mỗi byte dùng 7 bit cho dữ liệu, 1 bit cao báo "còn byte nữa". Hệ quả: số nhỏ tốn ít byte. Số 1 → 1 byte, 300 → 2 byte, hơn một triệu → nhiều byte hơn. Vì phần lớn số trong dữ liệu thật là nhỏ (id, số lượng, cờ, enum), varint tiết kiệm đáng kể so với kiểu cố định 4/8 byte.

Đo thật: hexdump từng byte

Mình marshal một message Demo{id:1, name:"cafe", big:300, active:true} rồi in hex:

m := &pb.Demo{Id: 1, Name: "cafe", Big: 300, Active: true}
b, _ := proto.Marshal(m)   // in ra hex từng byte

Ảnh chụp bảng kết quả đo thật hexdump wire format Protobuf output thật go-lab protobuf v1.34.2 proto.Marshal. Một Demo id 1 name cafe big 300 active true bằng 13 byte, các byte 08 01 12 04 63 61 66 65 18 ac 02 20 01, giải mã 08 bằng tag f1 varint 01 bằng giá trị 1 12 bằng tag f2 len 04 bằng dài 4 63 61 66 65 bằng cafe 18 bằng tag f3 varint ac 02 bằng varint 300 20 bằng tag f4 varint 01 bằng true. Hai so sánh kích thước theo giá trị chỉ id 1 ba field còn lại default 2 byte 08 01, big 5 số nhỏ varint 1 byte 2 byte 18 05, big 1.048.576 số lớn varint 3 byte 4 byte 18 80 80 40, Demo rỗng mọi field default 0 byte, Demo đầy đủ Protobuf 13 byte, Demo đầy đủ JSON 46 byte 3,5 lần to hơn. default không mã hoá nên message thưa cực nhỏ rỗng bằng 0 byte varint khiến số nhỏ chỉ 1 byte JSON phải ghi mọi tên khoá cộng dấu nháy nên 46 byte cho cùng dữ liệu Protobuf gói trong 13 byte

Hình 2: Đo thật. Demo{id:1,name:"cafe",big:300,active:true} = 13 byte, giải mã được từng tag. Chỉ id=1 → 2 byte; big=5 → 2 byte; big=1.048.576 → 4 byte; Demo rỗng → 0 byte; Protobuf đầy đủ 13 byte so với JSON 46 byte.

Đọc kết quả thật byte-by-byte cho message đầy đủ (08 01 12 04 63 61 66 65 18 ac 02 20 01):

  • 08 = tag field 1 (1<<3|0 = varint), 01 = giá trị 1.
  • 12 = tag field 2 (2<<3|2 = length-delimited), 04 = độ dài 4, 63 61 66 65 = "cafe" (ASCII).
  • 18 = tag field 3 (varint), ac 02 = varint của 300 (2 byte).
  • 20 = tag field 4 (varint), 01 = true.

Mọi byte đều giải thích được — không có byte thừa. Và những phép đo cạnh đó phơi bày ba đặc tính tiết kiệm:

  • Field default = 0 byte: Demo{Id:1} (name, big, active đều mặc định) chỉ tốn 2 byte — ba field mặc định không được mã hoá. Message rỗng hoàn toàn tốn 0 byte. JSON thì luôn phải ghi mọi khoá ({"id":0,"name":"",...}).
  • Varint theo độ lớn: big=5 tốn 2 byte (18 05), nhưng big=1.048.576 tốn 4 byte (18 80 80 40). Số càng lớn càng nhiều byte — nên dùng đúng kiểu cho đúng dải giá trị.
  • Tổng kết: cùng dữ liệu, Protobuf 13 byte vs JSON 46 byte (~3,5× nhỏ hơn). Chênh lệch này còn lớn hơn tỉ lệ 2,1× ở bài 1 vì message này có nhiều field số/bool nhỏ — đúng chỗ Protobuf thắng đậm nhất.

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

Field number nhỏ (1–15) rẻ hơn số lớn. Tag của field 1–15 chỉ tốn 1 byte; từ field 16 trở lên tag tốn 2 byte (vì field_number vượt 4 bit còn lại sau khi dành 3 bit cho wire_type trong byte đầu). Nên dành field number 1–15 cho các trường xuất hiện thường xuyên nhất. Đây là một tối ưu nhỏ nhưng miễn phí khi thiết kế schema cho message tần suất cao.

Default = 0 byte là con dao hai lưỡi. Vì proto3 không mã hoá giá trị mặc định, bạn không phân biệt được "field được đặt bằng 0" với "field không được đặt". Với int thường không sao, nhưng khi cần phân biệt "0" với "chưa có" (ví dụ một cờ tuỳ chọn), phải dùng optional (proto3 optional) hoặc wrapper type — nếu không, false/0/"" lẫn với "vắng mặt".

Nhỏ gọn đổi bằng khó đọc. Hexdump chỉ giải mã được nếu có schema — 18 ac 02 tự nó vô nghĩa nếu không biết field 3 là gì. JSON thì đọc được ngay bằng mắt. Đây là đánh đổi cốt lõi: Protobuf hy sinh khả năng tự mô tả để lấy kích thước và tốc độ. Dùng protoc --decode hoặc công cụ có schema để debug.

Ba ý mang về

  1. Wire format là chuỗi (tag, value); tag = field_number << 3 | wire_type. Trên dây không có tên trường — chỉ field number — nên nhỏ hơn JSON nhiều. Đo thật: message Demo đầy đủ giải mã được từng byte (08 01 12 04 ...), không byte nào thừa.
  2. Varint và bỏ-qua-default là hai nguồn tiết kiệm lớn. Đo thật: số nhỏ 1–2 byte (big=5 → 2 byte), field mặc định 0 byte, message rỗng 0 byte. JSON luôn phải ghi mọi khoá nên cùng dữ liệu tốn 46 byte vs Protobuf 13 byte (3,5×).
  3. Thiết kế schema theo wire format. Dành field number 1–15 cho trường tần suất cao (tag 1 byte), dùng optional khi cần phân biệt 0 với "chưa đặt", và nhớ Protobuf hy sinh khả năng đọc bằng mắt để lấy kích thước/tốc độ — cần schema mới debug được.

Nguồn

Phần sau ta khám phá sức mạnh mà REST không có sẵn: bốn kiểu RPC của gRPC — unary, server streaming, client streaming và bidirectional streaming — demo cả bốn bằng Go và biết khi nào dùng kiểu nào.