Go中递归解析JSON必须加深度限制,因goroutine栈有约1GB硬上限且无尾递归优化,超深嵌套会触发不可recover的runtime栈溢出panic;应优先用json.RawMessage延迟解析,必要时自定义UnmarshalJSON并显式传入depth校验,或改用json.Decoder.Token()流式跳过子树。

Go 里用递归函数解析深层嵌套 JSON,json.Unmarshal 一超过百层就大概率 panic: runtime: goroutine stack exceeds 1000000000-byte limit——这不是数据错,是栈真爆了,且 recover 完全无效。
为什么递归解析 JSON 必须加深度限制
Go 运行时对每个 goroutine 的栈空间有硬上限(约 1GB),而 json.Unmarshal 对自引用结构(如 Children []Node)会逐层压栈,不支持尾递归优化。哪怕你写了终止条件,只要输入是攻击者构造的 500 层嵌套对象,照样在第 200 层就崩。
- 错误信息永远是模糊的
runtime: goroutine stack exceeds ... limit,不是json.SyntaxError,无法靠errors.Is(err, json.SyntaxError)捕获 -
defer + recover对这类 panic 完全失效——它发生在 Go 运行时底层,没机会进入 Go 的 panic 处理链 - 不能靠
runtime.Stack动态检测剩余栈空间:快爆时读不到、读到也不准、开销还大
用 json.RawMessage 延迟解析代替硬编码递归结构
把可能无限嵌套的字段(比如 "children")声明为 json.RawMessage,跳过即时解码,等真正需要时再按需解析。这是最轻量、最安全的起点。
-
json.RawMessage是[]byte类型,不是string或*json.RawMessage,赋值前必须确保字节有效(可用json.Valid(data)预检) - 结构体定义示例:
type Node struct { ID int `json:"id"` Name string `json:"name"` Children json.RawMessage `json:"children,omitempty"` } - 后续解析时,可选:用
json.Unmarshal(childBytes, &subNode)解到具体 struct;或用json.Unmarshal(childBytes, &m)解到map[string]interface{}再做字段判断;甚至直接跳过——比如只统计节点总数,根本不需要展开
必须显式传入 depth 参数并校验
如果业务强依赖结构化解析(比如 AST 遍历、权限树校验),那就得自己实现 UnmarshalJSON,并在每层递归入口做深度检查。
立即学习“go语言免费学习笔记(深入)”;
- 深度阈值不能写死为
100,要结合场景:HTTP header 允许嵌套最多 5 层?YAML 模板!include最多 8 次?这些得写进常量或配置注释里 - 关键代码模式:
func (n *Node) UnmarshalJSON(data []byte) error { if len(data) == 0 { return nil } if !json.Valid(data) { return errors.New("invalid json") } var raw struct { ID int `json:"id"` Name string `json:"name"` Children json.RawMessage `json:"children"` } if err := json.Unmarshal(data, &raw); err != nil { return err } n.ID = raw.ID n.Name = raw.Name if len(raw.Children) > 0 { // 此处传入 depth+1,并提前检查 if depth > maxDepth { return errors.New("recursion too deep") } // 递归解析子节点,注意传入 raw.Children 而非 data if err := n.parseChildren(raw.Children, depth+1); err != nil { return err } } return nil } - 切记:不要在
UnmarshalJSON里直接调json.Unmarshal(data, n),这会触发无限递归
超深树优先用 json.Decoder 流式处理
面对日志、AST、配置模板等动辄几百上千层的结构,递归无论怎么加限制都吃力。这时该放弃“全量解码”,改用 json.Decoder.Token() 手动跳过不需要的子树。
-
json.NewDecoder(r).UseNumber()可避免 float64 精度丢失,配合Token()逐 token 判断类型和边界 - 遇到
"children"字段时,若当前 depth 已超限,直接调用decoder.Skip()跳过整个子树,不分配内存、不触发任何解码逻辑 - 优势明显:内存占用恒定(不随嵌套深度增长)、可中断、天然带深度计数(每进一层
{就depth++,每出一层}就depth--)
真正难的不是写个 if depth > 50,而是所有可能被用户输入触发的递归入口——包括 UnmarshalJSON、Walk 函数、甚至自定义的 Validate 方法——都得统一接入深度参数。漏掉任何一个,就是线上 panic 的伏笔。


















