委屈
Kitex 作为一款性能优秀的 RPC 框架,它在各业务中任劳任怨的同时,也不可避免地频繁出没在 panic 的 stack trace 里,因此承受了一些它这个年龄本不该承受的质疑和压力。
为洗 Kitex 的不白之冤,下面介绍两个典型案例。
Case 1: Client Panic
业务同学遇到 panic 监控报警,查日志中发现:
[KITEX: panic, to_service=XXX, to_method=XXX, error=runtime error: invalid memory address or nil pointer dereference
再看 panic 的 stack stace,最内层的两条是:
kitex/pkg/protocol/bthrift.binaryProtocol.WriteBinaryNocopy(...) kitex_gen/XXXRequest.fastWriteField2(...)
好家伙,panic 发生在框架对 Request 的编码过程中了,stack trace 里完全没有业务代码,可不是就该提个 kitex oncall 吗?
—— 实际上,这类 case 的原因通常是业务代码中的并发读写,破坏了 Request 中引用的某个对象,只是错误暴露在数据的编码过程中。
听完 oncall 同学耐心的狡辩后,业务研发同学表示非常理解,只不过自查代码的结论是:没有发现并发问题。
没办法,心(zheng)地(zai)善(mo)良(yu)的 oncall 同学找要来了构造 XXXRequest 的相关代码,吭哧吭哧看了起来。
这里略作简化如下:
u := &User{}
for _, name := range names {
go func() {
defer wg.Done()
if u.GetActID() == 0 {
u.SetActID(GetActIDFromSomewhere())
}
client.GetXXX(ctx, &XXXRequest{
ActID: u.GetActID(),
})
}()
}
虽然看起来 User.ActID 字段是在同一个 goroutine 里顺序执行的,但因为是 User 对象是在循环前分配的,会在循环中被不同的 goroutine 读/写,可能会将一个无效的 string 对象赋值给 XXXRequest.ActID,从而导致在框架 thrift 编码过程中 panic。
关于并发是如何导致 string 读取时 panic 的,可以参考这个故事「踩坑记:Go服务灵异panic」,这里就不展开了。
针对这个 case,我们给业务同学的建议是,在启动 goroutine 之前,先创建一份 User 对象的副本,不同 goroutine 之间就不会冲突了。
Case 2: Server Panic
接下来压力来到 Server 这边,另一个业务同学遇到的错误信息如下:
KITEX: panic happened, ..., error=<Error: runtime error: index out of range [3] with length 1>
panic 的 stack trace 头两条是:
kitex/pkg/protocol/bthrift.binaryProtocol.WriteI32(...) kitex_gen/.../XXXResponse.fastWriteField3(...)
于是我们又收到一个 oncall 。
听完 oncall 同学耐心的狡辩后,业务同学用 go build -race 重编了 server,在测试环境用 20 个 goroutine 并发请求,并没有复现该 case。
没办法,心(you)宽(zai)体(mo)胖(yu)的 oncall 同学再次要来了业务代码,吭哧吭哧看了起来。
这里略作简化如下:
func (h *xxxHandler) listReasons() {
reasons := cache.GetAllReasons(h.ctx)
for _, reason := range Reasons {
reason = shallowCopy(reason)
client.GetReasonText(h.ctx, reason)
for _, status := reason.FilterStatus {
reason.MainStatus = append(reason.MainStatus, status)
}
}
}
func shallowCopy(s *Reason) *Reason {
temp := *s
return &temp
}
虽然看起来是在同一个 goroutine 里对 reason 顺序执行读和写的,但是 reason 是从缓存中取出的、只做了个浅拷贝,因此可能会在不同的请求中被不同的 goroutine 读/写、导致 reason.MainStatus 字段被破坏。
针对这个 case ,最简单的方案是改成使用深拷贝,这确实能解决并发读写的问题;不过业务语义是要更新缓存,所以可能需要在 reason 中加上读写锁,才能满足业务需求。
洗白
上面两个 case 的共性是,panic stack trace 中都没有业务代码,直接导致 panic 的是 kitex 生成的 fastWriteFieldX 方法,于是 kitex 首当其冲;而从上面的 case 分析中可以看出,确实是业务代码破坏了数据的一致性,只是问题的暴露时刻延后到了编解码的过程中。
需要警惕的是,有些请求虽然没有出现 panic,但是可能已经导致内存中出现不一致的业务数据,甚至可能会通过 api call 传递给上下游。
可惜 Golang 本身的能力所限,针对这类问题目前也没有银弹,真遇到问题时只能 go -race 尝试复现,以及硬着头皮看代码;更多地还得大家在日常的开发、测试环节加强代码的质量。(wudi大佬:Rust!我是不会再回去写 go 了)
最后打个小广告,欢迎大家关注 CloudWeGo (同名微信公众号)
CloudWeGo 是一套可快速构建企业级云原生微服务架构的中间件集合。 它包含许多组件:Golang RPC 框架 Kitex,HTTP 框架 Hertz,Rust RPC 框架 Volo,网络库 Netpoll,Go 语言 Thrift 编译器 Thriftgo 等等。 通过结合社区优秀的开源产品和生态,可以快速搭建一套完善的云原生微服务体系。
(完)