你不知道的Redis:RedisCluster與JedisCluster

前言

Redis Cluster是Redis官方提供的集羣解決方案。因爲業務的飛速增加,單機模式總會遇到內存、性能等各類瓶頸,這個時候咱們總會喊,上集羣啊。就跟我家熱得快炸了,你總喊開空調呀同樣。的確,上集羣能夠解決大多數問題,可是在使用集羣的過程當中,不可避免會遇到這樣那樣的問題,這個時候怎麼辦呢,各類百度各類羣裏去問嗎?NO,做爲開發人員,在享受第三方提供的方便前,有必要去了解其基本的工做機制,這樣才能在遇到問題時快速定位,方便下手。本篇文章主要是梳理Redis集羣的原理和Java客戶端JedisCluster的工做流程及源碼分析,雖萬字長文,但原理通俗易懂,源碼條理清晰。html

1、RedisCluster

有關redis集羣的基本介紹及搭建教程請移步:Redis 集羣教程java

1.1 數據如何讀寫

在單個的 redis節點中,咱們都知道redis把數據已 k-v 結構存儲在內存中,使得 redis 對數據的讀寫很是之快。Redis Cluster 是去中心化的,它將全部數據分區存儲。也就是說當多個 Redis 節點搭建成集羣后,每一個節點只負責本身應該管理的那部分數據,相互之間存儲的數據是不一樣的。node

Redis Cluster 將所有的鍵空間劃分爲16384塊,每一塊空間稱之爲槽(slot),又將這些槽及槽所對應的 k-v 劃分給集羣中的每一個主節點負責。以下圖: redis

槽位分佈圖
key -> slot 的算法選擇上,Redis Cluster 選擇的算法是 hash(key) mod 16383,即便用CRC16算法對key進行hash,而後再對16383取模,結果即是對應的slot。

常見的數據分區方法:算法

  • 節點取餘分區:對特定數據取hash值再對節點數取餘來決定映射到哪個節點。優勢是簡單,缺點是擴容或收縮時需從新計算映射結果,極端狀況下會致使數據全量遷移。
  • 一致性哈希分區:給每一個節點分配一個0~2^32的token,使其構成一個環,數據命中規則爲根據key的hash值,順時針找到第一個token大於等於該hash的節點。優勢是加減節點隻影響相鄰的節點,缺點是節點少的時候優勢變缺點,反倒會影響環中大部分數據,同時加減節點時候會致使部分數據沒法命中。
  • 虛擬槽分區:使用分散度良好的hash函數將數據映射到一個固定範圍的整數集合,這些整數即是槽位,再分給具體的節點管理。Redis Cluster使用的即是虛擬槽分區。

上面主要介紹了下集羣中數據是如何分佈在各節點上的,但實際上客戶端是如何讀寫數據的呢?Redis Cluster 採用了直接節點的方式。集羣模式下,客戶端去操做集羣是直連到一個具體的節點上操做的。當該節點接收到任何鍵操做命令時,會先計算鍵對應的slot,而後根據slot找出對應節點(這裏如何找後面會提到),若是對應的節點是自身,則執行鍵操做命令,返回結果;若是不是自身,會返回給客戶端MOVED重定向錯誤,告訴客戶端應該請求具體哪一個節點,由客戶端發起二次請求到正確的節點,完成本次鍵操做。MOVED錯誤信息以下圖所示: spring

MOVED錯誤信息

當使用redis-cli 直連集羣中節點時,使用 -c 參數,redis-cli會自動重定向鏈接到目標節點進行鍵操做。須要注意的是,這個自動重定向功能是redis-cli實現的,跟redis節點自己無關,節點自己依舊返回了MOVED錯誤給客戶端。api

在鍵操做命令中,除了對單個鍵值的操做,還有多鍵值以及批量操做。Redis 集羣實現了全部在非分佈式版本中出現的處理單一鍵值的命令,可是在使用多個鍵值的操做,因爲集羣跟客戶端的通訊方式是直連節點,對於多鍵的操做倒是須要遍歷全部節點,所以是不支持的,通常由客戶端在代碼中實現須要的功能。對於批量操做,一方面能夠由客戶端代碼計算槽位,針對單個節點進行分檔,最後批量操做,另外一方面,Redis Cluster 提供了hashtag 的功能,經過爲key打上hashtag,讓一類key在存儲時就位於同一個slot,達到存儲於同一個節點的效果。緩存

hashtag: 是Cluster爲了知足用戶讓特定Key綁定到特定槽位的需求而實現的一個功能。在計算key的slot時,若是key中包括花括號{},而且花括號中內容不爲空,便會計算花括號中標誌對應的slot。若是不包括{}或是其中內容爲空,則計算整個key對應的slot。能夠利用這個功能,在特定需求中將一類key綁定到一個槽位上,但不可濫用,畢竟自己數據是分區存的,全這麼搞會致使各節點內存佔用不平衡,影響集羣性能。安全

注意:lua腳本執行、事務中key操做,前提都是所涉及的key在一個節點上,若是在使用集羣時沒法避免這些操做,能夠考慮使用hashtag,而後客戶端經過這臺節點的鏈接去操做。bash

1.2 節點間的信息共享

集羣中會有多個節點,每一個節點負責一部分slot以及對應的k-v數據,而且經過直連具體節點的方式與客戶端通訊。那麼問題來了,你向我這裏請求一個key的value,這個key對應的slot並不歸我負責,但我又要須要告訴你MOVED到目標節點,我如何知道這個目標節點是誰呢?

Redis Cluster使用Gossip協議維護節點的元數據信息,這種協議是P2P模式的,主要指責就是信息交換。節點間不停地去交換彼此的元數據信息,那麼總會在一段時間後,你們都知道彼此是誰,負責哪些數據,是否正常工做等等。節點間信息交換是依賴於彼此發出的Gossip消息的。經常使用的通常是如下四種消息:

  • meet消息 會通知接收該消息的節點,發送節點要加入當前集羣,接收者進行響應。
  • ping消息 是集羣中的節點按期向集羣中其餘節點(部分或所有)發送的鏈接檢測以及信息交換請求,消息包含發送節點信息以及發送節點知道的其餘節點信息。
  • pong消息是在節點接收到meet、ping消息後回覆給發送節點的響應消息,告訴發送方本次通訊正常,消息包含當前節點狀態。
  • fail消息 是在節點認爲集羣內另外某一節點下線後向集羣內全部節點廣播的消息。

在集羣啓動的過程當中,有一個重要的步驟是節點握手,其本質就是在一個節點上向其餘全部節點發送meet消息,消息中包含當前節點的信息(節點id,負責槽位,節點標識等等),接收方會將發送節點信息存儲至本地的節點列表中。消息體中還會包含與發送節點通訊的其餘節點信息(節點標識、節點id、節點ip、port等),接收方也會解析這部份內容,若是本地節點列表中不存在,則會主動向新節點發送meet消息。接收方處理完消息後,也會回覆pong消息給發送者節點,發送者也會解析pong消息更新本地存儲節點信息。所以,雖然只是在一個節點向其餘全部節點發送meet消息,最後全部節點都會有其餘全部節點的信息。

集羣啓動後,集羣中各節點也會定時往其餘部分節點發送ping消息,用來檢測目標節點是否正常以及發送本身最新的節點負槽位信息。接收方一樣響應pong消息,由發送方更新本地節點信息。當在與某一節點通訊失敗(故障發現策略後面會說)時,則會主動向集羣內節點廣播fail消息。考慮到頻繁地交換信息會加劇帶寬(集羣節點越多越明顯)和計算的負擔,Redis Cluster內部的定時任務每秒執行10次,每次遍歷本地節點列表,對最近一次接受到pong消息時間大於cluster_node_timeout/2的節點立馬發送ping消息,此外每秒隨機找5個節點,選裏面最久沒有通訊的節點發送ping消息。同時 ping 消息的消息投攜帶自身節點信息,消息體只會攜帶1/10的其餘節點信息,避免消息過大致使通訊成本太高。

cluster_node_timeout 參數影響發送消息的節點數量,調整要綜合考慮故障轉移、槽信息更新、新節點發現速度等方面。通常帶寬資源特別緊張時,能夠適當調大一點這個參數,下降通訊成本。

1.3 槽位遷移與集羣伸縮

Redis Cluster 支持在集羣正常服務過程當中,下線或是新增集羣節點。但不管是集羣擴容仍是收縮,本質上都是槽及其對應數據在不一樣節點上的遷移。通常狀況下,槽遷移完成後,每一個節點負責的槽數量基本上差很少,保證數據分佈知足理論上的均勻。

經常使用的有關槽的命令以下:

  • CLUSTER ADDSLOTS slot1 [slot2]...[slotN] —— 爲當前節點分配要負責的槽,通常用於集羣建立過程。
  • CLUSTER DELSLOTS slot1 [slot2]...[slotN] —— 將特定槽從當前節點的責任區移除,和ADDSLOTS命令同樣,執行成功後會經過節點間通訊將最新的槽位信息向集羣內其餘節點傳播。
  • CLUSTER SETSLOT slotNum NODE nodeId —— 給指定ID的節點指派槽,通常遷移完成後在各主節點上執行,告知各主節點遷移完成。
  • CLUSTER SETSLOT slotNum IMPORTING sourceNodeId —— 在槽遷移的目標節點上執行該命令,意思是這個槽將由原節點遷移至當前節點,遷移過程當中,當前節點(即目標節點)只會接收asking命令鏈接後的被設爲IMPORTING狀態的slot的命令。
  • CLUSTER SETSLOT slotNum MIGRATING targetNodeId —— 在槽遷移的原節點上執行該命令,意思是這個槽將由當前節點遷移至目標節點,遷移過程當中,當前節點(即原節點)依舊會接受設爲MIGRATING的slot相關的請求,若具體的key依舊存在於當前節點,則處理返回結果,若不在,則返回一個帶有目標節點信息的ASK重定向錯誤。其餘節點在接受到該槽的相關請求時,依舊會返回到原節點的MOVED重定向異常。

實際上遷移槽的核心是將槽對應的k-v數據遷移到目標節點。因此在完成slot在原節點和目標節點上狀態設置(即上面最後兩條命令)後,就要開始進行具體key的遷移。

  • CLUSTER GETKEYSINSLOT slot total —— 該命令返回指定槽指定個數的key集合
  • MIGRATE targetNodeIp targetNodePort key dbId timeout [auth password] —— 該命令在原節點執行,會鏈接到目標節點,將key及其value序列化後發送過去,在收到目標節點返回的ok後,刪除當前節點上存儲的key。整個操做是原子性的。因爲集羣模式下使用各節點的0號db,因此遷移時dbId這個參數只能是0。
  • MIGRATE targetNodeIp targetNodePort "" 0 timeout [auth password] keys key1 key2... —— 該命令是上面遷移命令基於pipeline的批量版本。

在整個slot的key遷移完成後,須要在各主節點分別執行CLUSTER SETSLOT slotNum NODE nodeId來通知整個slot遷移完成。redis-trib.rb 提供的reshard功能即是基於官方提供的上述命令實現的。

集羣的擴展過程實際上就是啓動一個新節點,加入集羣(經過gossip協議進行節點握手、通訊),最後從以前各節點上遷移部分slot到新節點上。

集羣的收縮過程除了除了將待下線節點的槽均勻遷移到其餘主節點以外,還有對節點的下線操做。官方提供了CLUSTER FORGET downNodeId命令,用於在其餘節點上執行以忘記下線節點,不與其交換信息,須要注意的是該命令有效期爲60s,超過期間後會恢復通訊。通常建議使用redis-trib.rb 提供的del-node功能。

1.4 高可用

Redis集羣犧牲了數據強一致性原則,追求最大的性能。上文中一直未提到從節點,主要都是從主節點出發去梳理數據存儲、集羣伸縮的一些原理。要保證高可用的前提是離不開從節點的,一旦某個主節點由於某種緣由不可用後,就須要一個一直默默當備胎的從節點頂上來了。通常在集羣搭建時最少都須要6個實例,其中3個實例作主節點,各自負責一部分槽位,另外3個實例各自對應一個主節點作其從節點,對主節點的操做進行復制(本文對於主從複製的細節不進行詳細說明)。Redis Cluster在給主節點添加從節點時,不支持slaveof命令,而是經過在從節點上執行命令cluster replicate masterNodeId 。完整的redis集羣架構圖以下:

圖片

Cluster的故障發現也是基於節點通訊的。每一個節點在本地存儲有一個節點列表(其餘節點信息),列表中每一個節點元素除了存儲其ID、ip、port、狀態標識(主從角色、是否下線等等)外,還有最後一次向該節點發送ping消息的時間、最後一次接收到該節點的pong消息的時間以及一個保存其餘節點對該節點下線傳播的報告鏈表。節點與節點間會定時發送ping消息,彼此響應pong消息,成功後都會更新這個時間。同時每一個節點都有定時任務掃描本地節點列表裏這兩個消息時間,若發現pong響應時間減去ping發送時間超過cluster-node-timeout配置時間(默認15秒,該參數用來設置節點間通訊的超時時間)後,便會將本地列表中對應節點的狀態標識爲PFAIL,認爲其有可能下線。

節點間通訊(ping)時會攜帶本地節點列表中部分節點信息,若是其中包括標記爲PFAIL的節點,那麼在消息接收方解析到該節點時,會找本身本地的節點列表中該節點元素的下線報告鏈表,看是否已經存在發送節點對於該故障節點的報告,若是有,就更新接收到發送ping消息節點對於故障節點的報告的時間,若是沒有,則將本次報告添加進鏈表。下線報告鏈表的每一個元素結構只有兩部份內容,一個是報告本地這個故障節點的發送節點信息,一個是本地接收到該報告的時間(存儲該時間是由於故障報告是有有效期的,避免誤報)。因爲每一個節點的下線報告鏈表都存在於各自的信息結構中,因此在瀏覽本地節點列表中每一個節點元素時,能夠清晰地知道,有其餘哪些節點跟我說,兄弟,你正在看的這個節點我覺的涼涼了。

故障報告的有效期是 cluster-node-timeout * 2

消息接收方解析到PFAIL節點,而且更新本地列表中對應節點的故障報告鏈表後,會去查看該節點的故障報告鏈表中有效的報告節點是否超過全部主節點數的一半。若是沒超過,便繼續解析ping消息;若是超過,表明超過半數的節點認爲這個節點可能下線了,當前節點就會將PFAIL節點本地的節點信息中的狀態標識標記爲FAIL,而後向集羣內廣播一條fail消息,集羣內的全部節點接收到該fail消息後,會把各自本地節點列表中該節點的狀態標識修改成FAIL。在全部節點對其標記未FAIL後,該FAIL節點對應的從節點就會發起轉正流程。在轉正流程完成後,這個節點就會正式下線,等到其恢復後,發現本身的槽已經被分給某個節點,便會將本身轉換成這個節點的從節點而且ping集羣內其餘節點,其餘節點接到恢復節點的ping消息後,便會更新其狀態標識。此外,恢復的節點若發現本身的槽仍是由本身負責,就會跟其餘節點通訊,其餘主節點發現該節點恢復後,就會拒絕其從節點的選舉,最終清除本身的FAIL狀態。

1.5 從節點坎坷晉升路

在集羣中如果某個主節點發生故障,被其餘主節點標價爲FAIL狀態,爲了集羣的正常使用,這時會由其對應的從節點中晉升一個爲新的主節點,負責原主節點的一切工做。

並非全部從節點都有被提名的資格,這個跟普通職員的晉升同樣。只有從節點與主節點的鏈接斷線不超過必定時間,纔會初步具有被提名的資格。該時間通常爲cluster-node-timeout *10,10是從節點的默認有效因子。

通常來講,故障主節點會有多個符合晉升要求的從節點,那麼怎麼從這些從節點中選出一個最合適的來晉升爲主節點恢復工做呢?從節點的做用是做爲主節點的備份,每一個對於主節點的操做都會異步在多個從節點上備份,但受具體的主從節點結構決定,通常每一個從節點對於主節點的通不程度是不一樣的。爲了能更好的替代原主節點工做,就必須從這些從節點中選舉一個最接近甚至徹底同步主節點數據的從節點來完成最終晉升

從節點晉升的發起點是從節點。從節點在定時任務中與其餘節點通訊,當發現主節點FAIL後,會判斷資深是否有晉升提名資格。若是有的話,則會根據相關規則設置一個選舉本身的時間。在到達那個設置的時間點後,再發起針對本身晉升的選舉流程,選票則由集羣中其餘正常主節點選投。若本身得到的選票超過正常主節點數的一半時,則會執行替換原主節點工做,完成本次選舉晉升。

設置選舉時間規則:發現主節點FAIL後並不會立馬發起選舉。而是通過 固定延時(500ms)+ 隨機延時(0-500ms)+ 從節點複製偏移量排名1000ms 後發起針對本身的選舉流程。其中 固定延時 是保證主節點的FAIL狀態被全部主節點獲知,隨機延時是爲了儘可能避免發生多個從節點同時發起選舉的狀況,最後的排名1000ms是爲了保證複製偏移量最大也就是最接近於原主節點數據的從節點最早發起選舉。所以通常來講,從節點晉升選舉一次就會成功。主節點是沒有區分哪一個從節點是最適合晉升的規則的,主要靠這裏的選舉發起時間來讓最合適的一次成功。

從節點發起選舉主要分爲兩步

  • 自增集羣的全局配置紀元,並更新爲當前節點的epoch(配置紀元這裏不詳細介紹,不懂的能夠先簡單理解爲版本號,每一個節點都有本身的epoch而且集羣有一個全局的epoch);
  • 向集羣內廣播選舉消息FAILOVER_AUTH_REQUEST,消息內會包含當前節點的epoch。

從節點廣播選舉消息後,在NODE_TIMEOUT*2時間內等待主節點的響應FAILOVER_AUTH_ACK。若收到大多數主節點的響應,表明選舉成功,則會經過ping\pong消息來宣誓主權。若未收到足夠響應則會中斷本次選舉,由其餘節點從新發起選舉。

主節點在每一個全局配置紀元中有且只有一張選票,一旦投給某個從節點便會忽視其餘節點的選舉消息。通常同一個配置紀元多個從節點競爭的狀況只有極小機率會發生,這是由從節點的選舉時間以及選舉步驟決定的。主節點的投票響應FAILOVER_AUTH_ACK消息中會返回接收到的選舉消息同樣的epoch,從節點也只會承認跟節點當前epoch一致的投票響應,這樣能夠避免由於網絡延遲等因素致使承認遲來的歷史承認消息。

從節點成功晉升後,在替換原主節點時,還須要進行最後三步:

  • 取消當前節點的複製工做,變身爲主節點;
  • 撤銷原主節點負責的槽,並把這些槽委派給本身;
  • 廣播pong消息,通知全部節點本身已經完成轉正以及轉正後負責的槽信息。

2、JedisCluster

Jedis是redis的java客戶端,JedisCluster則是Jedis根據Redis集羣的特性提供的集羣客戶端。上文介紹過了redis集羣下操做key的詳細流程,通常經過redis-cli啓動客戶端鏈接具體的節點時,要操做的key若不在這個節點上時,服務端會返回MOVED重定向錯誤,這時須要手動鏈接至重定向節點才能繼續操做。或者redis-cli鏈接服務節點時加上-c 參數,就可使用redis-cli提供的自動重定向機制,在操做其餘服務節點的key時會進行自動重定向,避免客戶端手動重定向。JedisCluster做爲操做Redis集羣的java客戶端,一樣遵照RedisCluster提供的客戶端鏈接規範,本節從源碼的角度去看其具體是怎麼作的。

2.1 初始化工做

不管你使用spring集成jedis或是直接使用jedis,第一步都是客戶端的初始化工做,這裏直接從JedisCluster着手去看。JedisCluster其實是一個高級客戶端,它繼承了BinaryJedisCluster,客戶端的初始化工做實際上都是由該類負責,此外還實現了JedisCommands、MultiKeyJedisClusterCommands和JedisClusterScriptingCommands三個接口,封裝了單鍵命令、多鍵操做命令以及腳本執行命令等具體的方法供開發人員調用。

圖片

JedisCluster的構造器有不少,但最終都是調用了父類BinaryJedisCluster的構造,實際上這裏是初始化了一個鏈接處理器,而且設置了最大重試次數。

public BinaryJedisCluster(Set<HostAndPort> jedisClusterNode, 
int connectionTimeout, int soTimeout, int maxAttempts, 
String password, GenericObjectPoolConfig poolConfig) {

  this.connectionHandler = new JedisSlotBasedConnectionHandler(jedisClusterNode, poolConfig,
          connectionTimeout, soTimeout, password);
  this.maxAttempts = maxAttempts;

}
複製代碼

JedisSlotBasedConnectionHandler實際上又調用了父類JedisClusterConnectionHandler 的構造器,而這裏纔是JedisCluster初始化的核心。

public JedisClusterConnectionHandler(Set<HostAndPort> nodes,
                                     final GenericObjectPoolConfig poolConfig, int connectionTimeout, int soTimeout, String password) {
  
  // 建立集羣信息的緩存對象
  this.cache = new JedisClusterInfoCache(poolConfig, connectionTimeout, soTimeout, password);

  // 初始化鏈接池與緩存信息
  initializeSlotsCache(nodes, poolConfig, password);
}
複製代碼

建立JedisClusterInfoCache 實例的時候看其構造能夠知道只是將鏈接配置信息賦值給實例屬性,並沒有其餘操做。那麼它究竟緩存了哪些信息呢?查看其源碼能夠發現以下兩個重要的屬性,分別存放了節點與其對應鏈接池的映射關係和槽位與槽位所在節點對應鏈接池的映射。

JedisClusterInfoCache.java

private final Map<String, JedisPool> nodes = new HashMap<String, JedisPool>();
private final Map<Integer, JedisPool> slots = new HashMap<Integer, JedisPool>();
複製代碼

初始化緩存數據則是經過遍歷全部節點,建立每一個節點的jedis實例,依次鏈接獲取節點及負責槽位數據。通常來講,是根據配置中第一個節點鏈接後獲取相關信息就會跳出遍歷。initializeSlotsCache方法代碼以下:

JedisClusterConnectionHandler.java

private void initializeSlotsCache(Set<HostAndPort> startNodes, GenericObjectPoolConfig poolConfig, String password) {
  for (HostAndPort hostAndPort : startNodes) {
    Jedis jedis = new Jedis(hostAndPort.getHost(), hostAndPort.getPort());
    if (password != null) {
      jedis.auth(password);
    }
    try {
      // 獲取節點及所負責的槽位信息
      cache.discoverClusterNodesAndSlots(jedis);
      break;
    } catch (JedisConnectionException e) {
      // try next nodes
    } finally {
      if (jedis != null) {
        jedis.close();
      }
    }
  }
}
複製代碼

關於緩存數據的獲取及更新實際是由JedisClusterInfoCache的discoverClusterNodesAndSlots方法實現,主要是經過cluster slots 命令獲取集羣內的槽位分佈數據,而後解析該命令的返回結果,爲每一個主節點初始化一個鏈接池,而後將節點與鏈接池、節點負責的全部槽位與鏈接池的映射關係緩存到上面說的兩個map中。源碼以下:

JedisClusterInfoCache.java

private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
// 使用讀寫鎖控制緩存更新時的線程安全
private final Lock r = rwl.readLock();
private final Lock w = rwl.writeLock();
// cluster slots 命令返回結果的每一個元素中第三部分爲主節點信息,後面的都是從節點信息
private static final int MASTER_NODE_INDEX = 2;

public void discoverClusterNodesAndSlots(Jedis jedis) {
  w.lock();
  try {
    reset();   // 銷燬鏈接池、清空緩存
    // 根據cluster slots 命令獲取槽位分佈信息
    List<Object> slots = jedis.clusterSlots();
    
    for (Object slotInfoObj : slots) {
      List<Object> slotInfo = (List<Object>) slotInfoObj;

      if (slotInfo.size() <= MASTER_NODE_INDEX) {
        continue;
      }
      // 獲取當前槽位節點負責的全部槽位
      List<Integer> slotNums = getAssignedSlotArray(slotInfo);

      // hostInfos
      int size = slotInfo.size();
      for (int i = MASTER_NODE_INDEX; i < size; i++) {
        // 獲取節點信息數據
        List<Object> hostInfos = (List<Object>) slotInfo.get(i);
        if (hostInfos.size() <= 0) {
          continue;
        }
        // 生成節點對象
        HostAndPort targetNode = generateHostAndPort(hostInfos);
        // 初始化節點鏈接池,並將節點與其鏈接池緩存
        setupNodeIfNotExist(targetNode);
        if (i == MASTER_NODE_INDEX) {
           // 若節點是主節點,則將其負責的每一個槽位與其鏈接池創建映射關係緩存
          assignSlotsToNode(slotNums, targetNode);
        }
      }
    }
  } finally {
    w.unlock();
  }
}
複製代碼

上面discoverClusterNodesAndSlots方法主要是解析cluster slots命令的返回結果,這塊不熟悉的話建議鏈接到集羣中的一個節點執行下該命令,對照着結果來看就會很明白。回過頭來看,這裏的初始化主要分爲一下幾部分:

  • 鏈接一個節點執行cluster slots命令,獲取槽位分佈以及集羣節點信息;
  • 爲每個節點都初始化一個鏈接池,並跟節點創建映射關係緩存;
  • 將每一個主節點負責的槽位一一與主節點鏈接池創建映射緩存。

初始化工做中緩存的映射信息,在JedisCluster的使用過程當中起到了相當重要的做用。但也正是由於JedisCluster在本地內存中緩存節點數據而且爲每一個節點維護一個鏈接池,在使用節點特別多的龐大集羣時,客戶端也會消耗更多內存。

2.2 鍵操做詳解

JedisCluster實現了JedisCommands接口封裝的單key命令,這裏分析單鍵操做命令的詳細流程以set爲例,其代碼以下:

JedisCluster.java

@Override
public String set(final String key, final String value) {
  return new JedisClusterCommand<String>(connectionHandler, maxAttempts) {
    @Override
    public String execute(Jedis connection) {
      return connection.set(key, value);
    }
  }.run(key);
}
複製代碼

經過代碼能夠看出,實際的set操做仍是依賴於jedis。上文在初始化部分提到,會爲集羣的每一個節點都建立一個jedisPool,同時初始化時建立的connectionHandler在這裏被JedisClusterCommand的實現類所使用,那麼不難理解,connectionHandler根據JedisClusterInfoCache的緩存數據,對外提供鏈接獲取服務。要麼你給我個節點,我給你個jedis實例,要麼你給我個slot,我給你一個jedis實例。這點去看JedisClusterConnectionHand-ler的源碼即可以獲得證實。所以,JedisClusterCommand在操做key時必定會處理相關信息,獲得獲取鏈接的必要參數。下面即是run(key)方法的實現(代碼略長,可是邏輯清晰,註釋詳細):

JedisClusterCommand.java
// 存放當前操做的ask重定向後的鏈接
private ThreadLocal<Jedis> askConnection = new ThreadLocal<Jedis>();

public T run(String key) {
  if (key == null) {
    throw new JedisClusterException("No way to dispatch this command to Redis Cluster.");
  }

  return runWithRetries(SafeEncoder.encode(key), this.maxAttempts, false, false);
}

private T runWithRetries(byte[] key, int attempts, boolean tryRandomNode, boolean asking) {
  if (attempts <= 0) {
    throw new JedisClusterMaxRedirectionsException("Too many Cluster redirections?");
  }

  Jedis connection = null;
  try {
    if (asking) {
      // 如果ask重定向操做,則從ThreadLocal中獲取重定向後的jedis
      connection = askConnection.get();
      connection.asking();
      asking = false;    // 若ask重定向成功,撤銷ask重定向標記
    } else {
      if (tryRandomNode) {  // 隨機鏈接至某個ping-pong正常的節點
        connection = connectionHandler.getConnection();
      } else {
        connection = connectionHandler.getConnectionFromSlot(JedisClusterCRC16.getSlot(key));    // 根據槽位算法計算key對應的slot,再根據slot獲取對應節點的jedis
      }
    }

    return execute(connection);

  } catch (JedisNoReachableClusterNodeException jnrcne) {
    throw jnrcne;
  } catch (JedisConnectionException jce) {
    // 發生鏈接異常時,釋放鏈接,開始遞歸重試
    releaseConnection(connection);
    connection = null;

    if (attempts <= 1) {
      // 重試次數遞減到1次時,表明目標節點可能發生故障,更新緩存數據,拋出原始異常
      this.connectionHandler.renewSlotCache();
      throw jce;
    }
    // 遞減重試次數開始重試
    return runWithRetries(key, attempts - 1, tryRandomNode, asking);
    
  } catch (JedisRedirectionException jre) {   // 發生了重定向異常
    // 釋放當前佔用鏈接
    releaseConnection(connection);
    connection = null;

    if (jre instanceof JedisAskDataException) {
      // ASK重定向表明當前槽位正在遷移,直接獲取ask異常信息裏的目標節點的jedis實例放入ThreadLocal,設置asking標誌,重試請求目標節點操做
      asking = true;     
      askConnection.set(this.connectionHandler
              .getConnectionFromNode(jre.getTargetNode()));
              
    } else if (jre instanceof JedisMovedDataException) {
      // MOVED重定向表明本地緩存的槽位數據跟集羣不一致,須要更新緩存數據後重試
      this.connectionHandler.renewSlotCache(connection);
      
    } else {
      throw new JedisClusterException(jre);
    }
    return runWithRetries(key, attempts - 1, false, asking); // 重試
  } finally {
    releaseConnection(connection);
  }
}
複製代碼

看完上述代碼,咱們不難梳理出JedisCluster對鍵操做的基本流程計算key的slot -》 從緩存中根據slot拿到目標節點的jedis -》 執行鍵操做。在這個過程當中,若是發生鏈接異常,則會重試配置的最大重試次數-1次,若鏈接依舊存在問題,則更新緩存信息,拋出鏈接的原始異常;若是發生重定向異常,再根據具體的重定向異常作不一樣處理。接收到MOVED重定向時會去更新緩存,而後重試。而接收到ASK重定向時是直接解析目標節點並獲取一個鏈接,而後重試走ask分支,並不更新緩存。這是由於發生ASK重定向異常時,slot正在遷移,並未完成,該slot的一部分key在目標節點,一部分又在原節點,沒法準確地將slot與某個節點綁定,因此不會更新緩存,等到遷移結束後,用舊的緩存去請求key時,這時就會接收到redis返回的MOVED重定向異常,那會纔會更新緩存,維持緩存數據的準確性。

發生鏈接異常時,先重試max-1次再更新緩存。一方面避免因網絡、讀寫阻塞等緣由誤判節點故障,中斷請求;另外一方面避免頻繁更新緩存,爲保證緩存數據在多線程場景下的線程安全,採用了讀寫鎖控制緩存的讀取及更新,頻繁更新勢必致使大多數讀請求被阻塞,影響性能。connectionHandler的renewSlotCache方法內部都是調用了JedisClusterInfoCache的renewClusterSlots(Jedis jedis)方法。不一樣的是無參時傳遞的jedis實例爲null。

JedisClusterInfoCache.java

public void renewClusterSlots(Jedis jedis) {
  //該變量默認false,當須要更新集羣緩存信息時,如有一個線程得到寫鎖,便會設置該標誌爲true,這樣在更新期間,其餘線程便不須要阻塞等待寫鎖,直接返回重試,在讀鎖出等待該線程更新完成。持有鎖的線程更新完緩存後,會在釋放鎖前恢復該標誌爲false
  if (!rediscovering) {
    try {
      w.lock();
      rediscovering = true;

      if (jedis != null) {
        try {
          // 經過cluster slots命令獲取新的槽位信息,更新緩存
          discoverClusterSlots(jedis);
          return;
        } catch (JedisException e) {
          // 若是當前鏈接更新緩存發生JedisException,則從全部節點重試更新
        }
      }

      for (JedisPool jp : getShuffledNodesPool()) {
        try {
          jedis = jp.getResource();
          discoverClusterSlots(jedis);
          return;
        } catch (JedisConnectionException e) {
          // 重試下一個節點
        } finally {
          if (jedis != null) {
            jedis.close();
          }
        }
      }
    } finally {
      // 恢復標誌位,釋放鎖
      rediscovering = false;
      w.unlock();
    }
  }
}
複製代碼

JedisCluster使用讀寫鎖保證cache數據的線程安全,因此在某個線程更新cache的時候,其餘線程在讀取cache中的槽位映射時會被阻塞。《Redis開發與運維》書中,付磊大大認爲此處尚可優化,將cluster slots命令執行放在加寫鎖前,同時與本地緩存判斷是否相同,不一樣則意味着必須更新,這時再去加寫鎖,從而縮短對其餘線程的阻塞時間,儘可能減小對操做槽位的緩存無誤部分的影響。

2.3 多鍵操做與腳本執行

在初始化工做部分看JedisCluster的類圖時提到過,其實現了MultiKeyJedisClusterCommands和JedisClusterScriptingCommands兩個接口規定的多鍵操做命令和腳本執行命令。到這裏你們都知道集羣模式下不一樣key可能存儲於不一樣的槽位上,那麼一次操做涉及多個key就意味着可能涉及多個節點。

JedisCluster執行命令的模式是從connectionHandler獲取連接,由JedisClusterCommand的匿名內部類去拿連接(Jedis實例)執行具體的命令,這個流程跟單鍵命令是一致的。不一樣的是,多key操做調用的是JedisClusterCommand.run(keys.length, keys)方法。相同的是,最終都是由 runWithRetries(byte[] key, **int **attempts, **boolean **tryRandomNode, **boolean **asking) 完成操做。

這裏以多個key的exists命令爲例,代碼以下:

JedisCluster.java

@Override
public Long exists(final String... keys) {
  return new JedisClusterCommand<Long>(connectionHandler, maxAttempts) {
    @Override
    public Long execute(Jedis connection) {
      return connection.exists(keys);
    }
  }.run(keys.length, keys);
}

JedisClusterCommand.java

public T run(int keyCount, String... keys) {
  if (keys == null || keys.length == 0) {
    throw new JedisClusterException("No way to dispatch this command to Redis Cluster.");
  }

  if (keys.length > 1) {
    int slot = JedisClusterCRC16.getSlot(keys[0]);
    for (int i = 1; i < keyCount; i++) {
      int nextSlot = JedisClusterCRC16.getSlot(keys[i]);
      if (slot != nextSlot) {
        throw new JedisClusterException("No way to dispatch this command to Redis Cluster "
            + "because keys have different slots.");
      }
    }
  }

  return runWithRetries(SafeEncoder.encode(keys[0]), this.maxAttempts, false, false);
}
複製代碼

代碼很簡單易懂,對於多個key它會先檢查是否是位於一個槽位,肯定是一個槽位後就會拿着第一個key去計算slot並向connectionHandler要jedis實例。所以JedisCluster不支持不在同一個槽位的多key操做(實際上redis集羣本就不提供此功能)。若調用多key命令方法時傳入的多個key不是同一個slot,會拋出JedisClu-sterException,而且告訴你沒辦法調度命令去集羣執行,由於這些key位於不一樣的slot。

在實際開發中,若是明確知道某類key會存在多鍵操做,咱們能夠在存儲時便經過打hashtag的方式強制其位於同一個slot同一個節點。另外,若真正須要操做多節點上的key時,能夠經過遍歷cache中緩存的節點到鏈接池的映射,在每一個主節點上一次執行。

腳本的執行實際上也是依賴於jedis去作的,這裏不深刻jedis去說了。腳本的執行也分涉及單個key和多個key兩種狀況,但其原理和上述一致。所以,JedisCluster也不支持涉及不一樣slot上多個key的腳本

2.4 類結構回顧

JedisCluster涉及的幾個類以下圖:

圖片
JedisCommand封裝集羣命令的執行抽象出兩種基本模式,單key和多key。我的理解這裏的編碼思想採用了模板方法模式,封裝基本執行流程,具體的執行由實現類去根據具體的需求調用實際的api作實現。

JedisCluster是面向開發人員的API類,實現三類命令接口,提供友好的方法供業務代碼調用。

JedisClusterConnectionHandler負責多個鏈接池的路由工做,根據緩存的映射關係,肯定一個正確的鏈接池並返回其引用給上層。JedisSlotBasedConnectionHandler實際上只是基於父類的基本功能進行加工,提供給上層友好的調用方法,直接返回上層須要的鏈接。

總結

Redis集羣經過分片存儲、主從數據複製以及合理科學的故障轉移策略,提供了更強的性能、更好的擴展性以及可用性,知足了CAP定理的AP兩個特性。對於一致性,集羣模式配合客戶端策略能夠說實現了「弱一致性」。筆者認爲實際開發中,是真的有必要去把這些東西都搞清楚再去使用,這樣能夠提早避免不少線上問題的產生。本篇文章重在梳理,我的感受哪怕是根據已有的資料,去梳理出一篇通過本身多方驗證、深度思考的文章比只是去看會對相關技術理解的更爲深入。

參考

相關文章
相關標籤/搜索