
「导入成功 3000 条」弹出来的那一秒,数据库里一条都没有
一个 forEach,让接口撒了个谎。这个坑我在 code review 里见了两年,今天连修三版讲透。
周四下午三点,运营小李在后台传了个 3000 行的客户 Excel,点「开始导入」。
不到一秒,弹窗跳出来:导入成功,共 3000 条。
小李截图发群里,配文「新系统真快 🚀」。
二十分钟后,客服群炸了。客户说没收到欢迎短信,销售说客户列表里搜不到人。小李自己刷新了一下后台,更诡异——数据又慢慢变多了,从 800 涨到 1600,再涨到 2400,像挤牙膏。
我拉出那段导入代码,就五行,干干净净:
jscustomers.forEach(async (customer) => {
await customerRepository.insert(customer); // 写进数据库
await smsService.sendWelcome(customer); // 发欢迎短信
});
return { imported: customers.length }; // 告诉前端:导完了,3000 条有 async,有 await,每一条都遍历到了。这段代码过了 review,过了单元测试,也过了测试环境——因为测试环境只导 10 条,快到看不出问题。
它唯一的问题是:它在撒谎。
它撒的不是「代码写错了」这种谎,而是**「这件事什么时候算做完了」**这种谎。
一、forEach 从头到尾就没打算等你
先把这件事讲透,因为后面所有的坑都从这儿长出来。
有个前提你得先接受:async 函数永远返回一个 Promise。
不管你在里面写了什么,只要函数前面挂了 async,它的返回值就一定是个 Promise——你可以把 Promise 理解成一张「取货凭条」:活儿还没干完,先给你张条,凭条以后能拿到结果。
所以上面那段代码里,回调每被调用一次,就产生一张凭条:「这条客户的导入,回头找我拿结果」。
而 forEach 对这些凭条的态度是——扔掉。
它不收集,不合并,不等待,也不把凭条交给外层函数。它干的事,本质上跟这段一模一样:
jsfor (let i = 0; i < customers.length; i++) {
callback(customers[i]); // 返回值?什么返回值,我不看
}打个比方。你是包工头,手上 3000 张工单。forEach 的做法是:拿着单子挨个冲进工人休息室,把单子往桌上一拍,转身就走,连头都不回。3000 张拍完,你回办公室拿起对讲机说:「今天的活儿全干完了。」
工人还在干呢。你一个都没等。
code你以为的 实际发生的
───────── ──────────
insert(客户1) insert(客户1) 发起 ┐
等 ... insert(客户2) 发起 │ 几乎在
insert(客户2) insert(客户3) 发起 │ 同一瞬间
等 ... ... ┘
insert(客户3) return { imported: 3000 }
等 ... ↓
return 3000 ← 接口在这里就返回了
(3000 条还在路上跑)再说个更隐蔽的点:回调是按顺序发起的,但异步操作可以按任意顺序完成。第一条客户所在的数据库刚好卡了一下,它完全可能排在第三条后面才写进去。
发起顺序 ≠ 完成顺序。 这句话你先记住,很多玄学 bug 的根都在这儿。
外面调用它的人,一样被骗:
jsawait importCustomers(customers);
console.log('导入完毕,开始清缓存'); // 这时候一条都还没写进去这里的 await 等的是 importCustomers 返回的那张凭条,而这张凭条在 forEach 那一圈跑完的瞬间就兑现了,跟里面 3000 个写库操作半毛钱关系没有。
一句话记住:
await只在「它等的那张凭条属于当前这条链子」时,才真的在等。
把 await 塞进回调里,不会让外面的循环变成等待。那张凭条必须被 return 出来、被数组收走、或者被真正管这件事的人直接 await——链子断在哪一环,谎话就从哪一环开始。
二、这个 bug 从来不在循环里爆炸
小李那次,最难受的不是数据慢,是没人知道该查哪儿。
因为循环本身看起来毫无问题。爆炸点在很远的地方:
- 接口早返回了:前端拿到「3000 条」,弹「成功」,运营去泡咖啡了
- 缓存清早了:导入完顺手清了列表缓存,可清的时候数据还没写完,前台又把「旧数据」重新存了一遍缓存,冷了二十分钟
- 通知下游太早:「新客户自动分配销售」的任务跑起来时,查到的是半截数据
- 报表对不上:当天新增客户少了四百,第二天自己又对上了
- 测试玄学:本地跑十次过九次,失败那次死活复现不了
我见过最离谱的一次排查,团队花了一下午怀疑数据库、怀疑网络抖动,最后问题在一个谁都懒得多看两眼的 forEach 上。
这类 bug 的现场,永远在「系统以为活儿干完了」的那个边界,而不是活儿开始的地方。
三、更狠的:try/catch 一条日志都没留下
小李那次事故还有个细节——日志里干干净净,一条错误都没有。
可代码明明包了 try/catch:
jsasync function notifyCustomers(customers) {
try {
customers.forEach(async (customer) => {
await smsService.sendWelcome(customer);
});
} catch (error) {
logger.error('短信发送失败', { error }); // 从来没进来过
}
}为什么抓不到?
因为在 async 函数里抛出的错误,不会当场炸出来,它会变成「一张作废的凭条」——过一会儿才告诉你「这单没办成」。而 forEach 早就把凭条扔了,等作废消息传回来的时候,外层那个 try 块已经执行完、退出了。
用大白话说就是:你伸手去接的时候,球还没扔出来;球落地的时候,你人已经走了。
code你以为:
短信接口报错 ──→ 外层 catch 接住 ──→ 打日志 ──→ 安排重试
实际是:
回调返回一张凭条
│
▼
forEach 把凭条丢掉 ← 链子断在这
│
▼
外层函数继续走,try 块已经结束
│
▼
凭条稍后作废(报错)
│
▼
没人接手 → 运气好打进进程日志,运气不好直接消失于是你得到:错误日志缺一半、接口返回假成功、重试逻辑压根没触发。
这里有条比例子更重要的原则:
一个操作,没法处理它从来没等过的活儿出的错。
异步工作是需要主人的。得有人对它的完成、失败、重试负责。当凭条被无意中开出来、又被无意中丢掉,这个主人就消失了。
这不是「代码风格不够优雅」,这是一次操作的成功和失败边界,在代码里已经看不见了。
四、第一版修复:全改串行,然后运营等了 11 分钟
知道原因,改起来很快。网上最常见的答案是「把 forEach 换成 for...of」:
jsfor (const customer of customers) {
await customerRepository.insert(customer);
await smsService.sendWelcome(customer);
}为什么这样就行了?因为 for...of 是普通循环,await 直接写在函数体里,属于这条链子。它会老老实实停在这一行,等这条客户处理完,再进下一圈。
上线,接口老老实实转圈,转完再返回,数据一条不少。
然后小李跑来找我:「导个 3000 条要转 11 分钟,中间浏览器还超时了。」
对。串行的代价就是——明明能一起干的活儿,非得排队。3000 条,每条 200 毫秒,那就是 10 分钟起步,网关那边 60 秒就把连接掐了。
所以 for...of 不是万能答案,它是有条件的答案。什么时候它是对的:
- 顺序有意义:得先建订单主表,再建订单明细,反了就是脏数据
- 后一步依赖前一步:拿到上一步返回的 ID 才能继续
- 大家在改同一个东西:比如都在扣同一件商品的库存,一起上会算错
- 对方限速:微信、支付宝这类接口,一秒超几次就直接把你拒了
- 错了就该停:比如数据迁移脚本,出错得赶紧停下人工看
小李这个场景,3000 条客户互相独立,谁先谁后无所谓。串行纯属自找罪受。
五、第二版修复:全并发,然后 DBA 半夜给我打电话
那就一起跑呗:
jsawait Promise.all(
customers.map((customer) => importOne(customer)),
);先说这版对在哪:map 会把每张凭条收进数组(注意,map 有返回值,forEach 没有,这是关键区别),Promise.all 把这一叠凭条合成一张大凭条,外层 await 等的就是这张大的。
断掉的链子,重新接上了。
3000 条,8 秒跑完。小李很满意。
我也很满意,直到那天凌晨一点,DBA(管数据库的同事)在群里 @ 全体成员:「谁在打库?连接全占满了,线上下单接口在超时。」
问题出在:Promise.all 干的事,是把 3000 个活儿在同一瞬间全部发起。
它不排队,它没有闸门,它就是把所有单子一次性全拍出去。
数据库同时只能接待固定数量的连接(一般几十个),你一口气涌进去 3000 个请求,后果是这一整套组合拳:
- 数据库连接被占满,连带把正常业务的接口一起拖死
- 第三方接口直接拒绝你(就是那个 429,意思是「你太快了,别打了」)
- 内存飙升——3000 个操作、3000 份数据同时挂在内存里
- 下游服务被你打挂,然后变成别人的事故
代码现在等对了,但一次放多少活儿进去,仍然是错的。
所以 review 时真正该问的,从来不是「你换掉 forEach 了吗」,而是:
这个系统,同一时刻能安全扛住多少并发?
六、第三版:给并发装个闸门
最简单的办法是分批——一次放 20 个进去,这 20 个跑完再放下一批:
js// JavaScript
export async function processInBatches(items, batchSize, handle) {
if (!Number.isInteger(batchSize) || batchSize <= 0) {
throw new RangeError('batchSize 必须是正整数');
}
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize); // 切出这一批
await Promise.all(batch.map(handle)); // 这批跑完才进下一圈
}
}ts// TypeScript
export async function processInBatches<T>(
items: T[],
batchSize: number,
handle: (item: T) => Promise<void>,
): Promise<void> {
if (!Number.isInteger(batchSize) || batchSize <= 0) {
throw new RangeError('batchSize 必须是正整数');
}
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
await Promise.all(batch.map(handle));
}
}用起来就一行:
jsawait processInBatches(customers, 20, importOne);外层是 for 循环(串行,一批一批来),内层是 Promise.all(这批内部一起跑)。两者套在一起,就是「限量放行」。
分批的毛病也很实在:每批都得等最慢的那个。一批 20 个,19 个 50 毫秒跑完,1 个卡了 3 秒,这批就是 3 秒——那 3 秒里,另外 19 条通道全闲着看戏。
想让「有一个干完就立刻补一个进来」,得用并发限流:
js// JavaScript
export async function mapWithLimit(items, limit, task) {
if (!Number.isInteger(limit) || limit <= 0) {
throw new RangeError('limit 必须是正整数');
}
const results = new Array(items.length);
let cursor = 0; // 共用的「取号机」,指向下一个待处理的下标
// 雇 limit 个工人,每个工人循环取号、干活,直到号取完
const workers = Array.from(
{ length: Math.min(limit, items.length) },
async () => {
while (cursor < items.length) {
const index = cursor++;
results[index] = await task(items[index], index);
}
},
);
await Promise.all(workers); // 等所有工人下班
return results;
}ts// TypeScript
export async function mapWithLimit<T, R>(
items: T[],
limit: number,
task: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
if (!Number.isInteger(limit) || limit <= 0) {
throw new RangeError('limit 必须是正整数');
}
const results = new Array<R>(items.length);
let cursor = 0;
const workers = Array.from(
{ length: Math.min(limit, items.length) },
async () => {
while (cursor < items.length) {
const index = cursor++;
results[index] = await task(items[index], index);
}
},
);
await Promise.all(workers);
return results;
}思路特别朴素:雇 limit 个工人,共用一个取号机,谁先干完谁去拿下一个号。
就像银行开 4 个窗口,来一百个人也只有 4 个人在被服务,队伍慢慢往前挪,谁都不会挤爆大厅。
结果按原来的下标存进数组,所以返回顺序和输入顺序一致,不用自己再排一次。
嫌麻烦就直接装个 p-limit,一个意思。
那 limit 到底设多少? 没有标准答案,但有可用的经验值:
| 场景 | 建议起点 |
|---|---|
| 打自己的数据库 | 连接数上限的一半(能开 20 个连接就设 10) |
| 打第三方接口 | 看对方文档写的每秒上限,往下打 5-7 折 |
| 纯计算(加密、压图) | CPU 核数 |
| 打公司内部服务 | 从 10 开始,压测再往上加 |
正确的异步代码不只是「会等」,它还得只用系统吃得消的那点并发。
七、最后一个坑:一个脏手机号,不该毁掉另外 2999 条
限流上了,DBA 不打电话了。然后小李又来了:
「我导 3000 条,里面有一条手机号是乱填的,结果整个导入直接失败了?」
对。Promise.all 有个脾气:只要里面有一个失败,整体立刻失败。
而且这里有个特别多人误解的点——它不会去取消其他操作。第二个失败了,Promise.all 立刻报错,但第一个可能早就成功写进数据库了,第三个还在跑。
我实际跑过验证:一个任务抛错后,其余的工人照样把队列跑完。这不是 bug,Promise.all 只是个「汇总员」,它负责报告结果,没有权力叫停任何人。
所以真要「要么全成功要么全不算」,得靠别的东西:数据库事务、失败后的补偿操作、对账脚本。
但小李这个场景不需要那么重。部分成功完全可以接受——一个脏手机号,不该拖累另外 2999 个人收不到短信。运营真正想要的其实很朴素:
告诉我哪几条没成、为什么没成,我改完重导那几条就行。
这时候该换 Promise.allSettled 上场。它跟 Promise.all 就一个区别:不管成没成,全都跑完,然后把每一项的结果原样交给你。
jsconst results = await Promise.allSettled(
customers.map((customer) => importOne(customer)),
);
// results 里每一项长这样:
// 成功 → { status: 'fulfilled', value: ... }
// 失败 → { status: 'rejected', reason: Error }
const failures = results
.map((result, index) => ({ result, customer: customers[index] }))
.filter(({ result }) => result.status === 'rejected')
.map(({ result, customer }) => ({
row: customer.rowNumber, // Excel 第几行,运营才看得懂
name: customer.name,
reason: result.reason.message,
}));
return {
imported: customers.length - failures.length,
failed: failures, // 直接回给前端,做成「失败明细」下载
};现在这个接口能说人话了:
成了 2999 条,失败 1 条,第 847 行「张伟」手机号格式错误。
运营改完那一行,重导一次就完事。
这才叫拥有这次操作。
整套选择压成一张图,贴脑子里:
code要不要等它做完?
否 ──→ 明确写成「故意不等」,或者丢进队列
是 ──→ 顺序有依赖 / 第一个失败就该停?
是 ──→ for...of
否 ──→ 量大吗?(上千条 / 对方有限速)
是 ──→ 分批 or 并发限流
否 ──→ 每一项都要试完、且要各自结果?
是 ──→ Promise.allSettled
否 ──→ Promise.all循环决定的不只是执行顺序,它决定了失败以什么样子出现在用户面前。
八、埋点那种「不等」,请写得像是故意的
也不是所有异步都得等。有些确实发起完就不管了,比如埋点统计:
jsvoid analytics
.track('order_paid', { orderId, userId })
.catch((error) => {
logger.warn('埋点上报失败', { orderId, error });
});前面那个 void 不会让操作更可靠,它一行代码的价值全在表态:这张凭条我是故意不等的。
对比一下这个:
jsusers.forEach(async (user) => {
await analytics.trackUser(user);
});前者把四件事说清楚了:调用方不等、这活儿不关键、失败在本地打个日志就行、脱钩是有意的。后者把这四件事全留成悬案,review 的人只能靠猜,而且大概率会猜成「他忘了写」。
不过别高兴太早,有意的「发完就不管」也会丢活儿:进程重启,活儿永远做不完;服务器被回收,操作直接蒸发。
送达这件事要是真重要,老老实实丢队列:
jsawait jobQueue.add('send-welcome-sms', { customerId: customer.id });请求只等到「任务已经存下来了」就行,不用等整个后台流程跑完。之后的重试、失败追踪、限速,全归后台 worker 管。
「发完就不管」不是重要工作的省事写法,它是一次明确的取舍。
说到底,是先挑了语法,才想语义
回到最开始那五行。被人点破之后,其实一秒就能认出来:
jsitems.forEach(async (item) => {
await processItem(item);
});回调开出凭条,forEach 把凭条扔了,外层函数在活儿干完之前就往下走了。
但把这条规则背下来是不够的。小李那次事故,我们连改三版才改对——不理解这次操作到底要什么就贸然换循环,往往只是把 bug 换个地方爆炸:
for...of让本来能并行的活儿慢了 80 倍,网关超时Promise.all在数据量涨上来那天把数据库打穿Promise.allSettled会放过一个本该中止整个流程的失败- 「发完就不管」会丢掉业务上根本丢不起的活儿
所以现在做 review,我要问的比「有没有 await」多得多:
调用方需要等吗?顺序重要吗?一次跑多少是安全的?一个失败该不该终止全部?是不是每一项都必须尝试?谁负责重试?服务重启了这活儿还在不在?以及最关键的一条——
这个操作,从哪一刻起才有资格宣称自己完成了?
这些都不是语法问题。它们是系统对外许下的承诺:关于完成,关于失败,关于顺序,关于可靠性。
这也是为什么我会一遍一遍在 code review 里揪这个模式。问题不只是 forEach 不等它的回调——
问题是这段代码替系统做了一个选择,却假装自己没做过选择。
聊两句 👇
我猜不少人跟小李他们一样,是线上翻过车之后才认识这个坑的。
说说你是怎么发现的? 是报表数字对不上、缓存清早了、接口返回成功但数据只写进去一半,还是像小李那样,眼睁睁看着列表数字自己往上涨?
如果这篇对你有帮助,欢迎关注公众号「前端达人」,每周更新实用前端干货。

