一种回放式压测工具的设计
不知道有多少人之前了解过 TCPCopy
、 TCPReplay
、 GoReplay
这类工具?这类工具都是将请求转发到另外一个环境中实现流量的『回放』,在量化金融领域,也有类似的概念,称之为“回测”。
这种回放型的测试其实非常适合用于压测,这要从传统压测的缺陷说起。
假设有这么一个外卖下单接口: POST /api/order
,需要传入商户ID、菜品ID等参数,如果计划用100并发线程去压这个接口,你肯定会考虑以下几个问题:
- 是使用同一商户、同一菜品去压测?
- 是使用同一商户、不同菜品去压测?
- 是使用100个商户、不同菜品去压测?
- 是使用20个商户、每个商户相同菜品去压测?
- …
为什么会有这么多场景?
以菜品对象为例,开发为了防止菜品出现超卖情况,需要使用锁来对菜品库存的扣减进行保护,保证竞态下扣减依然安全可靠。这样来,当使用同一个菜品去进行压测,所有请求都在竞争这把锁,当某个请求竞争得到时,其他线程只能处于等待状态,势必影响了服务的吞吐能力;相反,如果使用不同菜品压测,每个菜品的锁不存在竞争者,就不会出现其他请求等待的情况,吞吐能力更强。
以上只是举例说明,实际开发中可以追求最终一致性来提高吞吐能力
上面这个例子可以看出,传统的压测适合做基准测试,但如果要高仿真的模拟生产流量去评估容量瓶颈、规模增长影响等,较难进行流量特征的建模,因此导致评估结果也不够准确。
为什么是回放?
回放型测试能够解决上面提出的问题吗?
我们从下面这段Nginx的访问日志来展开:
47.29.201.179 - - [28/Feb/2019:13:17:10 +0000] "GET /?p=1 HTTP/2.0" 200 5316 "https://domain1.com/?p=1" "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36" "2.75"
上面的日志里包含了几个关键信息:
- 请求时间
- 请求方法
- 请求路径
- 请求头(UA部分)
- 请求参数(无Body部分)
- 返回结果状态码
这段日志具备了构建一个HTTP请求的完整信息(暂只考虑GET请求),手动回放这个请求很简单:
curl https://domain1.com/?p=1
我们再增加几行日志,比如:
47.29.201.179 - - [28/Feb/2019:13:17:10 +0000] "GET /?p=1 HTTP/2.0" 200 5316 "https://domain1.com/?p=1" "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36" "2.75" 47.29.201.179 - - [28/Feb/2019:13:17:20 +0000] "GET /?p=3 HTTP/2.0" 200 5316 "https://domain1.com/?p=3" "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36" "2.75" 47.29.201.179 - - [28/Feb/2019:13:18:20 +0000] "GET /?p=3 HTTP/2.0" 200 5316 "https://domain1.com/?p=2" "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36" "2.75"
那么手动回放下这个过程:
curl https://domain1.com/?p=1 curl https://domain1.com/?p=3 curl https://domain1.com/?p=2
上面这几行nginx的日志我不知道是不是已经能让你明白回放型压测的好处了: 如果我能从日志里提取出访问的接口以及接口参数,那么在压测设计时,我无需关注要用几个商户几个菜品去压测,日志内容已经告诉了我这一切。
整个流量特征建模的工作一下子就被解决了。
为什么不是GoReplay?
那是不是可以直接使用 GoReplay
这样的工具来实现?
我们先回到上面那三行nginx日志以及我们写出的回放步骤,有没有觉得不太准确?
上面的curl命令忽略了这三个请求之间的时间差:
- 2019:13:17:10
- 2019:13:17:20
- 2019:13:18:20
更仿真的回放过程其实是:
curl https://domain1.com/?p=1 sleep 10 curl https://domain1.com/?p=3 sleep 60 curl https://domain1.com/?p=2
时间戳是在回放型测试中非常重要的信息,它能告知控制程序应该在哪个时间点发出这个请求,而不是一股脑的丢出去。
我之前使用 GoReplay
进行离线回放时,它并不能根据时间戳来阻塞/放行请求,这样也就做不到高仿真了。另外控制中间的等待时间,还能实现快放、慢放能力,整个行为听上去是不是很像下面这个物件?

设计要点
我按照以下的架构来设计一个回放型压测工具

日志文件
日志文件由具体被压测服务来提供,希望包含时间戳、请求参数等信息。
不过在我使用过程中,不要把“日志文件”局限于真正的日志文件上,“日志文件”也可以是数据库记录、也可以是队列中的消息,只要包含了请求时间刻度的、包含了能够转换成请求内容的对象都可以视作“日志文件”。
Parser
提供了对日志文件的解析能力,能通过读取日志文件中的内容,提取出构建整个请求必要的信息:
- 请求方式
- 请求头
- 请求路径(包含query string)
- 请求body
- 请求时间
因为每个服务日志内容格式是不同的, Parser
往往得由压测具体的执行者来实现,很难提供一个大而全的统一解决方案。
TimeWheel
时间轮,用来控制阻塞/放行请求。家里还有录音机的朋友,可以把这个对象想象成仓内控制磁带转动的两根轴。
控制轴的转速就能实现回放的速率了。
Repeater
在压测中,如果我们想评估业务规模增长两倍、三倍后的服务器承压情况,这时候光一比一的回放就没有价值了。Repeater提供了请求的增益(倍率)能力,能将一个请求复制出多份,从而实现对业务增长的模拟。
另外,即便一比一等量回放,我们依然要面对回放型压测中不得不解决的一个问题:幂等性。
f(x) = f(f(x))
再举一个例子:我们的创建商户接口 POST: /api/createMch
,要求传入的商户名称是唯一的(业务要求),这个时候日志里包含了一个创建商户的动作,创建了一家小吃店:天大地大小吃店。如果你从日志中提取后,重放这个请求,必然创建失败:数据库中已经存在该商户。
你当然可以选择在压测前清理数据了,但是如果需要你放大一倍的量回放呢?也就是说,回放时会发出两个请求来创建“天大地大小吃店”,那其中必然有个接口报错。这样的回放就没有考虑接口幂等特性,无法达到要求。
因此 Repeater
模块不但需要实现请求增益能力还需要解决接口幂等性的问题。
Replayer
这个功能模块就比较纯粹了,就是用于发送请求。
除了要考虑并发模型。
为什么要考虑并发模型?直接用多线程模块不好吗?先看下我之前写的这篇文章: 聊聊ab、wrk、JMeter、Locust这些压测工具的并发模型差别
再回到上面提到的curl命令中,我们注意到日志中三次请求进入的时间差异是10秒、60秒。但我们curl命令中使用了sleep阻塞时间,如果第一次调用十几秒才完成,那么第二次请求发送后,实际在服务端感知到的请求时间差了20多秒(第一次请求的处理时间+10秒),这样仿真度就不够了。
所以在回放型的压测中,建议使用异步请求来实现
开源项目
我之前在练手Python内置的 asyncio
库的时候,写过一个单机版的回放工具:log-replay,完全可以应用在一般项目中。
另外我们近期也将开源内部使用的分布式回放压测工具Havok,内部应用在生产环境的全链路压测任务中。具备以下特点:
- Golang实现,简单高效
- 支持多个数据源,如日志文件、Kafka、ElasticSearch、阿里云SLS
- InfluxDB+Prometheus+Grafana,包含压测数据、自身metric
- 丰富的内置函数库,解决接口幂等性、request、response上下文管理等问题
参考资料:
- TCPCopy
- TCPReplay
- GoReplay
