用 autocannon 给本地 Web 服务做阶梯压力测试

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处
autocannon压力测试BunDocker

我最初为了测试博客,自己写了一套并发请求、延迟统计和结果汇总代码。代码越来越长,却一直在重复成熟压测工具已经解决的问题。

后来我把核心负载生成换成 autocannon,只保留博客特有的部分:扫描站内 HTML 页面、过滤静态资源、解析命令行参数和遇到错误时返回非零退出码。脚本从四百多行缩到一百多行,输出也更容易比较。

autocannon 适合什么场景

autocannon 是一个使用 Node.js 编写的 HTTP/1.1 benchmarking 工具,受 wrkwrk2 启发,支持 HTTPS、HTTP pipelining、CLI 和程序化 API。

它适合:

  • 在开发机或测试机快速测 HTTP 服务。
  • 比较同一应用的两种运行时或配置。
  • 观察固定连接数下的吞吐量和延迟分位数。
  • 写进 TypeScript 脚本,自定义请求路径和失败条件。

它不是分布式压测平台,也不模拟真实浏览器。要生成跨地域流量、复杂用户会话或几万台客户端,应该使用其他工具。我的博客并发几乎为零,没必要先搭一套高级系统。

安装为项目依赖

我不使用全局安装,把工具和类型固定在应用 workspace:

bun add --dev autocannon @types/autocannon

package script:

{
  "scripts": {
    "load-test": "bun scripts/load-test.ts"
  }
}

虽然 autocannon 自己使用 Node.js API 编写,Bun 的兼容层可以运行它。这里没有必要为了“原生 Bun”重新实现连接池和直方图。

最小程序化调用

README 中最小配置包含 URL、连接数、pipelining 和持续时间:

import autocannon from 'autocannon';

const result = await autocannon({
  url: 'http://127.0.0.1:3000',
  connections: 10,
  duration: 30,
  pipelining: 1,
});

console.log(autocannon.printResult(result));

几个参数不要混着理解:

  • connections 是同时保持的连接数量,默认 10。
  • duration 是持续秒数,默认 10。
  • pipelining 是每条连接同时排队的 HTTP 请求数量,默认 1。
  • amount 是固定请求总数;设置后会忽略 duration。

普通 Web 页面测试先保持 pipelining: 1。一开始就把 pipelining 调大,虽然能制造更高吞吐量,却可能偏离浏览器常见请求方式,也让不同服务的对比难以解释。

为什么还要自己写一层脚本

只压首页的命令很短:

bunx autocannon -c 10 -d 30 http://127.0.0.1:3000/

但博客有两百多篇文章。首页可能被静态生成、缓存或单独优化,只测它不能代表整站路径。

我的脚本先用普通 fetch() 从入口扫描同源链接:

const response = await fetch(url, {
  headers: REQUEST_HEADERS,
  redirect: 'follow',
});

if (!response.ok) {
  throw new Error(`蜘蛛访问 ${url} 返回 HTTP ${response.status}`);
}

只保留 text/html,并排除:

  • 外部域名。
  • /_next/api 和附件接口。
  • 图片、CSS、JavaScript、PDF、视频和压缩包。
  • 查询参数与 hash。
  • <script><style><pre><code> 中的伪链接。

得到路径列表后,交给 autocannon 的 requests

const result = await autocannon({
  url: options.url.origin,
  connections: options.connections,
  duration: options.duration,
  pipelining: 1,
  headers: REQUEST_HEADERS,
  requests: paths.map((path) => ({
    method: 'GET',
    path,
  })),
});

负载生成、延迟直方图和结果打印仍由 autocannon 负责,我只告诉它轮询哪些页面。

先解决重定向

第一次试跑出现了一批非 2xx。应用本身没有错误,原因是蜘蛛保存了重定向前的 URL,而 autocannon 不会像浏览器那样自动跟随每个跳转。

fetch() 已经使用 redirect: 'follow',因此应该保存最终响应地址:

pages.push(new URL(response.url).pathname);

而不是继续保存请求前的 url

这类问题说明“非 2xx”只是现象。压测前先用普通请求验证 URL 集合,否则会把 canonical 跳转、HTTP 到 HTTPS 或尾斜杠规范化误判成服务失败。

阶梯加压比一次打满更有用

我不会直接从 1000 个连接开始。正常流程是逐级提高:

bun run load-test -- --url http://127.0.0.1:3000 --connections 10 --duration 30
bun run load-test -- --url http://127.0.0.1:3000 --connections 20 --duration 30
bun run load-test -- --url http://127.0.0.1:3000 --connections 50 --duration 30
bun run load-test -- --url http://127.0.0.1:3000 --connections 100 --duration 30

如果吞吐量继续增长、p99 仍低,再增加到 200、400、800。每一档保持相同 duration、pipelining、页面集合和日志设置。

停止条件要在测试前写好,例如:

  • 出现连接错误或超时。
  • 出现非 2xx
  • p99 超过 1 秒。
  • 容器内存持续逼近限制。

否则很容易看到吞吐量还没归零就继续加压,最后得到一组服务虽然没崩、用户却已经等很久的数据。

怎样读结果

autocannon 会输出请求速率、延迟分布、总流量、错误和状态码。我的关注顺序是:

  1. errorsnon2xx 是否为零。
  2. p99 是否已经越过可接受阈值。
  3. 平均吞吐量是否还随连接数增长。
  4. 平均延迟和最大延迟是否突然跳升。

脚本把错误作为失败状态:

if (result.errors > 0 || result.non2xx > 0) {
  throw new Error(
    `压测出现 ${result.errors} 个连接错误和 ${result.non2xx} 个非 2xx 响应`,
  );
}

平均延迟低不代表所有请求都快。如果平均值只有 50ms,p99 已经超过 1 秒,说明至少有一部分请求在明显排队。

吞吐量平台也比单点峰值更有信息。当连接数从 100 加到 400,请求速率几乎不变、延迟接近四倍,服务已经饱和。

同时观察 Docker 容器

只看压测终端,无法判断瓶颈在 CPU、内存还是进程退出。我在另一条任务中持续执行:

docker stats --no-stream CONTAINER
docker top CONTAINER

记录:

  • CPU 百分比。
  • 内存占用与限制。
  • 进程数量。
  • 压测结束后的健康检查。

如果 CPU 长期约为 100%,吞吐量不再增长而延迟继续升高,通常是在单核瓶颈后排队。如果内存持续增长,则需要延长测试并做堆分析,不能只凭一次快照宣布内存泄漏。

压测客户端和服务端都在同一台电脑时,还要确认客户端没有先耗尽 CPU。更严谨的极限测试应该把负载生成器放到另一台机器。

不要拿第一次结果写结论

第一次运行会受到镜像启动、缓存预热、DNS、JIT 和文件缓存影响。我会先试跑,确认页面数量、错误和容器监控都正常,再重新启动容器做正式采样。

比较两个运行时时,还要保持:

  • 同一份应用和内容。
  • 相同端口映射。
  • 相同访问日志开关。
  • 相同页面集合和请求头。
  • 串行运行,避免两个容器争抢资源。
  • 相同停止条件。

基于这套方法得到的 Bun 与 Node.js 数据,整理在 Bun 1.4 与 Node.js 24 运行 Next.js standalone 极限压测

与 ApacheBench 的关系

我以前使用过 ApacheBench,相关命令仍保留在 ApacheBenchmark 压力测试。它适合快速打一个 URL,系统里已经有 ab 时尤其方便。

autocannon 对 TypeScript 项目更顺手的地方是程序化 API。扫描页面、组合多请求、定制请求头和根据结果设置退出码都在一个脚本里完成,不需要解析另一条命令的文本输出。

这也是我最终没有继续维护自制压测器的原因。项目真正特殊的是“测哪些页面”和“什么时候停止”,不是重新发明并发连接与延迟统计。