Go 高手(17):错误处理模式——哨兵错误、自定义错误、最佳实践
更新时间:2026-09-01。本文是
languages/go/intermediate/高手层第 17 篇,接 并发模式。Go 的错误处理没有异常,只有值。但正因为如此,Go 社区积累了一些成熟的错误处理模式。理解这些模式,能写出更可维护的代码。
本文要回答的问题
- 哨兵错误、自定义错误类型、错误包装,分别解决什么问题?
- 什么时候用
errors.New,什么时候用fmt.Errorf("%w"),什么时候自定义类型? - 业务错误和系统错误应该分开处理吗?
- 怎么在 Go 里做出"异常"的效果?但为什么不应该这么做?
一、三种错误模式
Go 的错误处理有三种模式:
| 模式 | 实现方式 | 适用场景 |
|---|---|---|
| 哨兵错误 | var ErrNotFound = errors.New("not found") | 调用者需要知道具体错误原因 |
| 自定义错误类型 | type MyError struct { ... } | 错误需要携带额外信息 |
| 错误包装 | fmt.Errorf("...: %w", err) | 添加上下文,保留错误链 |
二、哨兵错误(Sentinel Error)
go
var ErrNotFound = errors.New("user not found")
var ErrPermissionDenied = errors.New("permission denied")
var ErrInvalidInput = errors.New("invalid input")
func FindUser(id int) (*User, error) {
if id < 0 {
return nil, ErrInvalidInput
}
// ...
}
// 调用者用 errors.Is 检查
user, err := FindUser(42)
if errors.Is(err, ErrNotFound) {
// 用户不存在,返回 404
}注意: 哨兵错误应该作为包的导出变量,调用者才能用 errors.Is 检查。不要暴露内部错误细节。
三、自定义错误类型
go
type HTTPError struct {
StatusCode int
Body string
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Body)
}
func fetch(url string) error {
// ...
return &HTTPError{StatusCode: 404, Body: "not found"}
}
// 调用者用 errors.As 获取详细信息
var httpErr *HTTPError
if errors.As(err, &httpErr) {
log.Printf("HTTP error: %d %s", httpErr.StatusCode, httpErr.Body)
}什么时候自定义?
- 错误需要携带结构化数据(状态码、错误码、字段名)
- 调用者需要根据错误的不同部分做不同处理
- 错误需要序列化(比如返回给 HTTP 客户端)
四、错误包装链
go
func FindUser(ctx context.Context, id int) (*User, error) {
user, err := db.QueryUser(ctx, id)
if err != nil {
return nil, fmt.Errorf("find user %d: %w", id, err)
}
return user, nil
}
func QueryUser(ctx context.Context, id int) (*User, error) {
row := db.QueryRowContext(ctx, "SELECT * FROM users WHERE id = ?", id)
// ...
if err := row.Scan(&user); err != nil {
return nil, fmt.Errorf("query user %d: %w", id, err)
}
}包装原则:
- 每一层包装都加上上下文,方便定位
- 用
%w保留错误链,让errors.Is和errors.As能穿透 - 最上层的 HTTP handler 打印完整错误链,但返回给客户端只给通用错误
go
func handler(w http.ResponseWriter, r *http.Request) {
user, err := FindUser(r.Context(), 42)
if err != nil {
log.Printf("error: %+v", err) // 打印完整错误链
http.Error(w, "Internal Server Error", 500)
return
}
}五、业务错误 vs 系统错误
go
// 业务错误:调用者需要处理
var ErrInsufficientBalance = errors.New("insufficient balance")
var ErrUserNotFound = errors.New("user not found")
// 系统错误:调用者不需要区分,统一处理
// 直接用 fmt.Errorf 包装,不用导出
func transfer(from, to, amount int) error {
if err := db.Exec("UPDATE ..."); err != nil {
return fmt.Errorf("transfer: %w", err) // 系统错误
}
if balance < amount {
return ErrInsufficientBalance // 业务错误,调用者需要判断
}
}区分原则:
- 业务错误:调用者需要知道具体原因,做不同处理(如返回不同的 HTTP 状态码)
- 系统错误:调用者不需要区分,统一处理(如记录日志,返回 500)
六、Go 的错误处理 vs C++ 异常
| 对比 | Go error | C++ exception |
|---|---|---|
| 本质 | 值,普通返回 | 栈展开,特殊控制流 |
| 性能 | 正常路径无开销 | 异常路径有开销,正常路径无 |
| 是否必须处理 | 编译器不强制 | 可以不 catch |
| 错误链 | errors.Is/As 穿透 | catch 基类统一处理 |
| 检查是否处理 | 静态检查有限 | 编译器不检查 |
Go 的方式更显式,更安全——错误是返回值,不处理就编译不过。C++ 的异常更方便,但容易忘记 catch。
七、常见坑对照
| 坑 | 现象 | 对策 |
|---|---|---|
| 业务错误不导出 | 调用者无法区分 | 导出哨兵错误,用 errors.Is |
每层都用 %v 包装 | 错误链断了,调用者无法 Is 或 As | 用 %w 保留错误链 |
| 自定义错误用值接收者 | errors.As 找不到 | 用指针接收者实现 Error() |
| 错误信息暴露内部细节 | 安全风险 | 日志记录完整错误,返回给用户通用错误 |
相关与延伸
下一篇:基准测试与性能优化——benchstat、pprof、b.N;错误处理进阶基础,见 错误处理进阶。
一句话总结
Go 错误处理模式:哨兵错误(errors.New)导出,调用者用 errors.Is 检查;自定义错误类型实现 error 接口,用指针接收者,调用者用 errors.As 获取详情;错误包装链用 %w 保留上下文,每一层加上位置信息;业务错误导出哨兵让调用者判断,系统错误包装后记录日志即可;Go 的 error 比 C++ 异常更安全,虽然写起来更啰嗦。