Sentry 高并发时上报异常的问题排查

我追逐一个线上问题的幽灵,直到淹没在迷惑的日志森林里。我在脑海中苦苦思索,身后一声惊奇的鸦叫,我猛然发现当作指南针的 Sentry 报告,才是真正的迷惑来源……

发现问题

我们项目中使用 Sentry 来捕获错误,顺便会附带一些上下文,比如用户 ID、请求链路、版本环境……这些上下文在排查时非常有用,然而最近却误导了我,让我在错误的方向上花了不少时间,回过头来才发现上下文是有误的。

具体来说,原本是用户 A 触发了某个错误,Sentry 上报错误的上下文里却显示是用户 B 触发的。我进一步发现,这种 Sentry 误报并不是稳定出现,而是随机出现在项目几乎所有种类的错误报告里。出于直觉,我认为这很可能是一个并发冲突问题:比如 Sentry 在报告上下文时出现了并发冲突。

实验模拟

下面是一段最简单的实验代码:我们启动了两个并发的协程、分别不断地上报自己的错误,然后观察两个协程的错误上下文会不会互相干扰。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 协程一:不断上报错误 test-sentry-1,并附带上下文 {Username: "1"}
go func() {
	for {
		sentry.WithScope(func(scope *sentry.Scope) {
			scope.SetUser(sentry.User{Username: "1"})
			sentry.CaptureException(errors.New("test-sentry-1"))
		})
	}
}()
// 协程二:不断上报错误 test-sentry-2,并附带上下文 {Username: "2"}
go func() {
	for {
		sentry.WithScope(func(scope *sentry.Scope) {
			scope.SetUser(sentry.User{Username: "2"})
			sentry.CaptureException(errors.New("test-sentry-2"))
		})
	}
}()

很快我们就会发现,在 Sentry 上报的错误中,已经随机出现了错误上下文混乱的情况。比如在下图,在代码中上报错误 test-sentry-2 附带了 {Username: "2"} 的上下文信息,但 Sentry 实际却上报了 {Username: "1"} 的上下文。很明显这个错误的上下文被其他并发协程影响了。

抓个正着!看来 Sentry 报告上下文时确实出现了并发冲突(至少目前看来如此……)。

理论原因

查看 sentry-go 的源码,发现实现代码里常常出现这两个结构体 ScopeHub

  • 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。

1
2
3
4
5
// WithScope 方法的使用案例
sentry.WithScope(func(scope *sentry.Scope) {
	scope.SetUser(sentry.User{Username: "1"})
	sentry.CaptureException(errors.New("test-sentry-1"))
})

其中 WithScope 的源码实现:

1
2
3
4
5
6
// Sentry 的 WithScope 方法源码
func (hub *Hub) WithScope(f func(scope *Scope)) {
	scope := hub.PushScope()
	defer hub.PopScope()
	f(scope)
}

通过阅读源码,很明显 WithScope 这个方法在并发下不安全。如果一个协程用 WithScope 刚刚压入新层、正在编辑上下文,然后另一个协程也压入了新层,那么就会出现并发冲突,比如污染其他协程的上下文、或者用其他协程的上下文来上报自己的错误!

设计如此?

难道要怪 Sentry 客户端库存在漏洞?不,设计如此,或者更像是迫不得已。

Sentry 基于 Scope Stack 的设计可以很方便地继承和拓展上下文,尤其在程序不同层级之间很有用。因为 Sentry 不是只面向 Golang 这门语言,还需要支持 Node.js、Python、PHP、C#……所以 Sentry 必须要在所有语言中尽可能做到接口统一,像 pushpopwithScope 这类操作都是统一提供的。然而在 Golang 中,要让 WithScope 方法做到全局并发安全几乎不太现实。因为如果加锁的话,WithScope 的错误上报只能串行执行了,严重影响了上报性能。

规避方法

既然全局并发安全做不到,那么局部并发安全还是可以有的。只要每个协程都有一个自己专属的上下文堆栈,那么就不用担心互相污染的问题。为此 Sentry 专门提供了 hub.Clone() 方法。

1
2
3
4
5
6
7
go func () {
	hub := sentry.CurrentHub().Clone()         // 获得新 hub
	hub.WithScope(func(scope *sentry.Scope) {  // 为新 hub 添加上下文
		scope.SetTag("action", "produce")
		hub.CaptureException(err)              // 用新 hub 上报错误
	})
}()

Clone() 方法会引用当前 hub 的底层服务连接,并复制一份当前的上下文堆栈的拷贝,然后生成一个新的 hub 实例。因为复用了底层服务连接,很明显 hub 克隆的成本很低,可以随用随抛。因此在实践中,不管是否存在并发,上报错误时都最好克隆一下 hub 实例

这个方法可以完美地规避并发冲突问题,nice~

comments powered by Disqus