Node.js 避坑指南(一)
·
Node.js 避坑指南(一)
适用版本:Node.js ≥ 14
建议阅读方式:先完整跑通示例 → 再对照“踩坑现场”→ 最后阅读“总结”加深记忆。
1. 异步≠同步:一把“锁”引发的惨案
| 关键词 | 阻塞事件循环、CPU 密集型、mutex |
|---|
1.1 踩坑现场
// 需求:统计某个目录下所有文件的行数
const fs = require('fs');
const path = require('path');
let totalLines = 0;
function countLines(dir) {
fs.readdirSync(dir).forEach(file => {
const full = path.join(dir, file);
const stat = fs.statSync(full);
if (stat.isDirectory()) return countLines(full);
// 致命:同步读取 + 同步 split
const lines = fs.readFileSync(full, 'utf8').split('\n').length;
totalLines += lines;
});
}
console.time('count');
countLines('./src'); // 1.2 GB 源码
console.timeEnd('count'); // >> 42 s(事件循环被完全阻塞)
现象
- HTTP 接口超时 502
- WebSocket 心跳全部掉线
- 日志时间戳“跳跃” 42 秒
1.2 为什么坑
Node.js 只有一条主线程执行 JavaScript,任何同步的 CPU 耗时操作都会冻结整个事件循环。
误区:“文件系统 API 本来就有 Sync 版本,用起来更方便”→ 在服务端高并发场景下,Sync = 自杀。
1.3 正确姿势
把磁盘 IO + CPU 计算拆成异步流水,再用工作线程消化 CPU。
// line-worker.js
const { parentPort } = require('worker_threads');
parentPort.on('message', buf => {
const lines = buf.toString().split('\n').length;
parentPort.postMessage(lines);
});
// line-counter.js
const fs = require('fs').promises;
const path = require('path');
const { Worker } = require('worker_threads');
const Piscina = require('piscina'); // 线程池包装
const piscina = new Piscina({ filename: require.resolve('./line-worker') });
async function countLines(dir) {
const files = await fs.readdir(dir, { withFileTypes: true });
const promises = files.map(async f => {
const full = path.join(dir, f.name);
if (f.isDirectory()) return countLines(full);
const buf = await fs.readFile(full);
return piscina.run(buf); // 异步丢进线程池
});
const arr = await Promise.all(promises);
return arr.reduce((a, b) => a + b, 0);
}
(async () => {
console.time('count');
console.log('total', await countLines('./src'));
console.timeEnd('count'); // >> 3.8 s(事件循环畅通)
})();
1.4 总结
- 事件循环被阻塞是 Node 里最高危的错,没有之一。
- 磁盘 IO 优先用
*.promises或fs.createReadStream;一旦涉及计算密集,立即切到worker_threads。 - 用
clinic.js/0x火焰图可快速定位“平坦”阻塞段。
2. 默认 undefined 的 SQL 注入:query 拼接的代价
| 关键词 | mysql2、参数化查询、ORM |
|---|
2.1 踩坑现场
// 用户输入:username = "admin'; DROP TABLE users; --"
const sql = `SELECT * FROM users WHERE username='${username}'`;
db.query(sql, (err, rows) => { ... });
2.2 正确实例(mysql2 预处理)
const mysql = require('mysql2/promise');
const pool = mysql.createPool({ host, user, password, database, namedPlaceholders: true });
async function login(username, password) {
const [rows] = await pool.execute(
'SELECT id FROM users WHERE username=:user AND pwd=SHA2(:pwd, 256)',
{ user: username, pwd: password }
);
return rows[0];
}
2.3 总结
- 永远不要把用户输入直接插到 SQL 字面量里。
- 用
?占位符或命名参数,数据库驱动会帮你转义。 - ORM(Prisma、TypeORM、Sequelize)默认预处理,但写原生 SQL 时仍需自觉。
3. 内存泄漏三连:EventEmitter、闭包、全局缓存
| 关键词 | heap dump、gc、WeakMap |
|---|
3.1 踩坑现场
const EventEmitter = require('events');
const ee = new EventEmitter();
app.post('/api/subscribe', (req, res) => {
// 每次请求都注册一个匿名监听器
ee.on('data', data => res.json(data));
// 却从不 remove
});
结果ab -n 10000 -c 100 压测 3 分钟,RSS 从 90 MB 涨到 1.8 GB,最终 OOM。
3.2 正确姿势
const WeakMapEE = new WeakMap(); // key = res, value = 监听器
function onData(res) {
const listener = data => res.json(data);
WeakMapEE.set(res, listener);
ee.on('data', listener);
res.on('close', () => { // 响应结束立即清理
ee.off('data', listener);
WeakMapEE.delete(res);
});
}
app.post('/api/subscribe', (req, res) => onData(res));
3.3 排查利器
$ node --inspect app.js
# Chrome DevTools → Memory → Take heap snapshot
# 搜索 “(string)”、“system / Context” 增长最快的节点
3.4 总结
- EventEmitter 不 remove = 闭包持有 res → res 不释放 → 连接数暴涨。
- 用
WeakMap+res.on('close')做自动绑定与清理。 - 全局 LRU 缓存请用
lru-cache并设置max上限,禁止随意array.push。
4. 浮点数比较:0.1 + 0.2 !== 0.3 的利息计算错误
| 关键词 | Big.js、decimal.js、货币单位 |
|---|
4.1 踩坑现场
const fee = 0.1 + 0.2; // 0.30000000000000004
if (fee === 0.3) { // false
chargeUser(0.3);
} else {
chargeUser(0.30000000000000004); // 用户多扣 1 分钱
}
4.2 正确姿势
const Big = require('big.js');
const fee = Big('0.1').plus('0.2'); // "0.3"
if (fee.eq('0.3')) {
chargeUser(fee.toNumber()); // 0.3
}
最佳实践:金融系统存储与运算一律用整数“分”,仅在展示时 ÷100。
4.3 总结
- IEEE-754 浮点误差不可消除,不要直接比较
===。 - 用
Big.js/decimal.js或原生美分规避。 - 单元测试务必覆盖“边界分”场景(0.01、0.05、0.1)。
5. uncaughtException = 炸弹保留引信
| 关键词 | domain、graceful shutdown、crash-only |
|---|
5.1 踩坑现场
process.on('uncaughtException', err => {
console.error('oops', err);
// 没退出进程,继续服务
});
后续
某次 JSON.parse 抛异常被吞,进程进入半死不活状态:
- 端口还在监听,但业务逻辑已烂
- 健康检查 200,k8s 不重启 → 线上持续 502
5.2 正确姿势
// graceful.js
async function closeServer() {
await Promise.all([
new Promise(r => httpServer.close(r)),
db.end(),
redis.quit()
]);
process.exit(1);
}
process.on('uncaughtException', async err => {
console.error('uncaught', err);
await closeServer(); // 先释放连接,再自杀
});
process.on('unhandledRejection', async reason => {
console.error('unhandled', reason);
await closeServer();
});
k8s / systemd 会帮你重启新实例,实现 crash-only 哲学。
5.3 总结
uncaughtException只能做日志 + 优雅退出,绝不要继续事件循环。- 用
Promise.catch把异步错误收敛到上层。 - 给进程加
--report-on-fatalerror生成诊断报告,方便复盘。
结语
本文给出的 5 个案例只是冰山一角,却覆盖了性能、安全、稳定性、数据精度、容错五大维度。
记住口诀:
阻塞就抛线程,SQL 必占位,内存要弱引用,金钱用 Big,异常快自尽。
后续《Node.js 避坑指南(二)》将聚焦:
- 流反压 & highWaterMark
- ESM vs CommonJS 混搭
- npm 幽灵依赖与 lockfile
- 集群端口抢占 REUSEPORT
- GC 调优与快照分析
敬请期待,Happy Hacking!
更多推荐



所有评论(0)