最近在重构一个内部的文件同步服务,用 Go 写的。这个服务主要拉取远端对象存储的文件,校验之后落本地磁盘,跑了挺久,大部分时候稳定。前几天运维反馈,日志里打印成功,但部分文件实际没写完,数量不多,属于偶现,本地很难复现。
一开始看主流程,代码看着没啥问题,每一步都有 err 判断,失败就 return。跟着把日志打全,一步步追,才发现是错误变量覆盖的老问题,只是这次藏得比较隐蔽。
先看当时简化出来的那段有问题的代码:
var err error
buf := make([]byte, 4096)
for {
n, err := reader.Read(buf)
if err != nil {
break
}
_, err = writer.Write(buf[:n])
if err != nil {
break
}
}
if err != nil && err != io.EOF {
log.Printf("同步失败: %v", err)
return err
}
log.Println("文件同步完成")
第一眼扫过去,好像没问题。Read 和 Write 都接收 err,循环里出错就跳出。但这里的问题在于,循环内部 n, err := reader.Read(buf) 用的是短变量声明,会在循环作用域新建一个局部 err,外层定义的那个 err 根本不会被赋值。
循环正常读到末尾返回 io.EOF,break 跳出,外层 err 一直是 nil。哪怕中间 Write 返回了错误,跳出循环之后,外层拿到的 err 依然是空,最后就会打印同步成功。
这个坑不算新鲜,但写代码的时候很容易顺手就写成这样,尤其是循环嵌套、代码行数一多,注意力放在业务逻辑上,忽略变量作用域。
改法也简单,循环里面不要再用 :=,直接赋值:
n, err = reader.Read(buf)
本以为修好这一处就完事,没过几天又碰到另一种覆盖场景,是 defer 里面的。
业务里要关闭读写流,同时要返回错误,代码大概是这样:
func copyFile(r io.Reader, w *os.File) error {
var err error
defer func() {
closeErr := w.Close()
if closeErr != nil {
err = closeErr
}
}()
_, err = io.Copy(w, r)
return err
}
io.Copy 如果返回了一个非空 err,defer 执行的时候 Close 又正常无错,err 会被直接覆盖成 nil。上游拿到 nil,以为成功,但是 Copy 阶段其实已经失败了。
这种场景比上面那个更隐蔽,关闭文件的错误很多人不太重视,甚至觉得 Close 大概率不会报错,直接无脑覆盖主流程的错误。
我们这边的处理策略是,优先保留主流程的错误,只有主错误是 nil 的时候,才把 close 的错误赋值进去:
defer func() {
closeErr := w.Close()
if err == nil {
err = closeErr
}
}()
如果需要同时保留两个错误,项目里引入了 errors.Join,把拷贝错误和关闭错误合并返回,上游可以按需判断。
还有一种我之前踩过,是多阶段处理,重复复用 err 变量。比如先拉元信息,再下载,再校验哈希:
info, err := getMeta()
if err != nil {
return err
}
data, err := download(info)
if err != nil {
return err
}
hash, err := calcHash(data)
if err != nil {
return err
}
这段本身语法没问题,属于正常复用。但如果中间某个阶段的错误没有及时 return,继续往下跑,后面的 err 会直接冲掉前面的。之前有个同事写的时候,把 if 判断注释调试忘了恢复,前面接口报错,代码继续往下执行,err 被后面成功调用覆盖,错误直接丢失。
顺带提一个容易混淆的点:很多新手会把 error 当成普通值,习惯用 err != nil 判断一次之后,继续往下复用。error 一旦赋值,不会自己清空,但是被新的赋值语句覆盖之后,旧错误就丢了,Go 的 error 本身只是一个接口变量,没有堆栈追踪的魔法。
写久了 Go 之后,我自己慢慢形成几个小习惯,用来减少这类问题:
- 同一个作用域,尽量不要混用 := 和 = 给同一个 err 赋值;
- 循环内部读取、写入的时候,优先复用外层 err,不要短声明;
- defer 里面的资源关闭错误,不要无条件覆盖原有 err;
- 多步骤失败时,一旦拿到 err,尽快 return,不要继续执行后续逻辑;
- 关键链路的错误,不要只打印,要向上透传,让调用方有机会感知。
还有,go vet 其实可以抓到一部分变量遮蔽的问题,但是不是全部。像 defer 里面覆盖 err 这种,vet 检查不出来,只能靠 code review 和自己留意。我现在提交代码前,都会多看一眼 err 流动的路径,看看旧错误会不会悄无声息被冲掉。
很多 Go 的错误处理争议都在 "要不要每一行都判断 err",但比起漏判 err,err 被悄悄覆盖是更难受的一类问题:日志看着一切正常,业务数据却已经异常,排查的时候连报错信息都没有。