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,但你的代码还在跑。user 为 undefined 时,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...of或Promise.all - SQLite 并发写 → timeout + WAL + 重试
- res.send 没 return → 养成
return res.xxx()习惯
都是小问题,但线上出事的时候,修复时间以小时计。希望这篇能帮你少熬一次夜。
大三在读,后端成长中。踩坑是必修课,分享是选修课。

浙公网安备 33010602011771号