基于 TCP 的应用层协议如何解决拆包和粘包问题

1 为什么会遇到拆包和粘包问题

TCP 传输协议是一种面向流的通信方式,不具备明确的数据包边界。当客户端向服务器发送数据时,可能会将一个完整的报文拆分成多个较小的报文进行传输,或者将多个报文合并成一个较大的报文发送。因此会出现拆包和粘包现象。

在网络通信中,可发送的数据包大小受诸如 MTU、MSS 和滑动窗口等因素限制。若传输数据超过限制,则数据包可能拆分。若连续请求小数据包,TCP 并不逐个发送,而是通过 Nagle 算法进行优化。

1.1 MTU 和 MSS

MTU 即最大传输单元 (Maximum Transmission Unit), 是指在网络通信中,一次传输中能够发送的最大数据包大小。MTU 主要受网络设备和链路层协议的限制,其值会影响数据包的传输效率和网络性能。

MSS 即最大分段大小 (Maximum Segment Size), 是指在 TCP 协议中,数据段中可包含的最大数据字节数。MSS 主要用于控制 TCP 数据包的大小,以便于在不同的网络环境中更高效地传输数据。它通常基于 MTU 值进行计算,以确保数据包能够在网络中顺利传输,同时避免因过大的数据包导致的分片和重组。

1
2
3
4
5
6
7
            +--------------- MTU --------------+
            |                                  |
+----------++-----------++----------++----------
| Mac Head ||  IP Head  || TCP Head || Data...
+----------++-----------++----------++----------
   14 Byte     20 Byte     20 Byte   |         |
                                     +-- MSS --+

1.2 滑动窗口

滑动窗口 (Sliding Window) 是一种流量控制技术,在 TCP 协议中用于控制发送和接收数据的速率。滑动窗口机制通过动态调整发送和接收方的窗口大小来平衡网络中的数据传输速率,以确保数据传输的高效性和可靠性。

发送方的滑动窗口大小表示其可以发送的未被确认的数据量,而接收方的滑动窗口大小表示其能够接收的数据量。随着数据的传输和确认,窗口会在数据流中滑动,从而实现对数据传输速率的动态调整。当网络状况良好时,窗口大小可能会增大以提高传输速率;而在网络拥塞时,窗口大小可能会减小,以减轻网络拥塞并确保数据可靠传输。

1.3 Nagle 算法

Nagle 算法是一种在 TCP 协议中用于改善小数据包传输性能的技术。它主要解决了网络中频繁发送小数据包导致的拥塞问题。Nagle 算法的工作原理如下:

  • 当发送方有新数据要发送时,先检查是否存在未确认的小数据包(小于 MSS 的数据包)。
  • 如果不存在未确认的小数据包,立即发送新数据。
  • 如果存在未确认的小数据包,检查新数据的大小。
    • 如果新数据大于 MSS,立即发送新数据;
    • 否则,将新数据暂存,等待确认包到达。
  • 当收到确认包时,将缓冲区中的所有数据一起发送。

通过这种方式,Nagle 算法将多个小数据包合并成一个较大的数据包进行发送,从而降低了网络中小数据包的数量,减轻了网络拥塞。Linux 在默认情况下是开启 Nagle 算法的,在大量小数据包的场景下可以有效地降低网络开销。

注意
Nagle 算法可能会对一些实时性要求较高的应用产生负面影响,如网络游戏、VoIP 等。在这些情况下,可以通过禁用 Nagle 算法或使用其他优化策略来改善性能。

2 拆包和粘包问题的解决方案

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
+========+      Send      +========+
| Client | =============> | Server |
+========+                +========+

            +---+   +---+
            | B |   | A |
            +---+   +---+
------------------------------------
               +-------+
               | B | A |
               +-------+
------------------------------------
      +----+   +--------+
      | B2 |   | B1 | A |
      +----+   +--------+
------------------------------------
      +--------+  +----+
      | B | A2 |  | A1 |
      +--------+  +----+
------------------------------------
      +--------------+  +----+
      | A4 | A3 | A2 |  | A1 |
      +--------------+  +----+

在客户端和服务端通信的过程中,服务端一次读到的数据大小是不确定的。如上图所示,拆包和粘包可能会出现以下五种情况:

  • 服务端恰巧读到了两个完整的数据包 A 和 B,没有出现拆包和粘包问题;
  • 服务端接收到 A 和 B 粘在一起的数据包,服务端需要解析出 A 和 B;
  • 服务端收到完整的 A 和 B 的一部分数据包 B-1,服务端需要解析出完整的 A,并等待读取完整的 B 数据包;
  • 服务端接收到 A 的一部分数据包 A-1,此时需要等待接收到完整的 A 数据包;
  • 数据包 A 较大,服务端需要多次才可以接收完数据包 A。

由于拆包和粘包问题的存在,接收方很难确定数据包的边界,也难以识别出一个完整的数据包。因此,需要一种机制来确定数据包的界限,解决拆包和粘包问题的关键方法便是定义应用层的通信协议。接下来,我们将一起探讨主流协议的解决方案。

2.1 消息长度固定

每个数据报文需要具备固定长度。接收方在累计读取到固定长度的报文后,将认为已经获得了一个完整的消息。若发送方的数据小于固定长度,则需要对空位进行填充。

1
2
3
+----+------+------+---+----+
| AB | CDEF | GHIJ | K | LM |
+----+------+------+---+----+

假设固定长度为 4 字节,那么如上所示的 5 条数据一共需要发送 4 个报文:

1
2
3
+------+------+------+------+
| ABCD | EFGH | IJKL | M000 |
+------+------+------+------+

消息定长法的使用非常简单,但其缺点也相当明显。设定合适的固定长度值具有挑战性:长度过大可能导致字节浪费,过小则可能影响消息传输。因此,在通常情况下,消息定长法并不是首选解决方案。

2.2 特定分隔符

由于接收方无法区分消息边界,我们可以在发送报文尾部添加特定分隔符,以便接收方根据此分隔符进行消息拆分。例如,报文可根据特定分隔符 \n 按行解析,从而获取五条原始报文:AB、CDEF、GHIJ、K 和 LM。

1
2
3
+-------------------------+
| AB\nCDEF\nGHIJ\nK\nLM\n |
+-------------------------+

在发送报文时,需要确保尾部的特定分隔符与消息体字符不冲突,以免产生错误的消息拆分。一种推荐的做法是对消息进行编码(如 Base64 编码),并选择不在编码字符集中的字符作为特定分隔符。特定分隔符法在消息协议较简单的场景下较为高效,例如,知名的 Redis 在通信过程中采用换行分隔符。

2.3 消息长度 + 消息内容

1
2
3
4
  消息头    消息体
+--------+---------+
| Length | Content |
+--------+---------+

消息长度 + 消息内容是项目开发中最常用的一种通信协议,其基本格式如上所示。消息头存储消息的总长度,例如使用 4 字节的 int 值来记录消息长度,而消息体则包含实际的二进制字节数据。接收方在解析数据时,首先读取消息头的长度字段 Len,接着读取长度为 Len 的字节数据,这些数据被视为一个完整的数据报文。

依然以上述提到的原始字节数据为例,使用该协议进行编码后的结果如下所示:

1
2
3
+-----+-------+-------+----+-----+
| 2AB | 4CDEF | 4GHIJ | 1K | 2LM |
+-----+-------+-------+----+-----+

消息长度 + 消息内容的使用方式具有很高的灵活性,且不存在消息定长法和特定分隔符法的明显缺陷。而且,消息头不仅限于存储消息长度,还可以自定义其他必要的扩展字段,如消息版本和算法类型等。


欢迎关注我的公众号,第一时间获取文章更新:

微信公众号

相关内容