Jason Pan

一个固定频率压测工具

潘忠显 / 2025-05-16


上周开发了一个简单压测工具的,类似于众所周知的 wrk。

区别之处在于:支持固定QPS,支持按秒统计延迟

仓库:https://github.com/panzhongxian/wrk_with_fixed_qps/tree/main/cmd/wrk

开发这个工具,利用 Golang 和 Copilot 大概两三小时就开发出了初版。

本文聊聊背景、功能和亮点、遇到的问题和解决。

1. 背景

为啥要重复建设一个压测工具呢?

原来压测同学使用平台的压测工具,压测发现固定RPS模式下实际请求频率上下波动很大

qps-on-koala-1

仔细看了一下这个平台工具,发现:固定RPS模式,固定1000并发,无法调整

我们先搞清楚每秒的请求数是怎么得到的:

$$ RPS_{max} = {并发数}\cdot\dfrac{1s}{延迟} $$

固定并发数是 1000,如果服务的请求延迟是 200ms,那么这里的 RPS 最高就是 5000,如果你设置 6000,他是达不到的。其实上图也可以看到这个现象:服务延迟普遍高于200ms,而高延迟400ms周期性(1分钟)出现一次,在固定并发的情况下,就导致了看上去 RPS 频率上下波动很大

是不是平台的 RPS 模式无效呢?其实也不是,如果服务延迟比较低,$RPS_{max}$ 大于你要求的 固定 RPS的情况下,这里就符合预期:

qps-on-koala-2

大家熟知的 wrk 有高性能(多线程+事件驱动IO模型)支持 Lua 脚本的特点,最终会有统计信息,包括:平均每秒请求数、延迟分布、传输速率等信息。

但是 wrk 也是只能固定的连接数,如果想要固定的 QPS 很不方便:

wrk

2. 功能和亮点

高性能:压到 30000 QPS 占用CPU不到 4 核(跟实际请求大小肯定有关系):

qps

支持两种模式:固定 QPS 和 固定并发模式

根据前面的铺垫,固定 QPS 模式实现,是以比较大并发,按每个时间间隔发送固定的数量

这里故意模拟不同的延迟,可以看到QPS是可以保持不变的:

koala-qps-2

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

concurrency-mode

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

result-per-second

被压基准服务:之前为了比较平台工具、wrk 的压测效果,写了一个简单的服务作为被压测服务。在测试我自己压测工具的时候也能很好的使用。

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

min-1-log

我们在与某个特定服务器建立TCP连接的时候,需要本地IP和端口,如果本地端口耗尽,就会出现上边的这种状况。

那是什么导致本地端口会耗尽呢?直接登录容器,netstat -alnpt 可以看到有很多 TIME_WAIT。

many-tw

经典面试题——TIME_WAIT

为什么会出现 TIME_WAIT,有什么影响?

那我在 DevCloud 为什么没有这种错误出现?我们抓包看到就比较清晰:

tcpdump-1

为什么会以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 ,它是要做压测用的,有什么特点:

因此上边那个配置,其实不适合我们压测程序的,做一下修改:

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 之后,连接能够被正常的复用了,且连接个数跟并发数一致了:

connection-count

4. 小结

现在有 Copilot 的加持,我们可以很快的开发出一个工具或者服务。但是,还是得熟悉网络原理、掌握抓包工具,才能发挥出真正的生产力。

有需要的同学,可以直接访问仓库使用。