人人都会AI编程

25.4 异步钩子:async_hooks 异步上下文追踪

更新时间:2026-07-11

在现代 Node.js 应用中,异步操作比比皆是——数据库查询、HTTP 请求、文件读写、定时器回调。一个完整的请求可能跨越多个异步任务,但这些任务之间并没有天然的上下文关联。当我们需要在整个调用链中传递信息(如请求 ID、用户身份、性能追踪数据)时,传统“传参/全局变量”的方式就会显得捉襟见肘。

async_hooks 是 Node.js 提供的一个核心模块,它允许我们追踪异步资源的生命周期,进而在异步任务之间建立上下文联系。简单来说,它让你能够在“发起一个异步操作”和“这个异步操作的回调真正执行”之间,自动传递数据,而无需显式地一层层传参。


async_hooks 的核心概念

async_hooks 把 Node.js 内部的每一个异步操作抽象成一个异步资源(async resource)。每个资源都有一个唯一的 asyncId,并且不同的资源之间通过 triggerAsyncId 形成一棵父子树——即“是谁触发了我”。这种父子关系覆盖了定时器、Promise、回调、流、工作线程等几乎所有异步类型。

async_hooks 允许你在以下四个关键时间点插入自己的钩子函数:

  • init:一个异步资源被创建时触发。此时可以记录它的 asyncIdtriggerAsyncId(父资源 ID)和类型。
  • before:异步资源的回调即将被执行时触发。
  • after:异步资源的回调执行完毕后触发。
  • destruction:异步资源被销毁时触发。

通过这四个钩子,我们可以准确地追踪一个请求在整个生命周期中的流转路径。


基本 API 与用法

async_hooks 模块导出两个核心类:AsyncHookAsyncResource。通常我们使用 async_hooks.createHook 来注册钩子。

const async_hooks = require('async_hooks');

// 创建一个钩子实例
const hook = async_hooks.createHook({
  init(asyncId, type, triggerAsyncId, resource) {
    // 异步资源创建时
    console.log(`init: asyncId=${asyncId}, type=${type}, trigger=${triggerAsyncId}`);
  },
  before(asyncId) {
    // 回调执行前
    console.log(`before: asyncId=${asyncId}`);
  },
  after(asyncId) {
    // 回调执行后
    console.log(`after: asyncId=${asyncId}`);
  },
  destroy(asyncId) {
    // 资源销毁时
    console.log(`destroy: asyncId=${asyncId}`);
  }
});

// 启用钩子
hook.enable();

启用后,任何异步操作都会触发对应的钩子。例如:

setTimeout(() => {
  console.log('Timeout callback');
}, 100);

大概会输出:

init: asyncId=5, type=Timeout, trigger=1
before: asyncId=5
Timeout callback
after: asyncId=5
destroy: asyncId=5

其中 triggerAsyncId 指向触发该异步操作的那个环境的 asyncId(通常就是当前执行上下文的 asyncId)。


实现一个简单的请求上下文传递

async_hooks 最常见的用法是配合 AsyncLocalStorage(Node.js 13.10 之后提供的基于 async_hooks 的高级 API)或者手动实现一个上下文存储(CLS)。这里我们先看手动实现的方式,以便理解原理,然后介绍官方的 AsyncLocalStorage。

手动实现上下文映射

我们需要一个“存储中心”,可以在 init 时将当前上下文关联到新的 asyncId 上。

const async_hooks = require('async_hooks');
const fs = require('fs');

// 存储映射: asyncId -> 数据对象
const store = new Map();

const hook = async_hooks.createHook({
  init(asyncId, type, triggerAsyncId) {
    // 如果触发我的那个资源有上下文,则继承
    if (store.has(triggerAsyncId)) {
      store.set(asyncId, store.get(triggerAsyncId));
    }
  },
  destroy(asyncId) {
    store.delete(asyncId);
  }
});

hook.enable();

// 启用后,可以在任意异步代码中获取或设置上下文
function getContext() {
  const asyncId = async_hooks.executionAsyncId();
  return store.get(asyncId);
}

function runWithContext(data, fn) {
  const asyncId = async_hooks.executionAsyncId();
  store.set(asyncId, data);
  fn();
}

// 使用示例
runWithContext({ requestId: 'req-123' }, () => {
  setImmediate(() => {
    console.log('Request ID:', getContext()?.requestId); // req-123
  });
});

以上代码实现了一个简单的“异步本地存储”,它在同步调用 runWithContext 时将数据绑定到当前执行异步 ID,并在后续异步资源创建时自动继承该上下文。

官方 AsyncLocalStorage

Node.js 从 v13.10.0 开始内置 AsyncLocalStorage,它在 async_hooks 之上提供了更安全、更易于使用的 API,不需要手动管理存储映射和了解内部异步 ID。

const { AsyncLocalStorage } = require('async_hooks');
const als = new AsyncLocalStorage();

// 运行一个带上下文的异步函数
als.run({ requestId: 'req-456' }, () => {
  setImmediate(() => {
    const store = als.getStore();
    console.log(store.requestId); // req-456
  });
});

AsyncLocalStorage.run() 会创建一个新的上下文,该上下文在本次调用及其触发的所有异步链中自动传播,无需任何额外操作。它是 async_hooks 的最佳实践封装,推荐在生产环境中直接使用。


性能影响与使用注意事项

async_hooks 非常强大,但它也存在一些不容忽视的性能开销和潜在风险:

  • 性能损耗:启用 async_hooks 后,每一个异步操作都会触发 initbeforeafterdestroy 钩子,在高并发场景下这会带来明显的 CPU 开销和延迟。如果钩子内部做了较重的操作(如日志写入、字符串拼接),影响会更大。
  • Promise 钩子的缺失:早期版本中 async_hooks 对 Promise 的支持有限,但 Node.js v12+ 已经完整支持 Promise 的异步资源追踪。不过,大量原生 Promise 依然会增加钩子调用量。
  • 内存泄漏风险:如果 destroy 钩子中未能及时清理存储映射,或者某些异步资源由于引用问题未被销毁,会导致存储中的上下文对象无法被垃圾回收,最终内存泄漏。
  • 不要在生产代码中直接使用底层 async_hooks:官方 AsyncLocalStorage 已经做了很多优化,并且更好地处理了边缘场景。除非你清楚自己在做什么,否则应该优先使用它。

实际应用场景

async_hooks 及其衍生 API 在大型 Node.js 应用中有诸多现实应用:

1. 全链路分布式追踪

为每个进入系统的请求生成一个 traceId,并通过异步上下文贯穿所有后端服务调用、数据库查询、缓存操作等。当收集到所有日志或追踪信息后,可以在分布式追踪系统中将一次完整的请求链路串联起来。这也是 OpenTelemetry JavaScript SDK 的实现基础之一。

const { AsyncLocalStorage } = require('async_hooks');
const als = new AsyncLocalStorage();

function traceMiddleware(req, res, next) {
  const traceId = req.headers['x-trace-id'] || generateTraceId();
  als.run({ traceId }, () => {
    // 在这个作用域内的所有异步操作都会自动携带 traceId
    next();
  });
}

// 在数据库查询函数中
async function query(sql) {
  const { traceId } = als.getStore();
  console.log(`[${traceId}] Executing query: ${sql}`);
  // ...
}

2. 请求级日志上下文

通常我们会把请求级别的信息(如用户 ID、请求路径、客户端 IP)注入日志,但如果在每个函数调用中都显式传递这些参数就会十分繁琐。AsyncLocalStorage 允许日志工具直接从上下文中读取这些信息,而无需修改任何中间函数的签名。

3. 事务管理

在数据库操作中,同一个事务可能需要跨多个异步步骤。利用 async_hooks 可以将事务对象自动传递给所有嵌套的数据库操作,让下层代码无感知地加入到当前事务中。

await db.transaction(async (trx) => {
  als.run({ transaction: trx }, async () => {
    await serviceA.doWork(); // 内部使用 getStore().transaction
    await serviceB.doWork();
  });
});

async_hooks 与 AsyncLocalStorage 的版本兼容性

  • async_hooks 从 Node.js v8.0.0 开始提供,但稳定性经过多个版本迭代。
  • AsyncLocalStorage 在 Node.js v13.10.0 引入,v12.17.0 可用(需后台端口),v14 开始稳定。
  • 鉴于性能改进和 bug 修复,建议使用 Node.js v16 及以上版本。

小结

async_hooks 为 Node.js 打开了一扇通向“异步上下文感知”的大门,让我们得以在复杂的异步调用链中轻松传递数据。虽然直接使用原始 API 比较底层且容易出错,但通过 AsyncLocalStorage 这一官方的封装,绝大多数应用场景都可以安全、高效地实现。

在实际工程中,异步上下文追踪已经成为许多中间件(如日志系统、分布式追踪、事务管理)的核心基础设施,它极大地减少了代码侵入,让业务逻辑更加简洁纯粹。理解其原理和适用边界,能够帮助你在架构层面更好地设计跨切面关注点的解决方案。