在 Node.js 中跨异步调用保持上下文
你正在处理一个 HTTP 请求,已经深入到第三层异步调用。你需要 request ID 用于日志记录,user ID 用于数据库查询,tenant ID 用于缓存键。难道要把它们通过每一个函数签名层层传递吗?这样很快就会变得一团糟。
Node.js 内置了一个简洁的解决方案:AsyncLocalStorage。
关键要点
- 来自
node:async_hooks的AsyncLocalStorage可以在异步边界间传递上下文,而不会污染函数签名。 - 它自 Node.js 16.4.0 起已经稳定,优于
cls-hooked或直接使用底层的async_hooksAPI。 - 在请求入口处通过
run()建立一次上下文,然后在任何位置使用getStore()读取。 - 适用于 request ID、追踪数据、租户元信息和认证上下文 —— 而不是业务逻辑状态。
- 注意非原生 Promise 或遗留回调 API 可能导致上下文丢失,通常可通过
util.promisify()解决。
异步上下文传递的问题
在同步代码中,你可以用一个简单的全局栈来追踪上下文。但异步函数打破了这种模型。当 setTimeout 触发或 Promise resolve 时,原始的调用栈已经消失了。普通的全局变量会在所有并发请求之间共享 —— 这在任何真实的 API 服务器中都是一个潜伏的严重 bug。
在 AsyncLocalStorage 稳定之前,开发者会借助 cls-hooked 这类库,或者基于较底层的 async_hooks 模块手写解决方案。这两种方式都很脆弱。原生的 async_hooks API 有意设计得很底层,使用不当时会带来真实的性能开销。你不应该在应用代码中直接基于它构建。
AsyncLocalStorage 是 node:async_hooks 的一部分,是推荐的高层 API。它自 Node.js 16.4.0 起已经稳定,并且是诸如 AdonisJS 等框架内部用来管理 HTTP 上下文的方式。
AsyncLocalStorage 的工作原理
AsyncLocalStorage 的工作方式类似于其他语言中的线程本地存储 —— 不过 Node.js 是单线程的,所以”线程”被异步执行上下文取代了。任何在 run() 调用内部启动的异步操作都会自动继承该上下文,包括 setTimeout、Promise 链和 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() 建立的,它会自动跨越异步边界进行传递。
Discover how at OpenReplay.com.
应该在上下文中存储什么
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.