jsontext按Token读取超大JSON
来源:17golang原创
时间:2026-11-15 03:08:10 246浏览 收藏
处理一个几 GB 的 JSON 文件时,最容易踩的坑不是 JSON 语法,而是把整份文档一次性反序列化成 Go 结构体。Go 1.27 提供的 encoding/json/jsontext 更接近 JSON 的语法层:用 Decoder.ReadToken 逐个读取结构符号、字段名、字符串、数字和字面量,业务代码只保留当前需要的状态。
官方地址:https://pkg.go.dev/encoding/json/jsontext
如果目标是扫描超大 JSON 中的少数字段,优先把输入接到jsontext.NewDecoder,循环调用ReadToken,把io.EOF当作正常结束;不要把每个 Token 追加到切片。需要跨下一次读取保留原始 Token 时,再调用Clone。
先确定:你要 Token 还是完整 Value
jsontext 同时提供 ReadToken 和 ReadValue。前者读取一个词法 Token,例如 {、字段名、数字或 ];后者读取一个完整 JSON 值,完整对象和数组也属于 Value。两条路线都能流式推进,但内存边界和处理粒度不同。
| 需求 | 优先选择 | 原因 |
|---|---|---|
| 扫描字段名、统计结构、过滤少数成员 | ReadToken | 每次只处理当前 Token,状态可由业务代码控制 |
| 把一个完整对象交给下游组件 | ReadValue | 一次得到完整 JSON 值,调用方不必自己拼接边界 |
| 保存从解码器读出的 Token | Token.Clone | 原始 Token 的数据只保证在下一次 Decoder 调用前有效 |

从任意 io.Reader 创建流式 Decoder
文件、HTTP 响应体和网络连接都可以实现 io.Reader,所以扫描函数不必绑定具体输入来源。下面的函数只接收 Reader,并把每个 Token 的原始显示形式交给回调;它不会把整份 JSON 放进 []byte 或结构体。
package main
import (
"encoding/json/jsontext"
"fmt"
"io"
"strings"
)
// scanTokens 逐个读取 JSON Token,调用方决定如何消费当前 Token。
func scanTokens(r io.Reader, visit func(jsontext.Token, int) error) error {
dec := jsontext.NewDecoder(r)
for {
tok, err := dec.ReadToken()
if err == io.EOF {
// 输入流正常结束,不把 EOF 当成失败。
return nil
}
if err != nil {
// 语法错误或底层 Reader 错误都交给上层处理。
return err
}
// StackDepth 表示当前处于多少层对象或数组嵌套。
if err := visit(tok, dec.StackDepth()); err != nil {
return err
}
}
}
func main() {
err := scanTokens(strings.NewReader(`{"items":[{"id":1},{"id":2}]}`), func(tok jsontext.Token, depth int) error {
// String 对字符串返回解码后的值,对其他 Token 返回原始表示。
fmt.Printf("depth=%d token=%s\n", depth, tok.String())
return nil
})
if err != nil {
fmt.Println("scan failed:", err)
}
}
上面示例的 main 需要额外导入 strings 才能作为完整演示;生产代码通常把文件或 HTTP Body 直接传给 scanTokens。示例故意只打印当前 Token,不把 Token 收集到切片,这才是大文档扫描时的关键。
循环处理时,EOF 和错误要分开
ReadToken 返回 io.EOF 代表没有更多 Token,是成功路径;其他错误则可能表示 JSON 语法不完整、输入截断或底层 Reader 失败。把所有错误都当作 EOF,会让损坏的文件看起来像是成功处理。
func countNumbers(r io.Reader) (int, error) {
dec := jsontext.NewDecoder(r)
count := 0
for {
tok, err := dec.ReadToken()
if err == io.EOF {
// 全部 Token 已读完,返回累计结果。
return count, nil
}
if err != nil {
// 不吞掉语法错误,避免把半份数据当成完整结果。
return 0, fmt.Errorf("read JSON token: %w", err)
}
// Kind 可用于区分字符串、数字和对象/数组边界。
if tok.Kind() == jsontext.KindNumber {
count++
}
}
}
这里的计数条件只用于展示处理位置,实际项目应按当前 Go 版本的 jsontext.Kind 常量表达数字类型,不要依赖字符字面量猜测 Token 类型。更常见的做法是先根据字段名维护一个小状态机,再对目标值调用 tok.Int()、tok.Uint() 或 tok.Float(),并检查转换错误。
用字段状态和嵌套深度筛选目标数据
按 Token 读取并不意味着业务逻辑必须复杂。面对类似 {"items":[...]} 的文档,可以只保留“最近读到的字段名”“是否正在目标数组内”“当前深度”几个变量;读到目标值后立即处理,读到结束分隔符时清除状态。
func scanItemIDs(r io.Reader, handle func(string) error) error {
dec := jsontext.NewDecoder(r)
wantID := false
for {
tok, err := dec.ReadToken()
if err == io.EOF {
// 没有更多 Token,扫描成功结束。
return nil
}
if err != nil {
// 将 JSON 语法错误原样包装后返回。
return fmt.Errorf("scan item ids: %w", err)
}
if tok.Kind() == jsontext.KindString {
text := tok.String()
if text == "id" {
// 下一个 Token 是字段值,交给后续分支处理。
wantID = true
continue
}
}
if wantID {
// 这里示意把当前值交给业务层;真实代码应按 Kind 转换数值。
if err := handle(tok.String()); err != nil {
return err
}
wantID = false
}
}
}
生产代码还要把字段状态和对象层级绑定起来,避免嵌套对象里同名的 id 被误处理。StackDepth 能帮助你记录当前嵌套深度;如果需要精确判断当前路径,应维护一个与对象/数组边界同步的小型路径栈,而不是依赖“最近一次字段名”。
ReadToken 与 ReadValue 的边界
ReadToken 适合“看见一个就处理一个”,但它不会代表完整对象或数组;ReadValue 会返回一个完整的 JSON Value,适合把单个成员交给另一个解析器。选择时要看下游需要的最小单位,而不是简单追求 API 数量更少。

另一个容易被忽略的边界是 Token 的生命周期。ReadToken 返回的原始 Token 可能由 Decoder 的内部缓冲区支持;下一次 PeekKind、ReadToken、ReadValue 或 SkipValue 调用后,不能继续把它当作长期对象使用。若要放进队列、异步任务或跨循环保存,先复制:
func keepFirstToken(r io.Reader) (jsontext.Token, error) {
dec := jsontext.NewDecoder(r)
tok, err := dec.ReadToken()
if err != nil {
return jsontext.Token{}, err
}
// Clone 让 Token 脱离 Decoder 的内部缓冲区,便于跨下一次读取保存。
saved := tok.Clone()
return saved, nil
}
大文档场景下的内存和失败边界
- 文档总大小不等于单次内存:按 Token 扫描通常不需要保留整份文档,但单个超长字符串或数字仍可能成为最大瞬时 Token。
- 不要无界缓存:如果要把结果交给下游,用有界队列、批量提交或背压,不要把所有 Token 再复制一份。
- Reader 错误不能吞:网络中断、文件截断和语法错误应和正常 EOF 分开记录。
- 路径状态要有边界:同名字段可能出现在不同层级,使用对象/数组边界更新路径栈。
- 版本要对齐:
encoding/json/jsontext是 Go 标准库的语法层 API,部署环境应与文章示例所用 Go 版本一致。
小结:把“整份解码”改成“当前 Token 决策”
jsontext 按 Token 读取超大 JSON 的核心不是一段特殊的循环,而是重新划分责任:Decoder 负责语法推进,业务代码只保留字段、路径和结果所需的最小状态;io.EOF 是正常结束,其他错误必须向上返回;需要长期保存的 Token 用 Clone 脱离内部缓冲区。这样才能在输入从几十 MB 增长到 GB 级时,仍然让内存上限由“当前 Token 和业务窗口”决定,而不是由整个文档决定。
常见问题
ReadToken 会把整个 JSON 文件读进内存吗?
不会把完整文档一次性解码成结构体,但 Decoder 仍会缓冲并解析当前 Token;超长字符串本身仍可能占用较多内存。
什么时候应该改用 ReadValue?
当下游需要一个完整对象或数组,而不是逐个处理结构符号时,ReadValue 更直接;它的内存边界应按单个 Value 的大小评估。
为什么不能直接把 Token 存起来以后用?
由 Decoder 读取的原始 Token 只保证在下一次 Decoder 调用前有效。跨循环、异步或队列保存时调用 Clone。
发现 io.EOF 后还要再读一次吗?
不需要。对一次完整输入,io.EOF 表示 Token 流已经正常结束;重复读取没有意义,应该返回累计结果。
-
860 收藏
-
843 收藏
-
826 收藏
-
809 收藏
-
792 收藏
-
224 收藏
-
371 收藏
-
446 收藏
-
409 收藏
-
445 收藏
-
314 收藏
-
501 收藏
-
462 收藏
-
275 收藏
-
173 收藏
-
253 收藏
-
194 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习