用Go实现轻量级HTTP压测工具:并发模型与连接池优化实践
2026/9/18 0:29:15 网站建设 项目流程

先把结论说在前面:我最近用 Go 写了一个代号为 Colibri 的轻量级 HTTP 压测工具,英文名取自蜂鸟,寓意是“小、快、轻”。做这个工具的初衷很直接——手头压测接口时,curl 只能看个连通性,wrk 性能好但写 Lua 入门成本不低,ab 又多年不更新,vegeta 功能强大但参数一多反而显得沉重。于是花了一个周末,把一套可复用的并发请求模型、连接池调优、延迟百分位统计和限速逻辑都塞进了这个单二进制 CLI 里。这篇文章不是讲一个成熟大项目,而是记录我从“想压个接口”到“自己写了把压测锤子”的完整过程:包括并发模型怎么设计、连接复用为什么能决定压测上限、QPS 限速的心跳如何掐准、以及真实接口压测时被我踩出来的三个问题。如果你也在做后端接口性能验证、负载测试,或者单纯对压测工具底层原理好奇,这篇内容应该能帮你在工具选择和结果解读上少走不少弯路。

1. 项目定位:为什么还要再做一把“压测锤子”

1.1 现有压测工具的短板,逼我自己动手

先说说我实际遇到过的痛点。ab(ApacheBench)很多人喜欢,因为它一条命令就能跑,但是它对 HTTP keep-alive 的支持一直很别扭,默认每次请求都新建连接,在高并发下测出来的数字明显偏低,而且单线程模型决定了它很难把并发打到真正的高水位。wrk确实很强,但它的强依赖 Lua 脚本,很多做业务开发的同事一看要写脚本就走了,另外 wrk 在 Windows 上编译多多少少有些折腾。vegeta的设计我很喜欢,但它的“攻击器”概念需要先熟悉一套自己的 DSL,小团队和人临时做快速验证时,反而不如一个位置参数就能跑的 CLI 方便。

这个痛点在真实工作里很常见:服务端接口已经上线,运营团队马上要做一个秒杀活动,产品问“这个接口扛得住多大流量”,这时候我需要一个能快速压一轮、并且结果直观的工具。现成工具各有各的好,但把他们装齐、学会、用好,成本其实不低。我越来越觉得,压测工具本质上就是“按指定速率或压力,发一批 HTTP 请求,再把延迟和状态码统计出来”三件事。既然这么简单,干脆自己写一个,顺便把平时困扰我的几个问题——连接复用、端口耗尽、P99 偏高——用代码层面的方式彻底搞清楚。

1.2 蜂鸟符号的定位:轻量、高频、可观测

Colibri 这个名字不是我拍脑袋起的。蜂鸟是全世界最小的鸟之一,但翅膀扇动频率能到每秒几十次,悬停精度极高,还能完成急转、后退这些高难度动作。这和我想做的压测工具完全对上了:二进制体积要小,启动要快,单机并发能打高,延迟统计要精准,命令参数要像蜂鸟取蜜一样干脆。

于是我给这个工具定了几条铁律般的设计目标:

  • 单文件分发:一条go install或者一个编译后的二进制就能跑,不引入配置文件,不依赖 Python/Lua 运行时。
  • 默认值要合理:就算用户什么都不懂,直接colibri -u https://example.com/api也能得到一份可读的压测报告。
  • 结果要能解释:不只输出平均延迟和 TPS,还要有 P50、P90、P99、错误率、状态码分布,让使用者一眼看出尾部延迟。
  • 限速要可控:不是让用户一上来就把服务打死,而是可以指定目标 QPS,模拟真实生产流量。这一点后面我会专门展开。

1.3 为什么用 Go,而不是 Python 或 Rust

语言选型是很多读者关心的,我直接说结论:这种 CLI 压测工具的黄金选择是 Go,不是 Python,也不是 Rust。

Python 写起来是快,但并发模型受 GIL 限制,想靠 asyncio 把单机压到几万 QPS 很吃力,而且分发时需要带上解释器和依赖环境,和“单文件二进制”的定位冲突。Rust 性能没话说,但开发周期长,同样的需求我可能要多花两三倍时间,而且网络生态里的 HTTP 客户端库用起来不如 Go 标准库顺手。Go 的 goroutine 调度非常轻量,几万个并发 goroutine 也能扛得住,标准库net/http内置了连接池,flag标准库可以直接写 CLI 参数,交叉编译一条命令就能出 Linux/Windows/macOS 三份二进制。对一个压测工具来说,Go 几乎每个特性都踩在需求上。

2. 核心设计拆解:并发模型与请求调度

2.1 并发模型怎么设计才能打满 QPS

压测工具最容易犯的第一个错是:用户传了-c 10000,代码就悄悄go func起一万个 goroutine 疯狂发请求。表面看是并发了,实际可能把调度器拖垮,结果数字既不稳定也不可信。我在 Colibri 里采用的是一个经典的 worker pool 模型:主 goroutine 负责按节奏分发任务,固定数量的 worker goroutine 负责真正发 HTTP 请求,结果通过 channel 返回统计端。

这里有个关键点:worker 的数量就是并发数,不是 goroutine 总量。比如-c 200表示固定 200 个 worker 从任务 channel 里取请求执行。这样不管总请求量是一万还是一百万,活跃 goroutine 数量都是可控的,不会因为任务太多导致调度抖动。核心代码大概是这样的:

type Result struct { Duration time.Duration Status int Err error } func startWorkers(client *http.Client, taskCh <-chan *http.Request, resultCh chan<- Result, concurrency int) { var wg sync.WaitGroup wg.Add(concurrency) for i := 0; i < concurrency; i++ { go func() { defer wg.Done() for req := range taskCh { start := time.Now() resp, err := client.Do(req) d := time.Since(start) if err != nil { resultCh <- Result{Duration: d, Err: err} continue } resp.Body.Close() resultCh <- Result{Duration: d, Status: resp.StatusCode} } }() } wg.Wait() close(resultCh) }

任务 channel 我通常设一个缓冲,比如buf := concurrency * 10,降低分发 goroutine 和 worker 之间的耦合。如果缓冲太小,分发端容易阻塞;缓冲太大,内存占用和任务积压又不好控制。实测下来,这个比例在绝大多数场景下都能保持请求节奏稳定。

2.2 连接复用:决定压测上限的底层逻辑

很多人压测结果忽高忽低,问题不是服务端扛不住,而是客户端自己把自己卡死了。最典型的案例就是“每次请求新建 TCP 连接”。在一次 HTTP 请求里,TCP 三次握手 + TLS 握手 + 传输 + 四次挥手所占的时间,往往比真正处理业务逻辑还长。更麻烦的是,频繁关闭连接会在压测机器上堆积大量TIME_WAIT状态的端口,压测到一半就报cannot assign requested address,也就是端口耗尽。

所以在 Colibri 里,我直接用http.Transport默认连接池,并显式调整了连接池参数:

transport := &http.Transport{ MaxIdleConns: concurrency * 2, MaxIdleConnsPerHost: concurrency, MaxConnsPerHost: concurrency, IdleConnTimeout: 30 * time.Second, TLSHandshakeTimeout: 5 * time.Second, }

MaxIdleConnsPerHostMaxConnsPerHost设置成并发数,是为了让每个 worker 尽量复用一条连接,而不是每次请求都新建。压测工具如果默认关闭 keep-alive,就等于自己给结果上了一道枷锁。这一点也是 Colibri 在真实压测中比 ab 明显更有优势的根本原因。

2.3 目标 QPS 限速:把“洪水”变成“模拟流量”

压测工具常用的模式有两种:一种是“能打多快打多快”,看服务端极限在哪里;另一种是“按设定的速率打”,模拟线上正常流量。如果只提供前者,用户一上来就把-c调大,很容易把自己线上服务打挂。所以 Colibri 提供了-q参数来限制目标 QPS。

实现方式不复杂:任务分发循环里用time.Sleep控制两个请求之间的间隔。比如目标 QPS 是每秒 1000,那么理论间隔就是 1 毫秒一个请求:

func schedule(ctx context.Context, targetQPS int, duration time.Duration, taskCh chan<- *http.Request) { interval := time.Second / time.Duration(targetQPS) timer := time.NewTimer(interval) defer timer.Stop() start := time.Now() for time.Since(start) < duration { select { case <-ctx.Done(): return case <-timer.C: req, _ := http.NewRequestWithContext(ctx, method, url, body) // 简化示意 taskCh <- req timer.Reset(interval) } } }

严格来说,这种基于 timer 的调度会有微小漂移,但对于绝大多数业务压测来说完全够用。如果想更精确,可以引入golang.org/x/time/rate的令牌桶实现。我这里故意选择了轻量方案,因为 Colibri 的定位就是“够用、可解释”,而不是替代专业压测平台的精度。

这里要分享一个很多新手不知道的换算关系:并发数和 QPS 不是同一个概念,它们之间的桥梁是平均响应时间。假设接口平均响应时间是 100ms,一个 worker 串行执行,一秒最多完成 10 个请求。如果你想要 1000 QPS,至少需要 100 个并发。公式就是:

所需并发数 ≈ 目标QPS × 平均响应时间(秒)

所以当别人问“你们这个项目要多少并发”时,正确做法是先压一轮拿到响应时间,再反推并发参数。这也是我在 Colibri 默认配置里把并发设为 50、持续 10 秒的原因——先跑一个低压力基线,看响应时间,再决定要不要加并发。

3. 从 0 到 1 实现:关键代码与踩坑记录

3.1 CLI 参数设计与默认值

Colibri 的使用方式尽量向 curl 靠拢,所有参数都是无依赖的裸参数,我列一下完整参数表:

参数含义默认值使用备注
-u目标 URL无(必填)支持 http/https
-c并发数50固定 worker 数量
-n总请求数00 表示按持续时间跑
-d持续时间10s配合 -n 二选一
-m请求方法GET支持 POST/PUT/DELETE 等
-H请求头可重复传入
-B请求体POST 时配合 -T 指定类型
-t单请求超时5s超时计入错误
-q目标 QPS00 表示不限制
-k是否 keep-alivetrue建议保持默认

-n-d的关系我特别说明一下:如果同时传了-n 1000 -d 10s,我实现为“谁先到谁结束”,优先满足实际测试的意图,避免用户设了总请求数却因为响应太慢,跑了十分钟还停不下来。

3.2 统计报表与百分位计算:P99 比平均值更重要

压测结果里最低限度要给出“平均延迟”,但只给平均是非常误导人的。一个接口平均 50ms,听起来很健康,可如果有 1% 的请求延迟到了 800ms,对线上用户体验来说就是灾难。所以 Colibri 的结果报表里,我把百分位放在显眼位置:

TPS: 912.4 平均延迟: 108.2 ms P50: 95.7 ms P90: 186.4 ms P99: 512.8 ms 最大延迟: 1203.5 ms 错误率: 0.12% 状态码分布: 200: 9120, 500: 11

计算百分位的方法是在所有请求结束后,把延迟切片排序,然后取对应位置的值。核心代码:

func percentile(sortedDurations []float64, p float64) time.Duration { if len(sortedDurations) == 0 { return 0 } idx := int(float64(len(sortedDurations))*p/100 + 0.5) if idx > 0 { idx-- } return time.Duration(sortedDurations[idx]) }

这里的细节是把len*percent/100结果做四舍五入,再减去 1,确保索引不越界,同时也符合常见的百分位定义。如果请求量特别大,比如上百万条,排序整个切片会有内存压力,我采用的优化是只保存延迟分布直方图,固定划分区间,最后从累计分布里近似计算百分位。但对个人压测工具来说,直接排序 10 万条数据也就几十毫秒,完全够用。

3.3 实时进度输出:不要用复杂 UI

很多压测工具在终端里搞花里胡哨的进度条,Colibri 的定位是轻量,所以我只做了一个每秒刷新一次的简明输出,显示已请求数 / TPS / 当前错误数。实现思路就是统计 goroutine 每秒从结果 channel 里取一次数据,聚合后清零重记。这里要注意一个坑:结果 channel 的读取方必须和 worker 的关闭逻辑做好同步,否则会出现统计 goroutine 还在等数据,worker 却提前退出,导致最后一批请求结果丢失。我通过sync.WaitGroup确保所有 worker 完全退出后再关闭结果 channel,再让统计端退出,顺序不能反。

3.4 和 wrk 的对比测试:数据说明差距

自己写工具最怕自嗨,所以我把 Colibri 和 wrk 在同一个本地接口上做了对比压测。环境是 4 核 8G 的虚拟机,接口是一个简单的 Go HTTP handler,直接返回{"code":0},压测命令如下:

wrk -t4 -c200 -d10s http://127.0.0.1:8080/ping colibri -c 200 -d 10s -u http://127.0.0.1:8080/ping

结果对比如下:

指标wrkColibri
平均 TPS1512314876
平均延迟13.1 ms13.4 ms
P9921.5 ms22.3 ms
错误率0%0%

差距在 2% 以内,对于业务压测来说这个误差完全可以接受。wrk 作为 C 写的工具,在极限高并发下仍然有微弱的性能优势,但 Colibri 换来了跨平台、免编译 Lua 依赖、结果输出更可读的优势。数字证明,用 Go 实现一个够用的压测工具,在工程上站得住脚。

4. 真实场景实测:一次接口压测带来的三个发现

4.1 压测流程与完整输出

项目本身写完后,我拿公司一个真实的订单查询接口做了首轮验证。接口平均逻辑是查 Redis 缓存再加一个数据库兜底,平时 QPS 大约 300。我先用默认参数跑了一个基线:

colibri -u https://api.example.com/v1/order/detail -m POST -H "Content-Type: application/json" -B '{"orderId":"123456"}' -c 50 -d 10s

输出:

TPS: 512.7 平均延迟: 96.4 ms P50: 82.1 ms P90: 173.5 ms P99: 486.2 ms 最大延迟: 1044.0 ms 错误率: 0.00% 状态码分布: 200: 5127

从表面看,512 TPS 对目前 300 的日常流量来说已经有余量,P50 也很健康。但如果只看这个结果就收工,就会漏掉下面三个关键问题。

4.2 发现一:连接未复用导致端口耗尽

我在压测时故意把-k关掉,模拟一个没有连接池的客户端,结果压到第 6 秒时错误率突然飙升:

错误率: 23.40% 错误信息示例: dial tcp 10.0.0.8:443: connect: cannot assign requested address

这就是经典的端口耗尽问题。每次请求新建连接,客户端在TIME_WAIT状态里把本地端口占满,内核无法为新的 TCP 连接分配四元组。排查手段是用ss -s看 socket 统计,如果timewait数量暴涨到几万个,基本就实锤了。生产环境里如果也会遇到这个报错,解决思路有三层:最优先是启用 HTTP keep-alive;其次是调大本地端口范围,Linux 下执行sysctl -w net.ipv4.ip_local_port_range="1024 65535";最后是谨慎开启tcp_tw_reuse,因为它对 NAT 环境有副作用,不建议随意设置。

4.3 发现二:P99 明显偏高的元凶

回到正常压测结果,P50 是 82ms,P99 却到了 486ms,相差近 6 倍。如果你只把平均值报给领导,这个问题永远不会暴露。我当时用pprof对压测客户端抓 goroutine 栈,同时让服务端打印慢日志,最终定位到两个原因。

第一个原因是 Redis 连接池最大连接数满了,热点 key 的请求集中在几个库连接上排队。第二个原因是服务端 Handler 里有一个偶尔触发的冷数据路径,会额外走一次数据库查询,在缓存 miss 时延迟从 80ms 跳到 400ms 以上。这两个问题单独看都不致命,但叠加起来,P99 就变得很难看。这个案例也说明,压测时只关注平均延迟的代价,就是把大量不可控的尾部延迟隐藏在了“还不错的数字”里。

4.4 发现三:限速压测才更接近生产

拿到 512 TPS 这个数之后,我并没有立刻写“系统每秒能扛 500 请求”的结论。因为生产环境的流量不是均匀的平坦流,也不是一上来就满负载。我改用-q参数模拟真实流量形态,比如压 30 秒内从 100 QPS 逐渐涨到 400 QPS,观察服务端在流量爬坡时的反应:

colibri -u https://api.example.com/v1/order/detail -m POST -c 200 -q 300 -d 30s

这轮结果很有价值:在固定 300 QPS 下,P99 从之前的 486ms 降到了 180ms 左右,说明大部分排队延迟是打满压力后产生的,而不是服务端本身性能差。更重要的是,这个“限速后的稳定值”才是我们能向业务团队承诺的容量基线。压测的目的从来不是证明系统能被打得有多惨,而是找到“可以稳定承接的流量水位”。

5. 常见问题排查与经验补充

5.1 问题速查表

把这几轮实操中遇到的问题整理成一个速查表,日常排查时可以按图索骥:

问题现象常见原因解决思路
压测中途报connection reset by peer服务端主动断连,可能是超时或 GC 停顿查看服务端日志;适当调大-t超时时间
错误信息为too many open files客户端文件句柄数达上限ulimit -n调大,例如 100000;关闭不用的连接
高并发下 TPS 很低但 CPU sys 占用高客户端频繁系统调用;连接未复用确认 keep-alive;检查 TPR 是否握手开销过高
结果波动极大,同一命令两次跑差距超过 30%客户端机器成为瓶颈;服务端有定时任务干扰换更强压测机;服务端加 trace 确认
QPS 限速不准,整体偏低time.Sleep漂移导致每秒请求数不稳rate.Limiter;或缩短统计窗口
压测结束卡住不退出worker 未正确关闭,channel 阻塞检查sync.WaitGroup和 channel 关闭顺序

5.2 压测客户端机器需要调整的系统参数

很多人装了压测工具就以为万事大吉,实际上压测客户端本身也需要调优。我到一个新环境做压测时,第一轮做的不是压测,而是看内核参数。

  • 文件句柄限制:ulimit -n。生产服务器经常默认 1024,压测并发一高立刻too many open files
  • 本地端口范围:cat /proc/sys/net/ipv4/ip_local_port_range。默认32768 60999,压力大时建议扩到1024 65535
  • TCP 缓冲区:如果压测机延迟很大,考虑调整net.ipv4.tcp_wmemnet.ipv4.tcp_rmem,但这不是第一优先项。
  • 不要在共享服务器上做极限压测,否则压测机本身的噪声会污染结果。

5.3 几个容易忽略的实现细节

写 Colibri 的过程中,有几个小坑很小但很致命,值得单独记一笔。第一个是 POST 请求体没有设置Content-LengthContent-Type,有些服务端会直接解析失败。我在 CLI 里增加了一个-T参数,默认根据-B的字符串自动设置application/json,但允许手动覆盖。第二个是请求 URL 里如果带中文或特殊符号,必须做url.Parse而不是直接拼接字符串,不然百分号编码会错乱,导致 400 错误。第三个是压测时的 DNS 解析,千万不能每次都重新解析,应该用单次解析后的 IP 复用连接,否则 DNS 查询本身也会成为瓶颈。

5.4 如何正确解读压测结果

拿到压测报告,最忌讳直接看 TPS 就下结论。我的习惯是四步走:先看错误率,错误率不为 0 的数字没有参考价值;再看 P99 和最大值,确认尾部延迟是否可接受;然后确认压测端自身没有成为瓶颈,比如 CPU、内存、端口是否打满;最后复盘并发参数与真实流量之间的关系,判断结果能不能对应到生产场景。

还有一个经验:压测前一定要预热。JVM 应用和很多 Go 服务在启动后都有一个 JIT 编译或内存池热身的过程,直接上压力会得到偏低的数字。Colibri 里我建议先跑一个低 QPS 的短压测,比如-q 50 -d 30s,让服务端完成预热,再进入正式压测。

6. 后续扩展方向

6.1 从单机到分布式:多节点协同压测

单机压测的瓶颈始终存在,即使连接调优做到极致,几千 QPS 以上时客户端 CPU 和网络栈也会出现明显抢锁和中断开销。Colibri 后续如果要扩展,最自然的路线是做成 master-worker 模式:master 下发压测任务参数给多台压测节点,每台节点独立打压力,最后把结果聚合到 master 端。这个方案在实现上并不复杂,核心是把结果上报换成 HTTP/gRPC 接口,但要注意多节点的时钟同步,统计延迟尽量用节点本地的time.Now(),而不是依赖 master 的时间戳。

6.2 输出 JSON/HTML 报告,接入 CI

压测工具如果不能嵌入自动化流水线,就只能在人工排查时用。我计划在后续版本里增加--format json--format html,把 TPS、百分位、状态码分布一次性输出成结构化报告。这样每次发布前跑一次压测回归,CI 里直接对比两个版本的 P99 阈值,超过预设值就终止流水线。这是一种性价比非常高的质量保障手段,能提前拦截性能退化。

结尾就用一段个人体会收束吧。做 Colibri 这个过程,最大的收获不是最后跑出来的那组漂亮数字,而是我终于把压测工具背后那套底层的逻辑彻底捋顺了:连接复用为什么重要、QPS 和并发数之间怎么换算、P99 和平均值差多少才算危险。很多时候我们手里的工具越高级,越容易忽略原理,真到了结果异常的那天,连从哪查起都不知道。所以我建议每一个做后端或者运维的同行,都试着亲手写一个自己的压测小工具,不用多复杂,能自定义并发、能统计百分位就足够了。写完以后你再回头看 wrk、vegeta 这些工具,视角会很不一样。最后送大家一句压测时我总提醒自己的话:下手压之前,先想清楚这一串数字,你准备用它来做什么。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询