RESP (Redis Serialization Protocol) 是 Redis 客户端与服务器之间通信的数据序列化协议。Redis 6.2 版本采用 RESP3 协议,RESP3 在 RESP2 的基础上增加了对新数据类型的支持。

RESP2 协议

早期的 Redis 使用的是 RESP2 协议,它支持序列化以下几种类型的数据:

  • 整数:格式为 :<data>\r\n,例如 :888\r\n
  • 错误信息:格式为 -<data>\r\n,例如 -ERR syntax error\t\n
  • 单行字符串:格式为 +<data>\r\n,例如 +OK\r\n
  • 多行字符串:格式为 $<length>\r\n<data>\r\n,例如:
        $12\r\nhello       # 注意这里存在换行
        world\r\n
        
  • 数组:格式为 *<element-num>\r\n<element-1>\r\n ... <element-n>\r\n,例如:
          *2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n
        #       |            |
        #  num  |     e1     |     e2
        

    注意:数组中的元素类型,均是多行字符串,即便只有一行。

RESP3 协议

Redis 服务器与客户端始终保持长连接,理论上服务器可以主动向客户端推送数据。然而早期版本中使用的 RESP2 并不支持 Server Push,因此 Redis 6 引入了 RESP3 协议。

RESP3 在 RESP2 的基础上,新增了以下数据类型:

  • NULL:格式为 _\r\n
  • Number 类型:格式为 :<number>\r\n,例如 :123456\r\n
  • Double 类型:格式为 ,<floating-point-number>\r\n,例如 ,1.23456\r\n
  • Boolean 类型:true 为 #t\r\n、false 为 #f\r\n
  • Big Number 类型:格式为 (<big-number>\r\n,例如 (31415926535897384622\r\n
  • Blob error 多行错误类型:格式为 !<length>\r\n<bytss\r\n>,例如 !21\r\nSYNTAX invalid syntax\r\n
  • Verbatim string 带格式的多行字符串类型:格式为 =<length>\r\n<格式标志>:<字符串内容>\r\n
    • 格式标志固定为 3 个字符,如纯文本的格式标志为 txt、markdown 内容的格式标志为 mkd
    • 当服务端返回 Verbatim String 时,客户端不需要做任何转移或过滤操作,直接展示给用户即可。

RESP3 还新增了以下集合类型:

  • Map 类型:格式为 %<entry-num>\r\n<k1>\r\n<v1>\r\n...,例如 %2\r\n+name\r\n+xiaoming\r\n+age\r\n:18\r\n
  • Set 类型:格式为 ~<element-num>\r\n<element-1>\r\n ... <element-n>\r\n,除了第一个标识符 ~ 外,其他部分与 RESP2 中的数组相同;
  • Attribute type 属性类型:与 Map 类型的格式一样,只是第一个标识符改成了 |
  • Push type:与 RESP2 中的数组类型格式一样,只是第一个标识符改成了 >

此外,RESP3 协议还新增了两种流式类型:

  • Stream strings 流式字符串类型:使用分块编码的方式传输不定长的字符串,用于事先不知道字符串长度的场景。格式如下:

          $?\r\n;<count-1>\r\n<data-1>\r\n<count-2>\r\n<data-2>\r\n...;0\r\n
        
    • $?\r\n 头表示这是一个流式字符串;
    • ;0\r\n 结尾表示流式字符串传输完毕;
    • ;<count>\r\n<data>\r\n 表示每一个分段的数据。

    以下是通过 Stream string 传输一个 Hello World 字符串的示例(为了直观展示,这里添加了额外的换行):

        $?\r\n     # 头信息
        ;4\r\n     # 第一段
        hell\r\n
        ;5\r\n     # 第二段
        o wor\r\n
        ;2\r\n     # 第三段
        ld\r\n
        ;0\r\n     # 尾信息
        
  • Stream 集合类型:Stream 集合也用于实现不知道数据量的场景,支持数组、Set 以及 Map 的流式集合。以流式数组为例,其格式如下:

        *?\r\n<element-1>\r\n...<element-n>\r\n.\r\n
        
    • *?\r\n 头表示这是一个流式数组;
    • .\r\n 结尾表示流式数组传输完毕;

    Map 和 Set 的流与数组格式差不多,只是开头的标志不同,以下是一个 Map 流示例:

        %?\r\n
        +a\r\n
        :1\r\n
        +b\r\n
        :2\r\n
        .\r\n
        

最后,还有一种特殊的数据类型:

  • hello 类型:用于返回 hello 命令的结果,与 Map 类型类似,仅在客户端与服务器建立连接时使用。

拆包粘包问题

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

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

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

MTU 和 MSS

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

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

  +--------------- MTU ---------------+
|                                   |
+----------++-----------++----------++----------
| Mac Head ||  IP Head  || TCP Head || Data...
+----------++-----------++----------++----------
   14 Byte     20 Byte     20 Byte   |         |
                                     +-- MSS --+
  

滑动窗口

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

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

Nagle 算法

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

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

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

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

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

  +========+      Send      +========+
| Client | =============> | Server |
+========+                +========+

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

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

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

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

消息长度固定

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

  +----+------+------+---+----+
| AB | CDEF | GHIJ | K | LM |
+----+------+------+---+----+
  

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

  +------+------+------+------+
| ABCD | EFGH | IJKL | M000 |
+------+------+------+------+
  

消息定长法现实中用的很少,主要是因为长度不好确定。

特定分隔符

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

  +-------------------------+
| AB\nCDEF\nGHIJ\nK\nLM\n |
+-------------------------+
  

在发送报文时,需要确保尾部的特定分隔符与消息体字符不冲突,以免产生错误的消息拆分。一种推荐的做法是对消息进行编码(如 Base64 编码),并选择不在编码字符集中的字符作为特定分隔符。

消息长度 + 消息内容

  消息头    消息体
+--------+---------+
| Length | Content |
+--------+---------+
  

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

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

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

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

Redis 是如何解决的?

通过上述对 RESP 协议的介绍,我们很容易发现该协议天然支持处理“拆包”的情况:RESP 广泛使用了 \r\n 标志作为分隔符,读取过程中只要没遇到这个标志,就可以认为还没有读完。

对于“粘包”问题,由于 Redis 支持 pipeline 机制,即便客户端在一次请求中发送了多条命令,Redis 也服务器也可以正确的解析其中的命令。