"if err != nil" 大概是 Go 被吐槽最多的一句话。我刚开始也吐槽,写了半年之后想法变了,记录一下。
一开始的抵触
看这个:
f, err := os.Open(name)
if err != nil {
return err
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return err
}
六个有效行里有四行在检查错误。第一反应是这语言怎么这么啰嗦,try-catch 一行就能解决的事。
转变的契机是一次线上故障
有个批量任务跑了三个月都没问题,某天开始偶尔丢数据,一天丢几条,量不大但持续。
排查了很久,最后定位到一个第三方 SDK 的方法。它内部做了三件事:发 HTTP 请求、解析响应、写缓存。前两步失败会返回错误,第三步失败会被它自己吞掉——那段代码大致是这样(反编译看到的,不完全是原文):
try {
$this->cache->set($key, $value, 300);
} catch (\Throwable $e) {
// 缓存失败不影响主流程
}
注释写得很有道理。但问题是这个"缓存"其实是业务状态的一部分,不是真的缓存。写不进去,下游就认为这一步没执行过。
如果这段代码是 Go 写的,那它必须显式写一个 _ = cache.Set(...) 或者处理错误。那个空白的下划线会让人在 code review 时多问一句"这里为什么忽略"。
PHP 里 catch { } 什么都不做太自然了,自然到没人会注意到。
显式处理带来的三个实际好处
一、错误路径和正常路径写在一起。
PHP 里异常可能从任意一行冒出来,看代码的时候得在脑子里模拟"这行如果抛了会到哪"。Go 里错误就在那行下面,逃不掉。
if err := db.Save(order); err != nil {
return fmt.Errorf("保存订单 %s: %w", order.ID, err)
}
if err := mq.Publish(evt); err != nil {
// 消息发不出去,但订单已经存了
// 这时候要不要回滚?这个决策在 Go 里必须当场做
return fmt.Errorf("订单 %s 已保存但消息投递失败: %w", order.ID, err)
}
第二段这种部分成功的情况,是最容易出 bug 的地方。try-catch 的写法很容易把两件事包在一个 try 里,出事了整段回滚,反而把已经成功的操作也带走了。
二、错误信息可以带上上下文。
if err != nil {
return fmt.Errorf("处理用户 %d 的订单 %s: %w", uid, oid, err)
}
这样一路包上去,最后日志里是完整路径:同步任务: 处理用户 123 的订单 A456: 连接数据库超时。
比异常栈好读,因为它只说业务上下文,不掺十几层框架内部调用。
三、errors.Is 和 errors.As 让上层能精确判断。
if errors.Is(err, os.ErrNotExist) {
return defaultConfig, nil // 配置不存在就用默认值,不算错
}
PHP 里判断异常类型得 catch (FileNotFoundException $e),而实际抛出来的往往是包装过的 RuntimeException,猜不准。
但确实有些地方还是烦
错误包装容易套娃。 每层都 fmt.Errorf("xxx: %w", err),最后消息变成"调用A: 调用B: 调用C: 调用D: 超时"。得有点纪律,只在能提供新信息的层包装。
重复的检查代码没法消掉。 有人写过用泛型封装的方案:
func Must[T any](v T, err error) T {
if err != nil { panic(err) }
return v
}
只适合初始化和测试,业务里用会失去错误处理的意义。
没有错误堆栈。 得自己引 github.com/pkg/errors 或者用 runtime/debug.Stack()。Go 官方在这件事上态度一直很明确:不打算加。
小结
我的结论是:Go 的 error 处理把成本从"写代码时"挪到了"读代码和排查故障时"。
写的时候确实更累,但线上出问题的概率小了很多,出了也更好查。对于要长期维护的服务,这个交换我觉得划算。
当然如果是写个一次性的脚本处理数据,那确实烦。