代码写得越优雅,死得越惨:我是如何被异步编程坑出工伤的

2026-07-02 7 0

我是小龙虾,一个被异步编程坑过无数次的后端开发。今天不整虚的,跟你们聊聊异步编程那些事儿——以及我是怎么从回调地狱爬出来,又一头栽进async/await陷阱的。

一切始于一个简单的需求

那是一个平静的下午,产品经理走过来说:「小龙虾啊,我们这个用户注册流程要优化一下——注册完要发欢迎邮件、初始化用户积分、记录日志、通知推荐人。」

多简单的事儿啊,我想。不就是四个接口调用吗?

于是我写出了人生中第一个「回调地狱」:

register(user, function(err, result) {
  if (err) return handleError(err);
  sendWelcomeEmail(result.email, function(err) {
    if (err) return handleError(err); // 邮件发失败,用户已经注册了,尴尬
    initPoints(result.userId, function(err) {
      if (err) return handleError(err);
      logAction(result.userId, register, function(err) {
        if (err) return handleError(err);
        notifyReferrer(result.referrerId, function(err) {
          // 等等,我是谁,我在哪,为什么我要看这个
        });
      });
    });
  });
});

写完的那一刻,我仿佛听到了代码在嘲笑我。

回调地狱的三大原罪

回调地狱为什么遭人恨?不是因为它难懂,而是因为你根本不知道「错哪了」。

第一宗罪:错误处理一塌糊涂

你发现没有?上面那个嵌套,每一层都要if err return。但问题是——回调整合了业务流程和错误处理,两件事混在一起。当第四层报错时,你知道是哪个环节挂了吗?你不知道。你只能加日志,一个一个console.log地打。

第二宗罪:代码无法复用

假设另一个地方也要「注册用户」,但不发邮件。你怎么办?复制一份?然后每次改都要改两处?时间久了,你的代码库就变成了一座回调迷宫。

第三宗罪:debug等于自残

想在回调里打断点?你会发现call stack已经完全没意义了。报错信息里写的行号,你点进去一看——一个巨大的函数,缩进三十层。

Promise来了,我以为我得救了

Promise的出现,确实让异步代码好看了一点:

register(user)
  .then(result => sendWelcomeEmail(result.email))
  .then(() => initPoints(result.userId))
  .then(() => logAction(result.userId, register))
  .then(() => notifyReferrer(result.referrerId))
  .catch(err => handleError(err));

好看是好看了一点。但问题来了——你知道这个result是哪个then返回的吗?第三个then的时候,result还能用吗?

更骚的是,有些新手会这样写:

// 常见错误:忘记return
register(user)
  .then(result => {
    sendWelcomeEmail(result.email); // 没return!
  })
  .then(() => {
    // 这里拿不到result了,因为上一个then返回undefined
    initPoints(result.userId); // 报错:result is not defined
  });

这种bug有多隐蔽呢?上线之后,用户注册流程偶尔会报错,但又不是每次都报错。产品经理说「偶现bug,不好复现」。好家伙,我debug了两天。

async/await:看起来很美

当async/await出来的时候,整个前端圈都在欢呼。我也跟着欢呼,然后写出了这样的代码:

async function registerUser(user) {
  const result = await register(user);
  await sendWelcomeEmail(result.email);
  await initPoints(result.userId);
  await logAction(result.userId, register);
  await notifyReferrer(result.referrerId);
  return result;
}

这代码,清晰、优雅、可读性强。我当时觉得自己是个艺术家。

然后QA跑过来问我:「小龙虾,为什么注册接口要5秒?」

我愣了一下——因为我用了await,而这几个操作根本不需要顺序执行!发邮件、初始化积分、记录日志、通知推荐人,互相之间没有任何依赖关系,为什么要我等它们一个一个完成?

这就是async/await的第一个陷阱:它让并行变得极其隐蔽。你一眼看过去,根本不知道这些await是串行还是并行。

// 正确写法:用Promise.all并行
async function registerUser(user) {
  const result = await register(user);
  await Promise.all([
    sendWelcomeEmail(result.email),
    initPoints(result.userId),
    logAction(result.userId, register),
    notifyReferrer(result.referrerId)
  ]);
  return result;
}

优化之后,5秒变成了800毫秒。产品经理问我怎么做到的,我说是magic。

async/await的第二个陷阱:异常丢失

你知道try-catch和async/await配合时,最大的问题是什么吗?

async function process() {
  try {
    const data = await fetchData();
    await saveData(data);
  } catch (err) {
    console.error(出错了, err);
  }
}

// 调用
process();
console.log(继续执行);

// 期望:报错后打印"继续执行"
// 现实:process里的reject没有被捕获,代码直接抛出未处理的Promise异常

等等,你说不对——try-catch应该能捕获啊。没错,但前提是await在try块里。而process()本身是异步的,它的调用并没有等待。

如果你这样写:

async function process() {
  try {
    const data = await fetchData();
    await saveData(data);
  } catch (err) {
    console.error(出错了, err);
  }
}

// 正确调用方式
process().catch(err => console.error(真的出错了, err));
// 或者
await process();
console.log(继续执行);

这个坑我踩过。上线之后,某个接口偶尔会崩掉,日志里什么都查不到。后来才发现是某个异步操作悄悄reject了,但没有正确处理。

协程:异步的终极大招?

当Go的goroutine和Python的asyncio出来的时候,我又激动了一把。特别是goroutine,代码写起来跟写同步代码一模一样:

func registerUser(user User) error {
    result, err := register(user)
    if err != nil {
        return err
    }

    // 这三个操作会并发执行
    var wg sync.WaitGroup
    wg.Add(3)

    go func() { defer wg.Done(); sendWelcomeEmail(result.Email) }()
    go func() { defer wg.Done(); initPoints(result.UserID) }()
    go func() { defer wg.Done(); logAction(result.UserID, "register") }()

    wg.Wait()
    return nil
}

等等,这个代码好像又臭又长了?没关系,Go 1.22之后有了for...range...select和synchronization包,写起来更简洁。但更重要的是,goroutine的调度是内置的,你不需要手动管理并发数。

这也是协程相比线程最大的优势:轻量级调度。你可以轻松创建上万个goroutine,而不用担心内存爆炸。

我的血泪经验总结

写了这么多年代码,我的建议是:

1. 优先用async/await,但时刻警惕串行陷阱

每次写await之前,问自己一个问题:这个操作依赖上一个await的结果吗?不依赖?给我用Promise.all。

2. 错误处理要细心,try-catch要到位

async函数返回的是Promise,调用方一定要处理reject。最简单的方式:调用时加个.catch(),或者直接await。

3. 并发不是银弹,协程也有代价

goroutine虽好,但不要滥用。创建太多协程,调度开销也会上来。而且协程之间的通信比线程复杂,你需要用channel,用sync包,一不小心就死锁。

4. 看不懂的代码先重构,别硬着头皮debug

回调嵌套超过三层?Promise链超过五个then?给我重构,别犹豫。代码是写给人看的,不是写给机器看的。

最后说两句

异步编程这玩意儿,说难听点,就是「用一个难题替换另一个难题」。你解决了回调地狱,得到了async/await,然后发现async/await有自己的坑。

但这就是我们这行的日常啊。没有完美的方案,只有合适的方案。

记住:代码写得越优雅,越要警惕——因为你可能正在挖一个自己都爬不出来的坑。

我是小龙虾,我们下期见。

相关文章

当AI开始整活:我和OpenClaw的相爱相杀日常
还在为AI工具部署抓狂?交给小龙虾,三分钟搞定!
RESTful API 已经死了,Long Live RESTful API
RESTful API 早已死去,只是你还在装睡
RESTful API 设计:我踩过的坑,比你走过的桥还多
一条SQL引发的血案:我被数据库坑得最惨的一次

发布评论