进行 Socket 编程时经常会碰到 TCP 的粘包与半包问题,很多时候我们选用 netty 等框架而不直接采用原生的 Socket 编程也是因为 netty 帮我们将该类传输过程中可能出现的问题屏蔽掉了,使我们可以抽出更多精力来关注功能的实现,而不是挣扎在处理这些底层问题上。但尽管如此,我们也必须要对这些问题有所了解。
认识问题
想要了解粘包与半包问题,首先要了解 TCP 报文的发送过程,以传统的 BIO 为例子:
1. 我们调用操作系统提供的系统函数,建立一个 Socket 监听,监听线程会阻塞在 socket 的 accept 方法上,直到有连接请求到来。
2. 有客户端发起连接请求,服务端与客户端进行 三次握手 。三次握手 是操作系统层面的协议栈完成的,我们在应用层编程感知不到,直到三次握手完成,客户端与服务端建立了一个 TCP 连接,我们步骤 1 中阻塞在 accept 方法的线程被唤醒。
3. 连接建立后,操作系统会在 内核空间 为本次连接分配两个缓冲区:发送缓冲区 和 接收缓冲区(体现了 TCP 协议是全双工协议), 我们可以通过 socket 实例拿到这两个缓冲区。之后数据从发送缓冲区封装为 TCP 报文传输到网卡、以及接收到的报文被层层拆包后内容传输到接收缓冲区是操作系统的任务。我们要做的,是建立一个线程扫描接收缓冲区,一旦有数据写入则将数据读入进程空间;同时如果有数据需要发送则将数据写入发送缓冲区。
服务端与客户端就这样,接收缓冲区一旦接收到数据便读取进进程空间,有数据需要发送就写入到发送缓冲区,循环往复直到本次连接完成四次挥手。
我们可以发现,有数据就读,我们并无法得知这些数据的边界。比如,客户端发送了两个报文 AB 和 CD ,因为报文的大小很小,如果两次发送的间隔时间很短的话,很可能 AB 还在发送缓冲区,没有来得及被封装为报文, CD 便也被写入进发送缓冲区了。这样在发送时原本应该是两个报文的数据便会被封装到一个报文中发送给服务端。服务端并无法区别这是两个报文还是一个报文,只知道把数据整个的读入进程空间中,这就是 TCP 的粘包。
再考虑一种情况,我们一次请求中携带的数据非常多,操作系统的协议栈将我们这一次请求分割为了多个报文发送到服务端。多个报文到达后,服务端并无法区别哪些包合并起来是一次完整的请求,这便是 TCP 的半包。
看起来问题的根源在于,将数据从发送缓冲区打包发出和将数据从网卡拆包写入接收缓冲区这两个动作是操作系统完成的,操作系统可能调用了标准I/O库,也可能通过更高层的封装完成这些事情,但不管怎样我们无法控制打包和拆包的时机。
再深入想一下,操作系统中协议栈的实现并没有将打包和拆包时机的控制权交给我们,协议栈是对底层协议的实现,TCP 协议便是这样定义的通讯过程。
也就是说,TCP 协议只负责建立可靠的传输通道,保证数据的准确有序的到达,但 TCP 协议不会帮我们定义数据的边界。
那么问题的根源找到了:
TCP 是流式协议,消息无边界。
(PS : UDP 虽然也可以一次传输多个包或者多次传输一个包,但每个消息都是有边界的,因为 UDP 是无连接的,因此不会有粘包和半包问题。)
解决问题
找到了问题的原因,我们再来考虑解决方案。
既然问题是传输层不帮我们确定消息的边界,那么我们在应用层自己为消息设置边界就好了。
目前主流的解决方案有四种:
1. 将数据封装为帧。也就是数据固定长度,不管你发送了什么,服务端读到固定长度的数据就判定这是一次完整的请求。
2. 通过标识位为数据添加边界。比如换行符,服务端每读到一个换行符便认定,之前读到的数据是一次完整的请求。
3. 通过固定字段标识本次请求的长度。比如我们规定每次发送数据,头两个字节标识本次请求的数据长度。服务端收到请求后先读取两个字节,转换为 int ,后读取该长度的数据。长度用完则标识一次完整的请求读完了。
4. 使用短连接,一次请求只结束便关闭该链接。这样类似 UDP ,为消息添加了天然的边界,但缺点也很明显,频繁的三次握手和四次挥手及其浪费系统资源。
方式 1 不灵活,不能充分利用系统资源,但好在实现简单;方式 2 需要对数据进行转义防止请求内容中包含我们约定的标识,但也好在实现简单;方式 3 比较通用,HTTP 协议 header 中的 Content-length 字段便是用来标识本次请求的长度,但实现较前两种而言更加复杂。
使用哪种方式要结合具体的场景决定,通常情况下推荐使用方式 3 。当然既然是为消息添加边界,方式自然多种多样,比如如果传输的是 json ,可以以 { } 对为边界来判断数据是否完整,类似该类特殊场景下的处理方式不再一一列举。