技术干货 # nodejs # 后端已发布

Node.js AsyncLocalStorage:理解异步上下文、原理与实战

在后端开发中,我们经常会遇到这样一个需求:一次 HTTP 请求进来之后,我们希望在整个调用链路中都能拿到一些上下文信息...

2025.04.209 分钟阅读
Node.js AsyncLocalStorage:理解异步上下文、原理与实战
Node.js AsyncLocalStorage:理解异步上下文、原理与实战 · Cover

在后端开发中,我们经常会遇到这样一个需求:

一次 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 ThreadLocalNode.js AsyncLocalStorage
上下文ThreadAsync 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,却值得专门花时间理解。

#nodejs #后端
ZZC

ZZC

写代码,也写一些生活。