Redis實例運行在單獨的進程中,應用系統(Redis客戶端)通過Redis協議和Redis Server 進行交互。在Redis 協議之上,客戶端和服務端可以實現多種類型的交互模式:串行請求/響應模式、雙工的請求/響應模式(pipeline)、原子化的批量請求/響應模式(事務)、發佈/訂閱模式、腳本化的批量執行(Lua腳本)。
Redis 協議
Redis的交互協議包含2 個部分:網絡模型和序列化協議。前者討論數據交互的組織方式,後者討論數據本身如何序列化。
網絡模型
Redis協議位於TCP之上,客戶端和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,通過'$' 之後的數字進行區分:
- "$0 " 表示空串;
- "$-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在前一個命令執行完成之後,再發送第二個請求。如下圖所示:
串行化交互
這種方式的弊端在於,每一個請求的發送都依賴於前一個響應。同一個connection 上面的吞吐量較低:
單連接吞吐量 = 1 / (2*網絡延遲 + 服務器處理時間 + 客戶端處理時間)
Redis 對於單個請求的處理時間(10幾微秒)通常比局域網的延遲小1個數量級。因此串行模式下,單連接的大部分時間都處於網絡等待,沒有充分利用服務器的處理能力。
pipeline 實現
因爲TCP是全雙工的,請求響應穿插進行時,也不會引起混淆。此時批量的發送命令至服務器,在批量的獲取響應數據,可以極大的提高單連接的吞吐量。如下入所示:
pipeline 管道交互模式
上面就是pipeline 交互模式的穿插請求響應。pipeline 的實現取決於客戶端,需要考慮一下幾個方面:
- 通過批量發送還是異步化請求實現。
- 批量發送需要考慮每個批次的數據量,避免連接的buffer 滿之後的死鎖。
- 對使用者如何封裝接口,使得pipiline 使用簡單。
pileline 能達到的單連接每秒最高吞吐量爲:
(n - 2*網絡延遲) / n*(服務器處理時間 + 客戶端處理時間)
當n 無限大時,可以得到:
1 / (服務器處理時間 + 客戶端處理時間)
此時可以看出,吞吐量上了一個數量級。
事務模式
上面介紹的pipeline 模式對於Redis 來說和普通的請求/響應模式沒有太大的區別。當多個客戶端時,Server接到的交叉請求和pipeline 模式類似。如下圖所示:
Redis 服務端交叉請求
通常我們在開發時,需要將批量的命令原子化執行,Redis 中引入了事務模式,如下圖所示:
Redis 事務請求
1、入隊/執行分離的事務原子性
客戶端通過和Redis Server兩階段的交互做到了批量命令原子化的執行效果:
- 入隊階段:客戶端發送請求到服務器,這些命令會被存放在Server端的conn的請求隊列中。
- 執行階段:發送完一個批次後,Redis 服務器一次執行隊列中的所有請求。由於單實例使用單線程處理請求,因此不會存在併發的問題。
因爲Redis執行器一次執行的粒度是“命令”,所以爲了原子地執行批次命令,Redis引入了批量命令執行:EXEC。事務交互模式如下:
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,如下圖所示:
模式匹配的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。
寫在最後:
碼字不易看到最後了,那就點個讚唄!!!