使用wrk一键式压测后台接口

最近一段时间负责的项目开发到了尾声,进入改bug和性能测试阶段。wrk, 一个小巧方便性能又强劲的测试工具, 成了这次测试的主角。

wrk 简介

根据官方说法, wrk是一个现代化的http 基准(benchmark)测试工具, 可以在一台多核的电脑(服务器)上产生显著的负载。而且可以通过lua脚本来自定义http的请求和对响应的处理。
具体的描述可以在wrk的github源码中的SCRIPTING文件查看, 而且scripts/文件夹下也提供了很多实现不同功能的lua脚本例子。

测试结果一览

wrk测试很简单, 一条命令就ok。
wrk -t12 -c400 -d30s http://www.bing.com
上面这条命令是用来测试必应网站,
1. -t12 代表测试时本机开启12个线程, 一般为自己电脑cpu核数的倍数
2. -c400 代表和百度网站保持400个http连接
3. -d30s 代表测试时间为30s

我们来看下测试结果:

wrk -t4 -c400 -d10s http://www.bing.com           
Running 10s test @ http://www.bing.com    
  4 threads and 400 connections    
  Thread Stats   Avg      Stdev     Max   +/- Stdev    
            (平均响应时间) (标准差)  (最大值)  (正负标准差区间所占比例)    
    Latency     1.53s   393.19ms   1.99s    61.78%    
    Req/Sec    37.93     33.89   230.00     84.73%    
  1085 requests in 10.07s, 267.74KB read    
  Socket errors: connect 0, read 0, write 0, timeout 894    
Requests/sec:    107.74(QPS/TPS)    
Transfer/sec:     26.59KB    

网站的所有get请求接口计划都可以用上述命令来测, 但如果要添加自定义header(也可以在命令中指定)或者使用post请求,并设置body参数的话, 就得使用lua脚本了。

Post 请求参数设置

以post请求传递json参数为例,首先创建一个post.lua文件, 在里边设置wrk的header和body

wrk.method = "POST"    
wrk.headers["Content-Type"] = "application/json"    
wrk.body = '{"arg1": "1", "arg2": "2"}'    

然后测试的时候指定lua脚本
wrk -t4 -c400 -d10s -spost.lua http://www.bing.com
这种方法一般也能满足一些简单post接口测试的需求了。但现在我测试的项目业务逻辑比较多, 很多接口测试时传入的参数不能有重复,甚至还需要在数据库中必须有对应的资源。我首先用到的方法是在一个测试分支上修改代码中对参数的处理,patch掉一些参数错误。但这并不是一个好方法, 不仅不能反应真实的应用场景,而且修改的参数处理的部分也显著影响到了性能测试的准确性。于是继续深挖wrk, 发现是可以自定义每个request请求的。

每个请求使用不同的参数

wrk运行分成三个阶段, setup、 running、done。 每个阶段wrk都提供了hook函数,可以自定义测试的参数设置、运行过程以及报告输出。而且每个wrk线程都有独立的脚本环境。每个hook函数运行的规则如下图(图片来自参考文章1)所示:
描述
所以,为了实现每个请求中的参数——比如arg1, 都不相同, 可以在request函数里覆盖wrk.body, 为了保证每个request都不相同,可以设置一个计数器cnt。

wrk.headers["Content-Type"] = "application/json"    
local cnt = 0    
function request()    
    local body = '{"arg1": "Count%s","args": "0.10"}'    
    body = string.format(body, cnt)    
    cnt = cnt +1    
    return wrk.format(nil, nil, nil, body)    
end    

最后一句 return wrk.format(nil, nil, nil, body) 是调用wrk.format(method, path, headers, body), 这一句会覆盖掉wrk的body, 而nil的参数则会保留wrk原来的全局设置

如果你想在运行命令时传入指定的参数来作为变量的前缀(这样就能保证每条测试命令传入的参数都不同), 可以在init函数里来做。

local cnt = 0    
function init(args)    
    arg_pre = args[1]    
end    

function request()    
    local body = '{"arg1": "Count%s_%s","args": "0.10"}'    
    body = string.format(body, arg_pre, cnt)    
    cnt = cnt +1    
    return wrk.format(nil, nil, nil, body)    
end    

这样运行命令wrk -t4 -c400 -d10s -spost.lua http://www.bing.com PRE 就可以把"PRE"字符串传入arg_pre里。
目前为止, 我们做到了在同一个线程里, 每一个request请求的arg1参数都是不同的,而且还可以在命令行中指定参数的值的前缀。但这只限于同一个线程内不同, 由于不同的线程有着独立的脚本环境, 所以对于指定多线程(-t4)的情况,需要多一步处理——添加线程变量维度。
setup函数是在线程创建之后,启动之前, thread提供了一个属性,三个方法

function setup(thread)    

-- thread提供了1个属性,3个方法    
-- thread.addr 设置请求需要打到的ip    
-- thread:get(name) 获取线程全局变量    
-- thread:set(name, value) 设置线程全局变量    
-- thread:stop() 终止线程    

根据官方给的SCRIPTING中的示例, 可以通过下面这种方法获取线程变量

local counter = 1    

function setup(thread)    
    thread:set("id", counter)    
    counter = counter +1    
end    

function init(args)    
    arg_pre = string.format('t%s_%s', id, args[1])    
end    

这样, 参数arg1又多了一个维度——线程的id

对Response进行分析, 定制输出结果

wrk对收到的response,只要status是200就判定为成功,对于我们的业务并不适用, 通过response函数可以定制,而自定义的结果可以通过done函数来输出。

local json = require("cjson")  # 需要安装cjson库    
function init(args)    
    requests = 0    
    responses_all = 0    
    responses_200 = 0    
    responses_0 = 0    
end    

function response(status, headers, body)    
    responses_all = responses_all + 1    
    if status == 200 then    
        responses_200 = responses_200 + 1    
        tb = json.decode(body)    
        if tb["status"] == 0 then  # 0是我们业务返回的成功状态码    
            responses_0 = responses_0 + 1    
        else    
            print(string.format('返回结果失败:%s', tb["status"]))    
        end    
    end    
end    

function done(summary, latency, requests)    
    local total_responses_all = 0    
    local total_responses_200 = 0    
    local total_responses_0 = 0    
    local total_requests = 0    
    for _, thread in ipairs(threads) do    
        local responses_all = thread:get("responses_all")    
        local responses_200 = thread:get("responses_200")    
        local responses_0 = thread:get("responses_0")    
        local requests = thread:get("requests")    
        total_responses_all = total_responses_all + responses_all    
        total_responses_200 = total_responses_200 + responses_200    
        total_responses_0 = total_responses_0 + responses_0    
        total_requests = total_requests + requests    
    end    
    local msg = "total_requests: %s \n all_responses: %s \n 200_responses: %s \n success_resp: %s"    
    print(msg:format(total_requests, total_responses_all, total_responses_200, total_responses_0))    
end    

这样运行命令后就会在原有的输出结果下面添加一行自定义的输出结果,展示返回的数据中有多少在业务上是成功的。

使用shell脚本一键测试后台所有接口

因为接口众多, 如果一条命令一条命令的去输入,并把测试结果粘贴到一个表格里肯定很麻烦(刚开始我就是这么做的), 既然每个接口的测试命令都已经有了,为什么不写个一键式测试shell脚本呢?

输出结果处理

首先, wrk测试的输出结果并不都是我需要的, 这些输出是在源码里写死的,改源码的话会很麻烦,也不优雅。虽然不能控制wrk的输出, 但我可以把输出捕获,赋值给变量,然后用Python脚本处理,输出我想要的测试结果数据, shell 命令如下:

result=$(wrk -t4 -c400 -d30s -spost.lua "http://www.bing.com/" PRE)    
python3 print_result.py  "POST Bing" $result    

Python脚本我就不在这里啰嗦了, 就是获取运行命令时的参数, 对其进行一些过滤和格式化。

一键式测试

把所有的测试命令写入shell脚本, 运行sh wrk_test.sh就可以一键式压测后台接口并得到自己定义的输出格式的结果了。
shell 脚本如下:

#!/usr/bin/env bash    
export thread=4    
export connection=400    
export duration=30    
export django_env=test   # django 环境变量    
export pythonenv="/home/friday/sns-ops/env/gold_env3"  # Python env    
export base_url="http://127.0.0.1:8000"    

echo "初始化测试数据中(2min)..."    
. ${pythonenv}/bin/activate   #启动Python env    
python3 manage.py test_init ${thread}    # 用django启动设置测试数据的Python脚本    
echo "初始化完成!"    

wrkTest(){   # 定义测试函数    
#$1 测试名字    
#$2 测试lua脚本    
#$3 测试接口api    
#$4 lua脚本接受参数, 可以为空    

echo "$1..."    
result=$(wrk -t${thread} -c${connection} -d${duration}s -s$2 ${base_url}$3 $4)    
python3 print_result.py  $1 $result    
echo " "    
sleep 10s  # 休眠10s, 让server处理完上条命令发送的请求    
}    

wrkTest "测试接口1" "a.lua" "/a/" "PRE"    
wrkTest "测试接口2" "b.lua" "/b/" "PRE"    
wrkTest "测试接口3" "c.lua" "/c/" ""    

最后测试完成后得到的测试结果如下:

a接口测试...    
a接口测试结果:总请求数:26469, 平均响应时间:12.70ms, QPS:866.64, 并发数:11, 超时:358, 总响应:26069, 200响应:26069, 成功状态响应:26069    

b接口测试...    
b接口测试结果:总请求数:10455, 平均响应时间:30.51ms, QPS:333.24, 并发数:10, 超时:768, 总响应:10030, 200响应:10030, 成功状态响应:10030    

c接口测试...    
c接口测试结果:总请求数:7212, 平均响应时间:942.73ms, QPS:226.41, 并发数:213, 超时:1943, 总响应:6811, 200响应:6811, 成功状态响应:6811    
...    

小结

  1. 并不是每个request都会收到response, wrk的统计结果会忽略掉那些socket error的request
  2. 安装lua的cjson库的时候有个小坑, 可以参考下面的参考文章2
  3. 并发数 = QPS(TPS) * 平均响应时间(秒)
  4. 虽然之前lua脚本和shell脚本都很陌生,但其实并不需要学透一门编程语言才能开始码代码, 完全可以以目的为导向来学,这样更有效率。当然,如果要掌握一门语言的话,还是要老老实实系统的来学习才成。

参考文章

参考文章1
参考文章2
参考文章3
参考文章4
wrk github