2022春节红包后台 百万qps 探索优化之路
项目介绍
每年春节期间QQ会有红包活动。活动期间手Q消息tab会出现吊坠,点击进入到红包活动首页,可参与抽奖(收集星星)、留言、分享、小游戏等互动,并负责展示二级活动入口(如下图所示)。
首页(除抽奖逻辑)模块目标预估峰值67WQPS,其中首页命令字预估40WQPS。 服务使用trpc-go实现。
相关工具
go生态提供了大量的工具来诊断Go程序中的逻辑和性能问题。
-
Profiling
-
Tracing
-
Debugging
-
Runtime statistics and event
详见Diagnostics,本文绝大部分问题使用Profiling发现,部分使用了Tracing工具。
trpc-go开启admin服务即可开启profile,官方同样提供了火焰图代理服务,很方便做性能分析。详见tRPC-Go管理命令
性能优化
业务逻辑上的优化效果理论会更好,但不具备普适性。比如
-
需求简化、逻辑梳理优化,合并请求
-
高峰期开启柔性等
以下数据说明:压测基于8核16G CVM机器,绝对值&整体占比依赖具体业务逻辑,不具备参考价值,重点关注单个优化点前后数据的百分比提升。
trpc-go redis库调整
背景
春节红包存储主使用云上集群版redis,通过北极星连接。trpc-go database下redis库目前有两种封装,redigo 版本和go-redis版本。前期压测过两个库,redigo封装CPU占用约少10%(3950QPS CPU 5.23% VS 4.7%),性能上没有本质区别,考虑到go-redis的参数友好性并额外提供了cas相关的封装, 前期选择使用go-redis库 。go-redis trpc-go封装基于go-redis client提供的Hook接口:BeforeProcess 和 AfterProcess接口实现,通过新起goroutine和channel通知机制,将trpc-go filter的一个函数拆分到BeforeProcess,AfterProcess执行。具体原理参考trpc 生态方案集成到 go-redis,
问题
后期春节服务所有接口混合集群压测,007主调监控发现失败聚集在redis,监控见
服务主机CPU使用率在68%的情况下,redis全部超时基本不可用。
分析
首先怀疑云redis性能瓶颈。查看云redis监控数据,平均延迟、机器负载无异常。另外单机接口混合压测可复现redis超时问题,基本排除了云redis问题。怀疑重点放到client端。
使用tracing工具分析服务整个调用生命周期的延迟。经分析发现GC延迟和goroutine调度延迟确实较大,理论上应影响所有逻辑:rpc主调、命令字返回延迟等。但失败汇聚到主调redis,比较奇怪。
这里调试分析了下go-redis的封装代码,封装原理可参考trpc生态引入第三方库最佳实践。代码见joinfilters。 joinfilters的事件回调模型会触发额外次数的goroutine调度 :新起goroutine、若干次channel操作,另外 go-redis内部网络操作也是新起goroutine执行 。
超时直接原因是AfterProcess后,会重新判断ctx.Done,导致超时。go-redis内部其实已判断过ctx.Done,io当时并没有超时,经过若干次goroutine调度后,joinfilter AfterProcess再次判断ctx.Done时超时,严重放大了超时比例。
redis的超时应该是对应的网络超时,在BeforeProcess判断即可,避免已全链路超时导致无效请求,资源浪费,不应该在AfterProcess再次判断redis自身的ctx.Done(自身超时,非全链路),命令字整体满足全链路超时要求即可 。况且额外goroutine调度是自身封装引入。关于超时判断和作者沟通,有些冲突,暂不接受变更。解决方法
go-redis的封装会引入额外相应的goroutine调度成本,AfterProcess的ctx.Done判断理念有歧义。继续使用可选择服务整体优化减少GC和调大timeout,但不太想为redis设置过大timeout,服务整体优化暂无头绪。
新服务的切换成本不高,综合考虑这里选择 切换到redigo的封装 ,单次redis请求直接顺序执行,减少了两次新增goroutine和3次的channel操作带来的调度开销。 相同QPS与配置下redis不再超时。
随机数优化
背景
用户一定概率下出现财神,其他用户可抢,每小时刷新一次。 每个用户每小时需要一个随机数 来保证财神整体上概率。方案上计划实时计算随机值,避免提前计算引入额外的服务和存储,相同种子可保证当前时间段的随机数不变,这里种子为时间戳(按小时取整)+uin。
示例代码如下:func IsMammon(uin int64) bool {now := time.Now()begin := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())r := rand.NewSource(begin.Unix()+uin).Int63() % 100if r < 20 {return true}return false}
分析
rand.NewSource生成新的rand对象,避免了
rand.Int()
的全局锁带来的性能隐患,但性能损耗远超预期,
CPU占比超37%
。
分析代码发现,发现设置种子逻辑复杂,会循环计算来初始化数组,核心逻辑如下:
func (rng *rngSource) Seed(seed int64) {***x := int32(seed)for i := -20; i < rngLen; i++ {x = seedrand(x)if i >= 0 {var u int64u = int64(x) << 40x = seedrand(x)u ^= int64(x) << 20x = seedrand(x)u ^= int64(x)u ^= rngCooked[i]rng.vec[i] = u}}}
rngLen
默认值607,
从性能上考虑,业务逻辑中不允许频繁设置种子
,误用了标准库接口
解决方法
解决方法有两个思路
-
池化rand对象,避免重复创建
-
优化(简化)随机数算法
示例代码如下:
// 高性能版随机数func rand(seed uint64) uint64 {// 线性同余算法,参数值参考 https://code.woboq.org/userspace/glibc/stdlib/random_r.c.html#364return (seed*1103515245 + 12345) & 0x7fffffff}
模100循环运行10000统计结果,0-99基本可保证等概率,满足了产品需求。 优化后一条公式计算CPU损耗基本可忽略 。 不过在可穷举的情况下,推荐池化rand对象,优先使用标准库能力。
数据汇总
| 数据说明 | 优化前 | 优 化后 | 提升 |
|---|---|---|---|
| IsMammon CPU占比 |
37.4%
|
0.13% | 287倍 |
背景 pb转json库调整
春节红包后台与前端通过uni sso通道交互,具体可参考统一SSO通道说明文档。所有命令字返回协议统一为
message UniSsoServerRsp{int64 ret = 1;string errmsg = 2;UniSsoServerRspComm comm = 3;bytes rspdata = 4; //回包内容}
rspdata为客户端透传字段,承载实际返回值。
春节红包后台使用pb定义协议,和前端使用json交互,protobuf有提供相应序列化接口,返回前序列化,然后主动设置rspdata值
打包代码示例:
`github.com/golang/protobuf/jsonpb`m := jsonpb.Marshaler{}strRsp, err := m.MarshalToString(pageRsp)
其中
使用的jsonpb默认参数
,其中
pageRsp
是proto定义message生成的对应golang struct实例
原始火焰图
分析
返回时序列化 CPU占比接近25% 。其中checkRequiredFields 6.5%,实际的jsonpb.marshalObject 19%,远超预期。默认的jsonpb参数性能成本过高,其中checkRequiredFields校验逻辑非必要,可删除。 查阅trpc-go框架代码,发现有提供 jsonpb.Marshaler 对象,参数如下var Marshaler = jsonpb.Marshaler{EmitDefaults: true, OrigName: true, EnumsAsInts: true}
调整了部分默认参数
:
·
OrigName
、
EnumsAsInts
等。
切换框架Marshaler后,火焰图如下
jsonpb基于标准json库二次开发,提供pb结构体的序列化能力。众所周知,标准json库使用反射实现,性能较低。trpc-go默认json库选择高性能json-iterator。jsonpb是否可以切换json库以提高性能?有人提过issue ,但官方没有接受
Thejsonpbis fairly non-performant. In some cases it even does a unmarshal->marshal->unmarshal loop through the standard library to do its work. Switching to json-iterator is just a stop gap to improve performance. Really, the entirejsonpblibrary needs to be refactored. It may be worth just writing the parsing and formatting logic ourselves. Most of the complexity of theencoding/jsonpackage comes from the fact that it is so reflection heavy, which we can actually mostly avoid injsonpbonce we have a legit protobuf reflection API.
从回复本身也可以看出jsonpb本身的性能表现同样不佳
解决方法
jsonpb库是否必须,当前服务是否用到jsonpb不可替代的能力?本质是pb生成的 go struct 序列化成json 。直接使用json-iterator是否可行?切换后,经前端联调验证可行。注意这里的变动是 不兼容修改 ,需要client调整
json-iterator火焰图
性能损耗降低至2.7% 考虑春节红包高并发、无后续新增需求,选择 切换成json-iterator 。为保证兼容性,非极端场景下并不建议当前优化方式(不清楚jsonpb不可替代的能力,切换是否带来后续不可维护的隐患,有了解的同学可以评论下),相对而言还是推荐使用框架jsonpb.Marshaler调整后的默认参数
数据汇总
| 数 据说明 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 调 整jsonpb默认参数 HomePageItemConvertCPU占比 | 25.9% | 12% | 2倍 |
| 切换json-iterator HomePageItemConvertCPU占比 | 25.9 % | 2.7% | 9.5倍 |
trpc-go memcache库优化
背景
服务严重依赖本地memcache缓存,数据优先读本地memcache缓存,未命中再读远端redis(当前策略也许不妥,暂不讨论)。使用trpc-go封装的memcache库
原始火焰图如下
分析
分析火焰图,发现 getConn性能损耗异常 。创建连接成本过高,猜测未使用连接池解决方法
阅读调试memcache库,验证了上面猜测。memcache开源库本身支持连接池,trpc-go的额外封装有问题导致未生效。
修复连接池代码已合并到master ,并额外支持连接池参数可配置。有兴趣可查看相关代码,未使用最新版本建议 更新到最新的master版本
优化后火焰图
创建连接损耗基本可忽略
数据汇总
| 数据说明 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| memcache G et CPU占比 | 32.3% | 14.4% | 2.2倍 |
背景
核心逻辑依赖各种udp服务:oidb、bitmap等,单次请求会放大很多倍。使用的trpc-go标准的transport
原始火焰图如下
分析
分析火焰图,发现以下两个可疑点损耗较大
-
runtime.makeslice
分析代码,发现每次发包之前会新建byte slice。这里应可池化
buf := packetbuffer.New(make([]byte, defaultUDPRecvBufSize))
- net.Dial损耗较大
udp实例复用,避免不必要的Dial成本,类似连接池概念。参考几行代码为老板省百万-某服务GOGC及UDP Pool优化思路,具体原理可阅读上面文章
解决办法
详情可参考issue,框架测接受了优化点一,见代码,并额外提供了udp io多路复用的思路(设置
client.WithMultiplexed(true)
),不过udp io多路复用经测试需升级相关协议插件,成本较高。
基于框架udp transport,业务参考以上优化点
自实现了udp transpor
t。得益于框架的插件化设计,可通过
client.WithTransport(***)
替换默认的transport
,不过并不推荐当前做法,后续框架更新不方便同步。性能瓶颈下推荐优先使用
框架io多路复用
能力
优化后火焰图
数据汇总
| 数据说明 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| UDP RoundT rip CPU占比 | 25.2% | 20.1% | 25% |
最后
过早优化是万恶之源
没有量化的性能测试检测到真正的性能问题之前,在代码层面各种炫技式优化,不仅可能提升不了性能,反而更会导致更多 bugs。