evlog 每个请求增加 约 3µs 的开销,也就是 0.003ms,远低于任何 HTTP 框架或数据库调用的量级。性能在每次拉取时都会通过 CodSpeed 进行跟踪。
evlog 与其他方案对比
所有基准测试均使用 JSON 输出到无操作目标。pino 写入 /dev/null(同步),winston 写入无操作流,consola 使用无操作报告器,evlog 使用静默模式。
结果
{
user: { id, plan },
cart: { items, total },
payment: { method },
status: 200
}| Scenario | evlog | pino | consola | winston |
|---|---|---|---|---|
| Simple string log | 1.83M ops/s | 1.09M | 2.79M | 1.20M |
| Structured (5 fields) | 1.64M ops/s | 716.1K | 1.71M | 431.6K |
| Deep nested log | 1.55M ops/s | 464.9K | 1.01M | 164.0K |
| Child / scoped logger | 1.70M ops/s | 845.0K | 280.4K | 430.0K |
| Wide event lifecycle | 1.58M ops/s | 205.8K | — | 111.9K |
| Burst (100 logs) | 17.8K ops/s | 10.3K | 39.4K | 7.5K |
| Logger creation | 16.85M ops/s | 7.50M | 310.3K | 5.38M |
evlog 在 7 次一对一比较中赢了 4 次,而且最重要的胜利都很 निर्ण然:在广泛事件模式下比 pino 快 7.7 倍,日志创建 快 2.3 倍,深层嵌套日志 快 3.3 倍。consola 在简单字符串和突发场景中略胜一筹(它使用不做序列化的无操作报告器),但 evlog 为每个请求生成一条相关联事件,而传统日志记录器会发出 N 条独立日志行。
什么是“广泛事件生命周期”?
该基准测试模拟了一个真实的 API 请求:
const log = createLogger({ method: 'POST', path: '/api/checkout', requestId: 'req_abc' })
log.set({ user: { id: 'usr_123', plan: 'pro' } })
log.set({ cart: { items: 3, total: 9999 } })
log.set({ payment: { method: 'card', last4: '4242' } })
log.emit({ status: 200 })
const child = pinoLogger.child({ method: 'POST', path: '/api/checkout', requestId: 'req_abc' })
child.info({ user: { id: 'usr_123', plan: 'pro' } }, 'user context')
child.info({ cart: { items: 3, total: 9999 } }, 'cart context')
child.info({ payment: { method: 'card', last4: '4242' } }, 'payment context')
child.info({ status: 200 }, 'request complete')
相同的 CPU 开销,但 evlog 将所有内容集中在一个位置。
为什么 evlog 更快?
上述数字并非魔法,源于深思熟虑的架构选择:
原地修改,而非复制。 log.set() 通过递归的 mergeInto 函数直接写入上下文对象。其他日志记录器在每次调用时克隆对象(使用对象展开或 Object.assign)。evlog 在上下文累积过程中从不分配中间对象。
直到输出阶段才序列化。 上下文在整个请求生命周期中保持为普通 JavaScript 对象。JSON.stringify 仅在输出时运行一次。传统日志记录器在每次 .info() 调用时都进行序列化,4 条日志行意味着 4 次序列化。
惰性分配。 时间戳、采样上下文和覆盖对象仅在真正需要时创建。如果尾采样被禁用(常见情况),其上下文对象永远不会被分配。用于 ISO 时间戳的 Date 实例在多次调用间复用。
一条事件,而非 N 行。 对于典型请求,pino 会发出 4 条以上的 JSON 行,每条都需要序列化、传输和索引。evlog 只发出一条。这样为日志接收器减少 75% 的工作量,减少传输中的字节数,并且查询时只需处理一行而非四行。
正则表达式缓存。 全局模式(用于采样和路由匹配)只编译一次并缓存。重复评估命中缓存而非重新编译。
当 evlog 可能不会获胜时
上述基准测试衡量的是主线程上的 CPU + 序列化成本,没有真实 I/O。这是 pino、winston 和 logtape 在各自基准测试中采用的标准设置——但它遗漏了少数一些其他日志记录器可能略胜一筹的场景。对此应当诚实:
使用 worker-thread 的 pino 快速发送路径。 在生产环境中,pino 通常会配置 worker-thread transport(pino-pretty、pino-loki、厂商特定 transport)。序列化和 I/O 会完全转移到主线程之外。对于每秒发出数十万条 log.info('foo') 且不累积上下文的工作负载,pino-via-worker 在主线程上可达到约 2-3M ops/s,因为它只是排队。我们无法在单线程 vitest 进程中公平地对该模式进行基准测试,因此它不在表中——但这确实是 pino 更快的一个真实场景。
仅 CLI / 仅美化输出且不序列化。 我们基准测试中的 consola 无操作报告器模式(level: 4, reporters: [{ log: () => {} }])完全跳过了 JSON 序列化。如果你使用 consola 做仅终端输出的 CLI,这很现实,但这也是 consola 在“简单字符串”和“突发”场景中获胜的原因——它做的工作并不相同。evlog 和 pino 都会序列化为 JSON;而这些基准中的 consola 不会。如果你的使用场景是“美观的终端输出,不向任何地方发送日志”,consola 确实更轻量。
单次 log.info 调用,不累积上下文。 在 pino.info('hello') 与 evlog.info('hello') 的比较中,evlog 和 pino 大致打平(在我们的运行中分别是 1.83M vs 1.09M ops/s,但如果 pino 运行在异步模式下,差距会进一步缩小)。evlog 的约 7.7 倍优势具体体现在你本来会为一次逻辑操作发出 N 条独立日志行的场景。如果你确实是每次调用只记一行,而且不累积上下文,那么速度差距要小得多——选择 evlog 应该是因为 API 体验(log.set + 结构化错误),而不是原始吞吐量。
壁钟时间波动是真实存在的。 同一台机器上,不同运行之间,Vitest bench 数值会有 ±5-10% 的波动(热降频、GC、其他进程)。上述数字来自 MacBook 上的一次运行;CI 通过 CodSpeed 的 CPU 指令计数进行回归跟踪(确定性、±0.5% 噪声地板),但本页中的绝对 hz 值是壁钟快照,而不是有保证的下限。
结论是:对于广泛事件模式,这些胜利是真实的,但如果你的栈是“纯粹使用 worker transport 的 pino 快速发送”,那就是我们不声称能胜过的唯一场景。
真实世界开销
对于典型 API 请求:
| 组件 | 开销 |
|---|---|
| Logger creation | 52ns |
3x set() calls | 105ns |
emit() | 588ns |
| Sampling | 22ns |
| Enricher pipeline | 2.14µs |
| Total | ~2.9µs |
作为参考,数据库查询需 1-50ms,HTTP 调用需 10-500ms。evlog 的开销几乎不可察觉。
打包体积
每个入口点都支持 tree-shaking。你只为你导入的部分付费。
| 入口 | Gzip 体积 |
|---|---|
core (evlog) | 510 B |
toolkit (evlog/toolkit) | 720 B |
| utils | 1.58 kB |
| error | 1.46 kB |
| enrichers | 1.99 kB |
| pipeline | 1.35 kB |
| http | 1.22 kB |
| browser | 289 B |
| workers | 1.30 kB |
| client | 128 B |
典型的 Node.js 包(initLogger + createLogger)在 tree-shaking 之后端到端大小约为 ~6.3 kB gzip;再加入 createRequestLogger、createError、parseError 和 useLogger 后,包体积会达到 ~7.2 kB gzip。适配器和框架集成位于其上:Hono 为 617 B,Express 为 734 B,Axiom 为 1.48 kB。每个 PR 都会跟踪包体积,并与 main 基线进行比较。
详细基准测试
日志创建
| 操作 | ops/sec | 平均值 |
|---|---|---|
createLogger() (no context) | 19.20M | 52ns |
createLogger() (shallow context) | 18.74M | 53ns |
createLogger() (nested context) | 17.70M | 56ns |
createRequestLogger() (method + path) | 16.91M | 59ns |
createRequestLogger() (method + path + requestId) | 12.67M | 79ns |
上下文累积(log.set())
| 操作 | ops/sec | 平均值 |
|---|---|---|
| Shallow merge (3 fields) | 9.56M | 105ns |
| Shallow merge (10 fields) | 4.79M | 209ns |
| Deep nested merge | 8.04M | 124ns |
| 4 sequential calls | 7.05M | 142ns |
事件发射(log.emit())
| 操作 | ops/sec | 平均值 |
|---|---|---|
| Emit minimal event | 1.93M | 519ns |
| Emit with context | 1.70M | 588ns |
| Full lifecycle (create + 3 sets + emit) | 1.59M | 628ns |
| Emit with error | 65.9K | 15.17µs |
发射带错误 更慢,因为 Error.captureStackTrace() 是昂贵的 V8 操作(约 15µs)。仅当抛出错误时触发。负载扩展
| 负载 | ops/sec | 平均值 |
|---|---|---|
| Small (2 fields) | 1.72M | 581ns |
| Medium (50 fields) | 569.8K | 1.76µs |
| Large (200 nested fields) | 131.2K | 7.62µs |
采样
| 操作 | ops/sec | 平均值 |
|---|---|---|
| Tail sampling (shouldKeep) | 44.97M | 22ns |
| Full emit with head + tail | 7.01M | 143ns |
增强器
| 增强器 | ops/sec | 平均值 |
|---|---|---|
| User Agent (Chrome) | 2.61M | 384ns |
| Geo (Vercel) | 3.88M | 258ns |
| Request Size | 12.37M | 81ns |
| Trace Context | 4.35M | 230ns |
| All combined (all headers) | 466.7K | 2.14µs |
错误处理
| 操作 | ops/sec | 平均值 |
|---|---|---|
createError() | 232.2K | 4.31µs |
parseError() | 45.48M | 22ns |
| Round-trip (create + parse) | 231.4K | 4.32µs |
Middleware pipeline
| Operation | ops/sec | Mean |
|---|---|---|
resolveMiddlewarePluginRunner (no plugins) | 37.70M | 27ns |
resolveMiddlewarePluginRunner (2 plugins, cached) | 32.26M | 31ns |
createMiddlewareLogger (no plugins, safe headers) | 4.41M | 227ns |
createMiddlewareLogger (2 plugins, cached merge) | 4.13M | 242ns |
| Full request lifecycle (no plugins, no drain) | 993.7K | 1.01µs |
| Full request lifecycle (2 plugins, sync drain) | 621.2K | 1.61µs |
方法论与可信度
你能相信这些数字吗?
本页面上的每个基准测试都是开源的且可复现的。基准测试文件位于 packages/evlog/bench/。你可以阅读确切代码、在本机运行并验证结果。
所有库在相同条件下测试:
- 相同输出模式:JSON 输出到无操作目标(不测量磁盘或网络 I/O)
- 相同预热:每个基准测试在 JIT 稳定后运行 500ms
- 相同工具:Vitest bench,由 tinybench 提供支持
- 相同机器:比较库时,所有基准测试在同一进程的同一硬件上运行
CI 回归跟踪
性能回归在每次拉取请求时通过两个系统跟踪:
- CodSpeed 使用 CPU 指令计数运行所有基准测试(而非壁钟时间)。这消除了共享 CI 运行器中的噪声,并产生确定性、可复现的结果。回归直接在 PR 上标记。
- 打包大小比较 测量所有入口点与
main基线的对比,并在 PR 评论中发布大小差异报告。
自行运行
cd packages/evlog
pnpm run bench # 所有基准测试
pnpm exec vitest bench bench/comparison/ # 仅与替代方案对比
pnpm exec tsx bench/scripts/size.ts # 包体积