洗冤记:委屈的 Kitex

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

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

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

(完)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值