首页 / 文章

利用AsyncLocalStorage实现异步调用间日志的关联

了解 Node.js AsyncLocalStorage 如何在无需手动将上下文传递到每个函数的情况下,跨越 await 语句边界追踪如 requestId 这样的请求级上下文。

发布者:REACTAPP.TOP阅读约 6 分钟1412 词

来源: The Node.js Feature That Replaces Passing `requestId` Through Every Single Function

每位后端开发人员最终都会遇到这样的场景:当有请求进入时,系统会生成一个requestId;在六层嵌套的结构中,某个数据库辅助函数或共享的日志记录函数需要这个标识符,以便将日志条目与触发它的请求关联起来。显而易见的解决方案是将该ID作为参数传递给链中的每个函数。虽然这种方法可行,但维护起来十分麻烦,因为现在每个中间函数都要携带一个除了传递给下一个调用之外毫无用处的参数。

简单的解决方法及其失败原因

人们很容易想到使用模块级的变量,但这种做法是错误的,其问题要等到实际生产环境中才会显现:

let currentRequestId = null;

app.use((req, res, next) => {
  currentRequestId = req.headers["x-request-id"];
  next();
});

function logSomething(message) {
  console.log(`[${currentRequestId}] ${message}`); // works... until it doesn't
}

如果认为一次只处理一个请求,这种做法似乎合理,但一旦考虑到 Node 实际处理并发的方式,就会发现问题。Node 并非严格按顺序执行请求,而是通过在 await 语句处交错执行这些请求来“同时”处理多个请求。请求 A 先写入 currentRequestId,随后遇到 await 语句,控制权便交还给事件循环,而事件循环可能会立即开始处理请求 B,并用 B 的 ID 覆盖该变量。当请求 A 的代码继续执行时,currentRequestId 已指向 B,从那时起,请求 A 中的所有日志语句都会错误地使用错误的 ID。

AsyncLocalStorage:跟随执行流程而非调用栈的上下文

AsyncLocalStorage作为Node内置的async_hooks模块的一部分提供,正是为了解决这个问题而设计的。它会为每条异步执行路径保存独立的值,即便请求在await调用、承诺链和回调函数之间交错进行,它仍能正确地处理这些数据。

const { AsyncLocalStorage } = require("async_hooks");
const requestContext = new AsyncLocalStorage();

app.use((req, res, next) => {
  const store = { requestId: req.headers["x-request-id"] || crypto.randomUUID() };
  requestContext.run(store, next); // everything inside next() runs "inside" this context
});

function logSomething(message) {
  const store = requestContext.getStore();
  console.log(`[${store?.requestId}] ${message}`); // correct, regardless of interleaving
}

requestContext.run(store, next) 的作用不仅仅是赋值,它还会创建一个上下文,该上下文会自动贯穿从该调用内部调用的所有操作,无论执行是同步的还是异步的,包括在 await 之后继续执行的代码、在 .then() 处理程序中运行的代码,或是作为某些 I/O 操作的回调在稍后触发的代码。即便是在完全独立的文件中定义的数据库查询辅助函数内部,调用 requestContext.getStore() 也会返回属于当前请求的上下文,而绝不会是来自上一个执行的请求的上下文。这是因为该上下文是与发起调用的特定异步执行链绑定的,而非与每个请求都会写入的某个共享变量绑定。

为何这能在 await 的边界之间正常工作

这正是让 AsyncLocalStorage 有别于普通变量的特性,它直接关系到 async/await 在底层的工作机制。每当一个 async 函数在 await 处暂停后再继续执行时,Node 会记录下此次恢复与哪个异步上下文相关联,并在继续执行之前自动恢复该上下文:

app.get("/orders/:id", async (req, res) => {
  requestContext.run({ requestId: req.headers["x-request-id"] }, async () => {
    logSomething("fetching order"); // context is present
    const order = await db.getOrder(req.params.id); // pauses, other requests may run here
    logSomething("order fetched"); // context is STILL correctly present after resuming
    res.json(order);
  });
});

在两次 logSomething 调用之间,事件循环完全可以自由地运行其他请求的代码,但这种交错执行并不会影响该继续执行的代码所使用的上下文。这正是单纯的共享变量无法提供的保证:在 Node 并发模型所倡导的这种交错执行环境下依然能够保持正确性。

真正的优势:无需到处添加线程参数的结构化日志记录

这正是AsyncLocalStorage带来的额外复杂性在真实应用中发挥作用的地方:你可以构建一个日志记录器,它能够自动将请求的上下文附加到每次调用中,而无需任何调用代码额外传递参数:

function createLogger() {
  return {
    info(message, extra = {}) {
      const store = requestContext.getStore();
      logger.info({ requestId: store?.requestId, ...extra }, message);
    },
  };
}

// deep inside some completely unrelated file, five layers into the call stack
const log = createLogger();
log.info("Payment processed", { amount: 4999 }); // requestId is present, automatically

为了实现这一功能,processPayment的函数签名无需做任何更改。它既不接受requestId参数,也不会将其传递给其他函数,但它输出的每一条日志都会正确标注出生成该日志的请求。这才是真正的优势:请求范围内的数据可以在调用链中的任何位置使用,而无需在从请求开始到数据实际被使用的所有函数签名之间添加冗余参数。

让人困惑的边界

.run()内部创建的作用域仅覆盖实际属于同一执行链的代码:即通过回调直接或间接调用的所有内容,包括通过待处理的承诺链调用的部分。它不会回溯到在调用.run()之前已执行的代码,也不会延伸到真正脱离该执行链的其他操作:

app.use((req, res, next) => {
  requestContext.run({ requestId: req.headers["x-request-id"] }, () => {
    setTimeout(() => {
      logSomething("delayed log"); // still correct — setTimeout inside the context stays tracked
    }, 1000);
    next();
  });
});

在当前上下文内部调用setTimeout仍能按预期工作,因为 Node 的async_hooks传播机制涵盖了定时器、承诺以及几乎所有常见的标准异步原语。真正会出现问题的情况是那些被刻意设计为比触发它们的请求更长期存在的“抛之不管”的任务。被推送到独立的工作者进程中的任务就是一个典型例子:由于它并非同一异步调用序列的延续,因此不存在将其与原始上下文关联起来的执行链。这是一个完全独立、互不相关的任务。

关于性能的实话实说

AsyncLocalStorage并非免费功能。在每次异步操作之间捕获并恢复上下文需要耗费大量精力,而早期版本的Node处理此功能的效率远低于如今的运行时环境。在当前版本的Node中,对于典型的Web应用程序而言,其带来的开销足够小,对绝大多数项目来说并无影响,而且它在保证代码正确性及简洁性方面的优势几乎总能抵消这一微小成本。如果你的应用处于极高规模运行状态,每毫秒都至关重要,那么就有必要针对具体场景进行性能测试。但对于几乎所有的后端服务而言,将其作为安全默认选项是合理的。这并非那种偶尔才使用的特殊优化技巧,而是一种具有已知小成本的工具,在几乎所有正常情况下都能带来收益。

其背后的真正理念

AsyncLocalStorage 不应被视为一种行为较为“友善”的全局变量。从结构上讲,它绑定于特定的异步执行流程:在该流程中的每一个 await、回调函数以及承诺解析之后它依然存在,而对同时运行的其他流程则完全不可见。一旦理解了这一机制,它的行为就不再显得像某种技巧,而会显现为完成特定任务的理想工具——即让调用链中的任何位置都能访问请求相关的数据,同时无需让链上的每个函数都知晓或关心数据正在被传递经过它们。