详解 AsyncLocalStorage
在后端系统开发中,经常需要在整个请求生命周期中共享一些上下文信息,例如:
- requestId
- traceId
- userId
- tenantId
例如日志系统希望输出:
[req-123] query user
[req-123] call database
这样可以快速定位一次请求的完整执行流程。
在许多语言中,这类问题都有成熟解决方案:
| 语言 | 方案 |
|---|---|
| Java | ThreadLocal |
| Go | context.Context |
| Python | contextvars |
| .NET | AsyncLocal |
而在 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 系统的可观测性和架构整洁度。