异步堆栈跟踪:`Error.stack` 为何欺骗你
微任务队列吃掉了我的作业(以及我的调试上下文)
凌晨2点。PagerDuty 警报正在狂响。
你打开日志,看到这个:
Error: Cannot read properties of undefined (reading 'id') at processTicksAndRejections (node:internal/process/task_queues:96:5)就这些。没有函数名。没有行号。没有文件路径。只有 “processTicksAndRejections”。
欢迎来到异步 JavaScript——这里的堆栈跟踪是编造的,行号毫无意义。
为什么堆栈跟踪会失效
在同步代码中,调用栈是一份漂亮的族谱。A 调用了 B,B 调用了 C。当 C 崩溃时,你能清楚地看到你是如何走到这一步的。
在异步代码(async/await)中,每个 await 关键字都是一个挂起点。
当你 await 时,你的函数被从栈上剥离。它被扔进一个名为“微任务队列”的低温冷冻箱。此时栈是空的(或者在干别的事)。
当 Promise 被 resolve 后,你的函数被解冻并重新扔回栈上。但历史已经消失了。
引擎不知道 500 毫秒前是谁调用了 await。它只知道有一个任务要执行。
V8 的尝试
Node.js 试图帮忙。我们有:
Error.captureStackTrace():在创建时捕获栈。如果错误稍后才抛出,那就毫无用处。--async-stack-traces:一个标志,让 Node.js 维护一个 Promise 链的“影子栈”。- 代价:让你的应用慢 30%。
- 结果:有帮助,但很快就会变得嘈杂。
真正的解决方案:AsyncLocalStorage
如果你想在生产环境中存活下来,就别再看堆栈跟踪了。要看因果关系。
我们需要将上下文(用户ID、请求ID)附加到执行的“线程”上,即使它在栈和微任务队列之间跳跃。
Node.js 有一个内置工具可以做到这一点:AsyncLocalStorage。
import { AsyncLocalStorage } from 'async_hooks';
const context = new AsyncLocalStorage();
// 1. 包裹请求context.run({ requestId: '123' }, () => { // 2. 调用深层异步代码 await processOrder();});
// 3. 在 processOrder 深处:async function processOrder() { await db.query();
// 神奇!我们仍然能看到 requestId const { requestId } = context.getStore(); console.log(`[${requestId}] 处理订单失败`);}无论中间有多少个 await,上下文都会存活下来。
生产环境操作手册
- 别再相信
err.stack。它天生就是不完整的。 - 使用结构化日志。通过
AsyncLocalStorage将requestId附加到每一行日志上。 - 追踪,而不是堆栈。使用 OpenTelemetry。它能可视化跨服务的因果链,这才是你真正关心的。
你的代码是异步的。你的调试上下文不应该也是。