背景:「哔哩哔哩会员购」是B站的电商业务。随着业务规模不断扩大,系统设计也越来越复杂。当在具有一定应用规模和业务复杂度的系统上进行业务快速迭代时对系统的鲁棒性,兼容性以及测试Case的覆盖率,实效性也提出了更高的要求。我们首先想到的是增加更多的准入测试套件和自动化回归脚本,但由于系统每时每刻都在演化,这些脚本的正确性和实效性很难得到保证,同时由人工编写脚本,始终并无法很好的覆盖所有真实业务场景。为了减缓复杂度之熵对系统迭代造成的持续影响,我们开始探索如何利用流量回放,将线上真实的数据流转化为可以反复使用并且覆盖全面的回归测试用例。
调研:

虽然开源组件并不是特别符合我们的场景,但上述探索过程中我们也对「会员购」服务的自身特点进行了分析:「会员购」的核心业务服务都是基于Java体系,采用Spring Cloud框架搭建的集群系统,数据库统一使用MyBatis封装后的Dao层,缓存则使用基于Spring Data封装的RedisTemplate,与其他服务的交互则使用Feign。这种较为统一的框架使用也为后续持续不断的探索与实践提供了更多的可能。通过对实际业务场景的逐个分析和拆解,我们为符合「会员购」特点流量回放体系进行如下关键项定义:
能录制/回放应用调用链路入口(通常为HTTP)的Request/Response
能录制/回放应用调用链路内部对DB,MQ,Reids,Feign的Request/Response
能基于Trace ID串联整个调用链路期间所有相关的录制/回放(内部Trace体系已较完善)
能无限次数和环境回放至指定主机(包括线上,线下,指定IP等)
能随着业务的发展和新技术的应用扩展更多录制/回放点(Endpoint)
应用代码无侵入及录制过程对服务极低的性能耗损
峰回路转,柳暗花明。就在开源探索出师不利的时候,阿里云开发者社区的一篇文章有如一剂强心针:「海量流量下,淘宝如何进行稳定的流量回放?」。细看文章的内容:使用JVM-SANDBOX-REPEATER对JAVA应用进行无侵入式的流量录制,"成了,要的就是她"。但现实总是一个残酷的循环:一顿部署猛如虎,一到使用瘟如狗。JVM-SANDBOX-REPEATER比起他的兄弟JVM-SANDBOX的完善程度简直惨不忍睹,基本属于提供思路和Demo,落地全看自己的那种级别。
江湖有云:「师傅领进门,修行靠自身」。剑谱(思路)在手,练与不练就看自己了。顺着这个方向继续探索尝试下去,理论方向不至于有大的偏差,同时底层的JVM-SANDBOX也是一款非常出色的精品开源组件(赞)。
分析:既然准备借鉴JVM-SANDBOX的思路,构建契合「会员购」业务系统的流量回放系统。那么对当前的系统的组成和调用链路特点就需要详细分析一番。其实万变不离其宗,再复杂的业务系统经过高度概括都会变成这个样子:



「会员购」服务(或其他大数同类设计的服务)均会有一个明显的特点:除了入口HTTP 的Request和Response分别处于链路首位和末尾外。调用链路在应用内部的触发顺序并无法不确定。比如一个调用链路在A场景下可能先调用DB后调用Redis,也可能在B场景又变为先调用Redis后调用同样的DB SQL多次,这是一个没有任何规律(也无法推测出规律)的调用顺序。所以我们在探索实践的初期就明确了所有需要录制的Endpoint都应该同时被录制一个基于当前上下文(Trace)的调用编号(我们称之为Index,即上图所标记的1,2,3等)。由于基于Trace进行回放时的调用链路理论上应该与录制时一致的,所以这样可以通过调用编号准确的定位到当前Endpoint在当前步骤需要回放的数据。
在经过上述的高度概括后,我们再次详细分析了需要录制的调用链路(包括HTTP入口,DB,Redis,基于Feign的第三方服务调用),各个框架均存在既可以获取到入参数,后可以获取到出参的Endpoint,所以使用JVM-SANDBOX来进行AOP方式的录制看起来的确是切实可行的。
尝试:我们正式进入了「会员购」流量回放的实践阶段。

流量录制,我们是通过继承了JVM Sandbox提供的Agent接口,动态的对现有代码进行运行时增强(所有流量录制相关实现均打包名为Copy Agent的Jar Agent包),在指定代码位置(也就是我们之前一直提到的Endpoint)进行代码织入(AOP)。然后通过内置事件模型(Before,After,Throw)的通知机制在代码实际执行前拦截入参,实际执后拦截出参。通过对DB(使用MyBatis框架),Redis(使用Spring Data的RedisTemlate),第三方服务请求(使用Feign框架)进行AOP拦截,在相关Endpoint完成入参数拦截和出参拦截后(通常均为进行网络交互前后),在请求/响应/Trace/及上文提到的调用编号均完备的情况下,使用Json序列为包含元数据(比如Class信息,数组或者集合的元素类型等)的字符串,并推送至消息中间件(如Kafka),随后通过一个名为Repeat Service的服务异步消费后存入DB。
题外话:在录制/回放过程中,经常会发现一些非常多的小细节,比如入参拦截时需要立即进行序列化而不能仅仅保存参数的引用,因为业务代码很可能在执行过程中对入参进行了修改,又比如存在一些场景(通常为技改)仅仅调整了调用链路内部的顺序(比如原本调用多次DB,后优化为进行批量查询)但并不修改核心业务逻辑的代码修改场景,针对这些场景是需要开放流量编辑能力的,但目前为止我们还没有开始进行这方面的探索实践。



为什么一定需要JSON序列化元数据:起初我们尝试仅仅简单的序列化对象,并在回放时动态的通过解析当前Method的ResultType来反序列化。但是发现如果使用动态解析类型,会面临的特殊场景非常之多,比如泛型或抽象接口,又比如被代理后对象的解析,兼容所有场景耗费的精力非常之大。最终还是尝试使用Jackson将数据与类型一起打包序列化,基本解决了以上这几个问题。但是随之而来的代价就是存储量会变大(因为同时存储了Class信息),并且如果接口签名变动以会影响到回放数据反序列化,但这和之前的复杂场景兼容相比成本相对会更小。
为什么需要使用Kafka:为了做到最小的业务影响,所以并不能采用同步的数据传输方式(比如HTTP)。探索尝试过使用Log Agent日志收集和异步消息队列的方式,前者对基础设施有一定的改造,如果同时兼顾采集、上传、清洗,存储所有步骤,目前看来并不适合「会员购」在流量会放上的定位(探索),所以选择使用Kafka直接通过消息队列传递的方式看起来实践落地成本会比较低,当然并不排除后期成规模后切换至日志的方式。同时考虑到一定的容错场景(如网络卡顿等),数据并不会直接推送至Kafka,而是首先会推送至一个内部队列,实现上采用LinkedBlockingQueue并限制了容量,再通过Queue转发至Kafka,这么做也是为了防止即使发生了考虑外等因素,也不会对业务系统造成过大的影响。
为什么选择使用DB存储:当前选择的还是使用MySQL进行存储,因为通过少量部署后若干天后的的流量采集来看,数据约为百万条,存储容量为10G内。同时考虑到一些对流量录制时的一些限制和优化:如非业务HTTP入口的流量不采集(过滤掉了定时任务),按Trace ID进行百分比采样,数据存储前使用Snappy压缩等,以目前「会员购」探索实践的业务场景来说,使用MySQL进行存储"N天回放"的数据量并没有问题。当然同时我们也在尝试使用TiDB或其他时序数据库进行存储的可能。

流量回放,我们使用与流量录制时类似的方式:通过JVM Sandbox对代码进行运行时增强(同样相关实现均打包名为Repeat Agent包),在流量录制同样的位置进行代码织入(这样做的目的是保证流量录制与流量回放的Endpoint和调用编号是一致的)。区别在于,流量录制是序列化数据后推送Kafka,而流量回放是拦截入参后通过HTTP协议转发至Repeat Service获取回放数据后进行反序列化,并作为Method Result返回。这样流量回放后的系统,除了自身代码是实际运行的,诸如DB,Redis及其他服务的数据均是通过回放数据来进行的。
再来看下当前存储的一些必要属性,目前已经可以满足「会员购」在相对不复杂流量回放场景时的回放数据定位。当然随着探索的场景越来越多,后续数据维度还在不断的补充中。

比较特殊的是Index,因为在尝试流量录制的过程中发现一些服务会反复的调用同一个入口(Entry)进行数据交互,Index不仅需要记录常规的调用编号,同时对于此类反复调用场景也要进行调用编号的递增,这样才能在回放时通过同样的调用编号进行数据定位。
关于Index计数的实现,起初我们是采用ThreadLocal(InheritableThreadLocal)绑定线程计数,但是在实际录制过程中发现由于基础组件的一些限制(或使用不规范),经常导致无法从当先线程获取计数器。尝试过很多方式,最后选择了和Trace组件进行绑定(因为Trace组件使用相对比较规范)。所以现在的实现改为在流量入口(「会员购」基于Spring Cloud,所以默认均为@RestController)时在全局ConcurrentHashMap中存储Key为Trace的计数器,随后在流量出口(或异常)时移除计数器,使用时各Endpoint通过当前上下文Trace来获取计数器并递增。同时我们也在寻找更好的更优美的方案,比如阿里的TTL库。
流量录制成功后,一个接踵而来的问题迎面而来。由于线下环境配套设施的问题(「会员购」线下仅有三套测试联调环境),并无法为流量回放单独部署一整套系统,那意味着流量回放只能部署在以上三套环境之一,且并不能影响其他正在进行调试的团队。那么应用服务如何识别出当前请求是否是需要进行回放?对于回放请求则触发Repeat Agent进行拦截后通访问Repeat Service返回已经录制的数据,对于常规请求则不触发,从而保证回放服务对环境是无侵入的。
我们使用了一个比较轻量级的方法:如果是回放请求,则附带一个特定的HTTP报头。符合规则的请求(带特定Header)进入Endpoint时则开启流量回放,否则直接跳过。从目前的场景来看这个方法是满足需求且几乎无需改动的,但是考虑到一些定制化的回放场景(虽然我们还未涉及),比如数据库有兼容性修改,仅需要回放HTTP和Redis但不回放DB等,依然是需要传递回放配置供Repeat Agent区分的。以上的场景,我们任然还在探索中。

如上图5,在Service A所有外部输入采用回放的情况下,我们断言Service A的Response应该与流量录制时Response一致,从而达到将线上真实的数据流转化为覆盖全面的回归测试用例的用途。
后续:当然,这只是「会员购」在流量回放上的初步探索,由于我们现在也仅仅部署了少量服务进行回放,所以一些潜在的问题可能暂时未被我们触碰。当然随着规模的逐步扩大,我们的探索也会随之更深入:比如定制化的回放链路(允许跳过某些步骤的回放,允许编辑回放数据等),比如个性化的Response断言(现在要求绝对一致,并不会区分属性顺序和兼容性属性),比如更多的系统集成(比如集成Jacoco提高测试覆盖率,比如集成Skywalking进行线上问题回放等)。流量回放用于回归测试,可以极大的减少开发和测试在浩瀚的祖传代码中上线新功能时工作负担,投入回报的价值会随着时间的推移和场景的积累越来越大,所以我们的探索还会继续。
求贤: