高途全链路压测之方案设计
背景
在高途价值观中,“客户为先”被摆在了第一的位置,因此线上任何有损用户体验的事故都是不可容忍的。特别是在行课续班等业务高峰期,每出现一次问题都可能会导致严重的后果。然而,线上涉及的环境是非常复杂的,其中包括服务器、网络、中间件、操作系统、应用都有可能出现问题,因此我们需要对线上做一次全面的“体检”。阿里巴巴把全链路压测比喻为“正式考试前的模拟考”,通过这次“模拟考”,我们能获取实际的承载能力,对服务进行精准的容量规划,验证线上功能的稳定性,验证发生问题时的预警准确性。
在拥有全链路压测前,主要的压测方式有:
针对某一接口,针对线上单机或者服务集群进行调用。
录制线上流量,然后在线上进行回放。
以上方法很难对服务的整个集群进行评估,因为如果通过局部推断整个集群的健康状态往往做不到“面面俱到”,主要原因体现在:
只关注核心服务、无法覆盖到整个链路的所有环节。甚至由于数据问题,重点覆盖的读场景,对写场景覆盖不全面。
系统间的基础服务包括中间件,例如Nginx,Redis,数据库,网络,磁盘等,而基础服务的瓶颈在单应用压测中又很难被测试出问题。
综合来看,全链路压测是评估系统稳定性的有效方法,是确保业务高峰期时能够稳定运行的得力手段。
解决方案
全链路压测平台的目标是为了提供对整条链路进行全面、真实、安全地压测,因此我们对它提出了以下目标:
针对真实的线上流量进行录制、回放式压测
单一的场景往往无法覆盖真实的情况,因此需要通过真实的线上流量来覆盖真实的场景
压测过程中不影响线上的服务,做到完全的流量隔离
针对写操作,做到数据隔离
具备快速创建压测环境的能力
具备压测过程中的数据监控以及过载保护
同时,市面上有许多公司都有自己全链路压测平台,对比其它平台,高途全链路压测平台拥有以下特点:
根据调查,目前其它公司的压测流量均由线上pod进行处理。而在高途全链路压测中,正常流量与压测流量可以做到隔离,从而使得全链路压测时基本不会影响线上服务,更加安全。
利用Agent机制,对线上服务的修改做到无感知,节约了接入成本。通过铺设Agent,应用可以方便的接入以及升级全链路压测功能。
结合了流量录制功能,对线上真实的流量进行压测,做到1:N的还原。
结合现有的监控告警体系,做到故障演练,反馈终止。
整体方案
qa平台:负责流量的录制,压测任务的管理,压测流量回放,监听告警反馈,生成压测报告
轻舟平台:负责环境准备,压测开关的控制,应用隔离策略的控制
Agent:负责对压测中的流量控制,对中间件操作的控制,对压测流量的监控上报
压测开关
为了保障压测过程中的稳定性,所以引入了压测开关的概念。压测开关作为压测的总控制,只有当压测开关开启时压测服务才会接受压测流量,否则直接拒绝压测流量。压测开关可以保护压测应用的负载不会过大,也可以在出现紧急情况时及时中止压测,防止对服务产生影响。
流量隔离
由于线下测试压测不能反馈真实问题,所有压测均在线上进行。那么必须做到压测过程中对线上服务不造成影响,以免线上服务处理真实请求时造成阻塞或延迟,影响用户体验。为此我们对压测流量做了隔离。而流量又分为同步流量(通过spring-cloud注册发现调用)、异步请求(MQ)、挡板(第三方调用时的Mock服务)。我们分别有不同的流量隔离方案。
图1: 流量隔离整体示意图
同步流量
图2: 流量隔离
如图2所示,为了对线上服务完全不造成影响,我们对服务的压测pod,单独隔离出了一条泳道(stressTest),用来专门承载压测流量。
压测泳道上的pod在eureka上注册的时候就已经带上了环境标志“stressTest”,同时为了区分压测流量,当平台回放流量时,会在流量中打上压测标,具体是通过在流量请求头中携带traffic-env=stressTest来区分,当入口(网关)接收到流量时,会通过头中是否携带traffic-env以及traffic-env的值来确定是否为压测流量,当确定为压测流量后,在eureka中查找打上stressTest标记的服务,并把流量转发至这些pod上,如果找不到压测pod,则直接返回错误,不会把压测流量路由到主泳道,以此做到压测流量的隔离。同时,在压测服务调用下游时,重复这一过程,这样就做到了整个链路上的流量隔离,从而使压测不会影响到线上服务。
如图3所示,以网关的选路为例,所有的压测流量均携带有标志头traffic-env=stressTest,当流量到达网关时,网关根据压测流量,找到在Eureka中带有tag=stressTest的下游pod,并将流量进行转发。
同步流量隔离的实现难点在于针对压测流量不允许fallback逻辑。在之前的场景中,如果找不到对应pod就会fallback到主泳道,而压测流量只能打到压测pod,否则会对主泳道的pod造成影响,从而影响到线上服务。
图3: 压测流量隔离原理
异步流量
针对MQ的消息,我们从topic和group上均做了隔离。如果应用是发送端,那么针对发送消息的原topic,我们都会将其更改为影子topic(加上shadow__前缀),这样可以做到不影响正常消息的消费。针对消费者,我们会单独为其创建group(加上shadow__前缀),并在订阅topic时使之订阅影子topic,这样既能做到消费者端隔离,也能做到生产者隔离,真正做到MQ消息隔离。
图4:MQ线上压测环境消息对比
针对MQ的隔离,我们提出了三个策略:
生产端丢弃-producer根本不把消息发送到broker,直接丢弃
生产端发送、接收端丢弃-producer将消息发送到broker,broker投递给consumer,但是consumer不做处理,直接返回broker处理成功。
生产端发送、接收端接收-producer将消息发送到broker,broker投递给consumer,consumer根据逻辑进行处理后返回。
用户可以根据自己的压测场景选择对应的压测策略。
图5: MQ流量隔离策略
MQ流量隔离的实现难点在于找到关键的切点,对于producer,需要关注topic的变化,这时拦截producer的send方法,修改send中的topic名称即可。对于consumer需要同时改写topic和group,改写topic时需要拦截consumer的subscribe方法。而对于修改group,则需要修改创建consumer时的配置项,这时只能拦截构造函数(因为配置项在构造后还有其它逻辑,并且在很多地方使用到),而在skywalking中并没有提供修改构造函数的切点,所以实现上增加了构造函数执行前切点,针对构造函数中的参数中的groupId修改,以达到使用影子group的目的。
图6: MQ隔离实现原理
分布式定时任务
目前服务中使用的分布式定时任务框架基本为2种:xjob、ElasticJob。这些框架都是分片式触发,因此如果压测环境也注册成为同一定时任务,则该定时任务的分片会分配到压测泳道上。但是压测环境可能会读取或者写入影子资源,这样会导致定时任务执行与预期结果不一致。因此全链路压测中压测泳道的定时任务不会被注册执行。
挡板
挡板可以将设置的请求拦截,并返回预设好的结果,以达到模拟正常或异常请求的目的,它主要可用在以下场景:
第三方调用服务:若压测场景中调用了第三方的服务接口,例如:短信、支付等场景,而涉及到的第三方服务又很难配合时,可以使用挡板对接口返回特定的值,从而不用有实际的调用即可模拟返回;
内部服务调用:在部分内部服务无法配合进行压测时,例如:非JAVA应用,可以模拟请求返回结果;
请求结果改造:在压测过程中,可能希望对返回结果进行改造,这时也可以通过挡板进行拦截,并模拟返回结果;
数据隔离
当前的压测中还存在压测数据不便清理,容易造成数据混淆的问题。因此我们在数据层面也需要做到隔离,目前涉及到的存储有MySQL, ES, Redis三种,下面将分别进行介绍。
MySQL
为了确保读写数据能够隔离,MySQL目前的隔离策略有两种:
读原表写影子表
读影子表写影子表
图7: MySQL数据隔离原理
实现上我们通过拦截数据库连接池获取数据库连接并代理的方式。经过调研发现,集团在使用数据库时,均使用到了数据库连接池(Druid or HikariCP)。因此,在全链路压测环境中会针对数据库连接池中的getConnection方法做拦截,通过代理模式返回压测的Connection Proxy。当该Connection Proxy被调用时,通过改写SQL的方式,实现读写影子表,从而做到MySQL的压测数据隔离。
图8: MySQL切点选择
MySQL数据隔离实现的难点在于如何选择正确的切点,如图8所示,对MySQL操作的切点可以有4种选择,Spring-data层,它是Spring框架中进行的一次封装,ORM框架层(例如MyBatis),JDBC层,以及数据库连接池,它提供了获取数据库连接的入口。如果选择拦截JDBC,由于MySQL不同版本对于JDBC实现有差别,同时JDBC需要增强的方法很多,嵌套也多,这样执行一个SQL被拦截的次数也越多,随之而来的消耗也会增大。并且MyBatis层是有缓存的,这样会导致在JDBC层修改后的SQL与MyBatis层缓存的SQL不一致,出现与预期不符的情况。如果选择Mybatis层或者spring-data层,会导致如果应用没有接入这两层出现增强失败的情况(例如使用JPA,或者直接使用JDBC)。综合考虑后,我们决定增强数据库连接池层,对比其它层,它有两个优势,第一是收口比较容易,只需要实现DataSource和Statement的相关接口,做一个代理即可,第二是数据库连接池的使用更加普遍,所有使用到数据库的java应用都使用了数据库连接池,且它们基本都是Druid和HikariCP中二选一。因此最后我们选择对数据库连接池做拦截。
ES
ES与MySQL一样,目前也有两种隔离策略:
读原索引写影子索引
读影子索引写影子索引
实现上我们拦截了elasticsearch-client包中的RestHighLevelClient和RestClient中操作索引的方法,对于es的操作索引的所有方法,通过在请求中为原索引加上前缀来将原索引修改为影子索引,达到写影子索引的目的。
实现ES数据隔离的难点在于Es的版本问题,目前公司使用到ES的版本分为5.x, 6.x, 7.x(其中主要还是6.x)。且对于RestHighLevelClient和RestClient操作ES的方式亦有所不同。对于RestHighLevelClient,针对ES的每种操作都会封装成一种Request(例如search操作会使用SearchRequest),因此对于RestHighLevelClient则需要处理每种Request。而如果直接使用RestClient,则没有明确定义这次的操作类型,需要同时解析url和http method来判断es的操作类型。
Redis
Redis与以上两种存储有所不同,它目前的两种隔离策略为:
读原索引写原索引
读影子索引写影子索引
这是因为在改写Redis影子索引时很难区分它的读写。实现方式是通过拦截Jedis或者Luttuce的RedisClient,对于RedisClient操作索引的所有操作,为原索引添加前缀使之变为影子索引,达到写影子索引的目的。
实现Redis数据隔离的难点在于需要考虑RedisClient操作Redis时的各种情况,操作Redis时传递的参数可能为String, String[], byte[], byte[][]。同时还需要考虑Redis中eval操作中的索引,eval中的索引需要自行根据语法解析,这也是需要注意的难点所在。以Jedis为例,具体为代码如下:
private void doProcessEval(Object[] allArguments) {
if (allArguments.length != 3) {
return;
}
if (allArguments[1] instanceof Integer) {
int keyCount = (Integer) allArguments[1];
Object[] params = (Object[]) allArguments[2];
// change params
...
return;
}
if (allArguments[1] instanceof byte[]) {
int keyCount = 0;
try {
keyCount = Integer.parseInt(new String((byte[]) allArguments[1]));
} catch (Exception ex) {
return;
}
byte[][] params = (byte[][]) allArguments[2];
// change params
...
return;
}
if (allArguments[1] instanceof List) {
List<Object> objList = (List<Object>) allArguments[1];
List<Object> newList = new ArrayList<>();
for (Object obj : objList) {
String key;
// change key
...
}
allArguments[1] = newList;
}
}
该代码主要展示了处理jedis的eval方法的过程,大致可分为3种情况,首先判断传入的第二个参数是否为Integer,若是则代表后面有多少个key,改写第3个参数中的key即可。若第二个参数为byte[],则先将其转化为Integer后再改写key。若第二个参数为List,则需要对List中的每个key都进行改写。
反馈终止
保障全链路压测中稳定性的最后一环是反馈终止逻辑,它是全链路压测中的“保险”。当压测过程中监控出现异常,或者是服务出现告警时,出于对线上系统的保护机制,会终止全链路压测的执行。
总结
本文介绍了全链路压测的整体方案,以及实现流量隔离、数据隔离的原理。全链路压测能够更好地评估系统的稳定性,精确地评估系统容量。期待全链路压测能够在压测时大放异彩。
— END —
微信扫一扫
关注该公众号