BAT大牛Redis客戶端與服務端交互原理

Redis實例運行在單獨的進程中,應用系統(Redis客戶端)通過Redis協議和Redis Server 進行交互。在Redis 協議之上,客戶端和服務端可以實現多種類型的交互模式:串行請求/響應模式、雙工的請求/響應模式(pipeline)、原子化的批量請求/響應模式(事務)、發佈/訂閱模式、腳本化的批量執行(Lua腳本)。

Redis 協議

Redis的交互協議包含2 個部分:網絡模型和序列化協議。前者討論數據交互的組織方式,後者討論數據本身如何序列化。

網絡模型

Redis協議位於TCP之上,客戶端和Redis實例保持雙工的連接,如下圖所示:

BAT大牛Redis客戶端與服務端交互原理

Redis 客戶端與服務端全雙工交互模式

客戶端和服務端交互的內容是序列化後的數據,服務器爲每個客戶端建立與之對應的連接,在應用層維護一系列狀態保存在connection 中,connection 間相互無關聯。在Redis中,connection 通過redisClient 結構體實現。

  • 序列化協議

客戶端-服務端之間交互的是序列化後的協議數據。在Redis中,協議數據分爲不同的類型,每種類型的數據均以CRLF( )結束,通過數據的首字符區分類型。

inline command:這類數據表示Redis命令,首字符爲Redis命令的字符,格式爲 str1 str2 str3 ...。如:exists key1,命令和參數以空格分隔。

simple string:首字符爲'+',後續字符爲string的內容,且該string 不能包含' '或者' '兩個字符,最後以' '結束。如:'+OK ',表示"OK",這個string數據。simple string 本身不包含轉義,所以客戶端的反序列化效率很高,直接將'+'和最後的' ' 去掉即可。

bulk string:對於string 本身包含了' '、' ' 的情況,simple string 不再適用。通常可以使用的辦法有:轉義和長度自描述。Redis採用了後者(長度自描述),也就是 bulk string。bulk string 首字符爲'$',緊跟着的是string數據的長度,' '後面是內容本身(包含' '、' '等特殊字符),最後以' '結束。如:

"$12
hello
world
"

上面字節串描述了 "hello world" 的內容(中間有個換行)。對於" "空串和null,通過'$' 之後的數字進行區分:

  1. "$0 " 表示空串;
  2. "$-1 " 表示null。

error:對於服務器返回的內容,客戶端需要識別是成功還是失敗。對於異常信息,在Redis中就是一個普通的string,和simple string的表達的類似。唯一區別的就是首字符爲'-'。客戶端可以直接通過首字符'-',就可以識別出成功還是失敗。

例如:"-ERR unknown command 'foobar' ",表示的是執行錯誤,和相關的描述信息。

有些客戶端需要對不同種類的 error 信息做不同的處理,爲了使得error 種類的區分更加快速,在Redis 序列化協議之上,還包含簡單的error 格式協議,以error 的種類開頭,空格之後緊跟着error的信息。

integer:以 ':' 開頭,後面跟着整型內容,最後以' ' 結尾。如:":13 ",表示13的整數。

array:以'*' 開頭,緊跟着數組的長度," " 之後是每個元素的序列化數據。如:"*2 +abc :9 " 表示一個長度爲2的數組:["abc", 9]。

數組長度爲0或 -1分別表示空數組或 null。

數組的元素本身也可以是數組,多級數組是樹狀結構,採用先序遍歷的方式序列化。如:[[1, 2], ["abc"]],序列化爲:"*2 *2 :1 :2 *1 +abc "。

C/S 兩端使用的協議數據類型:由客戶端發送給服務器端的類型爲:inline command、由 bulk string 組成的array。

由服務端發給客戶端的類型爲:除了 inline command之外的所有類型。並根據客戶端命令或交互模式的不同進行確定,如:

請求/響應模式下,客戶端發送的exists key1 命令,返回 integer 型數據。

發佈/訂閱模式下,往 channel 訂閱者推送的消息,採用array 類型數據。

請求/響應模式

對於之前提到的數據結構,其基本操作都是通過請求/響應模式完成的。同一個連接上,請求/響應模式如下:

  • 交互方向:客戶端發送數據,服務端響應數據。
  • 對應關係:每一個請求數據,有且僅有一個對應的響應數據。
  • 時序:響應數據的發送發生在,服務器完全收到請求數據之後。

串行化實現

串行化的實現方式比較簡單,同一個connection在前一個命令執行完成之後,再發送第二個請求。如下圖所示:

BAT大牛Redis客戶端與服務端交互原理

串行化交互

這種方式的弊端在於,每一個請求的發送都依賴於前一個響應。同一個connection 上面的吞吐量較低:

單連接吞吐量 = 1 / (2*網絡延遲 + 服務器處理時間 + 客戶端處理時間)

Redis 對於單個請求的處理時間(10幾微秒)通常比局域網的延遲小1個數量級。因此串行模式下,單連接的大部分時間都處於網絡等待,沒有充分利用服務器的處理能力。

pipeline 實現

因爲TCP是全雙工的,請求響應穿插進行時,也不會引起混淆。此時批量的發送命令至服務器,在批量的獲取響應數據,可以極大的提高單連接的吞吐量。如下入所示:

BAT大牛Redis客戶端與服務端交互原理

pipeline 管道交互模式

上面就是pipeline 交互模式的穿插請求響應。pipeline 的實現取決於客戶端,需要考慮一下幾個方面:

  • 通過批量發送還是異步化請求實現。
  • 批量發送需要考慮每個批次的數據量,避免連接的buffer 滿之後的死鎖。
  • 對使用者如何封裝接口,使得pipiline 使用簡單。

pileline 能達到的單連接每秒最高吞吐量爲:

(n - 2*網絡延遲) / n*(服務器處理時間 + 客戶端處理時間)

當n 無限大時,可以得到:

1 / (服務器處理時間 + 客戶端處理時間)

此時可以看出,吞吐量上了一個數量級。

事務模式

上面介紹的pipeline 模式對於Redis 來說和普通的請求/響應模式沒有太大的區別。當多個客戶端時,Server接到的交叉請求和pipeline 模式類似。如下圖所示:

BAT大牛Redis客戶端與服務端交互原理

Redis 服務端交叉請求

通常我們在開發時,需要將批量的命令原子化執行,Redis 中引入了事務模式,如下圖所示:

BAT大牛Redis客戶端與服務端交互原理

Redis 事務請求

1、入隊/執行分離的事務原子性

客戶端通過和Redis Server兩階段的交互做到了批量命令原子化的執行效果:

  • 入隊階段:客戶端發送請求到服務器,這些命令會被存放在Server端的conn的請求隊列中。
  • 執行階段:發送完一個批次後,Redis 服務器一次執行隊列中的所有請求。由於單實例使用單線程處理請求,因此不會存在併發的問題。

因爲Redis執行器一次執行的粒度是“命令”,所以爲了原子地執行批次命令,Redis引入了批量命令執行:EXEC。事務交互模式如下:

BAT大牛Redis客戶端與服務端交互原理

Redis 事務交互模式

上圖由MULTI命令開啓事務,隨後發送的請求都是暫存在Server conn的隊列中,最後通過EXEC批量提交執行;並返回每一個命令執行的結果數組。

2、事務的一致性

當入隊階段出現語法錯誤時,不執行EXEC 也不會對數據產生影響;當EXEC 中有一條command 執行出錯時,後續請求繼續執行,執行結果會在響應數組中體現,並且由客戶端決定如何恢復,Redis 本身不包含回滾機制。

Redis 事務沒有回滾機制,使得事務的實現大大簡化,但是嚴格的將,Redis 事務並不是一致的。

3、事務的只讀操作

批量請求在服務器端一次性執行,應用程序需要在入隊階段確定操作值。也就是說,每個請求的參數不能依賴上一次請求的執行結果。由此看來,在事務操作中使用只讀操作沒有任何意義。

一個事務通常需要包含讀操作,應用程序需要根據讀取的結果決定後續的操作流程。但是在Redis中,事務中的讀操作並無意義,如下所示:

100 <== get a // 獲取a 的值=100
100 <== get b // 獲取b 的值=100
OK <== multi
QUEUED <== set b 110
QUEUED <== set a 90
[1, 1] <== exec // 執行上述操作,並返回2次執行的結果

由此可以看出,在multi 之前的步驟如果a / b 的值發生了改變,此時數據就錯了。

4、樂觀鎖的可串行化事務隔離

Redis可以通過watch 機制用樂觀鎖解決上述問題。

(1)將本次事務涉及的所有key 註冊爲觀察者模式,此時邏輯時間爲tstart。

(2)執行讀操作。

(3)根據讀操作的結果,組裝寫操作併發送到服務端入隊。

(4)發送exec 嘗試執行隊列中的命令,此時邏輯時間爲tcommit。

執行的結果有以下2種情況:

(4a)如果前面受觀察的key,在tstart和tcommit 之間被修改過,那麼exec 將直接失敗,拒絕執行;

(4b)否則順序執行請求隊列中的所有請求。

Demo如下所示:

WATCH mykey
val = GET mykey
val = val + 1
MULTI
SET mykey $val
EXEC

上面需要注意一點的是,exec 無論執行成功與否,甚至是沒執行,當conn斷掉的時候,就會自動unwatch。上述流程執行失敗後,客戶端通常的處理邏輯是重試,這也類似於JDK中提供的無鎖自旋操作。

5、事務實現

事務的狀態保存在redisClient中,通過2 個屬性控制:

typedef struct redisClient {
 ...
 int flags;
 multiState mstate;
 ...
} redisClient;

其中flags 包含多個bit,其中有2 個bit分別標記了:當前連接處於multi 和 exec 之間、當前watch 之後到現在它所觀察的key 是否被修改過。mstate 的結構如下:

typedef struct multiState {
 multiCmd *commands;
 int count;
 ...
} multiState;

count 用來標記multi 到exec 之間總共有多少個待執行命令,同時commands 就是該連接的請求隊列。

watch 機制通過維護redisDb 中的全局map 來實現:

typedef struct redisDb {
 dict *dict;
 dict *expires;
 dict *blocking_keys;
 dict *ready_keys;
 dict *watched_keys;
 struct evictionPoolEntry * eviction_pool;
 int id;
 long long avg_ttl;
} redisDb;

map的鍵是被watch 的key,值是watch 這些key 的redisClient 指針的鏈表。

當redis 執行一個寫命令時,它同時會對執行命令的key 在watched_keys 中找到對應的client,並將client 的flag 對應位置設爲:REDIS_DIRTY_CAS,client 執行exec 之前看到flag 有REDIS_DIRTY_CAS 標記,則拒絕執行。

事務的結束或者顯示的unwatch 都會重置redisClient 中的REDIS_DIRTY_CAS 標記,並從redisDb 對應watched_keys 中的鏈表中刪除。

6、事務交互模式

綜上,conn 中的事務交互如下:

客戶端發送4 類請求:監聽相關(watch、unwatch)、讀請求、寫請求的批量執行(EXEC)或者放棄執行請求(DISCARD)、寫請求的入隊(MULTI 和 EXEC之間的命令)。

交互時序爲:開啓對keys 的監聽 --> 只讀操作-->MULTI請求-->根據前面只讀操作的結果編排/參數賦值/入隊寫操作-->批量執行隊列中的命令。

腳本模式

對於前面介紹的事務模式,Redis 需要做到如下的約束:

  • 事務的讀操作必須優先於寫操作。
  • 所有寫操作不依賴於其他寫操作。
  • 使用樂觀鎖避免一致性問題,對相同key 併發訪問頻繁時,成功率較低。

然而Redis允許客戶端向服務器提交一個腳本,腳本可以獲取每次操作的結果,作爲下次執行的入參。這使得服務器端的邏輯嵌入成爲了可能,下面介紹一下腳本的交互。

1、腳本交互模式

客戶端發送 eval lua_script_string 2 key1 key2 first second 給服務端。

服務端解析lua_script_string 並根據string 本身的內容通過sha1 計算出sha值,存放到redisServer對象的lua_scripts變量中。

服務端原子化的通過內置Lua環境執行 lua_script_string,腳本可能包含對Redis的方法調用如set 等。

執行完成之後將lua的結果轉換成Redis類型返回給客戶端。

2、script 特性

提交給服務端的腳本包含以下特性:

每一個提交到服務器的lua_script_string 都會在服務器的lua_script_map 中常駐,除非顯示通過flush 命令清除。

script 在示例的主備間可通過script 重放和cmd 重放2 種方式實現複製。

前面執行過的script,後續可以通過直接通過sha指定,而不用再向服務器發送一遍script內容。

發佈/訂閱模式

上面幾種交互模式都是由客戶端主動觸發,服務器端被動接收。Redis還有一種交互模式是一個客戶端觸發,通過服務器中轉,然後發送給多個客戶端被動接收。這種模式稱爲發佈/訂閱模式。

1、發佈/訂閱交互模式

(1)角色關係

客戶端分爲發佈者和訂閱者2 中角色;

發佈者和訂閱者通過channel 關聯。

(2)交互方向

發佈者和Redis 服務端的交互模式仍爲 請求/響應模式;

服務器向訂閱者推送數據;

時序:推送發生在服務器接收到發佈消息之後。

2、兩類 channel

普通channel:訂閱者通過subscribe/unsubscribe 將自己綁定/解綁到某個channel上;

pattern channel:訂閱者通過psubscrige/punsubscribe 將自己綁定/解綁到某個 pattern channel上面,這個是模式匹配的channel,如下圖所示:

BAT大牛Redis客戶端與服務端交互原理

模式匹配的channel

上圖中的customer-2 同時訂閱了普通的channel abc 和pattern channel *bc。當producer-1 向channel abc 發送消息時,除了abc 之外,pattern channel *bc 也會收到消息,然後再推送給分別的訂閱者。

3、訂閱關係的實現

channel 的訂閱關係,維護在Redis 實例級別,獨立於redisDb 的key-value 體系,由下面2 個成員變量維護:

typedef struct redisServer {
 ...
 dict *pubsub_channels;
 list *pubsub_patterns;
 ...
};
  • pubsub_channels map 維護普通channel和訂閱者的關係:key 是channel的名字,value是所有訂閱者 client 的鏈表;
  • pubsub_patterns 維護 pattern channel 和訂閱者的關係:鏈表的每個元素包含2部分(pattern channel 的名字和訂閱它的client 指針)。

每當發佈者向某個channel publish 一條消息時,redis 首先會從pubsub_channels 中找到對應的value,向它的所有Client發送消息;同時遍歷pubsub_patterns列表,向能夠匹配到元素的client 發送消息。

普通/pattern channel的訂閱關係增減僅在pubsub_channels / pubsub_patterns 獨立進行,不做關聯變更。例如:向普通channel subscribe 一個訂閱者時,不會同時修改pubsub_patterns。

寫在最後:

碼字不易看到最後了,那就點個讚唄!!!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章