Redis系列第一篇之SPEC协议
前言
Redis客户端使用被称为RESP(Redis序列化协议)
的协议与Redis服务器进行通讯。虽然该协议是专门为Redis设计的,但它同样可以被用于其他客户端/服务器的软件项目。
RESP
是以下几点的折中方案:
- 实现起来简单
- 解析速度快
- 可读的
RESP
可以序列化诸如整型、字符串和数组等不同的数据类型,还有一个特定的错误类型。请求以字符串数组的形式由客户端发送到Redis服务器,字符串数组表示需要执行的命令。Redis用特定于命令的数据类型回复。
RESP
是二进制安全的,不需要处理从一个进程传输到另一个进程的批量数据,因为它使用长度前缀来传输批量数据。
注意: 这里描述的协议仅用于客户端/服务器通信,Redis集群使用不同的二进制协议在节点之间交换信息。
网络层
客户端通过创建端口号为6379的TCP来连接Redis服务器。
虽然RESP
在技术上是非TCP特定的,但该协议仅用于Redis上下文的(或者等效的面向流的连接,如Unix套接字)TCP连接。
请求-应答模型
Redis接收由不同参数组成的命令。一旦命令被接收,将会被执行并且发送一个回复给客户端。
这可能是最简单的模型,然而,有两个例外:
- Redis支持管道操作,所以客户端可能一次发送多个命令并且等待回复
- 当Redis客户端订阅了一个Pub/Sub频道,协议语义改变为一种推送协议。客户端不再需要发送命令因为只要服务器收到新的消息,将会自动发送新的消息到客户端(对于客户端订阅的频道)。
除了这两种例外,Redis协议是一种简单的请求-应答协议。
RESP协议描述
RedisRESP
协议在v1.2版本中介绍,但是到v2.0才变为与服务器通信的标准。
RESP
协议支持以下数据类型: Simple Strings(简单字符串),Errors(错误),Integers(整型),Bulk Strings(批量字符串)以及Arrays(数组)。
Redis通过以下方式将RESP
用作请求-应答协议:
- 客户端以Bulk String(批量字符串)组成的RESP数组发送命令到服务器。
- 服务器根据命令以RESP数据类型之一回复客户端
在RESP
中,第一个字节决定了数据类型:
-
+
表示Simple Strings(简单字符串) -
-
表示Errors(错误) -
:
表示Integers(整型) -
$
表示Bulk Strings(批量字符串) -
*
表示Arrays(数组)
在RESP
中,协议不同部分总是以\r\n
(CRLF)结尾。
RESP
使用特殊的组合表示空的Bulk Strings或者空的Arrays:$-1\r\n
表示空的Bulk Strings,*-1\r\n
表示空的Arrays,需要注意的是:$0\r\n
与*0\r\n
分别表示有回复,但长度为0。
RESP Simple Strings(简单字符串)
Simple Strings(简单字符串)的编码方式为:一个+
号在最前面,后面跟着一个不能包含CR或者LF字符的字符串(即不允许换行符),并且最后以CRLF(\r\n
)结尾。
Simple Strings(简单字符串)以最小的开销传输非二进制安全的字符串。例如:很多Redis命令执行成功后的回复只是OK
,RESP
简单字符串将以5个字节编码:+OK\r\n
如果想要传输二进制安全的字符串,请使用Bulk Strings替代。
当Redis以简单字符串回复时,客户端库应该返回+
号后面第一个字符后面的所有字符串(不包括CRLF字节)。
RESP Errors(错误)
Redis有特定的错误类型,与Simple Strings相似,不同的是第一个字符是减号-
而不是加号+
,二者真正不同的是,客户端将错误视为异常,而构成Error类型的字符串就是错误消息本身。
错误类型的基本格式为:
-Error message\r\n
只有当发生错误时才会回复错误,比如你想要在错误的数据类型上执行命令,或者命令根本不存在。客户端收到Error回复时应该抛出异常。
下面是错误回复的例子:
-ERR unknown command 'helloworld'
-WRONGTYPE Operation against a key holding the wrong kind of value
-
号到后面第一个空格或者新行的第一个单词表示返回的错误类型,这只是Redis使用的约定,而不是RESP
错误格式的一部分。
比如,ERR
是一般错误,但是WRONGTYPE
是一个更具体的错误,暗示客户端尝试执行应对错误类型的操作。这被称为错误前缀,是一种允许客户端了解服务器返回的错误类型而无需检查确切错误消息的方法。
客户端实现可能会针对不同的错误返回不同类型的异常,或者通过直接将错误名称作为字符串提供给调用者来提供捕获错误的通用方法。
但是不应将此类功能视为至关重要,因为它很少有用,并且有限的客户端实现可能会简单地返回通用错误条件,例如false
RESP Integers(整型)
这种类型只是一个以CRLF结尾的字符串,表示一个整数,前缀为:
,比如::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(批量字符串)
Bulk Strings被用来表示单个的最大长度512MB的二进制安全字符串。
Bulk Strings编码方式为:
-
$
字符开头,后面跟着字符串值的字节长度(长度前缀),以CRLF结尾。 - 实际的字符串数据。
- 最终的CRLF。
所以,字符串hello
被编码为:$5\r\nhello\r\n
一个空字符串被编码为:$0\r\n\r\n
RESP Bulk Strings也可用特殊格式表示不存在(NULL),在这种格式中,长度为-1,没有数据:$-1\r\n
,这被称作NULL Bulk String,当服务器回复NULL Bulk String时,客户端库的API不应该返回空的字符串,而是返回nil对象。
RESP Arrays(数组)
客户端使用RESP Arrays发送命令到服务器。同样,某些返回元素集合给客户端的命令使用RESP数组作为回复,比如:LRANGE
命令。RESP Arrays以下面的格式发送:
-
*
开头,后面跟着数组元素的数量,数量以十进制表示,然后跟着CRLF。 - Array每个元素附件的RESP类型。
所以,空数组编码为:*0\r\n
包含"hello"和"world"两个元素的RESP数组被编码为:*2\r\n$5\r\nhello\r\n$5\r\nworld\r\n
如你所见,*<count>CRLF
前缀后面,组成数组的其他数据类型只是一个接一个的连接起来,比如一个由3个整型构成的Array编码结果为:*3\r\n:1\r\n:2\r\n:3\r\n
Array可以包含不同的数据类型,比如一个有4个整型和一个批量字符串组成的Array编码为:(为了直观,以换行的形式展现)
*5\r\n
:1\r\n
:2\r\n
:3\r\n
:4\r\n
$6\r\n
hello\r\n
第一行*5\r\n
为了表示后面还有5个回复,然后再读取后面的5个数组元素。
值为NULL的数组也存在(通常使用NULL Bulk String,由于历史原因,NULL存在两种格式)。比如BLPOP
超时时将会返回一个长度为-1的NULL Array:*-1\r\n
在RESP中同样存在嵌套的数组,比如两个嵌套的数组编码结果为:
*2\r\n
*3\r\n
:1\r\n
:2\r\n
:3\r\n
*2\r\n
+Hello\r\n
-World\r\n
上面的编码结果包含两个元素的数组,第一个元素由(1,2,3)构成的子数组,第二个元素由一个Bulk String(+Hello)和一个Error(-World)组成的数组。
Array中的Null元素
一个Array的单个元素可能为NULL。这在Redis回复中用来表示这些元素丢失而不是空字符串。当SORT
命令使用GET pattern
子命令并且key缺失时,将会发生这种情况。一个包含NULL元素的数组回复为:
*3\r\n
$5\r\n
hello\r\n
$-1\r\n
$5\r\n
world\r\n
上面的编码解析结果为:["hello", nil, "world"]
发送命令到Redis服务器
可以根据上面几部分的介绍来编写Redis客户端,同时进一步了解客户端和服务器之间的交互是如何工作的。
- 客户端发送只由Bulk Strings组成的RESP Array到Redis服务器。
- Redis以各种有效的RESP数据类型回复客户端
所以,一种典型的交互场景可能如下:
为了获取存储在mylist
中的列表的长度,客户端发送命令LLEN mylist
到服务器,然后服务器回复客户端一个整型回复:
Client: *2\r\n$4\r\nLLEN\r\n$6\r\nmylist\r\n
Sserver: :48293\r\n
用Golang实现命令编码与回复解析
import (
"bufio"
"bytes"
"errors"
"fmt"
"net"
"strconv"
)
// Reply load parsed reply from redis server
type Reply struct {
array []*Reply // nested array
value []byte // SimpleString & Integer & BulkString
err error // Error
}
type Client struct {
c net.Conn // tcp connection
writer *bufio.Writer
reader *bufio.Reader
}
func (c *Client) Send(cmd string, args ...interface{}) error {
const crlf = "\r\n"
var buf bytes.Buffer
buf.WriteByte('*') // Array标志
buf.WriteString(strconv.FormatInt(int64(1+len(args)), 10)) // 写入数组长度
buf.WriteString(crlf) // 写入分隔符
buf.WriteByte('$') // 写入命令部分
buf.WriteString(strconv.FormatInt(int64(len(cmd)), 10)) // 写入命令长度
buf.WriteString(crlf) // 写入分隔符
buf.WriteString(cmd) // 写入命令
buf.WriteString(crlf) // 写入分隔符
// 写入各个参数
for _, arg := range args {
a := fmt.Sprint(arg)
buf.WriteByte('$')
buf.WriteString(strconv.FormatInt(int64(len(a)), 10))
buf.WriteString(crlf)
buf.WriteString(a)
buf.WriteString(crlf)
}
if _, err := c.writer.Write(buf.Bytes()); err != nil {
return err
}
return c.writer.Flush()
}
func (c *Client) Response() (interface{}, error) {
line, err := c.ReadLine()
if err != nil {
return nil, err
}
if c.IsNilReply(line) {
return nil, nil
}
switch line[0] {
case '+', ':':
return &Reply{value: line[1:]}, nil
case '-':
return &Reply{err: errors.New(string(line[1:]))}, nil
case '$':
bulk, err := c.ReadBulkString(line)
if err != nil {
return nil, err
}
return string(bulk), nil
case '*':
return c.ReadArray(line)
default:
return nil, fmt.Errorf("invalid redis reply type")
}
}
func (c *Client) ReadLine() ([]byte, error) {
line, err := c.reader.ReadSlice('\n')
if err != nil {
if err != bufio.ErrBufferFull {
return nil, err
}
full := make([]byte, len(line))
copy(full, line)
line, err = c.reader.ReadBytes('\n')
if err != nil {
return nil, err
}
full = append(full, line...)
line = full
}
if len(line) <= 2 || line[len(line)-2] != '\r' || line[len(line)-1] != '\n' {
return nil, fmt.Errorf("read invalid reply: %q", line)
}
return line[:len(line)-2], nil // 去掉结尾的'\r\n'
}
func (c *Client) DataLen(data []byte) (int, error) {
return strconv.Atoi(string(data))
}
func (c *Client) ReadBulkString(head []byte) ([]byte, error) {
length, err := c.DataLen(head)
if err != nil {
return nil, err
}
buf := make([]byte, length+2)
if _, err = c.reader.Read(buf); err != nil {
return nil, err
}
return buf[:length], nil
}
func (c *Client) ReadArray(head []byte) (interface{}, error) {
length, err := c.DataLen(head)
if err != nil {
return nil, err
}
// 处理空数组
if length <= 0 {
return &Reply{}, nil
}
var array = make([]interface{}, length)
for i := 0; i < length; i++ {
array[i], err = c.Response()
if err != nil {
return nil, err
}
}
return array, nil
}
func (c *Client) IsNilReply(b []byte) bool {
if len(b) == 3 && (b[0] == '$' || b[0] == '*') && b[1] == '-' && b[2] == '1' {
return true
}
return false
}