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 优先用 *.promisesfs.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!

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐