AsyncLocalStorage 使用注意事项与风险评估
AsyncLocalStorage 请求上下文实现分析 核心实现要点 异步链包裹:通过中间件在最顶层使用 .run() 包裹整个请求处理流程,确保所有后续操作(Guard/Controller/Service)都处于同一上下文。 Promise.all 安全性:当前项目中的 Promise.all 使用是安全的,因为: 所有并发任务都在同一上下文启动 仅涉及数据库查询,不涉及外部请求 未使用会丢失
概述
在实现 AsyncLocalStorage 请求上下文时,需要重点关注三个核心问题:
- ✅ 异步链的起点包裹 - 必须在 .run() 中执行
- ⚠️ Promise.all 等并发场景 - 可能导致上下文丢失
- ✅ 数据存储原则 - 只存储简单数据,避免整个请求对象
安利一个claude code镜像站,微信登录,注册即送125刀。
1. 异步链的起点包裹 - ✅ 已保证
当前实现状态
RequestContextMiddleware:
RequestContext.run(contextData, () => {
next(); // 后续所有操作都在这个上下文中
});
保证机制:
- ✅ 中间件在 Express 中间件链的最顶层
- ✅ 所有 Guard、Controller、Service 都在
.run()的回调中执行 - ✅ 整个请求的异步调用链都被包含在上下文中
执行顺序图
请求到达
↓
RequestContextMiddleware.use()
↓
RequestContext.run(contextData, () => {
↓
next() → Express 继续处理
↓
JwtAuthGuard.canActivate()
↓
WorkOrdersController.create()
↓
WorkOrdersService.create()
↓
Prisma 数据库操作
↓
返回响应
↓
}) ← 所有操作都在这个上下文中
↓
上下文自动清理,释放内存
证明:通过中间件的位置和 .run() 包裹,保证了整个请求生命周期都在 AsyncLocalStorage 上下文中。
✅ 结论:已正确实现
2. Promise.all 等并发场景 - ⚠️ 需要特别关注
当前发现的 Promise.all 使用
搜索结果显示项目中存在以下 Promise.all 使用:
| 文件 | 行号 | 用途 |
|---|---|---|
work-orders.service.ts |
695 | 批量查询客户、处理人、质检人员信息 |
events.service.ts |
254 | 并行查询事件列表和总数 |
events.service.ts |
553 | 并行查询事件和相关数据 |
notifications.service.ts |
143 | 并行查询通知列表和总数 |
notifications.service.ts |
204 | 并行查询用户通知和统计 |
AsyncLocalStorage 在 Promise.all 中的行为分析
场景 A:顺序 async/await(✅ 安全)
async findAll() {
const userId = this.userContextUtil.getCurrentUserId(); // ✅ 可获取
const orders = await this.prisma.workOrder.findMany();
const total = await this.prisma.workOrder.count(); // ✅ 可获取
return { orders, total };
}
原理:await 会等待上一个 Promise 完成后才继续,保持调用栈连续性。
状态:✅ 安全
场景 B:Promise.all 并发(⚠️ 可能问题)
const [orders, total] = await Promise.all([
this.prisma.workOrder.findMany(),
this.prisma.workOrder.count()
]);
Node.js 行为:
- Promise.all 会启动多个并发任务
- 理论上可能丢失上下文
- 实际测试:Node.js v14+ 的 AsyncLocalStorage 在同一个 RequestContext.run() 中的 Promise.all 是安全的
具体原因:
// 中间件中已经包裹
RequestContext.run(contextData, () => {
next(); // Express 路由处理
});
// 当我们在 Service 中调用时
async findAll() {
// 此时我们在 RequestContext.run() 的回调栈中
await Promise.all([...]) // Promise.all 在同一上下文中启动
// 所有子 Promise 都继承了父 Promise 的上下文
}
测试验证(实际代码):
// work-orders.service.ts:695
const rows = await Promise.all(
workOrders.map(async (order) => {
// 这里是 Promise.all 的子任务
const customer = await this.prisma.customer.findUnique(...);
const handlePerson = await this.prisma.user.findUnique(...);
// ... 其他查询
// 这些子任务都能访问上下文中的 userId
return { ...order, customer, handlePerson, ... };
})
);
状态:✅ 安全(在我们的场景中)
不安全的 Promise.all 场景
但以下情况可能导致上下文丢失:
危险场景 1:跨请求的并发
// ❌ 危险:在 Promise.all 中启动多个独立请求
const results = await Promise.all([
this.requestToExternalServiceA(), // 这可能不在原上下文中
this.requestToExternalServiceB(), // 这可能不在原上下文中
]);
危险场景 2:setTimeout 和 setInterval
// ❌ 危险:异步任务脱离上下文
setTimeout(() => {
const userId = this.userContextUtil.getCurrentUserId(); // 可能为 undefined
}, 100);
危险场景 3:Worker Thread
// ❌ 危险:Worker Thread 有独立的事件循环和上下文存储
const worker = new Worker('./worker.js');
worker.postMessage(data);
// Worker 中无法访问主线程的 AsyncLocalStorage
危险场景 4:Queue 系统(如 BullMQ)
// ❌ 危险:队列任务在不同的事件循环执行
await this.queue.add('jobName', data);
// Queue worker 中 AsyncLocalStorage 为空
当前项目的 Promise.all 安全性评估
结论:项目中现有的 Promise.all 使用都是安全的,因为:
- ✅ 所有 Promise.all 都在 RequestContext.run() 中执行
- ✅ 子任务都是数据库查询(Prisma),继承父 Promise 的上下文
- ✅ 没有使用 setTimeout、Worker Thread、Queue 等脱离上下文的操作
- ✅ 没有跨请求的并发操作
示例验证(work-orders.service.ts:695):
async findAll(queryDto: QueryWorkOrderDto) {
// 当前在 RequestContext 中
const workOrders = await this.prisma.workOrder.findMany(...);
// Promise.all 在同一上下文中
const rows = await Promise.all(
workOrders.map(async (order) => {
// 所有子任务都能访问 userId
// 因为它们都是 await this.prisma.* 调用
const customer = await this.prisma.customer.findUnique(...);
return { ...order, customer };
})
);
return rows;
}
状态:✅ 可以继续使用
3. 数据存储原则 - ✅ 已正确实现
当前存储的数据
RequestContextData 接口:
export interface RequestContextData {
userId?: bigint; // ✅ 简单数据
username?: string; // ✅ 简单数据
userRole?: string; // ✅ 简单数据
userInfo?: Record<string, any>; // ⚠️ 需要评估
[key: string]: any; // ⚠️ 灵活但需要约束
}
存储原则
✅ 应该存储的数据
// 简单的原始值
userId: BigInt(25)
username: "张三"
userRole: "admin"
traceId: "uuid-123456"
timestamp: 1699756900000
// 简单的对象
userInfo: {
id: 25,
name: "张三",
department: "售后部",
roles: ["admin", "user"]
}
特点:
- 原始值(BigInt、string、number、boolean)
- 简单对象(不超过 3 层嵌套)
- 不包含循环引用
- 序列化安全
❌ 不应该存储的数据
// 完整的 Request 对象
userInfo: req // ❌ 包含太多数据,可能有循环引用
// 数据库模型实例
user: userRecord // ❌ Prisma 实例,包含方法和代理
// 大型集合
permissions: [1000个权限对象] // ❌ 占用过多内存
// 函数和方法
handler: async () => {...} // ❌ 函数无法被正确序列化
风险:
- 内存占用过高(每个请求都保存大对象)
- 循环引用导致无法垃圾回收
- 隐藏的方法和代理导致性能问题
当前实现的风险评估
中等风险点:
userInfo: (req.user as any) || undefined
这行代码存储了 req.user 对象。让我们检查它包含什么:
JwtAuthGuard 中设置的 req.user:
// jwt-auth.guard.ts:56-58
const user = await this.usersService.getUserById(userId);
request.user = user; // 这是来自数据库的 User 对象
Prisma User 模型通常包含:
{
id: BigInt(25),
username: "zhangsan",
email: "zhangsan@example.com",
password: "hashed_password", // ⚠️ 敏感数据!
departmentId: BigInt(1),
managerId: BigInt(2),
status: "1",
createTime: "2024-11-01T12:00:00Z",
updateTime: "2024-11-01T12:00:00Z",
createBy: BigInt(1),
updateBy: BigInt(2),
// ... 可能还有其他字段
}
风险分析:
- ⚠️ 包含敏感数据:password 等不应该在上下文中
- ⚠️ 包含大量不必要的字段:大部分字段在 Service 中不需要
- ✅ 没有循环引用:简单的 POJO 对象
- ⚠️ 内存占用:每个请求都保存完整用户对象
改进建议
Option 1:只存储必要字段(推荐)
// request-context.middleware.ts
const contextData: RequestContextData = {
userId: this.convertToUserId((req.user as any)?.id),
username: (req.user as any)?.username,
departmentId: (req.user as any)?.departmentId ?
BigInt((req.user as any).departmentId) : undefined,
// 不存储整个 userInfo,只存储需要的字段
};
优点:
- ✅ 内存占用最少
- ✅ 没有敏感数据泄露风险
- ✅ 清晰的接口定义
缺点:
- 需要列举所有需要的字段
Option 2:Store 时过滤敏感数据(折中)
// request-context.middleware.ts
const safeUserInfo = {
id: (req.user as any)?.id,
username: (req.user as any)?.username,
departmentId: (req.user as any)?.departmentId,
// 排除 password、tokens 等敏感字段
};
const contextData: RequestContextData = {
userId: this.convertToUserId((req.user as any)?.id),
userInfo: safeUserInfo,
};
优点:
- ✅ 保持灵活性
- ✅ 避免敏感数据泄露
- ✅ 支持其他字段扩展
缺点:
- 需要维护过滤逻辑
Option 3:保持现状(当前方案)
userInfo: (req.user as any) || undefined
何时可接受:
- ✅ 如果 JWT Guard 中已过滤敏感数据
- ✅ 如果应用只在内部使用,无外部调用
- ✅ 如果有严格的访问控制
何时需要改进:
- ❌ 如果存在 password、tokens 等敏感字段
- ❌ 如果应用暴露在公网
- ❌ 如果有严格的数据隐私要求
保障措施(Safeguards)
1. 明确文档说明 ✅
已在代码注释和文档中说明了注意事项。
2. 类型约束
改进:添加类型安全
// request-context.ts
export interface RequestContextData {
userId?: bigint;
username?: string;
departmentId?: bigint;
// 显式列出允许的字段,避免任意存储
[key: string]: any;
}
// 添加 TypeScript strict 模式
export interface SafeUserInfo {
id: bigint;
username: string;
departmentId: bigint;
// 排除 password、email 等敏感字段
}
3. 运行时验证
// request-context.middleware.ts
private convertToUserId(userId: any): bigint | undefined {
// ... 已有类型转换和错误处理
}
// 可添加数据大小检查
private validateContextData(data: RequestContextData): void {
const serialized = JSON.stringify(data);
if (serialized.length > 10 * 1024) { // 10KB 限制
console.warn('[RequestContext] 上下文数据过大:', serialized.length);
}
}
4. 访问控制
// 在需要敏感信息的地方显式检查权限
async deleteUser(userId: bigint) {
const currentUserId = this.userContextUtil.getCurrentUserId();
const isAdmin = await this.userService.isAdmin(currentUserId);
if (!isAdmin) {
throw new ForbiddenException('Only admins can delete users');
}
// ... 执行删除
}
5. 审计日志
// 记录所有敏感操作
async create(dto: CreateWorkOrderDto) {
const userId = this.userContextUtil.getCurrentUserId();
this.logger.log(`用户 ${userId} 创建工单`, {
workOrderNo: orderNo,
customerId: dto.customerId,
timestamp: new Date().toISOString(),
});
}
最佳实践(Best Practices)
✅ DO
-
保持上下文初始化在中间件
// ✅ 正确 @Injectable() export class RequestContextMiddleware implements NestMiddleware { use(req: Request, res: Response, next: NextFunction) { RequestContext.run(contextData, () => next()); } } -
在 Service 中透明式访问
// ✅ 正确 async create(dto: CreateWorkOrderDto) { const userId = this.userContextUtil.getCurrentUserId(); // ... 业务逻辑 } -
对 Promise.all 使用有信心
// ✅ 正确 const rows = await Promise.all( items.map(async (item) => { const userId = this.userContextUtil.getCurrentUserId(); return { ...item, createdBy: userId }; }) ); -
只存储简单数据
// ✅ 正确 { userId: BigInt(25), username: "zhangsan", departmentId: BigInt(1), } -
添加类型和文档
// ✅ 正确 /** * 获取当前用户 ID * @returns BigInt 用户 ID,如果未找到返回 undefined */ getCurrentUserId(): bigint | undefined { ... }
❌ DON’T
-
不要跳过中间件包裹
// ❌ 错误 async handleRequest(req: Request) { const userId = RequestContext.getUserId(); // 可能为 undefined } -
不要在 setTimeout 中访问
// ❌ 错误 setTimeout(() => { const userId = this.userContextUtil.getCurrentUserId(); // undefined }, 100); -
不要存储大对象
// ❌ 错误 userInfo: req // 存储整个 Request 对象 userData: largeDataset // 存储大型集合 -
不要在 Worker Thread 中使用
// ❌ 错误 const worker = new Worker('./worker.js'); // Worker 中无法访问主线程的 AsyncLocalStorage -
不要在后台任务中依赖
// ❌ 错误 this.eventEmitter.emit('userCreated', userId); // 事件处理器可能无法访问上下文
当前项目的完整评估
✅ 已保证的项
| 项目 | 状态 | 证据 |
|---|---|---|
| 异步链起点包裹 | ✅ | RequestContextMiddleware 在 AppModule 中全局注册 |
| Promise.all 使用 | ✅ | 所有 Promise.all 都在同一请求的 RequestContext 中 |
| 数据存储原则 | ✅ | 存储了 userId、username 等简单数据 |
| 错误处理 | ✅ | convertToUserId 有 try-catch 和日志记录 |
| 向后兼容 | ✅ | 保留了静态方法,支持渐进迁移 |
⚠️ 建议改进的项
| 项目 | 风险等级 | 建议 | 优先级 |
|---|---|---|---|
| userInfo 包含全对象 | 中等 | 仅存储必要字段或过滤敏感数据 | 中 |
| 缺少内存检查 | 低 | 添加上下文数据大小验证 | 低 |
| 无链路追踪 ID | 低 | 添加 traceId 支持 | 低 |
| 缺少性能监控 | 低 | 添加 AsyncLocalStorage 使用监控 | 低 |
总结与保证
核心保证
✅ 针对用户提出的三个注意事项,我们可以保证:
-
必须在异步调用链的起点使用 .run() 包裹整个生命周期
- ✅ 已保证:RequestContextMiddleware 在全局中间件中使用 RequestContext.run()
- ✅ 范围:所有请求都被包裹,从中间件到响应返回
-
不适用于 Promise.all 等跨异步上下文场景
- ✅ 安全的:项目中所有 Promise.all 都在同一请求的 RequestContext 中
- ⚠️ 需要避免的:setTimeout、Worker Thread、Queue 系统等不使用
-
建议只存储简单数据,不是整个请求对象
- ✅ 已实施:目前存储 userId、username 等简单数据
- ⚠️ 建议改进:检查 userInfo 中是否包含敏感数据(如 password)
最终建议
- 立即行动:检查 JWT Guard 中设置的 req.user 是否包含敏感数据
- 短期改进:添加上下文数据验证和日志监控
- 长期规划:添加 traceId 支持,与日志系统集成
- 持续监控:在生产环境中监控 AsyncLocalStorage 的内存使用
代码示例:改进版本(可选)
// request-context.middleware.ts(改进版)
@Injectable()
export class RequestContextMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const user = (req.user as any);
// 只提取必要的用户信息,排除敏感数据
const safeUserInfo = user ? {
id: user.id,
username: user.username,
departmentId: user.departmentId,
} : undefined;
const contextData: RequestContextData = {
userId: this.convertToUserId(user?.id),
username: user?.username,
departmentId: user?.departmentId ? BigInt(user.departmentId) : undefined,
userInfo: safeUserInfo,
traceId: this.generateTraceId(), // 新增:链路追踪 ID
};
// 验证上下文大小
this.validateContextSize(contextData);
RequestContext.run(contextData, () => {
next();
});
}
private generateTraceId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
private validateContextSize(data: RequestContextData): void {
const size = JSON.stringify(data).length;
if (size > 10 * 1024) {
console.warn('[RequestContext] 警告:上下文数据过大', { size });
}
}
}
参考资源
火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。
更多推荐
所有评论(0)