redis 通信协议(RESP),最简单的应用层协议,没有之一

1,535 阅读5分钟

前言

本文主要针对 RESP2 进行分析,另外 redis6.0 已经支持 RESP3 协议

所谓 协议,本质是一种约定,需要使用者双方来准守,常见于 C/S 通信模式中,比如在浏览器中最常用的 HTTP 应用层通信协议。

通信两端需要某种约定,才能保持正常通信。一端通过约定的格式发送数据,另一端通过约定的格式解析数据,这种约定,取了一个好听的名字 ---- 协议

典型的 HTTP 协议,最本质的原理也是如此。redis 作为一款高性能内存组件,要尽可能将精力花在数据的组织形式上,因此,没有采用开源的一些复杂协议,比如 HTTP,而是简单的自定义一套应用层通信协议。

Redis 客户端 - 服务端通信协议称之为 RESP 协议,全称叫 Redis Serialization Protocol,即 redis 序列化协议。人类易读,相当精巧!


RESP 协议特点:

  • 人类易读
  • 简单实现
  • 快速解析

RESP 是一种二进制安全协议,因为编码后的每一个字符串都有前缀来表明其长度,通过长度就能知道数据边界,从而避免越界访问的问题。

值得注意的是,RESP 协议只用于 客户端 - 服务端 之间的交流,redis cluster 各节点之间采用不同的二进制协议(采用 Gossip 协议)进行交流。

网络通信:

我们知道,在传统计算机网络模型中,传输层(TCP / UDP)的上一层便是应用层。应用层协议一般专注于数据的编解码等约定,比如经典的 HTTP 协议。

RESP 协议本质和 HTTP 是一个级别,都属于应用层协议

在 redis 中,传输层协议使用的是 TCP,服务端从 TCP socket 缓冲区中读取数据,然后经过 RESP 协议解码得到我们的指令。

而写入数据则是相反,服务器先将响应数据使用 RESP 编码,然后将编码后的数据写入 TCP Socket 缓冲区发送给客户端。

协议格式:

在 RESP 协议中,第一个字节决定了具体数据类型:

  • 简单字符串:Simple Strings,第一个字节响应 +
  • 错误:Errors,第一个字节响应 -
  • 整型:Integers,第一个字节响应 :
  • 批量字符串:Bulk Strings,第一个字节响应 $
  • 数组:Arrays,第一个字节响应 *

我们来看看一具体的例子,我们一条正常指令 PSETEX test_redisson_batch_key8 120000 test_redisson_batch_key=>value:8,经 RESP 协议编码后长这样:

*4
$6
PSETEX
$24
test_redisson_batch_key8
$6
120000
$32
test_redisson_batch_key=>value:8

值得注意的是,在 RESP 协议中的每一部分都是以 \R\N 结尾。

❤️ 简单字符串:

Simple Strings。以 + 为前缀的响应数据,例如:

"+OK\r\n"

以上是字符串 OK,被编码后的格式,总共 5 字节。

这是一种非二进制安全的编码方式,因为, 我们无法确切的知道字符串的长度,只能以 \r\n 来判断,所以编码的字符串中,不能包含 \r 或者 \n 字符。

当然,如果你想要二进制安全字符串,可以选择 Bulk Strings 方式,我们后面会介绍。

💀 错误

Errors。RESP 提供了错误类型,和简单字符串非常类似,不过是以 - 开头,基本格式如下:

"-Error message\r\n"

与简单字符串真正不同的之处在于客户端的处理上,对 - 开头的响应,客户端直接以异常情况处理。

我们来看一个是实际例子,当我们的指令或者参数错误,redis 服务端会直接返回异常,如下:

-ERR unknown command 'helloworld'
-WRONGTYPE Operation against a key holding the wrong kind of value

- 后面的第一个单词,直到第一个空格或换行符,表示返回的错误类型。这只是 Redis 使用的一个惯例,并不是 RESP 错误格式的一部分。

ERR 是通用错误,而 WRONGTYPE 是一个更具体的错误,表示客户端尝试执行错误的数据类型,通常作为一个错误的前缀,它允许客户端在不检查确切错误消息的情况下理解服务器返回的错误类型。

我们在客户端实现的时候,可以针对不同的错误返回不同类型的异常,或者提供一种捕获错误的通用方法,比如,直接将错误名称作为字符串提供给调用者。

然而,这样的特性不应该被认为是至关重要的,因为它很少有用,而且有限的客户端实现可能只是返回一个通用的错误条件,比如 false

👉 整型

RESP Integers。表示响应的是整数,以 : 开头,比如 :0\r\n:1000\r\n

redis 中很多命令的响应都是整数,比如 ==INCR==, ==LLEN==, 及 ==LASTSAVE==。另外,响应值是一个 64 位的整数。

当然,整形也可以表示 true 或者 false 语义,比如 EXISTS 或者 SISMEMBER 返回 1 表示 true,0 表示 false。

还有其他命令,比如 SADD, SREM, 和 SETNX 返回 1 表示实际执行,反之为 0。

以下命令会响应结果为整数:

SETNX, DEL, EXISTS, INCR, INCRBY, DECR, DECRBY, DBSIZE, LASTSAVE, RENAMENX, MOVE, LLEN, SADD, SREM, SISMEMBER, SCARD.

✅ 批量字符串

RESP Bulk Strings。批量回复,是一个大小在 512 Mb 的二进制安全字符串,被编码成:

  • $ 开头,紧跟一个整数代表回复字符串的大小,以 \r\n 结束
  • 随后是 实际的字符串数据
  • 最后以 "\r\n" 结尾

比如 hello 被编码为:

"$5\r\nhello\r\n"

一个空字符串被编码为:

"$0\r\n\r\n"

另外,对于一些不存在的 value 可以返回 -1 表示 null,也被称为 NULL 批量回复

客户端库进行实现时,可以将此 -1 处理成空对象,比如 Ruby 将返回 nil,而 C 则返回 NULL

⭐ 数组

RESP Arrays。数组,对于响应的集合元素,比如 LRANGE 命令,返回的是元素列表,也就是数组形式。

编码格式:

  • * 开头表示,紧接着是一个整数,表示数组元素个数,并以 \r\n 结尾。
  • 数组的每个元素的都是 RESP 提供的类型。

例如,空数组

"*0\r\n"

包含 "hello" 和 "world" 的响应数组(也叫多批量字符串,每一个元素是批量字符串):

"*2\r\n$5\r\nhello\r\n$5\r\nworld\r\n"

3个整数的数组是这样的:

"*3\r\n:1\r\n:2\r\n:3\r\n"

另外,数组也可以混合类型的。

比如以下5个元素中,有4个是整形,一个是 批量字符串

*5\r\n
:1\r\n
:2\r\n
:3\r\n
:4\r\n
$5\r\n
hello\r\n

... ( ➡ 以上结果为了更加清晰的展示,进行了手动换行。

当然,也同样支持空数组(一般情况下,更习惯使用 Null Bulk String,但由于历史原因,两种方式都存在)

例如,当使用 BLPOP 命令 timeout 时,将返回空数组:

"*-1\r\n"

当 redis 返回 NULL 数组时,客户端实现库最好也返回一个空对象,有助于区别到底是 empty 数组还是产生了其他问题

内置数组,如下:

*2\r\n
*3\r\n
:1\r\n
:2\r\n
:3\r\n
*2\r\n
+Hello\r\n
-World\r\n

... ( ➡️ 同样,为了展示更加清晰,进行了手动换行

该响应结果表示,外层数组包含两个元素,每个元素都是数组。第一个子数组包含 3 个整型数字,第二个子数组包含 1 个简单字符串和一个错误。

👀 数组中的空元素

Null elements in Arrays。数组出现 NULL 元素,这种场景也是很常见的,比如我们使用 MGET 批量获取 key,当其中一些 key 不存在时,返回的就是 NULL 元素。

例如响应结果:

*3\r\n
$5\r\n
hello\r\n
$-1\r\n
$5\r\n
world\r\n

如上响应编码,客户端库解析之后应该是这样:

["hello",nil,"world"]

⭐ 多命令和管道

Multiple commands and pipelining。多命令和管道,redis 中提供了一次发送多条指令的操作,比如 ==MGET==、==MSET==、==pipline==,服务端接收并处理后一次性响应。

这种形式就是上面提到的 数组,数组里面可以是 批量字符串整数,甚至是 NULL 都可以。

我们先使用 telnet 看看原生响应结果:

[root@VM-20-17-centos ~]# telnet 127.0.0.1 6379
MGET key1 key2 key3
*3
$6
value1
$6
value2
$-1

我们再使用 redis-cli 看看被客户端解码后的结果:

127.0.0.1:6379> MGET key1 key2 key3
1) "value1"
2) "value2"
3) (nil)

👀 内联命令

Inline commands。是这样的,一般情况下我们和 redis 服务端通信都需要一个客户端(比如redis-cli),因为双方都遵循 RESP 协议,数据可以正常编码和解析。

考虑这样一种情况,当你没有任何客户端工具可用时,是否也能正常和服务端通信呢?比如 telnet

也是可以的,redis 正式通过 内联指令 支持的,咱们来看看例子:

例1,通过 RESP 协议发送指令(由于没有客户端,这里我们手动编码):

[root@VM-20-17-centos ~]# telnet 127.0.0.1 6379
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
*3       
$3
set
$4   
key1
$5 
world
+OK

我们正常的指令是 set key1 word,经过 RESP 编码之后 *3\r\n$3\r\nset\r\n$4\r\nkey1\r\r$5\r\nworld,redis 服务端解码之后便可得到正常指令。

例2,通过内联操作发送指令:

[root@VM-20-17-centos ~]# telnet 127.0.0.1 6379
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
exists key1
:1
get key1
$1
1
set key1 hello             
+OK
get key1
$5
hello

这里我们直接发送 内联指令 比如 EXISTS key1GET key1SET key1 hello 等,无需 RESP 协议编码,服务端仍可正常处理。

值得注意的是,因为没有了统一请求协议中的 * 项来声明参数的数量,所以在 telnet 会话输入命令的时候,必须使用空格来分割各个参数,服务器在接收到数据之后,会按空格对用户的输入进行解析,并获取其中的命令参数。

🚀 高性能 Redis 协议解析器

High performance parser for the Redis protocol,即,高性能 Redis 协议分析器。

RESP 是一款人类易读简单实现的通信协议,它可以类似于二进制协议的性能实现。

RESP 使用前缀长度来传输批量数据,因此不需要像 JSON 那样,为了查找某个特殊字符而扫描整个数据,也无须对发送至服务器的数据进行转义。

程序可以在对协议文本中的各个字符进行处理的同时, 查找 CR 字符, 并计算出批量回复或多条批量回复的长度, 就像这样:

#include <stdio.h>

int main(void) {
    unsigned char *p = "$123\r\n";
    int len = 0;

    p++;
    while(*p != '\r') {
        len = (len*10)+(*p - '0');
        p++;
    }

    /* Now p points at '\r', and the len is in bulk_len. */
    printf("%d\n", len);
    return 0;
}

得到了批量回复或多条批量回复的长度之后, 程序只需调用一次 read 函数, 就可以将回复的正文数据全部读入到内存中, 而无须对这些数据做任何的处理。

在回复最末尾的 CR 和 LF 不作处理,丢弃它们。

Redis 协议的实现性能可以和二进制协议的实现性能相媲美,并且由于 Redis 协议的简单性,大部分高级语言都可以轻易地实现这个协议,这使得客户端软件的 bug 数量大大减少。

总结

协议,本质是双方对数据处理的一种约定,redis 提供了简单易实现的 RESP 协议,你也看到了,确实相当简单,按照这种协议约定,你也能很快写出一个 redis 客户端。

协议工作的一般流程是:

  • 客户端:原始命令 -> RESP 编码
  • 服务端:RESP 解码 -> 原始命令

redis 服务端除了支持 RESP 协议,还支持 内联指令,也就是我们原始的命令,这样一来就不需要编码解码的过程了。

RESP3 提供了更清晰、丰富的数据类型,感兴趣可以点击详情




相关参考: