JSON tiện đến mức nó trở thành mặc định cho mọi thứ — kể cả nơi nó không nên là mặc định. JSON là định dạng văn bản, tự mô tả: mỗi bản ghi lặp lại tên field ("id":, "ten":...), số được viết thành chuỗi ASCII (99999 là 5 byte thay vì 4 byte nhị phân), và khi đọc, máy phải phân tích cú pháp từng ký tự. Với API công khai hay cấu hình cho người đọc, đó là đánh đổi đúng. Nhưng với dữ liệu nội bộ nóng — hàng triệu bản ghi truyền giữa các dịch vụ của chính bạn, cache, hàng đợi — JSON tốn cả kích thước lẫn CPU một cách không cần thiết.

Một encoding nhị phân tùy biến giải quyết cả hai: bạn tự thiết kế bố cục byte, ghi số ở dạng nhị phân, và đọc lại theo offset cố định — không phân tích cú pháp, không reflection. Bài này dựng một định dạng binary đơn giản trong Go, đo thật khác biệt kích thước và tốc độ so với JSON, và nêu rõ những rủi ro của việc tự viết.

Thiết kế định dạng: cố định + length-prefix

Với mỗi bản ghi, tôi chọn bố cục: [ID:4B][Diem:2B][len(Ten):2B][Ten:N byte]. Số ghi little-endian; chuỗi (độ dài thay đổi) ghi kèm một tiền tố độ dài (length-prefix) để lúc đọc biết đọc bao nhiêu byte.

type Ban struct { ID uint32; Diem uint16; Ten string }

Mấu chốt của mọi định dạng nhị phân: hoặc trường có kích thước cố định (số), hoặc trường có kích thước thay đổi phải kèm độ dài đứng trước — để bên đọc luôn biết ranh giới mỗi trường.

Mã hóa và giải mã

Mã hóa ghi byte trực tiếp bằng encoding/binary:

binary.LittleEndian.PutUint32(tmp[:4], b.ID)
buf.Write(tmp[:4])                         // 4 byte cho ID
binary.LittleEndian.PutUint16(tmp[:2], b.Diem)
buf.Write(tmp[:2])                         // 2 byte cho Diem
binary.LittleEndian.PutUint16(tmp[:2], uint16(len(b.Ten)))
buf.Write(tmp[:2]); buf.WriteString(b.Ten) // độ dài + chuỗi

Giải mã đọc lại đúng thứ tự byte đó, theo offset:

b.ID = binary.LittleEndian.Uint32(data[i : i+4]); i += 4
b.Diem = binary.LittleEndian.Uint16(data[i : i+2]); i += 2
n := int(binary.LittleEndian.Uint16(data[i : i+2])); i += 2
b.Ten = string(data[i : i+n]); i += n // đọc n byte chuỗi

Không có phân tích cú pháp, không reflection — chỉ đọc byte theo offset đã biết. Đây là lý do nó nhanh.

Ảnh chụp đoạn mã Go nền tối minh hoạ encoding nhị phân tùy biến trong Go nhỏ hơn và nhanh hơn JSON, vì sao không phải JSON JSON dạng văn bản tự mô tả lặp tên field mỗi bản ghi số là chuỗi ASCII phải phân tích cú pháp lúc đọc tốn cả kích thước lẫn CPU với dữ liệu nội bộ giữa dịch vụ của bạn một định dạng nhị phân tùy biến gọn và nhanh hơn nhiều, thiết kế định dạng cố định cộng length-prefix mỗi bản ghi ID 4 byte Diem 2 byte len Ten 2 byte Ten N byte số ghi nhị phân little-endian chuỗi ghi kèm tiền tố độ dài type Ban struct ID uint32 Diem uint16 Ten string, mã hóa ghi byte trực tiếp binary LittleEndian PutUint32 tmp 4 b.ID buf Write tmp 4 4 byte cho ID binary LittleEndian PutUint16 tmp 2 b.Diem buf Write tmp 2 2 byte cho Diem binary LittleEndian PutUint16 tmp 2 uint16 len b.Ten buf Write tmp 2 buf WriteString b.Ten độ dài cộng chuỗi, giải mã đọc lại đúng thứ tự byte b.ID bằng binary LittleEndian Uint32 data i tới i cộng 4 i cộng bằng 4 b.Diem bằng Uint16 data i tới i cộng 2 i cộng bằng 2 n bằng int Uint16 data i tới i cộng 2 i cộng bằng 2 b.Ten bằng string data i tới i cộng n i cộng bằng n đọc n byte chuỗi không phân tích cú pháp không reflection chỉ đọc byte theo offset

Hình 1: Định dạng nhị phân tùy biến — số little-endian và chuỗi length-prefix; mã hóa ghi byte trực tiếp bằng encoding/binary, giải mã đọc lại theo offset cố định (không phân tích cú pháp, không reflection).

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

Với 100.000 bản ghi, so JSON và binary về kích thước, rồi benchmark mã hóa/giải mã:

Kích thước: JSON = 4.37 MB, Binary = 1.89 MB
Binary nhỏ hơn: 56.7% (2.3x)
Giải mã binary đúng: true (bản cuối ID=99999 Ten=nguoi-99999 Diem=999)

Thao tác          JSON       Binary     Binary nhanh hơn
Mã hóa (encode)   6.008 ms   1.488 ms   ~4x
Giải mã (decode)  31.62 ms   3.421 ms   ~9.2x

Binary thắng trên cả ba trục:

  • Kích thước nhỏ hơn 2.3 lần: không lặp tên field cho mỗi bản ghi, và số ghi nhị phân (4 byte cho ID thay vì ~6 ký tự ASCII). Với dữ liệu truyền qua mạng hay lưu trữ nhiều, đây là tiết kiệm băng thông và dung lượng trực tiếp.
  • Mã hóa nhanh ~4 lần: ghi byte thẳng, không phải format số thành chuỗi.
  • Giải mã nhanh ~9.2 lần: đây là khác biệt lớn nhất. JSON Unmarshal tốn 100.034 cấp phát (dựng cây token, phân tích từng chuỗi số); binary chỉ đọc byte theo offset. Trong nhiều hệ thống, giải mã xảy ra nhiều hơn mã hóa (ghi một lần, đọc nhiều lần), nên tốc độ decode thường quan trọng nhất.

Ảnh chụp bảng kết quả đo thật nền tối encoding nhị phân tùy biến trong Go chạy bằng go run cộng go test bench Go 1.23 arm64 100000 bản ghi, kích thước JSON vs Binary 100k bản ghi JSON bằng 4366781 byte 4.37 MB Binary bằng 1888890 byte 1.89 MB binary nhỏ hơn 56.7 phần trăm 2.3x giải mã binary đúng true bản cuối ID 99999 Ten nguoi-99999 Diem 999, tốc độ mã hóa và giải mã mã hóa encode JSON 6.008 ms Binary 1.488 ms binary nhanh hơn khoảng 4x giải mã decode JSON 31.62 ms Binary 3.421 ms binary nhanh hơn khoảng 9.2x JSON Unmarshal 100034 allocs dựng cây phân tích chuỗi Binary decode đọc byte theo offset giải mã cực nhanh, vì sao binary thắng cả ba trục kích thước không lặp tên field số ghi nhị phân 4B thay khoảng 6 ký tự encode ghi byte thẳng không format số thành chuỗi decode đọc offset cố định không phân tích cú pháp reflection, cốt lõi định dạng tự thiết kế số little-endian cộng chuỗi length-prefix đo thật 100k bản ghi 2.3x nhỏ hơn 4x encode 9.2x decode khi dùng dữ liệu nội bộ nhiều nóng giữa dịch vụ của chính bạn đánh đổi mất tính tự mô tả cộng đọc được bằng mắt phải quản version rủi ro tự viết dễ sai offset endian overflow len test kỹ thay thế protobuf msgpack gọn cộng nhanh gần binary tay mà có schema

Hình 2: Đo thật 100k bản ghi — binary nhỏ hơn JSON 2.3 lần (4.37 MB → 1.89 MB), mã hóa nhanh ~4 lần, giải mã nhanh ~9.2 lần (JSON Unmarshal tốn 100034 cấp phát, binary đọc byte theo offset), giải mã đúng dữ liệu.

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

Mất tính tự mô tả và đọc được bằng mắt. JSON có thể mở bằng bất kỳ trình soạn thảo nào và đọc hiểu ngay; binary là một khối byte vô nghĩa nếu không có mã giải mã. Bạn mất khả năng debug bằng mắt, dùng curl | jq, hay để một hệ thống khác tự hiểu dữ liệu. Đây là lý do JSON vẫn đúng cho API công khai và dữ liệu trao đổi giữa các bên không cùng codebase.

Tự viết binary dễ sai một cách nguy hiểm. Code trên trông đơn giản, nhưng đầy cạm bẫy: đọc nhầm offset, sai endian giữa các kiến trúc, len(Ten) vượt uint16 (65535) làm tràn âm thầm, hay data[i:i+n] vượt biên gây panic khi dữ liệu hỏng. Một định dạng binary tự viết phải được test kỹ với dữ liệu biên và dữ liệu hỏng — vì lỗi ở đây thường là hỏng dữ liệu im lặng, khó lần hơn nhiều so với lỗi phân tích JSON.

Phải tự quản lý phiên bản (versioning). JSON dung thứ với thay đổi schema: thêm field mới, bên đọc cũ chỉ bỏ qua. Binary với offset cố định thì không — thêm một field là đổi bố cục, và bên đọc cũ đọc sai hoàn toàn. Bạn phải tự thiết kế cơ chế version (ví dụ byte đầu là số phiên bản) ngay từ đầu, nếu không việc tiến hóa định dạng sẽ rất đau.

Thường nên dùng protobuf hoặc msgpack thay vì tự viết. Đây là kết luận thực dụng: các định dạng như Protocol Buffers hay MessagePack cho bạn gần như toàn bộ lợi ích kích thước và tốc độ của binary tay, nhưng kèm schema, sinh code tự động, quản lý version, và tương thích đa ngôn ngữ. Tự viết binary chỉ đáng khi bạn có nhu cầu rất đặc thù (bố cục cực tối ưu cho một trường hợp nóng) và chấp nhận gánh nặng bảo trì. Bài này cho bạn hiểu cơ chế để biết các thư viện đó làm gì bên dưới.

Ba ý mang về

  1. Encoding nhị phân tùy biến nhỏ và nhanh hơn JSON trên cả ba trục: đo thật 100k bản ghi, binary nhỏ hơn 2.3 lần (không lặp tên field, số nhị phân), mã hóa nhanh ~4 lần, giải mã nhanh ~9.2 lần (đọc offset thay vì phân tích cú pháp) — decode thường là trục quan trọng nhất.
  2. Cơ chế cốt lõi là bố cục byte có ranh giới rõ: số kích thước cố định little-endian, chuỗi kèm tiền tố độ dài (length-prefix), giải mã đọc theo offset không reflection — đó là điều làm nó nhanh.
  3. Nêu rõ đánh đổi và rủi ro: mất tính tự mô tả và debug bằng mắt, tự viết dễ sai (offset, endian, tràn độ dài) nên phải test kỹ, và phải tự quản version — với hầu hết nhu cầu, protobuf/msgpack cho lợi ích tương tự mà an toàn hơn nhiều.

Phần sau ta xét một kỹ thuật đọc tệp cực lớn không qua bộ nhớ thường: memory-mapped file (mmap) trong Go — ánh xạ tệp thẳng vào không gian địa chỉ, đọc như slice byte, và đo thật khác biệt so với đọc thường.