Redis系列第一篇之SPEC協(xié)議

前言

Redis客戶端使用被稱為RESP(Redis序列化協(xié)議)的協(xié)議與Redis服務(wù)器進(jìn)行通訊。雖然該協(xié)議是專門為Redis設(shè)計(jì)的,但它同樣可以被用于其他客戶端/服務(wù)器的軟件項(xiàng)目。
RESP是以下幾點(diǎn)的折中方案:

  • 實(shí)現(xiàn)起來(lái)簡(jiǎn)單
  • 解析速度快
  • 可讀的

RESP可以序列化諸如整型、字符串和數(shù)組等不同的數(shù)據(jù)類型,還有一個(gè)特定的錯(cuò)誤類型。請(qǐng)求以字符串?dāng)?shù)組的形式由客戶端發(fā)送到Redis服務(wù)器,字符串?dāng)?shù)組表示需要執(zhí)行的命令。Redis用特定于命令的數(shù)據(jù)類型回復(fù)。
RESP是二進(jìn)制安全的,不需要處理從一個(gè)進(jìn)程傳輸?shù)搅硪粋€(gè)進(jìn)程的批量數(shù)據(jù),因?yàn)樗褂瞄L(zhǎng)度前綴來(lái)傳輸批量數(shù)據(jù)。
注意: 這里描述的協(xié)議僅用于客戶端/服務(wù)器通信,Redis集群使用不同的二進(jìn)制協(xié)議在節(jié)點(diǎn)之間交換信息。

網(wǎng)絡(luò)層

客戶端通過創(chuàng)建端口號(hào)為6379的TCP來(lái)連接Redis服務(wù)器。
雖然RESP在技術(shù)上是非TCP特定的,但該協(xié)議僅用于Redis上下文的(或者等效的面向流的連接,如Unix套接字)TCP連接。

請(qǐng)求-應(yīng)答模型

Redis接收由不同參數(shù)組成的命令。一旦命令被接收,將會(huì)被執(zhí)行并且發(fā)送一個(gè)回復(fù)給客戶端。
這可能是最簡(jiǎn)單的模型,然而,有兩個(gè)例外:

  • Redis支持管道操作,所以客戶端可能一次發(fā)送多個(gè)命令并且等待回復(fù)
  • 當(dāng)Redis客戶端訂閱了一個(gè)Pub/Sub頻道,協(xié)議語(yǔ)義改變?yōu)橐环N推送協(xié)議??蛻舳瞬辉傩枰l(fā)送命令因?yàn)橹灰?wù)器收到新的消息,將會(huì)自動(dòng)發(fā)送新的消息到客戶端(對(duì)于客戶端訂閱的頻道)。

除了這兩種例外,Redis協(xié)議是一種簡(jiǎn)單的請(qǐng)求-應(yīng)答協(xié)議。

RESP協(xié)議描述

RedisRESP協(xié)議在v1.2版本中介紹,但是到v2.0才變?yōu)榕c服務(wù)器通信的標(biāo)準(zhǔn)。
RESP協(xié)議支持以下數(shù)據(jù)類型: Simple Strings(簡(jiǎn)單字符串),Errors(錯(cuò)誤),Integers(整型),Bulk Strings(批量字符串)以及Arrays(數(shù)組)。
Redis通過以下方式將RESP用作請(qǐng)求-應(yīng)答協(xié)議:

  • 客戶端以Bulk String(批量字符串)組成的RESP數(shù)組發(fā)送命令到服務(wù)器。
  • 服務(wù)器根據(jù)命令以RESP數(shù)據(jù)類型之一回復(fù)客戶端

RESP中,第一個(gè)字節(jié)決定了數(shù)據(jù)類型:

  • +表示Simple Strings(簡(jiǎn)單字符串)
  • -表示Errors(錯(cuò)誤)
  • :表示Integers(整型)
  • $表示Bulk Strings(批量字符串)
  • *表示Arrays(數(shù)組)

RESP中,協(xié)議不同部分總是以\r\n(CRLF)結(jié)尾。
RESP使用特殊的組合表示空的Bulk Strings或者空的Arrays:$-1\r\n表示空的Bulk Strings,*-1\r\n表示空的Arrays,需要注意的是:$0\r\n*0\r\n分別表示有回復(fù),但長(zhǎng)度為0。

RESP Simple Strings(簡(jiǎn)單字符串)

Simple Strings(簡(jiǎn)單字符串)的編碼方式為:一個(gè)+號(hào)在最前面,后面跟著一個(gè)不能包含CR或者LF字符的字符串(即不允許換行符),并且最后以CRLF(\r\n)結(jié)尾。
Simple Strings(簡(jiǎn)單字符串)以最小的開銷傳輸非二進(jìn)制安全的字符串。例如:很多Redis命令執(zhí)行成功后的回復(fù)只是OKRESP簡(jiǎn)單字符串將以5個(gè)字節(jié)編碼:+OK\r\n
如果想要傳輸二進(jìn)制安全的字符串,請(qǐng)使用Bulk Strings替代。
當(dāng)Redis以簡(jiǎn)單字符串回復(fù)時(shí),客戶端庫(kù)應(yīng)該返回+號(hào)后面第一個(gè)字符后面的所有字符串(不包括CRLF字節(jié))。

RESP Errors(錯(cuò)誤)

Redis有特定的錯(cuò)誤類型,與Simple Strings相似,不同的是第一個(gè)字符是減號(hào)-而不是加號(hào)+,二者真正不同的是,客戶端將錯(cuò)誤視為異常,而構(gòu)成Error類型的字符串就是錯(cuò)誤消息本身。
錯(cuò)誤類型的基本格式為:
-Error message\r\n
只有當(dāng)發(fā)生錯(cuò)誤時(shí)才會(huì)回復(fù)錯(cuò)誤,比如你想要在錯(cuò)誤的數(shù)據(jù)類型上執(zhí)行命令,或者命令根本不存在??蛻舳耸盏紼rror回復(fù)時(shí)應(yīng)該拋出異常。
下面是錯(cuò)誤回復(fù)的例子:

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

-號(hào)到后面第一個(gè)空格或者新行的第一個(gè)單詞表示返回的錯(cuò)誤類型,這只是Redis使用的約定,而不是RESP錯(cuò)誤格式的一部分。
比如,ERR是一般錯(cuò)誤,但是WRONGTYPE是一個(gè)更具體的錯(cuò)誤,暗示客戶端嘗試執(zhí)行應(yīng)對(duì)錯(cuò)誤類型的操作。這被稱為錯(cuò)誤前綴,是一種允許客戶端了解服務(wù)器返回的錯(cuò)誤類型而無(wú)需檢查確切錯(cuò)誤消息的方法。
客戶端實(shí)現(xiàn)可能會(huì)針對(duì)不同的錯(cuò)誤返回不同類型的異常,或者通過直接將錯(cuò)誤名稱作為字符串提供給調(diào)用者來(lái)提供捕獲錯(cuò)誤的通用方法。
但是不應(yīng)將此類功能視為至關(guān)重要,因?yàn)樗苌儆杏茫⑶矣邢薜目蛻舳藢?shí)現(xiàn)可能會(huì)簡(jiǎn)單地返回通用錯(cuò)誤條件,例如false

RESP Integers(整型)

這種類型只是一個(gè)以CRLF結(jié)尾的字符串,表示一個(gè)整數(shù),前綴為:,比如::0\r\n:1000\r\n。
有很多返回整型的Redis命令,比如: INCR、LLEN以及LASTSAVE。返回的整型數(shù)據(jù)范圍為有符號(hào)的64位整數(shù)。
整型回復(fù)同樣可以用來(lái)表示true或者false,比如EXISTS或者SISMEMBER將會(huì)返回1表示true,0表示false。
其他命令比如SADD、SREM、SETNX如果被執(zhí)行了將會(huì)返回1,否則返回0。
其他返回整型的命令:SETNXDEL、 EXISTS、INCR、INCRBY、DECR、DECRBY、DBSIZE、LASTSAVE、RENAMENX、MOVE、LLENSADD、SREMSISMEMBER、SCARD。

RESP Bulk Strings(批量字符串)

Bulk Strings被用來(lái)表示單個(gè)的最大長(zhǎng)度512MB的二進(jìn)制安全字符串。
Bulk Strings編碼方式為:

  • $字符開頭,后面跟著字符串值的字節(jié)長(zhǎng)度(長(zhǎng)度前綴),以CRLF結(jié)尾。
  • 實(shí)際的字符串?dāng)?shù)據(jù)。
  • 最終的CRLF。

所以,字符串hello被編碼為:$5\r\nhello\r\n
一個(gè)空字符串被編碼為:$0\r\n\r\n
RESP Bulk Strings也可用特殊格式表示不存在(NULL),在這種格式中,長(zhǎng)度為-1,沒有數(shù)據(jù):$-1\r\n,這被稱作NULL Bulk String,當(dāng)服務(wù)器回復(fù)NULL Bulk String時(shí),客戶端庫(kù)的API不應(yīng)該返回空的字符串,而是返回nil對(duì)象。

RESP Arrays(數(shù)組)

客戶端使用RESP Arrays發(fā)送命令到服務(wù)器。同樣,某些返回元素集合給客戶端的命令使用RESP數(shù)組作為回復(fù),比如:LRANGE命令。RESP Arrays以下面的格式發(fā)送:

  • *開頭,后面跟著數(shù)組元素的數(shù)量,數(shù)量以十進(jìn)制表示,然后跟著CRLF。
  • Array每個(gè)元素附件的RESP類型。

所以,空數(shù)組編碼為:*0\r\n
包含"hello"和"world"兩個(gè)元素的RESP數(shù)組被編碼為:*2\r\n$5\r\nhello\r\n$5\r\nworld\r\n
如你所見,*<count>CRLF前綴后面,組成數(shù)組的其他數(shù)據(jù)類型只是一個(gè)接一個(gè)的連接起來(lái),比如一個(gè)由3個(gè)整型構(gòu)成的Array編碼結(jié)果為:*3\r\n:1\r\n:2\r\n:3\r\n
Array可以包含不同的數(shù)據(jù)類型,比如一個(gè)有4個(gè)整型和一個(gè)批量字符串組成的Array編碼為:(為了直觀,以換行的形式展現(xiàn))

*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個(gè)回復(fù),然后再讀取后面的5個(gè)數(shù)組元素。
值為NULL的數(shù)組也存在(通常使用NULL Bulk String,由于歷史原因,NULL存在兩種格式)。比如BLPOP超時(shí)時(shí)將會(huì)返回一個(gè)長(zhǎng)度為-1的NULL Array:*-1\r\n
在RESP中同樣存在嵌套的數(shù)組,比如兩個(gè)嵌套的數(shù)組編碼結(jié)果為:

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

上面的編碼結(jié)果包含兩個(gè)元素的數(shù)組,第一個(gè)元素由(1,2,3)構(gòu)成的子數(shù)組,第二個(gè)元素由一個(gè)Bulk String(+Hello)和一個(gè)Error(-World)組成的數(shù)組。

Array中的Null元素

一個(gè)Array的單個(gè)元素可能為NULL。這在Redis回復(fù)中用來(lái)表示這些元素丟失而不是空字符串。當(dāng)SORT命令使用GET pattern子命令并且key缺失時(shí),將會(huì)發(fā)生這種情況。一個(gè)包含NULL元素的數(shù)組回復(fù)為:

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

上面的編碼解析結(jié)果為:["hello", nil, "world"]

發(fā)送命令到Redis服務(wù)器

可以根據(jù)上面幾部分的介紹來(lái)編寫Redis客戶端,同時(shí)進(jìn)一步了解客戶端和服務(wù)器之間的交互是如何工作的。

  • 客戶端發(fā)送只由Bulk Strings組成的RESP Array到Redis服務(wù)器。
  • Redis以各種有效的RESP數(shù)據(jù)類型回復(fù)客戶端

所以,一種典型的交互場(chǎng)景可能如下:
為了獲取存儲(chǔ)在mylist中的列表的長(zhǎng)度,客戶端發(fā)送命令LLEN mylist到服務(wù)器,然后服務(wù)器回復(fù)客戶端一個(gè)整型回復(fù):

Client: *2\r\n$4\r\nLLEN\r\n$6\r\nmylist\r\n

Sserver: :48293\r\n

用Golang實(shí)現(xiàn)命令編碼與回復(fù)解析

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標(biāo)志
    buf.WriteString(strconv.FormatInt(int64(1+len(args)), 10)) // 寫入數(shù)組長(zhǎng)度
    buf.WriteString(crlf)                                      // 寫入分隔符
    buf.WriteByte('$')                                         // 寫入命令部分
    buf.WriteString(strconv.FormatInt(int64(len(cmd)), 10))    // 寫入命令長(zhǎng)度
    buf.WriteString(crlf)                                      // 寫入分隔符
    buf.WriteString(cmd)                                       // 寫入命令
    buf.WriteString(crlf)                                      // 寫入分隔符
    // 寫入各個(gè)參數(shù)
    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 // 去掉結(jié)尾的'\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
    }
    // 處理空數(shù)組
    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協(xié)議

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容