在后端开发中,我们经常会遇到这样一个需求:
一次 HTTP 请求进来之后,我们希望在整个调用链路中都能拿到一些上下文信息:
HTTP Request
↓
Controller
↓
Service
↓
Repository
↓
Redis
↓
Kafka
↓
日志
比如:
requestId
userId
tenantId
traceId
clientIp
最简单的办法可能是:
service(requestId);
然后:
service(requestId, userId);
继续:
repository(requestId, userId, tenantId);
很快整个项目就会变成:
foo(a, b, c, d, e, requestId);
参数污染越来越严重。
而 Node.js 提供了一个专门解决这类问题的能力:
AsyncLocalStorage
它可以让我们在一个异步调用链中保存上下文,并且在后续的异步代码中随时获取。
本文就从:
是什么
↓
为什么需要
↓
原理
↓
基本使用
↓
HTTP 请求上下文
↓
日志系统
↓
NestJS 实战
↓
常见坑
几个方面理解 AsyncLocalStorage。
一、AsyncLocalStorage 是什么?
AsyncLocalStorage 是 Node.js 提供的异步上下文存储 API。
它位于:
node:async_hooks
使用:
import { AsyncLocalStorage } from 'node:async_hooks';
它最核心的能力其实非常简单:
在一个异步执行上下文中保存数据,并让这个上下文中的后续异步操作能够访问到这些数据。
例如:
const storage = new AsyncLocalStorage();
storage.run(
{
requestId: 'req-123',
},
() => {
console.log(storage.getStore());
},
);
输出:
{
requestId: 'req-123'
}
但是它真正有价值的地方在于:
storage.run(
{
requestId: 'req-123',
},
async () => {
await foo();
console.log(storage.getStore());
},
);
即使中间经历了:
await
Promise
setTimeout
数据库查询
HTTP 请求
只要这些异步操作仍然处于这个异步上下文链路中,就可以继续拿到:
requestId = req-123
二、为什么需要 AsyncLocalStorage?
先看一个非常常见的场景。
假设我们的系统收到两个请求:
请求 A
requestId = aaa
请求 B
requestId = bbb
它们同时执行:
Request A
↓
Controller
↓
Service
↓
Database
Request B
↓
Controller
↓
Service
↓
Database
我们希望日志能够自动打印:
[aaa] 查询用户
[aaa] 查询订单
[bbb] 查询用户
[bbb] 查询订单
如果没有 AsyncLocalStorage,我们可能会这样写:
async function getUser(requestId: string) {
console.log(requestId);
return repository.findUser(requestId);
}
继续传:
async function getOrder(
requestId: string,
userId: string,
) {
console.log(requestId);
return repository.findOrder(
requestId,
userId,
);
}
然后:
service(
requestId,
userId,
tenantId,
);
问题就出现了。
requestId 明明只是日志上下文,却污染了大量业务方法。
三、AsyncLocalStorage 可以解决什么?
有了 AsyncLocalStorage:
async function getUser() {
const context = storage.getStore();
console.log(context.requestId);
return repository.findUser();
}
Service 不需要:
getUser(requestId);
而是:
getUser();
整个调用链可以变成:
HTTP Request
│
▼
AsyncLocalStorage
│
├── requestId
├── userId
├── tenantId
└── traceId
│
▼
Controller
│
▼
Service
│
▼
Repository
│
▼
Logger
上下文不需要作为业务参数层层传递。
四、AsyncLocalStorage 的核心 API
实际上 AsyncLocalStorage 的核心 API 并不多。
最重要的就是:
run()
getStore()
enterWith()
exit()
现代 Node.js 项目中最常用的是:
run()
getStore()
1. 创建 AsyncLocalStorage
import { AsyncLocalStorage } from 'node:async_hooks';
const storage = new AsyncLocalStorage();
通常整个应用只需要创建一个实例。
例如:
export interface RequestContext {
requestId: string;
userId?: string;
tenantId?: string;
}
export const asyncLocalStorage =
new AsyncLocalStorage<RequestContext>();
五、run():创建一个异步上下文
最重要的方法:
storage.run(store, callback);
例如:
storage.run(
{
requestId: 'req-123',
},
() => {
console.log(
storage.getStore(),
);
},
);
这里:
{
requestId: 'req-123'
}
就是当前上下文。
可以理解成:
run()
│
└── 创建上下文
│
├── callback
│
├── Promise
│
├── await
│
├── setTimeout
│
└── 其他异步操作
只要异步操作从这个上下文中创建,就可以继续继承这个上下文。
六、getStore():获取当前上下文
在异步调用链的任意位置:
const context = storage.getStore();
例如:
async function service() {
const context = storage.getStore();
console.log(
context?.requestId,
);
}
如果当前代码运行在:
storage.run(...)
创建的上下文中:
context
就可以拿到对应的数据。
如果没有上下文:
storage.getStore();
通常会得到:
undefined
所以实际代码最好:
const context =
storage.getStore();
if (!context) {
// 当前没有上下文
}
七、AsyncLocalStorage 为什么能跨越 await?
这是理解 AsyncLocalStorage 最关键的一部分。
很多人第一次使用它的时候都会产生一个疑问:
storage.run(
{ requestId: '123' },
async () => {
await something();
console.log(
storage.getStore(),
);
},
);
为什么经过:
await
以后上下文还在?
因为 Node.js 的异步模型并不是简单的:
函数 A
↓
函数 B
↓
函数 C
而是存在一套异步资源关系。
例如:
setTimeout(() => {
console.log('hello');
}, 1000);
Node.js 会创建对应的异步资源。
类似:
HTTP Request
│
▼
Async Resource A
│
├── Promise
│
├── Timer
│
└── I/O
这些异步资源之间存在上下文关联。
AsyncLocalStorage 利用 Node.js 的异步资源上下文机制,把:
当前异步资源
↓
创建出来的下一个异步资源
联系起来。
于是:
Request A
│
▼
Promise A
│
▼
Timer A
│
▼
DB Query A
这一整条异步链路可以继续找到:
requestId = aaa
八、可以把它理解成“异步调用链上的 ThreadLocal”
如果你以前接触过 Java,那么可以把 AsyncLocalStorage 类比成:
Java
ThreadLocal
Node.js
AsyncLocalStorage
但是一定要注意:
它们的底层模型并不完全一样。
Java:
Thread
↓
ThreadLocal
Node.js:
Async Execution Context
↓
AsyncLocalStorage
因为 Node.js 是事件循环 + 异步 I/O 模型。
Node.js 中大量请求可能运行在:
同一个 OS Thread
上。
所以你不能使用普通的:
global.currentRequestId
来保存当前请求。
九、为什么不能使用全局变量?
例如:
let currentRequestId: string;
请求 A:
currentRequestId = 'aaa';
请求 B:
currentRequestId = 'bbb';
如果两个请求并发:
Request A
│
├── currentRequestId = aaa
│
├── await database
│
│
Request B
│
├── currentRequestId = bbb
│
└── ...
A 恢复执行的时候:
console.log(currentRequestId);
很可能已经变成:
bbb
所以:
全局变量
❌
而:
AsyncLocalStorage
✅
可以做到:
Request A
↓
requestId = aaa
Request B
↓
requestId = bbb
两个上下文互不干扰。
十、一个完整的基础示例
来看一个完整例子。
import {
AsyncLocalStorage,
} from 'node:async_hooks';
interface Context {
requestId: string;
}
const storage =
new AsyncLocalStorage<Context>();
async function databaseQuery() {
await new Promise(resolve => {
setTimeout(resolve, 100);
});
console.log(
'database:',
storage.getStore(),
);
}
async function service() {
console.log(
'service:',
storage.getStore(),
);
await databaseQuery();
}
async function handleRequest(
requestId: string,
) {
await storage.run(
{
requestId,
},
async () => {
await service();
},
);
}
handleRequest('aaa');
handleRequest('bbb');
最终会看到类似:
service: { requestId: 'aaa' }
service: { requestId: 'bbb' }
database: { requestId: 'aaa' }
database: { requestId: 'bbb' }
即使两个请求并发执行:
aaa
bbb
也不会混在一起。
十一、最典型的用途:Request ID
这是 AsyncLocalStorage 最经典的应用场景。
假设 HTTP 请求:
GET /api/users/123
进入系统。
我们生成:
requestId = 8f3c...
然后整个调用链都自动携带:
requestId
最终日志:
[8f3c] request start GET /api/users/123
[8f3c] user service
[8f3c] database query
[8f3c] redis query
[8f3c] request finish 200
如果出现异常:
[8f3c] ERROR database timeout
就可以根据:
8f3c
把一次请求产生的所有日志找出来。
十二、NestJS 实战:实现 RequestContext
如果使用 NestJS,可以封装一个:
RequestContext
例如:
import {
AsyncLocalStorage,
} from 'node:async_hooks';
export interface RequestContextData {
requestId: string;
userId?: string;
tenantId?: string;
}
export const requestContext =
new AsyncLocalStorage<RequestContextData>();
然后创建一个 Service:
import { Injectable } from '@nestjs/common';
@Injectable()
export class RequestContextService {
get(): RequestContextData | undefined {
return requestContext.getStore();
}
getRequestId(): string | undefined {
return this.get()?.requestId;
}
getUserId(): string | undefined {
return this.get()?.userId;
}
getTenantId(): string | undefined {
return this.get()?.tenantId;
}
}
业务代码:
@Injectable()
export class UserService {
constructor(
private readonly context:
RequestContextService,
) {}
async getUser() {
const requestId =
this.context.getRequestId();
console.log(
'requestId:',
requestId,
);
// ...
}
}
这样业务代码不需要:
getUser(requestId)
十三、NestJS Middleware 创建上下文
接下来是最重要的一步。
我们需要在请求刚进入 NestJS 的时候创建:
AsyncLocalStorage.run()
例如:
import {
Injectable,
NestMiddleware,
} from '@nestjs/common';
import { randomUUID } from 'node:crypto';
@Injectable()
export class RequestContextMiddleware
implements NestMiddleware {
use(req: any, res: any, next: () => void) {
const requestId =
req.headers['x-request-id'] ||
randomUUID();
requestContext.run(
{
requestId,
},
() => {
next();
},
);
}
}
整个 HTTP 请求:
Middleware
↓
Controller
↓
Service
↓
Repository
都处于:
requestContext
创建的异步上下文中。
十四、让日志自动获取 requestId
这才是 AsyncLocalStorage 最舒服的用法。
我们定义:
@Injectable()
export class LoggerService {
constructor(
private readonly context:
RequestContextService,
) {}
log(message: string) {
const requestId =
this.context.getRequestId();
console.log(
`[${requestId}] ${message}`,
);
}
}
业务代码:
this.logger.log(
'开始查询用户',
);
输出:
[8f3c-1234] 开始查询用户
业务代码完全不需要关心:
requestId
从哪里来的。
十五、进一步:保存更多上下文
实际项目中,可以设计成:
interface RequestContextData {
requestId: string;
traceId?: string;
userId?: string;
tenantId?: string;
clientIp?: string;
userAgent?: string;
}
例如:
requestContext.run(
{
requestId,
traceId,
userId,
tenantId,
clientIp,
},
() => {
next();
},
);
这样整个请求生命周期中:
requestId
traceId
userId
tenantId
clientIp
都可以直接获取。
十六、AsyncLocalStorage 特别适合多租户系统
如果是 SaaS / IAM / 多租户系统,经常需要:
tenantId
userId
requestId
例如:
const context =
requestContext.getStore();
console.log(
context?.tenantId,
);
Service:
async findUsers() {
const tenantId =
this.context.getTenantId();
return this.repository.find({
where: {
tenantId,
},
});
}
这样可以减少:
findUsers(
tenantId,
userId,
requestId,
)
这种上下文参数污染。
十七、AsyncLocalStorage + 数据库日志
这是后端系统里非常实用的一种组合。
例如数据库查询:
[requestId=abc]
SELECT * FROM users WHERE id = 1
[requestId=abc]
SELECT * FROM orders WHERE user_id = 1
[requestId=abc]
Redis GET user:1
如果数据库超时:
[requestId=abc]
Postgres timeout
你可以快速找到:
HTTP 请求
↓
Controller
↓
Service
↓
SQL
↓
Redis
↓
错误
整个链路。
十八、AsyncLocalStorage + Kafka
在微服务系统中,AsyncLocalStorage 也很有价值。
例如:
HTTP Request
requestId = abc
↓
Kafka Producer
↓
topic
↓
Kafka Consumer
↓
Service
需要注意:
AsyncLocalStorage 不会自动跨进程、跨线程、跨服务传递。
这是非常重要的一点。
HTTP 请求进入:
Service A
然后发送 Kafka:
Service B
AsyncLocalStorage 不能神奇地穿过 Kafka。
必须主动把:
requestId
traceId
放进消息:
await producer.send({
topic: 'user.created',
messages: [
{
value: JSON.stringify(data),
headers: {
requestId,
traceId,
},
},
],
});
Consumer 收到:
const requestId =
message.headers?.requestId;
然后重新创建:
requestContext.run(
{
requestId,
},
async () => {
await handleMessage();
},
);
于是:
Service A
│
│ requestId=abc
▼
Kafka
│
│ requestId=abc
▼
Service B
两个 Node.js 进程之间的链路就重新建立起来了。
十九、AsyncLocalStorage 和 OpenTelemetry
如果系统进一步使用:
OpenTelemetry
那么会出现:
traceId
spanId
这样的链路追踪信息。
例如:
HTTP
│
├── Controller
│
├── PostgreSQL
│
├── Redis
│
└── Kafka
每个 Span 都可以拥有:
traceId
spanId
AsyncLocalStorage 在 Node.js 生态中也与这类异步上下文传播机制密切相关。
所以可以把它理解成:
AsyncLocalStorage
↓
异步上下文传播
↓
requestId
traceId
userId
tenantId
↓
日志 / 链路追踪 / 监控
对于大型微服务系统,这个能力非常重要。
二十、AsyncLocalStorage 和 ThreadLocal 的区别
简单对比:
| Java ThreadLocal | Node.js AsyncLocalStorage | |
|---|---|---|
| 上下文 | Thread | Async Context |
| 线程模型 | 多线程 | 事件循环 |
| 主要用途 | 线程上下文 | 异步调用链上下文 |
| HTTP 请求 | 常用 | 非常适合 |
| requestId | 可以 | 可以 |
| traceId | 可以 | 可以 |
| 自动跨进程 | 不可以 | 不可以 |
| Kafka 后自动传播 | 不可以 | 不可以 |
最重要的区别:
ThreadLocal
↓
线程
AsyncLocalStorage
↓
异步执行上下文
所以 Node.js 开发者应该避免把它简单理解成:
“Node.js 版本的 ThreadLocal。”
更准确的理解是:
Node.js 异步调用链上的上下文存储。
二十一、AsyncLocalStorage 的几个常见坑
1. 不要每个请求创建一个 AsyncLocalStorage
不要这样:
function handler() {
const storage =
new AsyncLocalStorage();
}
通常应该:
const storage =
new AsyncLocalStorage();
应用级共享。
然后每个请求:
storage.run(context, next);
二十二、不要把大量业务数据放进去
虽然可以:
storage.run({
requestId,
userId,
tenantId,
user,
orders,
permissions,
...
});
但不推荐。
AsyncLocalStorage 更适合保存:
requestId
traceId
userId
tenantId
这类:
上下文信息
而不是:
完整 User 对象
大量数据库结果
大型缓存对象
因为这些数据会随着异步上下文被引用。
正确的思路:
AsyncLocalStorage
↓
少量上下文
↓
requestId
traceId
tenantId
业务数据:
Database
Redis
Service
Cache
单独管理。
二十三、不要频繁修改 store 对象
例如:
const context = storage.getStore();
context.userId = userId;
context.tenantId = tenantId;
虽然技术上可以做到,但大型项目中容易造成:
谁修改了 Context?
什么时候修改的?
为什么这里突然出现 tenantId?
更推荐:
interface RequestContext {
requestId: string;
traceId?: string;
userId?: string;
tenantId?: string;
}
然后尽量在进入请求时确定主要上下文。
二十四、run() 和 enterWith() 有什么区别?
AsyncLocalStorage 还提供:
enterWith()
例如:
storage.enterWith({
requestId: '123',
});
它会把当前执行上下文切换到指定 store。
但在 Web 服务中:
run()
通常更容易理解,也更安全。
推荐:
storage.run(context, () => {
next();
});
而不是到处:
storage.enterWith(...)
因为 enterWith() 会改变当前同步执行上下文,并且可能让上下文边界变得不够清晰。
二十五、如何判断当前有没有上下文?
直接:
const context =
storage.getStore();
然后:
if (!context) {
// 没有 Request Context
}
例如日志:
const requestId =
storage.getStore()?.requestId;
console.log(
`[${requestId ?? 'system'}]`,
message,
);
这样后台任务:
Cron
BullMQ
Kafka Consumer
CLI
Script
即使没有 HTTP Request,也不会报错。
二十六、后台任务怎么办?
这是很多项目容易踩坑的地方。
例如:
@Cron('* * * * *')
async task() {
await service.execute();
}
这里没有 HTTP 请求。
因此:
storage.getStore();
可能是:
undefined
可以主动创建上下文:
storage.run(
{
requestId: `cron-${Date.now()}`,
},
async () => {
await service.execute();
},
);
Kafka Consumer 同样如此:
storage.run(
{
requestId:
message.headers.requestId,
},
async () => {
await handleMessage();
},
);
所以可以建立一个统一规则:
HTTP
↓
创建 Context
Kafka
↓
恢复 Context
BullMQ
↓
创建 / 恢复 Context
Cron
↓
创建 Context
CLI
↓
创建 Context
二十七、一个比较完整的项目结构
如果项目规模比较大,可以设计:
src/
├── common/
│ └── context/
│ ├── request-context.ts
│ ├── request-context.service.ts
│ └── request-context.middleware.ts
│
├── logger/
│ └── logger.service.ts
│
├── user/
│ ├── user.controller.ts
│ └── user.service.ts
│
└── main.ts
二十八、最终可以形成这样的架构
HTTP Request
│
▼
Request Context Middleware
│
▼
AsyncLocalStorage.run()
│
┌────────────┴────────────┐
│ │
▼ ▼
Controller Logger
│ │
▼ │
Service │
│ │
▼ │
Repository │
│ │
├──────────┐ │
▼ ▼ ▼
PostgreSQL Redis requestId
traceId
userId
tenantId
业务代码只负责业务:
await this.userService.findUser();
而:
requestId
traceId
tenantId
这些横切关注点由上下文系统统一管理。
二十九、AsyncLocalStorage 最适合解决什么问题?
可以总结成一句话:
当一个数据属于“这次执行”,但又不值得作为参数在整个调用链中层层传递时,AsyncLocalStorage 非常适合。
例如:
requestId
traceId
userId
tenantId
locale
clientIp
而不适合:
User
Order
Product
大对象
数据库查询结果
业务参数
可以记住一个判断方法:
这个数据是业务数据?
│
├── 是 → 正常传参
│
└── 否
│
└── 是不是执行上下文?
│
├── 是 → AsyncLocalStorage
│
└── 否 → 其他方案
三十、最后总结
AsyncLocalStorage 表面上只是一个很小的 Node.js API:
const storage =
new AsyncLocalStorage();
但它解决的是一个非常核心的问题:
异步程序中的上下文传播。
它让我们可以:
HTTP Request
↓
requestId
↓
Controller
↓
Service
↓
Database
↓
Redis
↓
Logger
而不需要:
controller(requestId)
service(requestId)
repository(requestId)
logger(requestId)
不断传递参数。
它的核心 API 可以记住:
storage.run(context, callback);
创建上下文:
storage.getStore();
获取上下文。
而真正理解 AsyncLocalStorage,最重要的是理解:
它不是全局变量
它不是普通变量
它不是线程变量
它不是跨进程通信
而是:
AsyncLocalStorage
↓
Async Execution Context
↓
异步调用链
↓
requestId / traceId / tenantId
对于现代 Node.js 后端,尤其是:
NestJS
微服务
Kafka
Redis
PostgreSQL
OpenTelemetry
日志系统
SaaS / 多租户
AsyncLocalStorage 是非常值得掌握的一项底层能力。
如果把现代 Node.js 后端的能力分成几个层次:
业务代码
↓
NestJS / Express / Fastify
↓
Node.js Runtime
↓
AsyncLocalStorage
↓
Event Loop / Promise / Async Hooks
那么 AsyncLocalStorage 恰好处在一个很有意思的位置:
它不直接参与业务,却连接了几乎整个后端请求生命周期。
这也是为什么它看起来只有几个 API,却值得专门花时间理解。
