去年做的一个物联网网关项目,服务端用 Java 写,客户端是一批树莓派和小作坊自己焊的采集板。功能本身很土:设备每 5 秒把一次读数发过来,服务端收下入库。头三天一切正常,第四天开始后台出现莫名其妙的空值和乱码,查库里的记录,有时候一条记录里有两个采样值,有时候一条都没有,最离谱的时候解出来的数值是 137.5,上报的温度明明是 27.5。
如果你也在自己写 TCP 长连接服务,或者要给设备、私有协议做服务端,可以先看看这几家的服务器和网络说明,把环境先跑顺再谈业务:dmit(服务器与主机,CN2 GIA 专线针对延迟抖动丢包优化)、Lisahost(服务器与主机,香港为主的带宽与网络资源)
折腾了一个礼拜,最后发现问题不在数据库,也不在设备端,而是最基础的一件事:我一直把 TCP 当成"消息"来用了。
TCP 传的是字节流,不是消息
这是老生常谈但总有人踩的概念。TCP 协议层只负责保证"你发出去的字节,按顺序、一个不少地送到对端",它完全不关心你发送时是怎么分组的。你这边调用一次 send 发 100 字节,对方可能:在一次 recv 里全收到;一次收到 50 字节,剩下 50 字节下次再收到;收到 200 字节因为上一次 send 的尾巴和这一次的头在网络上挨着,中间还被别的连接插了一脚。前两种叫拆包,第三种叫粘包。名字是约定俗成的,不是协议规定的。
为什么会这样?TCP 为了可靠性做了很多事:每个包带序号,接收端按序号重排;丢了就重传;为了不塞满接收方的缓冲区,会做流控。所以数据在网络里的分组边界,跟你应用层的 send 边界没有任何关系。MSS(最大段大小)协商、加解密、代理转发、负载均衡,任何一环都可能重新切分你的字节流。
反过来说,如果你在 UDP 上发消息,天然保留报文边界(虽然不保证到达),所以很多人写 P2P、游戏协议时反而选 UDP。而 TCP 的好处是可靠、有序、有重传,适合业务数据,代价就是得自己处理边界。

四种分帧方案,坑各不相同
要解决边界问题,办法就那么几类,我按实际用下来的顺序说。
一、定长包。每条消息固定多少字节,比如 512 字节,不足补零。实现最简单,接收端按 512 切。但业务一变长就得改协议,而且补零浪费带宽,如果消息实际只有 30 字节,你传了 512 字节,这个开销在高频采集场景下非常难看。
二、分隔符包。用 \n 或者 \r\n 结尾,类似 Redis 的行协议、HTTP 头部的空行。好处是人眼可读、抓包直接看;坏处是如果正文里也可能出现分隔符,就必须转义,而且要处理"半条消息"。用 StringBuilder 一直 append 直到看见分隔符,简单场景够用。
三、长度字段包。这也是我现在固定用的方案:头部固定长度,里面写 body 的字节数。比如 4 字节 magic + 1 字节版本 + 4 字节 body 长度 + 1 字节校验,然后跟 body。接收端先读满头部,拿到长度,再精确读 body 那么多字节,剩下的留在缓冲区里等下次。定长字段要放在最前面,读的时候必须知道它的字节序(Java 用 ByteBuffer 记得设 order(ByteOrder.BIG_ENDIAN),网络协议惯例是大端)。
四、TLV / TLVV。再加一层类型和长度,每条消息里可以塞多个不同类型的字段。扩展性好,但解析代码会明显变复杂,适合协议需要长期演进、字段会持续增加的场景,比如我那个网关后来加了固件版本号、信号强度、CRC 校验,就是慢慢滑向这种结构。

我的实现里踩过的三个细节
第一,缓冲区不能直接用 readLine 或者 readFully 这种一行式 API,要自己维护一个累积缓冲区(ByteBuffer 累积模式,或者 Netty 里的 CompositeByteBuf / cumulation 组件),把不足一个完整帧的字节留着,下次数据到了继续拼。很多人写成"read 一次就解析一次",一遇到拆包就抛异常,其实应该写成"循环:while(缓冲区里有完整帧) 解析一帧"。
第二,长度字段必须校验上限。我见过一个后端没做检查,客户端传了 bodyLength = 0x7FFFFFFF,服务端直接尝试分配 2G 堆内存,一个恶意连接就能把 JVM 打挂。正确做法是取长度后先比对 MAX_FRAME_SIZE,超过就直接断链并记一条告警。
第三,粘包的另一种"假象"更坑:两个请求确实粘在一起了,但你以为粘的是两个完整包,实际是「半个 A + 半个 B」。这种情况只有在有长度字段的时候才能正确切分,分隔符方案反而更容易处理。所以如果协议可以自己定,长度字段是最不容易出错的。

怎么确认问题出在粘包而不是别的地方
我一般是这么定位的,顺序基本固定。第一步,在接收端把每次读到的字节数打出来,看看是不是每次都是 1024 或者某个和业务无关的整数那是典型的拆包,如果每次长度都不固定并且跟发送端对不上,那就是粘包。第二步,把原始字节流用十六进制打到日志里,肉眼比对一下发送端真正写出去的字节和接收端拼出来的字节,差在哪一目了然。第三步,检查你的接收框架有没有自动帮你做缓冲:Netty 的 ByteToMessageDecoder 默认是累积的,会自动帮你处理拆包,你要是自己继承 ChannelInboundHandlerAdapter 又直接用 channel.read(),那基本就是自己给自己挖坑。
顺带说一个我自己的教训:这个问题最早是在查数据库乱码时误打误撞发现的。当时我一直在怀疑编码问题,试了 UTF-8、GBK、重新建表,全没用。后来把十六进制打出来一看,两个采样值直接拼在一起了,长度字段根本没读我最初压根没设计长度字段,就直接按"一次 read 出来就是一条记录"来写,代码能跑通是因为大部分时候运气好,恰好一次 read 到了完整的一条。设备端一并发,这种运气立刻就没了。

一句话总结
TCP 没有消息边界这个事实,决定了任何基于 TCP 的自定义协议都必须自己定义分帧规则。定长省事但浪费,分隔符可读但要转义,长度字段最通用,TLV 最灵活也最复杂。选哪种不重要,重要的是:定下来就别改,因为服务端和几百台设备同时升级是件痛苦的事;长度字段永远要做上限校验;接收端永远按"缓冲区里有几帧就解几帧"的循环来写,而不是来一次数据解一次。
把这个改掉之后,那个网关已经稳定跑了十一个月,再没出现过乱码。回头看,这类问题之所以让人抓狂,是因为它的表现(数据错乱)离根因(协议设计缺了长度字段)太远了,中间隔着一层"TCP 是字节流"的常识而这常识恰恰是很多人第一次用 socket 时跳过的部分。


喜欢
顶
难过
囧
围观
无聊





