從事Redis Cluster相關(guān)工具開發(fā)半年多, 記錄一下對它的理解和集群管理的想法吧. 這里不復述Redis Cluster基礎的東西, 需先看官方文檔.
Redis Cluster 要求客戶端使用新的協(xié)議, 我們公司為此開發(fā)了 corvus 這個proxy來讓客戶端可以繼續(xù)使用單機Redis協(xié)議. 不像twemproxy和codis, corvus本身無狀態(tài), 不需要依賴zookeeper, 而只是從redis集群中拉取集群信息來做路由表.
Redis Cluster 使用gossip協(xié)議維護集群信息. 它的gossip協(xié)議并不能嚴格保證集群信息一致, 在誤用或極端情況下, 集群信息并不能自動恢復一致, 而且不容易修復. 使用Redis Cluster需要理解透其中原理, 不隨意亂做變更操作, 并且要有一套成熟的運維系統(tǒng).
我們的業(yè)務對緩存可用性要求較高, 使用Redis Cluster的方針是首要保證能夠快速創(chuàng)建一個可用的集群, 其次要嚴格限制可對集群做的變更操作, 還有盡可能用小集群.
集群信息一致性問題
這里不是指數(shù)據(jù)的一致性, 而是集群信息的一致性. 最重要的兩個集群信息是主從角色和slot的歸屬. 個人感覺集群信息管理松散混亂, 但是在一般情況下能維持一致性. 如果真出現(xiàn)了不一致的問題, 建議不要浪費時間, 直接重建集群吧. 有些坑不是一時半會能解決的.
為什么我不提節(jié)點列表的一致性問題? 固然集群里面有哪些節(jié)點這個信息可以說是所有其它信息的基礎, 但是從實用的角度來說, 這可以由運維系統(tǒng)來保證不出問題, 下面另述.
主從和slot的一致性是由epoch來管理的. epoch就像Raft中的term, 但僅僅是像. 每個節(jié)點有一個自己獨特的epoch和整個集群的epoch, 為簡化下面都稱為node epoch和cluster epoch. node epoch一直遞增, 其表示某節(jié)點最后一次變成主節(jié)點或獲取新slot所有權(quán)的邏輯時間. cluster epoch則是整個集群中最大的那個node epoch. 我們稱遞增node epoch為bump epoch, 它會用當前的cluster epoch加一來更新自己的node epoch.
在使用gossip協(xié)議中, 如果多個節(jié)點聲稱不同的集群信息, 那對于某個節(jié)點來說究竟要相信誰呢? Redis Cluster規(guī)定了每個主節(jié)點的epoch都不可以相同. 而一個節(jié)點只會去相信擁有更大node epoch的節(jié)點聲稱的信息, 因為更大的epoch代表更新的集群信息.
原則上:
(1)如果epoch不變, 集群就不應該有變更(包括選舉和遷移槽位)
(2)每個節(jié)點的node epoch都是獨一無二的
(3)擁有越高epoch的節(jié)點, 集群信息越新
Epoch Collision
實際上, 在遷移slot或者使用cluster failover
的時候, 如果多個節(jié)點同時bump epoch, 就有可能出現(xiàn)多個節(jié)點擁有同一個epoch, 違反上述原則(2)和(3). 這個時候擁有較小node id的節(jié)點就會自動再一次bump epoch, 以保證原則(3). 而原則(2)實際上因此也并不嚴格成立, 因為解決epoch collision需要一小段時間.
選舉
從節(jié)點選舉的時候其實沒什么問題, 就是一個從節(jié)點搶選票的過程. 我們稱管理相同slot集合的所有主從節(jié)點為一個分片. 選舉的時候, 掛掉分片的所有從節(jié)點會向其它分片的所有主節(jié)點索取選票, 如果取到的選票超過分片數(shù)的半數(shù), 該從節(jié)點就選舉成功.
slot
最大的問題在于slot. 我們遇到過數(shù)次遷移slot失敗后出現(xiàn)slot不一致的情況. 如果還沒搞懂它怎么管slot, 請記住下面這句話:
不要用亂用cluster setslot node
.
我相信大多數(shù)不一致問題都是我們作死用這個命令造成的. 除了它我暫時還沒找到有什么大概率的情況會導致不一致.
slot 管理
首先我們搞清楚slot究竟是怎么管的. 每個節(jié)點都有一份16384長的表對應每個slot究竟歸哪個節(jié)點, 并且會保存當前節(jié)點所認為的其它節(jié)點的node epoch. 這樣每個slot實際上綁定了一個節(jié)點及其node epoch. 然后由自認為擁有某slot的節(jié)點來負責通知其它節(jié)點這個slot的歸屬. 其它節(jié)點收到這個消息后, 會對比該slot原先綁定節(jié)點的node epoch, 如果收到的是更大的node epoch則更新, 否則不予理睬. 除此之外, 除了使用slot相關(guān)命令做變更, 集群沒有其它途徑修改slot的歸屬.
slot x 是我管的, 我的node epoch是 y
node A ------------------------------> node B
(原來slot x歸node C管, 如果 y 比 node C 的node epoch大, 我就更新slot x的歸屬)
這實際上依賴上述的原則(3), 并且相信slot的舊主人還沒有更新epoch.
遷移slot的一致性
下面來看遷移slot如何保證slot歸屬的一致性.
從node A遷移一個槽位到node B的流程是:
(1) node A 設置migrating flag, node B 設置importing flag
(2) 遷移所有該slot的數(shù)據(jù)到node B
(3) 對兩個節(jié)點使用cluster setslot node
來消除importing和migrating flag, 并且設置槽位
重點在于遷移最后一步消除importing flag使用的cluster setslot node
, 如果對一個節(jié)點使用cluster setslot node
的時候節(jié)點有importing flag, 節(jié)點會bump epoch, 這樣這個節(jié)點聲稱slot所有權(quán)時別的節(jié)點就會認可.
但是這里并沒有跑一遍選舉中的投票流程. 如果另外一個節(jié)點也同時bump epoch, 就出現(xiàn)epoch collision. 這里是一個不完美但又略精妙的地方. 不管這個清importing flag的節(jié)點在解決collision后是否獲得更高的epoch, 其epoch肯定大于migrating那個節(jié)點之前的epoch.
但這里還是有漏洞, 萬一node B在廣播自己的新node epoch前, node A做了什么變更而獲取了一個更大的node epoch呢? 萬一發(fā)生collision的是node A和node B兩個節(jié)點呢? 這個時候假如node A的node id更小, node A會拿到更大的新epoch. 只要某個節(jié)點先收到node A的消息, 這個slot的遷移信息就永遠寫不進這個節(jié)點了, 因為node A的node epoch比node B更大.
上面提到的cluster setslot node
的問題在于, 如果節(jié)點沒有importing flag, 它會直接設置槽位, 但不會增加自己的node epoch. 這樣當他告訴別的節(jié)點對這個槽位的所有權(quán)時, 其他節(jié)點并不認可. 這實際上違反了上述原則(1). 詳細見這里. 所以實在要在遷移slot以外的地方用這個命令, 必須要給它發(fā)一次cluster bumpepoch
.
運維系統(tǒng)
運維成百上千大大小小的集群不是寫腳本能勝任的事情. 官方那個Ruby腳本絕對不能作為最終方案. 現(xiàn)在我們的方案是以一個可靠的運維系統(tǒng)為基礎把Redis Cluster池化.
檢查, 容錯, 重試, 回滾
實際運維的時候會有各種極端情況. 做任何變更操作, 都要先確保集群是一致并且穩(wěn)定的. 穩(wěn)定是指已經(jīng)沒有還沒同步的信息, 例如多個主節(jié)點有相同的epoch而未處理. 如果集群本身不穩(wěn)定, 有可能觸發(fā)上述遷移slot的時候發(fā)生epoch collision. 而且對于每一步操作, 一定要檢查前提條件是否成立, 例如遷slot最后用cluster setslot node
時需先檢查有沒有importing flag. 還要確保操作是否完成. Redis回一個OK并不能表示操作沒有問題, 因為大部分redis變更命令都是異步的. 例如踢節(jié)點的時候, 假如過了60秒還有節(jié)點認為被踢的節(jié)點還在, 就會因為gossip的傳播把那個節(jié)點重新加進集群.
還要有容錯. 例如在對集群操作的時候Redis給你返回Loading Error, 這個時候Redis是處于不能處理大部分命令的狀態(tài), 連cluster nodes
都不能. 這個時候運維系統(tǒng)要等待并不斷檢查節(jié)點可以接受命令沒有.
基本上每個變更操作都是大操作, 操作跑到一半可能只是部分掛了, 這時要重試, 實在不行要盡可能回滾.
用chunk管理節(jié)點
為了簡化管理, 我們規(guī)定了集群的規(guī)格. 具體做法是每個主節(jié)點有且只有一個從節(jié)點. 并且以4個節(jié)點為最小的管理單位, 我們稱為chunk. 一個chunk有兩主兩從, 分布在兩臺機器上面, 每臺機器兩個節(jié)點, 且4個節(jié)點內(nèi)互相組成主從關(guān)系, 要求負責一個分片的主從分布在不同的機器上面.
一個chunk:
machine A machine B
master 1 \/ master 2
slave 2 /\ slave 1
所有的集群都由 n 個chunk組成而成.
首先為了方便管理部署了不同集群的機器, 要把節(jié)點分組管理才容易. 其次, 這么做保證了主從不可能在同一臺機器上面. 然后在擴容跟縮容的時候, 只要增加或剔除chunk就好了, 可以盡可能平均每臺機器的節(jié)點數(shù), 但又不會破壞主從關(guān)系. 并且要求一個集群使用的機器數(shù)量最少為3臺, 這樣一臺掛了也不會導致有slot沒人管. 我們曾想過用6個節(jié)點為一個chunk, 但是在分配chunk的時候找不出一種好的分配算法, 而4個卻找到了分配算法.
我們只使用1主對應1從, 是因為我們還未發(fā)現(xiàn)多個從節(jié)點有什么好處, 而且從節(jié)點不能頂請求壓力還因為主從同步消耗不少資源. 如果把讀分一部分流量到從節(jié)點還會讀到舊數(shù)據(jù), 而且還提高選舉延遲發(fā)生的概率.
并且應當關(guān)掉replica migration, Redis Cluster自身管理松散, 但實踐中應當嚴格規(guī)定好節(jié)點的分布.
chunk分配算法
下面簡述如何分配chunk. 輸入是每臺機器的節(jié)點數(shù), 要求擁有最多節(jié)點數(shù)的機器上的節(jié)點數(shù), 不能超過總節(jié)點數(shù)的一半. 并且每臺機器的節(jié)點數(shù)是偶數(shù), 總節(jié)點數(shù)是4的倍數(shù)(一個chunk4個節(jié)點). 算法會把這些節(jié)點按照chunk的定義組成一個一個chunk, 并且一定能找到一種分配結(jié)果.
算法每次循環(huán):
(1)找出還沒組成chunk的節(jié)點數(shù)最多的那臺機器
(2)然后再找出這臺機器跟哪臺機器擁有最少的共同chunk數(shù)
(3)從這兩臺機器各取兩個節(jié)點, 組成一個chunk
其中(1)保證了算法能終止. (2)使一臺機器掛掉后, 主從切換后, 壓力能夠盡可能平均分到多臺機器上.
我們證明了算法能終止, 關(guān)鍵點是每次循環(huán)擁有最多節(jié)點數(shù)的機器上的節(jié)點數(shù), 不超過總節(jié)點數(shù)的一半
能一直成立, 證明這里就不寫了.
下面是各個運維操作要怎么做.
創(chuàng)建集群
用上述的分配算法算好哪臺機器部署哪些節(jié)點, 然后往上面部署. 我們沒有用官方那個冗長的流程來創(chuàng)建集群, 而是偽造nodes.conf這個用來存集群信息的文件, 然后把相應的節(jié)點進程都拉起來, 最后調(diào)整一下主從角色(因為拉起集群的時候可能發(fā)生了主從切換), 這樣一個集群就好了. 用這種辦法還有個好處, 我們可以自己構(gòu)造node id, 把用于管理的元信息放在里面.
擴容
首先用建集群的方法建一個沒有槽位的集群, 然后用cluster meet
把兩個集群融合起來, 等待所有新節(jié)點都成功加進去了, 再去均分槽位. 如果有節(jié)點硬是加不進去(一直處于handshake), 踢掉所有新節(jié)點, 重新來過. 因為總是可以回滾干凈, 所以不用擔心擴容失敗會導致集群不一致.
下面的操作還未實現(xiàn), 先給出方案.
并行遷移slot
有人在github給Redis提過這個需求, 希望腳本可以并行遷slot, 作者似乎不想實現(xiàn)這個功能. 遷移slot一直都是一個很慢的操作, redis已經(jīng)改了幾次方案了, 但明明并行遷移就可以大大加快遷移速度, 而且只要運維腳本去做就好了, 為什么作者不這么做呢? 我猜測是怕一致性會有問題. 上面提到, 如果migrating和importing的兩個節(jié)點都bump epoch, 是有可能導致集群信息不一致的. 但實際上還是可以做的. 因為基本上在遷移槽位的時候, 一個節(jié)點要么是遷入方, 要么是遷出方, 遷出方除非發(fā)生什么特殊情況, 例如epoch collision, 不然是不會bump epoch的. 防止epoch collision的辦法是操作前先查一遍集群的epoch穩(wěn)定了沒有. 另外, 在cluster setslot node
之后, 要查一遍是不是所有節(jié)點都認可了自己的所有權(quán), 如果不是, 先cluster bumpepoch
, 然后再靠gossip來廣播. 如果檢查一段時間后發(fā)現(xiàn)還是沒得到所有節(jié)點的認可, 重復上述流程直到所有節(jié)點都認同自己對slot的所有權(quán).
遷移機器
有時候機器掛了或者有問題, 想把集群某臺機器的節(jié)點遷移到另一臺機器上. 這個時候可以把nodes.conf文件拷貝到新機器上, 改掉nodes.conf中的ip, 把原節(jié)點關(guān)掉, 把新節(jié)點拉起來, 加進去集群里面. 這利用了只要節(jié)點的node id一樣, Redis就會把新節(jié)點替換掉原節(jié)點, 并且自動更新ip和port.
備份集群
這個主要是為了繞過集群不一致的問題. 在做遷移slot前, 先copy一份rdb文件在本地, 如果集群出現(xiàn)不一致并且難以修復, 在原來的機器上重新建立一個除了節(jié)點port, 其它跟遷移slot前一模一樣的集群, 并且用上之前備份的rdb文件. 最后把不一致的集群刪掉, 用新集群替換老集群.
吐槽
Redis Cluster一個進程一個節(jié)點會導致難以管理集群. 從方便管理的角度來看, 一個集群在一臺機器應當只有一個集群實例, 用多線程或多進程, 每個線程/進程管理該實例的一部分槽位. 現(xiàn)在這種單進程的做法導致大集群產(chǎn)生很大的ping包流量, 有一個幾百個節(jié)點的集群光放在那里沒有任何請求都有300MB的流量.
Redis Cluster的集群協(xié)議理論上只保證了正常流程中集群信息能一致. 只要有一套完善的運維系統(tǒng), 它仍然是一個不完美但可用的方案.