Back

在 Node.js 中跨异步调用保持上下文

在 Node.js 中跨异步调用保持上下文

你正在处理一个 HTTP 请求,已经深入到第三层异步调用。你需要 request ID 用于日志记录,user ID 用于数据库查询,tenant ID 用于缓存键。难道要把它们通过每一个函数签名层层传递吗?这样很快就会变得一团糟。

Node.js 内置了一个简洁的解决方案:AsyncLocalStorage

关键要点

  • 来自 node:async_hooksAsyncLocalStorage 可以在异步边界间传递上下文,而不会污染函数签名。
  • 它自 Node.js 16.4.0 起已经稳定,优于 cls-hooked 或直接使用底层的 async_hooks API。
  • 在请求入口处通过 run() 建立一次上下文,然后在任何位置使用 getStore() 读取。
  • 适用于 request ID、追踪数据、租户元信息和认证上下文 —— 而不是业务逻辑状态。
  • 注意非原生 Promise 或遗留回调 API 可能导致上下文丢失,通常可通过 util.promisify() 解决。

异步上下文传递的问题

在同步代码中,你可以用一个简单的全局栈来追踪上下文。但异步函数打破了这种模型。当 setTimeout 触发或 Promise resolve 时,原始的调用栈已经消失了。普通的全局变量会在所有并发请求之间共享 —— 这在任何真实的 API 服务器中都是一个潜伏的严重 bug。

AsyncLocalStorage 稳定之前,开发者会借助 cls-hooked 这类库,或者基于较底层的 async_hooks 模块手写解决方案。这两种方式都很脆弱。原生的 async_hooks API 有意设计得很底层,使用不当时会带来真实的性能开销。你不应该在应用代码中直接基于它构建。

AsyncLocalStoragenode:async_hooks 的一部分,是推荐的高层 API。它自 Node.js 16.4.0 起已经稳定,并且是诸如 AdonisJS 等框架内部用来管理 HTTP 上下文的方式。

AsyncLocalStorage 的工作原理

AsyncLocalStorage 的工作方式类似于其他语言中的线程本地存储 —— 不过 Node.js 是单线程的,所以”线程”被异步执行上下文取代了。任何在 run() 调用内部启动的异步操作都会自动继承该上下文,包括 setTimeoutPromise 链和 await 调用。

import { AsyncLocalStorage } from 'node:async_hooks';

const requestContext = new AsyncLocalStorage();

你创建一个实例(通常作为模块级单例),然后使用 run() 在每个请求的入口处建立上下文。

一个贴近实战的请求级日志示例

下面是一个极简的 Express 中间件,它会为每一行日志附加 request ID —— 而无需通过函数参数传递任何东西:

import express from 'express';
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';

const requestContext = new AsyncLocalStorage();

// Middleware: establish context for each request
function contextMiddleware(req, res, next) {
  const store = { requestId: randomUUID(), userId: req.headers['x-user-id'] };
  requestContext.run(store, next);
}

// Logger: reads context without any arguments
function log(message) {
  const ctx = requestContext.getStore();
  const prefix = ctx ? `[${ctx.requestId}]` : '[no-context]';
  console.log(`${prefix} ${message}`);
}

// Simulated async database query
async function someDbQuery() {
  return new Promise((resolve) => setTimeout(resolve, 50));
}

// Route handler: calls async functions freely
async function fetchUserData() {
  log('Fetching user data');         // ✅ has request ID
  await someDbQuery();
  log('Fetched user data');          // ✅ still has request ID
}

const app = express();
app.use(contextMiddleware);

app.get('/user', async (req, res) => {
  log('Request received');
  await fetchUserData();
  res.json({ ok: true });
});

app.listen(3000);

关键点在于:fetchUserData 从未接收 request ID 作为参数。由于上下文是通过 run() 建立的,它会自动跨越异步边界进行传递。

应该在上下文中存储什么

AsyncLocalStorage 非常适合处理那些请求级、但不属于业务逻辑本身的横切关注点(cross-cutting concerns):

  • Request ID 用于分布式追踪和日志关联
  • 已认证的用户或租户元信息,用于多租户应用
  • 追踪上下文,用于 OpenTelemetry 等工具
  • 特性开关,在请求时解析

避免存储大型对象或频繁变化的内容。保持 store 小巧,并将其视为初始化后基本只读的数据。

一个常见陷阱:上下文丢失

在使用非原生 Promise 实现或某些较旧的基于回调的 API 时,上下文可能会丢失。如果 getStore() 在你预期不该返回 undefined 的地方返回了 undefined,请检查该异步操作是否是在 run() 调用内部启动的。用 util.promisify() 包装基于回调的代码通常能解决问题,不过某些自定义异步资源可能需要使用 AsyncResource

结论

AsyncLocalStorage 优雅地解决了一个真实存在的问题。你不再需要把请求元数据沿着每个函数调用层层传递,而是在请求边界处建立一次上下文,然后在任何需要的地方读取它。对于任何 Node.js API 或 SSR 应用中的请求级日志、追踪和认证上下文而言,它都是合适的工具。

常见问题

存在少量开销,因为 Node.js 必须追踪异步资源以传递 store,但对于典型的 Web 工作负载而言,这一成本可以忽略不计。近几个 Node.js 版本中性能已有显著提升,与手动通过每个函数传递上下文相比,这种权衡通常是值得的。

可以。你可以为不同的关注点创建独立的实例,例如日志上下文、追踪和租户数据。每个实例维护自己独立的 store,彼此之间不会互相干扰。只需将每个实例作为模块级单例,以确保整个代码中使用的是同一个引用。

每个 worker 线程都有自己独立隔离的 AsyncLocalStorage 状态,因此上下文不会跨越线程边界。如果你需要将请求上下文共享给一个 worker,请通过 worker 的消息通道显式传递相关数据,并在 worker 内部通过另一次 run() 调用重新建立 store。

显式传递更可预测、更易测试,但会让函数签名变得繁杂,并污染那些其实并不需要这些数据的中间层。AsyncLocalStorage 最适合处理日志和追踪等横切关注点,而业务关键数据仍应通过参数流转,以保持代码清晰可测。

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay