写在前面
Java / Python / JS 抛异常,Go 用返回值——这是 Go 最被争论的设计。一段典型的 Go 业务代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
| user, err := userRepo.GetByID(id)
if err != nil {
return nil, err
}
order, err := orderRepo.Create(user, dto)
if err != nil {
return nil, err
}
err = mqProducer.Send(orderCreatedEvent(order))
if err != nil {
return nil, err
}
return order, nil
|
每隔三行就 if err != nil——很多新手吐槽"丑"。但写过几年 Go 你会发现:这种"显式错误传递"反而让错误处理路径极其清晰——不像异常那样在调用栈里随机冒出。
但工程上要做对,几个细节:
- 怎么区分错误类型?err 都是 string 不利于业务判断
- 怎么追溯错误源头?层层 return err 后只剩最后一层信息
- panic 什么时候用?
- 业务错误 vs 系统错误怎么处理?
本文把 Go 业务代码里错误处理的最佳实践讲清楚。
一、Go 错误处理的核心原则
四个关键原则:
- 错误是值(errors are values)——不是控制流
- 错误要有类型——光凭字符串无法做精确判断
- 错误要带上下文——知道错在哪里
- 错误要可断言——上层能判断"是不是某种特定错误"
老的 Go 代码多用 fmt.Errorf("%v: %v", ...) 拼字符串——丢失类型。
新的 Go(1.13+)有 errors.Is、errors.As、fmt.Errorf("%w", ...)——错误处理终于有了正经工具。
二、定义业务错误:sentinel + 类型
Sentinel 错误(哨兵错误)
1
2
3
4
5
6
7
8
9
| package usercase
import "errors"
var (
ErrUserNotFound = errors.New("user not found")
ErrUserInactive = errors.New("user inactive")
ErrInvalidStatus = errors.New("invalid status")
)
|
调用方判断:
1
2
3
4
| user, err := getUser(id)
if errors.Is(err, ErrUserNotFound) {
return c.Status(404).JSON(...)
}
|
errors.Is 会沿着 error 链查找——即便错误被 wrap 包过几层,仍然能识别。
类型化错误
光 sentinel 不够——业务错误经常带细节信息:
1
2
3
4
5
6
7
8
| type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation: %s: %s", e.Field, e.Message)
}
|
调用方判断:
1
2
3
4
5
6
7
| var ve *ValidationError
if errors.As(err, &ve) {
return c.Status(400).JSON(map[string]string{
"field": ve.Field,
"message": ve.Message,
})
}
|
errors.As 把 error 链中类型匹配的错误赋值给目标变量——能拿到具体字段。
三、Wrap 错误:保留上下文
朴素的 return err 让错误传递时丢失链路:
1
2
| // 反例:看不出错在哪里
return userRepo.GetByID(id)
|
正确做法是 wrap 上下文:
1
2
| // 1.13+ 用 fmt.Errorf %w
return fmt.Errorf("getUserByID(%d): %w", id, err)
|
%w 让错误保留可被 errors.Is/As 追溯的链——而不是普通 %v 拼字符串后丢失类型。
错误打印长这样:
1
| service.GetOrder: repo.GetOrder(123): db query: connection refused
|
每一层都加上下文——出问题时直接看错误信息就能定位。
有些第三方库用 pkg/errors
1
2
3
| import "github.com/pkg/errors"
err = errors.Wrap(err, "getOrder failed")
|
老 Go 项目还能见到这种写法——和标准库的 %w 同一思想,新项目直接用标准库即可。
四、什么时候 wrap,什么时候不 wrap
不是每个 err 都要 wrap——盲目 wrap 会让信息冗余:
1
2
| // 反例:每层都 wrap,错误信息变成俄罗斯套娃
service: handle: process: validate: check: getValue: db query: connection refused
|
实战原则:
- 跨层时 wrap(repo → service → handler)
- 加业务上下文时 wrap(“creating order for user 123”)
- 同层多次调用同一函数时 wrap(区分是哪次失败)
- 直接 return 即可的简单透传,不 wrap
五、Sentinel 还是类型化?
| sentinel error | typed error |
|---|
| 表达力 | 弱(只是个标识) | 强(带字段) |
| 比较方式 | errors.Is | errors.As |
| 模板代码 | 少 | 多(要写 type) |
| 适合 | 简单标志(NotFound/Forbidden) | 携带详细信息 |
实战折中:简单错误用 sentinel,需要带字段用 typed。
1
2
3
| var ErrNotFound = errors.New("not found") // sentinel
type RateLimitError struct { RetryAfter time.Duration } // typed
|
六、错误分层:业务错误 vs 系统错误
业务错误(用户输入错了、状态不允许)和系统错误(DB 挂了、网络超时)应该完全不同处理:
1
2
3
4
5
6
7
8
9
10
11
| type DomainError struct {
Code string // "USER_NOT_FOUND" / "INSUFFICIENT_BALANCE"
Message string // 给用户看的消息
HTTPStatus int // 4xx
}
func (e *DomainError) Error() string {
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
// system error 是普通 error
|
handler 里统一处理:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| func errorHandler(c *fiber.Ctx, err error) error {
var de *DomainError
if errors.As(err, &de) {
// 业务错误:返回给用户
return c.Status(de.HTTPStatus).JSON(map[string]string{
"code": de.Code,
"message": de.Message,
})
}
// 系统错误:500,且消息脱敏
log.Errorf("internal error: %+v", err)
return c.Status(500).JSON(map[string]string{
"code": "INTERNAL_ERROR",
"message": "服务暂时不可用",
})
}
|
业务错误暴露给前端没问题;系统错误的细节(如 SQL 报错)绝不能传给用户——日志记录、外部模糊处理。
七、不要用 panic 做控制流
panic 是 Go 的"异常逃生通道"——但不是给业务用的:
1
2
3
4
5
6
7
8
| // ❌ 反例:用 panic 做错误处理
func GetOrder(id int) *Order {
order, err := repo.GetByID(id)
if err != nil {
panic(err)
}
return order
}
|
业务代码里 panic 的合法用途几乎只有两类:
- 真正不应该发生的情况(如 nil pointer 解引用、数组越界)——表示编程 bug
- 初始化时的致命错误(如配置文件加载失败)——直接退出
正常业务流程永远 return error,让调用方决定怎么处理。
recover 的合法用途
1
2
3
4
5
6
7
8
9
| func handler(w http.ResponseWriter, r *http.Request) {
defer func() {
if r := recover(); r != nil {
log.Errorf("panic recovered: %v", r)
http.Error(w, "internal error", 500)
}
}()
// 业务逻辑
}
|
每个 HTTP handler 入口、goroutine 入口加 recover——避免一个 panic 把整个服务搞挂。这是兜底,不是常规手段。
八、goroutine 里的错误
启动 goroutine 后,错误不会自动传回 main——必须显式:
1
2
3
4
5
6
7
| // ❌ 错误丢失
go func() {
err := doWork()
if err != nil {
// ??? 没人收
}
}()
|
正确姿势:
1
2
3
4
5
6
7
| errCh := make(chan error, 1)
go func() {
errCh <- doWork()
}()
if err := <-errCh; err != nil {
return err
}
|
或者用 errgroup——更优雅:
1
2
3
4
5
6
7
8
9
10
11
12
| import "golang.org/x/sync/errgroup"
g, ctx := errgroup.WithContext(ctx)
for _, task := range tasks {
task := task
g.Go(func() error {
return process(ctx, task)
})
}
if err := g.Wait(); err != nil {
return err
}
|
errgroup 还会任一任务失败时自动 cancel ctx——其他任务能立刻退出。
九、错误日志的姿势
1
2
3
| // 反例:错误又抛出又日志,导致重复日志
log.Errorf("query user: %v", err)
return err
|
原则:要么 log,要么 return。同一错误只 log 一次——通常在最上层(HTTP handler / cron entry)。
1
2
3
4
5
6
7
8
| // 在 service / repo 层只 wrap:
return fmt.Errorf("getUser: %w", err)
// 在 handler 层一次性记录:
if err := service.GetUser(id); err != nil {
log.Errorf("getUser failed: %+v", err)
return ...
}
|
%+v 配合带 stack trace 的 error 库(如 pkg/errors 或 cockroachdb/errors)能打印完整调用栈。
十、几个常见反模式
1. 错误吞掉
1
| result, _ := json.Marshal(obj) // ❌
|
_ 把错误丢了——出问题永远查不到。显式忽略只在你 100% 确定不会出错时才用,并加注释说明。
2. 错误转字符串再判断
1
| if err.Error() == "not found" { ... } // ❌
|
字符串匹配脆得不行——别人改一个字你的判断就废了。用 errors.Is / errors.As。
3. 把所有错误压成一个 Internal Error
1
| return errors.New("internal error")
|
调用方完全不知道发生什么——永远要 wrap 原 error,保留链。
4. 错误信息含敏感数据
1
| return fmt.Errorf("query failed: %v", sqlPassword) // ❌
|
错误信息可能上日志、上前端——敏感数据要脱敏。
5. error 字段在结构体里
1
2
3
4
| type Result struct {
Data interface{}
Err error // ❌ 反 Go 风格
}
|
Go 的约定是"return (T, error)"——别学其他语言把 error 塞结构体里。
十一、Go 1.20+ 的 errors.Join
Go 1.20 引入 errors.Join——支持组合多个错误:
1
2
3
| err1 := process(1)
err2 := process(2)
return errors.Join(err1, err2) // 把多个错误打包返回
|
errors.Is 仍能识别每个原错误:
1
2
3
| joined := errors.Join(ErrA, ErrB)
errors.Is(joined, ErrA) // true
errors.Is(joined, ErrB) // true
|
适合并发任务、批量操作场景——以前要自己写一个 multi-error,现在标准库有了。
小结
把全文压一句:
Go 错误处理的核心是『错误是值,要有类型、有上下文、可断言』——if err != nil 看着烦,但带上 errors.Is/As/Wrap 后是 Go 最干净的设计。
工程实战清单:
- 业务错误用 sentinel 或 typed,不要
errors.New(...) 拼字符串 - 跨层 wrap,加业务上下文,用
%w errors.Is 比较 sentinel,errors.As 提取 typed- 业务错误和系统错误分层处理——前端区分对待
- panic 不做业务控制流——只在不可恢复 + 初始化失败时
- goroutine 用 errgroup
- 错误只 log 一次,在最上层
把这些做对,“if err != nil 写到吐"会变成你 Go 代码里最可靠的一部分——而不是负担。