我最初为了测试博客,自己写了一套并发请求、延迟统计和结果汇总代码。代码越来越长,却一直在重复成熟压测工具已经解决的问题。
后来我把核心负载生成换成 autocannon,只保留博客特有的部分:扫描站内 HTML 页面、过滤静态资源、解析命令行参数和遇到错误时返回非零退出码。脚本从四百多行缩到一百多行,输出也更容易比较。
autocannon 适合什么场景
autocannon 是一个使用 Node.js 编写的 HTTP/1.1 benchmarking 工具,受 wrk 和 wrk2 启发,支持 HTTPS、HTTP pipelining、CLI 和程序化 API。
它适合:
- 在开发机或测试机快速测 HTTP 服务。
- 比较同一应用的两种运行时或配置。
- 观察固定连接数下的吞吐量和延迟分位数。
- 写进 TypeScript 脚本,自定义请求路径和失败条件。
它不是分布式压测平台,也不模拟真实浏览器。要生成跨地域流量、复杂用户会话或几万台客户端,应该使用其他工具。我的博客并发几乎为零,没必要先搭一套高级系统。
安装为项目依赖
我不使用全局安装,把工具和类型固定在应用 workspace:
bun add --dev autocannon @types/autocannonpackage 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 会输出请求速率、延迟分布、总流量、错误和状态码。我的关注顺序是:
errors和non2xx是否为零。- p99 是否已经越过可接受阈值。
- 平均吞吐量是否还随连接数增长。
- 平均延迟和最大延迟是否突然跳升。
脚本把错误作为失败状态:
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。扫描页面、组合多请求、定制请求头和根据结果设置退出码都在一个脚本里完成,不需要解析另一条命令的文本输出。
这也是我最终没有继续维护自制压测器的原因。项目真正特殊的是“测哪些页面”和“什么时候停止”,不是重新发明并发连接与延迟统计。