wrk是一款开源的高性能http压测工具(也支持https),非常小巧,可以执行文件只有3M(其中主要是luajit和openssl占用绝大多数空间),别看核心代码3-5年没更新了,但依旧非常好用。虽然很早之前我就知道有这么个工具了,当时学习这个工具的时候我还拿它压测了我们的个人网站xindoo.me,发现mysql性能不行后加了wp-cache,通过cache把我网站的承载能力提升了10多倍。但当时之前简单使用它的初级功能,最近工作中恰好有个http服务需要压测,然后就拿wrk做了。这次使用了wrk lua高级功能实现了压测,我们找到了我们服务的瓶颈,同时也被wrk的超高性能所震惊。
如上图,我用单机(40 cores)压90台机器的集群,压到了31w的QPS,最后压不上去不是因为这台机器抗不住了,而是因为我们服务扛不住了。一个有复杂业务逻辑的服务和一个毫无逻辑的压测相比有失公允,但在压测过程中我也干垮了4台机器的nginx集群(这里nginx也只是个方向代理而已),这足见wrk性能之高。依赖lua脚本,wrk也可以完成复杂http请求的压测,接下来跟我一起了解下wrk的具体使用吧。
wrk的一切内容都在githubhttps://github.com/wg/wrk上,不像其他各种流行的工具包包一样,它并没有提供各个平台的可执行包,只有在mac上可以通过brew安装(应该也不是作者提供的)。好在编译wrk并不难,也不需要什么特殊的配置,git clone https://github.com/wg/wrk.git
或从github上直接下载zip包,进入项目目录后直接执行make
,你就可以得到一个可执行文件wrk 。
Options:
-c, --connections <N> Connections to keep open # 指定建立多少个网络链接,所有线程复用这些链接
-d, --duration <T> Duration of test # 指定总共起多少个线程
-t, --threads <N> Number of threads to use # 压测持续多长时间
-s, --script <S> Load Lua script file # 指定lua脚本文件,后文会详细介绍
-H, --header <H> Add header to request # 指定http请求的header头
--latency Print latency statistics
--timeout <T> Socket/request timeout
-v, --version Print version details # 输出版本号,经我测试实际上是用不了的
wrk这个命令提供的参数也不多,运用这些参数可以一行命令完成一个简单http请求的压测,我们以国民检测网络情况最常用的一个网站为例。
> ./wrk https://www.baidu.com -c100 -t10 -d100s
Running 20s test @ https://www.baidu.com
10 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 145.48ms 91.46ms 1.24s 93.71%
Req/Sec 71.11 16.91 144.00 66.95%
14161 requests in 20.09s, 211.91MB read
Socket errors: connect 0, read 137, write 0, timeout 0
Requests/sec: 705.00
Transfer/sec: 10.55MB
通过一行shell命令就可以轻而易举完成对百度首页的压测,但如果你需要压一些复杂的http请求时,指定这些参数明显做不到,这时候就需要wrk的高级功能,通过-s指定lua脚本。 当然lua脚本也不是随便写了就能用的,需要按wrk的规范去写wrk才能正常调用。
wrk封装了一个http请求的结构,他是通过wrk这个结构体中的内容去完成一次http请求的,所以你想让http请求不同只需要修改这里面的内容即可,wrk提供了让你修改内容的方法。注意:wrk每个线程都是单独的lua运行环境,互不干扰,没有交集。如果你想在多线程共享一些数据的话,你可以用table
这个全局变量来共享。
wrk = {
scheme = "http",
host = "localhost",
port = nil,
method = "GET",
path = "/",
headers = {},
body = nil,
thread = <userdata>,
}
除了上述结构体外,wrk允许你重写有些给的的function来实现你请求的自定义,以下是其方法名和调用时机。
global setup -- 线程启动前调用一次
global init -- 线程启动后调用一次
global delay -- 每次发起一个请求都会调用
global request -- 每发起一个请求前都会调用
global response -- 获取到请求响应结果后调用
global done -- 压测结束后会调用一次
每个方法都是可选的, 如果你想重定义某个阶段的行为,你可以选择重写该方法,具体方法介绍如下。
setup
function setup(thread)是有参数传入的,传入的内容就是当前的线程,setup是在ip地址解析后并且所有线程初始化后,但没用启动前执行的,所以这个时候你可以对thread的构造做一些自定义。
thread.addr - 设置当前线程压测的ip,可以指定线程只压测某个ip
thread:get(key) - 读取线程中某个key对应的值,后面可以用key-value执行不同的逻辑
thread:set(key, value) - 在线程环境中设置一个KV
thread:stop() - 停掉线程,只能在线程还在运行的情况下调用
init
function init(args)是在线程启动后调用,这里是可以传参数的,在启动命令后加-- arg1 arg2
,你就可以在init里通过args[1], args[2]获取到arg1和arg2,举例如下。
> ./wrk https://www.baidu.com -c100 -t10 -d100s -- 10 20
function init(args)
print(args[1]) -- 输出10
print(args[2]) -- 输出20
end
所以这里可以通过这种方式定义更多的自定义参数,然后通过init(args)做解析,后续可以实现多的功能。
delay
function delay()就很简单了,它是为了让你去控制请求发送的之间间隔,如果你想隔10ms发送一次请求,直接return 10
就行了,通过delay()可以实现qps大小的控制。
request()
function request()主要功能是为了定制每次请求的参数数据,如果你想构造一些复杂的请求,request()是不得不改的,你可以再request()中修改上文wrk 结构体中的所有值,基本上最长改动的就是wrk.header, wrk.path, wrk.body。这里需要注意,request()是要求有返回值的,其返回值是wrk.format(method, path, headers, body)
,wrk.format会将这些参数构造成一个http请求可用的请求数据。
response
function response(status, headers, body)是在每次wrk收到http请求响应后调用,wrk会将请求响应中的http status、headers和body作为参数传递进来,你可以通过这些参数信息做响应统计、调整压测流量、甚至停止压测……等比较自动化的操作。
done
function done(summary, latency, requests)是在压测结束后wrk会调用一次,即便有多个线程也只调用一次。wrk会将压测过程中的统计信息通过参数传递给你,你可以挑其中有用的部分输出。也可以输出你在response()中自行统计的内容。
wrk已经为你提供了以下的统计信息:
latency.min -- 最小延迟
latency.max -- 最大延迟
latency.mean -- 平均延迟
latency.stdev -- 延迟的标准差
latency:percentile(99.0) -- 99分位的延迟
latency(i) -- raw value and count
summary = {
duration = N, -- 运行的时间ms
requests = N, -- 总请求数
bytes = N, -- 总过收到的字节数
errors = {
connect = N, -- 链接错误数
read = N, -- socket数据读取出错数量
write = N, -- socket数据写入出错数量
status = N, -- http code 大于399的数量
timeout = N -- 超时请求的总数量
}
}
流量控制方法
wrk使用了多路复用的技术。多路复用使得用一个线程可以异步发起很多个请求,所以不太好用线程数来控制请求数。但一个http连接同时只能处理一个请求,所以可以按一次请求的latency估算出一个连接可以承载的qps数,调整连接数即可控制压测请求大小qps = 1000/latency * Connectnum
。 这里需要注意的是单个线程只能占用一个cpu核心,当cpu到瓶颈时也可能压不上去,需要调整线程数。
另外一个方法,把连接数设置的非常大,让连接数不再是发压的瓶颈,然后调整脚本中的delayTime和线程数,可以精确控制qps。 qps = 1000/delayTime * threadnum
总结
在实际压测过程中,我曾用一个线程压出过几十万qps,也好奇过为什么一个线程能压出这么高的qps。我们每次请求需要5ms,所以按道理一个线程只能压出200qps,那实际上几百倍的差异是如何来的?后来大致了解到wrk的作者使用了多路复用的技术(epoll,kqueue),每次请求后并不是阻塞等在在那里,而且异步等待结果,同时也可以发起下一个请求,这和redis很像吧,其实wrk的作者代码都是抄的redis的,哈哈。
所以这里要注意-c和-t连接数和参数的设置,一个线程只能占用一个cpu核,如果还没到cpu的瓶颈,决定qps的是连接处和瓶颈响应时间,举个例子,如果只有一个线程,链接数10,平均响应时间10ms,那么一个链接一秒能过100个请求,所以总共能压出1000qps。当cpu到瓶颈后,不管怎么去调大连接数qps都不会上去,这个时候就需要考虑调大线程数了,利用多核心的资源提升qps。
最后附上我们压测中实际使用的lua脚本,结构也比较简单,大家可以大致参考下。
local list = {}
local delaytime = 0 -- 默认delay是0ms
local filename = "reqdata.txt" -- 默认请求数据文件
setup = function(thread)
for k,v in pairs(wrk.addrs)
do
print(v)
end
end
init = function(args)
if (args[1] ~= nil) then
delaytime = args[1] -- 启动命令中可以指定延迟时间,如未指定,使用默认文件
end
if (args[2] ~= nil) then
filename = args[2] -- 启动命令中可以指定请求文件目录,如未指定,使用默认文件
end
math.randomseed(os.time())
local i = 0
for line in io.lines(filename) -- 把请求包体读入后写到list里,方便后续使用
do
list[i] = line
i = i+1
end
end
request = function()
wrk.body = list[math.random(0, #list)] -- 随机使用一个包体
wrk.method = "POST"
wrk.scheme = "http"
wrk.path = "/appstore/uploadLogSDK"
wrk.headers["Content-Type"]="application/x-www-form-urlencoded"
return wrk.format()
end
delay = function()
return delaytime
end
response = function(status, headers, body) --这里我没做特殊统计,只是在调试过程中输出了一些内容
--print(status)
--print(body)
--print(wrk.format(wrk.method, wrk.path, wrk.headers, wrk.body))
--wrk.thread:stop()
end
done = function(summary, latency, requests)
print("99 latency:"..latency:percentile(99.0)) -- 这里我只是额外输出了99分位的延时,貌似数据不太对
end