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.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,内存只装当前一条。

相关推荐
朝朝辞暮i4 小时前
C++ 第 23 课:class —— 开始真正进入面向对象
开发语言·c++·算法
明月_清风5 小时前
Muse 登顶 App Store 第一,SDK 直接开源:AI Agent 开始进入下一个阶段
人工智能·后端
小师兄吃牛肉5 小时前
C语言篇:宏
c语言·开发语言
郑同学的笔记5 小时前
【c++随笔28】strcmp 和 memcmp对比
开发语言·c++
言乐66 小时前
Python根据无法识别搜索词找出可能输入内容模型
开发语言·python·django·virtualenv·pygame
weixin_307779136 小时前
有限产能智能排产与动态重排智能体:从需求解构到技术实现
开发语言·人工智能·算法·架构
kakakahahahaha6 小时前
Windows C盘临时文件清理:%temp%、Windows Temp、Prefetch、Windows.old与hiberfil.sys处理指南
c语言·开发语言·windows
朝朝辞暮i6 小时前
C++ 第 27 课:智能指针 shared_ptr
开发语言·c++·算法
朝朝辞暮i7 小时前
C++ 第 29 课:回调函数 Callback
开发语言·c++·算法
笨笨饿7 小时前
140_AI新手村MCP与Skills是干嘛的
开发语言·人工智能·python·stm32·单片机·嵌入式硬件·物联网