Protobuf gọn và nhanh, nhưng đòi một file .proto và bước sinh code. Nếu cả hai đầu giao tiếp đều là Go — hai service Go gọi nhau, một cache Go, một file checkpoint của chương trình Go — thì có một lựa chọn gọn hơn về công sức: encoding/gob, định dạng nhị phân của riêng Go trong thư viện chuẩn. Nó tự mô tả kiểu: không cần schema ngoài, không cần sinh code, chỉ cần gob.NewEncoder(w).Encode(v). Nhưng gob có một đặc tính khiến người mới dễ đo sai và kết luận nhầm. Bài này (phần 5 loạt Serialization) chạy thật để lộ đặc tính đó.

Cơ chế: tự mô tả kiểu, gửi một lần

gob hoạt động trên một luồng qua Encoder/Decoder:

var buf bytes.Buffer
enc := gob.NewEncoder(&buf)
enc.Encode(order)    // lần đầu: gửi kèm MÔ TẢ KIỂU + dữ liệu
enc.Encode(order2)   // lần sau: CHỈ dữ liệu (kiểu đã biết)

var o Order
gob.NewDecoder(r).Decode(&o)   // đọc mô tả kiểu rồi dựng lại struct

Khác biệt cốt lõi với Protobuf: lần đầu Encode một kiểu, gob gói kèm một bản mô tả — tên struct, tên và kiểu của từng field. Bên Decode đọc bản mô tả này nên không cần schema chia sẻ trước. Đây là sự tiện lợi lớn: thêm một field vào struct, cả hai đầu cùng dùng struct đó là xong, không phải đồng bộ file .proto. Cái giá: chỉ Go hiểu gob — không dùng liên ngôn ngữ được. Và quan trọng cho phần đo: bản mô tả kiểu chỉ gửi một lần cho mỗi kiểu trên một Encoder, nên chi phí của nó khấu hao theo số giá trị bạn gửi.

Ảnh chụp đoạn mã Go nền tối minh hoạ encoding gob, dùng NewEncoder NewDecoder trên một luồng io var buf bytes Buffer enc bằng gob NewEncoder enc Encode order lần đầu gửi kèm mô tả kiểu cộng dữ liệu enc Encode order2 lần sau chỉ dữ liệu kiểu đã biết var o Order gob NewDecoder r Decode o đọc mô tả kiểu rồi dựng lại, cơ chế tự mô tả kiểu khác Protobuf cần proto ngoài lần Encode đầu tiên cho một kiểu gob gửi kèm bản mô tả tên struct tên cộng kiểu từng field overhead ban đầu bên Decode đọc mô tả này không cần schema chia sẻ trước nhưng chỉ Go hiểu gob không dùng liên ngôn ngữ mô tả kiểu chỉ gửi 1 lần cho mỗi kiểu trên một Encoder gửi 1 giá trị overhead lớn không đáng gửi N giá trị cùng kiểu overhead khấu hao rất gọn, điểm mấu chốt khi benchmark Encoder mới mỗi lần lặp lại mô tả kiểu cộng dựng máy mã hóa đắt Encoder tái dùng mô tả kiểu 1 lần các lần sau chỉ dữ liệu rẻ gob sinh ra cho luồng bền RPC lưu nhiều bản ghi Go-to-Go

Hình 1: gob dùng Encoder/Decoder trên luồng io; lần đầu mã hóa một kiểu, gob gửi kèm mô tả kiểu (tên struct + field) nên không cần schema ngoài — nhưng mô tả chỉ gửi một lần, nên overhead khấu hao theo số giá trị; chỉ Go-to-Go.

Đo thật: cái bẫy một-giá-trị và phần thưởng khi stream

Mình mã hóa đơn hàng bằng gob và JSON, đo kích thước theo số giá trị và benchmark tốc độ:

Ảnh chụp bảng kết quả chạy thật gob output thật, một kích thước overhead mô tả kiểu khấu hao theo số giá trị 1 giá trị JSON 398 byte gob 443 byte gob lớn hơn cộng 45 N bằng 10 gob mỗi giá trị 237.8 byte JSON mỗi giá trị 398 byte N bằng 100 gob 217.3 JSON 398 N bằng 1000 gob 215.2 JSON 398 điểm hòa vốn rất sớm từ khoảng 2 giá trị gob đã bắt đầu có lợi, hai benchmark mã hóa Encoder mới vs tái dùng JSONMarshal 484.1 ns op 528 B op 2 allocs op GobEncodeFresh 2955 ns op 3440 B op 52 allocs op mới mỗi lần đắt GobEncodeReuse 333 ns op 112 B op 1 alloc op tái dùng rẻ gob tái dùng nhanh hơn JSON và chỉ 1 alloc gob mới thì tệ, ba benchmark giải mã Decoder mới mỗi lần trường hợp một-lần JSONUnmarshal 2679 ns op 936 B op 21 allocs op GobDecodeFresh 10195 ns op 10517 B op 279 allocs op giải mã gob một-lần rất đắt dựng lại máy giải mã cộng đọc mô tả kiểu trong luồng bền mô tả kiểu chỉ đọc 1 lần chi phí khấu hao, kết luận một giá trị lẻ một-lần gob thua JSON cả kích thước lẫn tốc độ luồng nhiều giá trị cùng kiểu Encoder bền gob gọn nửa rẻ chỉ Go-to-Go round-trip khớp hợp cache RPC nội bộ checkpoint

Hình 2: Chạy thật — 1 giá trị gob 443 byte vs JSON 398 (gob lớn hơn), nhưng N=1000 thì gob 215,2 byte/giá trị vs JSON 398; encode Encoder mới 2.955 ns/52 allocs (đắt) vs tái dùng 333 ns/1 alloc (rẻ hơn JSON 484 ns/2 allocs); decode Decoder mới 10.195 ns/279 allocs.

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

  • Cái bẫy một-giá-trị: gửi đúng một đơn hàng, gob tốn 443 byte trong khi JSON chỉ 398 byte — gob lớn hơn 45 byte! Đây là điều khiến nhiều người thử gob một lần, thấy nó to hơn JSON, rồi kết luận sai là "gob tệ". 45 byte đó chính là bản mô tả kiểu đi kèm.
  • Phần thưởng khi stream: gửi nhiều giá trị cùng kiểu qua một Encoder, mô tả kiểu chỉ tính một lần. Ở N=10, gob đã còn 237,8 byte/giá trị; N=1000 xuống 215,2 byte/giá trị — chỉ ~54% của JSON (398 byte). Điểm hòa vốn đến rất sớm: từ khoảng 2 giá trị gob đã bắt đầu có lợi.
  • Encoder tái dùng cực rẻ: benchmark cho thấy Encoder mới mỗi lần tốn 2.955 ns và 52 allocs (phải dựng lại máy mã hóa + gửi lại mô tả kiểu) — tệ hơn JSON nhiều. Nhưng Encoder tái dùng chỉ 333 ns và 1 alloc — nhanh hơn JSON (484 ns/2 allocs) và ít cấp phát hơn hẳn. Cùng một thư viện, hai cách dùng, kết quả trái ngược.
  • Giải mã một-lần rất đắt: Decoder mới mỗi lần tốn 10.195 ns và 279 allocs — rất nặng vì phải dựng lại máy giải mã và đọc mô tả kiểu. Trong một luồng bền (đọc nhiều bản ghi từ một Decoder), chi phí này cũng chỉ trả một lần rồi khấu hao. Round-trip kiểm chứng: giải mã ra đúng id, khách hàng, số mặt hàng.

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

gob được thiết kế cho luồng bền, không cho thông điệp lẻ. Toàn bộ đặc tính trên xuất phát từ một lựa chọn thiết kế: gob tối ưu cho việc gửi nhiều giá trị qua một kết nối (đúng như net/rpc của Go dùng nó). Dùng gob cho một request-response HTTP lẻ (tạo Encoder mới mỗi request) là dùng sai công cụ — bạn trả toàn bộ overhead mà không hưởng khấu hao. Với thông điệp lẻ liên ngôn ngữ, JSON hoặc Protobuf đúng hơn.

Tự mô tả kiểu đổi lấy khóa chặt vào Go. Sự tiện của gob — không schema, thêm field là chạy — đến từ việc nó nhúng kiểu Go vào luồng. Điều này khiến gob vô dụng khi đầu kia không phải Go (một service Python, một client JavaScript). Nếu có bất kỳ khả năng nào hệ thống sẽ có thành phần không-Go, đừng chọn gob làm định dạng trao đổi.

Tương thích schema của gob lỏng nhưng có quy tắc. gob cho phép hai đầu có struct hơi khác nhau: field thừa ở bên gửi bị bỏ qua, field thiếu ở bên nhận giữ giá trị zero — khá linh hoạt cho việc nâng cấp dần. Nhưng đổi kiểu của một field (int thành string) sẽ lỗi khi giải mã. Nó không có cơ chế field number tường minh như Protobuf, nên tương thích dựa vào tên field — đổi tên field là mất dữ liệu đó.

Ba ý mang về

  1. gob tự mô tả kiểu — tiện (không schema ngoài) nhưng chỉ Go-to-Go: lần đầu mã hóa một kiểu, gob gói kèm mô tả cấu trúc nên bên nhận không cần .proto; đổi lại chỉ Go hiểu được, không dùng liên ngôn ngữ.
  2. Một giá trị lẻ: gob thua JSON: đo thật 443 byte vs 398 byte (kích thước) và encode mới 2.955 ns/52 allocs vs JSON 484 ns/2 allocs — overhead mô tả kiểu khiến gob tệ khi chỉ gửi một giá trị một-lần.
  3. Stream nhiều giá trị: gob thắng rõ: đo thật N=1000 chỉ 215 byte/giá trị (~nửa JSON) và Encoder tái dùng 333 ns/1 alloc (nhanh hơn JSON) — vì mô tả kiểu khấu hao; gob hợp cho RPC nội bộ, cache, checkpoint giữa các chương trình Go.

Nguồn

Phần sau ta sang MessagePack — một định dạng nhị phân liên ngôn ngữ, coi như "JSON nhị phân": cùng mô hình dữ liệu với JSON (map, array, string, số) nhưng mã hóa gọn hơn nhiều; đo xem nó tiết kiệm bao nhiêu byte so với JSON mà vẫn giữ được tính linh hoạt.