Phần 3 mổ từng byte cho thấy vì sao Protobuf gọn. Nhưng "gọn hơn" mới là một nửa câu chuyện — câu hỏi thực tế của kỹ sư là: đổi lấy tốc độ bao nhiêu, và có đáng bỏ tính dễ đọc của JSON không? Bài này (phần 4 loạt Serialization) đặt hai định dạng lên bàn cân đầy đủ: cùng một struct đơn hàng thật (có struct lồng và slice mặt hàng), đo cả kích thước byte lẫn tốc độ mã hóa/giải mã (ns/op, allocs/op) trong go-lab.

Thiết lập so sánh công bằng

Để công bằng, cả hai mã hóa đúng cùng một dữ liệu: một Order có id, ma, một Customer lồng bên trong, một slice 3 LineItem, tổng tiền và trạng thái.

type Order struct {
    ID        int64;  Ma string
    KhachHang Customer      // struct lồng: id, name, email
    Items     []LineItem    // slice: sku, ten, so_luong, don_gia
    TongTien  int64;  TrangThai string
}

JSON dùng json.Marshal quen thuộc. Phía Protobuf, để chạy được ngay trong container mà không cần cài protoc, mình tự mã hóa bằng protowire — ghép tag, varint, và bytes cho từng field, kể cả message lồng và repeated field:

func pbMarshalOrder(o Order) []byte {
    var b []byte
    b = protowire.AppendTag(b, 1, protowire.VarintType); b = protowire.AppendVarint(b, uint64(o.ID))
    b = protowire.AppendTag(b, 2, protowire.BytesType);  b = protowire.AppendString(b, o.Ma)
    b = protowire.AppendTag(b, 3, protowire.BytesType);  b = protowire.AppendBytes(b, pbCustomer(o.KhachHang))
    for _, it := range o.Items {   // repeated field 4
        b = protowire.AppendTag(b, 4, protowire.BytesType); b = protowire.AppendBytes(b, pbItem(it))
    }
    // ... tong_tien, trang_thai
    return b
}

Ảnh chụp đoạn mã Go nền tối minh hoạ so sánh Protobuf vs JSON, cùng một dữ liệu hai cách mã hóa type Order struct ID Ma KhachHang Customer struct lồng id name email Items slice sku ten so_luong don_gia TongTien TrangThai JSON json Marshal order reflection tự mô tả Protobuf pbMarshalOrder order tự ghép tag varint bytes, Protobuf marshal thủ công protowire func pbMarshalOrder AppendTag 1 VarintType AppendVarint o.ID AppendTag 2 BytesType AppendString o.Ma AppendTag 3 BytesType AppendBytes pbCustomer for range o.Items repeated field 4 AppendTag 4 BytesType AppendBytes pbItem giải mã ConsumeTag cộng Consume theo field number wire type, đo gì 1 kích thước byte 1 record và 100 record 2 benchmark marshal cộng unmarshal ns op B op allocs op công bằng cùng struct cùng dữ liệu cùng máy go-lab

Hình 1: Cùng một Order (nested + slice) mã hóa hai cách; phía Protobuf tự ghép tag/varint/bytes bằng protowire (kể cả message lồng và repeated field); đo kích thước và benchmark marshal/unmarshal công bằng.

Đo thật: kích thước và tốc độ

Ảnh chụp bảng kết quả chạy thật Protobuf vs JSON output thật, một kích thước byte cùng dữ liệu 1 record JSON 407 byte Protobuf 205 byte nhỏ hơn 1.99x 100 record JSON 40700 byte Protobuf 20500 byte nhỏ hơn 1.99x round-trip Protobuf id ma khách items tổng đều khớp, hai benchmark marshal mã hóa JSONMarshal 475.5 ns op 528 B op 2 allocs op ProtoMarshal 363.3 ns op 816 B op 18 allocs op Protobuf nhanh hơn về thời gian nhưng nhiều alloc hơn bản thủ công này cấp byte cho mỗi message lồng code sinh bằng protoc tối ưu hơn nhiều tái dùng buffer, ba benchmark unmarshal giải mã chênh lệch lớn JSONUnmarshal 2743 ns op 936 B op 21 allocs op ProtoUnmarshal 283.5 ns op 528 B op 13 allocs op Protobuf giải mã nhanh gấp 9.7 lần ít alloc hơn không parse text không reflection đọc thẳng byte, kết luận kích thước Protobuf 2x nhỏ hơn mã hóa Protobuf nhanh hơn chút giải mã Protobuf thắng đậm 9.7x lợi thế lớn nhất đổi lại cần schema proto sinh code không đọc bằng mắt

Hình 2: Chạy thật — kích thước JSON 407 vs Protobuf 205 byte (~1,99x, ổn định ở 100 record); marshal JSON 475,5 ns/2 allocs vs Protobuf 363,3 ns/18 allocs; unmarshal JSON 2.743 ns/21 allocs vs Protobuf 283,5 ns/13 allocs (~9,7x nhanh hơn).

Đọc kết quả đo được:

  • Kích thước: Protobuf nhỏ hơn ~2 lần, ổn định: 1 record là 407 vs 205 byte (1,99x), 100 record là 40.700 vs 20.500 byte (vẫn 1,99x). Tỉ lệ giữ nguyên vì tiết kiệm đến từ mỗi bản ghi (không lặp tên field, số nén varint), không phải chi phí cố định một lần. Round-trip Protobuf kiểm chứng: giải mã lại ra đúng id, mã, khách hàng, số mặt hàng, tổng tiền.
  • Mã hóa: Protobuf nhanh hơn về thời gian, nhưng đây là chỗ cần trung thực: ProtoMarshal 363 ns nhanh hơn JSONMarshal 475 ns — nhưng lại tốn 18 allocs so với 2 của JSON. Lý do: bản mã hóa thủ công của mình cấp một []byte mới cho mỗi message lồng (pbCustomer, pbItem) rồi mới ghép vào. Code sinh bằng protoc (proto.Marshal thật) tối ưu hơn nhiều — nó tính trước kích thước và ghi thẳng vào một buffer, giảm allocs mạnh. Nói cách khác: đừng lấy con số 18 allocs này làm đại diện cho Protobuf nói chung; nó là hạn chế của cách viết tay để minh họa.
  • Giải mã: Protobuf thắng đậm — lợi thế lớn nhất: ProtoUnmarshal 283 ns so với JSONUnmarshal 2.743 ns — nhanh gấp ~9,7 lần, và ít alloc hơn (13 vs 21). Đây là điểm quyết định. Như đã thấy ở phần 1, giải mã JSON đắt vì phải parse text (nhận diện dấu ngoặc, chuỗi, số dạng text) và dùng reflection để dựng struct. Protobuf đọc thẳng byte theo tag, không parse text, nên nhanh hơn một bậc.

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

Lợi thế thật nằm ở giải mã và kích thước, không phải mã hóa. Nếu hệ của bạn đọc nhiều hơn ghi (đa số hệ phân tán: một dịch vụ tạo dữ liệu, nhiều dịch vụ đọc), thì lợi thế ~9,7x ở giải mã và ~2x băng thông của Protobuf là rất lớn. Ngược lại, nếu chỉ trao đổi vài bản ghi nhỏ thi thoảng, chênh lệch microsecond này không đáng để đánh đổi.

Cái giá của Protobuf là quy trình, không phải runtime. JSON chạy ngay với struct Go và tag; Protobuf đòi bạn viết file .proto, chạy protoc sinh code, và quản lý schema đó qua thời gian. Đây là chi phí phát triển và vận hành thật — thêm một bước build, thêm một thứ phải đồng bộ giữa các dịch vụ. Với một API công khai cần ai cũng gọi được bằng curl, JSON vẫn thắng về khả năng tiếp cận.

Số benchmark phụ thuộc dữ liệu và cách cài. Kết quả ở đây với một đơn hàng có 3 mặt hàng; struct nhiều số nguyên nhỏ sẽ nghiêng về Protobuf hơn, còn struct nhiều chuỗi dài (mà cả hai đều phải chép) thì khoảng cách kích thước hẹp lại. Và như đã nói, allocs khi mã hóa phụ thuộc chất lượng bộ mã hóa. Luôn đo trên dữ liệu và thư viện thật của bạn trước khi kết luận.

Ba ý mang về

  1. Protobuf nhỏ hơn ~2 lần và ổn định theo số bản ghi: đo thật 407 vs 205 byte (1 record) và 40.700 vs 20.500 byte (100 record) — tiết kiệm đến từ mỗi bản ghi (không lặp tên field, varint), nên tỉ lệ không đổi khi dữ liệu lớn lên.
  2. Giải mã là lợi thế lớn nhất của Protobuf: đo thật unmarshal 283 ns vs 2.743 ns (~9,7x nhanh hơn) và ít alloc hơn — vì không parse text, không reflection; hợp cho hệ đọc nhiều hơn ghi.
  3. Mã hóa và chi phí thật cần nhìn trung thực: bản Protobuf thủ công nhanh hơn về thời gian nhưng tốn nhiều alloc hơn (code sinh bằng protoc tối ưu hơn); và cái giá thật của Protobuf là schema .proto + sinh code + không đọc được bằng mắt — JSON vẫn thắng về sự tiện lợi và khả năng tiếp cận.

Nguồn

Phần sau ta gặp encoding/gob — định dạng nhị phân riêng của Go: tự mô tả kiểu, không cần schema ngoài, rất tiện khi cả hai đầu đều là Go — đo xem nó đứng đâu giữa JSON và Protobuf về kích thước và tốc độ.