腾讯VATeam

2022春节红包后台 百万qps 探索优化之路

| 导语 本文总结2022春节红包首页服务代码层面优化的相关经验,服务使用trpc-go实现。

项目介绍

每年春节期间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() % 100   if 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 int64         u = int64(x) << 40         x = seedrand(x)         u ^= int64(x) << 20         x = seedrand(x)         u ^= int64(x)         u ^= rngCooked[i]         rng.vec[i] = u      }   }}

rngLen 默认值607, 从性能上考虑,业务逻辑中不允许频繁设置种子 ,误用了标准库接口

解决方法

解决方法有两个思路

  • 池化rand对象,避免重复创建

  • 优化(简化)随机数算法

种子数依赖uin和时间戳,不可穷举,不适合池化。这里使用方案二,参考C标准库实现, 使用线性同余法产生随机数 。

示例代码如下:

// 高性能版随机数func rand(seed uint64) uint64 {   // 线性同余算法,参数值参考 https://code.woboq.org/userspace/glibc/stdlib/random_r.c.html#364   return (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后,火焰图如下

CPU占比减少到12% 。比预想还是偏大,是否可以继续优化?

jsonpb基于标准json库二次开发,提供pb结构体的序列化能力。众所周知,标准json库使用反射实现,性能较低。trpc-go默认json库选择高性能json-iterator。jsonpb是否可以切换json库以提高性能?有人提过issue ,但官方没有接受

The jsonpb is 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 entire jsonpb library needs to be refactored. It may be worth just writing the parsing and formatting logic ourselves. Most of the complexity of the encoding/json package comes from the fact that it is so reflection heavy, which we can actually mostly avoid in jsonpb once 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。