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.

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 độ:

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
Encodermớ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ưngEncodertá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:
Decodermớ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ộtDecoder), 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ề
- 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ữ. - 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.
- 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
- Go docs — encoding/gob: https://pkg.go.dev/encoding/gob
- Go blog — Gobs of data (thiết kế gob): https://go.dev/blog/gob
- Go docs — net/rpc (dùng gob làm codec mặc định): https://pkg.go.dev/net/rpc
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.