热路径中反复调用 Errors.new 或 fmt.errorf 会产生可观测的性能损耗,主因是每次分配新错误对象并隐式捕获调用栈;go 1.13+ 后 fmt.errorf 默认不带栈,但 %w 包装或 errors.withstack 会恢复栈收集开销。

创建 errors.New 和 fmt.Errorf 的开销有多大
在热路径(比如高频请求处理、循环内)反复调用 errors.New 或 fmt.Errorf,确实会带来可测量的性能损耗——不是因为字符串拼接本身慢,而是因为每次都会分配新错误对象,并隐式捕获当前 goroutine 的调用栈(尤其 fmt.Errorf 默认带栈时)。Go 1.13+ 后 fmt.Errorf 默认不带栈,但若用了 %w 包装或显式启用了 errors.WithStack 类机制,开销就回来了。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 热路径中避免在循环里反复构造新错误,优先复用预定义的错误变量,例如:
var ErrNotFound = errors.New("not found") - 若必须动态生成(如含 ID 的错误),用
fmt.Sprintf拼好字符串再传给errors.New,比直接fmt.Errorf("not found: %d", id)少一次格式化 + 错误封装两层开销 - 确认你用的 Go 版本:1.13 前
fmt.Errorf总是带栈;1.13+ 只有含%w或调用errors.Unwrap相关逻辑时才触发栈收集
什么时候该用 errors.Is 而不是 ==
用 == 直接比较错误值,只在双方都是同一底层指针(即同一个变量或其副本)时才可靠。一旦错误被包装(比如 fmt.Errorf("wrap: %w", err))、序列化后再反序列化、或跨 goroutine 传递后重新构造,== 就失效了。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 只要错误可能被包装(http 中间件、数据库驱动、日志 wrapper 等常见场景),一律用
errors.Is(err, myErr)判断是否为某类错误 -
errors.Is是递归检查整个错误链,所以它比==多一点开销,但这个开销固定且极小(通常就几层指针解引用),远小于重复创建错误的代价 - 不要对每个错误都无脑用
errors.Is:如果确定错误没被包装(比如函数内部返回的固定错误变量),==更快也更直观
fmt.Errorf 的 %w 包装对性能的影响
加 %w 不只是语义上“包装”,它会让 Go 运行时在错误对象里存一份指向原错误的指针,并在后续 errors.Unwrap 或 errors.Is 时触发链式遍历。这意味着每次包装都新增一次内存分配(哪怕原错误是 nil),且错误链越长,errors.Is 查找越慢(最坏 O(n))。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 只在真正需要保留原始错误语义时才用
%w,比如 HTTP handler 中把 DB 错误转成 500 并透传根因;纯日志记录或中间转换可直接fmt.Errorf("timeout waiting for db") - 避免嵌套包装:不要写
fmt.Errorf("layer1: %w", fmt.Errorf("layer2: %w", err)),这会人为拉长错误链,增加判断和打印开销 - 注意第三方库行为:有些 ORM 或 client 库默认用
%w包装底层错误,你要么接受链式开销,要么在关键路径提前errors.Unwrap截断
错误日志打印时的隐式开销
很多日志库(如 log/slog、zap)在格式化错误时会自动调用 error.Error(),而如果错误链很长、或某个中间错误实现了自定义 Error() 方法(比如做了反射或 json 序列化),就会在日志这一瞬间触发意外计算。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 不要在错误类型里做重操作:自定义
Error()方法应只返回已计算好的字符串,避免在其中查 DB、序列化大结构体或调用runtime.Caller - 日志中打印错误前,先用
errors.Unwrap或slog.Group显式控制展开深度,防止日志线程卡住 - 生产环境开启日志采样时,注意错误字段是否参与采样判定——某些配置下,即使日志被丢弃,
Error()仍会被调用一次
错误处理的性能陷阱不在“怎么写 error”,而在“谁在什么时候、以什么方式触发了 error 的构造、包装和呈现”。链越深、调用越频、现场越热,这些细节就越不能靠直觉判断。