Open xiaoxiaojx opened 11 months ago
最近其他团队交接过来一个 Node.js 服务, 在完成该服务的技术架构迁移后发现了一个奇怪的现象, 表现是代码发布上线后第一次请求该服务 100% 会超时 (延时超过 2000ms), 第二次及以后的请求恢复正常 (延时稳定在 600ms 左右)。
首先查看 Cat 延时监控来确认是上游依赖的接口超时还是 Node.js 代码运行卡住导致的超时。插曲是第一次请求超时的记录却丢失了, 花了一些时间先排查解决了基础上报库的 bug。
非真实监控图, 示例图来自 cat/wiki/transaction
记录丢失问题修复后发现上游依赖的接口的第一次耗时也就 600ms 左右, 那超时的原因只能是 Node.js 的某些代码存在严重的性能问题, 卡住了 JS 的主线程 1400ms +。
接着对大量可疑的代码进行了埋点, 最终定位到如下仅一行代码 ❌ this.stack = new Error().stack; 运行耗时约 1600ms
this.stack = new Error().stack;
export function TestError(message = 'TestError') { this.name = 'TestError'; this.message = message; this.errorCode = 100; this.stack = new Error().stack; }
当我给出这个排查结果时大家都是不敢相信, 确实读取一个 Error 对象的 stack 属性能有这么大的能量 ?
紧接着我本地运行该 Node.js 服务来复现, 发现自己的 M1 电脑第一次运行这行代码也花费了约 1200ms, 第二次及以后基本就是 1ms 或者 0.x ms 可以忽略不计了。
定位 JS 代码性能比较好用的就是火焰图了, 我们可以使用下面的命令通过调试模式启动该服务
node --inspect you-app.js
随着调用堆栈的展示, Error.stack 的真相才慢慢浮出水面
从调用堆栈我们发现了当运行 Error.stack 这行代码时,
首先会运行 Node.js 源码中的 prepareStackTrace 函数, prepareStackTrace 函数会判断是否有全局被复写的实现, 如果有则调用
而该项目依赖的 source-map-support 库正好复写了 Error.prepareStackTrace 的值
source-map-support 库的 prepareStackTrace 函数则会读取报错的文件获取到该文件的 sourceMap 的地址最后把错误堆栈反解指向原始文件 (通常 js 文件的最后一行是 sourceMap 的地址)。比如代码打包后报错文件名是 dist/app.js 则可以把报错堆栈文件名改为 src/app.ts
好家伙这个报错的文件有 11MB 使得文件的同步读取操作以及后续的 sourceMap 反解操作一下把耗时怼上去了, 在性能差电脑上尤其明显。注意上图代码 fileContentsCache 等缓存使得第二次及以后运行性能不受影响
最后我们再说一下运行 Error.stack 代码后为什么会先运行 Node.js 源码中的 prepareStackTrace 函数 ?
// https://github.com/nodejs/node/blob/v12.13.1/lib/internal/bootstrap/node.js#L314C3-L314C51
setPrepareStackTraceCallback(prepareStackTrace);
2. SetPrepareStackTraceCallback 函数内部会调用 set_prepare_stack_trace_callback 函数把传入的参数 prepareStackTrace 函数保存下来 ```js // https://github.com/nodejs/node/blob/v12.13.1/src/node_errors.cc#L819 void SetPrepareStackTraceCallback(const FunctionCallbackInfo<Value>& args) { Environment* env = Environment::GetCurrent(args); CHECK(args[0]->IsFunction()); env->set_prepare_stack_trace_callback(args[0].As<Function>()); }
// https://github.com/nodejs/node/blob/v12.13.1/src/api/environment.cc#L199
isolate->SetPrepareStackTraceCallback(PrepareStackTraceCallback);
<img width="945" alt="image" src="https://github.com/xiaoxiaojx/blog/assets/23253540/efd1f17f-d55f-435c-866b-3a42c6c10d6f"> ## 问题结论 由于 v8 的 SetPrepareStackTraceCallback API 提供的底层能力让用户可以设置读取 Error.stack 属性时的回调, 相当于 JS Object 设置 getter 函数, 使得一些库比如 source-map-support 复写了 Error.prepareStackTrace 的值将可能导致预期外的性能问题。 更多关于 v8 Stack trace API 的内容见: [Customizing stack traces](https://v8.dev/docs/stack-trace-api#customizing-stack-traces)
问题背景
最近其他团队交接过来一个 Node.js 服务, 在完成该服务的技术架构迁移后发现了一个奇怪的现象, 表现是代码发布上线后第一次请求该服务 100% 会超时 (延时超过 2000ms), 第二次及以后的请求恢复正常 (延时稳定在 600ms 左右)。
问题排查
Cat Transaction
首先查看 Cat 延时监控来确认是上游依赖的接口超时还是 Node.js 代码运行卡住导致的超时。插曲是第一次请求超时的记录却丢失了, 花了一些时间先排查解决了基础上报库的 bug。
记录丢失问题修复后发现上游依赖的接口的第一次耗时也就 600ms 左右, 那超时的原因只能是 Node.js 的某些代码存在严重的性能问题, 卡住了 JS 的主线程 1400ms +。
Error.stack
接着对大量可疑的代码进行了埋点, 最终定位到如下仅一行代码 ❌
this.stack = new Error().stack;
运行耗时约 1600ms当我给出这个排查结果时大家都是不敢相信, 确实读取一个 Error 对象的 stack 属性能有这么大的能量 ?
紧接着我本地运行该 Node.js 服务来复现, 发现自己的 M1 电脑第一次运行这行代码也花费了约 1200ms, 第二次及以后基本就是 1ms 或者 0.x ms 可以忽略不计了。
Performance
定位 JS 代码性能比较好用的就是火焰图了, 我们可以使用下面的命令通过调试模式启动该服务
随着调用堆栈的展示, Error.stack 的真相才慢慢浮出水面
Call Tree
从调用堆栈我们发现了当运行 Error.stack 这行代码时,
首先会运行 Node.js 源码中的 prepareStackTrace 函数, prepareStackTrace 函数会判断是否有全局被复写的实现, 如果有则调用
而该项目依赖的 source-map-support 库正好复写了 Error.prepareStackTrace 的值
source-map-support 库的 prepareStackTrace 函数则会读取报错的文件获取到该文件的 sourceMap 的地址最后把错误堆栈反解指向原始文件 (通常 js 文件的最后一行是 sourceMap 的地址)。比如代码打包后报错文件名是 dist/app.js 则可以把报错堆栈文件名改为 src/app.ts
好家伙这个报错的文件有 11MB 使得文件的同步读取操作以及后续的 sourceMap 反解操作一下把耗时怼上去了, 在性能差电脑上尤其明显。注意上图代码 fileContentsCache 等缓存使得第二次及以后运行性能不受影响
问题探索
最后我们再说一下运行 Error.stack 代码后为什么会先运行 Node.js 源码中的 prepareStackTrace 函数 ?
setPrepareStackTraceCallback(prepareStackTrace);
isolate->SetPrepareStackTraceCallback(PrepareStackTraceCallback);