洗冤记:委屈的 Kitex

本文分析了 Kitex 在作为 RPC 框架时遇到的并发问题,导致的 Client Panic 和 Server Panic 两种情况。案例揭示了并发读写可能导致的内存错误在框架编码过程中暴露,提出了解决方案,并强调了日常开发中加强代码质量的重要性。CloudWeGo 项目也被提及,提供了一系列用于构建云原生微服务的中间件。

6e1fe725aaeb43da98360f56c8c97402.png

委屈

Kitex 作为一款性能优秀的 RPC 框架,它在各业务中任劳任怨的同时,也不可避免地频繁出没在 panic 的 stack trace 里,因此承受了一些它这个年龄本不该承受的质疑和压力。

389bfa7f5d2148929fd1b6e2282d2ab5.png

为洗 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 同学耐心的狡辩后,业务研发同学表示非常理解,只不过自查代码的结论是:没有发现并发问题。

86f752a148eda3513d5530a2aec4f54c.png

没办法,心(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。

f560639fa9692a830be285c90fa34829.png

关于并发是如何导致 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。

e96d7e77039b82f572dac371a05329cd.png

没办法,心(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 字段被破坏。

3ac2f7fe66ff00ef1e87c29ade9d9b7f.png

针对这个 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 等等。 通过结合社区优秀的开源产品和生态,可以快速搭建一套完善的云原生微服务体系。

(完)

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值