RedisCluster 是 Redis 的親兒子,它是 Redis 作者自己提供的 Redis 集群化方案。
相對於 Codis 的不同,它是去中心化的,如圖所示,該集群有三個 Redis 節點組成,每個節點負責整個集群的一部分數據,每個節點負責的數據多少可能不一樣。這三個節點相互連接組成一個對等的集群,它們之間通過一種特殊的二進制協議相互交互集群信息。

Redis Cluster 將所有數據划分為 16384 的 slots,它比 Codis 的 1024 個槽划分的更為精細,每個節點負責其中一部分槽位。槽位的信息存儲於每個節點中,它不像 Codis,它不需要另外的分布式存儲來存儲節點槽位信息。當 Redis Cluster 的客戶端來連接集群時,它也會得到一份集群的槽位配置信息。這樣當客戶端要查找某個 key 時,可以直接定位到目標節點。
這點不同於 Codis,Codis 需要通過 Proxy 來定位目標節點,RedisCluster 是直接定位。客戶端為了可以直接定位某個具體的 key 所在的節點,它就需要緩存槽位相關信息,這樣才可以准確快速地定位到相應的節點。同時因為槽位的信息可能會存在客戶端與服務器不一致的情況,還需要糾正機制來實現槽位信息的校驗調整。
另外,RedisCluster 的每個節點會將集群的配置信息持久化到配置文件中,所以必須確保配置文件是可寫的,而且盡量不要依靠人工修改配置文件。
槽位定位算法
Cluster 默認會對 key 值使用 crc32 算法進行 hash 得到一個整數值,然后用這個整數值對 16384 進行取模來得到具體槽位。Cluster 還允許用戶強制某個 key 掛在特定槽位上,通過在 key 字符串里面嵌入 tag 標記,這就可以強制 key 所掛在的槽位等於 tag 所在的槽位。
def HASH_SLOT(key)
s = key.index "{"
if s
e = key.index "}",s+1
if e && e != s+1
key = key[s+1..e-1]
end
end
crc16(key) % 16384
end
跳轉
當客戶端向一個錯誤的節點發出了指令,該節點會發現指令的 key 所在的槽位並不歸自己管理,這時它會向客戶端發送一個特殊的跳轉指令攜帶目標操作的節點地址,告訴客戶端去連這個節點去獲取數據。
GET x
-MOVED 3999 127.0.0.1:6381
MOVED 指令的第一個參數 3999 是 key 對應的槽位編號,后面是目標節點地址。MOVED 指令前面有一個減號,表示該指令是一個錯誤消息。客戶端收到 MOVED 指令后,要立即糾正本地的槽位映射表。后續所有 key 將使用新的槽位映射表。
遷移
Redis Cluster 提供了工具 redis-trib 可以讓運維人員手動調整槽位的分配情況,它使用Ruby 語言進行開發,通過組合各種原生的 Redis Cluster 指令來實現。這點 Codis 做的更加人性化,它不但提供了 UI 界面可以讓我們方便的遷移,還提供了自動化平衡槽位工具,無需人工干預就可以均衡集群負載。不過 Redis 官方向來的策略就是提供最小可用的工具,其它都交由社區完成。
遷移過程

Redis 遷移的單位是槽,Redis 一個槽一個槽進行遷移,當一個槽正在遷移時,這個槽就處於中間過渡狀態。這個槽在原節點的狀態為 migrating,在目標節點的狀態為 importing,表示數據正在從源流向目標。
遷移工具 redis-trib 首先會在源和目標節點設置好中間過渡狀態,然后一次性獲取源節點槽位的所有 key 列表(keysinslot 指令,可以部分獲取),再挨個 key 進行遷移。每個 key的遷移過程是以原節點作為目標節點的「客戶端」,原節點對當前的 key 執行 dump 指令得到序列化內容,然后通過「客戶端」向目標節點發送指令 restore 攜帶序列化的內容作為參數,目標節點再進行反序列化就可以將內容恢復到目標節點的內存中,然后返回「客戶端」OK,原節點「客戶端」收到后再把當前節點的 key 刪除掉就完成了單個 key 遷移的整個過
程。
從源節點獲取內容 => 存到目標節點 => 從源節點刪除內容。
注意這里的遷移過程是同步的,在目標節點執行 restore 指令到原節點刪除 key 之間,原節點的主線程會處於阻塞狀態,直到 key 被成功刪除。如果遷移過程中突然出現網絡故障,整個 slot 的遷移只進行了一半。這時兩個節點依舊處於中間過渡狀態。待下次遷移工具重新連上時,會提示用戶繼續進行遷移。在遷移過程中,如果每個 key 的內容都很小,migrate 指令執行會很快,它就並不會影響客戶端的正常訪問。如果 key 的內容很大,因為 migrate 指令是阻塞指令會同時導致原節點和目標節點卡頓,影響集群的穩定型。所以在集群環境下業務邏輯要盡可能避免大 key 的產生。在遷移過程中,客戶端訪問的流程會有很大的變化。首先新舊兩個節點對應的槽位都存在部分 key 數據。客戶端先嘗試訪問舊節點,如果對應的數據還在舊節點里面,那么舊節點正常處理。如果對應的數據不在舊節點里面,那么有兩種可能,要么該數據在新節點里,要么根本就不存在。舊節點不知道是哪種情況,所以它會向客戶端返回一個-ASK targetNodeAddr 的重定向指令。客戶端收到這個重定向指令后,先去目標節點執行一個不帶任何參數的 asking 指令,然后在目標節點再重新執行原先的操作指令。
為什么需要執行一個不帶參數的 asking 指令呢?
因為在遷移沒有完成之前,按理說這個槽位還是不歸新節點管理的,如果這個時候向目標節點發送該槽位的指令,節點是不認的,它會向客戶端返回一個-MOVED 重定向指令告訴它去源節點去執行。如此就會形成 重定向循環。asking 指令的目標就是打開目標節點的選項,告訴它下一條指令不能不理,而要當成自己的槽位來處理。從以上過程可以看出,遷移是會影響服務效率的,同樣的指令在正常情況下一個 ttl 就能完成,而在遷移中得 3 個 ttl 才能搞定。
容錯
Redis Cluster 可以為每個主節點設置若干個從節點,單主節點故障時,集群會自動將其中某個從節點提升為主節點。如果某個主節點沒有從節點,那么當它發生故障時,集群將完全處於不可用狀態。不過 Redis 也提供了一個參數 cluster-require-full-coverage 可以允許部分節點故障,其它節點還可以繼續提供對外訪問。
網絡抖動
真實世界的機房網絡往往並不是風平浪靜的,它們經常會發生各種各樣的小問題。比如網絡抖動就是非常常見的一種現象,突然之間部分連接變得不可訪問,然后很快又恢復正常。為解決這種問題,Redis Cluster 提供了一種選項 cluster-node-timeout,表示當某個節點持續 timeout 的時間失聯時,才可以認定該節點出現故障,需要進行主從切換。如果沒有這個選項,網絡抖動會導致主從頻繁切換 (數據的重新復制)。還有另外一個選項 cluster-slave-validity-factor 作為倍乘系數來放大這個超時時間來寬松容錯的緊急程度。如果這個系數為零,那么主從切換是不會抗拒網絡抖動的。如果這個系數大於 1,它就成了主從切換的松弛系數。
可能下線 (PFAIL-Possibly Fail) 與確定下線 (Fail)
因為 Redis Cluster 是去中心化的,一個節點認為某個節點失聯了並不代表所有的節點都認為它失聯了。所以集群還得經過一次協商的過程,只有當大多數節點都認定了某個節點失聯了,集群才認為該節點需要進行主從切換來容錯。Redis 集群節點采用 Gossip 協議來廣播自己的狀態以及自己對整個集群認知的改變。比如一個節點發現某個節點失聯了 (PFail),它會將這條信息向整個集群廣播,其它節點也就可以收到這點失聯信息。如果一個節點收到了某個節點失聯的數量 (PFail Count) 已經達到了集群的大多數,就可以標記該節點為確定下線狀態 (Fail),然后向整個集群廣播,強迫其它節點也接收該節點已經下線的事實,並立即對該失聯節點進行主從切換。
Cluster 基本使用
redis-py 客戶端不支持 Cluster 模式,要使用 Cluster,必須安裝另外一個包,這個包是依賴 redis-py 包的。
pip install redis-py-cluster
下面我們看看 redis-py-cluster 如何使用。
>>> from rediscluster import StrictRedisCluster
>>> # Requires at least one node for cluster discovery. Multiple nodes is recommended.
>>> startup_nodes = [{"host": "127.0.0.1", "port": "7000"}]
>>> rc = StrictRedisCluster(startup_nodes=startup_nodes, decode_responses=True)
>>> rc.set("foo", "bar")
True
>>> print(rc.get("foo"))
'bar'
Cluster 是去中心化的,它有多個節點組成,構造 StrictRedisCluster 實例時,我們可以只用一個節點地址,其它地址可以自動通過這個節點來發現。不過如果提供多個節點地址,安全性會更好。如果只提供一個節點地址,那么當這個節點掛了,客戶端就必須更換地址才可以繼續訪問 Cluster。 第二個參數 decode_responses 表示是否要將返回結果中的 byte 數組轉換成 unicode。
Cluster 使用起來非常方便,用起來和普通的 redis-py 差別不大,僅僅是構造方式不同。但是它們也有相當大的不一樣之處,比如 Cluster 不支持事務,Cluster 的 mget 方法相比 Redis 要慢很多,被拆分成了多個 get 指令,Cluster 的 rename 方法不再是原子的,它需要將數據從原節點轉移到目標節點。
槽位遷移感知
如果 Cluster 中某個槽位正在遷移或者已經遷移完了,client 如何能感知到槽位的變化呢?客戶端保存了槽位和節點的映射關系表,它需要即時得到更新,才可以正常地將某條指令發到正確的節點中。
我們前面提到 Cluster 有兩個特殊的 error 指令,一個是 moved,一個是 asking。
第一個 moved 是用來糾正槽位的。如果我們將指令發送到了錯誤的節點,該節點發現對應的指令槽位不歸自己管理,就會將目標節點的地址隨同 moved 指令回復給客戶端通知客戶端去目標節點去訪問。這個時候客戶端就會刷新自己的槽位關系表,然后重試指令,后續所有打在該槽位的指令都會轉到目標節點。
第二個 asking 指令和 moved 不一樣,它是用來臨時糾正槽位的。如果當前槽位正處於遷移中,指令會先被發送到槽位所在的舊節點,如果舊節點存在數據,那就直接返回結果了,如果不存在,那么它可能真的不存在也可能在遷移目標節點上。所以舊節點會通知客戶端去新節點嘗試一下拿數據,看看新節點有沒有。這時候就會給客戶端返回一個 asking error攜帶上目標節點的地址。客戶端收到這個 asking error 后,就會去目標節點去嘗試。客戶端不會刷新槽位映射關系表,因為它只是臨時糾正該指令的槽位信息,不影響后續指令。
重試 2 次
moved 和 asking 指令都是重試指令,客戶端會因為這兩個指令多重試一次。讀者有沒有想過會不會存在一種情況,客戶端有可能重試 2 次呢?這種情況是存在的,比如一條指令被發送到錯誤的節點,這個節點會先給你一個 moved 錯誤告知你去另外一個節點重試。所以客戶端就去另外一個節點重試了,結果剛好這個時候運維人員要對這個槽位進行遷移操
作,於是給客戶端回復了一個 asking 指令告知客戶端去目標節點去重試指令。所以這里客戶端重試了 2 次。
重試多次
在某些特殊情況下,客戶端甚至會重試多次,讀者可以開發一下自己的腦洞想一想什么情況下會重試多次。正是因為存在多次重試的情況,所以客戶端的源碼里在執行指令時都會有一個循環,然后會設置一個最大重試次數,Java 和 Python 都有這個參數,只是設置的值不一樣。當重試次數超過這個值時,客戶端會直接向業務層拋出異常。
集群變更感知
當服務器節點變更時,客戶端應該即時得到通知以實時刷新自己的節點關系表。那客戶端是如何得到通知的呢?這里要分 2 種情況:目標節點掛掉了,客戶端會拋出一個 ConnectionError,緊接着會隨機挑一個節點來重試,這時被重試的節點會通過 moved error 告知目標槽位被分配到的新的節點地址。運維手動修改了集群信息,將 master 切換到其它節點,並將舊的 master 移除集群。這時打在舊節點上的指令會收到一個 ClusterDown 的錯誤,告知當前節點所在集群不可用 (當前節點已經被孤立了,它不再屬於之前的集群)。這時客戶端就會關閉所有的連接,清空槽位映射關系表,然后向上層拋錯。待下一條指令過來時,就會重新嘗試初始化節點信息。