
1. 这个标题背后到底在聊什么问题做了几年网络编程特别是和长连接、消息推送、IoT 设备打交道比较多的同学对“自定义协议 序列化 粘包问题”这组词应该不陌生。这三个词组合在一起其实是在讲一个非常具体的场景你要在 TCP 之上传输结构化的业务数据比如一个订单、一条传感器读数、一行聊天消息那么数据怎么组织、怎么编码、接收方怎么把一串连续到达的字节正确地切成一条条完整的消息——这个链路里每一步都有讲究踩坑也基本集中在这几个点上。先说为什么需要自定义协议。TCP 本身只是把你给的字节流原封不动地搬到对端它不管这段字节在业务上代表什么更不会帮你把多包数据和单个消息对应起来。HTTP、WebSocket 这些成熟协议能直接给你消息边界但很多场景下它们太“重”了基于文本解析头部开销大连接管理复杂实时性也不可控。我做过的几个项目里有的是嵌入式设备和服务端通信一个完整报文就几十个字节用 HTTP 恨不得一半流量都是头部有的是需要控制帧率和服务质量的实时传输HTTP 的请求响应模型根本不适合主动推送。这时自己设计一套轻量协议就成了必然选择。“序列化”这个词其实是顺着协议设计自然产生的。既然要在自定义协议里传结构化数据就得把对象变成字节这就是序列化接收方再把字节还原成对象就是反序列化。选 JSON、XML 还是二进制格式语义清晰度、体积、解析性能完全不一样。而“粘包问题”是这个链路里最经典也最容易踩的坑。它并不是 TCP 本身有什么毛病而是 TCP 的字节流特性和“一条消息一个包”的直觉发生了冲突。发送方可能一次 write 了多条消息接收方一次 read 却只能拿到半个消息或者发送方自己就把一条消息拆成了两次 write接收方必须依靠约定的边界规则来重组。解决粘包问题的方案本质上就是你自定义协议的一部分。这篇文章想讲的就是把这套东西从头到尾讲通透协议头怎么设计、序列化怎么选型、粘包拆包怎么写不踩坑以及实战中可能遇到的那些“文档里根本没有但真实项目里一定会遇到”的问题。适合正在做网络编程、嵌入式通信、游戏后端或者纯粹想搞懂网络协议底层逻辑的人。看完之后你自己完全有能力基于 TCP 设计一套可靠、跨平台、可扩展的应用层协议。2. 整体思路拆解为什么自定义协议这么设计2.1 TCP 字节流带来的第一个认知转变理解自定义协议首先要理解 TCP 的一个本质特性它不给你保留任何消息边界。你 write 两次接收方可能一次 read 就把两次的数据都读走你 write 一次接收方也可能分三次才读完。这在 TCP 内部是由 MSS最大分段大小、接收窗口、Nagle 算法共同导致的但站在应用层看就是一个行为你拿到的只是一个连续的字节数组必须自己定义“从哪个字节开始是一条新消息到哪个字节结束”。这个认知的转变很重要因为它决定了自定义协议的第一个核心任务边界判定。边界判定没有银弹业界这么多协议本质上只有三种思路边界方案基本原理优点缺点代表协议定长协议每条消息固定字节数实现极简单CPU 开销最低浪费带宽只能传固定结构极少使用分隔符协议消息间用特殊字符分开可读性好调试方便消息内容不能包含分隔符需要转义Redis RESP、SMTP按行长度前缀协议头部固定字节声明 payload 长度短包时省字节解析效率高需要额外处理半包、粘包MQTT、TCP 私有协议的主流三种方案里长度前缀是应用最广的因为它对带宽的利用率和解析性能最均衡。我自己的经验是短消息几十字节级尤其适合长度前缀因为一个两字节的长度字段只占两个字节而分隔符方案还需要扫描整个 payload 去找分隔符浪费 CPU定长方案在结构复杂时会成倍浪费带宽除非是温度传感器那种“永远就是四个字节”的场景否则不推荐。2.2 有了 Serialization协议设计就多了一个维度边界问题理清楚之后第二个核心问题是一条消息内部数据到底是什么样的格式这里就是序列化方案选型的主场。文本协议和二进制协议的分水岭就在这里。用 JSON 传数据语义清晰调试时可以肉眼看到内容业务字段增删很随意尤其是和 Web 前端联调时对方直接用 JSON 解析器就能处理几乎不写额外代码。但 JSON 的体积是真的难看字段名全都有重复的字符串加之文本转数字也要分级解析高吞吐场景下性能远不如二进制。二进制协议则相反一切数据裸奔在字节里整数就是固定几个字节字符串前面带长度字段顺序写死在协议里。体积小编解码快但对端必须严格按照定义来解析调试时没法直接“看到”内容协议变更也要两端的版本严格对应。实际项目里我通常把这个决策做成一个权重表如果客户端是浏览器或脚本语言优先文本协议联调速度快出问题好排查如果两端都是自己可控的比如 C 服务端 C 端设备且流量敏感、机器资源紧张果断二进制如果传输层带宽极其宝贵比如 NB-IoT 模组按流量计费那体积是第一优先级二进制是唯一选择。很多刚入门的同学会问“为什么不用现成的序列化框架比如 JSON 随便用或者直接 Protobuf”这个问题问得对。下一节会专门把序列化的几个选型展开对比讲清楚它们的取舍和应用场景。序列化和协议设计的关系就像房屋的装修和户型设计你先定户型协议帧格式再定装修风格序列化格式两者必须兼容不能颠倒顺序。3. 核心细节解析协议头设计、序列化选型与粘包解决3.1 协议头设计不能只是画个“长度 数据”就完事儿很多人写自定义协议时第一版往往长这样4字节消息长度 若干字节负载这个设计在简单 demo 里完全没问题但放到生产环境里很快就会发现不够用。第一是没法区分数据是文本还是二进制第二是没法做协议升级兼容——旧版本客户端发来的包新版本服务端怎么判断它是哪个版本第三是缺乏校验手段传输过程中万一出现一个位错误接收方会把瞎解析出来的数据当做合法数据后果可能很严重。一个经得起实际考验的协议头至少要包含下面几个字段。我直接给出一个我反复用过的帧头模板0 1 2 3 4 5 6 7 ------------------------------------------------ | 魔数 | 魔数 | 版本 | 类型 | 标志 | 扩展 | 长度高 | 长度低 | ------------------------------------------------魔数Magic Number固定两个字节比如 0xA1 0xFE。作用是快速识别“这确实是我这套协议的包”。如果接收方读到的头两个字节不是约定的魔数说明数据错位或者根本不是本协议的数据可以直接拒收或者等待重新同步。零代价的过滤器强烈建议加。版本号Version一个字节标记协议主版本。协议升级时旧包还能被识别并走兼容分支。沒有这个字段线上协议升级等同于裸奔。消息类型Type一个字节表示业务消息的类型比如 0x01 是心跳0x02 是业务数据0x03 是 ACK。接收方拿到之后可以直接分流处理避免每次都要先反序列化 payload 才知道是什么。标志位Flags一个字节的位图比如 bit0 表示 payload 是否压缩bit1 表示是否需要 ACK。一个字节 8 个标志位基本覆盖了未来扩展的大部分需要。扩展字段Reserved一个字节留作未来使用默认全 0。协议这东西上线时觉得设计够用了三个月后就会发现少个字段。保留位能救场。长度字段两个字节表示 payload 的字节数。支持的最大 payload 长度就是 65535 字节大部分业务场景足够如果消息体可能超过这个值可以把长度字段扩到 4 字节。你可能注意到我在长度字段上用了大端字节序。这里必须统一约定网络字节序大端和主机字节序小端在不同的 CPU 平台上不一样如果不约定大端x86 上写出的整形数字在 ARM 设备上解析出来就是反的。最简单的做法是协议里统一全部用大端或者全部小端别混用。我在实际项目里吃过这个亏协议文档没写死字节序Windows 服务端和嵌入式 Linux 设备互相不认对方的整数最后排查了半天才发现是字节序问题。3.2 序列化方案选型JSON、手写二进制还是 Protobuf序列化方案决定了 payload 区域的样子也决定了编解码的复杂度和体积。这里我把主流方案放一起对比方案可读性体积编解码性能跨语言能力适用场景JSON 文本极好大中极好几乎所有语言原生支持Web、调试期、跨团队联调XML 文本好更大慢好存量金融/企业系统手写二进制差最小最快需要两端自行实现嵌入式、高性能自研链路Protobuf差小快好官方多语言支持跨语言、需要版本兼容的正式产品这几个方案里Protobuf 和手写二进制是我最常用的两套。先说 Protobuf。它的优势在于三点一是自带 schema字段以编号标识而非名字因此向后兼容性好增删字段不会破坏老对端二是编码后的体积接近手写二进制的水平三是官方提供 C/Java/Python/Go 等多语言实现团队跨语言协作时只要共享一份 .proto 文件所有端生成的代码行为一致省掉了大量联调成本。缺点也很明显——用 Protobuf 你得在开发流程里多一步“编辑 proto 生成代码”调试时抓到的包肉眼完全看不出内容必须依赖工具解码排查问题的链路会比 JSON 长一些。手写二进制则是另一种极端完全不依赖外部框架自己按字节码布局填充字段。它的好处是你可以把每一个字节都用在刀刃上协议精简到一个字段都不浪费解析也是纯指针移动、无任何中间对象分配吞吐量能拉到极致。代价则是每一个字节都是自己维护加一个字段要同步改发送端、接收端、协议文档任何一个“忘了改”的环节都可能让两端解析错位。给你一个具体的选择逻辑如果你是做一款面向海量设备的 IoT 平台设备端内存只有几十 KB一个月流量只有几 MB那就别犹豫直接手写二进制协议紧凑地塞下每个字段。如果你做的是两个微服务之间的内网 RPC追求开发效率和跨语言协作Protobuf 是比 JSON 更合适的答案虽然它牺牲了肉眼可读性但是换来了解析性能和版本兼容性。如果你只是写一个内部管理系统的前后端接口那么 JSON 依然是最合理的方案——体积和性能的劣势在局域网内根本不构成问题而开发效率是实打实的收益。3.3 粘包与半包不解决它前面设计得再好也白搭粘包问题的根源在 2.1 节已经讲过了但很多人虽然在概念上懂真正写代码时还是会处理错。这里我讲清楚它的两种变形第一种变形粘包。发送方连续发送两条消息 M1、M2接收方一次 read 却读到了 M1 M2 的完整字节流。这时如果接收方只按“读一个包就解析一条消息”的简单逻辑处理就会把 M1 和 M2 混合解析成一条脏数据。粘包的本质是 read 到的数据量大于一条消息的实际长度。第二种变形半包。发送方只发送了一条较长的消息 M但接收方一次 read 只拿到了 M 的前半部分后续部分要等下一次 read 才能到。如果接收方强行解析会因为 payload 长度不足、或者校验头不完整而出错。更典型的场景是接收方第一次 read 拿到了 M1 的完整数据加上 M2 的开头几个字节下一轮必须把剩余字节继续解析成 M2——这就是“半包 粘包”的混合态。解决思路就一句话接收方不能假设每次 read 到的数据恰好就是一条完整消息必须在应用层维护一个缓冲区把所有收到的字节追加进去然后按协议头里的长度字段从缓冲区头部尝试解析一条消息解析出一条就走一条直到剩余数据不足以构成下一条完整消息为止。这个思路是一个通用模板具体实现我放到下一节用代码完整演示一遍。这里先把核心原则立住协议是协议读写是读写。你的 write 不用管对方的 read 怎么切分只需要保证写出的每条消息都符合协议格式你的 read 只负责把字节流灌进缓冲区解析器负责从缓冲区里取出完整消息。两者解耦粘包问题就自然解决。4. 实操过程完整走一遍从零写一个长度前缀 JSON payload 的协议栈4.1 协议定义与数据包布局为了让你能照着直接写代码我挑了一套典型组合来演示长度前缀二进制帧头 JSON payload。这个组合的好处在于帧头部分让你体验二进制协议的精简和高效payload 部分又保留 JSON 的调试友好性很多 IoT 网关和游戏后端都是这么干的。帧格式定义为------------------------------------------------ | 魔数1 | 魔数2 | 版本 | 类型 | 标志 | 保留 | 长度高 | 长度低 | ------------------------------------------------ | payloadJSON | ------------------------------------------------魔数固定为0xFE 0x01两个字节版本固定为0x01类型字段用十个整数代表十种消息类型比如心跳是 1登录是 2业务数据是 3标志字段先只用 bit0 表示“是否需要 ACK”其他 bit 全部置 0保留字段置 0。长度字段两个字节按大端序存放 payload 的字节数。为什么魔数不用0xFF 0xFF因为网络上读到0xFF开头的字节会更常见误判率相对高一些而0xFE 0x01这种少见的组合更能降低错包误判概率。这种“小技巧”在协议设计里十有八九都是靠踩坑总结出来的。4.2 发送端的编码实现发送端逻辑很简单构造 JSON 字符串转成 byte 数组然后按帧头模板填充各个字段最后把帧头数组和 payload 数组拼接起来一次性 write 出去。这里有个容易出错的细节一定不要分成两次 write先写帧头再写 payload。连续两次 write 中间很可能插入其他线程或系统层的数据导致接收端收到的字节顺序错乱。合并成一次 write才能保证一个完整消息逻辑上“一口气”发出去。下面我给出一个完整示例语言用了 Java主要是为了贴近后端开发人群逻辑同样可以直接翻译成 Go、C 或 Rust。public class MessageCodec { private static final byte MAGIC_1 (byte) 0xFE; private static final byte MAGIC_2 0x01; private static final byte VERSION 0x01; // 根据业务 JSON 生成完整协议帧 public static byte[] encode(int msgType, byte flags, String payloadJson) throws Exception { byte[] payload payloadJson.getBytes(StandardCharsets.UTF_8); int payloadLen payload.length; if (payloadLen 65535) { throw new IllegalArgumentException(payload too long: payloadLen); } ByteBuffer buf ByteBuffer.allocate(8 payloadLen); buf.order(ByteOrder.BIG_ENDIAN); buf.put(MAGIC_1); buf.put(MAGIC_2); buf.put(VERSION); buf.put((byte) msgType); buf.put(flags); buf.put((byte) 0); // reserved buf.putShort((short) payloadLen); buf.put(payload); return buf.array(); } }这段代码里有几个细节值得抠一抠。第一个是payloadLen 65535的判断因为长度字段只占两个字节超过这个范围的 payload 根本塞不进帧头必须在编码阶段就报错而不是让接收方拿到一个诡异的截断包。第二个是ByteBuffer的order(ByteOrder.BIG_ENDIAN)确保长度字段按大端写入不写这一行JVM 在不同平台上的行为可能不一样。第三个是getBytes(StandardCharsets.UTF_8)必须显式指定 UTF-8不能依赖平台默认字符集否则同一个 JSON 字符串在 Windows 和 Linux 上可能编出不同字节长度接收端解析时极容易出偏差。4.3 接收端的解码与拆包实现接收端是处理粘包的核心所在。我直接写一个通用拆包器的骨架这个骨架稍加修改就能适配绝大多数长度前缀协议public class FrameDecoder { private final ByteArrayOutputStream buffer new ByteArrayOutputStream(); // 每次 socket read 到新数据后调用 public ListFrame feed(byte[] incomingData) throws Exception { buffer.write(incomingData); // 1. 追加到缓冲区 ListFrame frames new ArrayList(); // 2. 循环尝试从头部解析 while (true) { byte[] buf buffer.toByteArray(); if (buf.length 8) { // 连帧头都不完整等待下一次 read break; } // 检查魔数 if ((buf[0] 0xFF) ! 0xFE || (buf[1] 0xFF) ! 0x01) { // 没有对齐帧头说明数据错位这里直接重置缓冲区或者做滑动扫描 throw new ProtocolException(magic number mismatch); } int payloadLen ((buf[6] 0xFF) 8) | (buf[7] 0xFF); int totalLen 8 payloadLen; if (buf.length totalLen) { // 完整消息还没收完等下一次 read break; } // 3. 完整消息到了切出来 byte[] frameData Arrays.copyOfRange(buf, 0, totalLen); Frame frame parseFrame(frameData); frames.add(frame); // 4. 从缓冲区中移除已经消费掉的字节 // 实际项目里建议用一个环形队列或者直接用偏移量游标 // 频繁 toByteArray copyOfRange 在大流量下会产生明显的 GC 压力 buffer.reset(); buffer.write(Arrays.copyOfRange(buf, totalLen, buf.length)); } return frames; } private Frame parseFrame(byte[] frameData) throws Exception { ByteBuffer buf ByteBuffer.wrap(frameData); buf.order(ByteOrder.BIG_ENDIAN); buf.get(); buf.get(); // magic buf.get(); // version int msgType buf.get() 0xFF; byte flags buf.get(); buf.get(); // reserved int payloadLen buf.getShort() 0xFFFF; byte[] payload new byte[payloadLen]; buf.get(payload); String json new String(payload, StandardCharsets.UTF_8); return new Frame(msgType, flags, json); } }这个拆包器的核心价值在 while(true) 循环这一段它不是在“两条消息同时到达”时只解析第一条而是解析完一条之后立刻看缓冲区里还有没有剩余完整消息有就继续解析。这正是处理和拆包混合场景的标准姿势。如果你只处理一条就 return那粘包问题永远解决不了。代码里的buffer.reset()加上copyOfRange在真实高并发场景下会有性能问题我在注释里已经标注了。生产环境建议用带读指针和写指针的字节缓冲区或者直接用 Netty 的ByteBuf配合LengthFieldBasedFrameDecoder后者其实就是一个高度优化的拆包器。不过用原始实现演示能让你彻底看清背后的运行机制用框架等你看懂了原理再用效果完全不一样。4.4 关键细节大小端、UTF-8 和 ByteBuffer 的坑位这一节值得你反复看几遍因为这三个东西是新手最容易写错、写错又特别难排查的地方。第一个是大小端。Java 的putShort默认就是大端序但前提是你不改 ByteBuffer 的order改了之后行为就变了。C 语言的htons、Go 的binary.BigEndian、Python 的struct.pack(H, ...)都是为了处理字节序而存在的。协议文档里最好写死“所有多字节整数按大端序传输”然后每种语言的实现都按照这个文档来写不要各自发挥。第二个是 UTF-8。JSON 字符串必须统一用 UTF-8 编码不能把锅甩给系统默认字符集。这个问题最坑的地方是在你自己本地测试时默认字符集恰好是 UTF-8一切无恙上线之后服务端部署在 Windows ServerGBK 默认或者某些旧版 CentOS 上编码一错长度字段算出来的数值和实际字节数不一致接收端解析出一条截断的 JSON反序列化立刻报错。第三个是 ByteBuffer 读取边界。读取的时候要把getShort()的结果用 0xFFFF转成正整数。getShort()返回的是有符号 short如果 payload 长度为 40000它会以负数形态出现直接拿去分配new byte[payloadLen]要么负数组长度直接抛异常要么分配失败。类似的坑在get()读单个字节时也要用 0xFF处理——凡是想把字节当无符号整数的地方都要显式处理。5. 踩坑实录我实际项目中遇过的 5 类经典问题5.1 大消息被拆成多段长度前缀也救不了有次线上服务突然批量出现“payload length too short”的报警排查后发现是业务方上传了一个几 KB 的配置 JSONTCP 把它拆成了多个 IP 分段传输。接收端第一次 read 只拿到了前 64 字节其中长度字段明明写着 4000但缓冲区长度只有几十于是代码直接就 break 了。问题其实不在拆包器而在业务代码里“一次 read 必须解析出一条消息”的错误假设——很多同学改了拆包器还是报错就是卡在这个思维上。正确的解法就是 4.3 节的 while 循环读取永远只负责追加缓冲区解析永远只在“缓冲区足够完整消息长度”时才进行。TCP 给你多少数据不重要重要的是缓冲区里能不能凑够一个完整的帧。大消息被切成十段那就让拆包器等十次 read不要第 1 次就抢着解析。5.2 协议升级后魔数校验直接废掉老包一次给设备做固件升级新固件把协议从版本 1 升级到版本 2payload 格式从 JSON 换成了二进制帧头版本号也改成 0x02。上线后大量设备掉线排查发现老设备固件里的版本号还是 0x01但帧头魔数是同一个。老设备收到新服务端发来的版本 0x02 的包时版本检查不过直接丢弃整包链路全部被“重连 - 丢弃”循环卡死。这次踩坑之后我在协议设计里都会加一条铁律版本号的检查一定要分阶段。第一个阶段只校验魔数魔数对了就说明包是自家协议的第二个阶段再看版本号版本不兼容时不是直接丢包而是发一个带版本信息的业务响应让对端知道需要升级协议。直接把老版本判定为非法包处理是最省事但最伤人的做法一旦多端并存这种粗暴策略造成的线上事故比协议不一致本身还严重。5.3 字节序错了Beacon 扫描到的全是乱码有次做服务端和智能手表通信手表上报一串轨迹坐标。服务端解析出的数据里每个坐标点都“看起来很奇怪”——x 坐标特别大y 坐标特别小数据完全对不上。排查了字段偏移、封包长度、JSON 字段名都没毛病最后把坐标的原始字节打出来才发现手表端用 little-endian 存整数服务端用 big-endian 解析高低字节全反了。这类问题最高频出现在嵌入式设备和服务端之间因为嵌入式端很多编译器默认用主机字节序服务端则天然按网络字节序大端处理。解决方式没有捷径协议文档里必须写清楚字节序代码里每个多字节整数在编解码处显式调用大小端转换函数决不能依赖“这个平台碰巧是对齐的”。你可以在测试环境里跑一个专门打乱大小端的测试用例一次性堵死这个坑。5.4 让人头疼的“粘包但不成功”的脏缓冲区调试一个下载协议时程序突然进入“read 到垃圾数据 - 解析失败 - 崩溃”的死循环。我用十六进制工具打印缓冲区发现里面混着 HTTP 的GET /字符串——原来是服务端同时监听了同一个端口上的 HTTP 探活请求拆包器把 HTTP 字节流当成了自定义协议包魔数校验自然失败。当时我的代码是直接抛异常于是连接被断开探活方疯狂重连服务端也跟着疯狂抛异常导致 CPU 飙升。这种问题有两个解法。简单粗暴的做法是把魔数校验失败直接当作“非法连接断开拉黑”。复杂但更健壮的做法是引入滑动窗口在缓冲区头部找到下一个魔数对齐点从那个位置重新开始解析并对非法数据记录计数超过阈值再断开。我后来实现的协议栈里选择了后者因为 IoT 场景里会经常出现设备端协议栈异常导致的半截包直接断开会让设备陷入“断开 - 重连 - 再断开”的循环而滑动窗口同步机制能帮助快速恢复对齐。5.5 序列化框架反序列化导致的隐蔽陷阱最后提醒一个反序列化侧的坑。看起来序列化方案直接决定了反序列化的安全性。如果你选择 Java 原生的ObjectOutputStream做序列化那你的协议框架就会自动变成一个反序列化攻击入口——攻击者只要构造一个精心设计的数据payload传给接收端就能让反序列化过程执行恶意代码。这是那些年在 Java 生态里最经典的攻击方式fastjson 的多个漏洞、若干框架的 gadget 链都是这么闹出来的。所以在自定义协议里做序列化选型时我给你的建议是不要用 Java/Python 等语言的原生反序列化机制直接暴露给网络层。优先选择 JSON配合白名单类绑定、Protobuf 这类自带 schema 校验的方案它们天然不执行任意代码安全边界清晰得多。如果必须使用原生序列化至少要在协议层加入严格的类型白名单过滤并确保接收的数据源可信。6. 我的一些最终建议如果要把这套东西真正用在生产环境我会建议你按下面这几步走。先在协议文档里把每一字节的含义、字节序、版本兼容策略、错误处理方式全部写清楚。不要嫌文档麻烦协议这东西两端实现的人不同、排错的人不同文档就是唯一的契约。我自己见过太多“代码就是文档”的项目最后联调时两边都在瞎猜对方的字节布局。再用真实流量压测一遍粘包和半包场景。你可以把发送端的包大小故意改得参差不齐或者在接收端写一个“一次只 read 1 字节”的测试模式验证拆包器是不是真的能坚持到缓冲区凑够完整消息才解析。这种极端测试能帮你发现所有“碰巧能跑”的隐藏 bug。最后多借鉴成熟框架的实现。Netty 的LengthFieldBasedFrameDecoder、Go 的bufio.Scanner、内核里 TCP 的分片重组逻辑它们解决的都是同一类问题——把字节流变成消息流。看一遍它们的源码再回头看你自己的拆包器你会发现很多可以优化的细节。从我个人的使用体验来看自定义协议这件事真正决定成败的往往不是协议头画得有多精巧而是边界条件处理得有多稳。消息边界切分清楚、字节序统一、版本兼容有预案、序列化方案安全可控——把这几个问题解决掉剩下的就是填业务逻辑而已了。提示如果你刚接触这块建议先不要急着上 Protobuf 或者 Netty先用最基础的长度前缀协议手写一遍收发链路把粘包拆包流程彻底跑通。原理通了后面再用什么框架都是顺手的事。