Go 用 json.Decoder 流式解析大 JSON:边读边处理不爆内存

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.ReadAllUnmarshal,直接喂给 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,内存只装当前一条。

相关推荐
xiangyun611 小时前
【408数据结构 03】线性表与顺序表:C++手写SeqList
开发语言·数据结构·c++
Lsir10110_1 小时前
从按钮“点不动“讲起——深入理解 Qt 信号槽机制
开发语言·qt·信号处理
君科程序定做1 小时前
用 IDL 处理高分一号(GF-1 / GF-1B/C/D)数据
c语言·开发语言
敲代码的嘎仔1 小时前
用 Redis 合并写 + DelayQueue 延迟检测,把视频播放进度写库频率降到 1/60
java·开发语言·数据库·redis·缓存·音视频·高并发
代码中介商2 小时前
C++ 预约系统实战(一):项目总览与 C/S 架构设计
c语言·开发语言·c++
谢亮_vipxieliang2 小时前
ValidX时间段验证详解:ISO 8601标准与简化格式
java·spring boot·后端·spring·spring cloud·hibernate
我叫汪枫2 小时前
RAG 进阶:切分、检索、重排序,以及它和 Agent、微调、MCP 都是什么关系
开发语言·python
gf13211112 小时前
【python_发送飞书卡片消息_直接组装JSON格式发送卡片】
python·json·飞书
弘毅 失败的 mian2 小时前
数组和指针基础
c语言·开发语言·经验分享·笔记