基准测试
三个基准测试,都可以在本地运行,也都如实说明各自测量的是什么:
pnpm bench—— 单独测量路由器分发(scripts/bench-router.mjs):把 一个 pathname 与路由表相匹配的成本,脱离上下文测量。pnpm bench:http—— 整请求吞吐量(scripts/bench-http.mjs): 用 keep-alive HTTP 客户端压每个框架的真实服务器,测量从 socket accept 到响应排空的一切。pnpm bench:memory—— 内存与启动(scripts/bench-memory.mjs): 新进程到首个响应的时间、预热负载后的稳态 RSS、以及每条注册路由 保留的堆字节。
整请求吞吐量
node v22.23.2 | concurrency=32 requests=30000/scenario
s200 hello 9254 req/s param 9095 req/s
s200-light hello 11844 req/s param 11744 req/s
hono hello 10067 req/s param 9956 req/s
hono-patched hello 14130 req/s param 13762 req/s
express hello 6476 req/s param 6355 req/s
fastify hello 14115 req/s param 14064 req/s
elysia hello 14420 req/s param 14135 req/s机器:AMD Ryzen 7 8745HS,Fedora 42,Node 22.23.2,2026-09-21。 pnpm bench:http 在干净的子进程里重新运行每一行。
场景:
hello—GET /→{ "message": "hello" }param—GET /users/:id→{ "id": "42", "name": "ada" }
读数:
- 在真实 Web 标准路径上 s200 ≈ hono。 这里的
hono以overrideGlobalObjects: false运行@hono/node-server——即真实的Request/Response对象,也正是 s200 默认使用的东西。同一类别之内, s200 赢param,hono 赢hello。 s200-light是 opt-in 的快速路径(serve(app, { light: true })): node 适配器构造轻量的Request/Response对象(不 patch 任何全局 东西),并把字节背书的响应体直接写进 socket。它比默认类别高出约 1.3×,在真实 Web 标准对象上比 hono 快约 18%——与 patched 类别之间 剩下的差距来自Headers构造和适配器下限。hono-patched是 hono 默认的快速路径:适配器用它自己的极简类替换 全局Request/Response。在真实 Web 标准对象上它比两个框架都快约 1.5×——这笔成本按请求计在 undici 功能齐全的Request/Response构造器里,而不是在路由或分发里。s200 不 patch 全局:默认契约就是平台 的Request/Response,opt-in 的 light 模式用更窄的适用范围为速度 买单(见 README)。fastify和elysia落在与hono-patched相同的 ~14k 类别: fastify 直接搬运原始IncomingMessage(完全没有 Web 标准对象), elysia 的 srvx 适配器自带轻量 request 类。两者都以 hono 的 patch 和 s200 的 light 模式同款的方式避开了 undici 的构造器成本——在本次基准 测试里,这笔成本就是两个类别之间的全部差距。- express 落后于两者——它的路由(path-to-regexp)与每请求的流式 管道成本在饱和时大约翻倍。
路由器分发
routes=200 iterations=100000 (min of 3 rounds)
first hit 0.44 µs/req x1.0
last hit 0.49 µs/req x1.1
miss 0.19 µs/req x0.4
param-first 0.45 µs/req x1.0
routes=2000 iterations=100000 (min of 3 rounds)
first hit 0.46 µs/req x1.0
last hit 0.48 µs/req x1.0
miss 0.19 µs/req x0.4
param-first 0.46 µs/req x1.0从 200 条到 2000 条路由,分发成本是平坦的:遍历只访问请求自身各段 拼写出来的那些 trie 节点,因此表的大小从不进入热路径。(更早的快照 曾显示 2000 条路由时 2.2 µs——那是 2 万次迭代 GC 噪声造成的伪影; 上面的运行用 10 万次迭代,各轮之间稳定。)
param-first 是其他 trie 路由器会退化的形状(/:tenant/resourceN 风格);s200 的索引以每个静态段为键,因此匹配成本随 URL 深度走, 而不是随路由条数。残留的线性场景是一张完全没有静态段的表 (/:a/:b/:c)。
内存与启动(2026-10-21)
pnpm bench:memory(scripts/bench-memory.mjs)测量吞吐量基准从未 触碰的三个轴:一个新进程多快给出首个响应、一台预热后的服务器占多少 内存、一条注册路由保留多少堆。每一格都是各自全新 spawn 的子进程; s200 子进程导入构建后的核心(dist/),其他框架按任何用户的用法导入 各自的包。重跑用 node scripts/bench-memory.mjs(环境旋钮: COLD_RUNS、RSS_RUNS、PER_ROUTE_RUNS、ROUTES、WARMUP)。
机器:AMD Ryzen 7 8745HS w/ Radeon 780M Graphics,Fedora 42 (linux 6.19.14),Node v26.10.0,2026-10-21 —— 取 7/3/3 个子进程的 中位数。
A · 冷启动
从 spawn 到 GET / 首个 200 的毫秒数——刻意包含 node 引导 + 模块 加载 + listen + 首个响应,这正是"新进程到首个响应"的含义(serverless 冷启动的形状):
| 框架 | ms |
|---|---|
| s200 | 71.9 |
| hono | 81.3 |
| express | 93.1 |
| fastify | 102.7 |
| elysia | 138.6 |
双路由入口对每个框架完全一致。s200 与 hono 是最快的两个,相差几毫秒 (观察到的各次运行里差距在 2–9 ms 间摆动;s200 子进程导入的是一个小巧 的已构建模块图);elysia 为导入付出的最多(它的编译器机械随框架一起 加载)。这些是几十毫秒量级的差异:对每请求都重新 spawn 的 serverless 是决定性的,对长驻守护进程无关紧要。 五次观察到的运行间摆动:s200/hono/fastify/elysia 的中位数保持在 ~±5% 之内;express 在这台机器上呈双峰(~90–134 ms,页缓存依赖) ——把它的格读作区间,不是点。
B · 稳态 RSS
在同一个双路由入口上跑完 5000 请求 keep-alive 预热(并发 8)、最后一 个响应排空之后,直接从 /proc/<pid>/status 读取的 VmRSS。预热一列是 该负载的吞吐量,仅作背景——权威吞吐量表在上面:
| 框架 | RSS (MB) | 预热 (req/s) |
|---|---|---|
| s200 | 127.7 | 11560 |
| hono | 99.1 | 17338 |
| express | 88.5 | 17251 |
| fastify | 94.0 | 24664 |
| elysia | 97.1 | 23665 |
2026-10-26 更新: 上表的 s200 行已被取代。node 适配器不再把 disconnect 信号经由 undici 的 init.signal follow 机制传递(那组对象 要等 major GC 才回收);同一协议下重测 s200 落在 99.2 MB——与 hono(98.6)同档,每请求 CPU 再降约 16%。完整剖析与前后对照表见 下方 History 一节。
hono/express/fastify/elysia 聚在 88–97 MB;s200 在同一负载后读到 ~127 MB。两次一次性探针(不属于脚本的一部分)把这笔差钉清楚是什么、 不是什么:
- 不是可回收垃圾——在预热后的子进程里强制完整 GC(经由一个临时 的
--expose-gc探针路由)之后,s200 仍在 ~128 MB,hono 仍在 ~96 MB,两边的页都保持被持有。 - 不是无界泄漏——把一个 s200 子进程打到 5k / 20k / 50k 请求 (每次读取前强制 GC)得到 127.3 → 158.5 → 158.8 MB:堆在分配压力 下增长,然后从 20k 起走平。hono 的同一曲线是 99.4 → 101.0 → 101.1 MB。
如实的读法:s200 的默认路径每请求物化平台 Request/Response,外加 每请求的 abort 管道与 state/context 袋,因此负载下的分配率更高,V8 据此把新生代撑得更大且不归还页。所以 B 表数字对负载历史敏感——它是 恰好 5000 请求时的 RSS,被压得更狠的 s200 服务器会稳定在 ~159 MB 附近(hono 则保持 ~101 MB)。两个数字都没有错;挑与你的流量形状相符 的那个。
C · 每路由堆占用
每条注册路由保留的堆字节:一个 --expose-gc 子进程构建应用、GC 两 次、记录 heapUsed,注册 ROUTES(500)条 /res<i>/:id → JSON 形状 的路由,再 GC 两次,报告 heapUsed 增量 ÷ N。不 listen、不发请求 ——这只是注册时刻的保留量:
| 框架 | B/路由 |
|---|---|
| s200 | 538.7 |
| hono | 343.8 |
| express | 2144.9 |
| fastify | 13953.6 |
| elysia | 1243.4 |
hono 保留的是一个朴素的路由数组条目(~344 B);s200 的 trie 节点 + 路由记录 + 参数元数据落在 ~539 B;express 存一个带已编译 path-to-regexp 的 layer(~2.1 KB);fastify 每路由保留最多(~14 KB ——它的路由选项、钩子管道与 schema 机械按设计就是每路由的);elysia 注册时落在 ~1.2 KB。注意:任何被框架推迟到注册之后的工作(listen 时 编译处理器、schema 定稿、首请求 JIT)都不在这个数字里,因此把 C 读作 注册轴,而不是服务时刻的每路由总成本。
注意事项
- 与吞吐量基准相同的单机、每格单进程纪律;数字是快照而非保证。
- 场景 A 包含就绪探测 ~2 ms 的轮询粒度,叠加在模块加载(这本就是 测量点)之上。
- 场景 B 仅限 linux(
/proc/<pid>/status;脚本在其他平台如实打印 N/A),在预热排空后读取一次——GC 噪声逐次运行在几百 kB 量级,且如 上所示,读数取决于此前跑过多少负载。 - 场景 C 是子进程里强制 GC 后的保留堆;在 N=500 时 V8 的强制 GC 增量每路由可能留下几百字节的松动。
方法论注意事项
- 同一台机器、同一客户端(
node:httpkeep-alive agent)、相同载荷,每个 框架一台服务器,先预热。数字是快照而非保证——请在你自己的硬件上重跑。 - 所有服务器都运行在独立的子进程里,任何框架的全局 patch 都不会污染 对别家框架的测量。
hello/param两行是零中间件的双路由应用:它们测量的是分发 + 适配器 下限,不是中间件密集的负载。后面的mw场景(见下方「历史记录」里的 中间件密集场景条目)覆盖中间件一侧;那两行保持hello/param无中间件。往脚本里加你自己的路由,来建模你自己的流量。- 这不是 TechEmpower 式的测试装置(没有多核争用建模,没有内核调优)。 用它做相对比较,别用来做容量规划。
历史记录
下面各节是历次工作会话按时间顺序留下的笔记,保留它们是因为记录了 数字如何随功能落地而移动、以及今天的表为什么长这样。上面各节才是 权威运行;当下面的笔记与权威表不一致时,以权威表为准(笔记描述的是 它们当天的 dist)。
2026-09-29 更新(LightHeaders + 默认 disconnect-abort)
后来的两项变更在另一台机器上(Node 26,同一会话内先后对比,因此相对 增量依然成立,尽管绝对数字与上面的参考表不可比)改变了 s200 各行的 位置:
LightHeaders—— light 路径的请求/响应头现在是一个鸭子类型的Headers(大小写不敏感、按插入排序、线上输出一致),用一次按 pair 建索引的构建替换了每请求两次 undiciHeaders构造。s200-light拿到 +5.8%(hello)/ +4.0%(param);light 对正常的比值从 ~1.23–1.26× 升到 ~1.35×。- 默认 disconnect-abort ——
ctx.signal现在开箱即在客户端断连时 中止(abortOnDisconnect: false可恢复旧的共享永不中止 signal)。 每请求的AbortController+ socket 监听器在双路由下限上花费 ~7.7%;每个框架行都为其自己的断连策略付费,因此把此变更之前 测得的跨框架增量看作少付了一个策略。
2026-09-29 补充(light 模式覆盖扩展)
light 路径的表面覆盖被扩展并由测试钉住——普通 stream 响应、对流式 light 响应体施加的 compress、全部 serveStatic 形态(字节体、 stream: true 文件、readRange 206、HEAD/304/416/301),以及对 light 请求施展的请求体读取器(readJson/readText/readForm/ readStream,见 test/light-batteries.test.ts)。这些都不触碰 hello/param 热路径(json() 的字节快速路径),因此吞吐量如预期 持平——同一会话快照,Node v26,针对 s200 各行:
s200 hello 10768 req/s param 10639 req/s
s200-light hello 15016 req/s param 14638 req/slight 对正常的比值保持在 ~1.39×(hello)/ ~1.38×(param),与 LightHeaders 之后的 ~1.35× 相符(机器噪声)。覆盖工作浮出并修复了 一个真实 bug:针对流式静态文件的 HEAD 请求会宣告 content-length: 0 (两种模式皆是)——serveStream 现在让由 stat 得出的大小得以存活 (handle 的 HEAD 改写随后剥掉响应体)。
2026-09-30 更新(light 路径上的惰性请求头)
light 路径不再为没人读的头付费:LightHeaders 现在惰性构建它的 索引——node 适配器按引用把 req.rawHeaders 递给它(中间的 pairs 数组 没了),slot/index 机制在首次结构性访问(get/set/迭代/…)时才 物化。一个处理器从不碰 req.headers 的请求——hello/param 正是 这个形状——什么也不构建。响应侧拿到了 fillRecord,一个面向 writeHead 的直接 slots→record 填充(替换掉对同一数据的 forEach 闭包);http2 伪头过滤和每一个线上可见的语义(大小写不敏感、插入 顺序、", " 合并、不合并且 set-cookie)都由 test/light-batteries.test.ts 钉住。
如实测量,因为墙钟看不见它:官方基准客户端在这台机器上于 ~14.5k req/s 附近饱和(一个独立的 keep-alive 客户端把同一台 s200-light 服务器打到 25–26k),因此 hello/param 的墙钟时间受客户端制约 且保持平坦。服务端信号是每请求 CPU,用交错的同条件 A/B 运行来测量 (10 对,未变更的 s200 行作为漂移对照):
- 服务端 CPU/请求 −6.3%(中位数),10 对里 9 对 lazy 更快;
mw墙钟 +3–4%(fillRecord随响应头数量扩展,且链会读一个 请求头——惰性构建在那里也回本了);- light 对正常的比值在
hello和mw上都保持在 ~1.43×。
中间件密集场景(2026-09-30)
scripts/bench-http.mjs 长出了第三个场景 mw(--only mw 单独运行 它):与 hello 相同的 {"message":"hello"} JSON 路由,但要经由一条 五环中间件链才能抵达,链上每一环都
- 读取
x-test请求头, - 在 unwind 时设置自己的响应头,
- 并且——在第三环——把一个
performance.now()增量存进每请求上下文。
在每个框架里该链都限定在 /mw,因此 hello/param 两行继续测量 无中间件的下限,而 mw 行相对 hello 的增量就把链本身隔离了出来。
与参考表同一台机器(AMD Ryzen 7 8745HS,Fedora 42),如今跑在 Node v26.10.0 上——绝对数字与上面 Node 22 的参考不可比。本表是 针对 2026-09-30 最终 dist 的权威运行(含惰性 light 头;更早一次 pre-lazy 运行的排序一致,mw 各格在 ~±4% 之内,s200-light mw 低约 4%):
| 框架 | hello(同次运行) | mw | mw 对 hello |
|---|---|---|---|
| s200 | 10308 req/s | 9590 | −7% |
| s200-light | 14760 req/s | 13718 | −7% |
| hono | 12278 req/s | 10250 | −17% |
| hono-patched | 17904 req/s | 9313 | −48% |
| express | 11840 req/s | 10841 | −8% |
| fastify | 17590 req/s | 16681 | −5% |
| elysia | 16846 req/s | 15428 | −8% |
单个 hello 格逐次运行会有高达 ~±10% 的摆动,因此请把单格增量读作 近似值,把排序读作信号。
它测量什么——以及它不测量什么
该场景测量的是 unwind 一条五环链的每请求成本:链分发、每环一次 请求头读取和一次响应头写入、一次计时存储。它不重复测量适配器下限 ——hello 已经测过了——这就是为什么快速类框架(fastify、elysia、 hono-patched)在绝对数字上稳居前列,尽管它们的中间件成本天差地别。
各框架的形态是每个框架惯用的中间件写法,保持工作量等价(相同的读取、 写入和计时存储)但并非机械相同,这份诚实在读表时很重要:
- express(
app.use('/mw', …))和 fastify(封装的register作用域里的 asynconRequest钩子)把头写进一个存储,在响应序列化时 应用。elysia 以同样的方式写set.headers,只是放在guard({ beforeHandle })里——它的onRequest钩子无法限定作用域 (它们对整个实例触发,已验证),所以路由后钩子就是限定作用域的 等价物。 - hono(
app.use('/mw/*', …)配await next(); c.header(…))和 s200(use(app, '/mw', …)配next()之后的ctx.res.headers.set)做的是真正的 unwind:它们在处理器跑完之后 修改已经物化的响应。
这种不对称正是 mw vs hello 一列的故事。fastify 几乎不损失(各次 运行 ~0–5%):它的钩子链就是向头存储追加的普通函数调用。elysia (−8%)和 express(−8%)付出适度的链成本。hono 掉了 −17–18%: await next() 之后的 c.header() 会重新物化响应——每写一个头就 一次——而在 patched 快速路径上这个重建成本压过其他一切:hono-patched 崩落 −46%,在中间件负载下落到低于未 patch 的 hono。s200(各次 运行 −7–9%)同样在处理器之后写头,但是原地修改既有 Response 的头 ——不重建——尽管每请求上下文更丰富,仍落在 express 的邻域。
s200-light 自己的下限几乎未动(−7%,在 hello 格噪声之内),并在 中间件负载下把 light 对正常的比值保持在 ~1.43×:light 路径廉价的 LightHeaders 写入,让链叠加在一个本已廉价的响应之上几乎免费。
与整请求一节相同的方法论注意事项同样适用——快照而非保证;每个子进程 一台服务器;请在你自己的硬件上重跑 (node scripts/bench-http.mjs --only mw)。
2026-10-26 更新(disconnect 信号直接安装,不再经由 init.signal)
用 node:inspector 采样堆剖析器(16 KiB 间隔)对场景 B 的入口做 默认(非 light)路径剖析——并发 8 下 30 000 个请求——78% 的 采样字节落在 undici _Request 构造器之下,其中 53% 集中在一个 子树:传入 init.signal 时它的构造器搭起的 follow 机制(一个内部 AbortController、一个 WeakRef、一个作为监听器登记在我们的信号上 的 abort 闭包,以及持有这一切的 FinalizationRegistry 项)。s200 自身代码只占 ≤ 4%。
稳态 RSS 的差距来自这套机制的生命周期,而不是分配速率。负载后的 堆快照(5 000 请求)显示约 4 000 组存活的 AbortSignal + Listener + Map + WeakRef——每组对应一个请求:finalizer 项要等 下一次 major GC 才回收,期间 V8 堆线性增长(预热阶段 heapUsed 8.5 → 28.3 MB,零 GC 事件;80 000 请求时 RSS 一路爬到 164 MB)。
src/node.ts 现在把 disconnect 控制器的信号作为自有 signal 属性装进平台 Request(Object.defineProperty,不可枚举),不再传 init.signal。同一个对象、同样的中止时机与 reason——并且 ctx.req.signal === ctx.signal 现在字面成立,正是适配器文档承诺的 行为(undici 的 follow 从来没给过同一性)。已在 node 22、24、26 上 验证;契约测试 test/signal.test.ts 原样通过。
| metric (same machine, same session) | before | after |
|---|---|---|
| sampled allocations, 30 k req | 9.7 MB | 1.9 MB (−80%) |
| heapUsed after 5 k warmup | 28.3 MB | 10.9 MB |
| RSS plateau (80 k req) | 164 MB | 102 MB |
| B · steady-state RSS (median ×5) | 126.8 MB | 99.2 MB (−21.8%) |
| B drift control — hono (median ×5) | 98.7 MB | 98.6 MB |
| CPU per request (10 interleaved pairs) | 67.8 µs | 57.9 µs (median −16.0%, faster in 10/10) |
| wall rps in the same pairs | 19 271 | 21 152 (median +9.8%) |
| C · heap per route | 554 B | 554 B (unchanged) |
其余部分是契约锁定的:平台 Request 构造本身(剩余剖析的 ~25%)是 文档化的 Web Standard 表面,每请求的 AbortController(~10%)是 disconnect 协作取消契约。serve(app, { light: true }) 仍是彻底跳过 平台构造器的退出通道。