一文搞懂 TCP 粘包拆包(图解):字节流特性、问题示例与 4 种解决方法
文章目录TCP 粘包和拆包1. TCP 是“字节流”不是“消息流”2. 为什么会粘包3. 为什么会拆包4. 示例4.1 粘包发送两条接收端一次读到两条4.2 拆包发送一条接收端分两次读到5. TCP 层不提供“消息边界”应用必须自己定义协议6. 如何解决粘包拆包问题6.1 定长消息简单但浪费6.2 长度字段最常用例子长度字段6.3 分隔符例如换行符 \n6.4 固定消息头 状态机更复杂但更灵活6.4.1 协议怎么组织消息发送格式6.4.2 接收端准备一个“缓冲区 状态机”接收流程核心6.4.3 状态机阶段一等头/找魔数对齐消息边界6.4.4 状态机阶段二等长度/解析头字段确定还缺多少内容6.4.5 状态机阶段三等内容按长度读齐消息体优缺点优点“鲁棒性强”“可扩展”缺点“实现成本更高”7. 不要直接用 recv() “收一条消息”8. 总结最近在整理TCP 粘包拆包问题故总结本文如有错误请评论区指出TCP 粘包和拆包在写网络编程时经常会遇到一个现象发送的“消息边界”到了接收端却不再“按边界对齐”。比如你以为对方一定一次recv()就拿到你发的一整条消息但实际上可能出现粘包一条消息没分开接收端一次读到了多条“拼在一起”的数据拆包一条消息被拆开接收端分多次读到“半条半条”的数据1. TCP 是“字节流”不是“消息流”TCP 的核心特性是面向字节流Stream。你send()发出去的是一段段字节TCP 只保证这些字节会按顺序到达但 TCP不保证每次send()对应一次recv()因此“消息边界”属于应用层的概念而 TCP 只负责“把字节按序传过去”。TCP 不关心你发的是“几条消息”它只关心“字节顺序”。2. 为什么会粘包粘包一般发生在接收端的recv()读到的字节数比你想象的大于是多个发送的“消息”被读到同一个缓冲区里。常见场景应用连续调用多次send()对方recv()一次读取到了多个消息发送端/接收端处理快慢不同导致边界落在不同的读取点3. 为什么会拆包拆包一般发生在接收端的recv()读到的字节数比你想象的小导致一条消息没读全就返回了。常见原因接收端调用recv()时缓冲区里目前只有部分数据网络分段/延迟导致消息还没到齐就已经开始读取4. 示例4.1 粘包发送两条接收端一次读到两条假设客户端依次发送两条消息消息1hello消息2world客户端伪代码send(sock,hello,5);send(sock,world,5);接收端调用一次nrecv(sock,buf,1024);可能会以为buf里是hello但实际可能是helloworld粘在一起或者甚至hello wor这类不完整情况拆包粘包组合4.2 拆包发送一条接收端分两次读到客户端发送send(sock,abcdefghij,10);接收端第一次recv()只读到了abcd第二次recv()再读到efghij所以应用层需要自己处理“消息完整性”不能假设一条消息一定对应一次recv()。5. TCP 层不提供“消息边界”应用必须自己定义协议既然 TCP 不管“消息边界”那我们就要在应用层定义一种“消息协议”让接收端知道一条消息从哪里开始一条消息到哪里结束如何从字节流中解析出完整消息6. 如何解决粘包拆包问题6.1 定长消息简单但浪费假设每条消息固定长度比如 100 字节发送时不足补空格或填充接收时按 100 字节为一条消息优点实现简单缺点浪费带宽灵活性差6.2 长度字段最常用协议形式前 4 字节表示消息长度 L网络字节序后面跟 L 字节的内容[4字节长度L][L字节内容]接收端逻辑先读到长度字段4 字节再根据 L 去循环读取直到读满 L 字节组装成完整消息交给业务处理例子长度字段发送[00000005][hello]接收如果第一次recv()只拿到长度字段和部分内容也没关系只要循环读取直到“长度指定的内容齐全”就不会拆包/粘包影响解析“先知道要读多少再读多少”。6.3 分隔符例如换行符\n协议形式一条消息以某个分隔符结尾比如\n接收端按分隔符切割例如发送hello\nworld\n接收端累积字节直到发现\n才认为得到一条完整消息。优点协议直观缺点需要处理内容中可能出现分隔符的问题通常需要转义/约定分隔符问题如果消息内容里出现了\n接收端无法区分这个\n是“协议用的结束符”还是“消息正文的一部分”6.4 固定消息头 状态机更复杂但更灵活6.4.1 协议怎么组织消息发送格式每条消息都以固定消息头每条报文开头都按同一格式排列接收端按固定结构去解析开头。固定消息头里一般包含魔数固定字节序列用来确认“此处就是一条协议消息的开始”用于快速对齐边界版本协议版本号用于将来兼容不同协议格式长度后续消息体的字节数用于精确知道还需要读多少字节类型消息业务类型/指令编号用于让接收端按不同类型做不同处理消息主体紧跟在消息头后面6.4.2 接收端准备一个“缓冲区 状态机”接收流程核心接收端维护一个缓冲区把recv()得到的字节暂存起来直到凑够一条消息才能解析。接收端使用状态机按阶段逐步解析字节流等头/等长度/等内容解析到哪一步就停在哪一步直到满足条件才推进。6.4.3 状态机阶段一等头/找魔数对齐消息边界状态等头尝试在缓冲区中找到消息头的起点。做法从缓冲区开头开始检查是否是魔数固定字节序列确认消息起始位置。如果对不上丢弃部分字节并继续搜索魔数目的是从“错位”的字节流中重新对齐下一条消息的起点。这里的目标是保证后续字段版本/长度/类型都是从“正确的消息开始位置”读出来的。6.4.4 状态机阶段二等长度/解析头字段确定还缺多少内容状态等长度解析出长度字段后才知道要继续等哪些字节。做法缓冲区中至少要有“版本 类型 长度”这些字段的字节数。解析出版本协议版本号用来决定解析规则。解析出类型用来分发处理逻辑。解析出长度消息体字节数告诉接收端还要等length字节的内容。这里的目标是知道这一条消息的“边界在哪里”。6.4.5 状态机阶段三等内容按长度读齐消息体状态等内容等待直到缓冲区里累计到足够的消息体字节。做法如果当前缓冲区的字节数 长度消息体需要的总字节数就继续recv()补齐。如果当前缓冲区的字节数已经够了从缓冲区取出消息体内容长度指定的业务数据部分。组装成一条完整消息{版本, 类型, 内容}将已消费的字节从缓冲区移除。状态机回到“等头”解析下一条消息。这里的目标是无论 TCP 怎么粘包/拆包都能保证每条消息被“按边界精确切出来”。优缺点优点“鲁棒性强”鲁棒性遇到粘包/拆包/异常字节时仍能稳定解析并尽量恢复不容易错位崩坏主要来自两点魔数用于恢复对齐避免解析从错误位置开始长度用于读到恰好完整的消息体避免把半包当整包因此即使一次recv()只到半条、或者一次到两条状态机仍能正确拼装并分割。“可扩展”可扩展未来增加字段/新增消息类型/调整协议时不必彻底推翻现有解析主要来自版本允许新旧协议并行不同版本走不同解析规则类型新增业务功能时只需增加类型分支不影响基础帧格式缺点“实现成本更高”需要实现状态机多阶段解析 缓冲区管理不是简单 recv 一次就完事需要处理边界与异常情况缓冲区不足时如何等待魔数对不上如何滑动查找长度异常过大/过小/不合理时如何保护未知版本/类型时如何兼容或丢弃7. 不要直接用recv()“收一条消息”常见错误做法不可靠nrecv(sock,buf,sizeof(buf),0);process(buf,n);// 认为 buf 就是一条完整消息正确做法通常是建立接收缓冲区积累字节按协议解析长度字段/分隔符等不够就继续读够了才交给业务8. 总结TCP 是字节流不保证消息边界粘包/拆包是正常现象不是网络“坏了”必须在应用层定义协议常用方案长度字段或分隔符或定长