本文發在DBAplus公眾號,https://mp.weixin.qq.com/s/Uyw7MjqMW9DR6EInFIo9Wg,在自己的博客也發一遍。
Redis是非常流行的緩存。在Redis升級到3.0版本後,升級到 集群 版本,被稱之為 Redis cluster。在集群版本中,會將數據分成多份,被保存到多個server中,從而保證集群的水平擴展能力,加之每份數據保存多個副本,從而保證可用性。並且集群版本保證一定程度的Write Safety,本文詳細介紹redis cluster的實現細節,從而分析Redis cluster的write safety的保證程度。
接口和架構
接口
Redis cluster的接口基本向前兼容,仍然是key-value類型。
架構
Redis Cluster包含server和client兩個組件。一個Redis Cluster可以包含多個server,可以包含多個客戶端。每個客戶端可以連接任意的server,讀取寫入數據。保存在Redis cluster中的數據會被分成多份,分散地保存在多個server中,並且每一份數據也會保存多個副本。
實現
節點
在Redis cluster中,數據會被保存到多個Redis server中,每個Redis server都是一個獨立的進程,具有獨立的IP和Port,也被稱之為一個 實例,或者叫做 節點(Node)。Client通過這個IP和Port連接到這個node。
每個節點都有個 node id,node id是一個全局唯一的標識,它是在集群創建時隨機生成的。一個節點的id始終都不會變化的,但是node的IP和port是可以變化的。
Node table
一個Redis cluster包含哪些節點,也就是這個集群包含哪些節點的id,也就是 集群成員關係,這個信息被保存在一個表結構中,被稱之為 node table。 node table類似於:
id | ip | port |
---|---|---|
id1 | xxx.xxx.xxx.xxx | 3001 |
id2 | xxx.xxx.xxx.xxx | 3002 |
node table會在每個節點上都保存一份,Redis cluster通過gossip協議把node table複製到所有的節點。後面會繼續講述node table的複製。
集群成員關係變更
當添加一個節點或者刪除一個節點時,只需要將命令發給集群中的任意一個節點,這個節點會修改本地的node table,並且這個修改會最終複製到所有的節點上去。添加節點的命令是CLUSTER MEET,刪除節點的命令是CLUSTER FORGET。
舉例來說明,打算搭建一個3個master節點的集群,當集群創建以前,所有3個節點的node table都只包含自己。給其中的一個節點A發送命令,CLUSTER MEET NodeB,節點A修改自己的Node table,將NodeB添加到自己的node table中,並且連接節點B,把自己的node table發送給節點B,節點B收到節點A發送過來的Node table,會更新自己的Node table,這時節點B就知道集群中還有節點A存在。這時,給節點A再發送CLUSTER MEET NodeC,節點A會把節點C添加到自己的node table,並且把自己的Node table複製給節點B,節點B把接收到的Node table更新自己的本地的node table,從而知道節點C的加入。同樣節點A會把自己的node table發給節點C,節點C會更新自己本地的Node table,從而知道要加入的集群中已經存在節點A和節點B。
槽
前面說過Redis cluster會把數據分成多份,也就是把數據進行 分片。Redis cluster中的每一份數據被稱為 槽(Slot)。Redis cluster將數據拆分成16384份,也就是說有16384個槽。
Redis Cluster採用 哈希(Hash)機制來拆分數據。首先,數據的key通過CRC16算法計算出一個哈希值。這個哈希值再對16384取餘,這個餘數就是槽位,被稱為 hash slot。具體的CRC16算法可以參看Redis官方文檔^[1]^。所有餘數相同的key都在一個slot中,也就是說,一個slot其實就是一批hash餘數相同的key。
每個hash slot都會保存在Redis cluster一個節點中。具體哪個hash slot被保存在哪個實例中,就形成了類似於一個map的數據結構,被稱之為 hash slot map。hash slot map類似於:
0 -> NodeA
1 -> NodeA
2 -> NodeB
...
16383 -> NodeN
與node table相同,hash slot map也會在每個節點上都會保存一份,Redis cluster通過gossip協議把hash slot map複製到所有節點。同樣,後面還會講述hash slot map的複製。
數據分片變更
要修改數據分片關係,可以連接任意一個節點,給這個節點發送CLUSTER ADDSLOTS, CLUSTER SETSLOT, CLUSTER DELSLOT命令,修改這個節點上的hash slot map,該節點會把這個修改複製到所有其他節點,其他節點會用接收到的hash slot map更新自己的hash slot map。
CLUSTER ADDSLOTS、CLUSTER DELSLOTS、CLUSTER SETSLOT命令的使用如下:
CLUSTER ADDSLOTS slot1 [slot2] ... [slotN]
CLUSTER DELSLOTS slot1 [slot2] ... [slotN]
CLUSTER SETSLOT slot NODE node
CLUSTER ADDSLOTS用來把多個slot分配給當前連接的節點。例如,連接到節點A執行:
CLUSTER ADDSLOTS 1 2 3
這個命令會把slot1、slot2、slot3分配給節點A。
CLUSTER DELSLOTS用來把多個slot從當前連接的節點刪除。例如,連接到節點A執行:
CLUSTER DELSLOTS 1 2 3
這個命令會把slot1、slot2、slot3從節點A上刪除。
CLUSTER SETSLOT用來把一個slot分配給指定的節點,可以不是當前連接的節點,另外這個命令還可以設定MIGRATING和IMPORTING兩個狀態,我們後面再講。例如,連接到節點A執行:
CLUSTER SETSLOT 1 nodeB
這個命令會把slot1分配給節點B。
Slave
一個slot會被保存多個副本,既一個slot會保存在多個節點上,也就是slot會複製到多個節點上。Redis cluster的複製是以節點為單位的,一個節點上的所有slot會採用相同的複製。具體來說就是,其中一個節點會負責處理這個節點上所有slot的寫操作,這個節點被稱為 master,而其餘的節點被稱為 slave 節點。一個master可以有多個slave。在同一個節點上的所有slot的所有的寫操作都會被從master節點異步複製到所有的slave節點。所以slave會具有與master相同的slot。
通過SLAVEOF命令來設置slave節點。SLAVEOF命令用來改變一個slave節點的複製設置。SLAVEOF命令有兩種格式:
SLAVEOF NO ONE
SLAVEOF host port
具體來講,SLAVEOF NO ONE命令會停止一個slave節點的複製,並且把這個slave節點變成master節點。SLAVEOF host port命令會停止一個slave節點的複製,丟棄數據集,開始從host和port指定的新master節點複製。
master和slave的關係會被記錄在hash slot table中,相當於一個slot會映射到多個節點上,其中一個節點是master,其他記錄的節點是slave。
加入了master/slave信息後的hash slot map類似於:
0 -> NodeA,NadeA1(slave)
1 -> NodeA,NadeA1(slave)
2 -> NodeB,NadeB1(slave)
...
16383 -> NodeN,NadeN1(slave)
作為hash slot map的一部分,master/slave信息也會通過gossip協議複製到集群中的每個節點。
Configuration
Node table和hash slot map這兩個信息,被稱之為 configuration,在其他的分佈式系統中,也被稱之為元數據。
前面我們講過,hash slot map和node table在每個節點都會保存一份,並且對這兩個信息的任何改動,都會通過gossip協議 傳播(propogate)到所有的節點。本文我們就不繼續展開gossip協議了,對Redis cluster的gossip協議的實現感興趣的同學可以參看redis的文檔^[1]^。
在某些分佈式系統中,元數據被存儲在一個單獨的架構組件中的。Redis cluster並沒有這樣一個元數據存儲的組件,而是把元數據分散的存儲在所有的節點上。
Hash slot map和node table在集群創建時會被創建出來,並且會隨著後續的集群變更(比如failover和擴容、縮容等運維操作,後面會講述)而跟隨著變更。變動會在一個node上發起,通過gossip協議傳播到所有其他的節點。這兩個信息在所有節點都會保存,並且會最終達到一致。
集群創建
創建Redis cluster時,首先要以cluster模式運行多個redis server,redis server運行起來後,這些server的node id就已經生成了。但是這些redis server並沒有形成集群,也就是server彼此之間並不知道相互的存在。接下來運行CLUSTER MEET命令,讓這些節點形成一個集群。但是這時集群仍然沒有處於運行狀態,需要分配slot,通過CLUSTER SLOTADD命令把slot分配給具體的節點之後,集群就可以處理client的命令了。
客戶端的讀寫操作
客戶端要讀取、寫入數據時,雖然Client可以連接任意server,但是實際中,client需要根據實際需求連接到server讀取、寫入數據。client需要先根據key計算出hash slot,連接到負責這個hash slot的節點進行讀寫操作。這樣的話,client就要需要知道hash slot -> node的映射關係,也就是需要知道hash slot map。前面講過hash slot map被保存在server端的每個節點上。client可以從任意節點獲取hash slot map,並且把它緩存到client本地,下次操作時根據本地緩存直接進行操作,但是需要處理緩存信息過期的問題,如果client發現hash slot map發生變化(即client讀取寫入數據時server端返回錯誤,接下來會詳細講述),會重新從server端獲取新的hash slot map。通過hash slot map可以判斷某個key應該存在在哪個節點上,client再連接這個節點進行讀寫操作。
MOVED Redirection
Hash slot map會發生變更,這些變更會複製到所有的節點,但是gossip保證的是最終複製到所有的節點,再加上client會緩存hash slot map,client可能會把某個key的請求發給錯誤的節點來處理。錯誤的節點收到請求後,發現這個key不應該自己來處理,會給客戶端返回MOVED的錯誤,在錯誤消息中,會告訴客戶端,哪個節點應該負責這個slot。Client收到MOVED消息後,會向消息中指定的節點再次發送請求。
Client可以將slot的節點信息更新到本地緩存的hash slot map中,但是更好的方法是,重新獲取完整的hash slot map,替換本地的緩存。因為在大多數情況下,hash slot map中的變更不僅僅只修改一個slot。
雖然client按照MOVED消息中的節點信息重新發送請求,但是client仍然可能再次從新節點收到MOVED錯誤消息,因為上一個節點的hash slot map可能也不是最新的。但是因為hash slot map最終會在所有的節點上一致,所以client在幾次收到MOVED錯誤後,最終會獲取到最新的hash slot map。
Failover、currentEpoch、lastVoteEpoch
當master發生故障宕機後,Redis cluster會選出一個slave來接替這個master。如果有多個slave存在,那麼每個slave都可能都會發現master發生了宕機,並且試圖把自己變成為master,如果有多個slave成為master,那麼這些新master都會更新本地hash slot map,把舊master負責的slot更新成自己,並且把自己對hash slot map的更新傳播給其他的節點。這會導致hash slot map在節點間出現差異。從而導致,因為所連接的節點不同,client拿到不同的hash slot map,對於同一個slot,不同的client會連接不同的節點,最終導致節點上的數據出現差異。所以failover要保證,只有一個slave被選成新master。
Redis cluster採用了類似Raft算法的技術來防止多個slave被選成master。每個節點都會有叫做currentEpoch、lastVoteEpoch的兩個值。在集群剛創建時,每個節點的currentEpoch都是0。當slave發現master宕機時,這個slave會增加currentEpoch(即currentEpoch++)。並且向所有的master發送FAILOVER_AUTH_REQUEST請求,請求中會攜帶自己currentEpoch,master收到FAILOVER_AUTH_REQUEST,如果請求中的currentEpoch比自己的currentEpoch和lastVoteEpoch都大,則記錄請求中的currentEpoch值到自己的currentEpoch、lastVoteEpoch中,並且回覆FAILOVER_AUTH_ACK給slave,回覆中攜帶master的currentEpoch。所以可以看出,FAILOVER_AUTH_ACK中的Epoch一定與slave的currentEpoch相同。Slave從大多數的master收到FAILOVER_AUTH_ACK後,則成為master。
上面的過程保證只有一個slave被選出,我們來舉例說明。五個master的集群,master節點分別是A、B、C、D、E,A節點有兩個slave,分別是A1和A2。A節點發生宕機,A1增加自己的currentEpoch=5(4+1),A1給所有的master發送FAILOVER_AUTH_REQUEST,節點B、C、D收到FAILOVER_AUTH_REQUEST,把自己的currentEpoch和lastVoteEpoch更新成5,並且給A1回覆FAILOVER_AUTH_ACK,A1贏得選舉,成為新的master。但是與此同時,A2也發現A宕機,也試圖選舉成master。A2增加自己的currentEpoch=5(4+1),A2給所有的master發送FAILOVER_AUTH_REQUEST,但這時的B、C、D的lastVoteEpoch已經是5,所以B、C、D不會給A2回覆,E還沒有收到A1的請求,所以只有E會給A2回覆,但是不能形成大多數,所以A2不能稱為master。
上面講述的過程已經可以保證只有一個master被選出,但是除此之外,Redis cluster還做了一個優化,那就是master回覆了一個請求後不會在給這個master的其他的slave發送回覆。
Configuration epoch
Failover完成後,新master會修改hash slot map,把相應的slot記錄的節點改成自己,並且把這次對hash slot map的改動傳播給其他節點。雖然currentEpoch和lastVoteEpoch能保證每次failover只能有一個節點被選成新的master,但是先後兩次failover,可能選出的兩個不同的master,但是他們對hash slot map的修改的傳播卻是異步,也就是後面一次failover的改動可能先於第一次failover的改動到達某個節點,從而導致節點間對hash slot map這個信息產生不一致。
Redis cluster通過configEpoch來解決這個問題。每個節點會保存一個configEpoch的值。相當於在node table中還會有一列數據叫configEpoch,類似於下面的表:
id | ip | port | configEpoch |
---|---|---|---|
id1 | xxx.xxx.xxx.xxx | 3001 | 1 |
id2 | xxx.xxx.xxx.xxx | 3002 | 2 |
每次failover完成後,新選出的master會用currentEpoch覆蓋configEpoch。Failover的機制保證兩次failover的新master一定是具有不同的currentEpoch,並且後一次的failover的currentEpoch一定比前一次大。這樣就可以保證,即便採用了gossip這樣傳播協議,仍然能夠保證最後一次failover的hash slot map的變更會生效,也就是configEpoch更大的變更會生效,並且最終所有的節點上的hash slot map是一致的。
Slave節點的configEpoch就是其master的configEpoch。
由於gossip保證的hash slot map最終保持一致的,所以可能存在slave的hash slot map舊於master,failover不能基於舊的hash slot map基礎上做變更,所以前面failover的過程中還需要補充一個規則要遵守:
在FAILOVER_AUTH_REQUEST中會攜帶slave節點的configEpoch,如果這個slave的configEpoch比這個slave負責的所有slot的master的configEpoch中任意一個要小,則master不會給slave回覆FAILOVER_AUTH_ACK。
Resharding
在集群創建之後,我們還會有對Redis cluster做擴容、縮容、balancing這樣的運維需求,這些需求本質上都可以用 Resharding 操作解決,resharding操作就是把slot在節點間重新的分佈,把slot從一個節點轉移到另外一個節點上。在Redis cluster中,擴容需求實質上就是加入一個新的節點,再把一些slot分配到這個新節點上。縮容需求實質上就是先把這個節點上的所有slot分配到其他節點上,再把這個節點從集群中移出。當節點間的流量不均衡時,我們有balancing這樣的需求,balancing就是把流量比較大的節點上的一些slot分配都流量比較少的節點上。
Resharding操作可以是對整個hash slot map的調整,也就是可以包括對多個slot的 遷移(migration),遷移就是把一個slot從一個節點遷移到另外一個節點。一個slot migration操作包括前面講的hash slot map變更,另外還包括key的遷移操作。要把一個slot遷移到另外的節點上,首先把這個slot上的所有的key遷移到這個節點,當把所有key都遷移完後,再進行hash slot map變更,當hash slot map變更完成,這次slot migration結束。
Redis cluster使用CLUSTER SETSLOT來設置遷移。舉例說明,將slot1從節點A遷移到節點B。分別對節點A和節點B執行下面的命令:
節點A上:CLUSTER SETSLOT 1 MIGRATING NODEB
節點B上:CLUSTER SETSLOT 1 IMPORTING NODEA
其中,MIGRATING表示數據要從這個節點遷出,而IMPORTING表示數據要往這個節點遷入。
執行完這兩個命令後,節點A中的slot1不在創建新的key。一個叫做redis-trib的特殊的程序負責把所有的key從節點A遷移到節點B。redis-trib會執行下面的命令:
CLUSTER GETKEYSINSLOT slot count。
這個命令會返回count個key,對於每個返回的key,redis-trib執行下面的命令:
MIGRATE target_host target_port key target_database id timeout
這個命令會原子地把一個key從節點A遷移到節點B。具體來說,MIGRATE命令會連接目標節點,併發向目標節點發送這個key,一旦目標節點收到這個key,則從自己的數據庫中刪除這個key,在這個過程中,節點A和節點B都會加鎖。
在把所有的key遷移完後,再分別在兩個節點上執行下面的命令:
CLUSTER SETSLOT slot NODE nodeA
把所有的key遷移完一般需要一些時間,也就是說在開始遷移後和完成遷移前,在這個窗口期內,key的實際的分佈,與hash slot map裡記錄的是不一致的,client按照hash slot map訪問key,會出現錯誤。Redis cluster通過ASK redirection來解決這個問題。按照client端的hash slot map,slot1的key一定會發給節點A,節點A收到這個請求後,如果發現這個key已經遷移到節點B了,那麼就會給client回覆ASK redirection,client收到ASK redirection後,會向節點b先發送一個ASKING命令,之後在發送對這個key的請求。
Configuration的實際存儲
Hash slot map和node table都是邏輯上的結構,他們在Redis cluster中的實際存儲結構稍有不同^[2]^^[3]^^[4]^^[5]^。
在節點的內存中,用兩個變量來存儲這兩個信息。第一個是myself變量。myself代表本節點,是一個ClusterNode類型的變量,這個變量中,包含本節點的configEpoch,還包括slaveof,如果是slave節點則在slaveof中記錄著它的master節點,還包括一個bitmap,代表這個節點負責的所有的slot的槽位值。這個bitmap有2048個byte組成,一總是16384(2048*8)個bit,每個bit代表一個slot,bit置1,代表這個節點負責這個slot。
另外還有一個變量,叫做cluster,代表了所在集群的狀態,它包含currentEpoch、lastVoteEpoch和slots數組,slots數組的index代表了slot,數組的每個成員都指向一個節點,是一個ClusterNode類型的變量,與myself變量的類型一樣。
所有configuration的更改都會被保存到磁盤中,具體來講是保存到一個名字叫node.conf的文件中,這個文件是Redis cluster負責寫入的,不需要人工配置。node.conf按照節點維度進行保存。每一行對應一個節點。每行分別包含這些信息:id,ip:port,flag,slaveof,ping timestamp, pong timespamp,configEpoch,link status,slots。所有的節點結束後,會在文件的最後保存curruntEpoch和lastVoteEpoch兩個變量。其中flag字段是枚舉類型,會指明這個節點是不是自己,節點類型是master還是slave。如果是slave節點,則會在slaveof字段記錄其master節點的id。如果是master節點,則在最後多一個slots字段,記錄著這個節點負責著哪些slot。Flags字段還記錄著其他非常重要的狀態,本文就不繼續展開了。同樣,ping timestamp、pong timestmap、link staus三個字段本文也不繼續展開了。
具體的node.conf文件類似下面的例子:
[[email protected] data]# cat nodes-6384.conf
fb763117270d14205c41174605b15741co03a945 10.112.178.174:6383 slave 5e35bda1a44c8d781eb54e08be88a3bab42070f3 0 1596683852819 2 connected
3dc5890fb1591e3b20196f81eb5f2f99754253e8 10.112.178.141:6383 master - 0 1596683851915 1 connected 0-5461
f1967b687c9b2c27108cce08517e98e7a80d5e7e 10.112.178.171:6383 slave 3dc5890fb1591e3b20196f81eb5f2f99754253e8 0 1596683850813 1 connected
2bbab7353e973e991566df3bb52afb4857a7bf25 10.112.178.171:6384 slave 1f0a8cf1bfd0c915ef404482f3dc6bf5c7cf41f5 0 1596683848812 3 connected
5e35bda1a44c8d781eb54e08be88a3bab42070f3 10.112.178.142:6383 master - 0 1596683849813 2 connected 5462-10923
1f0a8cf1bfd0c915ef404482f3dc6bf5c7cf41f5 10.112.178.141:6384 myself,master - 0 0 3 connected 10924-16383
節點啟動時會讀取node.conf文件,把裡面的信息加載到myself和cluster兩個變量中。Slot信息會被轉換成bitmap保存在myself變量中。並且slot信息還會逆向的轉換成slot到節點的映射保存在cluster變量中。
Hash slot map變更或者node table變更,就是修改內存中的myself變量和cluater變量,並且每次變更都會把這兩個變量序列化轉化後保存到node.conf中。
查看configuration
Redis cluster提供了兩個命令來查看configuration。
第一個是CLUSTER SLOT命令,用來展示hash slot維度的信息,CLUSTER SLOT命令的展示如下:
第二個是CLUSTER NODE命令,用來展示node table維度的信息,CLUSTER NODE命令的展示如下:
$ redis-cli cluster nodes
d1861060fe6a534d42d8a19aeb36600e18785e04 127.0.0.1:6379 myself - 0 1318428930 1 connected 0-1364
3886e65cc906bfd9b1f7e7bde468726a052d1dae 127.0.0.1:6380 master - 1318428930 1318428931 2 connected 1365-2729
d289c575dcbc4bdd2931585fd4339089e461a27d 127.0.0.1:6381 master - 1318428931 1318428931 3 connected 2730-4095
CLUSTER NODE命令展示的結果與node.conf文件中的內容非常類似。每一行都代表一個節點,每一行都包含node id, address:port, slaveof,flags, last ping sent, last pong received, configuration epoch, link state, slots這些字段。
CLUSTER NODE、CLUSTER SLOT兩個命令可以連接到任意節點上執行,這兩個命令都是讀取的這個節點的本地信息,根據gossip的特性,存在這兩個命令展示的不是最新的configuration的可能性。
Conflict
雖然前面講的failover過程通過大多數master投票的方式保證只有一個slave選中,並且產生唯一的configEpoch。但是Resharding的過程卻沒有經過大多數的master的投票。執行slot遷移時,僅僅是在集群中所有configEpoch中最大的那個configEpoch的基礎上,再加一而得到的。並且由於Resharding一般包括多個slot的遷移,Redis cluster目前的做法是,在一次resharding過程中,所有的slot遷移使用的configEpoch都是第一個slot遷移時產生的那個configEpoch。
而failover和resharding都會修改hash slot map,如果在resharding的過程中發生了failover,這就有可能導致對hash slot map的修改產生衝突。另外,手動failover也是不經過master投票的,也就是執行CLUSTER FAILOVER命令(帶TAKEOVER參數)。
產生衝突就是指針對同一個slot,slot被修改成映射到不同的節點上,並且這些修改具有相同的configEpoch。
為了解決這個問題,Redis cluster需要存在一個衝突解決的機制。如果一個master發現相同的configEpoch,則比較一下兩個節點的id,id小的節點,把自己currentEpoch加一,作為自己的configEpoch。
Write safety
由於有衝突的存在,可能導致不同的節點上的hash slot map不一致,取決於連接的節點不同,一部分client可能會把某個slot的key寫入到一個節點中,而另外一部分client會把同樣slot的key寫入到另外一個節點中。當衝突被解決後,其中一個節點上接受的寫入會丟失。
另外,由於master和slave之間的數據複製是異步的,在failover時如果slave還沒有收到最新的數據,就發生了failover,那麼這部分寫入就會丟失。Redis cluster在這方面做了一個優化,當一個slave發現master發生了宕機,它不會立即開始選舉的過程,它會等待一個時間,這個時間計算公式如下:
DELAY = 500 milliseconds + random delay between 0 and 500 milliseconds + SLAVE_RANK * 1000 milliseconds
這個計算公式中,第一部分是一個固定500毫秒的時間,這是為了給master充分的時間,也發現節點宕機這個事實,第二個是隨機等待一段時間,這是為了儘量避免多個slave同時發現master宕機,然後同時開始選舉,導致master被瓜分,從而導致所有的選舉都不成功。第三部分是slave的rank,rank主要取決於slave的複製進度,複製的數據越多,則rank越小,也就是越短的等待時間,越先開始選舉,有更大的可能性被選成新的master。但這僅僅是個優化,不能完全防止丟數據的可能。
參考文獻
1. Redis Cluster Specification, https://redis.io/topics/cluster-spec.
2. https://github.com/redis/redis/blob/unstable/src/cluster.c
3. https://github.com/redis/redis/blob/unstable/src/cluster.h
4. https://github.com/redis/redis/blob/unstable/src/server.c
5. https://github.com/redis/redis/blob/unstable/src/server.h