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.

Ảnh chụp đoạn mã Go nền tối minh hoạ JSON trong Go struct tag và reflection, struct LineItem với json tag sku ten so_luong don_gia giam_gia omitempty bằng 0 thì bỏ khỏi JSON, struct Order với ID KhachHang nested struct Items slice GhiChu omitempty, Marshal Unmarshal cùng một cặp hàm cho mọi kiểu b bằng json Marshal order struct sang byte JSON var out Order json Unmarshal b vào out byte JSON sang struct, cơ chế json không biết trước kiểu Order nó dùng reflection package reflect để lúc chạy duyệt từng field đọc tag xác định kiểu rồi đọc ghi giá trị mạnh mẽ và tiện nhưng có chi phí ẩn mỗi lần gọi phải khám phá lại cấu trúc có cache nhưng vẫn tốn Unmarshal cấp phát nhiều slice map string đều tạo mới, so sánh dựng JSON bằng tay không reflection strings Builder WriteString ghi thẳng không reflect nhanh hơn nhiều nhưng viết tay dễ sai khó bảo trì không tổng quát

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:

Ảnh chụp bảng kết quả chạy thật đo JSON output thật, một kích thước byte cùng một đơn hàng JSON gọn Marshal 643 byte JSON có thụt lề MarshalIndent 928 byte cộng 44 phần trăm chỉ để cho người đọc JSON không omitempty 656 byte cộng 13 byte field ghi_chu rỗng vẫn hiện dựng tay chỉ 5 field 88 byte ít field hơn nhỏ hơn, hai benchmark Marshal vs Unmarshal vs dựng tay BenchmarkMarshal 1036 ns mỗi op 928 B mỗi op 3 allocs mỗi op BenchmarkUnmarshal 4184 ns mỗi op 1600 B mỗi op 27 allocs mỗi op BenchmarkBuildThuCong 139 ns mỗi op 637 B mỗi op 4 allocs mỗi op chỉ 5 field, ba điều đo được nói lên gì Unmarshal chậm khoảng 4 lần Marshal 4184 vs 1036 ns và tốn 27 allocs vs 3 giải mã phải cấp phát slice items các string struct con Marshal reflection 1036 ns 3 allocs dựng tay 5 field 139 ns reflection có chi phí thật nhưng đổi lại tổng quát an toàn ít lỗi omitempty bỏ field rỗng ghi_chu tiết kiệm byte MarshalIndent cộng 44 phần trăm byte chỉ dùng khi cần người đọc không cho máy

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ề

  1. JSON trong Go tiện nhờ reflection, và reflection có chi phí đo được: một cặp Marshal/Unmarshal xử lý mọi struct nhờ gói reflect duyệ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í.
  2. 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.
  3. 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); omitempty bỏ 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

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.