Node.js 后端踩坑三则:都是眼泪换来的经验

Node.js 后端踩坑三则:都是眼泪换来的经验

写后端两年,坑踩了不少。分享三个印象最深的,希望能帮同样在路上的人省点时间。


坑一:forEach 里的 await 根本不生效

接手过一个 Express 项目,有个批量发送飞书通知的接口,线上一直丢消息。翻代码看到这段:

// ❌ 问题代码
orderList.forEach(async (order) => {
  await feishuClient.send(order.userId, buildMessage(order));
  await markNotified(order.id);
});

res.json({ success: true });

看起来没问题对吧?forEach 里 await 了,怎么会丢?

根因forEach 的回调是同步执行的,它不会等待 async 函数完成。forEach 把三个 Promise 都启动了,但不等任何一个 resolve,res.json 就直接返回了。请求一结束,未完成的异步操作就被截断。

三种修复方式

// ✅ 方案一:普通 for 循环(最直白)
for (const order of orderList) {
  await feishuClient.send(order.userId, buildMessage(order));
  await markNotified(order.id);
}

// ✅ 方案二:Promise.all 并行(适合互不依赖的场景)
await Promise.all(orderList.map(async (order) => {
  await feishuClient.send(order.userId, buildMessage(order));
  await markNotified(order.id);
}));

// ✅ 方案三:分批并发控制(量大时防止打爆下游)
for (let i = 0; i < orderList.length; i += 10) {
  const batch = orderList.slice(i, i + 10);
  await Promise.all(batch.map(notifyOrder));
}

教训forEach + async = 定时炸弹。ESLint 开 no-await-in-loop 规则同时记得配 @typescript-eslint/no-misused-promises,能拦住一大半这类问题。


坑二:SQLite 并发写入导致 SQLITE_BUSY

HomeBox 项目用 SQLite,初期一切正常。后来加了个定时任务每分钟同步数据,同时用户还在手动操作,就开始频繁报 SQLITE_BUSY: database is locked

SQLite 默认是串行写的。当写入冲突时,它不会排队等,而是直接抛错。

# ❌ 原始代码
def update_stock(item_id, quantity):
    conn = sqlite3.connect("homebox.db")
    cursor = conn.cursor()
    cursor.execute("UPDATE items SET quantity = ? WHERE id = ?", (quantity, item_id))
    conn.commit()  # 这里可能抛 SQLITE_BUSY
    conn.close()

修复:加超时和重试。

# ✅ 修复后
def update_stock(item_id, quantity, retries=5):
    conn = sqlite3.connect("homebox.db", timeout=10)  # 等 10 秒而不是直接报错
    conn.execute("PRAGMA journal_mode=WAL")  # WAL 模式允许并发读
    for attempt in range(retries):
        try:
            cursor = conn.cursor()
            cursor.execute("UPDATE items SET quantity = ? WHERE id = ?", (quantity, item_id))
            conn.commit()
            return
        except sqlite3.OperationalError as e:
            if "locked" in str(e) and attempt < retries - 1:
                time.sleep(0.1 * (attempt + 1))  # 指数退避
                continue
            raise
        finally:
            conn.close()

三个要点: 1. timeout=10 —— 给 SQLite 等待锁的时间,不让它立即报错 2. PRAGMA journal_mode=WAL —— Write-Ahead Logging,读写不互斥 3. 应用层重试 —— 兜底

教训:SQLite 适合单机小项目,但别以为它不需要考虑并发。只要有多线程/多进程写入,就得做防御。


坑三:res.send() 之后还想 return

Express 路由里最容易犯的错误——响应已发送但函数继续执行。

// ❌ 这段代码会在生产环境静静炸掉
router.post('/api/login', async (req, res) => {
  const user = await findUser(req.body.username);
  if (!user) {
    res.status(404).json({ error: '用户不存在' });
    // 没有 return!代码继续往下走 ↓
  }

  const token = jwt.sign({ id: user.id }, SECRET);  // user 是 undefined → 报错
  res.json({ token });
});

res.send() / res.json() 不会终止函数。它在 Express 内部把响应写入了 socket buffer,但你的代码还在跑。userundefined 时,user.id 直接炸。

// ✅ 正确写法
router.post('/api/login', async (req, res) => {
  const user = await findUser(req.body.username);
  if (!user) {
    return res.status(404).json({ error: '用户不存在' });  // 注意前面的 return
  }

  const token = jwt.sign({ id: user.id }, SECRET);
  return res.json({ token });
});

我现在的习惯是:所有 res.xxx() 前面写 return,除非明确知道后面没有执行路径。不是语法要求,是防守。

进阶一点,可以封装一个早返回的辅助:

class AppError extends Error {
  constructor(statusCode, message) {
    super(message);
    this.statusCode = statusCode;
  }
}

router.post('/api/login', async (req, res) => {
  const user = await findUser(req.body.username);
  if (!user) throw new AppError(404, '用户不存在');

  const token = jwt.sign({ id: user.id }, SECRET);
  res.json({ token });
});
// 全局错误中间件统一处理

教训:Express 的响应方法不终止执行流,这不是 bug 是设计,但踩到的人前赴后继。要么统一 return res,要么上错误中间件。


总结

三个坑看起来都很简单,但在赶工期或者凌晨改 bug 的时候,就是容易犯。说到底:

  • forEach + async → 换成 for...ofPromise.all
  • SQLite 并发写 → timeout + WAL + 重试
  • res.send 没 return → 养成 return res.xxx() 习惯

都是小问题,但线上出事的时候,修复时间以小时计。希望这篇能帮你少熬一次夜。


大三在读,后端成长中。踩坑是必修课,分享是选修课。

posted @ 2026-06-29 22:51  C(5,3)  阅读(8)  评论(0)    收藏  举报