# Node.js 服务端内存详解

如果你的游戏后端是 Node.js,这些知识你必须掌握

前置知识:2_1_v8Learn(V8 内存模型)+ 第05章(浏览器全链路内存)+ 第07章(内存泄漏排查)


阅读指南(初学者必看)

为什么游戏开发者需要学 Node.js 内存?

不是所有游戏都用 Java 做后端。很多 H5 游戏的后端就是 Node.js:

  • 实时通信服务器(Socket.IO)
  • 游戏网关服务器
  • API 聚合层
  • 小型游戏的全栈后端

Node.js 和浏览器共享 V8 引擎,所以第05章和第07章的知识直接适用。但 Node.js 也有一些特殊之处:

  • 没有 DOM 和 GPU 显存,但有 BufferStream
  • 可以手动触发 GC(global.gc()
  • 可以调节 V8 堆大小(--max-old-space-size
  • 长时间运行的服务进程,泄漏更严重

学完本章,你能回答:

  • process.memoryUsage() 各字段含义
  • 为什么 Buffer 分配在 V8 堆外
  • Stream 背压是什么,为什么重要
  • Node.js 服务端内存监控和调优

本文结构

第一部分:process.memoryUsage() 详解
第二部分:V8 堆大小调优(--max-old-space-size)
第三部分:Buffer 的堆外内存
第四部分:Stream 背压与内存控制
第五部分:Node.js 服务端内存监控与实战排查

一、process.memoryUsage() 详解

各字段含义

process.memoryUsage()
// {
//   rss: 150MB,           // 驻留集大小(Resident Set Size)
//   heapTotal: 80MB,      // V8 堆总大小(已申请的)
//   heapUsed: 60MB,       // V8 堆已使用
//   external: 10MB,       // C++ 对象占用的内存
//   arrayBuffers: 5MB,    // ArrayBuffer 分配的内存
// }

生活类比

  • rss = 整栋楼的建筑面积(包含公共区域、走廊、电梯间)
  • heapTotal = 你租的仓库总面积(包含空位)
  • heapUsed = 仓库里实际堆了东西的面积
  • external = 不在仓库里但属于你的东西(停在楼外的卡车)
  • arrayBuffers = 卡车上的货物(ArrayBuffer 数据)

各字段的关系

rss(驻留集)
├── V8 堆
│   ├── heapUsed(已使用)
│   └── heapTotal - heapUsed(空闲但已申请)
├── C++ 对象(external 的一部分)
├── ArrayBuffer 数据
├── 代码段(Node.js 自身 + 模块)
└── 栈内存(每个线程的调用栈)

实时监控脚本

// 内存监控工具
function startMemoryMonitor(interval = 5000) {
  const baseline = process.memoryUsage();

  setInterval(() => {
    const current = process.memoryUsage();
    const diff = {
      rss: current.rss - baseline.rss,
      heapUsed: current.heapUsed - baseline.heapUsed,
      external: current.external - baseline.external,
    };

    console.log(`[内存] RSS: ${mb(current.rss)} | Heap: ${mb(current.heapUsed)}/${mb(current.heapTotal)} | External: ${mb(current.external)}`);
    
    if (diff.heapUsed > 100 * 1024 * 1024) { // 增长超过 100MB
      console.warn(`⚠️ 堆内存增长异常: +${mb(diff.heapUsed)}`);
    }
  }, interval);
}

function mb(bytes) {
  return (bytes / 1024 / 1024).toFixed(1) + 'MB';
}

startMemoryMonitor();

与浏览器内存指标的对比

Node.js 浏览器 说明
rss Memory Footprint 进程总物理内存
heapTotal V8 堆已申请的总大小
heapUsed JS Heap Size V8 堆已使用大小
external C++ 对象内存
GPU Memory Node.js 没有 GPU 显存
DOM 节点 Node.js 没有 DOM

二、V8 堆大小调优

--max-old-space-size

# 默认 V8 老生代最大约 1.5GB(64位系统)
# 游戏服务器可能需要更大
node --max-old-space-size=4096 server.js  # 设置老生代最大 4GB

什么时候需要调大?

场景 原因 建议
加载大量游戏配置 配置数据全部在内存 根据配置大小设置
大量玩家在线数据 每个玩家几KB~几MB 根据在线人数估算
计算密集型任务 产生大量临时对象 先优化算法再调大
Buffer 密集操作 Buffer 在堆外,不太影响 不需要调大

估算示例

游戏配置:50MB
1000 个在线玩家数据:1000 × 100KB = 100MB
缓存(排行榜、匹配池):200MB
V8 开销(编译代码等):100MB
总计:~450MB

建议:--max-old-space-size=1024(1GB,留有余量)

什么时候说明代码有问题?

1. 内存持续增长不回落 → 泄漏
   - 用第07章的三快照对比法排查
   - Node.js 可以用 --expose-gc 启动,手动触发 GC

2. GC 频繁但回收不多 → 大量对象在老生代
   - 可能是"中年危机"问题:对象活得不够久也不够短
   - 考虑用对象池减少分配

3. external 持续增长 → C++ 侧泄漏
   - 通常是 Buffer 没释放
   - 或者 C++ addon 的内存泄漏

4. rss 远大于 heapTotal → 堆外内存太多
   - 检查 Buffer 和 C++ addon

Node.js 调试参数

# 启用 GC 日志
node --trace-gc server.js

# 启用手动 GC
node --expose-gc server.js
# 然后可以调用 global.gc()

# 生成堆快照(用于离线分析)
node --heapsnapshot-signal=SIGUSR2 server.js
# 发送信号生成快照
kill -USR2 <pid>

# OOM 时自动生成堆快照
node --heapsnapshot-near-heap-limit=3 server.js

# 生成 CPU Profile
node --prof server.js

三、Buffer 的堆外内存

Buffer 分配在哪里?

// Buffer 分配在 V8 堆之外(C++ 侧)
const buf = Buffer.alloc(1024 * 1024); // 1MB
// heapUsed 不会增加 1MB!
// 但 external 和 rss 会增加

// 这就是为什么 Node.js 适合处理大量二进制数据
// Buffer 不经过 V8 GC,不会触发 Stop-The-World

小 Buffer vs 大 Buffer

// 小 Buffer(<8KB):从池中分配(在 V8 堆外的紧凑内存池)
const smallBuf = Buffer.alloc(1024); // 1KB,从池中分配

// 大 Buffer(>=8KB):直接分配
const largeBuf = Buffer.alloc(1024 * 1024); // 1MB,直接分配

// 池的好处:减少内存碎片
// 池的坏处:小 Buffer 可能浪费空间(8KB 对齐)

Buffer 常见问题

// ❌ 问题1:Buffer 拷贝开销
function processImage(data) {
  const buf = Buffer.from(data);  // 拷贝!
  // 如果 data 已经是 Buffer,不需要 from
}
// ✅ 修复:检查是否已经是 Buffer
function processImage(data) {
  const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
}

// ❌ 问题2:String ↔ Buffer 频繁转换
function handleMessage(msg) {
  const buf = Buffer.from(msg, 'utf8');  // String → Buffer
  const str = buf.toString('utf8');       // Buffer → String
  // 如果只是传输,不需要转换!
}
// ✅ 修复:直接传 Buffer
function handleMessage(buf) {
  // 直接处理 Buffer,不转换
  socket.write(buf);
}

// ❌ 问题3:BufferSlice 不是拷贝
const original = Buffer.alloc(1024);
const slice = original.slice(0, 512);  // 共享内存!不是拷贝!
slice[0] = 0xFF;  // original[0] 也变了!
// ✅ 如果需要独立拷贝
const copy = Buffer.from(original.slice(0, 512));  // 真正的拷贝

游戏协议中的 Buffer 使用

// 自定义二进制协议:用 Buffer 读写
class ProtocolWriter {
  constructor(initialSize = 1024) {
    this.buf = Buffer.alloc(initialSize);
    this.offset = 0;
  }

  writeUInt8(value) {
    this.buf.writeUInt8(value, this.offset);
    this.offset += 1;
    return this;
  }

  writeUInt16(value) {
    this.buf.writeUInt16BE(value, this.offset);
    this.offset += 2;
    return this;
  }

  writeString(str) {
    const len = Buffer.byteLength(str, 'utf8');
    this.writeUInt16(len);
    this.buf.write(str, this.offset, len, 'utf8');
    this.offset += len;
    return this;
  }

  toBuffer() {
    return this.buf.slice(0, this.offset);
  }
}

// 使用
const writer = new ProtocolWriter();
writer.writeUInt8(0x01)          // 消息ID
  .writeUInt16(100)              // 玩家ID
  .writeString('hello');         // 聊天内容
const packet = writer.toBuffer();
socket.write(packet);

四、Stream 背压与内存控制

什么是背压?

生活类比:背压就像水管系统的减压阀。如果下游处理不过来,上游就得减速,否则水就会溢出。

没有背压:
数据源(快)──▶ 中间处理 ──▶ 网络(慢)
                    │
                    ▼
               内存暴涨!数据在中间堆积

有背压:
数据源(快)──▶ 中间处理 ──▶ 网络(慢)
    ▲                               │
    └──── 慢一点!等等我 ──────────────┘
    数据源减速,内存不暴涨

❌ 没有背压控制

// 文件读取速度 > 网络发送速度 → 内存暴涨
fs.createReadStream('huge-file.txt')
  .pipe(res); // 如果客户端网速慢,数据会堆积在内存中

// 更明显的例子
const data = [];
fs.createReadStream('huge-file.txt')
  .on('data', (chunk) => {
    data.push(chunk); // 全部存在内存中!
  })
  .on('end', () => {
    res.send(Buffer.concat(data)); // 内存峰值 = 文件大小
  });

✅ 有背压控制

// 方式1:pipe 自动处理背压
fs.createReadStream('huge-file.txt')
  .pipe(res); // pipe 内部有背压控制

// 方式2:手动处理
const stream = fs.createReadStream('huge-file.txt');
stream.on('data', (chunk) => {
  const canWrite = res.write(chunk);
  if (!canWrite) {
    stream.pause(); // 下游处理不过来,暂停读取
    res.once('drain', () => stream.resume()); // 下游处理完,继续读取
  }
});

游戏服务器中的背压场景

// 场景:广播游戏状态给所有玩家
class GameRoom {
  constructor() {
    this.players = new Map(); // playerId → socket
    this.state = null;
  }

  // ❌ 无背压:直接 write
  broadcastState() {
    const data = JSON.stringify(this.state);
    for (const [, socket] of this.players) {
      socket.write(data); // 如果某个玩家网络慢,服务器内存会堆积
    }
  }

  // ✅ 有背压:检查 write 返回值
  broadcastState() {
    const data = JSON.stringify(this.state);
    const slowPlayers = [];

    for (const [id, socket] of this.players) {
      const canWrite = socket.write(data);
      if (!canWrite) {
        slowPlayers.push(id);
        // 选项1:暂停该玩家的状态更新
        // 选项2:降低更新频率
        // 选项3:断开连接
      }
    }

    if (slowPlayers.length > 0) {
      console.warn(`慢速玩家: ${slowPlayers.join(', ')}`);
    }
  }
}

高水位线(HighWaterMark)

// Stream 有一个 highWaterMark 参数
// 表示缓冲区最大字节数
const stream = fs.createReadStream('file.txt', {
  highWaterMark: 64 * 1024  // 64KB(默认 16KB for readable, 16KB for writable)
});

// 当缓冲区超过 highWaterMark:
// - Readable Stream:停止从底层读取
// - Writable Stream:write() 返回 false

// 游戏服务器建议:
const socket = net.createConnection({
  host: 'game-server',
  port: 8080,
  // 适当调大 highWaterMark 可以减少系统调用次数
  // 但也会增加内存占用
  highWaterMark: 256 * 1024  // 256KB
});

五、Node.js 服务端内存监控与实战排查

生产环境监控方案

// 1. Prometheus 指标采集
const client = require('prom-client');

const gauge = new client.Gauge({
  name: 'nodejs_memory_bytes',
  help: 'Node.js memory usage',
  labelNames: ['type']
});

setInterval(() => {
  const mem = process.memoryUsage();
  gauge.set({ type: 'rss' }, mem.rss);
  gauge.set({ type: 'heapUsed' }, mem.heapUsed);
  gauge.set({ type: 'heapTotal' }, mem.heapTotal);
  gauge.set({ type: 'external' }, mem.external);
}, 5000);

// 2. 告警规则
// 内存超过 80% 时告警
// heapUsed > heapTotal * 0.8 → 警告
// rss > 物理内存 * 0.8 → 严重

内存快照自动生成

// OOM 时自动生成堆快照
const v8 = require('v8');

function checkMemory() {
  const mem = process.memoryUsage();
  const usage = mem.heapUsed / mem.heapTotal;

  if (usage > 0.9) {
    console.error('⚠️ 内存使用率超过 90%,生成堆快照...');
    const snapshot = v8.writeHeapSnapshot();
    console.error(`堆快照已保存: ${snapshot}`);
    // 可以配置自动重启
    // process.exit(1); // 让 PM2/Docker 自动重启
  }
}

setInterval(checkMemory, 30000); // 每30秒检查一次

线上 OOM 排查步骤

Step 1:确认 OOM 类型

# 查看错误日志
<--- JS stacktrace --->
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory

# 这表示 V8 堆 OOM。如果是系统 OOM,会显示:
# FATAL ERROR: Reached heap limit Allocation failed

Step 2:生成 Heap Dump

# 方法1:启动时添加参数,OOM 时自动生成
node --heapsnapshot-near-heap-limit=3 app.js
# 当内存使用达到限制的90%时,自动生成 Heap Snapshot

# 方法2:运行时通过信号触发
kill -USR2 <pid>
# 这会生成一个 .heapsnapshot 文件

Step 3:分析 Heap Snapshot

# 使用 Chrome DevTools 分析
# 1. 把 .heapsnapshot 文件复制到本地
# 2. Chrome DevTools -> Memory -> Load
# 3. 查看 Summary 视图,找出占用最大的对象类型

服务端常见泄漏模式

模式 原因 检测方法 修复方案
全局缓存泄漏 缓存只增不减 Heap Snapshot 使用 LRU 缓存
Stream 背压泄漏 数据生产>消费 监控 RSS 使用 pipe/背压控制
连接池泄漏 连接获取后不释放 监控连接数 try-finally 释放
事件监听泄漏 服务端为每个请求注册监听器 Heap Snapshot 用完即移除
定时器泄漏 setTimeout 未清理 Heap Snapshot finally 中清除

常见 Node.js 内存问题排查清单

□ 基础检查
  □ 用 process.memoryUsage() 监控 RSS/Heap/External
  □ 确认 --max-old-space-size 设置合理
  □ 确认没有全局变量无限增长

□ 泄漏排查
  □ 用 --expose-gc 启动,手动 GC 后检查 heapUsed
  □ 用 v8.writeHeapSnapshot() 生成快照,用 DevTools 分析
  □ 检查事件监听器、闭包、定时器(同第07章)

□ Buffer 检查
  □ Buffer 是否有拷贝浪费?
  □ String ↔ Buffer 转换是否必要?
  □ 大 Buffer 是否及时释放?

□ Stream 检查
  □ 是否有 pipe/背压控制?
  □ highWaterMark 设置是否合理?
  □ 慢速客户端是否有处理策略?

□ 外部模块检查
  □ C++ addon 是否有内存泄漏?
  □ 数据库连接是否正确释放?
  □ 第三方 SDK 的内存行为是否了解?

自问自答

Q:Node.js 的 Buffer 为什么不在 V8 堆里? A:两个原因:1) Buffer 可能很大(几十MB到几GB),如果放在 V8 堆里会严重影响 GC 性能;2) Buffer 需要和 C++ 侧、操作系统、网络接口交互,放在堆外更高效。

Q:--max-old-space-size 设多大合适? A:取决于你的应用。一般建议设为物理内存的 50%75%。比如 4GB 内存的服务器,可以设 20483072。要留出空间给操作系统和其他进程。

Q:Node.js 服务进程内存持续增长,怎么判断是泄漏还是正常增长? A:在稳定负载下,如果内存持续线性增长(不是逐渐趋于平稳),基本是泄漏。可以用 --expose-gc 启动,手动触发 GC,如果 GC 后内存还是不降,说明有泄漏。

Q:游戏服务器用 Node.js 还是 Java? A:看场景。Node.js 适合:实时通信、API 聚合、小型游戏。Java 适合:复杂业务逻辑、高并发、大型 MMO。很多游戏两者混用:Node.js 做网关,Java 做核心逻辑。


实践任务

  • 任务1:用 process.memoryUsage() 监控一个 Node.js 服务的内存,观察启动→运行→GC 的内存变化
  • 任务2:编写 Buffer vs 普通数组的内存对比测试(分别分配 100MB 的 Buffer 和 Array,对比 heapUsed 变化)
  • 任务3:实现一个有背压控制的文件流传输(模拟慢速网络)
  • 任务4:用 v8.writeHeapSnapshot() 生成快照,用 Chrome DevTools 分析
  • 任务5:搭建 Node.js 内存监控系统(Prometheus + Grafana)

与其他章节的关联

本章内容 关联章节 关联点
V8 堆管理 2_1_v8Learn Node.js 和浏览器共享 V8,GC 机制相同
内存泄漏排查 第07章 三快照对比法在 Node.js 中同样适用
Stream 背压 3_1_java-backend-deep-dive Netty 也有类似的背压机制
服务端内存监控 5_1_game-engineering 生产环境监控是工程化的一部分

上一章:07-内存泄漏排查SOP 下一章:09-H5游戏内存优化实战