Go错误处理本质是error分层建模与可观测性实践,关键在区分业务错误(4xx)、系统错误(5xx,可重试)和编程错误(应崩溃而非recover),并统一用%w包装、errors.Join聚合、结构化日志记录。

Go 语言没有异常(try/catch),错误必须显式传递和检查,所以“错误处理架构”本质上不是一套框架,而是对 error 类型的分层建模、上下文增强、分类路由与可观测性集成的组合实践。硬套“架构”容易过度设计,真正关键的是:什么时候该包装、什么时候该终止、哪些错误该重试、哪些该透传给调用方。
如何区分业务错误、系统错误与编程错误
这是整个错误处理逻辑的起点。三类错误在 Go 中应有不同表现形式和处理策略:
- 业务错误(如
ErrUserNotFound、ErrInsufficientBalance):应实现error接口,且最好带语义化类型(可类型断言),不暴露内部细节,HTTP 层通常映射为 4xx 状态码 - 系统错误(如
io.EOF、os.PathError、数据库连接超时):多数来自标准库或依赖包,需用errors.Is()或errors.As()判断,常触发重试或降级,HTTP 层一般对应 5xx - 编程错误(如
nil pointer dereference、panic):不该出现在正常流程中,不应被recover捕获后转成 error 返回;日志记录 + 崩溃更安全,否则会掩盖 bug
什么时候用 fmt.Errorf,什么时候用 errors.Join 或 errors.WithStack
fmt.Errorf 是最常用但最容易滥用的工具。它适合添加一层上下文,但不保留原始错误链;而真实服务中往往需要追溯根因。
- 只加一句话说明?用
fmt.Errorf("failed to parse config: %w", err)—— 注意%w动词,它保留原始 error 链,后续可用errors.Is()判断 - 多个并行操作都可能出错,需聚合?用
errors.Join(err1, err2, err3),返回一个复合 error,errors.Is()对其中任一子错误都有效 - 调试阶段需要堆栈?不要自己写
WithStack,标准库不提供,也不推荐用第三方包注入运行时堆栈(影响性能且不可控);真要查调用路径,靠日志打点 + trace ID 关联更可靠
HTTP handler 中如何统一错误响应格式而不丢失错误语义
常见反模式是把所有 error 都转成 map[string]interface{} 或统一 JSON 结构,结果导致下游无法做类型判断或重试决策。
立即学习“go语言免费学习笔记(深入)”;
- 定义中间件,在
defer中 recover panic,并记录 trace ID,但不要把它转成error向上抛 - handler 内部只返回原生
error,由顶层 middleware 统一处理:先用errors.As(&MyAppError{})匹配业务错误类型,再根据类型决定 status code 和响应字段;对未知 error,默认 500 + 日志告警 - 避免在 error message 里拼接敏感信息(如 SQL、文件路径),可在 error 实现中重写
Error()方法做脱敏,或用结构体字段控制输出
日志与错误追踪中,为什么不能只依赖 err.Error()
err.Error() 是给人看的字符串,不是结构化数据。一旦你只记录它,就失去了错误分类、自动告警、根因分析的能力。
- 记录日志时,应同时打:trace ID、error 类型名(
fmt.Sprintf("%T", err))、关键字段(如err.Code如果实现了自定义接口)、发生位置(runtime.Caller可选) - 如果用了 OpenTelemetry,用
span.RecordError(err),它会提取 error 的类型、消息、堆栈(若支持)并上报,比手动解析Error()更稳定 - 线上排查时,
errors.Unwrap(err)手动展开错误链比反复 grep 日志更快,前提是你的包装始终用%w
最易被忽略的一点:错误处理的成本不在写代码时,而在每次新增一个 if err != nil 分支时,你是否同步更新了日志、监控、重试策略和文档。真正的“架构”是团队对这些决策的一致约定,而不是某个 error wrapper 包。










