Go 用 json.Decoder 流式解析大 JSON:边读边处理不爆内存
线上有个日志导出接口,返回一个包含几十万条记录的 JSON 数组,几百 MB。你用 json.Unmarshal 一读,内存直接飙上去,容器 OOMKilled。问题很清楚:Unmarshal 要求先把整个 JSON 读进内存的 []byte,再反序列化成一个完整的大切片------数据多大,内存就要多大,峰值还翻倍。
解法是 json.Decoder 的流式解析:边从 io.Reader 读,边逐个元素解码处理,处理完就丢,内存只留当前这一条。这篇讲清 Decoder 的三种用法,以及大文件场景怎么用 Token() 逐元素解析。
先看 Unmarshal 的问题
go
// 朴素写法:整个文件读进内存,再一次性反序列化
data, _ := os.ReadFile("huge.json") // 几百 MB 全进内存
var records []Record
json.Unmarshal(data, &records) // 又复制一份成切片,峰值内存 x2
for _, r := range records {
process(r)
}
两个内存大头:data 这个 []byte 是文件全量,records 又是全量对象。文件越大越危险。对「读一条处理一条」的场景,这完全没必要。
Decoder 基础:从 Reader 直接解码
json.NewDecoder 接受任意 io.Reader(文件、HTTP body、网络连接都行),不需要先读成 []byte:
go
type Record struct {
ID int `json:"id"`
Name string `json:"name"`
}
f, err := os.Open("huge.json")
if err != nil {
log.Fatal(err)
}
defer f.Close()
dec := json.NewDecoder(f) // 直接吃 Reader,不预读全量
如果 JSON 是单个对象 (不是数组),dec.Decode(&v) 一次就够,和 Unmarshal 用法几乎一样,但省去了手动读文件。真正的价值在解析大数组时。
核心:用 Token() + More() 逐元素解析数组
对一个大数组 [{...},{...},...],思路是:先读掉开头的 [,然后循环用 dec.More() 判断还有没有下一个元素,有就 Decode 一条、处理一条,最后读掉结尾的 ]。
go
dec := json.NewDecoder(f)
// 1. 读掉数组开头的 '['
t, err := dec.Token()
if err != nil {
log.Fatal(err)
}
if delim, ok := t.(json.Delim); !ok || delim != '[' {
log.Fatalf("期望 JSON 数组,实际开头是 %v", t)
}
// 2. More() 为真表示数组里还有下一个元素
for dec.More() {
var r Record
if err := dec.Decode(&r); err != nil { // 每次只解码一条,只占一条的内存
log.Fatal(err)
}
process(r) // 处理完这条就可以被回收,内存不累积
}
// 3. 读掉结尾的 ']'
if _, err := dec.Token(); err != nil {
log.Fatal(err)
}
关键在于:Decode 每次只从流里读出一个完整的数组元素 ,process 完这条,r 就能被 GC 回收。无论数组有一万条还是一千万条,常驻内存都只是当前一条,彻底摆脱了「全量进内存」。
Token() 返回的可能是 json.Delim(定界符 [ ] { })、字符串、数字、布尔或 nil。我们用它来精确越过数组的方括号。
进阶:解析「外层有元数据」的结构
真实接口很少是裸数组,通常长这样------外层是对象,大数组藏在某个字段里:
json
{ "total": 500000, "items": [ {"id":1,"name":"a"}, ... ] }
这时先用 Token() 一路读到 items 这个 key,再进它的数组流式解析:
go
dec := json.NewDecoder(f)
dec.Token() // 读掉最外层的 '{'
for dec.More() {
// 读 key
keyTok, _ := dec.Token()
key, _ := keyTok.(string)
if key == "items" {
dec.Token() // 读掉 items 的 '['
for dec.More() {
var r Record
if err := dec.Decode(&r); err != nil {
log.Fatal(err)
}
process(r)
}
dec.Token() // 读掉 items 的 ']'
} else {
// 其它字段(如 total)整体跳过,不关心就丢进一个 interface{}
var skip any
dec.Decode(&skip)
}
}
这样即便 items 有几十万条,total 等元信息也能正常读到,而大数组仍是流式处理。
两个实用开关:严格模式与数字精度
Decoder 有两个常被忽略但很有用的方法。
DisallowUnknownFields() 让 JSON 里出现结构体没定义的字段时直接报错,而不是默默忽略------对接外部接口时,能第一时间发现字段拼错或协议变更:
go
dec := json.NewDecoder(f)
dec.DisallowUnknownFields() // 多了未知字段就报 error,别默默吞掉
UseNumber() 让所有数字解析成 json.Number(本质是字符串)而不是 float64,避免大整数(如 64 位雪花 ID)被 float64 精度截断:
go
dec := json.NewDecoder(f)
dec.UseNumber() // 防止 ID 这种大整数被 float64 截断精度
大整数用默认 float64 接会丢精度这个坑很隐蔽------ID 值不大时看着正常,超过 2^53 才出错,上线才暴雷。涉及大整数一定记得 UseNumber()。
一个容易忽略的点:HTTP 响应天生就是 Reader
http.Response.Body 本身就是 io.Reader,所以处理大接口响应时,别先 io.ReadAll 再 Unmarshal,直接喂给 Decoder:
go
resp, err := http.Get(url)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
dec := json.NewDecoder(resp.Body) // 边下载边解析,不必等整个 body 落地
// ... 同上流式解析
这样下载和解析是流水线式的,既省内存,首字节到达就能开始处理,延迟也更低。
小结
json.Unmarshal要求全量数据进内存,大 JSON 会导致内存翻倍甚至 OOM;json.Decoder直接从io.Reader流式读,内存只留当前一条。- 解析大数组的套路:
Token()读掉[→for dec.More()里逐条Decode并处理 →Token()读掉]。 - 外层带元数据时,用
Token()定位到目标字段的数组再流式解析,其它字段整体跳过。 DisallowUnknownFields()抓协议变更,UseNumber()防大整数精度丢失,涉及大 ID 必开。- HTTP body 本身是 Reader,直接
json.NewDecoder(resp.Body),别多此一举ReadAll。
一句话记忆:大 JSON 别 Unmarshal,Decoder + More() 逐条 Decode,内存只装当前一条。