Redis Cluster 叢集一致性原理及slot遷移測試
叢集資訊一致性問題
主從和slot的一致性是由epoch來管理的. epoch就像Raft中的term, 但僅僅是像. 每個節點有一個自己獨特的epoch和整個叢集的epoch, 為簡化下面都稱為node epoch和cluster epoch. node epoch一直遞增, 其表示某節點最後一次變成主節點或獲取新slot所有權的邏輯時間. cluster epoch則是整個叢集中最大的那個node epoch. 我們稱遞增node epoch為bump epoch, 它會用當前的cluster epoch加一來更新自己的node epoch.
在使用gossip協議中, 如果多個節點聲稱不同的叢集資訊, 那對於某個節點來說究竟要相信誰呢? Redis Cluster規定了每個主節點的epoch都不可以相同. 而一個節點只會去相信擁有更大node epoch的節點聲稱的資訊, 因為更大的epoch代表更新的叢集資訊.原則上:
(1)如果epoch不變, 叢集就不應該有變更(包括選舉和遷移槽位)
(2)每個節點的node epoch都是獨一無二的
(3)擁有越高epoch的節點, 叢集資訊越新
Epoch Collision
實際上, 在遷移slot或者使用cluster failover的時候, 如果多個節點同時bump epoch, 就有可能出現多個節點擁有同一個epoch, 違反上述原則(2)和(3). 這個時候擁有較小node id的節點就會自動再一次bump epoch, 以保證原則(3). 而原則(2)實際上因此也並不嚴格成立, 因為解決epoch collision需要一小段時間.
slot
最大的問題在於slot. 我們遇到過數次遷移slot失敗後出現slot不一致的情況. 如果還沒搞懂它怎麼管slot, 請記住下面這句話:
不要用亂用cluster setslot node.實在要使用 如果此時的節點沒有importing flag則必須要給它發一次cluster bumpepoch.
我相信大多數不一致問題都是我們作死用這個命令造成的. 除了它我暫時還沒找到有什麼大概率的情況會導致不一致.
slot 管理
首先我們搞清楚slot究竟是怎麼管的. 每個節點都有一份16384長的表對應每個slot究竟歸哪個節點, 並且會儲存當前節點所認為的其它節點的node epoch. 這樣每個slot實際上綁定了一個節點及其node epoch. 然後由自認為擁有某slot的節點來負責通知其它節點這個slot的歸屬. 其它節點收到這個訊息後, 會對比該slot原先繫結節點的node epoch, 如果收到的是更大的node epoch則更新, 否則不予理睬. 除此之外, 除了使用slot相關命令做變更, 叢集沒有其它途徑修改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呼叫cluster setslot migrating設定migrating flag, node B呼叫cluster setslot importing設定importing flag
(2) 呼叫migrate指令遷移所有該slot的資料到node B
(3) 對兩個節點使用cluster setslot node來消除importing和migrating flag, 並且設定槽位
重點在於遷移最後一步消除importing flag使用的cluster setslot node,如果對一個節點使用cluster setslot node的時候節點有importing flag, 節點會bump epoch, 這樣這個節點聲稱slot所有權時別的節點就會認可.
但是這裡並沒有跑一遍選舉中的投票流程. 如果另外一個節點也同時bump epoch, 就出現epoch collision. 這裡是一個不完美但又略精妙的地方. 不管這個清importing flag的節點在解決collision後是否獲得更高的epoch, 其epoch肯定大於migrating那個節點之前的epoch.
但這裡還是有漏洞, 萬一node B在廣播自己的新node epoch前, node A做了什麼變更而獲取了一個更大的node epoch呢? 萬一發生collision的是node A和node B兩個節點呢? 這個時候假如node A的node id更小, node A會拿到更大的新epoch. 只要某個節點收到node A的訊息, 這個slot的遷移資訊就永遠寫不進這個節點了, 因為node A的node epoch比node B更大.
上面提到的cluster setslot node的問題在於, 如果節點沒有importing flag, 它會直接設定槽位, 但不會增加自己的node epoch.這樣當他告訴別的節點對這個槽位的所有權時, 其他節點並不認可. 這實際上違反了上述原則(1). 詳細見這裡.所以實在要在遷移slot以外的地方用這個命令, 必須要給它發一次cluster bumpepoch.
注意的地方
cluster setslot node在源節點和目標節點都須要執行,因此cluster bumpepoch也須要執行兩次思考
當slot處於migrating或者importing狀態時,客戶端該如何訪問該slot所屬的key (1)當一個槽被設定為 MIGRATING 狀態時, 原來持有這個槽的節點仍然會繼續接受關於這個槽的命令請求, 但只有命令所處理的鍵仍然存在於節點時, 節點才會處理這個命令請求。如果命令所使用的鍵不存在與該節點, 那麼節點將向客戶端返回一個 -ASK 轉向(redirection)錯誤, 告知客戶端, 要將命令請求傳送到槽的遷移目標節點。(2)當一個槽被設定為 IMPORTING 狀態時, 節點僅在接收到 ASKING 命令之後, 才會接受關於這個槽的命令請求。如果客戶端沒有向節點發送 ASKING 命令, 那麼節點會使用 -MOVED 轉向錯誤將命令請求轉向至真正負責處理這個槽的節點。
假設現在, 我們有 A 和 B 兩個節點, 並且我們想將槽 8 從節點 A 移動到節點 B , 於是我們:
- 向節點 B 傳送命令 CLUSTER SETSLOT 8 IMPORTING A
- 向節點 A 傳送命令 CLUSTER SETSLOT 8 MIGRATING B
每當客戶端向其他節點發送關於雜湊槽 8 的命令請求時, 這些節點都會向客戶端返回指向節點 A 的轉向資訊:
- 如果命令要處理的鍵已經存在於槽 8 裡面, 那麼這個命令將由節點 A 處理。
- 如果命令要處理的鍵未存在於槽 8 裡面(比如說,要向槽新增一個新的鍵), 那麼這個命令由節點 B 處理。
這種機制將使得節點 A 不再建立關於槽 8 的任何新鍵。
關於ASK轉向
當節點需要讓一個客戶端長期地(permanently)將針對某個槽的命令請求傳送至另一個節點時, 節點向客戶端返回 MOVED 轉向,另一方面, 當節點需要讓客戶端僅僅在下一個命令請求中轉向至另一個節點時, 節點向客戶端返回 ASK 轉向。
比如說, 在我們上一節列舉的槽 8 的例子中, 因為槽 8 所包含的各個鍵分散在節點 A 和節點 B 中, 所以當客戶端在節點 A 中沒找到某個鍵時, 它應該轉向到節點 B 中去尋找, 但是這種轉向應該僅僅影響一次命令查詢, 而不是讓客戶端每次都直接去查詢節點 B :在節點 A 所持有的屬於槽 8 的鍵沒有全部被遷移到節點 B 之前, 客戶端應該先訪問節點 A , 然後再訪問節點 B 。
因為上述原因,如果我們要在查詢節點 A 之後, 繼續查詢節點 B , 那麼客戶端在向節點 B 傳送命令請求之前, 應該先發送一個 ASKING命令, 否則這個針對帶有 IMPORTING 狀態的槽的命令請求將被節點 B 拒絕執行。接收到客戶端 ASKING 命令的節點將為客戶端設定一個一次性的標誌(flag), 使得客戶端可以執行一次針對 IMPORTING 狀態的槽的命令請求。
從客戶端的角度來看, ASK 轉向的完整語義(semantics)如下:
- 如果客戶端接收到 ASK 轉向, 那麼將命令請求的傳送物件調整為轉向所指定的節點。
- 先發送一個 ASKING 命令,然後再發送真正的命令請求。
- 不必更新客戶端所記錄的槽 8 至節點的對映: 槽 8 應該仍然對映到節點 A , 而不是節點 B 。
一旦節點 A 針對槽 8 的遷移工作完成, 節點 A 在再次收到針對槽 8 的命令請求時, 就會向客戶端返回 MOVED 轉向, 將關於槽 8 的命令請求長期地轉向到節點 B 。
注意, 即使客戶端出現 Bug , 過早地將槽 8 對映到了節點 B 上面, 但只要這個客戶端不傳送 ASKING 命令, 客戶端傳送命令請求的時候就會遇上 MOVED 錯誤, 並將它轉向回節點 A 。
測試
環境模擬:oldkey{TEST_ASK}是slot遷移前set的,屬於8003節點,newkey{TEST_ASK}是slot遷移後set的,屬於8001節點,以上過程 也間接印證了一旦原節點slot處於migrating狀態,不再處理關於遷移的slot的任意新的鍵 客戶端測試: 一:客戶端使用單機Jedis例項 (1)測試對遷移前的key讀取情況
public static void main(String[] args) { //key-redis節點對映的map,解決ASK 只應該是一次性的,因此將map定義在方法內部 Map<String,HostAndPort> askHostAndPortMap = new HashMap<String, HostAndPort>(); Boolean returnFlag; String key = "oldkey{TEST_ASK}"; HostAndPort hp; Jedis jedis = null; String host = BaseConfig.HOST; int port = BaseConfig.PORT;//8001 do { returnFlag = Boolean.FALSE; hp = askHostAndPortMap.get(key);//從快取中取出HostAndPort if (hp == null) hp = movedHostAndPortMap.get(key);//從快取中取出HostAndPort try { if (hp != null){ host = hp.getHost(); port = hp.getPort(); } else { port = BaseConfig.PORT; host = BaseConfig.HOST; } jedis = jedisUtils.getJedis(host,port); if (askHostAndPortMap.size()>0){ askHostAndPortMap.clear();//清空askMap jedis.asking();//上次返回了ask } String value = jedis.get(key); System.out.println(key+":"+value); } catch (JedisMovedDataException e) { //rediscluster:當前key所在的slot不是當前連線的redis節點,jedis丟擲moved,要求客戶端重定向到正確的redis節點 returnFlag = Boolean.TRUE; movedHostAndPortMap.put(key, e.getTargetNode());//更新快取的map System.out.println("我進行了一次moved:"+e.getTargetNode().getHost()+":"+e.getTargetNode().getPort()); } catch (JedisAskDataException e) { //rediscluster:當前key所在的slot處於migrating/importing狀態,jedis丟擲ask,要求客戶端重定向到正確的redis節點 returnFlag = Boolean.TRUE; askHostAndPortMap.put(key, e.getTargetNode());//更新快取的map System.out.println("我進行了一次ask:"+e.getTargetNode().getHost()+":"+e.getTargetNode().getPort()); } catch (Exception e) { e.printStackTrace(); } finally { if (jedis != null) jedisUtils.closeJedis(jedis,host,port);//釋放連線 } } while (returnFlag); }控制檯輸出:
moved之後從8003上讀取到了value,測試通過 (2)測試對遷移後的新key讀取情況,將key替換為新的key
String key = "newkey{TEST_ASK}";控制檯輸出:
move到8003嘗試讀取,服務端返回ask定向,最終在8001上讀取到了value,測試通過 (3)測試客戶端未傳送asking的情況 註釋asking傳送
if (askHostAndPortMap.size()>0){ askHostAndPortMap.clear();//清空askMap //jedis.asking();//上次返回了ask }控制檯輸出:
由於8001沒有接收到asking指令,請求被拒絕,節點使用 -MOVED 轉向錯誤將命令請求轉向至真正負責處理這個槽的節點8003 由於客戶端始終不會發送asking指令,進入了死迴圈,測試通過 (4)測試slot遷移完畢後的情況
遷移slot
控制檯輸出:
發現slot遷移完畢後,直接在8001上讀取到了value,預設連線8001所以無須重定向,測試通過。 二:客戶端使用JedisCluster例項
經測試當客戶端使用JedisCluster例項時不須要考慮MOVED和ASK轉向,內部已經封裝了對其的一些處理,直接呼叫即可
@Test public void testJedisCluster() { try { String key = "newkey{TEST_ASK}"; System.out.println(key+":"+jedisCluster.get(key)); jedisCluster.close(); } catch (IOException e) { e.printStackTrace(); } }
至此,slot遷移完畢,遷移中間狀態的讀取測試完畢。