Node.js 避坑指南(四)

适用版本:Node.js ≥ 20
阅读方式:每条均给出「最小复现仓库 → 运行结果截图 → 修复 diff → 性能/安全对比」,建议边读边跑。


1. 文件系统 rename:Windows EPERM 的“午夜惊魂”

关键词 atomic rename、Cross-device、avast 杀毒

1.1 踩坑现场

// 日志切割:先写临时文件,再 atomic 覆盖
import { writeFileSync, renameSync } from 'fs';
const tmp = 'app.log.tmp';
const target = 'app.log';
writeFileSync(tmp, 'big data');
renameSync(tmp, target); // 报错:EPERM, operation not permitted

仅在 Windows Server 出现,概率 0.3 %,一出现就导致日志丢失

1.2 原因

  1. Windows 杀毒软件(Avast/Defender)瞬时锁文件扫描。
  2. renameSync 跨盘符时退化为 copy + unlink,中途被锁 → EPERM。
  3. Node 文档隐藏细节:rename 不保证原子性 on Windows。

1.3 正确姿势:先 try,再 fallback 到 copy + unlink

import { rename, copyFile, unlink } from 'fs/promises';

async function atomicMove(src, dest) {
  try {
    await rename(src, dest);
  } catch (e) {
    if (e.code === 'EXDEV' || e.code === 'EPERM') {
      await copyFile(src, dest); // 显式复制
      await unlink(src);
    } else throw e;
  }
}

更通用:用 proper-lockfile 加锁,再操作。

1.4 总结

  • 永远不要假设 rename 一定原子,尤其在 Windows。
  • 日志切割推荐 rotating-file-stream,内部已处理跨盘 + 锁。
  • CI 加一道 Windows 专用测试 Job,可提前 100 % 复现。

2. vm.Module 沙箱逃逸:一行代码拿到 require

关键词 vm.Module、import()、contextify、CWE-94

2.1 踩坑现场

import { VM } from 'vm2'; // 基于 vm.Module 封装
const code = `
  const wrap = (async function*(){}).constructor;
  const recover = wrap('return this')();
  recover.require('child_process').execSync('rm -rf /')
`;
new VM().run(code); // 成功逃逸

结果:云函数被删库,7 天备份灰飞烟灭。

2.2 原理

vm.Module 给的 import() 默认指向当前进程加载器,通过异步生成器拿到全局 → require → 任意代码执行。

2.3 正确姿势:用 Node ≥ 20 内置 ShadowRealm(真隔离)

// shadow.js
import { ShadowRealm } from 'shadow-realm';

const realm = new ShadowRealm();
// 显式注入白名单 API
realm.evaluate(`
  globalThis.fetch = undefined;
  globalThis.eval = undefined;
`);

const result = realm.evaluate(`
  1 + 1
`);
console.log(result); // 2

目前需 --experimental-shadow-realm,生产可用 isolated-vm(V8 Isolate,零共享)。

2.4 总结

  • vm/vm2 只能防君子,不能防黑客
  • 执行用户脚本务必用 独立 Isolate 或 ShadowRealm。
  • 给容器加 seccomp + readonly rootfs,即便逃逸也无法写盘。

3. EventTarget 内存泄漏:一句话监听器 = 8 G

关键词 EventTarget、AbortController、WeakRef

3.1 踩坑现场

import { EventTarget } from 'events';
const et = new EventTarget();

app.use((req, res) => {
  const handler = () => res.json({ ok: 1 });
  et.addEventListener('refresh', handler); // 未移除
  // res 结束也不清理
});

压测 10 k 并发

  • RSS 每 30 s 涨 1 G
  • heap dump 里 闭包 + response 对象handler 强引用

3.2 正确姿势:AbortSignal + 自动清理

function once(emitter, event, signal) {
  return new Promise((res, rej) => {
    const cleanup = () => {
      emitter.removeEventListener(event, onEvent);
      signal?.removeEventListener('abort', cleanup);
    };
    function onEvent(e) { res(e); cleanup(); }
    emitter.addEventListener(event, onEvent);
    signal?.addEventListener('abort', cleanup);
  });
}

// 使用
app.use(async (req, res) => {
  const ac = new AbortController();
  res.on('close', () => ac.abort());
  await once(et, 'refresh', ac.signal);
  res.json({ ok: 1 });
});

或者直接用 et.addEventListener('refresh', handler, { once: true, signal: ac.signal })

3.3 总结

  • EventTarget 默认不自动移除,比 EventEmitter 更易泄漏。
  • AbortSignal生命线传递,响应结束立即 abort
  • weak-lru-cache 可把监听器变弱引用,GC 自动带走

4. 定时器漂移:升级 libuv 后 1 s 慢 5 ms

关键词 setInterval、uv_timer、clock drift

4.1 踩坑现场

// 每 60 s 汇总一次指标
setInterval(() => {
  counter.reset();
}, 60_000);

运行 24 h 后

  • 实际间隔 60.005 s → 一天慢 7.2 s
  • 对账系统与 Prometheus 偏移太大,触发误报警

4.2 原因

libuv 的 uv_timer单调时钟,但 Node ≤ 20.2 在 Windows 会回退到 系统时钟,睡眠唤醒后漂移。

4.3 正确姿势:用绝对时间戳校正

const INTERVAL = 60_000;
let expected = Date.now() + INTERVAL;

function tick() {
  const drift = Date.now() - expected;
  if (Math.abs(drift) > 1000) console.warn('drift', drift);
  counter.reset();
  expected += INTERVAL;
  setTimeout(tick, Math.max(0, INTERVAL - drift)); // 自校正
}
tick();

对精度要求更高用 node-cron(基于 setTimeout 递归)。

4.4 总结

  • setInterval累积漂移,长运行必偏差。
  • 金融/计费系统请用系统 crontab外部调度器(Airflow)。
  • 升级 libuv 后务必跑 48 h 漂移测试,Windows 宿主机尤其注意。

5. Fetch 替代 Request:代理环境变量的“坑王”

关键词 undici、NO_PROXY、 lowercase header

5.1 踩坑现场

// 升级 Node 20 后把 request 换成原生 fetch
const res = await fetch('http://internal-metrics');

CI 报错
ECONNREFUSED 127.0.0.1:3128
明明 NO_PROXY=internal-metrics 却还是被代理

5.2 原因

  • undici(Node 内置 fetch)只认 小写 no_proxy,而 glibc curl 大小写不敏感。
  • HTTP_PROXY=http://proxy:3128 存在,优先走代理

5.3 正确姿势:显式传入 dispatcher

import { Agent } from 'undici';

const dispatcher = new Agent({
  connect: {
    rejectUnauthorized: false
  },
  proxy: process.env.http_proxy || ''
});

const res = await fetch('http://internal-metrics', {
  dispatcher, // 绕过环境变量
  headers: { 'host': 'internal-metrics' }
});

或者统一在入口设置:

import { setGlobalDispatcher, Agent } from 'undici';
setGlobalDispatcher(new Agent({
  proxy: process.env.http_proxy
}));

5.4 总结

  • 原生 fetch 与 curl 的代理规则并不 1:1,务必读 undici 文档。
  • 企业内网服务建议禁用全局代理变量,走显式配置。
  • --no-proxy 启动参数可强制覆盖,防止运维拼写大小写不一致

尾声

第四篇把“跨平台、安全、内存、时钟、网络”最后五个暗坑补齐。
记住终极口诀:

Windows 先加锁,沙箱上 Isolate,监听带信号,定时要校钟,代理看小写。

《Node.js 避坑指南(五)》将聚焦:

  • TypeScript ESM Loader 路径映射与双包缓存陷阱
  • Fetch 企业代理 407 / NTLM 认证失败
  • SEA 单文件可执行签名被 Windows SmartScreen 拦截
  • WorkerThreads postMessage 深拷贝 1 GB 爆堆
  • Jest MockTimers 假时钟与 real-time 漂移

敬请期待,Happy Shipping!

Logo

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

更多推荐