详解 AsyncLocalStorage

0 阅读5分钟

详解 AsyncLocalStorage

在后端系统开发中,经常需要在整个请求生命周期中共享一些上下文信息,例如:

  • requestId
  • traceId
  • userId
  • tenantId

例如日志系统希望输出:

[req-123] query user
[req-123] call database

这样可以快速定位一次请求的完整执行流程。

在许多语言中,这类问题都有成熟解决方案:

语言方案
JavaThreadLocal
Gocontext.Context
Pythoncontextvars
.NETAsyncLocal

而在 Node.js 中,对应的机制就是:AsyncLocalStorage,但要真正理解 AsyncLocalStorage,我们需要先回答一个问题:Node.js 为什么需要 AsyncLocalStorage?

一、Node.js 的核心问题:异步上下文丢失

Node.js 是一个 高度异步化的运行环境,很多操作都会创建异步任务:

  • HTTP 请求
  • Promise
  • setTimeout
  • 数据库查询
  • 文件 IO

当代码进入异步任务后,原有的调用栈会消失,例如:

function controller() {
  const requestId = "req-1"

  setTimeout(() => {
    service(requestId)
  }, 100)
}

逻辑调用链看起来像这样:

controller
   ↓
setTimeout callback
   ↓
service

但实际上:

  • controller 的调用栈已经结束
  • setTimeout 在未来某个时刻重新执行

这就带来一个问题:如何让 service 知道当前请求的 requestId?

二、传统解决方案

在 AsyncLocalStorage 出现之前,Node.js 通常有两种解决方案。

2.1 参数传递

最常见方式是通过参数传递。

function controller(req) {
  const requestId = req.id
  service(requestId)
}

function service(requestId) {
  repository(requestId)
}

function repository(requestId) {
  console.log(requestId)
}

调用链:

controller(requestId)
      ↓
service(requestId)
      ↓
repository(requestId)

这就造成了一个问题: 参数污染, 因为很多函数本身并不需要 requestId,但必须传递,例如:

calculatePrice(order, requestId)

业务参数和系统参数混在一起,随着系统规模扩大:

  • 函数签名会越来越复杂
  • 代码可读性下降

2.2 全局变量

另一种直觉方案是使用全局变量。

global.requestId = null

function controller(req) {
  global.requestId = req.id
  service()
}

function repository() {
  console.log(global.requestId)
}

这种方案在并发场景下会出现问题。假设两个请求同时到来:

Request A
Request B

执行顺序可能是:

Request A 设置 requestId = A
Request B 设置 requestId = B
Request A 执行 repository

输出:

B

原因是:Node.js 虽然是单线程,但请求是并发执行的。全局变量无法隔离不同请求。

三、AsyncLocalStorage 的设计思想

AsyncLocalStorage 的核心思想是:将上下文绑定到异步调用链,而不是函数调用。

换句话说:

同步世界
Context = Call Stack

异步世界
Context = Async Execution Chain

因此 AsyncLocalStorage 做的事情就是:

Call Stack Context
        ↓
Async Chain Context

这样:

logger.info("query user")

也可以自动输出:

[req-123] query user

而不需要手动传递 requestId。

四、AsyncLocalStorage 的底层原理

AsyncLocalStorage 的实现依赖 Node.js 的:async_hooks

4.1 Node.js 的异步资源模型

Node.js 将所有异步任务抽象为 AsyncResource

例如:

  • HTTP
  • Promise
  • Timer
  • TCP
  • FS

每个异步任务都会分配两个 ID:

字段含义
asyncId当前异步任务
triggerAsyncId创建该任务的父任务

4.2 异步调用树

例如代码:

setTimeout(() => {
  Promise.resolve().then(() => {})
})

Node.js 内部可能创建以下任务:

1 HTTP Request
2 setTimeout
3 Promise

关系:

2.triggerAsyncId = 1
3.triggerAsyncId = 2

形成一棵 异步调用树

HTTP Request (1)
      │
      └── setTimeout (2)
              │
              └── Promise (3)

4.3 Context 传播机制

AsyncLocalStorage 内部维护 Map<asyncId, store>,store 就是上下文对象,例如:{ requestId: "req-123" },当调用als.run(store, callback),系统会记录(asyncId → store),当新的异步任务创建时init(asyncId, triggerAsyncId),AsyncLocalStorage 会执行(store[asyncId] = store[triggerAsyncId]),也就是:子任务继承父任务的上下文。

传播过程:

HTTP Request (1) → storeA
        │
        └── setTimeout (2) → storeA
                │
                └── Promise (3) → storeA

因此整条调用链都可以访问同一个 context。

五、使用方法

5.1 创建实例

const { AsyncLocalStorage } = require("async_hooks")

const asyncLocalStorage = new AsyncLocalStorage()

通常建议:

  • 全局只创建 一个实例

例如:

export const requestContext = new AsyncLocalStorage()

5.2 run()

创建一个新的上下文并执行函数。

asyncLocalStorage.run(store, callback)

示例:

const { AsyncLocalStorage } = require("async_hooks")

const als = new AsyncLocalStorage()

als.run({ requestId: "req-1" }, () => {

  setTimeout(() => {
    console.log(als.getStore())
  }, 100)

})

输出:

{ requestId: 'req-1' }

5.3 getStore()

获取当前上下文。

const store = asyncLocalStorage.getStore()

示例:

function log(message) {
  const store = als.getStore()
  console.log(`[${store.requestId}] ${message}`)
}

5.4 enterWith()

直接在当前执行上下文中设置 store。

als.enterWith({ requestId: "req-1" })

区别:

API行为
run创建新的上下文
enterWith修改当前上下文

通常建议优先使用 run()。

5.5 disable()

关闭 AsyncLocalStorage。

asyncLocalStorage.disable()

关闭后:

getStore() → undefined

六、完整 Web 示例

import Koa from "koa"
import Router from "@koa/router"
import { AsyncLocalStorage } from "node:async_hooks"
import { randomUUID } from "node:crypto"

const app = new Koa()
const router = new Router()

const als = new AsyncLocalStorage()

// 为每个请求创建上下文
app.use(async (ctx, next) => {

  const context = {
    requestId: randomUUID()
  }

  return als.run(context, async () => {
    await next()
  })

})

// 日志函数
function log(msg) {
  const store = als.getStore()
  console.log(`[${store?.requestId}] ${msg}`)
}

// 路由
router.get("/", async (ctx) => {

  await new Promise((resolve) => {
    setTimeout(() => {
      log("processing request")
      resolve()
    }, 100)
  })

  ctx.body = "ok"

})

app
  .use(router.routes())
  .use(router.allowedMethods())

app.listen(3000, () => {
  console.log("server running at http://localhost:3000")
})

七、典型使用场景

AsyncLocalStorage 本质解决的是:请求级别上下文传播。适用于所有需要 跨异步调用链共享数据 的场景。

7.1 请求上下文

常见信息:

  • requestId
  • userId
  • tenantId
  • headers

7.2 日志系统

日志自动注入:

  • traceId
  • requestId
  • userId

7.3 分布式链路追踪

例如:

  • OpenTelemetry
  • Jaeger
  • Zipkin

传播:

  • traceId
  • spanId

7.4 数据库事务上下文

ORM 可以通过 AsyncLocalStorage 共享 transaction。

7.5 SSR 请求隔离

SSR 框架:

  • Nuxt
  • Next.js
  • NestJS

保证每个请求拥有独立 context。

八、限制与注意事项

1 性能开销

AsyncLocalStorage 基于:async_hooks,会监听所有异步资源:Promise,Timer,TCP,FS,通常性能影响 小于 5%。建议 store 保持轻量,例如只保存 requestId,traceId,userId

2 内存占用

不要在 store 中保存大型对象,例如:数据库连接,缓存实例,大数据对象。

3 部分旧库可能破坏上下文

例如使用:process.nextTick,自定义 Promise,native addon。

4 run() 嵌套

AsyncLocalStorage 支持嵌套:

als.run(A)
   └─ als.run(B)

内部 context 会覆盖外部。

九、总结

AsyncLocalStorage 的本质可以概括为一句话:基于 async_hooks,实现异步调用链的上下文传播机制。

核心机制:

  • asyncId
  • triggerAsyncId

形成:异步调用树上下文沿着调用树自动继承。

它广泛应用于:

  • 请求上下文
  • 日志系统
  • 分布式追踪
  • 数据库事务
  • SSR 请求隔离

正确使用 AsyncLocalStorage,可以显著提升 Node.js 系统的可观测性和架构整洁度