cover_image

高途全链路压测之方案设计

艾明 高途技术 2023年11月08日 01:01
图片

高途全链路压测之方案设计



图片


图片

背景

在高途价值观中,“客户为先”被摆在了第一的位置,因此线上任何有损用户体验的事故都是不可容忍的。特别是在行课续班等业务高峰期,每出现一次问题都可能会导致严重的后果。然而,线上涉及的环境是非常复杂的,其中包括服务器、网络、中间件、操作系统、应用都有可能出现问题,因此我们需要对线上做一次全面的“体检”。阿里巴巴把全链路压测比喻为“正式考试前的模拟考”,通过这次“模拟考”,我们能获取实际的承载能力,对服务进行精准的容量规划,验证线上功能的稳定性,验证发生问题时的预警准确性。

在拥有全链路压测前,主要的压测方式有:

  • 针对某一接口,针对线上单机或者服务集群进行调用。

  • 录制线上流量,然后在线上进行回放。

以上方法很难对服务的整个集群进行评估,因为如果通过局部推断整个集群的健康状态往往做不到“面面俱到”,主要原因体现在:

  • 只关注核心服务、无法覆盖到整个链路的所有环节。甚至由于数据问题,重点覆盖的读场景,对写场景覆盖不全面。

  • 系统间的基础服务包括中间件,例如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 —


图片
图片


微信扫一扫
关注该公众号

继续滑动看下一个
高途技术
向上滑动看下一个