[性能测试工具] wrk 使用教程

1,271 阅读15分钟
  • 标题:wrk 使用教程
  • 分类:性能测试
  • 标签:工具性能测试
  • 简介:wrk是一款高性能的HTTP压力测试工具,用于评估Web服务器或应用程序的性能。

1. wrk介绍

wrk是一款高性能的HTTP压力测试工具,用于评估Web服务器或应用程序的性能。它通过多个线程来模拟多个客户端同时访问Web服务器,并且可以指定请求的并发数、请求的时长和请求的延迟,以便评估Web服务器的性能。wrk还支持自定义请求格式,可以让用户模拟不同的客户端请求,以便对Web服务器进行更细致的测试。

1.1 wrk的运行周期

wrk运行分成三个阶段, setup、 running、done。

每个阶段wrk都提供了hook函数,可以自定义测试的参数设置、运行过程以及报告输出。

每个wrk线程都有独立的脚本环境。每个hook函数运行的规则如下图所示:

1.1.1 setup - 启动阶段

启动阶段是ip地址已经被成功解析并且线程初始化完毕但是还未启动的阶段,用于将数据传递给线程。

setup(): 每个线程调用一次setup() 并接收代表该线程的userdata对象。

  • thread:get()/ thread:set():设置或获取线程运行时需要的全局变量,变量值可以是布尔值,nil,数字和字符串值或表的值。
  • thread:stop(): 可以停止正在运行中的线程。
  • thread.addr:获取线程的服务地址。

1.1.2 running - 运行阶段

在运行阶段从调用init()方法开始,之后循环调用request()response()方法。即每个线程只调用一次init()

  • function init(args):每个线程初始化时调用。可以接受命令行参数。
  • function request():为每个请求返回HTTP对象。在此函数中,我们可以修改method,headers,path,和 body。
    • 使用wrk.format()辅助函数来生成请求对象。格式:return wrk.format(method, path, headers, body)
  • function delay():返回延迟发送下一个请求的毫秒数。
  • function response(status, headers, body):在响应返回时调用。需要status(http响应状态),headers(http响应头),body(http响应体)三个参数。解析响应头和响应体非常消耗计算机资源,因此,如果调用init()方法之后全局变量response是nil,wrk将不会解析响应头和响应体。

1.1.3 done - 完成阶段

当所有请求都已经完成并且请求结果已经被统计时调用。

  • function done(summary, latency, requests):函数接收的字段为:summarylatencyrequests

    • summary:表类型的结果
    summary = {
      duration = N,  -- run duration in microseconds 运行持续时间(以微秒为单位)
      requests = N,  -- total completed requests 完成的请求总数
      bytes    = N,  -- total bytes received 接受的总字节数
      errors   = {
        connect = N, -- total socket connection errors    socket连接错误的总数目
        read    = N, -- total socket read errors    socket读取错误的总数目
        write   = N, -- total socket write errors    socket写入错误的总数目
        status  = N, -- total HTTP status codes > 399   HTTp状态码大于399的总数目
        timeout = N  -- total request timeouts    所有请求的超时时间总和
      }
    }
    
    • latency:每个请求的延迟时间(以微秒为单位)
    latency.min              -- minimum value seen 延时最小值
    latency.max              -- maximum value seen 延时最大值
    latency.mean             -- average value seen 延时平均值
    latency.stdev            -- standard deviation 延时标准差
    latency:percentile(99.0) -- 99th percentile value 第99%的值
    latency[i]              -- raw value and count 请求i的原始延迟数据
    
    • requests:每个线程的请求速率

1.2 原理分析

wrk使用了非阻塞I/O和多线程技术,能够充分利用多核CPU的资源,提高测试效率。

wrk的工作原理大致如下:

  1. 启动wrk程序,并指定要测试的URL地址、并发数和测试时间等参数。
  2. wrk程序根据参数创建多个线程,每个线程对应一个工作者。
  3. 每个工作者根据测试参数,创建多个HTTP请求,并使用非阻塞I/O发送给服务器。
  4. 服务器接收到请求后,处理请求并返回响应。
  5. 工作者接收到响应后,统计响应时间和其他指标,并继续发送下一个请求。
  6. 测试时间结束后,wrk程序统计测试结果,并输出报告。

什么是非阻塞I/O呢?

非阻塞I/O是指程序在执行I/O操作时,如果数据暂时无法提供,不会进入阻塞状态,而是立即返回一个特殊值。这样,程序就可以在执行I/O操作时同时处理其他事情。非阻塞I/O的好处是可以提高程序的并发性能,有效地利用CPU的资源。

非阻塞I/O与阻塞I/O相比,需要程序多写一些代码来处理数据暂时无法提供的情况。例如,在读取文件时,如果读取的数据不够,阻塞I/O会进入等待状态,直到数据可用为止;而非阻塞I/O则会立即返回一个特殊值,告诉程序数据暂时无法提供。程序需要在下一次调用I/O函数时重新尝试读取数据(程序可以根据实际情况决定下一次调用I/O函数的时间)。

例如,如果程序在调用I/O函数读取数据时,发现数据暂时无法提供,它可以等待一段时间后,再次调用I/O函数读取数据。这种方法通常称为轮询,它可以保证程序始终保持活跃,但会消耗更多的CPU资源。此外,程序还可以使用select函数等方法,来监控文件描述符或句柄的变化,并在文件描述符或句柄可读时再次调用I/O函数读取数据。

2. wrk的使用

2.1 wrk脚本

1.2.1 脚本结构

  • 线程初始化设置setup
  • 线程初始化数据init
  • 线程发起请求request
  • 线程发起请求延迟delay
  • 线程接收请求响应response
  • 线程执行分析的结果分析done
---
--- Created by boyizhang.
--- DateTime: 2022/11/17 11:25
--- wrk -d2s -c1 -t1 -s post.lua https://xxxx.xx/
---

function setup(thread)
    -- called during thread setup
    
end

function init(args)
    -- called when the thread is starting
    -- init()每个线程初始化时调用。
  	-- 可用来初始化数据,可以接受命令行参数
    
function delay()
   return math.random(10, 50)
end
  
end

function request()
    --为每个请求返回HTTP对象。在此函数中,我们可以修改method,headers,path,和 body。可以使用wrk.format()辅助函数来生成请求对象
    --return wrk.format(method, path, headers, body)
    
end

function response(status, headers, body)
    --在响应返回时调用。需要status(http响应状态),headers(http响应头),body(http响应体)三个参数,
    --解析响应头和响应体非常消耗计算机资源,因此,如果调用init()方法之后全局变量response是nil,wrk将不会解析响应头和响应体。
    
end

function done(summary, latency, requests)
    --当所有请求都已经完成并且请求结果已经被统计时调用。
    --函数接收一个包含结果数据的表(表是lua内置数据类型),以及两个统计对象:每个请求的延迟时间(以微秒为单位)、每个线程的请求速率(以每秒请求数为单位)
    
end

1.2.2 脚本的执行

执行wrk -v,获取命令行参数:

$ wrk -v                                              
wrk  [kqueue] Copyright (C) 2012 Will Glozer
Usage: wrk <options> <url>                            
  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       
    -H, --header      <H>  Add header to request      
    --latency          Print latency statistics   
    --timeout     <T>  Socket/request timeout     
    -v, --version          Print version details      
                                                      
  Numeric arguments may include a SI unit (1k, 1M, 1G)
  Time arguments may include a time unit (2s, 2m, 2h)
-c, --connections 连接数,连接数一定要大于线程数。每个线程可以同时打开的连接数。-c 1
-t, --threads线程的数量。-t 10
-d, --duration设置测试的时长,单位为秒,默认为10s。-d 1s/-d 2m/-d 3h
-s, --script使用指定的请求脚本进行测试
-H, --headerAdd header to request
--latencyPrint latency statistics,压测结束时会打印Latency Distribution部分的内容
--timeoutSocket/request timeout--timeout 1s
-v, --version

一般线程数不宜过多, 核数的2到4倍足够了. 多了反而因为线程切换过多造成效率降低。 因为 wrk 不是使用每个连接一个线程的模型, 而是通过异步网络 io 提升并发量。所以网络通信不会阻塞线程执行,这也是 wrk 可以用很少的线程模拟大量网路连接的原因。

而现在很多性能工具并没有采用这种方式, 而是采用提高线程数来实现高并发. 所以并发量一旦设的很高, 测试机自身压力就很大。测试效果反而下降。

-c选项用于指定每个线程可以同时打开的连接数。并发连接是指在同一时间内,服务器能够处理的客户端连接数量。例如,如果您的服务器具有10个并发连接,那么它就能够在同一时间内处理10个客户端的请求。

并发连接数量对于服务器性能非常重要,因为它决定了服务器能够同时处理多少客户端请求。如果并发连接数量过少,服务器将无法充分利用它的性能,并且可能无法应对突发流量。反之,如果并发连接数量过多,服务器可能会因为资源不足而崩溃。因此,在进行压力测试时,并发连接数量是一个非常重要的参数。

2.2 结果分析

    • Latency:延时时间。
    • Req/Sec:每秒请求数。
    • Avg:平均值。
    • Stdev:标准差。
    • Max:最大值。
    • +/- Stedv:

2.3 wrk应用

2.3.1 Get请求

通过随机获取参数发起请求

---
--- Created by boxy.
--- DateTime: 2022/11/17 11:25
--- wrk -d2s -c1 -t1 -s get.lua https://xxxx.xx/
-- example script that demonstrates use of setup() to pass
-- data to and from the threads

request = function()
    data = { "29", "34", "36", "52"}
    resource = data[math.random(#data)]
    path = "/api/tsp/transify?resource_id=" .. resource
    return wrk.format(nil, path)
end

2.3.2 Post 请求

从文件将测试数据读取到内存中,随机取其中的测试数据发起请求。

---
--- Created by boxy.
--- DateTime: 2022/11/17 11:25
--- wrk -d5s -c1 -t1 -s post.lua https://xxxx.xx/
---
local counter = 1
local threads = {}

shop_ids = {}
wrk.method = "POST"
wrk.headers["Content-Type"] = "application/json"
wrk.headers["x-sp-servicekey"] = "xxxxxxxxx"
wrk.headers["x-sp-sdu"] = "xxxxxxx"
wrk.headers["Cookie"] = "xxxxxxx"

local function readFile(fileName)
  local file = io.open("listing_limit_shop_ids.txt", "r")
  --local file = io.open("test_shop_ids.txt", "r")

  for row in file:lines() do
    --print(row)
    table.insert(shop_ids, tonumber(row))
  end
  file:close()
  print("shop_ids[1]:%s", shop_ids[1])

end

function setup(thread)
  -- called during thread setup
  thread:set("id", counter)
  table.insert(threads, thread)
  counter = counter + 1
end

function init(args)
  -- called when the thread is starting
  -- init()每个线程初始化时调用。可以接受命令行参数
  requests = 0
  responses = 0
  -- ready shop_ids from txt 
  readFile('./test_shop_ids.txt')

  local msg = "thread %d created"
  print(msg:format(id))

end

function request()
  --为每个请求返回HTTP对象。在此函数中,我们可以修改method,headers,path,和 body。可以使用wrk.format()辅助函数来生成请求对象
  --return wrk.format(method, path, headers, body)
  requests = requests + 1
  print("request:shop_ids:", shop_ids[1])
  shop_id = shop_ids[math.random(#shop_ids)]
  local body = '{"shop_ids":[%d]}'
  body = string.format(body, shop_id)
  path = "/sprpc/xxxxxxxxxxxx"
  print("request body:" .. body)
  return wrk.format('POST', path, nil, body)
end

function response(status, headers, body)
  --在响应返回时调用。需要status(http响应状态),headers(http响应头),body(http响应体)三个参数,
  --解析响应头和响应体非常消耗计算机资源,因此,如果调用init()方法之后全局变量response是nil,wrk将不会解析响应头和响应体。
  responses = responses + 1
  print("status:", status, "response:", body)
end

function done(summary, latency, requests)
  --当所有请求都已经完成并且请求结果已经被统计时调用。
  --函数接收一个包含结果数据的表(表是lua内置数据类型),以及两个统计对象:每个请求的延迟时间(以微秒为单位)、每个线程的请求速率(以每秒请求数为单位)
  for index, thread in ipairs(threads) do
    local id = thread:get("id")
    local requests = thread:get("requests")
    local responses = thread:get("responses")
    local msg = "thread %d made %d requests and got %d responses"
    print(msg:format(id, requests, responses))
  end
end

3. 番外篇-Locust

locust是一款基于Python语言的开源Web压力测试工具。它的实现原理主要包括以下几个方面:

  1. 协程技术:locust使用协程技术,能够在单线程中并发执行多个任务。协程的实现方式是通过挂起函数和调度器来实现的。在协程中,每个任务都是一个挂起函数。当挂起函数需要等待某些条件(例如,网络请求完成)时,它会挂起执行,并返回控制权给调度器。调度器会负责调度挂起函数的执行,以便在单线程中实现多个任务的并发执行。
  2. 任务定义:locust通过定义任务类和任务实例,来模拟真实用户的行为。用户可以定义任务类,指定任务类中包含哪些请求和响应处理逻辑。然后,在测试过程中,locust会根据定义的任务类,生成任务实例,并在协程中执行。
  3. 分布式测试:locust支持分布式测试。用户可以在多台机器上运行locust程序,模拟大量的并发用户。locust会在多台机器之间进行通信,协调测试过程和统计测试结果。
  4. Web界面:locust提供了Web界面,方便用户实时监控测试过程和结果。用户可以通过Web界面,调整测试参数、查看实时统计信息、调试测试脚本等。Web界面的实现方式是通过Flask框架来实现的。Flask提供了丰富的API,可以方便地构建Web应用。通过在Flask框架中集成locust的测试结果,locust可以提供方便、灵活的Web界面供用户使用

什么是挂起函数?什么是调度器?

挂起函数(Coroutine)是一种轻量级的线程,它能够在单线程中实现并发执行多个任务。挂起函数在执行过程中,可以挂起执行,并返回控制权给调度器。调度器是一种特殊的程序,它负责调度挂起函数的执行,以便在单线程中实现多个任务的并发执行。

具体来说,挂起函数的执行过程如下:

  1. 当挂起函数需要等待某些条件(例如,网络请求完成)时,它会挂起执行,并返回控制权给调度器。
  2. 调度器会接收到挂起函数的控制权,并将其保存在一个任务队列中。
  3. 调度器会按照一定的规则,从任务队列中选择一个挂起函数,并将其控制权返回给挂起函数。
  4. 挂起函数接收到控制权后,会继续执行。如果在执行过程中,再次需要挂起执行,它会再次返回控制权给调度器,重复上述操作。

调度器的选择规则可以根据实际需要进行调整。例如,可以根据挂起函数的优先级,或者根据挂起函数的执行时间,来决定调度器应该选择哪个挂起函数执行。这样,就能够保证挂起函数的执行公平、合理,从而实现单线程中多个任务的并发执行。

总之,挂起函数和调度器是协程技术的两个关键部分,通过挂起函数和调度器的协作,可以实现单线程中的多个任务的并发执行。正因为如此,协程技术在Web压力测试工具(如locust)中得到了广泛应用。

通过python实现一个挂起函数和调度器

Python提供了专门的语法和库函数,能够方便地实现协程。

下面是一个简单的例子,实现了挂起函数和调度器。

首先,定义挂起函数,使用async关键字来声明挂起函数。挂起函数内部,可以使用await关键字来挂起执行,并等待某些条件(例如,网络请求完成)。

# 定义挂起函数
async def coroutine_1():
  print("coroutine_1 is running")
  await coroutine_2()
  print("coroutine_1 is done")

async def coroutine_2():
  print("coroutine_2 is running")
  await coroutine_3()
  print("coroutine_2 is done")

async def coroutine_3():
  print("coroutine_3 is running")
  print("coroutine_3 is done")

然后,定义调度器,使用asyncio库来实现调度器。调度器的实现过程如下:

  1. 首先,调用asyncio.get_event_loop()函数,获取事件循环对象。事件循环对象是调度器的核心部分,负责调度挂起函数的执行。
  2. 然后,调用asyncio.gather()函数,将挂起函数封装成任务对象。任务对象是挂起函数的包装,能够方便地调度挂起函数的执行。
  3. 最后,调用事件循环对象的run_until_complete()方法,执行任务对象。这样,就能够实现调度器的核心功能,即在单线程中实现多个挂起函数的并发执行。
import asyncio


# 定义挂起函数
async def coroutine_1():
  print("coroutine_1 is running")
  await coroutine_2()
  print("coroutine_1 is done")

async def coroutine_2():
  print("coroutine_2 is running")
  await coroutine_3()
  print("coroutine_2 is done")

async def coroutine_3():
  print("coroutine_3 is running")
  print("coroutine_3 is done")
# 定义调度器
async def main():
  # 获取事件循环对象
  loop = asyncio.get_event_loop()
  # 封装挂起函数为任务对象
  task = asyncio.gather(coroutine_1(), coroutine_2(), coroutine_3())
  # 执行任务对象
  loop.run_until_complete(task)

# 调用调度器
main()

以上代码中,定义了3个挂起函数:coroutine_1、coroutine_2和coroutine_3。每个挂起函数在执行过程中,都会使用await关键字挂起执行,并等待其他挂起函数执行完成。然后,定义了一个调度器,使用asyncio库实现了调度器的功能。调度器的核心功能是,在单线程中实现多个挂起函数的并发执行。

在执行上述代码时,会输出以下内容:

coroutine_1 is running
coroutine_2 is running
coroutine_3 is running
coroutine_3 is done
coroutine_2 is done
coroutine_1 is done

从输出内容可以看出,挂起函数的执行顺序是coroutine_1、coroutine_2、coroutine_3。此外,这些挂起函数的执行是并发进行的,从而实现了单线程中多个任务的并发执行。