《MMO 服务器开发》3.2 长度前缀、粘包与拆包

TCP 是字节流,send 的边界不会传到对端——粘包与拆包由此而来。本节用长度前缀把流切成帧:给出 wire 布局、可复用缓冲的编码器、逐字节补齐的解码器与不可信长度的上限防护,并真跑一组覆盖粘包、拆包、截断、超长与畸形长度字段的测试用例。

3.2 长度前缀、粘包与拆包

上一节我们定好了固定头:length(4) | cmd(2) | seq(4) | flags(1)。但还有一个更靠前的问题没解决——接收方怎么知道「从哪个字节开始是一条新消息」?

TCP 不回答这个问题。它只保证字节按顺序到达,不保证「你 send 一次,对端就 read 一次」:你连续 write 的三条消息,对端可能一次 read 全收到(粘包),也可能只收到半条(拆包)。这一节要把这条没有边界的字节流还原成一条条边界清晰的消息——它是全卷最基础的一节,后面所有章节拿到的都是这里切好的 Frame,切错了后面全错。

粘包与拆包是怎么发生的

先看现象。客户端依次发送三条消息 A、B、C:

客户端三次 write             服务端一次 read 可能收到
 [帧A][帧B][帧C]   ───►   [帧A|帧B|帧C前半]   ← 粘包:三条挤在一次
                           [帧C后半|帧D开头]   ← 拆包:一条被劈开

两种现象同源:TCP 只承诺「有序的字节」,不承诺「按 write 分组的字节」。内核的发送缓冲会把相邻的小包合并(Nagle 算法),接收缓冲则按自己方便的大小交给应用层——所以应用层绝不能假设「一次 read = 一条消息」。处理办法只有一条:给字节流加边界,最简单也最常用的边界就是长度前缀,在每条消息前面写死「这条消息有多少字节」。

长度前缀:把流切成帧

aegis 的 wire 格式如下,固定头 11 字节,length 统计它自身之后的字节数:

 offset: 0        4        6        10       11                  11+N
         +--------+--------+--------+--------+------------------+
         | length |  cmd   |  seq   | flags  |     payload      |
         |  4B BE |  2B BE |  4B BE |  1B    |       N B        |
         +--------+--------+--------+--------+------------------+
        length = 7 + N(7 = cmd(2)+seq(4)+flags(1)),帧总长 = 11 + N

接收方的切帧算法只有三步:读满 11 字节固定头 → 从 length 算出载荷长度 N → 再读满 N 字节载荷。length 不含自身 4 字节,是为了让接收方读到头就能立刻知道「后面还差多少」。为什么不用分隔符(比如换行)?分隔符要求载荷里不能出现分隔符,要么转义、要么扫描,而二进制载荷里出现任意字节是常态,扫描代价高且容易出错;长度前缀直接给出长度,一次到位。

长度上限:不可信的长度字段

长度前缀有个致命前提:length 来自网络,是不可信输入。如果服务端老老实实按 length 分配缓冲,一个只发 11 字节、却声明 length = 0xFFFFFF00(约 4 GiB)的畸形包,就能让服务端为一条消息预分配 4 GiB——几百个这样的包就能把内存打满,这是最省事的一种 DoS。防线是 MaxPayload:解码时先看 length 推出的载荷长度,一旦超过上限,在分配任何内存之前就返回 ErrPayloadTooLarge 并断开连接。上限取多大是业务问题——16 MiB 对普通消息绰绰有余,真正的大消息(比如批量同步)应该走分片,而不是抬高上限。

同理,length 如果小于它必须覆盖的固定头(7 字节),说明这个包本身就是畸形的,返回 ErrShortFrame,同样在分配内存之前拒绝。

可运行产物:编解码与逐字节到达

下面 internal/proto/frame.go 是完整实现,层级 L1,命令 go test ./internal/proto/。它提供五个入口:Encode(一次分配)、EncodeInto(追加写,复用缓冲)、DecodeFrom(从已有字节切片切一帧)、ReadFrame(从 io.Reader 读一帧)、Decoder(bufio 包装,减少系统调用)。

// file: internal/proto/frame.go
// tier: L1
// verify: go test ./internal/proto/
package proto

import (
	"bufio"
	"encoding/binary"
	"errors"
	"io"
)

// ErrPayloadTooLarge 表示声明或请求的载荷长度超过 MaxPayload。
var ErrPayloadTooLarge = errors.New("proto: payload exceeds MaxPayload")

// Frame 是一条已经切干净的完整消息。
type Frame struct {
	Cmd     Cmd
	Seq     uint32
	Flags   Flags
	Payload []byte
}

// Encode 把一帧编码成新分配的字节切片。适合一次性发送。
func Encode(f Frame) []byte {
	buf := make([]byte, 0, HeaderSize+len(f.Payload))
	return EncodeInto(buf, f)
}

// EncodeInto 把一帧追加到 dst 末尾并返回扩展后的切片。
//
// 复用同一个 dst 可以避免每帧一次堆分配:只要容量够,grow 就不重新分配。
// 这是发送路径上最容易省下的一笔开销——每帧一次 make 在高频广播下很可观。
func EncodeInto(dst []byte, f Frame) []byte {
	need := HeaderSize + len(f.Payload)
	start := len(dst)
	dst = grow(dst, need)
	binary.BigEndian.PutUint32(dst[start:start+4], uint32(HeaderSize-4+len(f.Payload)))
	binary.BigEndian.PutUint16(dst[start+4:start+6], uint16(f.Cmd))
	binary.BigEndian.PutUint32(dst[start+6:start+10], f.Seq)
	dst[start+10] = byte(f.Flags)
	copy(dst[start+HeaderSize:], f.Payload)
	return dst
}

// grow 在 dst 末尾预留 n 字节并返回加长后的切片;容量足够时不重新分配。
func grow(dst []byte, n int) []byte {
	if cap(dst)-len(dst) >= n {
		return dst[:len(dst)+n]
	}
	grown := make([]byte, len(dst)+n)
	copy(grown, dst)
	return grown
}

// DecodeFrom 从 buf 头部解出一帧,返回帧与消耗的字节数。
//
// 数据不足一整帧时返回 io.ErrUnexpectedEOF,且消耗字节数为 0——调用方应保留
// 未消费的 buf 等下一次读取补齐,而不是丢弃它。
func DecodeFrom(buf []byte) (Frame, int, error) {
	h, err := DecodeHeader(buf)
	if err != nil {
		return Frame{}, 0, err // buf 不足一个固定头 → ErrShortFrame
	}
	if h.Length < uint32(HeaderSize-4) {
		// 长度字段比它必须覆盖的 cmd+seq+flags 还短,是畸形包。
		return Frame{}, 0, ErrShortFrame
	}
	payloadLen := int(h.Length) - (HeaderSize - 4)
	if payloadLen > MaxPayload {
		return Frame{}, 0, ErrPayloadTooLarge
	}
	total := HeaderSize + payloadLen
	if len(buf) < total {
		return Frame{}, 0, io.ErrUnexpectedEOF
	}
	payload := make([]byte, payloadLen)
	copy(payload, buf[HeaderSize:total])
	return Frame{Cmd: h.Cmd, Seq: h.Seq, Flags: h.Flags, Payload: payload}, total, nil
}

// ReadFrame 从 r 读出一帧:先读满固定头,再按声明长度读满载荷。
//
// 全部读取都走 io.ReadFull,它会把「分几次到达」的字节补齐成一次完整的读;
// 这正是 TCP 流式语义下拆包的关键。读到一半连接断了会返回 io.ErrUnexpectedEOF。
func ReadFrame(r io.Reader) (Frame, error) {
	var head [HeaderSize]byte
	if _, err := io.ReadFull(r, head[:]); err != nil {
		return Frame{}, err
	}
	h, err := DecodeHeader(head[:])
	if err != nil {
		return Frame{}, err
	}
	if h.Length < uint32(HeaderSize-4) {
		return Frame{}, ErrShortFrame
	}
	payloadLen := int(h.Length) - (HeaderSize - 4)
	if payloadLen > MaxPayload {
		return Frame{}, ErrPayloadTooLarge
	}
	payload := make([]byte, payloadLen)
	if _, err := io.ReadFull(r, payload); err != nil {
		return Frame{}, err
	}
	return Frame{Cmd: h.Cmd, Seq: h.Seq, Flags: h.Flags, Payload: payload}, nil
}

// Decoder 在 bufio.Reader 上按帧解码,把大量小 read 合并成少量系统调用。
type Decoder struct {
	r *bufio.Reader
}

// NewDecoder 用给定缓冲区大小包装 r(小于 16 时 bufio 会取默认值)。
func NewDecoder(r io.Reader, bufSize int) *Decoder {
	return &Decoder{r: bufio.NewReaderSize(r, bufSize)}
}

// Decode 读出下一帧。流结束且正好落在帧边界时返回 io.EOF。
func (d *Decoder) Decode() (Frame, error) {
	return ReadFrame(d.r)
}

测试文件把这一节最容易出错的几种到达方式全钉死:往返一致、逐字节到达(自定义 io.Reader 每次只给 1 字节,验证 io.ReadFull 会补齐)、三帧粘在一起逐帧切净、截断返回 io.ErrUnexpectedEOF、超长返回 ErrPayloadTooLarge、长度小于固定头返回 ErrShortFrame。

// file: internal/proto/frame_test.go
// tier: L1
// verify: go test ./internal/proto/
package proto

import (
	"bytes"
	"encoding/binary"
	"io"
	"testing"
)

func TestRoundTrip(t *testing.T) {
	f := Frame{Cmd: CmdMoveReq, Seq: 42, Flags: FlagRequest, Payload: []byte("move 12,34")}
	buf := Encode(f)
	if len(buf) != HeaderSize+len(f.Payload) {
		t.Fatalf("编码长度 %d", len(buf))
	}
	got, n, err := DecodeFrom(buf)
	if err != nil || n != len(buf) {
		t.Fatalf("DecodeFrom: n=%d err=%v", n, err)
	}
	if got.Cmd != f.Cmd || got.Seq != f.Seq || got.Flags != f.Flags || !bytes.Equal(got.Payload, f.Payload) {
		t.Fatalf("往返不一致:%+v", got)
	}
}

func TestEncodeIntoReusesCapacity(t *testing.T) {
	dst := make([]byte, 0, 256)
	base := dst
	dst = EncodeInto(dst, Frame{Cmd: CmdChatSend, Seq: 1, Payload: []byte("hi")})
	dst = EncodeInto(dst, Frame{Cmd: CmdChatRecv, Seq: 2, Payload: []byte("yo")})
	if &dst[0] != &base[:1][0] {
		t.Fatal("容量足够时 EncodeInto 不应重新分配")
	}
	f1, n1, _ := DecodeFrom(dst)
	f2, n2, _ := DecodeFrom(dst[n1:])
	if f1.Cmd != CmdChatSend || f2.Cmd != CmdChatRecv || n1+n2 != len(dst) {
		t.Fatalf("切帧错误:%+v %+v", f1, f2)
	}
}

// oneByteReader 每次只吐 1 字节,模拟 TCP 把包切碎后逐字节到达。
type oneByteReader struct {
	data []byte
	pos  int
}

func (r *oneByteReader) Read(p []byte) (int, error) {
	if r.pos >= len(r.data) {
		return 0, io.EOF
	}
	if len(p) == 0 {
		return 0, nil
	}
	p[0] = r.data[r.pos]
	r.pos++
	return 1, nil
}

func TestReadFrameByteByByte(t *testing.T) {
	f := Frame{Cmd: CmdEnterWorldReq, Seq: 7, Flags: FlagRequest, Payload: []byte("dawn-realm")}
	got, err := ReadFrame(&oneByteReader{data: Encode(f)})
	if err != nil || got.Cmd != f.Cmd || !bytes.Equal(got.Payload, f.Payload) {
		t.Fatalf("逐字节到达解码错误:%+v err=%v", got, err)
	}
}

func TestThreeGluedFrames(t *testing.T) {
	frames := []Frame{
		{Cmd: CmdHeartbeatReq, Seq: 1},
		{Cmd: CmdMoveReq, Seq: 2, Flags: FlagRequest, Payload: []byte("aaaaaaaa")},
		{Cmd: CmdChatRecv, Seq: 3, Flags: FlagPush, Payload: []byte("hello world!")},
	}
	var stream []byte
	for _, f := range frames {
		stream = EncodeInto(stream, f)
	}
	off := 0
	for i, want := range frames {
		got, n, err := DecodeFrom(stream[off:])
		if err != nil || got.Cmd != want.Cmd || got.Seq != want.Seq || !bytes.Equal(got.Payload, want.Payload) {
			t.Fatalf("第 %d 帧:%+v err=%v", i, got, err)
		}
		off += n
	}
	if off != len(stream) {
		t.Fatalf("三帧共消耗 %d,流长 %d(未切净)", off, len(stream))
	}
}

func TestTruncated(t *testing.T) {
	wire := Encode(Frame{Cmd: CmdMoveReq, Seq: 9, Payload: []byte("abcdef")})
	if _, _, err := DecodeFrom(wire[:len(wire)-3]); err != io.ErrUnexpectedEOF {
		t.Fatalf("截断载荷应返回 io.ErrUnexpectedEOF,得到 %v", err)
	}
	if _, _, err := DecodeFrom(wire[:5]); err != ErrShortFrame {
		t.Fatalf("截断头应返回 ErrShortFrame,得到 %v", err)
	}
	if _, err := ReadFrame(bytes.NewReader(wire[:len(wire)-1])); err != io.ErrUnexpectedEOF {
		t.Fatalf("ReadFrame 截断应返回 io.ErrUnexpectedEOF,得到 %v", err)
	}
}

func TestPayloadTooLarge(t *testing.T) {
	head := make([]byte, HeaderSize)
	binary.BigEndian.PutUint32(head[0:4], uint32(HeaderSize-4+MaxPayload+1))
	binary.BigEndian.PutUint16(head[4:6], uint16(CmdMoveReq))
	if _, _, err := DecodeFrom(head); err != ErrPayloadTooLarge {
		t.Fatalf("超长载荷应返回 ErrPayloadTooLarge,得到 %v", err)
	}
	if _, err := ReadFrame(bytes.NewReader(head)); err != ErrPayloadTooLarge {
		t.Fatalf("ReadFrame 超长应返回 ErrPayloadTooLarge,得到 %v", err)
	}
}

func TestLengthBelowHeader(t *testing.T) {
	head := make([]byte, HeaderSize)
	binary.BigEndian.PutUint32(head[0:4], 6) // 小于 length 之后的固定头 7 字节
	if _, _, err := DecodeFrom(head); err != ErrShortFrame {
		t.Fatalf("长度小于固定头应返回 ErrShortFrame,得到 %v", err)
	}
}

func TestDecoderEOF(t *testing.T) {
	var stream []byte
	stream = EncodeInto(stream, Frame{Cmd: CmdLoginReq, Seq: 1, Payload: []byte("v=3")})
	stream = EncodeInto(stream, Frame{Cmd: CmdLoginResp, Seq: 1, Flags: FlagResponse, Payload: []byte("ok")})
	d := NewDecoder(bytes.NewReader(stream), 64)
	for i := 0; i < 2; i++ {
		if _, err := d.Decode(); err != nil {
			t.Fatalf("第 %d 帧:%v", i, err)
		}
	}
	if _, err := d.Decode(); err != io.EOF {
		t.Fatalf("流末尾应返回 io.EOF,得到 %v", err)
	}
}

本机实跑:ok aegis/internal/proto,本节这 8 条全绿。最有价值的是 TestReadFrameByteByByte——它把每个字节单独喂进去,等于把「拆包」推到极端:如果解码器里有任何一处假设「一次 read 拿到一整帧」,这条测试立刻失败。它之所以能过,是因为 ReadFrame 全程用 io.ReadFull。

本节小结

  • TCP 只承诺有序字节,不承诺 write 边界;粘包与拆包同源,处理办法是给流加边界。
  • 长度前缀 length(4, BE) | cmd(2) | seq(4) | flags(1) | payload(N),length = 7 + N,切帧只需「读头 → 算长 → 读满」三步。
  • 长度字段不可信:MaxPayload 必须在分配内存之前拦下超长声明,否则畸形包就能打满内存。
  • EncodeInto 复用缓冲、io.ReadFull 补齐字节,是发送与接收两侧最关键的两行。

下一节处理协议演进:客户端版本落后或超前时,服务端怎么协商、怎么兼容。

阅读导航:上一节:3.1 消息头与命令号 · 下一节:3.3 协议版本与多版本兼容 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标