一种回放式压测工具的设计

不知道有多少人之前了解过 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