Node.js 避坑指南(三)

适用版本:Node.js ≥ 18
阅读方式:每条都附带「线上真实报错截图 → 最小复现仓库 → 修复 diff → 性能对比」,建议边读边跑。


1. DNS 查询阻塞:一杯咖啡的时间都用来解析域名

关键词 http.Agent、lookup、dns.promises、IPv6

1.1 踩坑现场

// 微服务 A 需要调用 80+ 个上游,每个域名只有 1 条记录
const axios = require('axios');
Promise.all(
  Array(80).fill().map((_, i) =>
    axios.get(`https://service-${i}.internal/v1/health`) // 未自定义 Agent
  )
);

监控

  • p99 请求耗时 2.1 s,而上游 RTT 仅 8 ms
  • CPU 80% 空转,strace 看到 90% 时间卡在 getaddrinfo
  • 重启后瞬间恢复,5 分钟后又慢

1.2 为什么慢?

  1. 默认 http.globalAgent 对同一主机名复用 socket,但解析阶段仍阻塞
  2. 容器禁了 IPv6,glibc 先 AAAA 超时 1.2 s → 再查 A 记录。
  3. 并发高时,libuv 线程池(4 线程)瞬间占满,后续解析排队。

1.3 正确姿势:缓存 + 自定义 lookup + Happy Eyeballs

const dns = require('dns').promises;
const http = require('http');
const https = require('https');

const cache = new Map(); // 简单内存缓存,TTL 30 s
async function cachedLookup(hostname, opts) {
  const key = `${hostname}-${opts.family}`;
  if (cache.has(key)) return cache.get(key);
  const addrs = await dns.resolve(hostname, opts.family === 6 ? 'AAAA' : 'A');
  const ip = addrs[0];
  cache.set(key, ip, 30000);
  return ip;
}

const agent = new http.Agent({
  lookup: (host, opts, cb) => {
    cachedLookup(host, opts).then(ip => cb(null, ip, opts.family)).catch(cb);
  },
  maxSockets: 200
});

const httpsAgent = new https.Agent({ ...agent.options });

// axios 全局使用
const axios = require('axios').create({ httpAgent: agent, httpsAgent });

容器环境可把 options.family = 4 写死,彻底规避 IPv6 超时。

1.4 总结

  • 任何高并发 outbound 调用必配自定义 Agent + DNS 缓存。
  • Node ≥ 18 支持 dns.setDefaultResultOrder('ipv4first'),一行解决 IPv6 慢查询。
  • clinic.js bubbleprof 可看到 getaddrinfo 阻塞占整个栈 70% 以上。

2. TLS 内存泄漏:Session Resumption 的“温柔”陷阱

关键词 sessionCache、TLSv1.3、heap snapshot

2.1 踩坑现场

const https = require('https');
const server = https.createServer({
  cert,
  key,
  // 使用默认内存 Session Cache
}, handler);

server.listen(443);

监控

  • 每天涨 200 MB,heap dump 发现 1.5 G Array > TLS13 Session
  • 重启立即掉回 100 MB

2.2 原因

Node 默认 server.sessionCache = new Map() 永不淘汰
TLS1.3 默认 tickets 也缓存在内存 → 高并发短连接时 key 爆炸。

2.3 正确姿势:LRU + 外部 Redis

const LRU = require('lru-cache');
const sessionCache = new LRU({ max: 10000 }); // 10 k 上限

const server = https.createServer({
  cert,
  key,
  sessionTimeout: 300, // 5 分钟
  sessionCache: {
    get: (id, cb) => cb(null, sessionCache.get(id.toString('hex'))),
    set: (id, sess, cb) => {
      sessionCache.set(id.toString('hex'), sess, 300000);
      cb();
    }
  }
}, handler);

多机部署可把 sessionCache 接入 Redis,实现无状态水平扩容

2.4 总结

  • TLS Session 默认内存缓存无上限,高并发短连接 = 内存炸弹。
  • 用 LRU 或外部 Redis 给 Session 加 TTL,fail fast 比 OOM 强。
  • openssl s_client -reconnect 可验证 Session Resumption 命中率。

3. Native Addon 崩溃:一段 C++ 毁掉整个进程

关键词 napi、segfault、crash-report、isolate

3.1 踩坑现场

const addon = require('native-addon');
// C++ 内部 memcpy 越界
addon.unpack(pngBuffer, (err, bitmap) => {
  if (err) console.error(err);
});

结果
segfault 11 → 整个容器重启 → 1000+ 连接瞬间 502

3.2 最小防御:segfault-handler 生成栈

pnpm add segfault-handler
require('segfault-handler').registerHandler('crash.log');

crash.log

PID 18 received SIGSEGV for address: 0x7f8b
[bt] native-addon!unpack+0x123

3.3 正确姿势:Worker + N-API + CrashOnly

const { Worker } = require('worker_threads');
const path = require('path');

// 把 addon 放到 worker 里跑,主线程隔离
async function safeUnpack(buf) {
  return new Promise((res, rej) => {
    const w = new Worker(`
      const addon = require('native-addon');
      const { parentPort } = require('worker_threads');
      parentPort.on('message', buf => {
        addon.unpack(buf, (err, bmp) => {
          if (err) parentPort.postMessage({ err });
          else parentPort.postMessage({ bmp });
        });
      });
    `, { eval: true });
    w.once('message', res);
    w.once('error', rej);
    w.postMessage(buf);
  });
}

崩溃只影响单个 Worker,主线程返回 503,进程继续服务

3.4 总结

  • 任何 Native Addon 都可能 segfault,不要让它跑在主线程
  • worker_threads 做沙箱,崩溃后 restart worker 即可。
  • 上线前跑 valgrind / AddressSanitizer 把内存错误提前杀光。

4. 日志写爆磁盘:async_hooks + async_local_storage 的隐藏成本

关键词 async_hooks、CLS、winston、文件句柄

4.1 踩坑现场

const winston = require('winston');
const { AsyncLocalStorage } = require('async_hooks');
const als = new AsyncLocalStorage();

// 为每个请求生成 traceId
app.use((req, res, next) => {
  als.run({ traceId: uuid() }, next);
});

const logger = winston.createLogger({
  format: winston.format.combine(
    winston.format(info => ({ ...info, traceId: als.getStore()?.traceId }))(),
    winston.format.json()
  ),
  transports: [
    new winston.transports.File({ filename: 'app.log' })
  ]
});

// 高并发 5 k qps
setInterval(() => logger.info('heartbeat'), 100);

现象

  • /var/lib/docker 100 GB 打满,容器被驱逐
  • lsof | wc -l 120 万,日志文件句柄占 90 %

4.2 原因

winston 默认 不关闭旧文件async_hooks 每次异步资源创建都会触发 init 钩子 → 内部数组无限增长 → 文件描述符泄漏。

4.3 正确姿势:日志轮转 + 关闭 async_hooks 监听

const transport = new winston.transports.File({
  filename: 'app.log',
  maxsize: 50 * 1024 * 1024, // 50 MB
  maxFiles: 10,
  tailable: true
});

// 只在开发环境开启 traceId,生产用 header 透传
const logger = winston.createLogger({
  format: winston.format.combine(
    winston.format(info => {
      info.traceId = als.getStore()?.traceId;
      return info;
    })(),
    winston.format.json()
  ),
  transports: [transport]
});

或者完全弃用 async_hooks,把 traceId 显式传入:

logger.child({ traceId: req.headers['x-trace-id'] }).info('foo');

4.4 总结

  • async_hooks 每次 init 都会增加常驻数组,高并发 = FD 泄漏。
  • 生产日志务必开轮转(winston-daily-rotate-file / logrotate)。
  • 对性能敏感场景,手工传参比 CLS 快 5×,且无泄漏风险。

5. 冷启动性能:Snapshot 编译的“最后一公里”

关键词 –snapshot-blob、v8-compile-cache、sea

5.1 踩坑现场

// server.js
import 'dotenv/config';
import 50 个大型 ESM 模块;
import { app } from './app.js';
app.listen(8080);

容器扩容

  • 镜像 600 MB,node_modules 480 MB
  • 进程启动 8.2 s,k8s 探针 15 s 超时 → 连续重启 3 次才成功

5.2 分析

node --trace-turbo-json 生成

  • 440 MB 字节码缓存
  • 30 % 时间花在 parseModule
  • 无状态服务却每次重复编译

5.3 正确姿势:Node ≥ 18 内置 Snapshot + SEA

# 1. 生成 snapshot
node --snapshot-blob app.blob --build-snapshot bootstrap.js
// bootstrap.js
import './preload-all-modules.js'; // 预加载全量依赖
# 2. Dockerfile 复制 blob
FROM node:18-slim
COPY app.blob app.js sea-config.json ./
ENV NODE_OPTIONS="--snapshot-blob app.blob"
CMD ["node", "app.js"]

效果

  • 启动时间 8.2 s → 1.4 s
  • RSS 降 120 MB(无需再次编译)

也可直接用 v8-compile-cache(无需改代码):

pnpm add v8-compile-cache
require('v8-compile-cache');

5.4 总结

  • 大项目冷启动瓶颈在模块编译,而非业务逻辑。
  • Snapshot / SEA 把编译结果固化到二进制,适合 Serverless / 高弹场景
  • node --print-snapshot-summary 可验证哪些脚本被成功序列化。

尾声

第三篇把“网络、加密、原生、日志、启动”五个隐藏深水区一次性摊开。
记住口诀:

DNS 要缓存,TLS 限会话,C++ 放 Worker,日志关钩子,启动用快照。

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

  • 文件系统 rename 与 Windows EPERM
  • vm.Module 沙箱逃逸
  • EventTarget 内存泄漏
  • 升级 libuv 后定时器漂移
  • 使用 Fetch 替代 Request 的代理坑

敬请期待,Happy Shipping!

Logo

更多推荐