Koa 把整个框架押在 async/await 上,但框架教会你的只有 await next()。这一节补齐语言侧的另一半:await 到底传播了什么、并行与串行怎么选、漏 await 的三种事故形态,以及一个进阶话题——请求上下文(谁发的请求、请求 ID 是几)怎么跨越异步边界自动跟随,不用层层传参。
await 的实质是"等这个 Promise 落定,落定时把结果或异常带回来"。对 Koa 意味着:await next() 等的是内层所有层的总和——内层任何一个异步抛出的 rejection 都会在这一行冒出来。把这条规则推到底,可以判断任意代码的可靠性:
// 可靠:三条链都收进 await,任何一个失败都会被兜底层接住 app.use(async (ctx) => { const user = await loadUser(ctx.state.user.id); const perms = await loadPermissions(user); const prefs = await loadPreferences(user); ctx.body = { user, perms, prefs }; });
串行写法的问题一眼可见:三次加载共耗时等于三者之和,而后两者其实互不依赖。彼此独立的异步必须并行,并行必须收口:
// 并行:总耗时取决于最慢的那个,而非三者之和 app.use(async (ctx) => { const [user, perms, prefs] = await Promise.all([ loadUser(ctx.state.user.id), loadPermissions(ctx.state.user.id), loadPreferences(ctx.state.user.id), ]); ctx.body = { user, perms, prefs }; });
并行有两种收口姿势,语义不同:Promise.all 全成功才成功、一失败立即整体失败——适合"缺一不可"的组合;Promise.allSettled 等全部结束、逐项报告成败——适合"可选增强"(比如偏好加载失败不该拖垮整个请求)。选错姿势的事故形态很典型:用 all 收口可选任务,结果一个边缘服务抖动打挂整个接口——这时该换成 allSettled 并对失败项降级。
第 2.2 节实验过漏 await 对层序的破坏,这里把业务代码里的同类事故归成三种形态,按"难查程度"排序。
形态一:响应缺数据但状态正常。 漏 await 的查询还在路上,处理器已经带着半成品 body 出层——用户看到"收藏夹是空的",刷新一次又有了。间歇性、无报错,是最典型的幽灵 bug。
形态二:静默 rejection。 链外的 Promise 落定时没人接,变成 unhandledRejection:日志取决于你有没有进程级兜底(6.3 节),没有就彻底无声。开篇工单里"日志干净的 502"就是它——网关等超时了,服务端还毫无感知。
形态三:误用 forEach 跑异步。 这是最隐蔽的一种:
// 错误:forEach 不等异步回调,循环"结束"时一个任务都没完成 items.forEach(async (item) => { await invoiceService.issue(item); }); ctx.body = { done: true }; // 实际开票还在半路 // 正确:想全部完成用 map 加 all 收口 await Promise.all(items.map((item) => invoiceService.issue(item)));
forEach 接 async 回调等于并行启动所有任务但立刻返回,错误也全部脱离链外——既丢数据又丢异常。修复口诀:异步集合操作只用 map 加收口,或者需要逐个按序时用 for-of 加 await。
先看痛点。日志要带请求 ID,于是一路传参:
router.post('/orders', async (ctx) => { const reqId = ctx.state.reqId; const order = await orderService.create(ctx.validated, reqId); // 传 }); // orderService 里再传给 repo,repo 里再传给日志器……reqId 渗透所有函数签名
Node.js 13 之后有了标准解法——AsyncLocalStorage:它把一块存储绑定到异步执行链上,从绑定点开始的任何异步后代都能读到它,跨越多少层 await 都不失联:
// context.js:请求现场的全局出口 const { AsyncLocalStorage } = require('async_hooks'); const als = new AsyncLocalStorage(); function requestContext() { return async (ctx, next) => { const store = { reqId: ctx.state.reqId, userId: ctx.state.user && ctx.state.user.id }; await als.run(store, next); // 内层所有异步都在这个 store 的作用域里 }; } function getLogger() { const store = als.getStore() || {}; return { info: (msg, extra = {}) => console.log(JSON.stringify({ reqId: store.reqId, userId: store.userId, msg, ...extra, })), }; } module.exports = { requestContext, getLogger };
// 任意深度的业务代码:不再传 reqId,日志自动携带 const { getLogger } = require('./context'); async function charge(order) { getLogger().info('charging', { orderId: order.id }); // 自动带 reqId 与 userId }
注意两点边界。其一,请求上下文层要装配在鉴权层之后(要读 user)与业务层之前; Als 的 run 包裹 next(),内层整棵异步树都在作用域内。其二,线程池上的同步重活、脱离 Promise 的裸回调拿不到 store——但那本来就该在链上(回到 await 纪律)。

⚠️ 常见坑:把 AsyncLocalStorage 当缓存用,往 store 里塞大对象(整个用户资料、整个配置)。store 的生命周期与请求等长、每个请求一份,塞大东西等于每请求复制一份常驻数据。原则:store 只放"打标"用的轻字段——ID、标记、起点时间。
链上的异步纪律立住了,但总有异常走到最外层、总有 rejection 漏到链外——下一节把全局错误事件接进日志体系,让每一次失败都留下可检索的痕迹。