Mở đầu loạt bài về serialization — biến cấu trúc dữ liệu trong bộ nhớ thành chuỗi byte để gửi đi/lưu lại rồi dựng lại — ta bắt đầu từ định dạng quen thuộc nhất: JSON. Trong Go, gói encoding/json tiện đến mức gần như vô hình: định nghĩa một struct, gắn vài tag, gọi json.Marshal là xong. Chính vì tiện, ta dễ quên rằng nó không miễn phí. Dưới lớp API gọn gàng đó là reflection — cơ chế cho phép một hàm duy nhất xử lý mọi kiểu dữ liệu lúc chạy, và đó là nơi chi phí ẩn nằm. Bài này (phần 1 loạt Serialization) chạy thật trong container Go để đo xem JSON tốn bao nhiêu byte và reflection tốn bao nhiêu thời gian, bộ nhớ.
Cơ chế: struct tag và reflection
JSON trong Go xoay quanh struct tag — chuỗi trong dấu backtick sau mỗi field, điều khiển cách field đó xuất hiện trong JSON:
type LineItem struct {
SKU string `json:"sku"`
Ten string `json:"ten"`
SoLuong int `json:"so_luong"`
DonGia float64 `json:"don_gia"`
GiamGia float64 `json:"giam_gia,omitempty"` // =0 thì bỏ khỏi JSON
}
type Order struct {
ID int64 `json:"id"`
KhachHang Customer `json:"khach_hang"` // nested struct
Items []LineItem `json:"items"` // slice
GhiChu string `json:"ghi_chu,omitempty"`
}
Điều đáng nói là một cặp hàm duy nhất xử lý mọi kiểu: json.Marshal(order) và json.Unmarshal(b, &out). Làm sao một hàm không biết trước kiểu Order lại đọc được từng field? Câu trả lời là reflection (gói reflect): lúc chạy, json duyệt kiểu của giá trị bạn đưa vào, đọc từng field và từng tag, xác định kiểu, rồi đọc hoặc ghi giá trị tương ứng. Nó mạnh và tổng quát — nhưng phải "khám phá" cấu trúc lúc chạy (có cache lại, nhưng vẫn tốn), và khi giải mã còn phải cấp phát bộ nhớ cho slice, string, struct con.

Hình 1: Struct tag điều khiển tên field và omitempty; json.Marshal/Unmarshal dùng chung cho mọi kiểu nhờ reflection — gói reflect duyệt cấu trúc lúc chạy; so với dựng JSON thủ công bằng strings.Builder (nhanh hơn nhưng phải viết tay, dễ sai).
Đo thật: kích thước và chi phí reflection
Mình định nghĩa một Order thực tế (có struct con Customer, slice 5 LineItem, nhiều kiểu field) rồi đo trong go-lab:
b, _ := json.Marshal(o)
fmt.Printf("KICH_THUOC_JSON=%d byte\n", len(b)) // kích thước gọn
Benchmark bằng go test -bench=. -benchmem — Go tự chạy mỗi hàm hàng triệu lần và báo ns/op (thời gian), B/op (byte cấp phát), allocs/op (số lần cấp phát) mỗi thao tác:

Hình 2: Chạy thật — JSON gọn 643 byte, thụt lề 928 byte (+44%), không omitempty 656 byte; benchmark Marshal 1.036 ns/op (928 B, 3 allocs), Unmarshal 4.184 ns/op (1600 B, 27 allocs), dựng tay 5 field 139 ns/op.
Đọc kết quả đo được:
- Kích thước: cùng một đơn hàng, JSON gọn là 643 byte; bản
MarshalIndent(thụt lề đẹp cho người đọc) là 928 byte — tốn thêm 44% chỉ cho khoảng trắng. Bài học: đừng bao giờ gửi JSON thụt lề cho máy đọc; chỉ dùng khi cần con người xem. - Unmarshal đắt hơn Marshal nhiều: giải mã tốn 4.184 ns/op với 27 lần cấp phát, so với Marshal chỉ 1.036 ns/op và 3 lần cấp phát — chậm gấp ~4 lần và cấp phát gấp ~9 lần. Lý do: khi mã hóa, dữ liệu đã có sẵn trong struct, chỉ việc "đọc ra"; khi giải mã, Go phải dựng lại mọi thứ — cấp phát slice
items, từng chuỗi, struct con. Đây là lý do trong hệ tải cao, giải mã JSON thường là điểm nóng, không phải mã hóa. - Reflection có giá thật: dựng JSON bằng tay (chỉ 5 field scalar) chạy 139 ns/op — nhanh hơn Marshal đầy đủ nhiều lần. Nhưng đây không phải so sánh công bằng (nó làm ít việc hơn: chỉ 5 field, không nested, không slice). Điểm rút ra không phải "hãy viết JSON bằng tay" — mà là reflection có chi phí đo được, và khi nó thành nút cổ chai thì có cách né (bài phần 2).
Đánh đổi cần cân nhắc
omitempty tiết kiệm byte nhưng đổi nghĩa ngữ nghĩa. Ở đo thật, omitempty bỏ field ghi_chu rỗng, tiết kiệm 13 byte — nhỏ ở đây, nhưng với struct nhiều field tùy chọn thường rỗng thì tiết kiệm rất đáng kể. Cái bẫy: omitempty không phân biệt được "giá trị bằng 0/rỗng" với "không có giá trị". Một GiamGia: 0 bị bỏ khỏi JSON; phía nhận không biết là "giảm giá 0đ" hay "thiếu thông tin". Với field mà số 0 có ý nghĩa, đừng dùng omitempty (hoặc dùng con trỏ *float64).
Tiện lợi của reflection đáng giá trong đa số trường hợp. Đừng vội tối ưu. encoding/json đổi một chút tốc độ lấy sự tổng quát, an toàn kiểu, và ít lỗi: một struct mới chỉ cần thêm tag là chạy, không phải viết và bảo trì hàm mã hóa tay dễ sai. Với phần lớn API và dịch vụ, 1–4 microsecond mỗi lần là không đáng kể so với thời gian mạng và truy vấn CSDL. Chỉ tối ưu khi đo được nó là nút cổ chai.
Thời gian và bộ nhớ từng lần nhỏ, nhưng nhân theo lưu lượng. 4 microsecond cho một lần Unmarshal là bé; nhưng một service xử lý 100.000 request/giây, mỗi request giải mã vài JSON, thì chi phí và áp lực lên bộ thu gom rác (GC) từ 27 allocs/lần cộng dồn thành đáng kể. Đây chính là lý do các phần sau khám phá streaming, tránh cấp phát, và các định dạng nhị phân.
Ba ý mang về
- JSON trong Go tiện nhờ reflection, và reflection có chi phí đo được: một cặp
Marshal/Unmarshalxử lý mọi struct nhờ góireflectduyệt cấu trúc lúc chạy — đo thật Marshal 1.036 ns/op với 3 allocs, đủ nhanh cho đa số nhưng không miễn phí. - Giải mã đắt hơn mã hóa nhiều: đo thật Unmarshal 4.184 ns/op và 27 allocs/op — chậm ~4 lần và cấp phát ~9 lần so với Marshal, vì phải dựng lại slice/string/struct con; trong hệ tải cao, giải mã thường là điểm nóng.
- Kích thước phụ thuộc lựa chọn của bạn: đo thật JSON gọn 643 byte nhưng thụt lề 928 byte (+44% — chỉ cho người đọc);
omitemptybỏ field rỗng để tiết kiệm nhưng đánh mất phân biệt "0" với "thiếu" — cân nhắc con trỏ khi số 0 có nghĩa.
Nguồn
- Go docs — encoding/json: https://pkg.go.dev/encoding/json
- Go blog — JSON and Go: https://go.dev/blog/json
- Go docs — testing: Benchmarks: https://pkg.go.dev/testing#hdr-Benchmarks
Phần sau ta tối ưu chính JSON: dùng streaming encoder/decoder (json.Encoder/Decoder), json.RawMessage để hoãn giải mã, và các kỹ thuật giảm cấp phát — đo xem tiết kiệm được bao nhiêu allocs và thời gian so với Marshal/Unmarshal thẳng.