人人都会AI编程

net 模块:TCP 服务端与客户端、粘包问题处理

更新时间:2026-07-10

在 Node.js 中,net 模块提供了创建基于流的 TCP 或 IPC 服务器与客户端的异步网络 API。它比 HTTP 模块更底层,适用于开发自定义协议、长连接服务、实时通信中间件等场景。掌握 net 模块,不仅能让你彻底理解 Node.js 网络编程的运作方式,还能在面对粘包、断连、背压等问题时游刃有余。

TCP 服务端与客户端基础

创建 TCP 服务端

使用 net.createServer 创建 TCP 服务端。每当有新连接建立时,会得到一个 net.Socket 对象,它同时是一个可读可写的双工流,可以监听 dataendclose 等事件。

const net = require('net');

const server = net.createServer((socket) => {
  console.log('客户端已连接:', socket.remoteAddress, socket.remotePort);

  // 监听客户端发送的数据
  socket.on('data', (data) => {
    console.log('收到数据:', data.toString());
    // 原样返回给客户端
    socket.write(`服务端回复:${data.toString()}`);
  });

  // 客户端断开连接
  socket.on('end', () => {
    console.log('客户端断开连接');
  });

  socket.on('error', (err) => {
    console.error('Socket 错误:', err);
  });
});

server.listen(9000, () => {
  console.log('TCP 服务器监听在 9000 端口');
});

创建 TCP 客户端

客户端通过 net.createConnectionnet.connect 连接到服务端,同样会得到一个 net.Socket 对象。

const net = require('net');

const client = net.createConnection({ port: 9000 }, () => {
  console.log('已连接到服务器');
  // 发送数据
  client.write('Hello, server!');
});

client.on('data', (data) => {
  console.log('收到服务端回复:', data.toString());
  // 通信结束后断开
  client.end();
});

client.on('end', () => {
  console.log('连接已关闭');
});

client.on('error', (err) => {
  console.error('连接错误:', err);
});

运行上述代码,你会看到双方的通信就像打电话一样:一方写入,另一方读出,事件驱动模型自然地处理了异步 I/O。

粘包问题的本质

TCP 是一个面向流的协议,它保证数据按顺序、无差错地传输,但 不保证消息边界。应用层多次调用 socket.write() 发送的多个小数据包,可能会被 TCP 底层合并成一个数据段发送;同样,接收端一次 data 事件可能收到多个消息的拼合体,也可能只收到一个消息的其中一段。这就是著名的 粘包半包 问题。

举个简单例子:客户端连续发送三条消息 “AAA”、“BBB”、“CCC”,服务端可能会一次触发 data 事件接收到 “AAABBBCCC”,也可能收到 “AAAB” 和 “BBCCC” 这样的分片。如果业务逻辑依赖消息的独立边界,直接按 data 原始数据解析就会出现协议错乱。

处理粘包的常见方案

解决粘包问题的核心在于 应用层自定义协议,让接收端能够区分每一条完整的消息。下面介绍三种典型方案,复杂度依次递增,适用于不同需求。

方案一:定长消息

如果每条消息长度固定,接收端只需累计缓冲区字节数到达固定长度,即可切出一条完整消息。此方案最简单,但不够灵活,适用于指令简单、变长需求极少的场景。

const FRAME_SIZE = 8; // 每条消息固定 8 字节

socket.on('data', (buffer) => {
  // 假设已有一个累积缓冲区 buf
  buf = Buffer.concat([buf, buffer]);
  while (buf.length >= FRAME_SIZE) {
    const message = buf.slice(0, FRAME_SIZE);
    buf = buf.slice(FRAME_SIZE);
    console.log('解析到消息:', message.toString());
  }
});

方案二:分隔符协议

在每个消息的末尾附加一个特殊的分隔符(如换行符 \n 或更复杂的字符串),接收端按分隔符切分数据流。这是文本协议(如 Redis、HTTP 首行)中非常常见的做法。

const DELIMITER = '\n';

let buffer = '';

socket.on('data', (chunk) => {
  buffer += chunk.toString();
  let index;
  while ((index = buffer.indexOf(DELIMITER)) !== -1) {
    const message = buffer.slice(0, index);
    buffer = buffer.slice(index + DELIMITER.length);
    console.log('解析到消息:', message);
  }
});

权衡点:分隔符需要保证不在消息体中出现,如果消息中可能包含分隔符,需要对消息体进行转义或用更长的分隔符序列。

方案三:长度前缀(Length-Prefix)协议

这是最健壮、最通用的变长消息解决方案。每条消息由 N 字节的固定长度头(表示消息体的字节数) + 消息体 组成。接收端先读取定长头部,解析出消息体长度,再读取对应长度的数据,即可保证消息边界绝对清晰。

实际开发中通常会封装一个简易的状态机:

const HEADER_SIZE = 4; // 前4字节存放消息体长度(32位整数)

let buffer = Buffer.alloc(0);

socket.on('data', (chunk) => {
  buffer = Buffer.concat([buffer, chunk]);
  parseBuffer();
});

function parseBuffer() {
  // 至少需要头部长度才能开始解析
  if (buffer.length < HEADER_SIZE) return;

  // 读取消息体长度(大端序)
  const bodyLength = buffer.readUInt32BE(0);
  const totalLength = HEADER_SIZE + bodyLength;

  // 还未收到完整的消息体,继续等待
  if (buffer.length < totalLength) return;

  // 取出消息体
  const body = buffer.slice(HEADER_SIZE, totalLength);
  console.log('解析到完整消息:', body.toString());

  // 从缓冲区移除已处理的数据
  buffer = buffer.slice(totalLength);

  // 递归检查是否还能解析下一条消息
  parseBuffer();
}

客户端发送时也按同样格式打包:

function encodeMessage(message) {
  const body = Buffer.from(message);
  const header = Buffer.alloc(HEADER_SIZE);
  header.writeUInt32BE(body.length, 0);
  return Buffer.concat([header, body]);
}

// 使用
client.write(encodeMessage('Hello, server!'));

方案选择与进阶考量

  • 定长消息:适合指令式、IOT 设备通信等。
  • 分隔符:适合命令行工具、基于行的协议,开发效率最高。
  • 长度前缀:适合二进制协议、大数据量传输,构建通用 RPC 框架或即时通讯系统的最佳选择。

在实际生产中,可能会混合使用,例如先以换行符作为简单控制命令的边界,切换到长度前缀模式传输二进制数据。此外,当通信量极大时,还需注意背压控制(socket.write 返回 false 时暂停写入)、心跳保活、以及半双工和全双工交互模式的合理设计。

掌握了net模块的 TCP 编程与粘包处理,你就能够脱离 HTTP 框架的封装,直接击穿网络层的底层细节,构建出高性能、自定义协议的后端服务。这在实时通信、物联网网关、RPC 中间件等场景中是必不可少的技能。