一个以为"发一次就收一次"的 TCP 通信程序,在高频发送时把多条消息粘成了一坨、又把一条拆成了两半:一次 TCP 粘包拆包的深度复盘
那个 bug 出现在我们一个基于 TCP 自研的设备通信协议上,诡异得让我一度怀疑是网络丢包:设备端每次 send 一条 JSON 消息,服务端 recv 收上来解析。平时好好的,可一到设备高频上报的时候,服务端就开始疯狂报 JSON 解析失败——有时一次收上来的数据里挤着两三条消息粘在一起({...}{...}{...},JSON 解析器直接懵了),有时又只收到半条消息({"temp":2 后面没了),下一次才收到剩下的半条。我一开始以为是设备发错了,抓包一看,设备发得明明白白、一条是一条。我对着这堆"粘在一起又断成两半"的数据查了大半天,才终于想起 TCP 那个被我彻底忽略的本质,后背发凉:TCP 是一个面向字节流的协议,它根本没有"消息"或"包"的概念——它只保证"你发的字节,会按顺序、不丢不重地到达对方",但完全不保证"你一次 send 的数据,对方就一次 recv 收到"。我"发一次就收一次"的假设,从根上就是错的:TCP 会根据自己的缓冲、网络状况,把我连续发的几条消息合并成一次发给对方(粘包),也可能把我一条大消息拆开分几次到达(拆包)。我的协议里没有任何"消息边界"的设计,自然就分不清一坨字节里到底是几条消息、每条到哪结束。这篇就把这次"TCP 粘包拆包"的坑,从头到尾复盘一遍。
故障现场:一个假设"send 一次 = recv 一次"的协议
问题代码,是一个想当然地把 TCP 当成"消息收发"来用的程序:
// ✗ 出问题的代码: 假设"对端send一次, 我就recv一次, 收到完整的一条消息"
func handleConn(conn net.Conn) {
buf := make([]byte, 4096)
for {
n, err := conn.Read(buf) // ✗ 以为这一次Read就是完整的一条消息
if err != nil { return }
msg := buf[:n]
var data SensorData
json.Unmarshal(msg, &data) // ✗ 直接当成一条JSON解析 → 高频时频繁失败!
process(data)
}
}
// 设备端: 连续发送多条消息
// conn.Write([]byte(`{"temp":20}`))
// conn.Write([]byte(`{"temp":21}`))
// conn.Write([]byte(`{"temp":22}`))
// 实际服务端 Read 可能收到:
// 情况A(粘包): 一次Read收到 `{"temp":20}{"temp":21}{"temp":22}` → JSON解析失败(多个对象)
// 情况B(拆包): 一次Read收到 `{"temp:2`, 下次收到 `0}{"temp":21}...` → 解析失败(不完整)
// 情况C(正常): 偶尔恰好一次一条 → 看起来正常(低频时容易这样, 掩盖了问题)
// 为什么:
// - TCP是【字节流】, 不是【消息流】; 它不保证send和recv的次数/边界一一对应;
// - TCP出于效率会合并小包(Nagle算法)或按MSS/窗口拆分大包;
// - → 你连续发的消息可能被【粘】在一次Read里, 一条大消息可能被【拆】到多次Read;
// - 你的协议没有"消息边界", recv端无法知道"一条消息从哪开始、到哪结束"。
// 关键: TCP只保证"字节流有序可靠到达", 不保证"消息边界"; 把TCP当"消息收发"用, 必踩粘包拆包。
第一次想通这一点时,我又懊恼又恍然:"我一直把 TCP 当成'发消息'的管道,原来它只是个'字节流',压根不知道我心里的'一条消息'是什么?"这个坑最隐蔽的地方在于:它在低频、小数据时"大概率正常"——你发一条、对方往往恰好收一条(因为发得慢、数据小,TCP 还没来得及合并),开发测试时手动发几条几乎都正常;它只在高频发送、或消息较大时,TCP 的合并/拆分才频繁发生,粘包拆包才集中暴露。这种"低频伪装正常、高频原形毕露"的特性,让它极易躲过测试、潜入生产。下面就来拆解,TCP 的字节流本质和消息边界。
第一件事:搞懂 TCP 是字节流,没有消息边界
我认真重学了 TCP 的本质,才彻底理解粘包拆包为什么是必然的。
为什么TCP会"粘包/拆包"? —— 因为TCP是【字节流】, 不是【消息流】
【核心: TCP只保证"字节有序可靠到达", 它眼里没有"消息"的概念, 不维护消息边界】
1. TCP 是"面向字节流(byte stream)"的:
- 它把你要发的数据看成一长串【没有结构的字节】;
- 它保证: 这些字节会【按顺序、不丢、不重、不错】地到达对方;
- 但它【不关心】这些字节里, 哪几个字节是"一条消息"——它没有"消息"这个概念。
2. 所以 send 和 recv 的次数/边界, 不对应:
- 你 send 3 次(3条消息), TCP可能把它们【攒在一起】一次发出(为了效率, Nagle算法);
- 对方可能一次 recv 就收到这一坨(粘包: 3条粘成1次);
- 你 send 1 次但消息很大(超过MSS), TCP会【拆成多个段】发送;
- 对方可能要 recv 多次才收齐(拆包: 1条拆成多次)。
3. 为什么TCP这么设计?
- 它的职责是"可靠地传输字节流"(像水管送水, 只管水有序流过去);
- "一条消息是什么"是【应用层的概念】, TCP(传输层)不该也不会去理解;
- → 维护"消息边界"是【应用层(你)自己的责任】, 不是TCP的。
4. 对比 UDP:
- UDP是"面向数据报(datagram)"的, 一个send对应一个recv, 保留消息边界;
- 但UDP不可靠(会丢、会乱序)。TCP用"字节流+可靠"换掉了"消息边界"。
类比: TCP像一条"水管", 你倒进去3杯不同颜色的水, 它只保证水按顺序流到对面,
但到对面时, 3杯水可能【混流】出来(分不清原来是3杯)——水管不记得你"分了几杯"。
一句话: TCP是面向字节流的, 只保证字节有序可靠到达、不维护消息边界; send/recv次数不对应,
消息会粘会拆; "区分消息边界"是应用层自己的责任, 必须在协议里自己设计。
这套本质,是整个坑的根。TCP 是面向字节流的:它把数据看成一长串没有结构的字节,保证它们按顺序、不丢不重地到达,但不关心哪几个字节是"一条消息"——它没有"消息"这个概念。所以 send 和 recv 的次数/边界不对应:你 send 3 次 TCP 可能攒在一起一次发出(对方一次 recv 收到一坨 = 粘包);你 send 一条大消息 TCP 会拆成多段(对方要 recv 多次才收齐 = 拆包)。为什么这么设计?TCP 的职责是可靠传输字节流(像水管送水),"一条消息是什么"是应用层的概念,维护消息边界是应用层(你)自己的责任。对比 UDP 面向数据报、保留消息边界但不可靠;TCP 用"字节流+可靠"换掉了"消息边界"。就像水管倒进 3 杯不同颜色的水,到对面可能混流出来——水管不记得你分了几杯。一句话:TCP 面向字节流、只保证字节有序可靠到达、不维护消息边界;send/recv 次数不对应、消息会粘会拆;区分消息边界是应用层自己的责任,必须在协议里自己设计。
第二件事:正解——在应用层自己定义"消息边界",并循环读直到收齐
搞懂了原理,正解就清晰了:在应用层协议里加上"消息边界"——最常用的是"长度前缀"(每条消息前面带它的长度),或"分隔符";收端按边界循环读、缓冲不完整的部分,直到拼出完整一条再处理。
// ====== 正解一(推荐): 长度前缀(length-prefixed)协议 ======
// 约定: 每条消息 = 4字节长度头(表示后面消息体有多少字节) + 消息体
// 发送: 先写长度, 再写消息体
func sendMsg(conn net.Conn, body []byte) error {
var head [4]byte
binary.BigEndian.PutUint32(head[:], uint32(len(body))) // 长度头
if _, err := conn.Write(head[:]); err != nil { return err }
_, err := conn.Write(body)
return err
}
// 接收: 先读4字节长度, 再【精确地】读那么多字节作为消息体
func readMsg(r *bufio.Reader) ([]byte, error) {
var head [4]byte
if _, err := io.ReadFull(r, head[:]); err != nil { // ★ ReadFull: 读满4字节才返回
return nil, err
}
n := binary.BigEndian.Uint32(head[:])
body := make([]byte, n)
if _, err := io.ReadFull(r, body); err != nil { // ★ 精确读n字节(自动处理拆包)
return nil, err
}
return body, nil // ← 返回的就是完整的一条消息, 不粘不拆
}
// → io.ReadFull 会一直读到读满指定字节数为止, 自动跨越多次底层Read(解决拆包);
// 按长度头精确读取每条消息, 也不会把下一条粘进来(解决粘包)。
// ====== 正解二: 分隔符(delimiter)协议, 适合文本协议 ======
// 约定: 每条消息以 '\n' 结尾(消息体内不能含'\n', 或要转义)
func readLineMsg(r *bufio.Reader) (string, error) {
line, err := r.ReadString('\n') // ★ 一直读到遇到'\n'(自动处理粘包拆包)
return strings.TrimRight(line, "\n"), err
}
// → 很多文本协议(HTTP头、Redis的RESP、SMTP)都用 \r\n 做分隔符。
// ====== 正解三: 直接用成熟的协议/框架, 别自己造 ======
// - 用 HTTP / gRPC / WebSocket 等成熟协议(它们都已处理好消息边界);
// - 用 Netty(Java)的 LengthFieldBasedFrameDecoder、Go的bufio等, 内置粘包拆包处理;
// - 用 Protobuf + 长度前缀 做高效的二进制消息。
// ====== 核心思路: 收端要"缓冲 + 按边界切分" ======
// 1. 把收到的字节追加到一个缓冲区;
// 2. 反复检查: 缓冲区里够不够"一条完整消息"(够长度头? 够消息体? 有分隔符?);
// 3. 够一条就切出来处理, 不够就继续等下次Read补齐 —— 直到把所有完整消息都取出。
// 核心: TCP没有消息边界, 必须在应用层协议里自己定义(长度前缀/分隔符); 收端用ReadFull/缓冲
// 按边界切分、循环读到收齐再处理; 或直接用HTTP/gRPC/Netty等已处理好边界的成熟方案。
修复的核心,是"应用层自己定义并维护消息边界"。正解一(推荐):长度前缀协议——每条消息 = 4 字节长度头 + 消息体;收端先读 4 字节长度、再用 io.ReadFull 精确读那么多字节(ReadFull 会读满才返回、自动跨越多次底层 Read 解决拆包;按长度精确读也不会粘进下一条)。正解二:分隔符协议——每条消息以 \n 结尾,ReadString('\n') 读到分隔符(很多文本协议如 HTTP 头、Redis RESP 都这么做)。正解三:用成熟协议/框架(HTTP/gRPC/WebSocket、Netty 的 LengthFieldBasedFrameDecoder)它们都已处理好边界。核心思路是收端要缓冲 + 按边界切分:把收到的字节追加到缓冲区,反复检查够不够一条完整消息,够就切出来处理、不够就等下次补齐。归根结底:TCP 没有消息边界,必须在应用层协议里自己定义(长度前缀/分隔符);收端用 ReadFull/缓冲按边界切分、循环读到收齐再处理;或用 HTTP/gRPC/Netty 等成熟方案。
第三件事:TCP / 网络编程的其他常见坑
排查后我把 TCP 网络编程相关的其他常见坑也系统梳理了一遍。
TCP / 网络编程的其他常见坑
# 1. 粘包拆包(本文): 把TCP当消息流。→ 应用层定义消息边界(长度前缀/分隔符)。
# 2. 没循环读/没读满: 一次Read不一定读完, 要循环读到期望字节数(io.ReadFull)。
# 3. 没设读写超时: 对方不发/不收, 你的Read/Write永久阻塞。→ SetReadDeadline等。
# 4. CLOSE_WAIT堆积: 对端关了连接你没close, socket泄漏。→ 收到EOF要close连接。
# 5. TIME_WAIT堆积: 大量短连接主动关闭方累积TIME_WAIT。→ 用长连接/连接池复用。
# 6. 忽略Write的返回值: Write可能没写完全部(返回写了多少), 要循环写完。
# 7. 大消息一次性读进内存: 没限制消息大小, 恶意/异常的超大长度头导致OOM。→ 校验最大长度。
# 8. 字节序问题: 多字节数字的长度头, 收发要约定同样的字节序(大端/小端)。
# 共同根源: 把"高层的、有边界有保证的抽象"(消息收发)的期待, 套在"低层的、字节流"的TCP上;
# TCP给的是"可靠字节流"这个原语, 上面的消息、超时、连接管理都要应用层自己正确处理。
# 核心: 理解TCP是可靠字节流(无消息边界); 应用层自定义消息边界、循环读写到完整、设超时、
# 管好连接生命周期(close/复用)、限制消息大小; 或直接用成熟协议框架省去这些坑。
排查让我把网络编程的其他坑也梳理清了。一、粘包拆包(本文)。二、没循环读/没读满(用 io.ReadFull)。三、没设读写超时(永久阻塞)。四、CLOSE_WAIT 堆积(收到 EOF 要 close)。五、TIME_WAIT 堆积(用长连接/连接池)。六、忽略 Write 返回值(要循环写完)。七、大消息读爆内存(校验最大长度)。八、字节序问题。它们的共同根源是:把"高层的、有边界有保证的抽象(消息收发)"的期待,套在"低层的、字节流"的 TCP 上;TCP 给的是"可靠字节流"这个原语,上面的消息、超时、连接管理都要应用层自己正确处理。核心是:理解 TCP 是可靠字节流(无消息边界);应用层自定义消息边界、循环读写到完整、设超时、管好连接生命周期、限制消息大小;或直接用成熟协议框架。下面这张图,是这次粘包拆包坑的成因与解法:
第四件事:几种消息边界方案对比表
这次踩坑后,我把"在 TCP 上定义消息边界"的几种常见方案整理成一张表。
| 方案 | 怎么做 | 适用/特点 |
|---|---|---|
| 长度前缀 | 消息前加N字节长度头 | 通用高效, 二进制首选(推荐) |
| 分隔符 | 消息以\n等结尾 | 文本协议(HTTP头/RESP), 体内不能含分隔符 |
| 固定长度 | 每条消息定长 | 简单但浪费, 适合定长数据 |
| 自描述格式 | 如带结束符的TLV | 复杂但灵活 |
| 用成熟协议 | HTTP/gRPC/WebSocket | 不用自己处理边界(最省心) |
这张表把消息边界方案钉清了。核心是:在 TCP 字节流上"切出一条条消息",方案虽多,本质都是"用某种约定告诉收端:一条消息从哪到哪结束"——长度前缀(我先告诉你我有多长)、分隔符(读到这个符号就是一条的结尾)、固定长度(每条都一样长)、或直接用已经约定好这套规则的成熟协议。它给我的最大启发是:这其实是一个普遍的"分帧(framing)"问题——任何时候你要在一个"连续的流"里区分出"一个个独立的单元",都需要某种"边界标记"的约定;不只是网络:文件里区分一条条记录(换行/定长/分隔符)、二进制流里区分字段、日志里区分一条条日志,本质都是同一个"如何在流里划分边界"的问题。这让我看到了知识的迁移性:把一个具体问题(TCP 粘包),抽象成它背后的通用模式("流的分帧/定界"),你就能用同一套思路去解决一大类看似不同的问题——理解了"长度前缀 vs 分隔符"这对经典权衡,你不仅会处理 TCP 消息,也会设计文件格式、解析协议、切分数据流。把粘包抽象成通用的"流分帧"问题、迁移到一大类场景——是这个坑带给我的认知升级。
第五件事:长度前缀 vs 分隔符的取舍
两种最主流的方案——长度前缀和分隔符,各有优劣。我把它们的取舍整理成表。
| 维度 | 长度前缀 | 分隔符 |
|---|---|---|
| 消息体内容 | 任意(二进制也行) | 不能含分隔符(否则要转义) |
| 收端处理 | 读头→精确读体, 简单高效 | 逐字节找分隔符, 稍慢 |
| 是否需预知长度 | 发送前要知道总长 | 不需要, 边写边发 |
| 典型代表 | gRPC/Protobuf/Redis批量 | HTTP头/Redis命令/行协议 |
| 大消息 | 友好(直接知道多大) | 要一直读到分隔符 |
这张表道出了两种方案的取舍。核心是:长度前缀适合二进制、大消息(内容任意、收端能精确读取、提前知道多大),是高性能 RPC 的主流;分隔符适合文本协议(可读、可流式、不需预知长度),但消息体不能含分隔符(否则要转义);没有谁绝对更好,看你传的是二进制还是文本、消息大不大。它再次印证了我反复体会的那条规律:工程里几乎每个设计选择都是权衡——长度前缀用"需提前知道长度"换了"内容任意+精确读取",分隔符用"内容受限"换了"可流式+简单";理解每个方案"牺牲了什么、换来了什么",才能按场景选对。更进一步,它让我体会到读懂成熟设计的价值:这些"边界方案"不是凭空的——去看 HTTP、Redis RESP、gRPC、Protobuf 这些经过千锤百炼的成熟协议是怎么做分帧的(HTTP 头用 \r\n 分隔、body 用 Content-Length 长度前缀,正是两者结合!),就是最好的学习;"站在巨人的设计上"——研究优秀协议/系统的设计取舍,比自己闭门造车能学到多得多。理解长度前缀与分隔符的取舍、从成熟协议的设计中学习——是这个坑带给我的、关于协议设计的认知。
第六件事:做 TCP 通信时,我现在的检查习惯
现在每当我要基于 TCP 做通信,我都会按这张图先想清楚:
这张图的精髓,是"能用成熟协议就别自研,自研必先定义消息边界"。优先用 HTTP/gRPC 等成熟协议(边界已处理);必须自研就定义消息边界(二进制用长度前缀、文本用分隔符)、收端 ReadFull 精确读并限最大长度;再配齐读写超时和连接管理。这套习惯,让我从"TCP 当消息管道随手用"变成了"用 TCP 先想消息边界和成熟方案"——核心始终是:TCP 是字节流没有消息边界,自研协议必须自己定义边界,能用成熟协议就别造轮子。
我立下的几条规矩
这场"TCP 粘包拆包"的事故,换来了我做网络编程时,刻进骨子里的几条铁律:
- TCP 是字节流,不是消息流。它只保证字节有序可靠到达,不维护消息边界。
- send 一次不等于 recv 一次。消息会被粘在一起、也会被拆成多次。
- 消息边界是应用层自己的责任。必须在协议里用长度前缀或分隔符定义。
- 收端用 ReadFull 精确读、循环读到收齐。一次 Read 不一定读完。
- 限制单条消息最大长度。防异常/恶意的超大长度头导致 OOM。
- 能用 HTTP/gRPC 等成熟协议就别自研。它们已处理好边界和一堆细节。
- 把粘包看成通用的"流分帧"问题。文件、协议、数据流都是同一思路。
写在最后
回头看,这场由"以为发一次就收一次"引发的、消息粘连断裂的事故,真正教给我的,远不止"TCP 要处理粘包拆包"这一个技巧。它让我对"每一层抽象,都有它'保证什么'和'不保证什么'的明确边界,用错了'不保证'的部分就会踩坑",有了一次刻骨的体会。我栽跟头,根源在于我误解了 TCP 这层抽象"到底保证了什么"。我想当然地以为 TCP 保证的是"消息的可靠收发"——发一条、收一条、完整无缺。可 TCP 实际保证的,只是"字节流的可靠、有序传输";"消息的完整边界"从来不在它的保证范围之内——那是它明确不管、留给上层的事。我把一个"它不保证的东西(消息边界)",当成了"它保证的东西"来依赖,这个错误的依赖,就是 bug 的源头。这让我领悟到一个理解分层系统的核心认知:计算机系统是层层抽象搭起来的(物理层→IP→TCP→应用层),每一层都有一份清晰的"契约":它向上层保证什么(provides)、又不保证什么(does not provide)——TCP 保证"可靠有序的字节流",但不保证"消息边界";IP 保证"尽力转发",但不保证"可靠、有序"(那是 TCP 在 IP 之上补的);用一层抽象,关键是分清它的契约边界——哪些它替你保证了(你可放心依赖)、哪些它不管(你必须自己在上层处理)。这给了我一种使用任何抽象时的清醒:每当依赖一层抽象/一个组件/一个库时,都要搞清楚它的"责任边界"——"它承诺给我什么、又把什么明确地留给了我";别把"它不负责的事"当成"它会替我搞定的事"——那些"我以为它会管、其实它不管"的灰色地带,正是踩坑的高发区;看清每层抽象的契约边界,在它保证的范围内放心依赖、在它不保证的地方主动补位,才能稳稳地站在抽象的肩膀上。分清每层抽象"保证什么、不保证什么"、在其契约边界内正确使用——这,是我用一次 TCP 粘包的事故,换来的、关于网络、也关于如何驾驭一切分层抽象的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次基于 TCP 写通信时,先想一句"它只给我字节流,消息边界得我自己来",转而加上长度前缀,那我对着那堆粘连断裂的数据排查的这大半天,就值了。
—— 别看了 · 2026