go 1.20+ 中 errors.unwrap 循环调用会导致栈溢出,主因是错误包装器未遵守 unwrap 返回更底层错误的约定;应使用 map[error]bool 记录已访问错误来安全展开错误链。

Go 1.20+ 中 errors.Unwrap 循环调用的典型表现
当你在递归检查错误链时看到 stack overflow 或 runtime: goroutine stack exceeds 1GB limit,基本就是 errors.Unwrap 遇到了循环引用。这不是你代码写错了,而是底层错误包装器(比如某些 SDK、中间件或自定义 error 实现)没遵守「unwrap 必须返回更底层错误」的约定,导致 errors.Unwrap 反复绕回自己。
手动实现安全的递归展开,避开无限 Unwrap
别依赖 errors.Unwrap 一路到底,改用带访问记录的遍历。核心是用 map[error]bool 记录已见过的错误指针,遇到重复就终止。
常见错误场景包括:日志中间件反复包装同一错误、测试中用 fmt.Errorf("wrap: %w", err) 包装自身、第三方库错误类型重写了 Unwrap 但返回了自己。
- 用
unsafe.Pointer或fmt.Sprintf("%p", err)做 key 不可靠——得用map[error]bool,Go 运行时能正确比对接口值是否指向同一底层对象 - 不要只比对
err.Error(),字符串相同不等于错误相同,且可能有性能开销 - 如果必须兼容 Go errors.Unwrap 行为一致,该方案同样适用
// 安全展开错误链
func safeErrorChain(err error) []error {
seen := make(map[error]bool)
var chain []error
for err != nil {
if seen[err] {
break
}
seen[err] = true
chain = append(chain, err)
err = errors.Unwrap(err)
}
return chain
}
判断是否真需要递归检查:先问「你在查什么」
90% 的递归错误检查其实只为了做两件事:errors.Is 判断底层是否含某错误类型,或 errors.As 提取某包装结构。这两者本身已内置循环防护——Go 标准库从 1.13 起就在内部用了类似 seen 机制。
立即学习“go语言免费学习笔记(深入)”;
- 直接用
errors.Is(err, io.EOF),不用自己展开再遍历 - 要用
errors.As(err, &target)提取自定义错误字段?它也防循环,无需前置展开 - 只有当你要「收集全部中间错误信息用于日志/诊断」时,才需要手动展开——这时必须加防循环逻辑
自定义错误类型里写错 Unwrap 是最隐蔽的坑
如果你自己实现了 error 接口并加了 Unwrap 方法,最容易犯的错是:返回了当前实例本身,或返回了一个新构造但又包装了自己的错误。
- 错例:
func (e *MyErr) Unwrap() error { return e }—— 直接循环 - 错例:
func (e *MyErr) Unwrap() error { return fmt.Errorf("wrapped: %w", e) }—— 新错误又包回自己 - 正确做法:只返回真正更底层的错误字段,比如
e.cause,且确保cause不会形成闭环 - 测试建议:对每个自定义错误类型写一个循环检测单元测试,用
safeErrorChain辅助验证
复杂点永远在错误来源不可控的地方——比如你依赖的库返回了一个包装器,它内部的 Unwrap 实现你没法改。这时候,安全展开不是“更优雅”,而是唯一能防止 panic 的方式。










