最近,我们前前后后封闭开发将近有一年的的产品临近上线。不同于走小周期迭代出产品:每一个小的迭代都是单独线上验证过的,如果有问题其范围也是有限。像这种一次性整体上线的需求,说实话心里还是有点没底的。再加上临近年终,说不定一不小心一年就白干了。因此更需要我们在上线之前做万全的准备,今可能的将所有的问题提前暴漏出来,将bug扑灭在萌芽之中。虽然测试同学已经进行过多轮的回归和验证,但这只能尽可能的保证了低流量场景下的业务逻辑表现正常。有过高性能经验的开发应该知道,有一些问题只能是会在高压力场景下才会暴漏出来。一些系统雪崩的问题是很难由测试同学在测试环境下发现的。因此,为了保证平稳上线。我们就必须进行一次线上高压场景的模拟,去探测系统雪崩点,并探查系统的容量上限。在这种背景下,我们进行了一次线上写压测。本文章就是对压测过程和经验的总结。
为什么选择线上压测?
由于测试环境和线上环境的节点在单机规格和集群规模上有很大的不同、应用之间错综复杂的依赖关系、各种基础架构(db,redis,mq等)规格不同。测试环境压测的结果参考的意义不大,无法正确反映线上部署的集群的容量上限,同时并不富裕的机器资源和昂贵的开支在整个降本增效的背景下也不可能支持我们将线上的集群1:1的部署一份单独部署为一个性能环境。因此在这种情况下想要更加真实的探查系统的瓶颈,就必须直接的在线上进行的写压测。
线上写压测改造
要在线上进行写压测就需要隔离数据的污染并保证用户的无感。无法就将会是另外一个p0级事故了。
我们的项目工程是直接阿里云上部署的springBoot工程,因此无法直接利用公司已经建设技术基础能力设施。也因此我们需要自己完善项目写压测录了的数据隔离的技术能力。
流量标记
第一个问题是要解决流量标记的问题。我一开始是构思了两种方式来进行流量的标记。
- 第一种,压测机在所有的请求的head中携带一个压测标识,然后在工程中使用aop进行拦截,识别压测标识,丢进threadlocal中,在请求结束后清理压测标识。但是在通常的业务逻辑处理中,都必须要验证用户身份和权限进行验证,因此要求用户必须是登录的状态。也就说压测请求里的所有mock用户必须在登录过的。压测脚本需要模拟登录过程,获得cookie。
- 第二种:新增一个接口,这个新增的接口和目标压测入口的入参和返回值几乎一致,在新的入口中手动标记压测标识,并记录日志,入参直接接收一个uid,从而跳过登录验证流程。但需要针对每一个目标接口开发一个对应的压测接口,同时需要增加一些技术手段防止压测接口被异常访问。
为了降低压测脚本的复杂度和方便我们mock线上的用户。我们最终选择了第二种方式,针对每一个目标接口开发一个压测接口。
数据库存储隔离
标记流量以后,下面就需要针对这些流量做特殊的处理首先是数据存储的隔离。
- 使用影子表:通过mybaits提供的插件能力来动态的修改数据表。判断sql的目标数据表名称,统一在数据表后面增加后缀 \_TEST。 同时在数据库中创建表结构一样的影子表,这样压测操作下的所有数据都将从影子表读写。
- 大的偏移ID。通过影子表的隔离已经能够达到隔离的要求,如果说担心有数据表遗漏从而影响的了正常的数据。为了能够在中情况出现时,快速识别出来哪些是压测数据,我们可以修改id的生成规则,将压测的id的起始值变的很大。
数据库缓存隔离
千万别忘记对数据库的缓存进行改造。
我们项目的数据库缓存使用的是独立的一组redis,同我们封装了一个统一的缓存操作入口工具类。因此我们只需要在缓冲设置的入口处,拦截所有的缓存key生成,将所有的缓存key拼接前缀即可。
业务redis存储隔离。
业务redis的隔离就显得相对复杂一些,主要是因为操作分散,调用的姿势太多,因此这里并没有什么更好的办法可以一劳永逸的完成整个的隔离。需要我们自己深入业务的链路找到没一处redis的调用,因此需要我们对业务逻辑比较熟悉才行。当然我们可以通过大的范围来保证隔离,比如单独开一个测试用户的逻辑服,单独开一个房间,单独开一个测试的场次等来完成大范围的逻辑上的隔离。
异步链路压测标连贯
由于我们压测是高并发的场景,因此链路中避免不了会使用异步流程来分担系统压力达到填谷削峰的作用。比如我们会异步的记用户的榜单、异步的进行结算奖励。当压测流量从同步转到异步时,对应的标识也必须在继续传递下去。我们项目中使用rocketmq。rocketmq给我们提供的附近getProperties能力刚好可以来做这个事情。我们只需要拦截所有的消息发送点和所有的消息接收点。消息发出时,将压测标识读出来放到Properties,在消费时检查Properties,并重新标记压测标识(用于消费者使用线程组消费,所以要注意消息消费完成后,压测标识要清理掉)。
到此我们只能说是完成了技术基础能力的建设,让我们有能力完成的存储和缓存层的自动隔离。相当于是补了技术基础设施的欠账。接下来是要根据业务强耦合的业务上的写逻辑改造,以更好的方便进行写压测。
业务压测写逻辑改造
为了进一步说明白讲清楚接下来的代码改造,在这里我需要简略的将一下描述一下我们实际业务上的场景。
首先在业务上,一个逻辑服务内会有多个公会(就是游戏中的公会),每一个公会大约会有一百多个用户,而每一个公会都会有多个怪物可以让公会的成员可以在特定的时间段内去挑战,挑战时需要扣减自己的挑战次数并根据用户和公会整体的伤害进行排榜,挑战完成后可以获得丰厚的奖励。按照我们的预计,在挑战玩法开放的一瞬间时间,挑战流程的tps峰值将达到1w。而我们的压测就是模拟这个峰值的挑战流程。在能够愉快的压测之前,有几个问题需要我们处理。
- 需要查询用户归属工会的,否则会被拦截,压力无法到后续流程。
- 游戏开发时间限制。若不在开放时间内的挑战请求,将会被拦截。压力无法走到后续流程。然而开放时间内,业务本身就是高峰期,不可能再进行额外压测。因此必须在高峰期来之前完成压测,找到系统瓶颈并修复问题。
- 用户的挑战次数只有n次,消耗完后将不能继续发起挑战,压力无法继续走到后续流程。
- 怪兽会死亡,怪兽死亡后会刷新到下一个怪兽。怪兽死亡后,压测脚本需要刷新怪兽,否则所有的压测请求将会被校验拦截,无法进行模拟整个流程。
我们需要一一处理这些问题。保证在方便压测情况下保留对系统各个流程的压力。
仔细思考一下,一般来说业务逻辑基本是两个操作流
- 查询-判断拦截。
- 查询-判断-逻辑处理-修改为新值。
对于压测流量我们可以改掉这个操作流程
- 查询-判断不拦截。
- 查询-判断不拦截-逻辑处理-修改为旧值。
因此针对上面的问题,我们可以逐一改造为下面的处理。
- 仍然查询用户归属工会,但中途改掉公会id
- 对于时间判断,我们获取场次后,如果是压测流量则不进行判断拦截,直接进行下面的流程。
- 对于攻击次数:攻击次数获取到后,不判断余额,放过请求,在需要扣减时,允许次数余额为负数。
- 怪兽扣减血量:对于压测请求,仍然计算用于现在的装备等级功能打出的伤害,但在伤害算出以后重新设置为0。对怪兽的血量进行零扣减,因此压力保留了但怪兽不会死亡。
经过这个改造以后,系统就可以做到压测流量随时随地愉快的打怪兽了。
压测脚本准备
压测脚本同样有多重选择,比如ApcheBench,Locust,JMeter,基于go的go-stree-test。为了充分压榨cpu的资源,我们选择了并发能力高的基于go的go-stree-test 。
但在我提前说明的是,我们项目是springBoot的体系的,其实我们对go并不熟悉,也因此在压测过程中遇到了一些奇奇怪怪的问题,后面会详细展开来讲。
在所有的改造和脚本都确定好了以后,我们就可以开始始压测了。但在正式压测之前仍然有几个事情需要来做。
- 确定压测的目标,目标上限的qps是多少。一般来说,我们都会将预估出来的业务tps * 2 来探索容量的瓶颈。
- 压测计划周知,提起通知好相关的上下游,否则突然的流量对上下游来说就是一个super suprice。
- 打开所有的观察指标(内存,cpu,带宽,报错日志等),一旦指标爆表,就需要立即关停压测脚本。不能影响到正常的用户是我们的底线。
压测问题
整个压测过程我们可以算是有诸多的收获,成功找出了多个系统的问题。下面我将详细介绍一下我们压测时发现的问题。
但能够让你更好的代入我们接下来我们遇到的问题,我需要在这里大概来介绍一下我们项目使用的通信技术框架iogame
iogame是基于netty的高性能通信框架,但这里我不会展开来将iogame,感兴趣的可以直接去翻看iogame的官网介绍。这里只介绍跟接下来的问题相关的一些iogame的组成和消息收发过程。
iogame的三个部分
- 逻辑服:业务服务,是业务处理的地方上诉的改造的攻击场景是处于逻辑服务。逻辑服业务逻辑。
- 网关负责负载均衡逻辑服,并在对外服和逻辑服之间转发消息
- 对外服:负责维护和用户之间的长连接,并将用户的请求转发给网关,或把网关的请求转给用户。
消息在iogame中传递过程。
第一压测:内存cpu爆涨
第一次压测当qps达到1000时,对外服内存暴涨到80%,cpu也是快速增加。于是立马停止了压测,保留了一台现场机器,dupm出hprof文件进行分析。使用VisualVM分析如下图所示
DefaultChannelPromise:代表一个异步处理消息的句柄。类似于Java的Future。
PooledUnsafeDirectByteBuf是netty用来管理直接内存的缓冲池。ChannelOutboundBuffer说明这个缓冲池是用于出站的处理。
一开始我们怀疑对外服务在往外部发消息时申请的直接内存一致没有释放,导致内存暴涨。经过根据网上找到的一篇资料(https://blog.csdn.net/weixin_41778440/article/details/125309109)了解到netty的直接内存有两种释放方式
- 手动释放
- 对应Clear对象被回收
初次怀疑直接内存没有限制导致,于是按照文章里的做法通过-XX:MaxDirectMemorySize后再次尝试进行压测,当达到1000qps时,疯狂进行fullgc。说明不是直接内存的问题,而是请求量确实是太大了。而且是对外服往外部写的消息量太大。为了排除问题,我们更换了一个无返回值的压测接口。此时qps能够达到4000。说明确实是写消息量太大。但iogame是基于netty的,其本身的性能应该会很高,不至于1000qps就压不上去了,经过网上的一番搜索找到一篇文章 https://www.arloor.com/posts/netty/netty-direct-memory-leak/ 简单来说是消息发的太快了,没有及时读取会导致内存暴涨。
所以我当时就开始怀疑是压测脚本有问题,压测脚本中,没有处理任何的读取逻辑,那么所有的消息到达压测机以后就会在接收缓冲区中排队,而tcp的拥塞控制和可靠传输机制会根据下游的速度来调节整个发送速度从而在对外服务上产生一个读写差,如果压测脚本一直发送数据而不读取数据,那么会不会可能所有的消息都积压在了对外服务?按照这个思路,跟测试同学一起修改了压测脚本读取来源端上的返回的消息,并开始了第二次压测
第二次压测:广播增加限流
但这时启动压测后,对外服务的内存仍然会飙升,但qps能够从之前的1000达到1200。 说明上面的分析只能说是一部分的原因,而不是全部的关系。(后面才知道,压测脚本会维护一个接收缓冲区,并设置了3秒的读超时时间)
到此,只能怀疑回过头来排除是否是业务逻辑本身有问题。于是重新梳理攻击流程,发现确实有点问题。
上面提到过,为了方便压测,用户uid和工会id的归属会按照一定的映射规则来。一开始是通过 uid%200 -1 而来的。而问题就在于这个uid上,uid的后几位代表是游戏的区服id,为了隔离影响,所有的uid都在专门的测试服中生成,因此所有的uid%200 -1值都相同,也就是说用于压测的将近1万个用户都在同一个房间中。那么每一次怪兽时,为了同步血量,就需要将消息广播1万份给不同的用户,广播消息被放大了1万倍!这或许就是问题的根源。针对这一点我们做了两个改造。
- 进行消息限流,并不是每一次的血量变更都要严格同步广播,我们只需要1秒发送1次消息即可,如果过多,客户端也会处理不过来。
- 重新改造攻击流程,通过uid的hash值,将uid平均分散到200个房间。
经过改造以后单机压测能够达到4千的qps。对外服务内存和cpu不再暴涨。说明确实广播的原因。
但 单机达到4千qps并不像是go的语言的能达到的极限,说实话对于go来将4千并不是很高,按我们的预期应该可以达到1万左右。但当时时间有限为了尽快达到我们的压测目标,我们启动了多台压测机器进行压测。回头再看压测脚本的性能问题。
第三次压测:redis单点问题
第三次压测达到qps 2w时,redis集群开始告警。表现为redis集群中的某一个特定的节点cpu利用率能达到85%,但其他的节点的cpu仅有40%。说明业务里的key有明显的单点问题。经过整个链路的排除,找到异步链路中有一个记榜单的操作。而这个榜单是全局公会伤害榜单,而这个热点问题应该就是伤害榜单记榜的请求导致的,于是顺手增加了一个消费限流。
再次压测时,顺利的达到了压测目标,进而宣告了本次的压测任务完成。
脚本问题
现在我们回过头来看压测脚本的问题,上面说过我们的技术栈都是java体系的对go语言并不是很熟悉,github上找了一个开源代码,我们自己改改就拿来用了。
在我们压测过程中,每当达到一个较高的qps时往往无法维持很久的时间就会断开。而且压测时,服务端回复的消息会集中回流到同一台压测脚本上,在网络带宽的表现上,上行带宽和下行带宽都会打满。上面也提到过,压测脚本开启读取时(读取后清空内核的缓冲队列)能够提高qps上限。根据这些信息,我们进行了多个方向的改造
- 增加读取缓存区,将原来的1000 改成10000.扩大10倍
- 将读超时的时间从3秒改成1秒。让消息在没有读取的情况下尽快超时,
- 写操作更换为直接发送二进制的底层低级操作。
- 增加一个心跳线程用于保活。
经过这一系列的改造,我们的压测脚本能够达到9000千左右的qps,提升了1倍。到此,整个压测算是圆满完成了。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。