我追逐一个线上问题的幽灵,直到淹没在迷惑的日志森林里。我在脑海中苦苦思索,身后一声惊奇的鸦叫,我猛然发现当作指南针的 Sentry 报告,才是真正的迷惑来源……
发现问题
我们项目中使用 Sentry 来捕获错误,顺便会附带一些上下文,比如用户 ID、请求链路、版本环境……这些上下文在排查时非常有用,然而最近却误导了我,让我在错误的方向上花了不少时间,回过头来才发现上下文是有误的。
具体来说,原本是用户 A 触发了某个错误,Sentry 上报错误的上下文里却显示是用户 B 触发的。我进一步发现,这种 Sentry 误报并不是稳定出现,而是随机出现在项目几乎所有种类的错误报告里。出于直觉,我认为这很可能是一个并发冲突问题:比如 Sentry 在报告上下文时出现了并发冲突。
实验模拟
下面是一段最简单的实验代码:我们启动了两个并发的协程、分别不断地上报自己的错误,然后观察两个协程的错误上下文会不会互相干扰。
|
|
很快我们就会发现,在 Sentry 上报的错误中,已经随机出现了错误上下文混乱的情况。比如在下图,在代码中上报错误 test-sentry-2
附带了 {Username: "2"}
的上下文信息,但 Sentry 实际却上报了 {Username: "1"}
的上下文。很明显这个错误的上下文被其他并发协程影响了。
抓个正着!看来 Sentry 报告上下文时确实出现了并发冲突(至少目前看来如此……)。
理论原因
查看 sentry-go 的源码,发现实现代码里常常出现这两个结构体 Scope
和 Hub
。
- Scope:即上下文,存放为错误追加的额外信息,比如用户 ID、请求 ID、请求路径、附加标签……
- Hub:也许可以叫“捕获器”,内部维护了一个上下文堆栈。
- Hub.stack:一个堆栈,由多层 layer 组成
- Hub.stack[0].client:每一层包含服务连接实例
- Hub.stack[0].scope:每一层也可能包含当前上下文
当我们上报错误时,每次执行 sentry.CaptureException(err)
,实际上是从当前 hub 中获取最新的上下文(一般位于 stack 顶层,若当前层没有则查找下一层),然后与错误一起通过服务连接上报。
而添加/删除上下文时,可以用 hub.PushScope()
或 hub.PopScope()
方法,即为 hub 的内部堆栈压入一个包含上下文信息的新堆栈层,或者弹出不再需要的层。我们一般用 WithScope
方法来附加一些临时的上下文。这个方法的原理是执行传入函数,在传入函数执行前自动 push,在执行后自动 pop。
|
|
其中 WithScope
的源码实现:
|
|
通过阅读源码,很明显 WithScope
这个方法在并发下不安全。如果一个协程用 WithScope
刚刚压入新层、正在编辑上下文,然后另一个协程也压入了新层,那么就会出现并发冲突,比如污染其他协程的上下文、或者用其他协程的上下文来上报自己的错误!
设计如此?
难道要怪 Sentry 客户端库存在漏洞?不,设计如此,或者更像是迫不得已。
Sentry 基于 Scope Stack 的设计可以很方便地继承和拓展上下文,尤其在程序不同层级之间很有用。因为 Sentry 不是只面向 Golang 这门语言,还需要支持 Node.js、Python、PHP、C#……所以 Sentry 必须要在所有语言中尽可能做到接口统一,像 push
、pop
、withScope
这类操作都是统一提供的。然而在 Golang 中,要让 WithScope
方法做到全局并发安全几乎不太现实。因为如果加锁的话,WithScope
的错误上报只能串行执行了,严重影响了上报性能。
规避方法
既然全局并发安全做不到,那么局部并发安全还是可以有的。只要每个协程都有一个自己专属的上下文堆栈,那么就不用担心互相污染的问题。为此 Sentry 专门提供了 hub.Clone()
方法。
|
|
Clone()
方法会引用当前 hub 的底层服务连接,并复制一份当前的上下文堆栈的拷贝,然后生成一个新的 hub 实例。因为复用了底层服务连接,很明显 hub 克隆的成本很低,可以随用随抛。因此在实践中,不管是否存在并发,上报错误时都最好克隆一下 hub 实例。
这个方法可以完美地规避并发冲突问题,nice~