一个固定频率压测工具
潘忠显 / 2025-05-16
上周开发了一个简单压测工具的,类似于众所周知的 wrk。
区别之处在于:支持固定QPS,支持按秒统计延迟。
仓库:https://github.com/panzhongxian/wrk_with_fixed_qps/tree/main/cmd/wrk
开发这个工具,利用 Golang 和 Copilot 大概两三小时就开发出了初版。
本文聊聊背景、功能和亮点、遇到的问题和解决。
1. 背景
为啥要重复建设一个压测工具呢?
原来压测同学使用平台的压测工具,压测发现固定RPS模式下实际请求频率上下波动很大:

仔细看了一下这个平台工具,发现:固定RPS模式,固定1000并发,无法调整。
我们先搞清楚每秒的请求数是怎么得到的:
$$ RPS_{max} = {并发数}\cdot\dfrac{1s}{延迟} $$
固定并发数是 1000
,如果服务的请求延迟是 200ms,那么这里的 RPS 最高就是 5000,如果你设置 6000,他是达不到的。其实上图也可以看到这个现象:服务延迟普遍高于200ms,而高延迟400ms周期性(1分钟)出现一次,在固定并发的情况下,就导致了看上去 RPS 频率上下波动很大。
是不是平台的 RPS 模式无效呢?其实也不是,如果服务延迟比较低,$RPS_{max}$ 大于你要求的 固定 RPS的情况下,这里就符合预期:

大家熟知的 wrk 有高性能(多线程+事件驱动IO模型)、支持 Lua 脚本的特点,最终会有统计信息,包括:平均每秒请求数、延迟分布、传输速率等信息。
但是 wrk 也是只能固定的连接数,如果想要固定的 QPS 很不方便:
- 需要根据延迟,计算需要多少连接数
- 如果延迟有变化,QPS也会像图一那样,是会波动的

2. 功能和亮点
高性能:压到 30000 QPS 占用CPU不到 4 核(跟实际请求大小肯定有关系):
支持两种模式:固定 QPS 和 固定并发模式
根据前面的铺垫,固定 QPS 模式实现,是以比较大并发,按每个时间间隔发送固定的数量。
这里故意模拟不同的延迟,可以看到QPS是可以保持不变的:

上图左侧已经展示了固定 QPS 模式,下边的指令是固定并发的指令:

支持按秒统计耗时:像前边提到的,如果服务本身延迟有很明显的周期性波动,只使用类似 wrk 的一次性统计,是无法看出问题的。因此,工具加了一个按秒统计请求、失败、耗时分位的功能

被压基准服务:之前为了比较平台工具、wrk 的压测效果,写了一个简单的服务作为被压测服务。在测试我自己压测工具的时候也能很好的使用。
- 该服务需要连接redis,会记录每秒的请求情况
- 可以通过请求指定延迟,比如下边指令会延迟1000ms,即一秒
curl -X POST "http://wrk-test-server.shcdpdsp-in.woa.com/delay" --data '{"delay_ms": 1000}'
TODO:支持一个Web UI界面:
- 支持通过页面启动
- 显示按秒展示的压测数据
- 显示压测程序负载
3. 遇到问题和解决
初版压测工具提供给别的同事使用,同事反馈压测到QPS 1000都不能保持,且负载很高。
首先,进行简单的排查。我在DevCloud 上压测服务 QPS 1000 时负载很低。 同事部署在容器中,为什么会出现这种情况?
查看了容器的日志,一分钟周期性地报错然后恢复,报错内容:
请求失败: Post xxx, failed after 3 retries, last error: dial tcp 192.168.6.4:30000: connect: cannot assign requested address
我们在与某个特定服务器建立TCP连接的时候,需要本地IP和端口,如果本地端口耗尽,就会出现上边的这种状况。
那是什么导致本地端口会耗尽呢?直接登录容器,netstat -alnpt
可以看到有很多 TIME_WAIT。

经典面试题——TIME_WAIT
为什么会出现 TIME_WAIT,有什么影响?
那我在 DevCloud 为什么没有这种错误出现?我们抓包看到就比较清晰:
- 相同点:Client 在收到 HTTP 返回之后,直接关闭了连接,发送 FIN,没有复用 TCP 连接,在发出最后一个ACK之后,进入TIME_WAIT状态
- 不同点:DevCloud 上有复用TIME_WAIT占住的端口,不会造成端口耗尽

为什么会以1分钟为周期性循环呢?
因为 TimeWait 的等待时间设置的是 1分钟,可以通过 cat /proc/sys/net/ipv4/tcp_fin_timeout
查看。
为什么TCP没有被复用
最开始我是这么创建 Client 的,看上去平平淡淡貌似没什么问题。
var client = createClient()
// createClient 创建一个新的HTTP客户端
func createClient() *http.Client {
transport := &http.Transport{
MaxIdleConns: 100, // 增加最大空闲连接数
MaxIdleConnsPerHost: 10, // 增加每个主机的最大空闲连接数
IdleConnTimeout: 60 * time.Second, // 空闲连接超时时间
DisableCompression: true, // 禁用压缩
ResponseHeaderTimeout: 20 * time.Second, // 响应头超时时间
ExpectContinueTimeout: 2 * time.Second, // 100-continue超时时间
DialContext: DialWithCache, // 使用DNS缓存
TLSHandshakeTimeout: 10 * time.Second, // TLS握手超时时间
MaxResponseHeaderBytes: 4096, // 限制响应头大小
WriteBufferSize: 4096, // 写缓冲区大小
ReadBufferSize: 4096, // 读缓冲区大小
}
return &http.Client{
Transport: transport,
Timeout: 10 * time.Second,
}
}
但我们这里不是个普普通通 Client ,它是要做压测用的,有什么特点:
- 连接的往往只有一个IP(CLB)
- 并发至少上千
因此上边那个配置,其实不适合我们压测程序的,做一下修改:
MaxIdleConns: 10000, // 增加最大空闲连接数
MaxIdleConnsPerHost: 10000, // 增加每个主机的最大空闲连接数
上边修改了之后发现,还是有 TIME_WAIT 出现。
让 GPT 写了一个单独的程序使用上述 Client 并发 1000 去发送请求并没有出现问题(为了简洁,我省去了 wg 和错误):
for i := 0; i < concurrency; i++ {
go func() {
for {
select {
case <-ctx.Done():
return
default:
// 从客户端池获取客户端
client := clientPool.GetClient()
req, _ := http.NewRequest("POST", "http://xxxx.com/delay", bytes.NewBuffer([]byte("{\"delay\": 1}")))
req.Header.Set("Content-Type", "application/json")
resp, _ := client.Do(req)
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
fmt.Printf("\nRequest failed with status: %d, body: %s\n", resp.StatusCode, string(body))
}
}
}
}()
}
比较了半天,发现我的程序,少了读取 resp.Body
而是直接调用 resp.Body.Close()
,即下边这行:
body, _ := io.ReadAll(resp.Body)
【待看代码确认】如果响应体没有被完全读取,HTTP 客户端库(如 Go 的 net/http
包)可能会认为连接状态不完整或不稳定,从而选择关闭连接而不是重用它——来自GPT的解释——要想连接被复用,正确的方式应该是:
- 始终读取响应体: 即使不需要响应体的内容,也应该使用
io.ReadAll(resp.Body)
来确保响应体被完全读取 - 正确关闭响应体: 确保在读取完响应体后调用
resp.Body.Close()
,以便连接可以被正确管理和重用
在加上 io.ReadAll
之后,连接能够被正常的复用了,且连接个数跟并发数一致了:

4. 小结
现在有 Copilot 的加持,我们可以很快的开发出一个工具或者服务。但是,还是得熟悉网络原理、掌握抓包工具,才能发挥出真正的生产力。
有需要的同学,可以直接访问仓库使用。