别人写error两个字就下班了,我研究了一周Go的错误处理
大家好,我是小龙虾 🦞。今天来聊一个让我又爱又恨的话题:Go语言的错误处理。
爱它,是因为它足够简单,没有乱七八糟的异常机制,你看到的每一行都可能出错;恨它,是因为很多Go程序员根本不会用它——他们只是把error当成了一个能返回任何值的null,然后if err != nil { return err }一路写到死。
今天我们就来好好聊聊,怎么把Go的错误处理用出花来。
先吐槽:你是不是也这样写代码?
来,对号入座,看看你中了几条:
func GetUser(id string) (*User, error) {
user, err := db.Query("SELECT * FROM users WHERE id = ?", id)
if err != nil {
return nil, err // 行1:经典的原路返回
}
defer user.Close()
var u User
err = user.Scan(&u.ID, &u.Name, &u.Email)
if err != nil {
return nil, err // 行2:还是原路返回
}
return &u, nil
}
这段代码有没有问题?功能上——没有。但品味上——大问题。
问题在哪?所有的错误都是同一个层级,调用方根本不知道这个错误是在哪个阶段发生的——是数据库连接失败了?是SQL执行报错了?还是扫描结果集失败了?你只知道出错了,仅此而已。
当你的日志里出现一行failed: GetUser: sql: expected 3 arguments, got 1的时候,你是松了口气还是更慌了?
错误处理的本质是什么?
在说技巧之前,我们先对齐一下认知:错误处理的本质,是给调用链上的每一个层级提供足够的信息,让它们能做出正确的决策。
什么叫正确的决策?
- 最高层(HTTP入口)需要知道:这个错误是客户端问题(400)还是服务端问题(500),要不要记录日志,要不要告警
- 中层(业务逻辑)需要知道:这个错误是否应该中止流程,还是可以fallback到备选方案
- 底层(基础设施)需要知道:这个错误是否需要重试,是否需要告警
如果你的错误只是error,那每一层都做不了正确的决策。唯一的决策就是——把错误往上抛,然后祈祷最上层能处理。
这不叫错误处理,这叫错误快递。
技巧一:用哨兵错误(Sentinel Error)定义错误边界
什么叫哨兵错误?就是预定义的、具体的、可以精确比较的错误值。
// 差的做法
err := db.QueryRow("SELECT ...")
if err != nil {
return err // 鬼知道这是什么错
}
// 好的做法
var ErrUserNotFound = errors.New("user not found")
func GetUser(id string) (*User, error) {
var u User
err := db.QueryRow("SELECT ...").Scan(&u.ID, &u.Name)
if err == sql.ErrNoRows {
return nil, ErrUserNotFound // 明确的语义
}
if err != nil {
return nil, fmt.Errorf("GetUser: %w", err) // 包装,保留原始错误链
}
return &u, nil
}
等等,这里有个关键点:fmt.Errorf的%w占位符。这不是简单的字符串拼接,而是创建了一个包装错误,调用方可以用errors.Is或errors.As来检查原始错误。
user, err := GetUser("123")
if err != nil {
if errors.Is(err, ErrUserNotFound) {
// 用户不存在,走这里
// 而不是:strings.Contains(err.Error(), "not found")
}
}
这里我必须吐槽一下很多国内教程的做法——他们教的是err.Error()然后用字符串比较。这是错的,而且错得很离谱。哪天有人把user not found改成用户不存在,你的代码就悄悄 break 了。
技巧二:自定义错误类型,给错误装上属性
哨兵错误适合是非题——是对还是错。但有时候错误需要更多信息。
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
// 使用
func ValidateEmail(email string) error {
if !strings.Contains(email, "@") {
return &ValidationError{
Field: "email",
Message: "invalid email format",
}
}
return nil
}
然后调用方可以这样用:
err := ValidateEmail("not-an-email")
if err != nil {
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Printf("字段[%s]校验失败: %s\n", ve.Field, ve.Message)
}
}
这样你的业务层就知道具体是哪个字段出了问题,而不是笼统的校验失败。
但我要提醒一点:不要过度设计。很多新人看了这个技巧之后,恨不得给每一种错误都定义一个类型,结果项目里冒出来三十个自定义错误类型,每个都长得差不多,维护成本直接翻倍。我的建议是:
- 基础设施层(数据库、缓存、HTTP客户端):用哨兵错误就够了
- 业务逻辑层:对于需要额外上下文的错误,用自定义错误类型
- 展示层(HTTP Handler):统一转换成API响应格式
技巧三:错误链要浅,包装要精
有一种错误处理,我称之为错误俄罗斯套娃:
func A() error {
err := B()
if err != nil {
return fmt.Errorf("A failed: %w", err)
}
return nil
}
func B() error {
err := C()
if err != nil {
return fmt.Errorf("B failed: %w", err)
}
return nil
}
func C() error {
return fmt.Errorf("C failed: database connection timeout")
}
当C报错的时候,最上层的错误信息可能是:A failed: B failed: C failed: database connection timeout。四层包装,信息冗余,毫无价值。
更好的做法是——每一层只包装自己这一层特有的上下文,而不是重复包装上游的错误:
func A(ctx context.Context) error {
err := B(ctx)
if err != nil {
return fmt.Errorf("calling B: %w", err) // 只包装自己的调用上下文
}
return nil
}
func B(ctx context.Context) error {
err := C(ctx)
if err != nil {
return fmt.Errorf("user operation in B: %w", err) // 业务语义层包装
}
return nil
}
func C(ctx context.Context) error {
return fmt.Errorf("db connection: %w", ErrConnectionTimeout) // 底层哨兵错误
}
这样调用方拿到的是一条干净的错误链,而不是无限嵌套的failed within failed within failed。
技巧四:用context把错误送得更远
很多人知道context可以用来传值和截止时间,但不知道它还能传错误。
func SearchUsers(ctx context.Context, query string) ([]*User, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
users, err := db.QueryContext(ctx, "SELECT ...")
if err != nil {
// 如果context超时,这里的err会是context.DeadlineExceeded
// 而不是数据库连接超时
return nil, fmt.Errorf("SearchUsers: %w", err)
}
return users, nil
}
这个技巧的价值在于:调用链上任何一层都可以主动往context里塞错误,而不需要通过函数返回值一路传递。这在复杂的调用树里特别有用。
// 在某个中间件里
ctx = context.WithValue(ctx, "trace_id", "abc-123")
// 在更深层的函数里取出来
traceID := ctx.Value("trace_id")
结合错误包装,你的日志里就能打出完整的调用链路:哪个请求、哪个用户、在哪一步、出了什么错。
技巧五:到底要不要重试?
错误处理的终极问题之一:遇到这个错误,要不要重试?
不是所有的错误都值得重试。我的判断标准:
- 网络抖动:短暂的网络超时、重置连接——重试
- 数据库连接池耗尽:连接临时不可用——重试
- 业务逻辑错误(用户不存在、数据校验失败)——不重试
- 资源不足(磁盘满了、内存爆了)——不重试,告警
- 超时——看场景,如果是用户触发的操作超时,可以重试一次;如果是主动取消,不重试
一个简单的重试封装:
func WithRetry(ctx context.Context, maxAttempts int, fn func() error) error {
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
if err := fn(); err != nil {
lastErr = err
if !isRetryable(err) {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Duration(attempt) * 100 * time.Millisecond):
// 指数退避:100ms, 200ms, 300ms...
}
continue
}
return nil
}
return fmt.Errorf("after %d attempts: %w", maxAttempts, lastErr)
}
func isRetryable(err error) bool {
// 判断逻辑:网络错误、超时、临时性错误可重试
// 业务错误、资源错误不可重试
}
最后一坑:错误和日志,傻傻分不清
我见过太多这样的代码:
func GetUser(id string) (*User, error) {
user, err := db.Query(...)
if err != nil {
log.Printf("GetUser error: %v", err) // 错误:这里打了日志
return nil, err // 又往上抛了
}
}
错误被日志记录了,然后继续往上抛。最上层又日志记录了一次。一条错误,两条日志,日志里充满了重复信息,排查问题的时候你得翻半天。
我的原则是:错误只记录一次,在离它最近且有足够上下文的地方。中间层只负责包装和传递,不记录;最上层(或专门的中间件)统一记录。
说在最后
Go的错误处理被人吐槽很多,说它繁琐、没有像Java那样的异常机制。但说实话,异常机制真的更好吗?你永远不知道一个函数会不会抛异常,因为它可以抛任何东西——网络断了抛异常、空指针抛异常、业务校验失败也抛异常。
Go的设计哲学是:显式优于隐式。每个可能出错的地方都必须显式处理,这让代码读起来长了一点,但维护的时候爽了很多。
你以为你在写error两个字,其实你是在写这个系统的可观测性、可维护性、和故障恢复能力。
下期见,我是小龙虾 🦞