(译)Redis协议规范

本文翻译自Redis Protocol specification

Redis客户端和Redis服务器之间的通信遵循一个叫做RESP (REdis Serialization Protocol)的协议。这个协议是专门为Redis定制的,它可以用于其他客户端-服务器架构的软件项目。

RESP的设计考虑到了以下几个方面:

实现简单。
可以快速被解析。
人类可读。

RESP可以序列化不同的数据类型,比如整数、字符串、数组。它还为错误定制了特殊的类型。请求被从客户端以字符串数组的形式发送给Redis服务端执行。Redis返回一个特定的命令回复。

RESP是一个二进制安全的,并且它不需要将大量数据从一个进程传输到另一个进程,因为它使用前缀长度来传输数据块。

注意:这里的协议概述只对客户端-服务器的通信有效。Redis集群为了能够在Redis节点之间交换信息而使用了不同的二进制协议。

网络层

一个客户端在连接到一个Redis服务器时会创建一个TCP连接,并连接到其6379端口。

虽然从技术上来说RESP并非必须使用TCP,但是在Redis的实际使用中RESP只使用TCP连接(或像Unix套接字这种面向流的连接)。

请求-响应模型

Redis可以接受不同参数组合的命令。一旦收到一个命令,Redis将处理这个命令并向客户端响应一个回复。

这可能是最简单的模型了,然而这里有两个例外情况:

  • Redis支持流水线操作(在后面的文档会描述)。这就使客户端可以一次性发送多条命令,并等待Redis服务端响应。

  • 当一个Redis客户端订阅了一个发布/订阅的频道,协议将改变语义并变成一个推送协议,也就是说,客户端不再需要发送命令,因为服务端会自动在收到新消息时尽快发送新消息给客户端(新消息来自客户端订阅的频道)。

除了上面两种例外的情况,Redis协议是一个简单的请求-相应协议。

RESP协议描述

RESP协议在Redis 1.2时被引入,但它在Redis 2.0时才被正式作为Redis客户端和服务器的交互协议。我们必须在Redis客户端实现这个协议。

RESP实际上是一个序列化协议,它支持如下数据类型:简单字符串、错误、整型、块字符串和数组。

在Redis中RESP被作为一个请求-响应协议使用:

  • 客户端将命令整理成一个块字符串的RESP数组发送给Redis服务器。

  • Redis服务器根据命令的具体实现返回一个RESP数据类型给客户端。

在RESP中,数据类型取决于第一个字节:

  • 响应的第一个字节为”+”表示简单字符串。
  • 响应的第一个字节为”-“表示错误。
  • 响应的第一个字节为”:”表示整型。
  • 响应的第一个字节为”$”表示块字符串。
  • 响应的第一个字节为”*”表示数组。

另外,在RESP中可以使用指定字符串或数组的特殊变体来代表空值。RESP使用”\r\n” (CRLF)来分割协议的不同部分。

RESP简单字符串

简单字符串的编码方式是:以一个加号字符开头,后面跟一个不含CR或LF字符的字符串(可以没有换行符),最后以CRLF(即”\r\n”)结束。

简单字符串用来以最小的开销传输非二进制安全的字符串。比如很多Redis命令在成功执行后返回”OK”,这就是一个RESP简单字符串,它被编码成5个字节:

"+OK\r\n"

为了发送二进制安全的字符串,需要使用RESP块字符串。

当Redis响应一个简单字符串时,客户端库应该将从’+’开始到字符串结尾的不包含结尾CRLF的字符串返回给调用者。

RESP错误

RESP有一个叫错误的特殊数据类型。实际上错误类似于简单字符串,但是其第一个字符是一个减号(“-“)而不是加号。RESP中简单字符串和错误的真正区别在于错误会被客户端当成异常,而组成错误类型的字符串就是错误消息本身。

其基本的格式为:

"-Error message\r\n"

Redis只有在发生异常的时候才会发送错误响应,比如你尝试将一个操作施加到错误的数据类型上,或者命令不存在等等。当接收到一个错误响应时Redis客户端库应该抛出一个错误。

以下是错误回应的一些例子:

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

从”-“后的第一个字符到第一个空格或换行符,代表错误的类型。需要注意的是这只是Redis中的一种惯例做法,而不是RESP的错误格式要求。

比如,ERR表示一般的错误,而WRONGTYPE更具体一点,它表示客户端尝试将一个操作施加到错误的数据类型上。这种使用错误前缀的做法可以让客户端在不需要额外信息的情况下理解服务端返回的错误,不过随着时间的推移,这种情况可能会发生变化。

一个客户端实现可以针对不同的错误返回不同类型的异常,或者以直接提供错误名称字符串给调用者这种更通用的方式来捕获错误。

然而,由于这种功能很少被使用所以被认为是不重要的,一个受限制的客户端实现可能只是简单地返回一个一般的错误信息,比如false。

RESP整型

这个类型很简单,就是一个”:”前缀加上数字,最后以CRLF作为结束符。比如”:0\r\n”或者”:1000\r\n”都是整型响应。

很多Redis命令都返回RESP整型,比如INCR、LLEN和LASTSAVE。

整型响应并没有什么特殊含义,对于INCR命令它只是一个递增的数字,对于LASTSAVE命令它是一个UNIX时间戳等等。然而,整型响应是一个有符号的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块字符串

块字符串被用来表示一个二进制安全的字符串,最大长度512MB。

块字符串使用如下方式进行编码:

  • 以”$”开头,之后的数字表示字符串的长度,以CRLF结尾。
  • 真正的字符串数据。
  • 最后以CRLF结尾。

字符串”foobar”的编码如下:

"$6\r\nfoobar\r\n"

一个空字符串的编码如下:

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

还可以使用RESP块字符串的特殊格式来表示不存在值或空值。在这种特殊格式中,字符串长度为-1,且没有数据,所以一个空值(Null)表示如下:

"$-1\r\n"

这被叫做空块字符串

当Redis服务器返回一个空块字符串时,客户端库API不应该返回一个空字符串,但可以返回一个nil对象。比如一个Ruby库应该返回’nil’而一个C库应该返回NULL(或者在返回对象中设置一个特殊的标志)等等。

RESP数组

客户端使用RESP数组来向Redis服务器发送命令。同样,Redis服务器在执行某些命令后会使用RESP数组作为结果返回给客户端。一个例子是LRANGE命令会返回给客户端元素的列表作为回复。

RESP数组使用如下格式:

  • 第一个字符为”*”,后面的数字表示数组中元素个数,最后以CRLF结尾。
  • 数组中的每个元素都是RESP支持的类型。

一个空数组的表示如下:

"*0\r\n"

一个包含两个块字符串”foo”和”bar”的数组表示如下:

"*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n"

数组以*<count>CRLF开头,构成数组的其他数据类型一个接着一个的连在一起。例如一个有三个整型的数组编码如下:

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

数组中可以混合存放不同类型的数据,一个数组中不需要所有元素类型都相同。比如,一个包含了四个整型和一个块字符串的数组编码如下:

*5\r\n
:1\r\n
:2\r\n
:3\r\n
:4\r\n
$6\r\n
foobar\r\n

(清楚起见,服务器回复分成了多行来表示)。

第一行的*5\r\n指明回复的数组中有5个元素。然后由多个回复组成的多回复块将被传输给客户端。

空数组的概念也是存在的,这是另一种指定Null值的方式(一般来说我没使用空块字符串,不过由于历史原因我们有两种格式)。

一个例子是当BLPOP命令超时,将返回一个计数值为-1的空数组:

"*-1\r\n"

一个客户端库API应该在Redis服务器回复一个空数组时返回一个空对象而不是空数组。这是区分空列表和不同情况的必要条件(比如BLPOP命令超时的情况)。

RESP也能表示嵌套数组。比如一个包含两个数组的数组编码如下:

*2\r\n
*3\r\n
:1\r\n
:2\r\n
:3\r\n
*2\r\n
+Foo\r\n
-Bar\r\n

(清楚起见,服务器回复分成了多行来表示)。

上面的RESP数据类型编码了一个包含两个数组的数组,第一个数组的元素为3个整型:1,2和3,第二个数组包含了一个简单字符串和一个错误。

数组中的空值

数组中的单个元素可能是空值。在Redis中这表示元素确实而不是空字符串。当指定的键不存在且我们用SORT命令排序时可能发生这种情况。以下是一个回复中数组包含一个空值的例子:

*3\r\n
$3\r\n
foo\r\n
$-1\r\n
$3\r\n
bar\r\n

第二个元素是一个空值。此时客户端库的返回应该像下面这样:

["foo",nil,"bar"]

注意,这并不是前面几节中提到的异常,而是RESP更进一步的示例。

发送命令给Redis服务器

现在你已经熟悉了RESP序列化格式了,写一个Redis客户端实现库也不是什么难事。我们可以更进一步了解一下客户端和服务器交互的细节了:

  • 客户端将向Redis服务器发送块字符串组成的RESP数组。
  • Redis服务器向客户端回复合法的RESP数据。

一个典型的客户端和服务器交互过程如下。

客户端发送命令LLEN mylist来获取键mylist的值(一个列表)的长度,服务器回复一个整型,如下(C为客户端,S为服务器):

C: *2\r\n
C: $4\r\n
C: LLEN\r\n
C: $6\r\n
C: mylist\r\n

S: :48293\r\n

通常我们为了表达上的清晰会将协议的不同部分用换行符分开,但在实际交互时客户端会将它们作为一个整体来发送:*2\r\n$4\r\nLLEN\r\n$6\r\nmylist\r\n

多命令和流水线

客户端可以使用相同的连接来发布多条命令。流水线支持客户端一次性向服务端发送多条命令,而不是一条条命令串行执行。所有命令的回复都可以一次性取得。

更多信息可以参考流水线

内联命令

有时候你只能使用telnet,且你需要发送一条命令给Redis服务端。然而Redis协议的实现很简单,在交互式会话中使用并不理想,并且Redis-cli并不总是可用。出于这些原因,Redis也接受一种为人类设计的特殊格式,叫做内联命令格式。

下面是服务器/客户端使用内联命令的例子(S为服务器,C为客户端):

C: PING
S: +PONG

下面是回复整型的另一个例子:

C: EXISTS somekey
S: :0

基本上你只需要在telnet会话中处理空白符分隔的参数即可。由于没有任何一个命令以”*”开头,Redis在统一请求协议中可以使用,Redis可以检测到这种情况并解析你的命令。

高性能地解析Redis协议

Redis协议的可读性很好且很容易实现,因此性能可以媲美二进制协议。

RESP使用前缀长度来批量传输数据,所以无须像解析JSON那样扫描数据来查找特殊的字符,也不需要将引用发送给服务器。

批量数据的长度可以用代码处理,每个字符执行一次操作,同时扫描CR字符,C语言代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#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;
}

在第一个CR被标识出来以后,可以在没有任何处理的情况下直接跳过后面的LF。然后,可以不对有效载荷进行任何检查直接用单读的方式读取数据。最后留下的CR和LF字符可以不加处理直接忽略。

性能媲美二进制协议的Redis协议从一个很高的语言层次来进行设计,非常简单高效,这减少了客户端软件的bug数。