Go 的 TCP 粘包与拆包:用长度前缀协议 + bufio 正确读消息

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 根本不知道。想正确收发消息,必须自己在字节流上定义边界。常见有三种:

  1. 固定长度:每条消息都补齐到 N 字节。简单但浪费,且不灵活。
  2. 分隔符 :用 \n 之类分割(HTTP 头、Redis 协议就用了 \r\n)。但消息体里若出现分隔符就要转义,处理麻烦。
  3. 长度前缀 :每条消息前面加个定长的「长度字段」,先读长度,再按长度读完整消息体。这是最通用、最省心的方案,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。

相关推荐
leikooo1 小时前
ARTS 0906: 辅助栈记录每层最小值、OSI 模型从未真正落地与与其被动等待被颠覆不如主动设计规则
数据结构·人工智能·tcp/ip
Shadow(⊙o⊙)1 小时前
数据链路层ARP协议、NAT路由技术、代理服务器、内网穿透、内网打洞、交换机+集线器
网络·网络协议·tcp/ip
青瓦梦滋1 小时前
Linux高级IO
linux·运维·服务器·网络·网络协议·tcp/ip
the局外人1 小时前
学习 FastAPI 的 Day 1:看懂接口与请求流程
后端·python·fastapi
会编程的吕洞宾2 小时前
Spring Boot多环境配置实战 配置文件加载顺序与切换不再翻车
java·后端
Csvn2 小时前
🐍 Day 11: 调试与诊断 — 从 print 到 pdb 的进阶之路
后端·python
泡海椒2 小时前
告别代码重启部署:JQuick-Java动态规则加载机制原理与实战
后端
HLeiDev2 小时前
邮箱登录与 Google 登录的账号模型设计与实现
后端
Zenova EdgeOS2 小时前
Go 工业边缘 Protobuf 实战:从 proto 到 Marshal 的完整落地
物联网·go·边缘计算·序列化·protobuf·工业边缘