Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Node.js 读取 Error.stack 属性耗时过长导致的性能问题 #68

Open
xiaoxiaojx opened this issue Dec 15, 2023 · 0 comments
Open
Labels
Node.js Node.js® is a JavaScript runtime built on Chrome's V8 JavaScript engine.

Comments

@xiaoxiaojx
Copy link
Owner

xiaoxiaojx commented Dec 15, 2023

image

问题背景

最近其他团队交接过来一个 Node.js 服务, 在完成该服务的技术架构迁移后发现了一个奇怪的现象, 表现是代码发布上线后第一次请求该服务 100% 会超时 (延时超过 2000ms), 第二次及以后的请求恢复正常 (延时稳定在 600ms 左右)。

问题排查

Cat Transaction

首先查看 Cat 延时监控来确认是上游依赖的接口超时还是 Node.js 代码运行卡住导致的超时。插曲是第一次请求超时的记录却丢失了, 花了一些时间先排查解决了基础上报库的 bug。
image

非真实监控图, 示例图来自 cat/wiki/transaction

记录丢失问题修复后发现上游依赖的接口的第一次耗时也就 600ms 左右, 那超时的原因只能是 Node.js 的某些代码存在严重的性能问题, 卡住了 JS 的主线程 1400ms +。

Error.stack

接着对大量可疑的代码进行了埋点, 最终定位到如下仅一行代码 ❌ this.stack = new Error().stack; 运行耗时约 1600ms

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 可以忽略不计了。

Performance

定位 JS 代码性能比较好用的就是火焰图了, 我们可以使用下面的命令通过调试模式启动该服务

node --inspect you-app.js

image

随着调用堆栈的展示, Error.stack 的真相才慢慢浮出水面

Call Tree

从调用堆栈我们发现了当运行 Error.stack 这行代码时,

  1. 首先会运行 Node.js 源码中的 prepareStackTrace 函数, prepareStackTrace 函数会判断是否有全局被复写的实现, 如果有则调用
image
  1. 而该项目依赖的 source-map-support 库正好复写了 Error.prepareStackTrace 的值
image
  1. source-map-support 库的 prepareStackTrace 函数则会读取报错的文件获取到该文件的 sourceMap 的地址最后把错误堆栈反解指向原始文件 (通常 js 文件的最后一行是 sourceMap 的地址)。比如代码打包后报错文件名是 dist/app.js 则可以把报错堆栈文件名改为 src/app.ts
image
  1. 好家伙这个报错的文件有 11MB 使得文件的同步读取操作以及后续的 sourceMap 反解操作一下把耗时怼上去了, 在性能差电脑上尤其明显。注意上图代码 fileContentsCache 等缓存使得第二次及以后运行性能不受影响
    image

问题探索

最后我们再说一下运行 Error.stack 代码后为什么会先运行 Node.js 源码中的 prepareStackTrace 函数 ?

  1. Node.js 源码启动时中调用了 setPrepareStackTraceCallback 函数, 参数是 prepareStackTrace 函数
// https://github.com/nodejs/node/blob/v12.13.1/lib/internal/bootstrap/node.js#L314C3-L314C51

setPrepareStackTraceCallback(prepareStackTrace);
  1. SetPrepareStackTraceCallback 函数内部会调用 set_prepare_stack_trace_callback 函数把传入的参数 prepareStackTrace 函数保存下来
// 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>());
}
  1. Node.js 源码启动时会调用 v8 的 SetPrepareStackTraceCallback API 设置当读取 Error.stack 时的回调函数为 PrepareStackTraceCallback, PrepareStackTraceCallback 内部则会调用步骤 2 与 步骤 1 中传入的 prepareStackTrace 函数
// https://github.com/nodejs/node/blob/v12.13.1/src/api/environment.cc#L199

isolate->SetPrepareStackTraceCallback(PrepareStackTraceCallback);
image

问题结论

由于 v8 的 SetPrepareStackTraceCallback API 提供的底层能力让用户可以设置读取 Error.stack 属性时的回调, 相当于 JS Object 设置 getter 函数, 使得一些库比如 source-map-support 复写了 Error.prepareStackTrace 的值将可能导致预期外的性能问题。

更多关于 v8 Stack trace API 的内容见: Customizing stack traces

@xiaoxiaojx xiaoxiaojx added the Node.js Node.js® is a JavaScript runtime built on Chrome's V8 JavaScript engine. label Dec 15, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Node.js Node.js® is a JavaScript runtime built on Chrome's V8 JavaScript engine.
Projects
None yet
Development

No branches or pull requests

1 participant