TCP 粘包与基于长度前缀的拆包

本节目标

问题:TCP 是字节流,没有消息边界。发送方连续 write 两次,接收方可能一次 data 收到两包(粘包),也可能分两次才收完一包(半包)。必须在应用层自己定边界。

方案:长度前缀:每条消息 = 4 字节大端长度 + 负载。接收方先读长度,再按长度凑齐负载。

// 运行环境:Node.js 14+
// 保存为 aj-l4.js,执行:node aj-l4.js
const net = require('net')

// 编码:加 4 字节长度前缀
function encode(msg) {
  const payload = Buffer.from(msg)
  const head = Buffer.alloc(4)
  head.writeUInt32BE(payload.length, 0) // 大端 32 位长度
  return Buffer.concat([head, payload])
}

// 解码:维护缓冲区,按长度前缀拆出完整消息
class Decoder {
  constructor() { this.buf = Buffer.alloc(0) }
  push(chunk) {
    this.buf = Buffer.concat([this.buf, chunk])
    const out = []
    while (this.buf.length >= 4) {
      const len = this.buf.readUInt32BE(0)
      if (this.buf.length < 4 + len) break // 负载还没收齐,等更多
      out.push(this.buf.slice(4, 4 + len).toString())
      this.buf = this.buf.slice(4 + len)
    }
    return out
  }
}

// 调用示例:模拟粘包(两条拼一起发)与半包(分两段发)
const d = new Decoder()
console.log(d.push(Buffer.concat([encode('hello'), encode('world')]))) // ['hello','world'](粘包被拆开)
const part1 = encode('longmessage').slice(0, 6)
const part2 = encode('longmessage').slice(6)
console.log(d.push(part1), d.push(part2)) // [] 然后 ['longmessage'](半包被拼合)

名词解释

课后练习

  1. 为什么不能用"换行符 \n"给二进制协议定界?
    • 答案:二进制负载里可能本身含有 \n 字节,会误判边界;而长度前缀与内容无关,能承载任意字节。文本协议才常用换行定界。
  2. Decoder 里 this.buf.length < 4 + len 时为什么 break 而不是继续?
    • 答案:说明负载还没收齐,当前无法拆出完整消息;break 保留已读缓冲,等下一次 push 补齐全后再处理,避免误截。

总结

TCP 粘包是每一个写网络程序的人的"成人礼"——几乎所有初学者第一次用 socket 都会栽在"为什么我收的数据串味了"。这课的灵魂只有一句话:TCP 是流,不是消息;边界必须由你在应用层定义。长度前缀之所以成为行业标准(Redis 的 RESP、gRPC 的 length-delimited 都如此),是因为它简单、可靠、又能承载任意二进制。我特别想强调缓冲区 buf 的维护:它把"没收齐的半包"攒着,下次再来补齐——这个"攒buffer"的动作,是所有高性能网络框架(Node 的 readable 流、Netty 的 ByteToMessageDecoder)共同在做的事。理解了长度前缀拆包,你就拿到了手写任何私有协议的钥匙,RPC、游戏同步、IM 长连接都建立在这之上。