12k
All articles

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

使用 AsyncLocalStorage 在 Node.js 异步调用中保持请求 ID、用户 ID 和租户数据。了解 run() 与 getStore() 的用法。

OpenReplay Team
OpenReplay Team
在 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 应用中的请求级日志、追踪和认证上下文而言,它都是合适的工具。

常见问题

AsyncLocalStorage 有显著的性能开销吗?

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

可以在一个应用中使用多个 AsyncLocalStorage 实例吗?

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

AsyncLocalStorage 在 worker 线程中使用安全吗?

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

AsyncLocalStorage 与显式传递上下文相比如何?

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

DevTools for the frontend

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.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.