RESP 的全称是 Redis Serialization Protocol。基于这个实现简单且解析性能优秀的通信协议,Redis 的服务端与客户端可以通过底层命令的方式进行数据的通信。
随着 Redis 版本的不断更新以及功能迭代,RESP V2 协议开始渐渐无法满足新的需求。为了适配在 Redis 6.0 中出现的一些新功能,在它的基础上发展出了全新的下一代 RESP3 协议。
下面我们先来回顾一下继承自 RESP V2 的 5 种数据返回类型。在了解这些类型的局限性后,再来看看 RESP3 中新的数据返回类型都在什么地方做出了改进。
1 继承 RESP V2 的类型
首先,协议中数据的请求格式与 RESP V2 完全相同,请求的格式如下:
*<参数数量> CRLF
$<参数1的字节长度> CRLF
<参数1的数据> CRLF
$<参数2的字节长度> CRLF
<参数2的数据> CRLF
...
$<参数N的字节长度> CRLF
<参数N的数据> CRLF
每行末尾的 CRLF 转换成程序语言是 \r\n,也就是回车加换行。以 set name hydra 这条命令为例,转换过程及转换后的结果如下:
在了解了发送的协议后,下面对不同类型的回复进行测试。这一过程如何进行模拟呢?
我们今天采用一种更为简单的方式,直接在命令行下使用 Telnet 进行连接。以我本机启动的 Redis 为例,直接输入 telnet 127.0.0.1 6379 就可以连接到 redis 服务了。之后再将包含换行的指令一次性拷贝到命令行,然后回车,就能够收到来自Redis服务的回复了:
下面先来看看继承自 RESP V2 的 5 种返回格式。为了统一命名规范,介绍中均采用 RESP3 官方文档中的新的名称来替代 RESP V2 中的旧命名。例如不再使用旧的批量回复、多条批量回复等类型名称。
表示简单字符串回复,它只有一行回复。回复的内容以 + 作为开头,不允许换行,并以 \r\n 结束。有很多指令在执行成功后只会回复一个 OK,使用的就是这种格式。能够有效地将传输、解析的开销降到最低。
还是以上面的 set 指令为例,发送请求:
*3
$3
set
$4
name
$5
hydra
收到回复:
+OK\r\n
错误回复。它可以看做简单字符串回复的变种形式,它们之间的格式也非常类似。区别只有第一个字符是以 - 作为开头,错误回复的内容通常是错误类型及对错误描述的字符串。错误回复出现在一些异常的场景,例如当发送了错误的指令、操作数的数量不对时,都会进行错误回复。
发送一条错误的指令:
*1
$8
Dr.Hydra
收到回复,提示错误信息:
-ERR unknown command `Dr.Hydra`, with args beginning with:\r\n
整数回复。它的应用也非常广泛。它以 : 作为开头,以 \r\n 结束,用于返回一个整数。例如当执行 incr 后返回自增后的值,执行 llen 返回数组的长度,或者使用 exists 命令返回的 0 或 1 作为判断一个 key 是否存在的依据。这些都使用了整数回复。
发送一条查看数组长度的指令:
*2
$4
llen
$7
myarray
收到回复:
:4\r\n
4) Blob string
多行字符串的回复,也被叫做批量回复。在 RESP V2 中将它称为 Bulk String。以 $ 作为开头,后面是发送的字节长度,然后是 \r\n,然后发送实际的数据,最终以 \r\n 结束。如果要回复的数据不存在,那么回复长度为 -1。
发送一条 get 命令请求:
*2
$3
get
$4
name
收到回复:
$5\r\n
hydra\r\n
可以理解为 RESP V2 中的多条批量回复。当服务端要返回多个值时,例如返回一些元素的集合时,就会使用 Array。它以 * 作为开头,后面是返回元素的个数,之后再跟随多个上面的 Blob String。
*4
$6
lrange
$7
myarray
$1
0
$2
-1
收到回复,包含了集合中的 4 个元素:
*4
$1
1
$1
2
$1
2
$2
32
这 5 种继承自 RESP V2 协议的返回数据类型的简单回顾到此结束。下面我们来开启 RESP3 协议新特性的探索之旅。
目前在 Redis 6.0.X 版本中,仍然是默认使用的 RESP V2 协议,并且在兼容 RESP V2 的基础上,也同时也支持开启 RESP3。估计在未来的某个版本,Redis 可能会全面切换到 RESP3。不过这么做的话对目前的 Redis 客户端连接工具会有不小的冲击,都需要根据协议内容进行底层通信的改造。
在使用 telnet 连接到 Redis 服务后,先输入下面的命令来切换到 RESP3 版本的协议,至于 hello 命令的具体返回数据以及数据表示的意义。
hello 3
下面我们就来详细看看在 RESP3 中,除了保留上面的 5 种旧回复类型外,新增的 13 种通信返回数据类型,部分数据类型会配合示例进行演示。为了看起来更加简洁,下面的演示例子发送命令均使用原始命令,不再转化为协议格式,并且省略数据返回结果中每行结束的 \r\n!
1) Null
新协议中使用下划线字符后跟 CR 和 LF 字符来表示空值,也就是用 _\r\n 来替代原先的单个空值的返回 $-1。例如在使用 get 命令查找一个不存在的 key 时:
get hydra
RESP V2 返回:
$-1
RESP3 返回:
_
浮点数返回时以逗号开头,格式为 ,<floating-point-number>\r\n。使用 zset score key member 获取分数的命令来进行测试:
zscore fruit apple
RESP V2 返回时使用的是 Bulk String 的格式:
$18
5.6600000000000001
RESP3 返回格式:
,5.6600000000000001
布尔类型的数据返回值,其中 true 被表示为 #t\r\n,而 false 被表示为 #f\r\n。不过 Hydra 暂时没有找到返回布尔类型结果的例子,即使是用 Lua 脚本直接返回布尔类型也无法实现。
eval "return true" 0
eval "return false" 0
上面的 Lua 脚本在返回 true 时结果为 :1\r\n,返回 false 时结果为 _\r\n。这是因为 Lua 中布尔类型的 true 会转换为 Redis 中的整数回复 1,而 false 类型会转换成 Nil Bulk。
4) Blob error
与字符串类型比较相似,它的格式为 !<length>\r\n<bytes>\r\n。但是与简单错误类型一样,开头使用 ! 表示返回的是一段错误信息描述。例如错误 SYNTAX invalid syntax 会按照下面的格式返回:
!21
SYNTAX invalid syntax
Verbatim string 也表示一个字符串格式,与 Blob String 非常相似,但是使用 = 开头替换了 $。另外,之后的三个字节提供了有关字符串格式的信息,例如 txt 表示纯文本,mkd 表示 markdown 格式,第四个字节则固定为 :。这种格式适用于在没有任何转义或过滤的情况下显示给用户。
使用延时事件统计与分析指令进行测试,发送:
latency doctor
RESP2 返回的数据还是 Blob String 格式:
$196
Dave, no latency spike was observed during the lifetime of this Redis instance, not in the slightest bit. I honestly think you ought to sit down calmly, take a stress pill, and think things over.
RESP V3 返回的数据采用了新的格式:
=200
txt:Dave, no latency spike was observed during the lifetime of this Redis instance, not in the slightest bit. I honestly think you ought to sit down calmly, take a stress pill, and think things over.
Big number 类型用于返回非常大的整数数字。可以表示在有符号 64 位数字范围内的整数,包括正数或负数,但是需要注意不能含有小数部分。数据格式为 (<big number>\r\n,以左括号开头。示例如下:
(3492890328409238509324850943850943825024385
注意:当 Big number 不可用时,客户端会返回一个字符串格式的数据。
与前面我们介绍的给定数据类型的单个值不同,Aggregate data types 可以理解为聚合数据类型。这也是 RESP3 的一个核心思想,要能够从协议和类型的角度,来描述不同语义的聚合数据类型。
聚合数据类型的格式如下,通常由聚合类型、元素个数以及具体的单一元素构成:
<aggregate-type-char><numelements><CR><LF>
... numelements other types ...
例如一个包含三个数字的数组 [1,2,3] 可以表示为:
*3
:1
:2
:3
当然聚合数据类型中的元素可以是其他聚合数据类型,例如在数组中也可以嵌套包含其他数组(下面的内容包含了缩进方便理解):
*2
*3
:1
$5
hello
:2
#f
上面的聚合数据类型所表示的数据为 [[1,"hello",2],false]。
Map 数据类型与数组比较类似,但是以 % 作为起始,后面是 Map 中键值对的数量,而不再是单个数据项的数量。它的数据内容是一个有序的键值对的数组,之后分行显示键值对的 key 和 value,因此后面的数据行一定是偶数行。先看一下官方文档给出的例子,以下面的 JSON 字符串为例:
{
"first":1,
"second":2
}
转换为 Map 类型后格式为下面的形式:
%2
+first
:1
+second
:2
但是通过实验,Hydra 发现了点有意思的东西,当我们发送一条 hgetall 的命令来请求哈希类型的数据时:
hgetall user
RESP V2 返回的数据仍然使用老的 Array 格式,符合我们的预期:
*4
$4
name
$5
Hydra
$3
age
$2
18
但是下面 RESP3 的数据返回却出乎我们的意料,可以看到虽然前面的 %2 表示使用了 Map 格式,但是后面并没有遵循官方文档给出的规范,除了开头的 %2 以外,其余部分与 Array 完全相同()。
%2
$4
name
$5
Hydra
$3
age
$2
18
关于实际传输数据与文档中给出示例的出入,Hydra 有一点自己的猜测,放在最后总结部分。
9) Set
Set 与 Array 类型非常相似,但是它的第一个字节使用 ~ 替代了 *,它是一个无序的数据集合。还是先看一下官方文档中给出的示例,下面是一个包含了 5 个元素的集合类型数据,并且其中具体的数据类型可以不同:
~5<CR><LF>
+orange<CR><LF>
+apple<CR><LF>
#t<CR><LF>
:100<CR><LF>
:999<CR><LF>
下面使用 SMEMBERS 命令获取集合中的所有元素进行测试:
SMEMBERS myset
RESP V2 返回时仍然使用 Array 格式:
*3
$1
a
$1
c
$1
b
RESP3 的数据返回情况和 Map 比较类似,使用 ~ 开头但是没有完全遵从协议中的格式:
~3
$1
a
$1
c
$1
b
10) Attribute
Attribute 类型与 Map 类型非常相似,但是头一个字节使用 | 来代替了 %。Attribute 描述的数据内容比较像 Map 中的字典映射。客户端不应该将这个字典内容看做数据回复的一部分,而是当做增强回复内容的辅助数据。
在文档中提到,在未来某个版本的 Redis 中可能会出现这样一个功能,每次执行指令时都会打印访问的 key 的请求频率,这个值可能使用一个浮点数表示。那么在执行 MGET a b 时就可能会收到回复:
|1
+key-popularity
%2
$1
a
,0.1923
$1
b
,0.0012
*2
:2039123
:9543892
在上面的数据回复中,实际中回复的数据应该是 [2039123,9543892],但是在前面附加了它们请求的属性,当读到这个 Attribute 类型数据后,应当继续读取后面的实际数据。
11) Push
Push 数据类型是一种服务器向客户端发送的异步数据,它的格式与 Array 类型比较类似,但是以 > 开头。接下来的数组中的第一个数据为字符串类型,表示服务器发送给客户端的推送数据是何种类型。数组中其他的数据也都包含自己的类型,需要按照协议中类型规范进行解析。
简单看一下文档中给出的示例,在执行 get key 命令后,可能会得到两个有效回复:
>4
+pubsub
+message
+somechannel
+this is the message
$9
Get-Reply
在上面的这段回复中需要注意,收到的两个回复中第一个是推送数据的类型,第二个才是真正回复的数据内容。
注意!这里在文档中有一句提示:虽然下面的演示使用的是 Simple string 格式,但是在实际数据传输中使用的是 Blob string 格式。所以盲猜一波,上面的 Map 和 Set 也是同样的情况?
这里先简单铺垫一下 Push 回复类型。在 Redis 6 中非常重要的一个使用场景客户端缓存 client-side caching,它允许将数据存储在本地应用中。当访问时不再需要访问 Redis 服务端,但是其他客户端修改数据时,需要通知当前客户端作废掉本地应用的客户端缓存。这时候就会用到 Push 类型的消息。
我们先在客户端 A 中执行下面的命令:
client tracking on
get key1
在客户端 B 中执行:
set key1 newValue
这时就会在客户端 A 中收到 Push 类型的消息,通知客户端缓存失效。在下面收到的消息中就包含了两部分,第一部分表示收到的消息类型 为invalidate,第二部分则是需要作废的缓存 key1:
>2
$10
invalidate
*1
$4
key1
在前面介绍的类型中,返回的数据字符串一般都具有指定的长度,例如下面这样:
$1234<CR><LF>
.... 1234 bytes of data here ...<CR><LF>
但是,有时候需要将一段不知道长度的字符串数据从客户端传给服务器(或者反向传输)时,很明显这种格式无法使用。因此需要一种新的格式用来传送不确定长度的数据。
文档中提到,过去在服务端有一个私有扩展的数据格式,规范如下:
$EOF:<40 bytes marker><CR><LF>
... any number of bytes of data here not containing the marker ...
<40 bytes marker>
它以 $EOF: 作为起始字节,然后是 40 字节的 marker 标识符,在 \r\n 后跟随的是真正的数据,结束后也是 40 字节的标识符。标识符以伪随机的方式生成,基本上不会与正常的数据发生冲突。
但是这种格式存在一定的局限性,主要问题就在于生成标识符以及解析标识符上。由于一些原因使得上面这种格式在实际使用中非常脆弱。因此最终在规范中提出了一种分块编码格式。举一个简单的例子,当需要发送事先不知道长度的字符串 Hello world 时:
$?
;4
Hell
;5
o wor
;2
ld
;0
这种格式以 $? 开头,表示是一个不知道长度的分块编码格式,后面传输的数据数量没有限制,在最后以零长度的 ;0 作为结束传输的标识。文档中提到,目前还没有命令会以这个格式来进行数据回复,但是会在后面的功能模块中实装这个协议。
在介绍 RESP3 的最开始,我们就在 telnet 中通过 hello 3 的命令来切换协议到 V3 版本。这个特殊的命令完成了两件事:
hello 命令的格式如下,可以看到除了协议版本号外,还可以指定用户名和密码:
HELLO <protocol-version> [AUTH <username> <password>]
hello 命令的返回结果是前面介绍过的 Map 类型,仅仅在客户端和服务器建立连接的时候发送。
%7
$6
server
$5
redis
$7
version
$6
6.0.16
$5
proto
:3
$2
id
:18
$4
mode
$10
standalone
$4
role
$6
master
$7
modules
*0
转换为我们可读的 Map 格式后,可以看到它返回的 Redis 服务端的一些信息:
{
"server":"redis",
"version":"6.0.16",
"proto":3,
"id":18,
"mode":"standalone",
"role":"master",
"modules":[]
}
在 RESP V2 中,通信协议还是比较简单,通信内容大多也都还是通过数组形式进行编码和发送。这种情况带来了很多不便,有很多情况需要根据操作命令的类型来判断返回的数据具体是什么类型,这无疑增加了客户端解析数据的难度与复杂度。
而在 RESP3 中,通过引入新的多种数据类型。通过起始字节的字符进行类型的区分编码,使客户端可以直接判断返回数据的类型,在相当大的程度上减轻了解析的复杂度提升了效率。
本文中对于新的返回数据类型,一部分给出了通信数据的示例,但还是有一些类型暂时没有找到合适的命令进行测试,有了解的小伙伴们可以给我补充。
另外对于 Map 和 Set,实际传输的数据与官方文档给出的仍有一定出入,个人认为情况和 Push 相同。可能是官方文档中更多只偏向于演示,使用 Simple string 来代替了 Blob string。
最后再啰嗦一句,说说协议的命名。新一代的协议名称就叫 RESP3,而没有继承第二代的命名规范叫 RESP V3,也不是 RESP version3 什么乱七八糟的,所以就不要纠结文中为啥一会是 RESP V2,一会是 RESP3 这种不对称的命名了。
参考文档
https://github.com/redis/redis-doc/blob/master/docs/reference/protocol-spec.md
https://github.com/antirez/RESP3/blob/master/spec.md
https://redis.io/docs/reference/protocol-spec/#high-performance-parser-for-the-redis-protocol
- EOF -
1、Redis 使用 List 实现消息队列的利与弊
2、一文读懂Redis常见对象类型的底层数据结构
3、Redis 实战篇:巧用 Bitmap 实现亿级海量数据统计
看完本文有收获?请转发分享给更多人
关注「ImportNew」,提升Java技能
点赞和在看就是最大的支持❤️