json.Unmarshal(data, &v) là cách quen thuộc để giải mã JSON trong Go, và nó hoàn hảo cho payload nhỏ. Nhưng nó có một đặc tính chết người với dữ liệu lớn: nó cần toàn bộ dữ liệu nằm trong bộ nhớ, rồi dựng tất cả struct cùng một lúc. Xử lý một tệp JSON 40MB nghĩa là giữ 40MB bytes cộng toàn bộ struct đã giải mã — và với tệp hàng GB (log, export dữ liệu, feed), điều này làm sập ứng dụng vì hết bộ nhớ.

json.Decoder là lời giải: nó đọc lười (streaming) từ một io.Reader, cho phép bạn giải mã từng phần tử một của một mảng JSON lớn, xử lý rồi bỏ đi — không bao giờ giữ cả mảng trong RAM. Bộ nhớ trở thành hằng số, không phụ thuộc kích thước tệp. Bài này đo thật khác biệt bộ nhớ giữa hai cách, và giải thích cơ chế Token/More/Decode.

Vấn đề: Unmarshal nạp toàn bộ vào RAM

// json.Unmarshal cần TOÀN BỘ dữ liệu trong bộ nhớ, rồi dựng
// TẤT CẢ struct cùng lúc. File 40MB -> giữ 40MB bytes + N struct.
// Bộ nhớ tỉ lệ với KÍCH THƯỚC file -> file GB làm sập app.

Cách 1 — đọc cả tệp rồi unmarshal:

raw, _ := os.ReadFile(path)  // nạp CẢ file vào RAM
var tatca []Ban
json.Unmarshal(raw, &tatca)  // dựng TOÀN BỘ N struct cùng lúc
for _, b := range tatca { tong += b.Diem }

Ở đây bạn giữ đồng thời hai thứ lớn: mảng bytes cả tệp và slice chứa mọi struct đã giải mã.

Cách 2: json.Decoder streaming

f, _ := os.Open(path)
dec := json.NewDecoder(bufio.NewReader(f))
dec.Token() // đọc dấu mở mảng [
for dec.More() { // còn phần tử?
	var b Ban
	dec.Decode(&b) // giải mã TỪNG phần tử một
	tong += b.Diem // xử lý ngay, rồi bỏ đi
}
dec.Token() // đọc dấu đóng mảng ]

json.NewDecoder nhận một io.Reader (ở đây là file bọc bufio.Reader) và đọc dữ liệu khi cần. Vòng lặp giải mã từng phần tử vào một biến b duy nhất, xử lý, rồi ghi đè ở vòng sau — nên tại mỗi thời điểm chỉ có một struct trong bộ nhớ.

Ảnh chụp đoạn mã Go nền tối minh hoạ streaming JSON lớn trong Go json.Decoder thay vì Unmarshal cả tệp, vấn đề Unmarshal nạp toàn bộ vào RAM json.Unmarshal cần toàn bộ dữ liệu trong bộ nhớ rồi dựng tất cả struct cùng lúc file 40MB giữ 40MB bytes cộng N struct bộ nhớ tỉ lệ với kích thước file file GB làm sập app cần xử lý từng phần tử không giữ cả mảng trong RAM, cách 1 ReadFile cộng Unmarshal tốn RAM raw bằng os ReadFile path nạp cả file vào RAM var tatca slice Ban json Unmarshal raw tatca dựng toàn bộ N struct cùng lúc for range tatca tong cộng b Diem giữ đồng thời 40MB bytes cộng 500k struct trong bộ nhớ, cách 2 json Decoder streaming bộ nhớ hằng số f bằng os Open path dec bằng json NewDecoder bufio NewReader f dec Token đọc dấu mở mảng for dec More còn phần tử var b Ban dec Decode b giải mã từng phần tử một tong cộng b Diem xử lý ngay rồi bỏ đi dec Token đọc dấu đóng mảng mỗi lúc chỉ giữ 1 struct cộng buffer đọc nhỏ RAM không đổi, cơ chế Token More Decode Token đọc một token cú pháp để bóc vỏ mảng More còn phần tử trong mảng object đang mở không Decode giải mã phần tử kế vào một struct đọc thêm từ nguồn khi cần Decoder đọc lười từ io Reader không cần cả tệp trong RAM

Hình 1: json.Unmarshal nạp cả tệp và dựng mọi struct cùng lúc (RAM tỉ lệ kích thước); json.Decoder streaming đọc lười từng phần tử qua Token/More/Decode, giữ một struct một lúc — bộ nhớ hằng số.

Cơ chế Token, More, Decode

Ba phương thức của json.Decoder làm nên streaming:

  • Token(): đọc một token cú pháp kế tiếp — dấu [, ], {, }, một khóa, hay một giá trị. Ta dùng nó để "bóc vỏ" dấu mở/đóng mảng ngoài cùng.
  • More(): cho biết mảng (hoặc object) đang mở còn phần tử nữa không — điều kiện dừng cho vòng lặp.
  • Decode(&v): giải mã phần tử kế tiếp vào v, tự đọc thêm dữ liệu từ nguồn khi cần.

Điểm mấu chốt: Decoder đọc lười từ io.Reader, nên nó không bao giờ cần cả tệp trong RAM. Nó chỉ giữ một buffer đọc nhỏ và phần tử đang giải mã.

Đo thật: 41.6 MB, +142 MB hay +2.3 MB

Tạo tệp JSON 41.6 MB (500.000 bản ghi), xử lý hai cách, đo HeapAlloc tăng thêm:

Cách                  heap tăng thêm  giữ gì trong RAM
ReadFile + Unmarshal  +142.5 MB       cả file 41MB + 500k struct
Decoder streaming     +2.3 MB         1 struct + buffer nhỏ

Hai cách CÙNG kết quả (tổng điểm 249750000): đúng
Streaming giảm bộ nhớ: 142.5 MB -> 2.3 MB (~62 lần)

Khác biệt rất lớn: Unmarshal tốn +142.5 MB heap (giữ cả tệp bytes cộng 500.000 struct cùng lúc), còn streaming chỉ +2.3 MB (một struct cộng buffer đọc). Giảm ~62 lần. Và quan trọng — cả hai cho cùng kết quả (tổng điểm 249.750.000), nên đây không phải mẹo bỏ qua dữ liệu; cùng 500.000 bản ghi được xử lý, chỉ khác cách nạp.

Bản chất: với Unmarshal, RAM = kích thước tệp + tổng mọi struct, nên nó tăng theo tệp; với streaming, RAM = một struct + buffer đọc, nên nó không đổi theo tệp. Tệp 40MB hay 40GB, streaming vẫn giữ bộ nhớ gần như nhau — điều này khiến xử lý tệp khổng lồ không lọt vào vừa RAM trở nên khả thi.

Ảnh chụp bảng kết quả đo thật nền tối streaming JSON lớn trong Go chạy bằng go run Go 1.23 arm64 file JSON 41.6 MB 500000 bản ghi HeapAlloc, xử lý file JSON 41.6 MB 500000 bản ghi ReadFile cộng Unmarshal heap tăng thêm cộng 142.5 MB giữ cả file 41MB cộng 500k struct Decoder streaming heap tăng thêm cộng 2.3 MB giữ 1 struct cộng buffer nhỏ hai cách cùng kết quả tổng điểm 249750000 đúng streaming giảm bộ nhớ 142.5 MB tới 2.3 MB khoảng 62 lần, vì sao bộ nhớ hằng số không tỉ lệ file Unmarshal RAM bằng kích thước file cộng tổng mọi struct tăng theo file streaming RAM bằng 1 struct cộng buffer đọc không đổi theo file file 40MB hay 40GB streaming vẫn giữ RAM gần như nhau xử lý tập khổng lồ vào vừa RAM là khả thi, cốt lõi vấn đề Unmarshal cần cả tệp trong RAM cộng mọi struct cùng lúc streaming json Decoder đọc lười xử lý từng phần tử rồi bỏ đi đo thật 41.6 MB 500k bản ghi cộng 142.5 MB tới cộng 2.3 MB khoảng 62 lần bộ nhớ hằng số theo streaming không tỉ lệ kích thước dữ liệu NDJSON mỗi dòng 1 JSON log hợp streaming tự nhiên nhất đánh đổi streaming mã dài hơn mất truy cập ngẫu nhiên cả mảng

Hình 2: Đo thật xử lý tệp JSON 41.6 MB (500.000 bản ghi) — ReadFile+Unmarshal tốn +142.5 MB heap, json.Decoder streaming chỉ +2.3 MB (~62 lần ít hơn), cùng kết quả; streaming giữ bộ nhớ hằng số không tỉ lệ kích thước tệp.

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

Streaming đổi tính tiện lấy bộ nhớ. Code streaming dài hơn và cần hiểu cấu trúc JSON (bóc vỏ mảng bằng Token). Với payload nhỏ (một API response vài KB), Unmarshal gọn hơn và khác biệt bộ nhớ không đáng kể — đừng streaming khi không cần. Streaming là công cụ cho dữ liệu lớn hoặc không giới hạn, không phải mặc định cho mọi JSON.

Mất truy cập ngẫu nhiên vào cả mảng. Vì streaming xử lý từng phần tử rồi bỏ đi, bạn không giữ cả mảng để truy cập lại phần tử thứ i, sắp xếp toàn bộ, hay đếm rồi mới xử lý. Nếu thuật toán cần nhìn toàn bộ dữ liệu nhiều lần, streaming không hợp (hoặc phải đọc lại tệp nhiều lượt). Streaming hợp nhất với xử lý một-lượt (one-pass): lọc, tổng hợp, chuyển đổi, nạp vào DB.

NDJSON hợp streaming tự nhiên nhất. Định dạng "mỗi dòng một JSON object" (newline-delimited JSON, phổ biến cho log và data export) được thiết kế riêng cho streaming: bạn đọc từng dòng và giải mã, không cần bóc vỏ mảng ngoài. Với JSON cực lớn mà bạn kiểm soát định dạng, chọn NDJSON thay vì một mảng khổng lồ giúp streaming đơn giản và mạnh hơn — mỗi dòng độc lập, dễ xử lý song song và khôi phục khi lỗi giữa chừng.

Ba ý mang về

  1. json.Unmarshal giữ bộ nhớ tỉ lệ kích thước tệp — nó cần cả tệp trong RAM và dựng mọi struct cùng lúc; đo thật, tệp 41.6 MB tốn +142.5 MB heap (cả tệp bytes + 500k struct), đủ làm sập app với tệp GB.
  2. json.Decoder streaming giữ bộ nhớ hằng số: đọc lười từng phần tử qua Token/More/Decode, chỉ giữ một struct một lúc — đo thật cùng tệp chỉ +2.3 MB (~62 lần ít hơn) với cùng kết quả.
  3. Nêu rõ đánh đổi: streaming đổi mã dài hơn và mất truy cập ngẫu nhiên lấy bộ nhớ, chỉ hợp xử lý một-lượt trên dữ liệu lớn/không giới hạn — với payload nhỏ cứ dùng Unmarshal, và NDJSON là định dạng hợp streaming tự nhiên nhất.

Phần sau ta xét khi JSON là quá tốn: encoding nhị phân tùy biến trong Go — tự thiết kế định dạng binary gọn và nhanh hơn JSON, đo thật khác biệt kích thước và tốc độ.