乐趣区

关于golang:流量录制与回放技术实践

文章导读

本文次要介绍了流量录制与回放技术在压测场景下的利用。通过浏览本篇文章,你将理解到开源的录制工具如何外部系统集成、如何进行二次开发以反对 Dubbo 流量录制、怎么通过 Java 类加载机制解决 jar 包版本抵触问题、以及流量录制在自动化测试场景下的利用与价值等。文章共约 1.4 万字,配图 17 张。本篇文章是对我集体过来一年所负责的工作的总结,外面波及到了很多技术点,集体从中学到了很多货色,也心愿这篇文章能让大家有所播种。当然集体能力无限,文中不妥之处也欢送大家指教。具体章节安顿如下:

1. 前言

本篇文章记录和总结了本人过来一年所主导的我的项目——流量录制与回放,该我的项目次要用于为业务团队提供压测服务。作为我的项目负责人,我承当了我的项目约 70% 的工作,所以这个我的项目承载了本人很多的记忆。从需要提出、技术调研、选型验证、问题处理、方案设计、两周内上线最小可运行零碎、推广应用、反对年中 / 终全链路压测、迭代优化、反对 dubbo 流量录制、到新场景落地产生价值。这里列举每一项本人都深度参加了,因而也从中学习到了很多货色。蕴含但不限于 go 语言、网络常识、Dubbo 协定细节,以及 Java 类加载机制等。除此之外,我的项目所产生的价值也让本人很欣慰。我的项目上线一年,帮忙业务线发现了十几个性能问题,帮忙中间件团队发现了根底组件多个重大的问题。总的来说,这个我的项目对于我集体来说具备不凡意义,受害良多。这里把过来一年的我的项目经验记录下来,做个总结。本篇文章着重讲实现思路,不会贴太多代码,有趣味的敌人能够依据思路本人定制一套。好了,上面开始注释吧。

2. 我的项目背景

我的项目的呈现源自业务团队的一个诉求——应用线上实在的流量进行压测,使压测更为“实在”一些。之所以业务团队感觉应用老的压测平台(基于 Jmeter 实现)不实在,是因为压测数据的多样性有余,对代码的覆盖度不够。惯例压测工作通常都是对利用的 TOP 30 接口进行压测,如果人工去欠缺这些接口的压测数据,老本是会十分高的。基于这个需要,咱们调研了一些工具,并最终抉择了 Go 语言编写的 GoReplay 作为流量录制和回放工具。至于为什么抉择这个工具,接下来聊聊。

3. 技术选型与验证

3.1 技术选型

一开始选型的时候,经验不足,并没有思考太多因素,只从功能性和知名度两个维度进行了调研。首先性能上肯定要能满足咱们的需要,比方具备流量过滤性能,这样能够按需录制指定接口。其次,候选项最好有大厂背书,github 上有很多 star。依据这两个要求,选出了如下几个工具:

图 1:技术选型

第一个是选型是阿里开源的工具,全称是 jvm-sandbox-repeater,这个工具其实是基于 JVM-Sandbox 实现的。原理上,工具通过字节码加强的模式,对指标接口进行拦挡,以获取接口参数和返回值,成果等价于 AOP 中的盘绕告诉 (Around advice)。

第二个选型是 GoReplay,基于 Go 语言实现。底层依赖 pcap 库提供流量录制能力。驰名的 tcpdump 也依赖了 pcap 库,所以能够把 GoReplay 看成极简版的 tcpdump,因为其反对的协定很繁多,只反对录制 http 流量。

第三个选型是 Nginx 的流量镜像模块 ngx_http_mirror_module,基于这个模块,能够将流量镜像到一台机器上,实现流量录制。

第四个选型是阿里云云效里的子产品——双引擎回归测试平台,从名字上能够看进去,这个零碎是为回归测试开发的。而咱们需要是做压测,所以这个服务里的很多性能咱们用不到。

通过比拟筛选后,咱们抉择了 GoReplay 作为流量录制工具。在剖析 GoReplay 优缺点之前,先来剖析下其余几个工具存在的问题。

  1. jvm-sandbox-repeater 这个插件底层基于 JVM-Sandbox 实现,应用时须要把两个我的项目的代码都加载到指标利用内,对利用运行时环境有侵入。如果两个我的项目代码存在问题,造成相似 OOM 这种问题,会对指标利用造成很大大的影响。另外因为方向小众,导致 JVM-Sandbox 利用并不是很宽泛,社区活跃度较低。因而咱们放心呈现问题官网无奈及时修复,所以这个选型待定。
  2. ngx_http_mirror_module 看起来是个不错的抉择,出世“名门”。但问题也有一些。首先只能反对 http 流量,而咱们当前肯定会反对 dubbo 流量录制。其次这个插件要把申请镜像一份进来,势必要耗费机器的 TCP 连接数、网络带宽等资源。思考到咱们的流量录制会继续运行在网关上,所以这些资源耗费肯定要思考。最初,这个模块没法做到对指定接口进行镜像,且镜像性能开关须要批改 nginx 配置实现。线上的配置是不可能,尤其是网关这种外围利用的配置是不能轻易改变的。综合这些因素,这个选型也被放弃了。
  3. 阿里云的引擎回归测试平台在咱们调研时,本身的性能也在打磨,用起来挺麻烦的。其次这个产品属于云效的子产品,不独自发售。另外这个产品次要还是用于回归测试的,与咱们的场景存在较大偏差,所以也放弃了。

接着来说一下 GoReplay 的优缺点,先说长处:

  • 单体程序,除了 pcap 库,没有其余依赖,也无需配置,所以环境筹备很简略
  • 自身是个可执行程序,可间接运行,很轻量。只有传入适合的参数就能录制,易使用
  • github 上的 star 数较多,知名度较大,且社区沉闷
  • 反对流量过滤性能、按倍速回放性能、回放时改写接口参数等性能,性能上贴合咱们的需要
  • 资源耗费小,不侵入业务利用 JVM 运行时环境,对指标利用影响较小

对于以 Java 技术栈为根底的公司来说,GoReplay 因为是 Go 语言开发的,技术栈差别很大,日后的保护和拓展是个大问题。所以单凭这一点,淘汰掉这个选型也是很失常的。但因为其长处也绝对突出,综合其余选型的优缺点思考后,咱们最终还是抉择了 GoReplay 作为最终的选型。最初大家可能会纳闷,为啥不抉择 tcpdump。起因有两点,咱们的需要比拟少,用 tcpdump 有种大炮打蚊子的感觉。另一方面,tcpdump 给咱们的感觉是太简单了,驾驭不住(流下了没有技术的眼泪😭),因而咱们一开始就没怎么思考过这个选型。

选型 语言 是否开源 长处 毛病
GoReplay Go 1. 开源我的项目,代码简略,不便定制
2. 单体继续,依赖少,无需配置,环境筹备简略
3. 工具很轻量,易使用
3. 性能绝对丰盛,可能满足咱们所有的需要
4. 自带回放性能,可能间接应用录制数据,无需独自开发
5. 资源耗费少,且不侵入指标利用的 JVM 运行时环境,影响小
6. 提供了插件机制,且插件实现不限度语言,不便拓展
1. 利用不够宽泛,无大公司背书,成熟度不够
2. 问题比拟多,1.2.0 版本官网间接不举荐应用
3. 接上一条,对使用者的要求较高,出问题状况下要能本人读源码解决,官网响应速度个别
4. 社区版只反对 HTTP 协定,不反对二进制协定,且外围逻辑与 HTTP 协定耦合了,拓展较麻烦
5. 只反对命令行启动,没有内置服务,不好进行集成
JVM-Sandbox
jvm-sandbox-repeater
Java 1. 通过加强的形式,能够间接对 Java 类办法进行录制,非常弱小
2. 性能比拟丰盛,较为合乎需要
3. 对业务代码通明无侵入
1. 会对利用运行时环境有肯定侵入,如果产生问题,对利用可能会造成一些影响
2. 工具自身依然偏差测试回归,所以导致一些性能在咱们的场景下没法应用,比方不能应用它的回放性能进行高倍速压测
3. 社区活跃度较低,有进行保护的危险
4. 底层实现的确比较复杂,保护老本也比拟高。再次留下了没有技术的眼泪😢
5. 须要搭配其余的辅助零碎,整合老本不低
ngx_http_mirror_module C 1. nginx 出品,成熟度能够保障
2. 配置比较简单
1. 不不便启停,也不反对过滤
2. 必须和 nginx 搭配只用,因而应用范畴也比拟受限
阿里云引擎回归测试平台

3.2 选型验证

选型实现后,紧接着要进行性能、性能、资源耗费等方面的验证,测试选型是否符合要求。依据咱们的需要,做了如下的验证:

  1. 录制性能验证,验证流量录制的是否残缺,蕴含申请数量完整性和申请数据准确性。以及在流量较大状况下,资源耗费状况验证
  2. 流量过滤性能验证,验证是否过滤指定接口的流量,以及流量的完整性
  3. 回放性能验证,验证流量回放是否能如预期工作,回放的申请量是否合乎预期
  4. 倍速回放验证,验证倍速性能是否合乎预期,以及高倍速回放下资源耗费状况

以上几个验证过后在线下都通过了,成果很不错,大家也都挺称心的。可是倍速回放这个性能,在生产环境上进行验证时,回放压力死活上不去,只能压到约 600 的 QPS。之后不论再怎么增压,QPS 始终都在这个水位。咱们与业务线共事应用不同的录制数据在线上测试了多轮均不行,开始认为是机器资源呈现了瓶颈。可是咱们看了 CPU 和内存耗费都非常低,TCP 连接数和带宽也是很充裕的,因而资源是不存在瓶颈的。这里也凸显了一个问题,晚期咱们只对工具做了功能测试,没有做性能测试,导致这个问题没有尽早裸露进去。于是我本人在线下用 nginx 和 tomcat 搭建了一个测试服务,进行了一些性能测试,发现随随便便就能压到几千的 QPS。看到这个后果哭笑不得,脑裂了😭。起初发现是因为线下的服务的 RT 太短了,与线上差别很大导致的。于是让线程随机睡眠几十到上百毫秒,此时成果和线上很靠近。到这里基本上可能大抵确定问题范畴了,应该是 GoReplay 呈现了问题。然而 GoReplay 是 Go 语言写的,大家对 Go 语言都没教训。眼看着问题解决唾手可得,可就是无处下手,很窒息。起初大佬们拍板决定投入工夫深刻 GoReplay 源码,通过剖析源码寻找问题,自此我开始了 Go 语言的学习之路。原打算两周给个初步论断,没想到一周就找到了问题。原来是因为 GoReplay v1.1.0 版本的应用文档与代码实现呈现了很大的偏差,导致依照文档操作就是达不到预期成果。具体细节如下:

图 2:GoReplay 应用阐明

先来看看坑爹的文档是怎么说的,--output-http-workers 这个参数示意有多少个协程同时用于产生 http 申请,默认值是 0,也就是无限度。再来看看代码(output_http.go)是怎么实现的:

图 3:GoRepaly 协程并发数决策逻辑

文档里说默认 http 发送协程数无限度,后果代码里设置了 10,差别太大了。为什么 10 个协程不够用呢,因为协程须要原地期待响应后果,也就是会被阻塞住,所以 10 个协程可能打出的 QPS 是无限的。起因找到后,咱们明确设定 –output-http-workers 参数值,倍速回放的 QPS 最终验证下来可能达到要求。

这个问题产生后,咱们对 GoReplay 产生了很大的狐疑,感觉这个问题比拟低级。这样的问题都会呈现,那前面是否还会呈现有其余问题呢,所以用起来心里发毛。当然,因为这个我的项目保护的人很少,根本能够认定是集体我的项目。且该我的项目通过没有大规模的利用,尤其没有大公司的背书,呈现这样的问题也能了解,没必要太苛责。因而前面碰到问题只能见招拆招了,反正代码都有了,间接白盒审计吧。

3.3 总结与反思

先说说选型过程中存在的问题吧。从下面的形容上来看,我在选型和验证过程均犯了一些较为重大的谬误,被本人活泼的上了一课。在选型阶段,对于知名度,竟然认为 star 比拟多就算比拟有名了,当初想想还是太童稚了。比起知名度,成熟度其实更重要,稳固坑少上班早🤣。另外,可观测性也肯定要思考,否则查问题时你将体验到什么是无助感。

在验证阶段,性能验证没有太大问题。但性能验证只是象征性的搞了一下,最终在与业务线共事一起验证时翻车了。所以验证期间,性能测试是不能马虎的,一旦相干问题上线后才发现,那就很被动了。

依据这次的技术选型经验做个总结,当前搞技术选型时再翻出来看看。选型维度总结如下:

维度 阐明
功能性 1. 选型的性能是否可能满足需要,如果不满足,二次开发的老本是怎么的
成熟度 1. 在相干畛域内,选型是否通过大范畴应用。比方 Java Web 畛域,Spring 技术栈根本人尽皆知
2. 一些小众畛域的选型可能利用并不是很宽泛,那只能本人多去看看 issue,搜寻一些踩坑记录,自行评估了
可观测性 1. 外部状态数据是否有观测伎俩,比方 GoReplay 会把外部状态数据定时打印进去
2. 方不不便接入公司的监控零碎也要思考,毕竟人肉察看太吃力

验证总结如下:

  1. 依据要求一项一项的去验证选型的性能是否合乎预期,能够搞个验证的 checklist 进去,逐项确认
  2. 从多个可能的方面对选型进行性能测试,在此过程中留神察看各种资源耗费状况。比方 GoReplay 流量录制、过滤和回放性能都是必须要做性能测试的
  3. 对选型的长时间运行的稳定性要进行验证,对验证期间存在的异常情况留神观测和剖析
  4. 更严格一点,能够做一些故障测试。比方杀过程,断网等

对于选型更具体的实战经验,能够参考李运华大佬的文章:如何正确的应用开源我的项目。

4. 具体实际

当技术选型和验证都实现后,接下来就是要把想法变为事实的时候了。依照当初小步快跑,疾速迭代的模式,启动阶段通常咱们仅会布局最外围的性能,保障流程走通。接下来再依据需要的优先级进行迭代,逐步完善。接下来,我将在依照我的项目的迭代过程来进行介绍。

4.1 最小可用零碎

4.1.1 需要介绍

序号 分类 需要点 阐明
1 录制 流量过滤,按需录制 反对按 HTTP 申请门路过滤流量,这样能够录制指定接口的流量
2 录制时长可指定 可设定录制时长,个别状况下都是录制 10 分钟,把流量波峰录制下来
3 录制工作详情 蕴含录制状态、录制后果统计等信息
4 回放 回放时长可指定 反对设定 1 ~ 10 分钟的回放时长
5 回放倍速可指定 依据录制时的 QPS,按倍数进行流量放大,最小粒度为 1 倍速
6 回放过程容许人为终止 在发现被压测利用呈现问题时,可人为终止回放过程
7 回放工作详情 蕴含回放状态、回放后果统计

以上就是我的项目启动阶段的需要列表,这些都是最根本需要。只有实现这些需要,一个最小可用的零碎就实现了。

4.1.2 技术计划简介

4.1.2.1 架构图

图 4:压测系统一期架构图

下面的架构图通过编辑,与理论有肯定差别,但不影响解说。须要阐明的是,咱们的网关服务、压测机以及压测服务都是别离由多台形成,所有网关和压测实例均部署了 GoRepaly 及其控制器。这里为了简化架构图,只画了一台机器。上面对一些外围流程进行介绍。

4.1.2.2 Gor 控制器

在介绍其余内容之前,先说一下 Gor 控制器的用处。用一句话介绍:引入这个中间层的目标是为了将 GoReplay 这个命令行工具与咱们的压测系统进行整合。这个模块是咱们本人开发,最早应用 shell 编写的(苦不堪言😭),起初用 Go 语言重写了。Gor 控制器次要负责上面一些事件:

  1. 把握 GoRepaly 生杀大权,能够调起和终止 GoReplay 程序
  2. 屏蔽掉 GoReplay 应用细节,升高复杂度,进步易用性
  3. 回传状态,在 GoReplay 启动前、完结后、其余标志性事件完结后都会向压测系统回传状态
  4. 对录制和回放产生数据进行解决与回传
  5. 打日志,记录 GoRepaly 输入的状态数据,便于后续排查

GoReplay 自身只提供最根本的性能,能够把其设想成一个只有底盘、轮子、方向盘和发动机等根本配件的汽车,尽管能开起来,然而比拟吃力。而咱们的 Gor 控制器相当于在其根底上提供了一键启停,转向助力、车联网等加强性能,让其变得更好用。当然这里只是一个近似的比喻,不要纠结合理性哈。通晓控制器的用处后,上面介绍启动和回放的执行过程。

4.1.2.3 录制过程介绍

用户的录制命令首先会发送给压测服务,压测服务本来能够通过 SSH 间接将录制命令发送给 Gor 控制器的,但出于平安思考必须绕道运维零碎。Gor 控制器收到录制命令后,参数验证无误,就会调起 GoReplay。录制完结后,Gor 控制器会将状态回传给压测系统,由压测断定录制工作是否完结。具体的流程如下:

  1. 用户设定录制参数,提交录制申请给压测服务
  2. 压测服务生成压测工作,并依据用户指定的参数生成录制命令
  3. 录制命令经由运维零碎下发到具体的机器上
  4. Gor 控制器收到录制命令,回传“录制行将开始”的状态给压测服务,随后调起 GoReplay
  5. 录制完结,GoReplay 退出,Gor 控制器回传“录制完结”状态给压测服务
  6. Gor 控制器回传其余信息给压测系统
  7. 压测服务断定录制工作完结后,告诉压测机将录制数据读取到本地文件中
  8. 录制工作完结

这里阐明一下,要想应用 GoReplay 倍速回放性能,必须要将录制数据存储到文件中。而后通过上面的参数设置倍速:

# 三倍速回放
gor --input-file "requests.gor|300%" --output-http "test.com"
4.1.2.4 回放过程介绍

回放过程与录制过程根本类似,只不过回放的命令是固定发送给压测机的,具体过程就不赘述了。上面说几个不同点:

  1. 给回放流量打上压测标:回放流量要与实在流量辨别开,须要一个标记,也就是压测标
  2. 按需改写参数:比方把 user-agent 改为 goreplay,或者减少测试账号的 token 信息
  3. GoReplay 运行时状态收集:蕴含 QPS,工作队列积压状况等,这些信息能够帮忙理解 GoReplay 的运行状态

4.1.3 不足之处

这个最小可用零碎在线上差不多运行了 4 个月,没有呈现过太大的问题,但依然有一些不足之处。次要有两点:

  1. 命令传递的链路略长,增大的出错的概率和排查的难度。比方运维零碎的接口偶然失败,要害还没有日志,一开始基本没法查问题
  2. Gor 控制器是用 shell 写的,约 300 行。shell 语法和 Java 差别比拟大,代码也不好调试。同时对于简单的逻辑,比方生成 JSON 字符串,写起来很麻烦,后续保护老本较高

这两点有余始终随同着咱们的开发和运维工作,直到前面进行了一些优化,才算是彻底解决掉了这些问题。

4.2 继续优化

图 5:Gor 控制器优化后的架构图

针对后面存在的痛点,咱们进行了针对性的改良。重点应用 Go 语言重写了 gor 控制器,新的控制器名称为 gor-server。从名称上能够看出,咱们内置了一个 HTTP 服务。基于这个服务,压测服务下发命令终于不必再绕道运维零碎了。同时所有的模块都在咱们的掌控中,开发和保护的效率显著变高了。

4.3 反对 Dubbo 流量录制

咱们外部采纳 Dubbo 作为 RPC 框架,利用之间的调用均是通过 Dubbo 来实现的,因而咱们对 Dubbo 流量录制也有较大的需要。在针对网关流量录制获得肯定成绩后,一些负责外部零碎的共事也心愿通过 GoReplay 来进行压测。为了满足外部的应用需要,咱们对 GoReplay 进行了二次开发,以便反对 Dubbo 流量的录制与回放。

4.3.1 Dubbo 协定介绍

要对 Dubbo 录制进行反对,需首先搞懂 Dubbo 协定内容。Dubbo 是一个二进制协定,它的编码规定如下图所示:

图 6:Dubbo 协定图示;起源:Dubbo 官方网站

上面简略对协定做个介绍,依照图示程序顺次介绍各字段的含意。

字段 位数(bit) 含意 阐明
Magic High 8 魔数高位 固定为 0xda
Magic Low 8 魔数低位 固定为 0xbb
Req/Res 1 数据包类型 0 – Response
1 – Request
2way 1 调用形式 0 – 单向调用
1 – 双向调用
Event 1 事件标识 比方心跳事件
Serialization ID 5 序列化器编号 2 – Hessian2Serialization<br/>3 – JavaSerialization<br/>4 – CompactedJavaSerialization<br/>6 – FastJsonSerialization
……
Status 8 响应状态 状态列表如下:
20 – OK
30 – CLIENT_TIMEOUT
31 – SERVER_TIMEOUT
40 – BAD_REQUEST
50 – BAD_RESPONSE
……
Request ID 64 申请 ID 响应头中也会携带雷同的 ID,用于将申请和响应关联起来
Data Length 32 数据长度 用于标识 Variable Part 局部的长度
Variable Part(payload) 数据载荷

通晓了协定内容后,咱们把官网的 demo 跑起来,抓个包钻研一下。

图 7:dubbo 申请抓包

首先咱们能够看到占用两个字节的魔数 0xdabb,接下来的 14 个字节是协定头中的其余内容,简略剖析一下:

图 8:dubbo 申请头数据分析

下面标注的比较清楚了,这里略微解释一下。从第三个字节能够看出这个数据包是一个 Dubbo 申请,因为是第一个申请,所以申请 ID 是 0。数据的长度是 0xdc,换算成十进制为 220 个字节。加上 16 个字节的音讯头,总长度正好是 236,与抓包结果显示的长度是统一。

4.3.2 Dubbo 协定解析

咱们对 Dubbo 流量录制进行反对,首先须要依照 Dubbo 协定对数据包进行解码,以判断录制到的数据是不是 Dubbo 申请。那么问题来了,如何判断所录制到的 TCP 报文段里的数据是 Dubbo 申请呢?答案如下:

  1. 首先判断数据长度是不是大于等于协定头的长度,即 16 个字节
  2. 判断数据前两个字节是否为魔数 0xdabb
  3. 判断第 17 个比特位是不是 1,不为 1 可抛弃掉

通过下面的检测可疾速判断出数据是否合乎 Dubbo 申请格局。如果检测通过,那接下来又如何判断录制到的申请数据是否残缺呢?答案是通过比拟录制到的数据长度 L1 和 Data Length 字段给出的长度 L2,依据比拟后果进行后续操作。有如下几种状况:

  1. L1 == L2,阐明数据接管残缺,无需额定的解决逻辑
  2. L1 < L2,阐明还有一部分数据没有接管,持续期待余下数据
  3. L1 > L2,阐明多收到了一些数据,这些数据并不属于以后申请,此时要依据 L2 来切分收到的数据

三种状况示意图如下:

图 9:应用层接收端几种状况

看到这里,必定有同学想说,这不就是典型的 TCP“粘包”和“拆包”问题。不过我并不想用这两个词来阐明上述的一些状况。TCP 是一个面向字节流的协定,协定自身并不存在所谓的“粘包”和“拆包”问题。TCP 在传输数据过程中,并不会理睬下层数据是如何定义的,在它看来都是一个个的字节罢了,它只负责把这些字节牢靠有序的运送到指标过程。至于状况 2 和状况 3,那是应用层应该去解决的事件。因而,咱们能够在 Dubbo 的代码中找到相干的解决逻辑,有趣味的同学能够浏览 NettyCodecAdapter.InternalDecoder#decode 办法代码。

本大节内容就到这里,最初给大家留下一个问题。在 GoReplay 的代码中,并没有对状况 3 进行解决。为什么录制 HTTP 协定流量不会出错?

4.3.3 GoReplay 革新

4.3.3.1 革新介绍

GoReplay 社区版目前只反对 HTTP 流量录制,其商业版反对局部二进制协定,但不反对 Dubbo。所以为了满足外部应用需要,只能进行二次开发了。但因为社区版代码与 HTTP 协定解决逻辑耦合比拟大,因而想要反对一种新的协定录制,还是比拟麻烦的。在咱们的实现中,对 GoReplay 的革新次要蕴含 Dubbo 协定辨认,Dubbo 流量过滤,数据包完整性判断等。数据包的解码和反序列化则是交给 Java 程序来实现的,序列化后果转成 JSON 进行存储。成果如下:

图 10:Dubbo 流量录制成果

GoReplay 用三个猴头 🐵🙈🙉 作为申请分隔符,第一眼看到感觉挺搞笑的。

4.3.3.2 GoReplay 插件机制介绍

大家可能很好奇 GoReplay 是怎么和 Java 程序配合工作的,原理倒也是很简略。先看一下怎么开启 GoReplay 的插件模式:

gor --input-raw :80 --middleware "java -jar xxx.jar" --output-file request.gor

通过 middleware 参数能够传递一条命令给 GoRepaly,GoReplay 会拉起一个过程执行这个命令。在录制过程中,GoReplay 通过获取过程的规范输出和输入与插件过程进行通信。数据流向大抵如下:

+-------------+     Original request     +--------------+     Modified request      +-------------+
|  Gor input  |----------STDIN---------->|  Middleware  |----------STDOUT---------->| Gor output  |
+-------------+                          +--------------+                           +-------------+
  input-raw                              java -jar xxx.jar                            output-file           
4.3.3.3 Dubbo 解码插件实现思路

Dubbo 协定的解码还是比拟容易实现的,毕竟很多代码 Dubbo 框架曾经写好了,咱们只须要按需对代码进行批改定制即可。协定头的解析逻辑在 DubboCodec#decodeBody 办法中,音讯体的解析逻辑在 DecodeableRpcInvocation#decode(Channel, InputStream) 办法中。因为 GoReplay 曾经对数数据进行过解析和解决,因而在插件里很多字段就没必要解析了,只有解析出 Serialization ID 即可。这个字段将领导咱们进行后续的反序列化操作。

对于音讯体的解码略微麻烦点,咱们把 DecodeableRpcInvocation 这个类代码拷贝一份放在插件我的项目中,并进行了批改。删除了不须要的逻辑,只保留了 decode 办法,将其变成了工具类。思考到咱们的插件不不便引入要录制利用的 jar 包,所以在批改 decode 办法时,还要留神把和类型相干的逻辑移除掉。批改后的代码大抵如下:

public class RpcInvocationCodec {public static MyRpcInvocation decode(byte[] bytes, int serializationId) {ObjectInput in = CodecSupport.getSerializationById(serializationId).deserialize(null, input);
        
        MyRpcInvocation rpcInvocation = new MyRpcInvocation();
        String dubboVersion = in.readUTF();
        // ......
        rpcInvocation.setMethodName(in.readUTF());    
        
        // 原代码:Class<?>[] pts = DubboCodec.EMPTY_CLASS_ARRAY;
        // 批改后把 pts 类型改成 String[],泛化调用时须要用到类型列表
        String[] pts = desc2className(int.readUTF());
        Object[] args = new Object[pts.length];
        for (int i = 0; i < args.length; i++) {// 原代码:args[i] = in.readObject(pts[i]);
            // 批改后不在依赖具体类型,间接反序列化成 Map
            args[i] = in.readObject();}
        rpcInvocation.setArguments(args);
        rpcInvocation.setParameterTypeNames(pts);
        
        return rpcInvocation;
    }
}

仅从代码开发的角度来说,难度并不是很大,当然前提是要对 Dubbo 的源码有肯定的理解。对我来说,工夫次要花在 GoRepaly 的革新上,次要起因是对 Go 语言不熟,边写边查导致效率很低。当性能写好,调试结束,看到后果正确输入,的确很开心。然而,这种开心也仅维持了很短的工夫。不久在与业务共事进行线上验证的时候,插件花色解体,局面一度非常难堪。报错信息看的我一脸懵逼,一时半会解决不了,为了保留点脸面,连忙终止了验证🤪。预先排查发现,在将一些的非凡的反序列化数据转化成 JSON 格局时,呈现了死循环,造成 StackOverflowError 谬误产生。因为插件主流程是单线程的,且仅捕捉了 Exception,所以造成了插件谬误退出。

图 11:循环依赖导致 Gson 框架报错

这个谬误通知咱们,类之间呈现了循环援用,咱们的插件代码也的确没有对循环援用进行解决,这个谬误产生是正当的。但当找到造成这个谬误的业务代码时,并没找到循环援用,直到我本地调试时才发现了猫腻。业务代码相似的代码如下:

public class Outer {   
    private Inner inner;

    public class Inner {
        private Long xyz;
        
        public class Inner() {}
    }
}

问题出在了外部类上,Inner 会隐式持有 Outer 援用。不出意外,这应该是编译器干的。源码背后了无机密,咱们把外部类的 class 文件反编译一下,所有就明了了。

图 12:外部类反编译后果

这应该算是 Java 基本知识了,奈何平时用的少,第一眼看到代码时,没看出了暗藏在其中的循环援用。到这里解释很正当,这就完结了么?其实还没有,实际上 Gson 序列化 Outer 时并不会报错,调试发现其会排除掉 this$0 这个字段,排除逻辑如下:

public final class Excluder
    public boolean excludeField(Field field, boolean serialize) {
        // ......

        // 判断字段是否是合成的
        if (field.isSynthetic()) {return true;}
    }
}

那么咱们在把录制的流量转成 JSON 时为什么会报错呢?起因是咱们的插件反序列化时拿不到接口参数的类型信息,所以咱们把参数反序列化成了 Map 对象,这样 this$0 这个字段和值也会作为键值对存储到 Map 中。此时 Gson 的过滤规定就不失效了,没法过滤掉 this$0 这个字段,造成了死循环,最终导致栈溢出。晓得起因后,这么问题怎么解决呢?下一大节开展。

4.3.3.4 直击问题

我开始思考是不是能够人为荡涤一下 Map 里的数据,但发现如同很难搞。如果 Map 的数据结构很简单,比方嵌套了很多层,荡涤逻辑可能不好实现。还有我也不分明这外面会不会有其余的一些弯弯绕,所以放弃了这个思路,这种脏活累活还是丢给反序列化工具去做吧。咱们要想方法把拿到接口的参数类型,插件怎么拿到业务利用 api 的参数类型呢?一种形式是在插件启动时,把指标利用的 jar 包下载到本地,而后由独自的类加载器进行加载。但这里会有一个问题,业务利用的 api jar 包外面也存在着一些依赖,这些依赖难道要递归去下载?第二种形式,则简略粗犷点,间接在插件我的项目中引入业务利用 api 依赖,而后打成 fat jar。这样既不须要搞独自的类加载器,也不必去递归下载其余的依赖。惟一比拟显著的毛病就是会在插件我的项目 pom 中引入一些不相干的依赖,但与收益相比,这个毛病基本算不上什么。为了不便,咱们把很多业务利用的 api 都依赖了进来。一番操作后,咱们失去了如下的 pom 配置:

<project>
    <groupId>com.xxx.middleware</groupId>
    <artifactId>DubboParser</artifactId>
    <version>1.0</version>
    
    <dependencies>
        <dependency>
            <groupId>com.xxx</groupId>
            <artifactId>app-api-1</artifactId>
            <version>1.0</version>
        </dependency>
        <dependency>
            <groupId>com.xxx</groupId>
            <artifactId>app-api-2</artifactId>
            <version>1.0</version>
        </dependency>
        ......
    <dependencies>
</project>

接着要改一下 RpcInvocationCodec#decode 办法,其实就是把代码还原回去😓:

public class RpcInvocationCodec {public static MyRpcInvocation decode(byte[] bytes, int serializationId) {ObjectInput in = CodecSupport.getSerializationById(serializationId).deserialize(null, input);
        
        MyRpcInvocation rpcInvocation = new MyRpcInvocation();
        String dubboVersion = in.readUTF();
        // ......
        rpcInvocation.setMethodName(in.readUTF());    
        
        // 解析接口参数类型
        Class<?>[] pts = ReflectUtils.desc2classArray(desc);
        Object args = new Object[pts.length];
        for (int i = 0; i < args.length; i++) {
            // 依据具体类型进行反序列化
            args[i] = in.readObject(pts[i]);
        }
        rpcInvocation.setArguments(args);
        rpcInvocation.setParameterTypeNames(pts);
        
        return rpcInvocation;
    }
}

代码调整结束,择日在上线验证,一切正常,可喜可贺。但不久后,我发现这外面存在着一些隐患。如果哪天在线上产生了,将会给排查工作带来比拟大的艰难。

4.3.3.5 潜在的问题

思考这样的状况,业务利用 A 和利用 B 的 api jar 包同时依赖了一些外部的公共包,公共包的版本可能不统一。这时候,咱们怎么解决依赖抵触?如果外部的公共包做的不好,存在兼容性问题怎么办。

图 13:依赖抵触示意图

比方这里的 common 包版本抵触了,而且 3.0 不兼容 1.0,怎么解决呢?

简略点解决,咱们就不在插件 pom 里依赖所有的业务利用的 api 包了,而是只依赖一个。然而害处是,每次都要为不同的利用独自构建插件代码,显然咱们不喜爱这样的做法。

再进一步,咱们不在插件中依赖业务利用的 api 包,放弃插件代码洁净,就不必每次都打包了。那怎么获取业务利用的 api jar 包呢?答案是为每个 api jar 专门建个我的项目,再把我的项目打成 fat jar,插件代码应用自定义类加载器去加载业务类。插件启动时,依据配置去把 jar 包下载到机器上即可。每次只须要加载一个 jar 包,所以也就不存在依赖抵触问题了。做到这一步,问题就能够解决了。

更进一步,新近在浏览阿里开源的 jvm-sandbox 我的项目源码时,发现了这个我的项目实现了一种带有路由性能的类加载器。那咱们的插件是否也搞个相似的加载器呢?出于好奇,尝试了一下,发现是能够的。最终的实现如下:

图 14:自定义类加载机制示意图

一级类加载器具备依据包名“片段”进行路由的性能,二级类加载器负责具体的加载工作。利用 api jar 包对立放在一个文件夹下,只有二级类加载器能够进行加载。对于 JDK 中的一些类,比方 List,还是要交给 JVM 内置的类加载器进行加载。最初阐明一下,搞这个领路由性能的类加载器,次要目标是为了玩。尽管能达到目标,但在理论我的项目中,还是用上一种办法稳当点。

4.4 开花结果,落地新场景

咱们的流量录制与回放零碎次要的,也是过后惟一的应用场景是做压测。零碎稳固后,咱们也在思考还有没有其余的场景能够搞。正好在技术选型阶段试用过 jvm-sandbox-repeater,这个工具次要利用场景是做流量比照测试。对于代码重构这种不影响接口返回值构造的改变,能够通过流量比照测试来验证改变是否有问题。因为大佬们感觉 jvm-sandbox-repeater 和底层的 jvm-sandbox 有点重,技术复杂度也比拟高。加之没有资源来开发和保护这两个工具,因而心愿咱们基于流量录制和回放零碎来做这个事件,先把流程跑通。

我的项目由 QA 团队主导,流量重放与 diff 性能由他们开发,咱们则提供底层的录制能力。零碎的工作示意图如下:

图 15:比照测试示意图

咱们的录制零碎为重放器提供实时的流量数据,重放器拿到数据后立刻向预发和线上环境重放。重放后,重放器能够别离拿到两个环境返回的后果,而后再把后果传给比对模块进行后续的比对。最初把比对后果存入到数据库中,比对过程中,用户能够看到哪些申请比对失败了。对于录制模块来说,要留神过滤重放流量。否则会造成接口 QPS 倍增,重放变压测了🤣,喜提故障一枚。

这个我的项目上线 3 个月,帮忙业务线发现了 3 个比较严重的 bug,6 个个别的问题,价值初现。尽管我的项目不是咱们主导的,然而作为底层服务的提供方,咱们也很开心。冀望将来能为咱们的零碎拓展更多的应用场景,让其成长为一棵枝繁叶茂的大树。

5. 我的项目成绩

截止到文章公布工夫,我的项目上线靠近一年的工夫了。总共有 5 个利用接入应用,录制和回放次数累计差不多四五百次。应用数据上看起来有点寒碜,次要是因为公司业务是 toB 的,对压测的需要并没那么多。只管应用数据比拟低,然而作为压测系统,还是施展了相应价值。次要蕴含两方面:

  1. 性能问题发现:压测平台共为业务线发现了十几个性能问题,帮忙中间件团队发现了 6 个重大的根底组件问题
  2. 应用效率晋升:新的压测系统性能简略易用,仅需 10 分钟就能实现一次线上流量录制。相较于以往单人半天能力实现的事件,效率至多晋升了 20 倍,用户体验大幅晋升。一个佐证就是目前 90% 以上的压测工作都是在新平台上实现的。

可能大家对效率晋升数据有所狐疑,大家能够思考一下没有录制工具如何获取线上流量。传统的做法是业务开发批改接口代码,加一些日志,这要留神日志量问题。之后,把改变的代码公布到线上,对于一些比拟大的利用,一次公布波及到几十台机器,还是相当耗时的。接着,把接口参数数据从日志文件中荡涤进去。最初,还要把这些数据转换成压测脚本。这就是传统的流程,每个步骤都比拟耗时。当然,基建好的公司,能够基于全链路追踪平台拿到接口数据。但对于大多数公司来说,可能还是要应用传统的形式。而在咱们的平台上,只须要抉择指标利用和接口、录制时长、点击录制按钮就行了,用户操作仅限这些,所以效率晋升还是很显著的。

6. 展望未来

我的项目我的项目尽管曾经上线一年,但因为人手无限,目前根本只有我一个人在开发保护,所以迭代还是比较慢的。针对目前在实践中碰到的一些问题,这里把几个显著的问题,心愿将来可能一一解决掉。

1. 全链路节点压力图

目前在压测的时候,压测人员须要到监控平台上关上很多个利用的监控页面,压测期间须要在多个利用监控之间进行切换。心愿将来能够把全链路上各节点的压力图展现进去,同时能够把节点的报警信息发送给压测人员,升高压测的监督老本。

2. 压测工具状态收集与可视化

压测工具本身有一些很有用的状态信息,比方工作队列积压状况,以后的协程数等。这些信息在压测压力上不去时,能够帮忙咱们排查问题。比方工作队列工作数在增大,协程数也放弃高位。这时候能推断出什么起因吗?大概率是被压利用压力太大,导致 RT 变长,进而造成施压协程(数量固定)长时间被阻塞住,最终导致队列呈现积压状况。GoReplay 目前这些状态信息输入到管制台上的,查看起来还是很不不便。同时也没有告警性能,只能在出问题时被动去查看。所以冀望将来能把这些状态数据放到监控平台上,这样体验会好很多。

3. 压力感知与主动调节

目前压测系统更没有对业务利用的压力进行感知,不论压测利用处于什么状态,压测系统都会依照既定的设置进行压测。当然因为 GoReplay 并发模型的限度,这个问题目前不必放心。但将来不排除 GoReplay 的并发模型会发生变化,比方只有工作队列里有工作,就立刻起个协程发送申请,此时就会对业务利用造成很大的危险。

还有一些问题,因为重要水平不高,这里就不写了。总的来说,目前咱们的压测需要还是比拟少,压测的 QPS 也不高,导致很多优化都没法做。比方压测机性能调优,压测机器动静扩缩容。但想想咱们就 4 台压测机,默认配置齐全能够满足需要,所以这些问题都懒得去折腾🤪。当然从集体技术能力晋升的角度来说,这些优化还是很有价值的,有工夫能够玩玩。

7. 集体播种

7.1 技术播种

1. 入门 Go 语言

因为 GoReplay 是 Go 语言开发的,而且咱们在应用中的确也遇到了一些问题,不得不深刻源码排查。为了更好的掌控工具,不便排查问题和二次开发,所以专门学习了 Go 语言。目前的程度处于入门阶段,菜鸟程度。用 Java 用久了,刚开始学习 Go 语言还是很懵逼的。比方 Go 的 办法 定义:

type Rectangle struct {
    Length uint32
    Width  uint32
}

// 计算面积
func (r *Rectangle) Area() uint32 {return r.Length * r.Width}

过后感觉这个语法十分的奇怪,Area 办法名后面的申明是什么鬼。好在我还有点 C 语言的常识,转念一想,如果让 C 去实现面向对象又该如何做呢?

struct Rectangle {
    uint32_t length;
    uint32_t width;
 
    // 成员函数申明
    uint32_t (*Area) (struct Rectangle *rect);
};

uint32_t Area(struct Rectangle *rect) {return rect->length * rect->width;}

struct Rectangle *newRect(uint32_t length, uint32_t width)
{struct Rectangle *rp = (struct Rectangle *) malloc(sizeof(struct Rectangle));  
    rp->length = length;
    rp->width = width;
 
    // 绑定函数
    rp->Area = Area;
    return rp;
}

int main()
{struct Rectangle *rp = newRect(5, 8);
    uint32_t area = rp->Area(rectptr);
    printf("area: %u\n", area);
    free(pr);
    return 0;
}

搞懂了下面的代码,就晓得 Go 的办法为什么要那么定义了。

随着学习的深刻,发现 Go 的语法个性和 C 还真的很像,竟然也有指针的概念,21 世纪的 C 语言果然名不副实。于是在学习过程中,会情不自禁的比照两者的个性,依照 C 的教训去学习 Go。所以当我看到上面的代码时,十分的惊恐。

func NewRectangle(length, width uint32) *Rectangle {var rect Rectangle = Rectangle{length, width}
    return &rect
}

func main() {fmt.Println(NewRectangle(4, 5).Area())
}

过后预期操作系统会有情的抛个 segmentation fault 谬误给我,然而编译运行竟然没有问题 … 问.. 题..。难道是我错了?再看一遍,心想没问题啊,C 语言里不能返回栈空间的指针,Go 语言也不应该这么操作吧。这里就体现出两个语言的区别了,下面的 Rectangle 看起来像是在栈空间里调配到,实际上是在堆空间里调配的,这个和 Java 倒是一样的。

总的来说,Go 语法和 C 比拟像,加之 C 语言是我的启蒙编程语言。多以对于 Go 语言,也是感觉十分亲切和喜爱的。其语法简略,规范库丰盛易用,应用体验不错。当然,因为我目前还在新手村混,没有用 Go 写过较大的工程,所以对这个语言的意识还比拟肤浅。以上有什么不对的中央,也请大家见谅。

2. 较为熟练掌握了 GoReplay 原理

GoReplay 录制和回放外围的逻辑根本都看了一遍,并且在内网也写过文章分享,这里简略和大家聊聊这个工具。GoReplay 在设计上,形象出了一些概念,比方用 输出 输入 来示意数据起源与去向,用介于输出和输出模块之间的 中间件 实现拓展机制。同时,输出和输入能够很灵便的组合应用,甚至能够组成一个集群。

图 16:GoReplay 集群示意图

录制阶段,每个 tcp 报文段被形象为 packet。当数据量较大,须要分拆成多个报文段发送时,收端须要把这些报文段按程序序组合起来,同时还要解决乱序、反复报文等问题,保障向下一个模块传递的是一个残缺无误的 HTTP 数据。这些逻辑统封装在了 tcp_message 中,tcp_message 与 packet 是一对多的关系。前面的逻辑会将 tcp_message 中的数据取出,打上标记,传递给中间件(可选)或者是输出模块。

回放阶段流程绝对简略,但依然会依照 输出 → [中间件] → 输入 流程执行。通常输出模块是 input-file,输出模块是 output-http。回放阶段一个有意思的点是倍速回放的原理,通过按倍数缩短申请间的距离实现减速性能,实现代码也很简略。

总的来说,这个工具的外围代码并不多,然而性能还是比拟丰盛的,能够体验一下。

3. 对 Dubbo 框架和类加载机制有了更多的认知

在实现 Dubbo 流量录制时,基本上把解码相干的逻辑看了一遍。当然这块逻辑以前也看过,还写过文章。只不过这次要去定制代码,还是会比单纯的看源码写文章理解的更深刻一些,毕竟要去解决一些理论的问题。在此过程中,因为须要自定义类加载器,所以对类加载机制也有了更多的意识,尤其是那个领路由性能的类加载器,还是挺好玩的。当然,学会这些技术也没什么大不了的,重点还是可能发现问题,解决问题。

4. 其余播种

其余的播种都是一些比拟小的点,这里就不多说了,以问题的模式留给大家思考吧。

  1. TCP 协定会保障向下层 有序 交付数据,为何工作在应用层的 GoReplay 还要解决乱序数据?
  2. HTTP 1.1 协定通信过程是怎么的?如果在一个 TCP 连贯上间断发送两个 HTTP 申请会造成什么问题?

7.2 教训和感想

1. 技术选型要谨慎

开始搞选型没什么教训,考查维度很少,不够全面。这就导致了几个问题,首先在验证阶段工具始终达不到预期,耽搁了不少工夫。其次在后续的迭代期间,发现 GoReplay 的小问题比拟多,感觉谨严水平不够。比方 1.1.0 版本应用文档和代码有很多处差别,应用时要小心。再比方应用过程中,发现 1.3.0-RC1 版本中存在资源泄露问题 #926,棘手帮忙修复了一下 #927。当然 RC 版本有问题也很失常,然而这么显著的问题说实话不应该出。不过思考到这个我的项目是集体保护的,也不能要求太多。然而对于使用者来说,还是要当心。这种要在生产上运行的程序,不靠谱是很闹心的事件。所以对于我集体而言,当前选型成熟度肯定会排在第一位。对于集体保护的我的项目,尽量不作为靠前的候选项。

2. 技术验证要全面

初期的选型没有进行性能测试和极限测试,这就导致问题在线上验证时才发现。这么显著的问题,拖到这么晚才发现,搞的挺难堪的。所以对于技术验证,要从不同的角度进行性能测试,极限测试。更严格一点,能够向李运华大佬在 如何正确的应用开源我的项目 文章中提的那样,搞搞故障测试,比方杀过程,断电等。把前期工作做足,防止前期被动。

3. 磨刀不误砍柴工

这个我的项目波及到不同的技术,公司现有的开发平台无奈反对这种我的项目,所以打包和公布是个麻烦事。在开发和测试阶段会频繁的批改代码,如果手动进行打包,而后上传的 FTP 服务器上(无奈间接拜访线上机器),最初再部署到具体的录制机器上,这是一件非常机械低效的事件。于是我写了一个自动化构建脚本,来晋升构建和部署效率,实践证明成果挺好。从此心态稳固多了😀,很少进入火暴模式了。

图 17:自动化构建脚本效果图

非常难堪的是,我在我的项目上线后才把脚本写好,后期没有享受到自动化的福利。不过好在后续的迭代中,自动化脚本还是帮了很大的忙。尽早实现编译和打包自动化工具,有助于进步工作效率。只管咱们会感觉写工具也要花不少工夫,但如果能够预料到很多事件会反复很屡次,那么这些工具带来的收益将会远超付出。

8. 写在最初

十分侥幸可能参加并主导这个我的项目,总的来说,我集体还是从中学到了很多货色。这算是我职业生涯中第一个深度参加和继续迭代的我的项目,看着它的性能逐步欠缺起来,稳固不间断给大家提供服务,施展出其价值。作为我的项目负责人,我还是十分开心自豪的。但同时也有些遗憾的,因为公司的业务是 toB 的,对压测系统的要求并不高。零碎目前算是进入了稳定期,没有太多可做的需要或者大的问题。我尽管能够私下做一些技术上的优化,但很难看出成果,毕竟现有的应用需要还没达到零碎瓶颈,提前优化并不是一个好主见。冀望将来公司的业务能有大的倒退,对压测系统提出更高的要求,我也非常乐意持续优化这个零碎。另外,要感激一起参加我的项目的共事,他们的强力输入得以让我的项目在缓和的工期内保质保量上线,如期为业务线提供服务。

本篇文章到此结束,感激浏览。个好主见。冀望将来公司的业务能有大的倒退,对压测系统提出更高的要求,我也非常乐意持续优化这个零碎。另外,要感激一起参加我的项目的共事,他们的强力输入得以让我的项目在缓和的工期内保质保量上线,如期为业务线提供服务。

本篇文章到此结束,感激浏览。

本文在常识共享许可协定 4.0 下公布,转载请注明出处
作者:田小波
原创文章优先公布到集体网站,欢送拜访:https://www.tianxiaobo.com


本作品采纳常识共享署名 - 非商业性应用 - 禁止演绎 4.0 国内许可协定进行许可。

退出移动版