4、wrk进阶实战:自定义lua脚本、模拟不同请求体、保持用户登录态、多接口压测。

好,咱们继续往下走。上一章我们把 wrk 的基础用法捋了一遍,说白了就是能发个 GET 请求,看看 QPS 和延迟。但真实的生产环境哪有这么简单?你想想看,一个电商系统,你得先登录拿到 token,然后带着 token 去下单、去支付,中间还得传不同的 JSON 体。如果 wrk 只能发固定请求,那这压测基本就是自欺欺人。

所以这一章,咱们来点真格的。我会带你手写几个 Lua 脚本,把 wrk 从一个「玩具」变成真正的「生产级压测工具」。我个人习惯是把 Lua 脚本分成三个层次:请求构造层、状态管理层、多接口编排层。咱们一层一层剥开。

4.1 为什么需要 Lua 脚本?

wrk 本身是 C 写的,性能极好。但它只提供了一个简单的 HTTP 请求模板。如果你想动态改变请求体、想从上一个响应里提取数据、想模拟用户登录态……这些事,wrk 自己干不了。

Lua 脚本就是 wrk 的「外挂大脑」。它允许你在三个关键阶段插入自定义逻辑:

  • setup:线程初始化时执行一次,适合做全局配置。
  • request:每次发送请求前执行,你可以在这里修改请求头、请求体。
  • response:每次收到响应后执行,你可以在这里解析数据、提取 token。

核心思路:用 Lua 脚本把「无状态」的 wrk 变成「有状态」的压测引擎。说白了,就是让 wrk 学会「记住」东西。

4.2 自定义请求体:模拟不同 JSON 数据

先来个简单的。假设我们要压测一个下单接口,每次请求的订单号、商品 ID、数量都得不一样。你不能所有请求都用同一个订单号,那后端一查缓存全是命中,压出来的数据就是假的。

我写一个 Lua 脚本,每次请求生成随机的订单数据:

-- random_order.lua
math.randomseed(os.time())

request = function()
    local order_id = "ORD" .. math.random(100000, 999999)
    local product_id = math.random(1, 100)
    local quantity = math.random(1, 5)

    local body = string.format(
        '{"order_id":"%s","product_id":%d,"quantity":%d}',
        order_id, product_id, quantity
    )

    wrk.headers["Content-Type"] = "application/json"
    return wrk.format("POST", "/api/order/create", nil, body)
end

这里有个坑,我踩过。你看第一行 math.randomseed(os.time()),如果不加这一句,所有线程生成的随机数序列是一样的。因为 wrk 是多线程并发,每个线程的 Lua 环境是独立的,但如果不重新播种,每个线程的随机数种子都一样。结果就是:线程1生成订单号 ORD123456,线程2也生成 ORD123456。压测时后端收到大量重复订单号,直接报唯一索引冲突。

我曾经在一次双十一压测中忘了加随机种子,结果压了半小时,数据库里全是重复主键冲突。排查了半天才发现是随机数没播种。嗯,这个细节你一定要注意。

4.3 保持用户登录态:从响应中提取 Token

这才是 wrk 进阶的核心。很多系统需要先登录,拿到 token,然后后续请求都带着这个 token。wrk 本身没有 cookie 管理机制,但我们可以用 Lua 脚本手动实现。

思路是这样的:

  1. 在 setup 阶段,先发一次登录请求,拿到 token。
  2. 把 token 存到全局变量里。
  3. 在 request 阶段,把 token 塞进请求头。

看代码:

-- login_and_press.lua
token = nil

setup = function(thread)
    -- 每个线程只做一次登录
    local login_body = '{"username":"test_user","password":"123456"}'
    local login_resp = wrk.format("POST", "/api/user/login", nil, login_body)

    -- 注意:这里需要自己实现一个简单的 HTTP 客户端来发登录请求
    -- wrk 的 setup 阶段不能直接用 wrk.format 发送,需要借助 socket
    -- 实际项目中我一般用下面的方式:
    local sock = ngx.socket.tcp()
    sock:connect("your_host", 80)
    sock:send("POST /api/user/login HTTP/1.1\r\nHost: your_host\r\nContent-Type: application/json\r\nContent-Length: " .. #login_body .. "\r\n\r\n" .. login_body)
    local response = sock:receive("*a")
    sock:close()

    -- 解析 JSON 提取 token(这里用简单的字符串匹配,生产环境建议用 cjson)
    local _, _, t = string.find(response, '"token":"([^"]+)"')
    token = t
    thread:set("token", token)
end

request = function()
    local token = wrk.thread:get("token")
    wrk.headers["Authorization"] = "Bearer " .. token
    return wrk.format("GET", "/api/user/profile")
end

我个人习惯把登录逻辑单独抽成一个函数,然后在 setup 里调用。这样脚本结构更清晰。另外,如果登录接口有验证码或者动态密码,那你就得在压测前先准备好一批有效的 token,写到文件里,让每个线程读一个。别想着在压测过程中动态获取验证码,那太慢了。

4.4 多接口压测:模拟用户真实行为

真实用户不会只点一个按钮。他先登录,然后浏览商品,加购物车,下单,支付。如果你只压测下单接口,那后端看到的流量模式是失真的。因为缓存、连接池、数据库连接等资源的使用情况完全不同。

我建议用 Lua 脚本实现一个「用户行为序列」。说白了,就是定义一个数组,里面按顺序放好要调用的接口,然后循环执行。

-- multi_api_press.lua
local api_sequence = {
    {method = "GET", path = "/api/product/list", body = nil},
    {method = "POST", path = "/api/cart/add", body = '{"product_id":1,"quantity":1}'},
    {method = "POST", path = "/api/order/create", body = '{"address_id":1}'},
    {method = "POST", path = "/api/payment/pay", body = '{"payment_method":"wechat"}'},
}

local index = 1

request = function()
    local api = api_sequence[index]
    index = index + 1
    if index > #api_sequence then
        index = 1  -- 循环执行
    end
    return wrk.format(api.method, api.path, nil, api.body)
end

这里有个关键点:接口之间的依赖关系。比如下单接口需要用到购物车接口返回的 cart_id。那你就得在 response 阶段把 cart_id 提取出来,动态拼接到下一个请求里。这其实就是一个「轻量级的业务流程编排」。

我画了一张图,帮你理解整个流程:

wrk Lua 脚本多接口压测流程 setup 阶段 登录获取 token request 阶段 构造请求头/请求体 发送 HTTP 请求 wrk 内核执行 response 阶段 提取数据/更新状态 循环执行下一个接口 线程局部存储 token cart_id order_id 接口索引 随机种子 每个线程独立 互不干扰

4.5 实战中的避坑指南

脚本写完了,跑起来也简单,一行命令:

wrk -t4 -c100 -d30s -s multi_api_press.lua http://your-api.com

但跑起来之后,你可能会遇到几个问题。我一个个说:

常见问题 原因 解决方案
QPS 上不去 Lua 脚本里做了太多字符串拼接或 JSON 解析 把能预计算的内容放到 setup 阶段,不要在 request 里重复计算
Token 失效 压测时间太长,token 过期了 在 response 里检测 401,触发重新登录;或者压测前生成一批长期有效的 token
随机数重复 多线程未正确播种 每个线程的 setup 里调用 math.randomseed(os.time() + thread_id)
内存泄漏 response 阶段不断拼接字符串,未释放 使用局部变量,避免全局 table 无限增长

我曾经遇到过一个特别隐蔽的问题。压测跑了 10 分钟,QPS 从 5000 慢慢掉到了 2000。排查了半天,发现是 Lua 脚本里有个全局 table,每次 response 都往里 push 数据,但从来没清理过。结果这个 table 越来越大,GC 频繁触发,把 CPU 都吃掉了。嗯,全局变量一定要慎用,用完记得置 nil。

4.6 小结

这一章的内容,说白了就是让 wrk 学会「思考」。通过 Lua 脚本,我们可以:

  • 动态生成不同的请求体,模拟真实用户数据
  • 从响应中提取 token,保持登录态
  • 编排多接口调用,模拟完整的业务流程

这些技巧,我在多个生产项目中都用过。比如某次电商大促压测,就是用这套方案模拟了 10 万用户同时浏览、加购、下单、支付的完整链路。最后发现支付接口的数据库连接池配置有问题,提前暴露了隐患。

你想想看,如果当时只用单接口压测,这个问题根本发现不了。这就是进阶实战的价值所在。

下一章,我们会聊到如何分析 wrk 的输出结果,以及如何根据延迟分布图来定位性能瓶颈。但那是后话了,先把这一章的脚本练熟,跑通几个场景,你就算真正入门了 wrk 进阶。

想要更多资料,联系可可