天天看點

網絡協定之:redis protocol 詳解

作者:flydean程式那些事

簡介

redis是一個非常優秀的軟體,它可以用作記憶體資料庫或者緩存。因為他的優秀性能,redis被應用在很多場合中。

redis是一個用戶端和伺服器端的模式,用戶端和伺服器端是通過TCP協定進行連接配接的,用戶端将請求資料發送到伺服器端,伺服器端将請求傳回給用戶端。這樣一個請求流程就完成了。

當然在最開始的時候,因為用的人很少,系統還不夠穩定,通過TCP協定傳輸的資料不規範的。但是當用的人越來越多,尤其是希望開發适用于不同語言和平台的redis用戶端的時候,就要考慮到相容性的問題了。

這時候用戶端和伺服器端就需要一個統一的互動協定,對于redis來說這個通用的互動協定就叫做Redis serialization protocol(RESP)。

RESP是在Redis 1.2版本中引入的,并在Redis 2.0中成為了與 Redis 伺服器通信的标準方式。

這就是說,從Redis 2.0之後,就可以基于redis protocol協定開發出自己的redis用戶端了。

redis的進階用法

一般來說,redis的用戶端和伺服器端組成的是一個請求-響應的模式,也就是說用戶端向伺服器端發送請求,然後得到伺服器端的響應結果。

請求和響應是redis中最簡單的用法。熟悉redis的朋友可能會想到了兩個redis的進階用法,這兩個用法并不是傳統意義上的請求-響應模式。

到底是哪兩種用法呢?

第一種就是redis支援pipline,也就是管道操作,管道的好處就是redis用戶端可以一次性向伺服器端發送多條指令,然後等待伺服器端的傳回。

第二種redis還支援Pub/Sub,也就是廣播模型,在這一種情況下,就不是請求和響應的模式了,在Pub/Sub下,切換成了伺服器端推送的模式。

Redis中的pipline

為什麼要用pipline呢?

因為redis是一個典型的請求響應模式,我們來舉個常見的incr指令的例子:

Client: INCR X

Server: 1

Client: INCR X

Server: 2

Client: INCR X

Server: 3

Client: INCR X

Server: 4

事實上用戶端隻想得到最終的結果,但是每次用戶端都需要等待伺服器端傳回結果之後,才能發送下一次的指令。這樣就會導緻一個叫做RTT(Round Trip Time)的時間浪費。

雖然每次RTT的時間不長,但是累計起來也是一個非常客觀的數字。

那麼可不可以将所有的用戶端指令放在一起發送給伺服器呢? 這個優化就叫做Pipeline。

piepline的意思就是用戶端可以在沒有收到伺服器端傳回的時候繼續向伺服器端發送指令。

上面的指令可以用pipline進行如下改寫:

(printf "INCR X\r\nINCR X\r\nINCR X\r\nINCR X\r\n"; sleep 1) | nc localhost 6379
:1
:2
:3
:4
           

因為redis伺服器支援TCP協定進行連接配接,是以我們可以直接用nc連到redis伺服器中執行指令。

在使用pipline的時候有一點要注意,因為redis伺服器會将請求的結果緩存在伺服器端,等到pipline中的所有指令都執行完畢之後再統一傳回,是以如果伺服器端傳回的資料比較多的情況下,需要考慮記憶體占用的問題。

那麼pipline僅僅是為了減少RTT嗎?

熟悉作業系統的朋友可能有聽說過使用者空間和作業系統空間的概念,從使用者輸入讀取資料然後再寫入到系統空間中,這裡涉及到了一個使用者空間的切換,在IO操作中,這種空間切換或者拷貝是比較耗時的,如果頻繁的進行請求和響應,就會造成這種頻繁的空間切換,進而降低了系統的效率。

使用pipline可以一次性發送多條指令,進而有效避免空間的切換行為。

Redis中的Pub/Sub

和Pub/Sub相關的指令是SUBSCRIBE, UNSUBSCRIBE 和 PUBLISH。

為什麼要用Pub/Sub呢?其主要的目的就是解耦,在Pub/Sub中消息發送方不需要知道具體的接收方的位址,同樣的對于消息接收方來說,也不需要知道具體的消息發送方的位址。他們隻需要知道關聯的主題即可。

subscribe和publish的指令比較簡單,我們舉一個例子,首先是用戶端subscribe topic:

redis-cli -h 127.0.0.1
127.0.0.1:6379> subscribe topic
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "topic"
3) (integer) 1
           

然後在另外一個終端,調用publish指令:

redis-cli -h 127.0.0.1
127.0.0.1:6379> publish topic "what is your name?"
(integer) 1
           

可以看到用戶端會收到下面的消息:

1) "message"
2) "topic"
3) "what is your name?"
           

RESP protocol

RESP協定有5種類型,分别是imple Strings, Errors, Integers, Bulk Strings 和 Arrays。

不同的類型以消息中的第一個byte進行區分,如下所示:

類型 第一個byte
Simple Strings +
Errors
Integers :
Bulk Strings $
Arrays *

protocol中不同的部分以 “\r\n” (CRLF)來進行差別。

Simple Strings

Simple Strings的意思是簡單的字元串。

通常用在伺服器端的傳回中,這種消息的格式就是”+”加上文本消息,最後以”\r\n”結尾。

比如伺服器端傳回OK,那麼對應的消息就是:

"+OK\r\n"
           

上面的消息是一個非二進制安全的消息,如果想要發送二進制安全的消息,則可以使用Bulk Strings。

什麼是非二進制安全的消息呢?對于Simple Strings來說,因為消息是以”\r\n”結尾,是以消息中間不能包含”\r\n”這兩個特殊字元,否則就會産生錯誤的含義。

Bulk Strings

Bulk Strings是二進制安全的。這是因為Bulk Strings包含了一個字元長度字段,因為是根據長度來判斷字元長度的,是以并不存在根據字元中某個特定字元來判斷是否字元結束的缺點。

具體而言Bulk Strings的結構是”$”+字元串長度+”\r\n”+字元串+”\r\n”。

以OK為例,如果以Bulk Strings來表示,則如下所示:

"$2\r\nok\r\n"
           

Bulk Strings還可以包含空字元串:

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

當然還可以表示不存在的Null值:

"$-1\r\n"
           

RESP Integers

這是redis中的整數表示,具體的格式是”:”+整數+”\r\n”。

比如18這個整數就可以用下面的格式來表示:

":18\r\n"
           

RESP Arrays

redis的多個指令可以以array來表示,伺服器端傳回的多個值也可以用arrays來表示。

RESP Arrays的格式是”*”+數組中的元素個數+其他類似的資料。

是以RESP Arrays是一個複合結構的資料。比如一個數組中包含了兩個Bulk Strings:”redis”,”server”則可以用下面的格式來表示:

"*2\r\n5\r\nredis\r\n6\r\nserver\r\n"
           

RESP Arrays中的原始不僅可以使用不同類型,還能包含RESP Arrays,也就是array的嵌套:

"*3\r\n5\r\nredis\r\n6\r\nserver\r\n*1\r\n$4\r\ngood\r\n"
           

為了友善觀察,我們将上面的消息格式一下:

"*3\r\n
5\r\nredis\r\n6\r\nserver\r\n
*1\r\n
$4\r\ngood\r\n"
           

上面的消息是一個包含三個元素的數組,前面兩個元素是Bulk Strings,最後一個是包含一個元素的數組。

RESP Errors

最後,RESP還可以表示錯誤消息。RESP Errors的消息格式是”-“+字元串,如下所示:

"-Err something wrong\r\n"
           

一般情況下,”-“後面的第一個單詞表示的是錯誤類型,但是這隻是一個約定俗成的規定,并不是RESP協定中的強制要求。

另外,經過對比,大家可能會發現RESP Errors和Simple Strings是消息格式是差不多的。

這種對不同消息類型的處理是在用戶端進行區分的。

Inline commands

如果完全按RESP協定的要求,當我們連接配接到伺服器端的時候需要包含RESP中定義消息的所有格式,但是這些消息中包含了額外的消息類型和回車換行符,是以直接使用協定來執行的話會比較困惑。

于是redis還提供一些内聯的指令,也就是協定指令的精簡版本,這個精簡版本去除了消息類型和回車換行符。

我們以”get world”這個指令為例。來看下不同方式的連接配接情況。

首先是使用redis-cli進行連接配接:

redis-cli -h 127.0.0.1
127.0.0.1:6379> get world
"hello"
           

因為redis-cli是redis的用戶端,是以可以直接使用inline command來執行指令。

如果使用telnet,我們也可以使用同樣的指令來獲得結果:

telnet 127.0.0.1 6379
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
get world
$5
hello
           

可以看到傳回的結果是”$5\r\nhello\r\n”。

如果要使用協定消息來請求redis伺服器應該怎麼做呢?

我們要請求的指令是”get world”,将其轉換成為RESP的消息則是:

"*2\r\n3\r\nget\r\n5\r\nworld\r\n"
           

我們嘗試一下将上述指令使用nc傳遞到redis server上:

(printf "*2\r\n3\r\nget\r\n5\r\nworld\r\n"; sleep 1) |  nc localhost 6379
-ERR Protocol error: expected '#39;, got ' '
           

很遺憾我們得到了ERR,那麼是不是不能直接使用RESP消息格式進行傳輸呢?當然不是,上面的問題在于$符号是一個特殊字元,我們需要轉義一下:

(printf "*2\r\n\3\r\nget\r\n\$5\r\nworld\r\n"; sleep 1) |  nc localhost 63795
hello
           

可以看到輸出的結果和直接使用redis-cli一緻。

總結

以上就是RESP協定的基本内容和手動使用的例子,有了RESP,我們就可以根據協定中定義的格式來建立redis用戶端。

可能大家又會問了,為什麼隻是redis用戶端呢?有了協定是不是redis伺服器端也可以建立呢?答案當然是肯定的,隻需要按照協定進行消息傳輸即可。主要的問題在于redis伺服器端的實作比較複雜,不是那麼容易實作的。

繼續閱讀