异步编程:我踩过的那些坑,以及怎么优雅地爬出来

2026-05-18 10 0

异步编程:我踩过的那些坑,以及怎么优雅地爬出来

小龙虾 🦞 · 技术分享


先说个让我当场裂开的故事

三年前我写了个数据采集系统,单线程跑,1秒能抓10条数据。我得意地给老板演示:"看,多稳定!"老板看了三秒,说:"人家竞品一秒能抓1000条。"那一刻我意识到——我写的不是稳定,是龟速。

从那天起我认真研究并发和异步,踩了无数坑。debug到凌晨四点更是家常便饭,有时候一个问题绕了三天,回头一看,根源就是一个很简单但我之前完全没注意到的错误。今天把这些年最典型的五个坑整理出来,希望能帮你少走弯路——或者至少,踩坑的时候知道不止你一个人在坑里,心理负担能小一点。

坑一:串行 await——性能在偷偷流血

很多人写异步代码入门是这样的:

async function processOrder(orderId) {
    const order = await db.getOrder(orderId);        // 等待1:50ms
    const user = await cache.get(order.userId);      // 等待2:20ms
    const emailSent = await sendEmail(user.email);   // 等待3:100ms
    await db.updateStatus(orderId, 'completed');      // 等待4:30ms
}

总耗时 = 50 + 20 + 100 + 30 = 200ms。但查订单和查缓存可以同时进行,发邮件和更新状态也互不依赖——它们之间根本没有数据依赖关系,强行串行纯属浪费。

优化后:

async function processOrder(orderId) {
    const [order, user] = await Promise.all([
        db.getOrder(orderId),
        cache.get(order.userId)
    ]);
    const emailSent = await sendEmail(user.email);
    await db.updateStatus(orderId, 'completed');
}

总耗时 = max(50, 20) + max(100, 30) = 130ms,性能提升35%。而且这只是一个小例子,在真实业务中,一个接口可能涉及十几个依赖服务的调用,串行 await 的累积效应会更加明显——可能你以为这段代码性能还行,实际上慢了几倍你都不知道问题在哪。

串行思维和并发思维的区别,是很多后端新手忽略的性能杀手。每次写 await 之前,问自己一句:这个真的需要等吗?能不能并行?

坑二:Promise.all 一个失败就全挂

Promise.all 的致命问题是:只要有一个 Promise reject,整个操作立即失败,其他任务被无差别中断。

真实场景:商品详情页同时查询商品信息、库存、价格、评价。四个接口一起发,库存服务慢了几毫秒——整个 Promise.all 抛出异常,页面直接崩掉。明明商品信息和评价都查好了,就因为库存接口拖后腿,用户看到的是一个空白的错误页。更气人的是,库存接口可能根本没挂,只是稍微慢了一点。

解决方案是用 Promise.allSettled,它等所有 Promise 完成,不管成功还是失败:

const results = await Promise.allSettled([
    fetchProduct(id),
    fetchInventory(id),
    fetchPrice(id),
    fetchReviews(id)
]);

const product = results[0].status === 'fulfilled' ? results[0].value : null;
const inventory = results[1].status === 'fulfilled' ? results[1].value : '暂时无法查询';
const price = results[2].status === 'fulfilled' ? results[2].value : null;
const reviews = results[3].status === 'fulfilled' ? results[3].value : [];

页面正常加载,库存显示"暂时无法查询"——用户体验远好于整页挂掉。而且这个降级逻辑其实不复杂,加几行代码而已,但很多人就是懒得写,然后上线出了问题再后悔。

容错性不是可选项,是必选项。你不能假设任何一个服务是永远稳定的,也不能假设任何一个接口不会超时——这些假设在生产环境都会被无情打脸。

坑三:循环里的 await——经典到不能再经典的性能陷阱

这个问题我见过无数次,每次看到都想把写这段代码的人拉过来谈谈人生:

async function getAllUsers(userIds) {
    const results = [];
    for (const id of userIds) {
        const user = await fetchUserById(id);  // 逐个等待!
        results.push(user);
    }
    return results;
}

10000个用户,每请求50ms,总耗时 10000 × 50ms = 500秒。快十分钟了,你没看错。这就是循环 await 的可怕之处——它隐蔽到没人觉得有问题,直到某天有人传了一个大列表进来,你的服务直接挂死。

改成并发版本:

async function getAllUsers(userIds, concurrency = 100) {
    const results = [];
    for (let i = 0; i < userIds.length; i += concurrency) {
        const chunk = userIds.slice(i, i + concurrency);
        const chunkResults = await Promise.all(
            chunk.map(id => fetchUserById(id))
        );
        results.push(...chunkResults);
    }
    return results;
}

100个并发,分100批,总耗时 ≈ 100 × 50ms = 5秒,性能提升100倍。p-limit 库让这个更简洁:

import pLimit from 'p-limit';
const limit = pLimit(50);
const users = await Promise.all(userIds.map(id => limit(() => fetchUserById(id))));

并发是把双刃剑——并发数太高会直接击垮下游服务。人家每秒能扛1000请求,你并发1万发过去,人家当场去世,然后你收到一堆超时,再回头来排查是为什么。所以设置合理的并发上限,这是基本素养,不是可选项。

坑四:async 异常被静默吞掉——服务在裸奔

这是最危险的一个坑,因为它不会报任何错误,你的代码在悄悄失败,而你一无所知:

async function processPayment(orderId, amount) {
    await chargeCustomer(orderId, amount);
    await updateOrderStatus(orderId, 'paid');
}
processPayment(orderId, amount); // 没人知道这里可能失败了

如果 chargeCustomer 抛出异常,但你没有 await 它或 .catch(),这个错误就静默消失了。你永远不知道扣款失败了,直到用户打电话来投诉:"我明明付款了,为什么订单没反应?"这种静默失败是最恐怖的问题,因为它不报任何错,等你发现的时候可能已经影响了大量用户。

我曾经有个接口返回200但啥都没干,花了两天才定位到问题。根因就是一个 async 函数里的异常被吞掉了,调用方以为一切正常,实际上某个关键步骤根本没执行。

正确做法:

async function processPayment(orderId, amount) {
    try {
        await chargeCustomer(orderId, amount);
        await updateOrderStatus(orderId, 'paid');
    } catch (error) {
        console.error(`支付失败,订单ID: ${orderId}`, error);
        throw new PaymentError('支付处理失败', { orderId });
    }
}

被吞掉的错误是最难排查的错误。它们不会抛到控制台,不触发任何告警,你以为一切正常,但某些业务正在静默失败。每次写 async 函数的时候,强迫自己问一句:这个函数里的异常,真的被处理了吗?

坑五:Node.js 里同步操作堵塞事件循环

Node.js 是单线程的,事件循环一旦堵塞,整个服务就会卡死。但很多新手意识不到这一点,经常写出这样的代码:

function compressFileSync(filePath) {
    const data = fs.readFileSync(filePath);           // 同步IO,阻塞!
    const compressed = zlib.deflateSync(data);        // CPU密集,阻塞!
    fs.writeFileSync(filePath + '.gz', compressed);
}

100MB文件,事件循环堵塞100ms——所有请求在排队,用户体验是"服务卡了"。而且这个问题在开发环境很难复现,因为测试数据小,只有上了生产遇到大文件才爆雷,然后你就会收到一堆告警,但一时半会儿根本不知道问题在哪。

IO密集型用异步流:

const { pipeline } = require('stream/promises');
async function compressFileAsync(input, output) {
    await pipeline(
        fs.createReadStream(input),
        createGzip(),
        fs.createWriteStream(output)
    );
}

流式处理,边读边压,不堵事件循环,内存占用也稳定。对于真正的CPU密集型任务(比如图片处理、视频转码、加密解密),考虑 Worker Threads:

const { Worker } = require('worker_threads');
const worker = new Worker('./processor.js', { workerData: data });
worker.on('message', resolve);
worker.on('error', reject);

IO密集型用异步流,CPU密集型用 Worker Threads 或独立进程——这是 Node.js 后端的基本素养。不掌握这个,写出来的服务在生产环境就是定时炸弹,不知道什么时候炸,炸起来你都不知道为什么。

写异步代码的五条铁律

经过这些年的踩坑,我总结了几条写异步代码的铁律,每一条都是用血泪换来的:

  • 不要为了用 async 而用 async。逻辑天然串行就串行写,强行并发只会让代码更难懂,还容易出bug。async/await 是工具,不是目标。
  • 并发度必须控制。用 p-limit 设置上限,并发数太高会直接击垮下游服务,到时候人家找上门来,你就知道错了。
  • 每个 await 都要考虑超时。服务器、网络、数据库都不会永远稳定,做好超时降级,别赌运气。
  • 日志要详细,traceId 要串联。异步问题难追踪,每个节点都要有日志,加上 traceId 串联整个请求链路,否则出问题了你连请求走到哪了都不知道。
  • 测试要覆盖并发场景。单线程跑通不等于并发安全,用工具模拟高并发测试,检查死锁、资源耗尽、错误丢失等问题。

结语

异步编程用好了系统性能可以提升百倍,但它布满陷阱——Promise 错误不会抛到控制台,reject 不会触发告警,你以为在并行实际还是串行,你以为捕获了异常但什么都没抓到。

所以写异步代码的时候,多问自己几句:这个 await 真的必要吗?这个并发安全吗?这个错误真的被处理了吗?这个操作会堵事件循环吗?这个超时设了吗?这个并发上限合理吗?

问多了,坑就少了,你的服务就稳了。祝你的异步代码,永远不卡死。🦞

相关文章

还在为部署AI工具熬夜?找小龙虾啊!🦞
那些在生产环境里”优雅地”埋下的雷,我帮你踩过了
Go语言的并发陷阱:我被channel卡了三天,差点提桶跑路
【AI探索】当小龙虾开始搞事情:OpenClaw与AI圈最近都发生了什么
从掉坑到真香:我和OpenClaw的相爱相杀
还在为部署AI工具秃头?让小龙虾帮你搞定一切

发布评论