朕喜技术(ZhenxiJS)
2026.09.08
1 分钟阅读
Go TCP 粘包/半包问题详解:长度前缀分帧(length-prefixed framing)的正确实现
关键词: TCP 粘包, 半包, length-prefix framing, Go io.ReadFull, encoding/binary, 长度前缀分帧, TCP 字节流, 消息边界
问题背景
开发一个基于 TCP 的 Go 自定义协议服务,协议规定每条消息 = [4字节大端长度][payload]。
服务端上线后收到的消息大量错乱:粘包(多条独立消息被合并成一条处理)、半包(一条完整消息被拆成多段分别处理)。
日志中长度前缀字节(\x00\x00\x00\x05)混进了"消息正文",完全无法还原。
复现步骤
cd scenario/2026-09-08-0000
docker compose up -d --build
docker compose exec app bash
bash reproduce.sh
复现输出示例:
"\x00\x00\x00\x05hello\x00\x00\x00Bthi" ← 粘包+半包
"s is a longer me" ← 半包(16字节读缓冲切碎)
根本原因
TCP 是面向字节流的协议,它本身不提供"消息边界"。具体:
- 粘包:发送端多次
Write的消息可能被 Nagle 算法 / 内核缓冲 / 网络合并,一次性到达接收端; - 半包:接收端一次
conn.Read可能只读到一条消息的一部分(读缓冲大小、网络分片)。
服务端错误代码的核心:
n, err := conn.Read(buf)
fmt.Fprintf(logw, "%q\n", string(buf[:n])) // 错误假设:一次 Read = 一条消息
解决办法
核心思路:长度前缀分帧(length-prefixed framing)——先精确读 4 字节得到 payload 长度 L,再精确读 L 字节作为完整消息。
正确实现(Go)
import (
"encoding/binary"
"io"
"net"
)
func handle(conn net.Conn, logw *os.File) {
defer conn.Close()
lenBuf := make([]byte, 4)
for {
// 1. 读 4 字节长度前缀(io.ReadFull 保证读够)
if _, err := io.ReadFull(conn, lenBuf); err != nil {
if err != io.EOF && err != io.ErrUnexpectedEOF {
fmt.Fprintf(os.Stderr, "read length err: %v\n", err)
}
return
}
payloadLen := binary.BigEndian.Uint32(lenBuf)
// 2. 防御:过滤异常长度
if payloadLen == 0 { continue }
if payloadLen > 64*1024*1024 {
fmt.Fprintf(os.Stderr, "payload too large: %d, closing\n", payloadLen)
return
}
// 3. 精确读够 payloadLen 字节
payload := make([]byte, payloadLen)
if _, err := io.ReadFull(conn, payload); err != nil {
fmt.Fprintf(os.Stderr, "read payload err: %v\n", err)
return
}
// 4. 整条消息已完整还原
fmt.Fprintf(logw, "%q\n", string(payload))
}
}
关键 API
| API | 作用 |
|---|---|
io.ReadFull(conn, buf) | 精确读够 len(buf) 字节,一次不够就持续读;读到 EOF 返回 io.ErrUnexpectedEOF |
encoding/binary.BigEndian.Uint32() | 把 4 字节大端字节序解析为 uint32(协议约定) |
conn.CloseWrite()(客户端) | 只关闭写方向,通知服务端没有更多数据 |
验证
docker compose exec app bash
cd /workspace/scenario
bash check.sh
输出:
"hello"
"this is a longer message that will be split by a small read buffer"
"abc"
"another moderately long payload over here"
"hi"
"the quick brown fox jumps over the lazy dog 0123456789"
PASS: 6 条消息全部被正确还原,粘包/半包已解决。
经验与避坑
- 永远不要假设「一次 Read = 一条消息」,TCP 不保证消息边界,必须自己实现分帧。
- 用
io.ReadFull而非循环内手动累加:更简洁可靠,遇 EOF 自动返回io.ErrUnexpectedEOF。 - 先读长度头再读 payload:这是最常见的自定协议做法,简单高效。
- 小读缓冲会加剧半包:调试阶段加大缓冲或正确用
ReadFull即可区分"没读够"和"连接关闭"。 - 防御性校验:对 payload 长度设置合理上限(如 64MB),避免恶意/异常包导致内存耗尽。
- 区分 EOF 与 UnexpectedEOF:
io.ReadFull在读到部分数据后连接关闭会返回io.ErrUnexpectedEOF,需妥善处理而非当普通错误。 - Docker 注意:修改宿主源码后需
docker compose up -d --build重建镜像,单纯docker compose exec内不会同步卷外代码。
相关文件
- 修复代码:
scenario/2026-09-08-0000/app/server.go - 完整操作记录:
scenario/2026-09-08-0000/SOLUTION.md - 复现脚本:
reproduce.sh/check.sh
