本文尝试用最清晰的描述回答以下四个问题,当我们需要处理错误的时候:
- 什么时候选择 error
- 什么时候选择 log
- 什么时候选择 metrics
- 什么时候选择 panic
以及为什么做这样的选择。
用户分析
处理错误的关键是,谁来处理?
- 对于你的用户而言:他们可能是一段代码,把你的代码做为lib来使用。他们可能是最终的用户,使用你的代码做为日常工具。
- 对于你的同事(比如说运维)而言:他们负责喊你来修bug。
- 对于你而言:你负责这段代码的正确运行,以及修复bug。
对于这三类人群,他们的诉求是完全不同的。错误处理的艺术就是同时让这三类人群满意。所以我们需要先对每个人群的诉求进行深入分析
你的用户
他们的目的是使用你的代码。当你的代码无法满足需求的时候,你得清晰地告诉他们。hey,这个时候你无法使用我了,因为我搞砸了。比如说
func ValidatePassport(passport string) (UserId, error)
这里返回的 error 就是用来给你的用户传递这个信息的。但是做为用户是需要推动问题解决的,你不能仅仅告诉他们我搞砸了,比如
func ValidatePassport(passport string) (UserId, bool)
如果你仅仅返回了 true/false,用户是无法知道这是一个问题。也不知道该怎么办。比如是否应该给你的公司打电话。用户希望得到的信息是这么几种
- 当用户是一个真人的时候,他们只会把错误分成两类:
- 噢,网络错误。那不用管了。等会再回来试试吧。
- 未知错误。呀,这个不是重试能解决的。我得找人。这个错误消息得足够让我找到人,并且他能接下来跟进。比如得有个订单号什么的东西。
- 当用户是一段程序得时候:我可以对指定的几种错误,进行针对性的处理。比如你告诉我文件不存在,那我可以创建一个新文件。
所以,error是给用户看的。不要把给你自己看的东西打到这个里面,比如stacktrace。太长的错误信息会让用户迷惑。error是一个完整的错误报告的索引,人们可以根据error里的内容按图索骥,很快还原现场。
那么panic是什么呢?当你的接口契约上没有error,但是又出错了的时候,怎么办?这个时候panic。panic意味着,给你的用户说,你不用管错误,错了算我的。使用panic,免除了用户对你出问题时进行容错的责任。panic是抛给自己看的。
你的同事
你们是一个团队,共同负责一个很大的平台。但是每个人都有自己的分工,负责自己的一块事情。当平台挂了,整个团队需要很快找到谁应该来负责,喊这个人来修bug。你可能就是那个需要来修bug的人。你需要提供足够的信息,让喊你来修bug的同事的工作更轻松。一般一个组织里,专门负责喊人来修bug的角色叫运维。
需要立即喊人来修bug的情况大多数就这两个场景
- A 调用 B,最近一段时间的错误率是否飙升
- A 调用 B,最近一段时间的延迟是否飙升
当这两个条件被满足的时候。A和B都会被喊来看问题。当A是最终用户的时候,则只有B会被喊来看问题,比如客户端团队。当B是外部团队的时候,则只有A会被喊来看问题,比如网络供应商出问题了。
所以,我们总共需要三个信息,以及若干附加信息
- 主叫方:发起调用的功能点名称。大粒度的是模块,小粒度的是函数。
- 被叫方:被调用的功能点的名称。
- 是否出错:出错了还是没出错。错误码可选。
- 延迟:调用耗时。可选。
- timestamp:可选。默认为当前时间
- 其他信息:上下文
countlog.Info("metric",
"caller", "ValidatePassport",
"callee", "Redis",
"err", errors.New("some error"),
"latency", time.Second * 1,
"traceId", traceId
)
一条metric就是一条log。metric本身比error log打印得都更全。比如调用正常的情况下,metirc也会被上报的,而不仅仅只是在出错的情况下。所以 metric 上报是可以替代 error log的。
当然metric不会被直接打给你的同事们看。metric的信息要被多级聚合之后,成为measurement,绘制成曲线,变成告警才是有价值的信息。这些一般有运维的监控平台来负责。
所以,metric是给同事们看的。要在所有可能出错的地方都加上标准化的metric。metric至少要说明谁在调用谁,出错了没有。通过metric构成了一个职责链,按照这个链条,我们可以找到负责的人。比如api出错了,发现是调passport引起的,passport出错了发现是调redis引起的。有这样一个链条,可以快速地让redis团队介入问题处理,避免了逐级踢皮球。
你自己
用户通过error找到你的同事,你的同事通过metric找到了你。到你这之后,皮球就没法踢给别人了。你得修复问题。
如果幸运得话,error里的错误消息,加上同事提供的metric,你就能够猜到问题是怎么产生的了。但是不幸的话,你需要更多的信息。
countlog.Debug("file rotated",
"traceId", traceId,
"file", file
)
这种普通地日志,就是打给我们自己看的。他的作用就是当问题发生了之后,可以根据一部分的日志维度,找到关联的其他日志做为上下文,重建出故障现场。在这个例子里,traceId就是关联用的维度,file就是故障现场的上下文。
错误处理方式的选择
按照前面的分析,我们知道了四种工具的分类:
- error:给使用者的错误消息
- metric:一种特殊的日志。统计之后给同事看的。
- log/panic:辅助自己定位bug用的
然后有以下规则
- 所有你自己会出错,或者你的依赖方会出错的场景。你都需要在接口上返回error
- 一般来说只有纯内存的操作才存在无error的api接口。但凡牵涉了磁盘或者网络,都要返回error。
- 纯内存操作时遇到了无法解释的情况,比如数组越界。只能panic。因为接口上不返回error,但还是必须要报错。
- 如果你不能提供更多的错误消息,直接返回底层依赖的error。
- 如果底层依赖的error,在你的场景里时含糊的。比如json解析失败。你有责任提供更明确的错误消息做为error返回,不能直接返回底层依赖的error。毕竟error是一个索引,如果用户不能按图索骥找到解决问题的人,这个error是无效的。
- error的错误消息尽量包含一些编号字段,无论是trace id还是订单号。方便场景还原。
- 但凡调用了会返回error的函数,都需要打印metric日志。提供caller/callee/error。不仅仅是在出错的时候才打印错误日志,正确的时候也要打metric。什么时候输出到文件和控制台,这个是metric/log框架要考虑的事情。
- 除了metric之外的日志都是给自己看的。要尽可能的用结构化的方式提供更多的上下文信息。
- 所有的goroutine都必须在最上层处理panic。防止panic引起程序crash。因为进程重启需要花时间。fail fast 不用这么极端。能够把错误日志告警报出来就行了。
- 所有的panic都要写入fatal级别的log,并且提供stacktrace
- 每次临时添加fmt.Println用完又删掉,都是给你的日志基础设施提的一个bug。日志好使的话,为什么需要fmt.Println。
日志级别的选择
现有的日志框架在日志级别的处理上是不够灵活的。
countlog.Debug("metric",
"caller", "ValidatePassport",
"callee", "Redis",
"err", err,
"latency", time.Second * 1,
"traceId", traceId
)
比如我在这里使用了Debug的日志级别并不代表这个log本身一直是不重要的。当err是非nil的时候,它应该是ERROR级别,而不是DEBUG级别了。Debug仅仅是代表在正常的情况下,不要打印到log文件里。
countlog.Trace("file rotated",
"traceId", traceId,
"file", file
)
设置成了Trace级别之后。一般情况下就扔掉了。但是更好的行为是,如果这个附近没有错误,这个log就是无用的,可以扔掉。如果附近有错误,则应该把log留下来做为故障现场的一部分。现在的日志框架都是逐条处理的。丢弃就直接丢弃了。如果能够在请求级别进行缓存,则出错的时候,可以记录下大量的现场。以上两点是对现有日志框架的吐槽。然后我们应该如何选择日志级别:
- Trace:开发的时候定位问题用的。仅仅可能在开发的时候打开。日志的条数和请求数量n成正比,而且可能是n的数倍。开发的时候只在必要的情况下,才会打开。
- Debug:线上定位问题时用的。仅仅可能在线上短暂的打开。日志的条数和请求数量是一个级别。开发时候的默认日志级别。一个日志框架必须在没有任何初始化的时候也能打印出Debug以及之上的日志来。否则在单元测试之类的场景就会很蛋疼。log4j就很蛋疼。对于日志框架来说,没有做任何初始化,就应该自己现在跑在开发者的IDE里。假设日志级别是Debug,输出是stderr,格式应该是人类可读的格式,是最合理的默认值。
- Info:正常的线上日志级别。日志的条数要远小于请求数量。只在特殊事件发生时(系统状态发生迁移的时候)才打印出来。
- Warn:可预见的错误,但是责任不在我。本质上也是Info。
- Error:可以预见的错误发生了的时候使用。所有的metric日志,在error不是nil的时候,日志级别要自动变成Error。
- Fatal:不可遇见的错误发生了的时候。也就是panic日志。
标准的错误处理案例
最naive的例子
func doX(ctx context.Context) error {
file, err := os.OpenFile("/tmp/my-dir/abc", os.O_RDWR, 0666)
if err != nil {
return err
}
defer file.Close()
_, err = file.Write([]byte("hello"))
if err != nil {
return err
}
return nil
}
每次写 if err != nil { return err } 的时候都充满了负罪感。总觉得应该再多做一点什么。我们第一个golang项目上线之后,大家都震惊了。这json.Unmarshal错误到底是哪里报的啊。然后一行一行的猜。然后无比怀念java的异常堆栈。
第二次的选择是,给错误加上一些“特征”。让错误容易定位到代码行:
func doA(ctx context.Context) error {
file, err := os.OpenFile("/tmp/my-dir/abc", os.O_RDWR, 0666)
if err != nil {
return fmt.Errorf("failed to open file: %v", err)
}
defer file.Close()
_, err = file.Write([]byte("hello"))
if err != nil {
return fmt.Errorf("failed to write: %v", err)
}
return nil
}
如果要更高级一些,可以把 fmt.Errorf 再封装一下。把行号之类的也给加进去。
但是这样做的问题是每一层都叠加自己的信息到error上。最后这个error就和java的异常堆栈log一样长了。如果你是library,你的用户用了你的api,看到这样的error会非常懵逼的。
一个优化策略就是只对最底层的错误封装一次。上面的报错就直接透传了。问题是,要记得哪些错误是最底层错误,哪些是已经封装过的,这个并不容易。按照我们前面的分析。error是给最终用户看的。所以除非可以添加一些信息把error讲得更容易让用户理解,否则不要把自己用的定位信息都加到error里。这里的os.OpenFile的报错已经包含了path了。完全特别大的再次封装的必要了。所以应该正确的做法是加日志。
func doY(ctx context.Context) error {
defer func() {
recovered := recover()
if recovered != nil {
countlog.Fatal("event!doY.panic",
"err", recovered,
"stacktrace", countlog.ProvideStacktrace)
}
}()
start := time.Now()
file, err := os.OpenFile("/tmp/my-dir/abc", os.O_RDWR, 0666)
if err != nil {
countlog.Error("event!metric",
"callee", "ioutil.WriteFile", "ctx", ctx, "latency", time.Since(start))
countlog.Error("event!doY.failed to open file", "err", err)
return err
}
countlog.Trace("event!metric",
"callee", "ioutil.WriteFile", "ctx", ctx, "latency", time.Since(start))
defer func() {
err = file.Close()
if err != nil {
countlog.Error("event!doY.failed to close file", "err", err)
}
}()
_, err = file.Write([]byte("hello"))
if err != nil {
return err
}
return nil
}
在本来已经很冗长的go错误处理代码上加完善的日志是很难读的。
func doZ(ctx countlog.Context) error {
defer countlog.LogPanic(recover()) // only necessary for goroutine
file, err := os.OpenFile("/tmp/abc", os.O_RDWR, 0666)
ctx.TraceMetric("callee!os.OpenFile", err)
if err != nil {
return err
}
defer countlog.Close(file)
_, err = file.Write([]byte("hello"))
ctx.TraceMetric("callee!file.Write", err)
if err != nil {
return err
}
return nil
}
这个是最终使用countlog精简之后的日志代码:v2pro/plz