Go 的 TCP 粘包与拆包:用长度前缀协议 + bufio 正确读消息
用 Go 写第一个 TCP 服务时,几乎所有人都栽在同一个坑:客户端 conn.Write 三次,服务端 conn.Read 却一次就把三条消息全读出来了;要么反过来,一条消息被拆成两次才读完。于是有人开始怀疑 Go 的网络库有 bug,或者到处加 time.Sleep 凑合。
这不是 bug,是你把 TCP 当成了消息队列。这篇讲清楚粘包/拆包到底怎么来的,以及生产里通用的解法------长度前缀协议。
先看现象:一个会「粘包」的回显服务
go
// server.go ------ 朴素读法,会粘包
package main
import (
"fmt"
"net"
)
func main() {
ln, _ := net.Listen("tcp", ":9000")
for {
conn, _ := ln.Accept()
go func(c net.Conn) {
defer c.Close()
buf := make([]byte, 1024)
for {
n, err := c.Read(buf) // 一次 Read 读到多少全凭 TCP 心情
if err != nil {
return
}
fmt.Printf("收到 %d 字节: %q\n", n, buf[:n])
}
}(conn)
}
}
客户端连着发三条:
go
// client.go
conn, _ := net.Dial("tcp", "127.0.0.1:9000")
conn.Write([]byte("hello"))
conn.Write([]byte("world"))
conn.Write([]byte("go!"))
你期望服务端打印三行,实际大概率是:
收到 13 字节: "helloworldgo!"
三条消息粘成了一条。偶尔网络抖动时,又可能拆成 "hellowor" + "ldgo!"。
为什么会这样:TCP 是字节流,不是消息
关键就一句话:TCP 是面向字节流的协议,它只保证字节顺序,不保证「你写一次就对应对方读一次」。
- 你的
Write只是把数据塞进内核发送缓冲区,内核会按自己的节奏(受 MSS、Nagle 算法、拥塞窗口影响)组织成 TCP 段发出去。多个小Write可能被合并成一个段------这就是粘包。 - 一个大
Write超过 MSS,会被拆成多个段,接收端一次Read只拿到一部分------这就是拆包。
所以「一条消息」的边界是应用层的概念,TCP 根本不知道。想正确收发消息,必须自己在字节流上定义边界。常见有三种:
- 固定长度:每条消息都补齐到 N 字节。简单但浪费,且不灵活。
- 分隔符 :用
\n之类分割(HTTP 头、Redis 协议就用了\r\n)。但消息体里若出现分隔符就要转义,处理麻烦。 - 长度前缀 :每条消息前面加个定长的「长度字段」,先读长度,再按长度读完整消息体。这是最通用、最省心的方案,gRPC、Thrift、大多数私有 RPC 都用它。
下面重点实现长度前缀。
正确写法:4 字节长度前缀 + bufio
协议设计:每条消息 = [4 字节 body 长度(大端)] + [body]。
先写编码/发送函数:
go
package main
import (
"encoding/binary"
"io"
"net"
)
// writeMsg 把一条消息按「长度前缀」格式写出去
func writeMsg(conn net.Conn, payload []byte) error {
// 4 字节头 + body 一次性拼好再写,避免两次 Write 之间被别的 goroutine 插入
buf := make([]byte, 4+len(payload))
binary.BigEndian.PutUint32(buf[:4], uint32(len(payload)))
copy(buf[4:], payload)
// Write 可能只写一部分,但对 net.Conn 而言 Write 会写完全部或返回 err,
// 这里用一次 Write 即可;若是自定义 Writer 才需要循环
_, err := conn.Write(buf)
return err
}
再写读取函数,核心是 io.ReadFull------它会一直读到填满整个 buffer 才返回,天然解决拆包:
go
// readMsg 从连接里读出一条完整消息
func readMsg(r io.Reader) ([]byte, error) {
header := make([]byte, 4)
// ReadFull:读不满 4 字节就阻塞等待,中途断开返回 err
if _, err := io.ReadFull(r, header); err != nil {
return nil, err
}
n := binary.BigEndian.Uint32(header)
// 关键:校验长度,防止对端发个超大长度把你内存撑爆(见下文「坑」)
const maxMsgSize = 10 << 20 // 10MB 上限
if n > maxMsgSize {
return nil, fmt.Errorf("消息过大: %d 字节", n)
}
body := make([]byte, n)
if _, err := io.ReadFull(r, body); err != nil {
return nil, err
}
return body, nil
}
服务端改用 bufio.Reader 包一层再 readMsg,减少系统调用次数:
go
func handle(conn net.Conn) {
defer conn.Close()
// bufio 把多次小 Read 合并成一次系统调用,长度前缀协议配它很香
r := bufio.NewReader(conn)
for {
msg, err := readMsg(r)
if err != nil {
if err != io.EOF {
log.Printf("读取失败: %v", err)
}
return
}
log.Printf("收到完整消息: %q", msg)
}
}
现在客户端发三条:
go
writeMsg(conn, []byte("hello"))
writeMsg(conn, []byte("world"))
writeMsg(conn, []byte("go!"))
服务端稳定输出三行,无论 TCP 怎么切分字节流:
收到完整消息: "hello"
收到完整消息: "world"
收到完整消息: "go!"
因为 io.ReadFull 会精确读满「头声明的字节数」,粘包时它只取属于当前消息的部分(剩下的留在 bufio 缓冲里给下一次读),拆包时它会阻塞等后续数据到齐。
用 encoding/binary 直接读,更省一次拷贝
其实连 header 的 buffer 都可以省,binary.Read 直接从 reader 解析:
go
func readMsg(r io.Reader) ([]byte, error) {
var n uint32
// binary.Read 内部就是 ReadFull(4字节) + 解析,一步到位
if err := binary.Read(r, binary.BigEndian, &n); err != nil {
return nil, err
}
if n > 10<<20 {
return nil, fmt.Errorf("消息过大: %d", n)
}
body := make([]byte, n)
_, err := io.ReadFull(r, body)
return body, err
}
binary.Read 可读性更好,但每次会有一点反射开销;高频路径上手写 PutUint32/Uint32 更快。按场景选。
三个必踩的坑
坑 1:必须校验长度上限。 header 是对端发来的,不可信。若攻击者(或有 bug 的客户端)发个 0xFFFFFFFF,你 make([]byte, n) 直接申请 4GB,服务瞬间 OOM。上面代码里的 maxMsgSize 检查是生产必备,不是可选项。
坑 2:别用 conn.Read 配大 buffer 硬读。 有人想「我 buffer 开大点一次读完」------没用。TCP 不保证一次给你完整消息,buffer 再大也可能只读到半条。解决边界问题的是协议(长度前缀/分隔符),不是 buffer 大小。
坑 3:发送端也要防「半包写」。 net.Conn.Write 在正常情况下会写完全部数据或返回错误,一般不用循环。但如果你把数据写进的是自定义 io.Writer(比如加密层、压缩层),Write 可能只写一部分,这时必须循环写直到写完,否则也会「拆包」。对 net.TCPConn 本身则不用担心。
还有个小坑:bufio.Reader 只该给读用,别拿 bufio.Writer 缓冲了 writeMsg 又忘了 Flush------数据会卡在缓冲区发不出去,表现为对端一直收不到消息。
小结
- TCP 是字节流,不保证「一次写对应一次读」,粘包/拆包是必然现象,不是 bug。
- 消息边界是应用层的事,三种方案里长度前缀 最通用:
[4字节长度] + [body]。 - 读取用
io.ReadFull(或binary.Read),它会精确读满指定字节数,天然解决拆/粘包;外面套一层bufio.Reader减少系统调用。 - 长度字段来自对端,一定要校验上限,否则一个恶意长度就能把你 OOM。
一句话记忆:别问 TCP 要消息边界,自己在协议里定义它------先读长度,再按长度读满 body。