Go 错误处理:为什么你的程序总是悄悄挂掉?

2026-03-05 7 0

> 错误处理不是try-catch那么简单的事儿

写在前面

前两天线上服务又双叒叕挂了,排查了半天才发现——某个 goroutine 里的错误被"优雅地"忽略了。是的,你没看错,错误被静默吞掉了,连个日志都没有。

这让我想起来一个经典段子:

「我的程序从来没有错误,只是有些我不知道的行为。」

今天咱们来聊聊 Go 里的错误处理,那些面试官不会问、但线上会教的坑。

Go 错误处理的"佛系"哲学

Go 的错误处理可能是最"反直觉"的设计之一了。没有 try-catch,没有异常机制,错误就是一个普普通通的 error 接口。

type error interface {
    Error() string
}

就这?是的,就这。

很多人吐槽 Go 错误处理太繁琐,满屏的 if err != nil 看的人头皮发麻。但我要说,这恰恰是 Go 的设计哲学——错误是值,不是异常

什么意思?异常意味着"例外",意味着"我不该处理"。但 Go 告诉你,错误就是一种普通的返回值,和 intstring 没区别。你得重视它,处理它。

那些年我们踩过的错误处理坑

坑一:错误被静默吞掉

// ❌ 典型反面教材
func process(data []byte) {
    result, err := parse(data)
    // 错误?不存在的好吧
    fmt.Println(result)
}

这种代码在生产环境就是定时炸弹。你永远不知道什么时候会出错,出了什么错。

// ✅ 最低限度:至少打个日志
func process(data []byte) {
    result, err := parse(data)
    if err != nil {
        log.Printf("parse failed: %v", err)
        return
    }
    fmt.Println(result)
}

坑二:wrapping 过度,丢失上下文

// ❌ 错误信息成了俄罗斯套娃
func outer() error {
    if err := inner(); err != nil {
        return fmt.Errorf("inner failed: %v", err)
    }
    return nil
}

// 打印出来是这样的:
// inner failed: database failed: connection refused
// ...请问到底是哪一层挂了?

Go 1.13 引入了 %w 包装错误,解决了这个问题:

// ✅ 正确姿势
func outer() error {
    if err := inner(); err != nil {
        return fmt.Errorf("outer 调用 inner 失败: %w", err)
    }
    return nil
}

%w 包装的错误可以用 errors.Is()errors.As() 来检查,再也不用字符串匹配了。

坑三:Goroutine 里的错误是无底洞

// ❌ 经典错误:goroutine 的错误石沉大海
func fetchAll(urls []string) []Result {
    results := make([]Result, 0)
    for _, url := range urls {
        go func(u string) {
            r, err := http.Get(u)
            if err != nil {
                // 错误去哪了?
                // 答:去了虚空
            }
            results = append(results, parseResponse(r))
        }(url)
    }
    return results // 恭喜你,拿到了一个竞态条件的 results
}

goroutine 里的错误处理是个大难题。推荐几种方案:

方案一:使用 channel 收集错误

func fetchAll(urls []string) ([]Result, error) {
    results := make([]Result, 0, len(urls))
    errCh := make(chan error, len(urls))
    
    var wg sync.WaitGroup
    for _, url := range urls {
        wg.Add(1)
        go func(u string) {
            defer wg.Done()
            r, err := http.Get(u)
            if err != nil {
                errCh <- fmt.Errorf("fetch %s failed: %w", u, err)
                return
            }
            results = append(results, parseResponse(r))
        }(url)
    }
    
    wg.Wait()
    close(errCh)
    
    // 检查有没有错误
    for err := range errCh {
        log.Println(err)
    }
    return results, nil
}

方案二:使用 errgroup

func fetchAll(urls []string) ([]Result, error) {
    ctx := context.Background()
    g, ctx := errgroup.WithContext(ctx)
    
    results := make([]Result, len(urls))
    
    for i, url := range urls {
        i, url := i, url // 避免闭包问题
        g.Go(func() error {
            r, err := http.Get(url)
            if err != nil {
                return fmt.Errorf("fetch %s failed: %w", url, err)
            }
            results[i] = parseResponse(r)
            return nil
        })
    }
    
    if err := g.Wait(); err != nil {
        return nil, err // 自动取消其他 goroutine
    }
    return results, nil
}

errgroup 是 golang.org/x/sync/errgroup,自动处理了 wait、错误传播和 context 取消,简直是并发错误处理的救星。

错误处理的最佳实践

1. Sentinel Error 是你的好朋友

// 定义预定义的错误
var (
    ErrNotFound = errors.New("resource not found")
    ErrUnauthorized = errors.New("unauthorized")
    ErrTimeout = errors.New("operation timeout")
)

// 使用 errors.Is 检查
if errors.Is(err, ErrNotFound) {
    // 处理资源不存在的情况
}

预定义错误让错误处理有据可依,代码可读性 up up。

2. 自定义错误类型

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("%s: %s", e.Field, e.Message)
}

// 使用 errors.As 检查
var valErr *ValidationError
if errors.As(err, &valErr) {
    fmt.Printf("验证失败字段: %s\n", valErr.Field)
}

自定义错误类型可以携带更多上下文信息,调用方可以根据错误类型做不同的处理。

3. 错误也要有层级

// 底层库返回底层错误
func lowLevel() error {
    return fmt.Errorf("connection refused: %w", net.ErrConnectionRefused)
}

// 中间层包装业务错误
func middleLevel() error {
    if err := lowLevel(); err != nil {
        return fmt.Errorf("调用用户服务失败: %w", err)
    }
    return nil
}

// 顶层处理具体的业务逻辑
func topLevel() error {
    if err := middleLevel(); err != nil {
        if errors.Is(err, net.ErrConnectionRefused) {
            return fmt.Errorf("用户服务不可用,请稍后重试: %w", err)
        }
        return err
    }
    return nil
}

每层只添加自己关心的信息,保留底层错误信息,用 errors.Is() 做精确判断。

4. 日志还是返回?成年人不做选择

func processWithLogging(data []byte) error {
    result, err := parse(data)
    if err != nil {
        // 记录日志 + 返回错误
        log.Printf("parse failed, data length: %d, error: %v", len(data), err)
        return fmt.Errorf("parse data failed: %w", err)
    }
    return nil
}

原则是:记录日志是为了调试,返回错误是为了让调用方知道出错了。两者不冲突。

写在最后

Go 的错误处理确实没有 Java 的 try-catch 那么"优雅",满屏的 if err != nil 确实看着烦。但正是这种"繁琐",强迫你直面每一个可能的错误,而不是假装它不存在。

记住这句话:任何一个被忽略的错误,都可能在某个夜深人静的时候,给你发来一条报警短信。

好好处理错误,程序才能稳定运行。毕竟——

没有错误的人生是假的,没有错误处理程序也是。

相关文章

为什么你的Prompt总是得不到想要的结果?——资深调教AI的私房秘籍
你的日志正在谋杀你的系统——一个被低估的性能杀手
RESTful API 早该扔进垃圾桶了
Goroutine: 你真的懂并发吗?
你的SQL正在偷偷拖垮你的系统——一个后端工程师的索引踩坑总结
SQL查询慢得想砸电脑?来,我教你几招

发布评论