Go错误处理最佳实践:从panic到errors.Is
Go 的错误处理看起来很朴素:函数返回 error,调用方判断是否为 nil。这种方式没有异常机制那么“自动”,但优点是错误路径非常明确。项目规模变大后,真正的难点不是写 if err != nil,而是如何保留上下文、如何判断错误类型、什么时候使用 panic,以及如何让日志对排查问题有帮助。
error 的基本约定
Go 中的错误是一种普通值。一个函数如果可能失败,通常把 error 放在最后一个返回值:
1 | func LoadConfig(path string) (*Config, error) { |
调用方必须根据 err 决定下一步:
1 | cfg, err := LoadConfig("config.json") |
这种写法虽然重复,但可读性高。每个错误分支都能清楚看到资源释放、重试、降级或返回响应的处理逻辑。
给错误增加上下文
直接返回底层错误会丢失业务语义。例如只看到 open config.json: no such file or directory,还不清楚发生在哪个流程。推荐使用 %w 包装错误:
1 | func LoadConfig(path string) (*Config, error) { |
%w 会保留原始错误链,调用方仍然可以通过 errors.Is 或 errors.As 判断底层错误。
不要在每一层都重复打印日志并返回错误,否则一次失败会产生多条相似日志。通常选择在边界层记录日志,例如 HTTP handler、任务入口或命令行入口;中间层负责补充上下文并返回错误。
使用 errors.Is 判断错误
当需要判断某个错误是否属于指定类型或哨兵错误时,使用 errors.Is,不要直接字符串匹配。
1 | func EnsureFile(path string) error { |
即使错误经过多层 %w 包装,errors.Is 仍然可以识别链路中的 os.ErrNotExist。
使用 errors.As 提取具体类型
有些错误带有更多字段,例如网络错误、路径错误。此时可以用 errors.As 提取具体类型:
1 | func IsTimeout(err error) bool { |
这种写法比断言具体实现类型更稳,因为错误链可能经过包装。
自定义业务错误
业务系统经常需要把内部错误映射成 HTTP 状态码或错误码。可以定义一个结构体错误:
1 | type AppError struct { |
创建错误时保留底层原因:
1 | return &AppError{ |
边界层可以识别 AppError 并返回统一响应:
1 | var appErr *AppError |
panic 应该用在哪里
panic 不应该用于普通业务失败。用户输入错误、数据库查不到数据、第三方接口超时都应该返回 error。panic 更适合表示程序无法继续运行的内部错误,例如初始化配置缺失、模板必须存在却不存在、不可恢复的编程错误。
在 Web 服务中,通常会加一个 recover 中间件,防止单个请求的 panic 导致整个进程退出:
1 | func Recover(next http.Handler) http.Handler { |
recover 只能兜底,不能替代正常的错误处理。真正的业务错误仍然应该显式返回。
日志与错误返回的分工
一个常见原则是“要么处理,要么返回”。如果当前函数能修复问题,例如重试、降级、返回默认值,那就处理掉。如果处理不了,就补充上下文后返回给上层。
日志一般放在请求入口或任务入口:
1 | if err := service.CreateOrder(ctx, req); err != nil { |
这样日志中既有业务字段,也有完整错误链。中间层不需要重复打印。
小结
Go 错误处理的关键是保持错误链清晰。底层返回具体错误,中间层用 %w 补充上下文,边界层统一记录日志和转换响应。需要判断错误时使用 errors.Is 和 errors.As,不要匹配字符串。panic 只用于不可恢复错误,并通过 recover 做服务稳定性的最后防线。







