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.

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
Unmarshaltố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.

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ề
- 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.
- 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.
- 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.