有个用 TypeScript 写的下单流程,逻辑是:扣库存、写订单、再发一条通知。代码我写得很顺,每一步都封装成了 async 函数,主流程里一个个调过去。可上线后,偶发地出现一些诡异现象:有时订单写进去了,库存却没扣;有时通知发了,订单还没落库;还有一次,某个步骤明明在内部抛了异常,可外层的 try/catch 却完全没捕获到,错误像人间蒸发了一样,既没日志、也没告警,只留下一堆对不上的脏数据。
我盯着代码看了好久,逻辑明明是顺序写的、一步接一步,怎么会乱序、还会吞掉异常?直到我逐行抠那些 async 调用,才发现真凶——一个我犯了无数次、又极其容易被忽略的低级错误:我调用一个返回 Promise 的 async 函数时,忘了写 await。比如那句 reduceStock(order);,我以为它会"执行完扣库存再往下走",可因为漏了 await,它根本没有等待——这个函数只是启动了扣库存这个异步操作,然后立刻返回了一个 Promise,而我没接住、也没等它,主流程就头也不回地往下执行了下一步。于是,"扣库存"还在后台慢悠悠地跑,"写订单"却已经先一步执行了——顺序就这么乱了。
更可怕的是异常被吞:那个忘了 await 的 Promise,如果内部抛了错,因为没有任何人 await 它、也没有 .catch 它,这个错误就成了一个"未处理的 Promise 拒绝(unhandled rejection)"——它不会冒泡到我的 try/catch 里(因为 try/catch 早就执行完了),只会悄无声息地飘走,顶多在某个角落留下一句不起眼的警告。这就是 JavaScript/TypeScript 异步编程里极其经典、又极其隐蔽的坑:浮动的 Promise(floating promise)——一个被创建、却没有被 await 或 catch 的 Promise。它会让你的异步代码乱序、让错误凭空消失。这篇文章,就从这次"忘了一个 await 引发的乱序与吞错"事故出发,把它讲透。
先摆几个关于 async/await 的想当然
动手复盘前,先把我自己曾经深信、后来被这个漏掉的 await 教育的几个念头摆出来。
| 想当然的念头 | 残酷的真相 |
|---|---|
| "调了 async 函数, 它就会执行完再往下走" | 不 await 的话, 它只是启动了异步操作就立刻返回 |
| "代码一行行写的, 就会一行行顺序执行" | 漏了 await, 异步操作会脱离主流程乱序进行 |
| "异步函数里的错, try/catch 总能抓到" | 没 await 的 Promise 抛错, try/catch 抓不到 |
| "忘个 await 顶多慢一点, 没大碍" | 会导致乱序、脏数据、错误被吞, 后果严重 |
| "编译器/类型检查会提醒我漏了 await" | 默认不会! 漏 await 是合法代码, 编译器不报错 |
这些念头的共同病根,是没真正理解 await 在 async 函数调用中扮演的角色——它不是可有可无的装饰,而是"等待这个异步操作完成、并接住它的结果与异常"的关键。漏掉它,async 调用的行为就发生了本质的改变。要看清这次事故,得先理解"调用 async 函数"和"await 它"到底有什么区别。
第一件事:调用 async 函数 ≠ 等待它完成
关键的认知是:一个 async 函数,调用它,只是"启动"了它内部的异步操作,并立即返回一个 Promise 对象;它不会阻塞、不会等待。而 await 这个关键字,才是"暂停当前流程,等待这个 Promise 完成(变为 resolved 或 rejected),再继续"的指令。所以,reduceStock(order) 和 await reduceStock(order),是两件完全不同的事:前者是"按下启动按钮就走人",后者是"按下启动按钮,然后站在原地等它真正完成"。
在我的事故里,reduceStock(order) 漏了 await,等于"按下扣库存的按钮,然后立刻去执行下一步写订单",而扣库存还在后台没跑完——主流程和那个异步操作"脱钩"了,各跑各的,顺序自然就乱了。下面这张图,把"有 await"和"没 await"两种情况的执行流画出来:
看懂这张图,事故的根就清楚了:await 是把"异步操作"重新"缝合"回"主流程顺序"的那根线;漏了它,异步操作就脱线了,既不保证顺序、也不保证错误能被接住。每写一个 async 函数的调用,都要下意识地问自己:这里我需不需要等它完成?需要,就必须 await;不写,它就脱离了你的掌控,自顾自地在后台漂走。接下来,我们就看怎么系统性地避免漏掉它。
第二件事:让工具替你抓漏——no-floating-promises 规则
"漏写 await"是人最容易犯的错,靠肉眼和自觉去防,迟早会漏。最可靠的办法,是让工具自动帮你揪出来。TypeScript 生态里,typescript-eslint 提供了一条专门的规则:@typescript-eslint/no-floating-promises——它会静态检查出所有"创建了 Promise、却没有被 await、没有 .then/.catch 处理、也没有显式标记忽略"的浮动 Promise,在你编码或 CI 阶段就报错。这是防范这类问题最有效的一道防线。
// .eslintrc 里开启(需要 typescript-eslint 的 type-aware 规则)
// "@typescript-eslint/no-floating-promises": "error"
// 开启后, 漏 await 会被直接标红报错:
reduceStock(order); // 报错: Promise 未处理(floating)
await reduceStock(order); // 正确
// 如果你是【故意】不等待某个 Promise(发后不管), 必须显式表明意图:
void fireAndForgetLog(msg); // 用 void 明确告诉工具和读者: 我就是不等它
// 或者:
fireAndForgetLog(msg).catch(err => logger.error(err)); // 至少接住错误
这条规则的价值,远不止"少打一个 await"——它强迫你对每一个 Promise 都做出明确的表态:要么 await 它、要么 .then/.catch 处理它、要么用 void 显式声明"我故意不等它"。这种"必须表态"的约束,让"漏 await"从一个隐蔽的、靠运气发现的 bug,变成了一个编码时就被工具拦下的、显式的决策。对于这种"极易疏忽、后果又严重"的问题,与其依赖人的细心,不如交给工具去强制保证——这是工程化最朴素也最有效的智慧。我那次事故后,第一件事就是把这条规则开成 error 级别,从此再没漏过 await。
第三件事:数组循环里的 async 陷阱——forEach 不会等待
漏 await 还有一个高发、且更隐蔽的变种:在数组的 forEach 里用 async。很多人想"对数组每个元素做个异步操作、并等它们都完成",于是写了 arr.forEach(async item => { await doSomething(item); })——然后在外面 await 这个 forEach,以为就等到了。大错特错:forEach 根本不关心、也不等待回调返回的 Promise,它会立刻把所有异步操作都启动了、然后自己马上返回。你外面 await 的,是一个 forEach 返回的 undefined,根本没等任何异步操作。
// 反例:forEach + async, 完全不会等待! 外面 await 也没用
async function processAll(items: Item[]) {
items.forEach(async (item) => {
await save(item); // forEach 不等这个 Promise!
});
console.log("done"); // 立刻打印, 此时 save 们都还没完成!
}
// 正解一:要顺序执行, 用 for...of(它配合 await 会真正逐个等待)
async function processAll(items: Item[]) {
for (const item of items) {
await save(item); // 逐个等待, 顺序执行, 真正等到
}
console.log("done"); // 此时所有 save 真的都完成了
}
// 正解二:要并发执行, 用 map 产生 Promise 数组 + Promise.all
async function processAll(items: Item[]) {
await Promise.all(items.map((item) => save(item))); // 并发, 全部等到
console.log("done"); // 所有 save 并发完成后才打印
}
这个坑的关键认知是:forEach 是为同步回调设计的,它对回调返回的 Promise 视而不见;想在循环里做异步,要么用 for...of(顺序、逐个 await),要么用 map + Promise.all(并发、统一等待)。选哪个取决于你要顺序还是并发:有先后依赖的用 for...of,互不依赖、想快的用 Promise.all。记住:看到 forEach(async ...) 就该亮红灯——它几乎总是一个 bug。(顺带一提,no-floating-promises 这条规则,通常也能帮你揪出 forEach 里这种被忽略的 Promise。)
第四件事:Promise.all 一败全败,需要时用 allSettled
用 Promise.all 做并发时,有一个语义要心里有数:Promise.all 是"一败全败"——只要其中任何一个 Promise 失败(rejected),整个 Promise.all 就立刻失败,并抛出那第一个错误;其余的 Promise 即便后来成功了,它们的结果你也拿不到。这在"必须全部成功才算成功"的场景下是对的;但如果你想"每个都执行,有的成功有的失败都能接受、并分别拿到各自的结果",就该用 Promise.allSettled。
// Promise.all:一败全败, 任一失败整体就 reject
try {
const results = await Promise.all([taskA(), taskB(), taskC()]);
// 只要有一个失败, 这里直接抛错, 其它成功的结果也拿不到
} catch (e) { /* 第一个失败的错误 */ }
// Promise.allSettled:每个都跑完, 分别返回成功/失败, 一个都不漏
const results = await Promise.allSettled([taskA(), taskB(), taskC()]);
for (const r of results) {
if (r.status === "fulfilled") {
console.log("成功:", r.value);
} else {
console.error("失败:", r.reason); // 失败的也能单独拿到原因
}
}
// 适用:批量发通知/批量处理, 允许部分失败, 要分别知道每个的结果
选 all 还是 allSettled,取决于你的业务语义:"要么全成、有一个失败就整体算失败"用 all;"每个独立、部分失败可接受、要逐个知道结果"用 allSettled。用错了会很尴尬——比如批量发 100 条通知,用了 all,结果第 3 条失败就让整批"失败"了、剩下 97 条的成功你也无从得知。理解这两者的差异,是写对并发逻辑的基础。还有 Promise.race(谁先完成用谁,常用于做超时)、Promise.any(谁先成功用谁),也各有适用场景,一并了解能让你的并发工具箱更趁手。
第五件事:给异步错误兜个底——全局未处理拒绝监听
即便有了 lint 规则,百密一疏总难免会漏掉一个 await,让某个 Promise 的错误成为"未处理拒绝"。为了不让这种错误彻底无声无息地消失,要在全局设一个兜底的监听器,把所有"未被处理的 Promise 拒绝"都捕获、记录下来——至少让它们留下痕迹,而不是凭空蒸发。
// Node.js:全局监听未处理的 Promise 拒绝, 别让异步错误无声消失
process.on("unhandledRejection", (reason, promise) => {
logger.error("未处理的 Promise 拒绝!", { reason });
// 记录、告警; 严重的甚至可以选择优雅退出进程并由编排系统重启
});
// 浏览器端:监听 window 的 unhandledrejection 事件
window.addEventListener("unhandledrejection", (event) => {
logger.error("未处理的 Promise 拒绝", event.reason);
// 上报到前端监控系统
});
// 这是"最后一道防线": 它不能修复漏 await, 但能让漏掉的错误被看见
这个全局监听器是"最后一道防线":它治不了"漏 await"这个病根(那要靠 lint),但它能保证即便漏了、即便错误成了未处理拒绝,这个错误也会被记录、被告警,而不是彻底无声无息地消失。对于我那次"错误像人间蒸发"的事故,如果当初有这个监听,至少能在日志里看到那条被吞掉的异常,排查就不会那么抓瞎。好的系统,不仅要努力不出错,更要保证'即便出了错,也一定能被发现'——别让任何一个错误悄无声息。
第六件事:警惕"事务/资源在异步完成前就被收尾"
漏 await 还有一类后果尤其凶险:当它发生在数据库事务、文件操作、资源释放这类有"边界"的上下文里,会导致"边界提前关闭"——异步操作还没真正完成,事务却已经提交、连接却已经归还、资源却已经释放了。这会造成数据不一致、操作丢失等严重问题。
// 反例:事务里漏了 await, 异步写还没完成, 事务就提交了!
async function transfer(tx: Transaction) {
await tx.begin();
updateAccount(tx, from, -100); // 漏了 await! 这个异步更新还没完成
updateAccount(tx, to, +100); // 同样漏了
await tx.commit(); // 事务提交了, 可上面两个更新可能还没落库!
// 结果:事务提交了, 但转账操作可能丢失或不完整 → 数据不一致
}
// 正解:边界内的每个异步操作都必须 await, 确保在收尾前真正完成
async function transfer(tx: Transaction) {
await tx.begin();
await updateAccount(tx, from, -100); // 等它真正完成
await updateAccount(tx, to, +100); // 等它真正完成
await tx.commit(); // 此时两个更新都已完成, 安全提交
}
这个场景的危险在于:漏 await 不只是"乱序"那么简单,它会让本该被某个边界(事务、连接、文件句柄)包裹的异步操作,逃逸到边界之外去执行——而那时边界已经关闭了。这和我们之前聊的"连接在用完前被归还""事务里夹了不该夹的操作"是同源的危害。凡是在有生命周期边界的上下文里写异步代码,务必确保边界内的每一个异步操作都被 await,在边界关闭之前真正完成。到这儿,这次事故的方方面面就齐了。我把排查与防范思路收成一张决策图:
把这套理解建立起来,"漏 await"这类隐蔽的异步 bug 就能被预防、被发现。最后,拧成几条可直接照做的铁律:
- 调用 async 函数 ≠ 等它完成,需要等结果/顺序/错误处理时, 必须写
await。 - 开启
no-floating-promiseslint 规则,让工具强制你对每个 Promise 表态, 杜绝漏 await。 - 故意不等待的 Promise 用
void显式标记,并至少.catch接住它的错误。 - 循环里做异步别用
forEach,顺序用for...of, 并发用map + Promise.all。 - 分清
Promise.all(一败全败)与allSettled(各自独立),按业务语义选。 - 设全局未处理拒绝监听,作为最后防线, 别让漏掉的异步错误无声消失。
- 事务/资源边界内,每个异步操作都要 await,确保在边界关闭前真正完成。
一张 async/await 避坑速查表
把异步代码里这些高频坑汇成一张表,写 async 时对照着查。
| 坑 | 后果 | 对策 |
|---|---|---|
| 调 async 函数漏写 await | 乱序、操作丢失、错误被吞 | 补 await + no-floating-promises |
| forEach 里用 async | 根本不等待, 立刻往下走 | for...of 或 map+Promise.all |
| Promise.all 一个失败 | 整体失败, 其它结果拿不到 | 需独立结果用 allSettled |
| 没 await 的 Promise 抛错 | 未处理拒绝, try/catch 抓不到 | 全局 unhandledRejection 监听 |
| 事务/资源边界内漏 await | 边界提前关闭, 数据不一致 | 边界内每个异步都 await |
| 故意不等待却没标记 | 读者/工具分不清是 bug 还是有意 | 用 void 显式标记并 catch |
一个延伸思考:为什么这个低级错误如此高发
修好之后我反思了一下:漏写 await,论难度,实在是个不能再低级的错误——谁都知道异步要 await。可为什么它如此高发、又如此难防?我想,根源在于 async/await 这套语法设计得"太像同步"了。它最大的优点,是让你能用近乎同步的、顺序的写法去写异步逻辑,极大降低了心智负担;可这份"像同步"的优点,反过来也成了它的陷阱——正因为它看起来那么像普通的顺序代码,你就极容易忘记,它骨子里仍然是异步的,那个不起眼的 await,才是维系"看起来顺序"和"真的顺序"之间的唯一纽带。漏掉它,代码"看起来"还是顺序的,"实际上"却已经脱了线。
这是一个很有意思的现象:一个抽象越是成功地"隐藏"了底层的复杂性,你就越容易忘记那份复杂性的存在,从而在它"漏出来"的那一刻措手不及。这和我们之前聊 LINQ 延迟执行、聊 GC、聊浮点数,其实是同一类故事——便利的抽象,让我们站得更高、走得更快,但也悄悄地让我们对脚下的机制失去了警觉。所以用好 async/await,关键不在于记住多少花哨的 Promise 方法,而在于始终在心里维持一个清醒的认知:我现在写的这行,虽然看起来是顺序的,但它真的是顺序执行的吗?那个 await,我写了吗?把这份对"同步表象下的异步本质"的警觉,变成一种下意识的习惯,你就能稳稳地驾驭这套既强大又微妙的语法。
写在最后
这次"忘了一个 await"的事故,看似只是个微不足道的疏忽,却让我对编程中的"细节"二字,有了近乎敬畏的体会。一个小小的、五个字母的关键字,写或不写,竟能在乱序、脏数据、错误蒸发这些后果之间,划出一道天壤之别。这让我深刻地意识到:在软件的世界里,"灾难"和"正常",常常只隔着一个被遗漏的细节;而很多最磨人的 bug,恰恰不是源于什么难以攻克的复杂逻辑,而是源于这些简单到我们以为'绝不会出错'、因而放松了警惕的地方。越简单、越基础、越"想当然"的地方,越是疏忽的重灾区——因为我们的注意力,本能地都投向了那些看起来更"难"的部分。
而这次经历给我最实在的收获,是它让我更加坚信"工程化"的力量。面对"漏 await"这种"人一定会偶尔犯"的错误,最好的办法,不是要求自己和团队"更细心一点"(人的细心是靠不住的、会疲劳的),而是建立一道工具的防线——用 lint 规则,让这个错误在被写下的那一刻就被自动拦截。这是一种思路的升维:从"依赖个人的自律"到"依赖系统的保障"。一个成熟的工程团队,正是靠着这样一道又一道"把人的疏忽,交给工具去兜底"的防线,才能在成百上千人、成年累月的协作中,把那些"人必然会犯的低级错误",挡在生产环境之外。这次事故于我,是一个朴素却深刻的提醒:真正的可靠,不是建立在'我不会犯错'的自负之上,而是建立在'我一定会犯错,所以我要为犯错建好防线'的清醒之上。愿你我都能怀着这份对细节的敬畏、对自身疏忽的坦诚,去为每一类"人必然会犯的错误",亲手筑起一道工具的、流程的、机制的防线——因为正是这些不起眼的防线,默默地把无数个本可能酿成事故的"忘了一个 await"的瞬间,化解于无形。
如果你手上也有 TypeScript/JavaScript 的异步代码,不妨今天就花二十分钟做三件小事。第一,把 @typescript-eslint/no-floating-promises 规则开成 error 级别,然后跑一遍 lint——你很可能会惊讶地发现,代码里藏着好几个被遗漏的、自己从未察觉的浮动 Promise,逐个补上 await 或显式处理。第二,全局搜一下 forEach(async,这种写法几乎都是 bug,改成 for...of 或 Promise.all。第三,在程序入口加上全局的 unhandledRejection 监听,给所有漏网的异步错误兜个底、留个痕。这三步成本都不高,却能帮你把一整类"乱序、吞错、数据不一致"的隐蔽异步 bug,从源头上摁住。
异步编程,是现代软件绕不开的一座大山。从回调地狱,到 Promise,再到 async/await,我们的工具越来越好用、写法越来越优雅,可异步那份"事情不是按你写的顺序、而是按它自己的节奏发生"的本质,却从未改变。async/await 的伟大,在于它用一层精巧的语法,把这份本质包裹得近乎无形,让我们能像写同步代码一样从容;但也正因如此,它要求我们始终对那层包装之下的真相保有一份清醒——知道每一个 await 都在等待什么、每一个漏掉的 await 都意味着什么。这次"忘了一个 await"的小事故,于我而言,正是对这份清醒的一次珍贵校准。愿你我在享受 async/await 带来的优雅时,都不忘对它背后那个永远在异步流动的世界,保持一份恰如其分的敬畏与掌控。
—— 别看了 · 2026