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命令执行成功后的回复只是OKRESP简单字符串将以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命令,比如: INCRLLEN以及LASTSAVE。返回的整型数据范围为有符号的64位整数。
整型回复同样可以用来表示true或者false,比如EXISTS或者SISMEMBER将会返回1表示true,0表示false。
其他命令比如SADDSREMSETNX如果被执行了将会返回1,否则返回0。
其他返回整型的命令:SETNXDELEXISTSINCRINCRBYDECRDECRBYDBSIZELASTSAVERENAMENXMOVELLENSADDSREMSISMEMBERSCARD

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
}

参考资料

protocol-spec

原文连接

Redis系列第一篇之SPEC协议

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容