重新审视 JavaScript 中的异步循环(贴合实际业务)
为什么"在循环里用 await"常让系统变慢?
在 for/for...of 循环里直接 await,会让每一步串行 执行:下一个请求必须等上一个完成,独立的网络调用因此被迫排队,吞吐量下降。相反,在 map() 里用 async 却不会等待 ,你得到的是一组 Promise,若不进一步汇聚,就会产生"已经结束但任务仍在后台跑"的错觉。citeturn1search1turn1search10turn1search29
结论:要么有意识地串行 (确有顺序或限速要求),要么明确地并行 (如
Promise.all/受控并发);避免模棱两可的写法。citeturn1search1
三种主流执行模式
1) 串行:for...of + await
适用:后一步依赖上一步;需要严格顺序;受供应商 API 严格限速(如每秒 1 次)。
ts
for (const id of orderIds) {
const order = await getOrder(id); // 依赖顺序
const enriched = await enrich(order); // 逐步加工
await persist(enriched);
}
这类写法最直观、可控、易于断点调试,但对独立 I/O 来说性能最慢。
2) 并行:Promise.all(users.map(...))
适用 :各任务彼此独立,无需保持顺序,例如并发拉取 100 个客户资料 或批量生成报表。
ts
const results = await Promise.all(
customerIds.map(id => fetchCustomerProfile(id))
);
注意 Promise.all() "失败即全部失败":任一子任务拒绝即整体 reject,需要配合边界处理。
更安全的并行:Promise.allSettled()
保留所有结果(成功/失败),适合批处理 与部分成功可接受的业务(如批量发券、推送)。
ts
const settled = await Promise.allSettled(tasks);
const ok = settled.filter(r => r.status === 'fulfilled').map(r => r.value);
const bad = settled.filter(r => r.status === 'rejected').map(r => r.reason);
allSettled() 等待全部任务落定,不会因为单次失败提前退出。
3) 受控并发:p-limit
适用 :既要加速,又要尊重限流/连接数(如支付、物流、第三方风控)。
ts
import pLimit from 'p-limit';
const limit = pLimit(5); // 最多并行 5 个
const tasks = orderIds.map(id => limit(() => processOrder(id)));
const results = await Promise.all(tasks);
p-limit 通过"令牌桶"式并发上限,避免把上游打垮或把本机线程池占满。
快速选型:顺序 →
for...of;极速并行 →Promise.all;限流 →p-limit。
常见误用与坑
forEach+async:forEach不等待异步回调,外层函数早结束,错误与资源泄露被"吞掉"。请改为for...of或Promise.all(map(...))。citeturn1search1turn1search29map(async ...)后不汇聚 :得到的是 Promise[] 而非数据;要await Promise.all(...)或allSettled(...)。citeturn1search1- 无上限的"全并发" :容易触发供应商限流、雪崩或本机资源耗尽;请用
p-limit/批处理。
业务级健壮性(必做项)
1) 超时、取消与重试
ts
// 简易超时包装
const withTimeout = (p, ms) => new Promise((res, rej) => {
const t = setTimeout(() => rej(new Error(`Timeout ${ms}ms`)), ms);
p.finally(() => clearTimeout(t)).then(res, rej);
});
// 结合 AbortController 取消(浏览器/Node18+)
const controller = new AbortController();
const resp = await withTimeout(fetch(url, { signal: controller.signal }), 5000);
// controller.abort(); // 需要时取消
// 指数退避重试(只对可重试错误)
async function retry(fn, times = 3) {
let i = 0; let err;
while (i < times) {
try { return await fn(); } catch (e) {
err = e; await new Promise(r => setTimeout(r, 2 ** i * 200)); i++;
}
}
throw err;
}
与 Promise.all/Settled 组合能显著提升批处理成功率与可观测性。
2) 幂等与去重
在 支付、发货、开票 等场景,务必给任务加幂等键(如 orderId#step),避免并行与重试导致的重复扣费/重复推送 。这一点与并发模式无关,但与并发越大越重要。(业务通用原则)
3) 可观测性
为每个批次记录:并发度、成功/失败数、耗时分位数、重试次数 ,并对失败样本做原因分类 (5xx/4xx/解析/超时/取消)。(工程最佳实践)
面向海量数据:批处理、流与异步可迭代
- 批处理 :按 50~200 的 batch 大小切片 +
p-limit,通常能在吞吐与稳定性间取到好平衡。 for‑await‑of:消费异步可迭代(如分页 API、Node.js 流)。便于"边到边处理",控制内存占用。Array.fromAsync():把(异步)可迭代转为数组,内部顺序地等待每个元素 ;而Promise.all是并发等待。用于需要"逐个确认、有节奏地拉取"的场景。citeturn1search16
ts
// 例:分页抓取→顺序聚合(避免瞬时压垮后端)
async function* pages() { let page = 1; while (true) {
const res = await fetch(`/invoices?page=${page}`);
if (!res.ok) break; yield res.json(); page++;
}}
const all = await Array.fromAsync(pages(), p => p); // 顺序消费
Array.fromAsync 与 Promise.all 的差异与取舍,详见 MDN。
贴合业务的示例片段
A. 受第三方 API 限流的客户资料同步
ts
import pLimit from 'p-limit';
const limit = pLimit(8); // 第三方给的窗口:每秒 10 次,这里预留安全边际
const tasks = customers.map(c => limit(() => retry(() => fetchCrm(c.id))));
const settled = await Promise.allSettled(tasks);
const ok = settled.filter(x => x.status === 'fulfilled').length;
const fail = settled.length - ok;
并发受控、失败不拖累整体,失败项可落盘重试。
B. 订单批量开票(保持顺序)
ts
for (const id of orderIds) {
const order = await getOrder(id);
const pdf = await buildInvoicePdf(order);
await uploadToStorage(pdf, `${id}.pdf`);
}
订单号与发票号需一一对应、可审计,故选串行。
C. 报表并行渲染(失败可容忍)
ts
const jobs = views.map(view => renderReport(view));
const result = await Promise.allSettled(jobs);
const okReports = result.filter(x => x.status === 'fulfilled');
某些报表失败不影响其他成功交付,用 allSettled 收敛更稳妥。
速查清单 / 决策树
- 是否必须保持顺序或严格限速? 是 →
for...of + await;否 → 2。citeturn1search40 - 是否需要尽快完成且任务彼此独立? 是 →
Promise.all(map(...));否 → 3。citeturn1search10 - 是否担心压垮上游或自身? 是 →
p-limit设定并发上限;否 → 直接Promise.all。 - 是否需要"全部结果都要拿到"用于汇总/审计? 用
Promise.allSettled。citeturn1search34 - 数据像"流水"般到达? 用
for‑await‑of/Array.fromAsync。