緩存學習(十):分佈式Redis之Redis Cluster

目錄

1 數據拆分

2 Redis Cluster配置

3 集羣搭建

4 集羣命令

4.1 redis-cli 的集羣管理模式

4.2 客戶端命令

5 原理

5.1 節點通信

5.2 槽的遷移

5.2.1 集羣收縮

5.2.2 集羣擴容

5.3 請求路由和重定向

5.4 故障轉移

6 JedisCluster

6.1 集羣節點的自動發現

6.2  JedisClusterCRC16

6.3 JedisCluster對重定向的支持


1 數據拆分

副本機制和哨兵解決了Redis節點可用性的問題,同時通過讀寫分離部分地提升了總體性能,但是沒能完全解決性能瓶頸問題:單個節點CPU處理能力和內存容量都是有極限的,不可能無限擴張,副本機制的引入在一定程度上擴展了讀請求的處理能力,但一方面沒有提升寫請求的處理能力,另一方面還由於數據一致性的要求引入了同步的壓力。

以上問題的解決方案就是數據拆分,即將數據按照一定規則分散到不同的副本集羣(或稱爲分片)中,在讀取數據時,按照相同的規則計算數據在哪個分片上,然後去目標分片查找即可。

對於關係型數據庫,數據拆分一般以時間、地點等具有明確邊界的數據列作爲劃分依據。例如按照年份劃分,2016、2017、2018年的數據各存入一臺服務器,好處在於劃分規則簡單,且這種順序分區的形式比較容易計算位置。

但是缺點也很明顯,首先這種方式很容易出現數據傾斜,以電商數據中的用戶購買記錄爲例,購買記錄的數量一般是逐年增長的,如果按年份劃分的話,要麼性能過剩(使用同樣規格的服務器存儲,則存儲2016年數據的服務器性能一定相對過剩),要麼失去擴展性(爲每年的數據購買性能剛好的服務器,這意味着一旦數據略有伸縮,服務器可能就需要更換);另一個問題是業務需求帶來的冗餘,同樣以購物記錄說明,對於用戶來說,想要看到的數據是自己在不同賣家的購買記錄,即1(買家)——N(賣家),而在賣家角度,需求則完全相反,賣家希望看到的是有多少買家購買了自己的商品,即1(賣家)——N(買家),如果以買家作爲劃分依據,則統計賣家數據時會有很大負擔,反之亦然,這種情況下,爲了保證買家、賣家查詢性能,只好維護兩份數據,產生了兩倍的請求,成本非常高。

而在Redis這種NoSQL數據庫上,數據要麼組織特別鬆散,要麼特別緊密。例如要存儲用戶zhangsan的性別、年齡,可以有三種方式:

  • 一種是分別存儲在user:zhangsan:sexual、user:zhangsan:age中;
  • 一種是將兩個屬性序列化爲一個字符串,如{sexual:male,age:20},然後存儲在一個鍵user:zhangsan上;
  • 還有一種就是以list的形式存儲。

第一種形式其實還比較好處理,後面兩種就相對麻煩一些。如果採用順序分區,也會有關係數據庫的一系列問題,解決的方案是哈希分區。

哈希分區具有以下特點:離散度較好,只要哈希規則設置的足夠優秀,可以較好的解決數據傾斜問題;性能高,哈希算法可以提供O(1)級別的性能;數據分佈業務無關,對所有業務均具有基本相同的性能表現;無法順序訪問,只能按照分區規則計算位置再訪問。

常用的哈希分區規則有:

1)直接哈希(取餘法):

即對數據的鍵使用哈希函數h,然後對服務器總數N取餘來計算其位置,公式:h(key)%N。這種方法最大的優點就是計算非常簡單,缺點是需要預先對數據量、服務器處理能力等進行規劃,且伸縮性差,無論節點增加還是刪除,都會對整個集羣產生影響,例如原先有10個節點,某個數據經過哈希,值爲16,取餘後落在6號節點上,現在5號節點移除,那麼讀取該數據時,就會去7號節點尋找,這當然是找不到的。

2)一致性哈希:

直接哈希最大的問題就是對整個空間的劃分依賴於節點數量,一旦節點數量改變,就會導致劃分方式改變,從而導致原先的劃分完全失效。一致性哈希則解決了這個問題,它的核心思路是,不考慮節點數量,而將整個空間劃分爲2^{32}塊,分別標號爲0~2^{32}-1,構成一個哈希環。然後將節點也同樣哈希、取餘,分佈在環上,如下圖(來自網絡,侵刪):

當讀取或寫入數據時,哈希公式變成:h(key)%2^{32},然後順時針找到第一個節點。例如上圖中,某個鍵的計算哈希並取餘後,其值處於node1、node2之間,則按順時針方向到node2上執行操作。假如此時將node2下線,那麼影響的只是哈希值在node1和node2之間的數據,不會影響其他部分的數據。

一致性哈希存在以下問題:首先是不適合節點數量少的環境,例如上圖中,如果下線一個節點,就將影響25%的數據,如果是一個100個節點的集羣,那麼下線一個節點隻影響1%的數據,顯然節點數量越多,一致性哈希的效果越好;另一個缺點是,如果有節點上線或下線,會導致順時針方向下一個節點的負載增倍/減半,即每臺服務器正常運行狀態下,負載不能超過50%,這對服務器性能是極大的浪費。

3)虛擬槽

Redis Cluster使用的是該方法。該方法的思路和一致性哈希有相似之處,同樣是將整個空間劃分爲固定的若干塊,然後讓每個節點負責一部分空間。

在Redis的實現中,數據空間被劃分爲16384個槽,數據分佈算法爲:crc16(key)%16384,當主節點下線時,會將其負責的槽遷移到其他節點上,遷移過程保證各節點負載相對均勻。同樣以四個節點組的集羣爲例,當某個節點組下線後,剩下的三個節點組各負載1/3的數據,負載增大約1/3( (33%-25%)/25%),相比一致性哈希方案,有明顯改進。

2 Redis Cluster配置

在redis.conf中,有一部分集羣配置,在緩存學習(五):Redis安裝、配置沒有做介紹,在這裏進行介紹。

  • cluster-enabled:是否啓用集羣模式
  • cluster-config-file:集羣配置文件名稱,該文件由節點自動維護,不需要手動編輯,且必須保證該配置在每個集羣節點處都不同
  • cluster-node-timeout:集羣節點間通信所允許的最大延時,超過該時間則認爲節點下線,進入failover
  • cluster-slave-validity-factor:用於在failover時篩選從節點,以保證使用最新版本的數據進行恢復。在副本機制中,failover時,會以複製偏移量作爲標準,嘗試選取具有最大偏移量的節點進行晉升,但是偏移量大不代表數據足夠新,例如進行恢復時,需要恢復最近30秒內的數據,但是最大偏移量節點的數據是40秒前的,那麼恢復過來意義也不大,可以不恢復。對於一個從節點,假如它上一次和主節點正常通信發生在:cluster-node-timeout * cluster-slave-validity-factor + repl-ping-slave-period 之前,則不進行故障轉移。可見在cluster-slave-validity-factor爲0時,會始終進行故障轉移,從而保證可用性(但是數據的一致性無法保證)。
  • cluster-migration-barrier:如果集羣中某個主節點沒有從節點(稱爲孤立主節點),則會大大降低魯棒性,Redis可以自動地轉移從節點給孤立主節點,該配置決定了轉移的節點至少有額外多少個節點纔會觸發轉移操作。該配置默認爲1,即一個主節點至少有兩個以上的從節點,纔會轉讓一個從節點給孤立主節點
  • cluster-require-full-coverage:是否允許在未完成槽遷移,或者有槽未分配的情況下響應查詢
  • clsuter-slave-no-failover:是否關閉自動failover,設置爲yes是,將不會主動進行故障轉移(可以手動進行)
  • 網絡配置:用於在NAT轉換網絡下配置公共地址和端口,其中bus的端口一般是節點端口加上10000
    • cluster-announce-ip
    • cluster-announce-port
    • cluster-announce-bus-port

Redis集羣通過總線進行節點間的數據交換,每個Redis節點都會開闢一個額外端口(Redis port加上10000)與總線進行TCP連接,以二進制形式進行數據交換。

3 集羣搭建

這裏以3主3從,共6個節點爲例。

首先在redis.conf中開啓cluster-enabled配置,並配置cluster-node-timeout

然後將redis.conf複製爲6份,分別命名爲redis-6379.conf、redis-6380.conf。。。

然後分別修改這些配置文件中 port、cluster-config-file、pidfile、logfile、dir、dbfilename、appendfilename等屬性,使每個配置文件中,上述配置項的內容互不相同

然後分別以這些配置文件啓動服務器實例,此時這六個節點互相孤立,未構成集羣:

從日誌可以看到,此時以集羣模式運行:

89:M 06 May 2019 11:12:14.390 * No cluster configuration found, I'm 65f40c78c2fa077483faecfe75a648ffd68a74c1
                _._
           _.-``__ ''-._
      _.-``    `.  `_.  ''-._           Redis 5.0.4 (00000000/0) 64 bit
  .-`` .-```.  ```\/    _.,_ ''-._
 (    '      ,       .-`  | `,    )     Running in cluster mode
 |`-._`-...-` __...-.``-._|'` _.-'|     Port: 6379
 |    `-._   `._    /     _.-'    |     PID: 89
  `-._    `-._  `-./  _.-'    _.-'
 |`-._`-._    `-.__.-'    _.-'_.-'|
 |    `-._`-._        _.-'_.-'    |           http://redis.io
  `-._    `-._`-.__.-'_.-'    _.-'
 |`-._`-._    `-.__.-'    _.-'_.-'|
 |    `-._`-._        _.-'_.-'    |
  `-._    `-._`-.__.-'_.-'    _.-'
      `-._    `-.__.-'    _.-'
          `-._        _.-'
              `-.__.-'

並且沒有找到集羣配置文件(於是會自動創建一個),後面的ID是該節點的唯一ID,節點間通過ID互相識別,而非host、port。

需要從客戶端發起 cluster meet 命令使節點加入集羣,這裏讓6380、6381分別加入集羣,剩下的節點處理方式相同:

127.0.0.1:6380> cluster meet 127.0.0.1 6379
OK
...
127.0.0.1:6381> cluster meet 127.0.0.1 6380
OK

嘗試插入數據,卻提示集羣狀態爲down:

127.0.0.1:6379> set hello world
(error) CLUSTERDOWN The Cluster is down

使用cluster nodes或cluster info命令查看集羣狀態:

127.0.0.1:6379> cluster info
cluster_state:fail
cluster_slots_assigned:0
...
cluster_known_nodes:6
...

集羣狀態爲fail,從下一行可以看出來,原因是槽還沒有進行分配。此外也還沒有設置副本。

redis-cli提供了一個快捷命令(需要先使用cluster forget命令將6379、6380、6381節點恢復到相互獨立的狀態):

root@Yhc-Surface:~# redis-cli --cluster create 127.0.0.1:6379 127.0.0.1:6380 127.0.0.1:6381 127.0.0.1:6382 127.0.0.1:6383 127.0.0.1:6384 --cluster-replicas 1
>>> Performing hash slots allocation on 6 nodes...
Master[0] -> Slots 0 - 5460
Master[1] -> Slots 5461 - 10922
Master[2] -> Slots 10923 - 16383
Adding replica 127.0.0.1:6383 to 127.0.0.1:6379
Adding replica 127.0.0.1:6384 to 127.0.0.1:6380
Adding replica 127.0.0.1:6382 to 127.0.0.1:6381
>>> Trying to optimize slaves allocation for anti-affinity
[WARNING] Some slaves are in the same host as their master
M: 65f40c78c2fa077483faecfe75a648ffd68a74c1 127.0.0.1:6379
   slots:[0-5460] (5461 slots) master
M: 120bf876d9d30d9e9585f7161ad9d6820295efec 127.0.0.1:6380
   slots:[5461-10922] (5462 slots) master
M: 7715a4d32b681bfb7ba4d0f9033f33c32c5117b2 127.0.0.1:6381
   slots:[10923-16383] (5461 slots) master
S: 636f6eaf5c3f11942a4a6633e9bdfa9446b83686 127.0.0.1:6382
   replicates 120bf876d9d30d9e9585f7161ad9d6820295efec
S: 41503f4809a3fbb16fdac6f008fca2c6c1aaa389 127.0.0.1:6383
   replicates 7715a4d32b681bfb7ba4d0f9033f33c32c5117b2
S: 002c91cd8ea6581ed6ee0a43c677246f2428553d 127.0.0.1:6384
   replicates 65f40c78c2fa077483faecfe75a648ffd68a74c1
Can I set the above configuration? (type 'yes' to accept): yes
>>> Nodes configuration updated
>>> Assign a different config epoch to each node
>>> Sending CLUSTER MEET messages to join the cluster
Waiting for the cluster to join
.......
>>> Performing Cluster Check (using node 127.0.0.1:6379)
M: 65f40c78c2fa077483faecfe75a648ffd68a74c1 127.0.0.1:6379
   slots:[0-5460] (5461 slots) master
   1 additional replica(s)
S: 636f6eaf5c3f11942a4a6633e9bdfa9446b83686 127.0.0.1:6382
   slots: (0 slots) slave
   replicates 120bf876d9d30d9e9585f7161ad9d6820295efec
M: 7715a4d32b681bfb7ba4d0f9033f33c32c5117b2 127.0.0.1:6381
   slots:[10923-16383] (5461 slots) master
   1 additional replica(s)
S: 41503f4809a3fbb16fdac6f008fca2c6c1aaa389 127.0.0.1:6383
   slots: (0 slots) slave
   replicates 7715a4d32b681bfb7ba4d0f9033f33c32c5117b2
S: 002c91cd8ea6581ed6ee0a43c677246f2428553d 127.0.0.1:6384
   slots: (0 slots) slave
   replicates 65f40c78c2fa077483faecfe75a648ffd68a74c1
M: 120bf876d9d30d9e9585f7161ad9d6820295efec 127.0.0.1:6380
   slots:[5461-10922] (5462 slots) master
   1 additional replica(s)
[OK] All nodes agree about slots configuration.
>>> Check for open slots...
>>> Check slots coverage...
[OK] All 16384 slots covered.

該命令做了兩件事:將6379、6380、6381作爲主節點,平均分配槽;將6382、6383、6384分別作爲前面三個主節點的副本。非常方便。

現在集羣的狀態變爲:

127.0.0.1:6379> cluster info
cluster_state:ok
cluster_slots_assigned:16384
...
cluster_known_nodes:6
cluster_size:3
...

並且可以正常操作:

127.0.0.1:6379> set hello world
OK

此時,集羣拓撲如下:

 

4 集羣命令

Redis Cluster的命令分爲兩部分:redis-cli --cluster 選項提供的命令,以及客戶端命令

4.1 redis-cli 的集羣管理模式

使用 redis-cli --cluster help查看該模式下所有命令及用法,命令和選項的含義以註釋形式給出:

Cluster Manager Commands:
  create         host1:port1 ... hostN:portN //自動創建集羣並分配槽
                 --cluster-replicas <arg> //自動創建集羣、副本,並分配槽
  check          host:port //檢查集羣狀態
                 --cluster-search-multiple-owners //檢查是否有槽同時被分配給了多個節點
  info           host:port //獲取集羣信息
  fix            host:port //修復集羣
                 --cluster-search-multiple-owners //修復槽的重複分配問題
  reshard        host:port //重新分片
                 --cluster-from <arg>
                 --cluster-to <arg>
                 --cluster-slots <arg>
                 --cluster-yes
                 --cluster-timeout <arg>
                 --cluster-pipeline <arg>
                 --cluster-replace
  rebalance      host:port //重新均分槽
                 --cluster-weight <node1=w1...nodeN=wN>
                 --cluster-use-empty-masters
                 --cluster-timeout <arg>
                 --cluster-simulate
                 --cluster-pipeline <arg>
                 --cluster-threshold <arg>
                 --cluster-replace
  add-node       new_host:new_port existing_host:existing_port //添加節點
                 --cluster-slave //以從節點身份添加
                 --cluster-master-id <arg> //將從節點附加到指定主節點上
  del-node       host:port node_id //刪除節點
  call           host:port command arg arg .. arg //調用命令
  set-timeout    host:port milliseconds //設置cluster-node-timeout
  import         host:port //從外部實例導入數據,實現遷移
                 --cluster-from <arg>
                 --cluster-copy
                 --cluster-replace
  help

4.2 客戶端命令

在集羣模式下,客戶端除了支持單機模式下的命令,還支持許多形如“cluster XXX”的命令,使用cluster help命令可以查看:

CLUSTER <subcommand> arg ... 子命令有:

  •  ADDSLOTS <slot> [slot ...] 將槽分配給本節點
  •  BUMPEPOCH 將當前集羣epoch值增大1
  •  COUNT-failure-reports <node-id> 返回某個節點的失敗報告計數
  •  COUNTKEYSINSLOT <slot> 返回某個槽內有多少數據
  •  DELSLOTS <slot> [slot ...] 從本節點刪除某個槽
  •  FAILOVER [force|takeover] 強制發起故障轉移,使當前節點(從節點)晉升爲主節點
    • force:用於主節點宕機的情形,此時不再確認未複製數據,直接進行選舉,會導致未複製數據丟失
    • takeover:用於集羣中半數以上主節點宕機的情形,此時無法通過選舉完成故障轉移,將不考慮選舉,直接晉升本節點。該選項的風險是,可能引發衝突,此時將以epoch更大的一方爲準(相同則以節點ID字典序更大的一方爲準),從而導致另一方的一部分數據丟失。例如集羣有5個主節點及其從節點,一次網絡中斷引發分區,一個分區包含3個主節點,另一個包含2個主節點,此時第二個分區無法完成自動故障恢復,只能通過takeover強制恢復,當網絡恢復通暢時,兩個分區會產生衝突,需要合併,此時無論以哪一方爲準,都會導致另一方在分區期間接受的寫入操作丟失。
  •  FORGET <node-id> 從集羣中刪除一個節點
  •  GETKEYSINSLOT <slot> <count> 返回本節點存儲在某個槽內的key
  •  FLUSHSLOTS 重置本節點的槽信息,即本節點不再擁有槽
  •  INFO 顯示集羣信息
  •  KEYSLOT <key> 返回key對應的槽號
  •  MEET <ip> <port> [bus-port] 將本節點加入一個集羣中
  •  MYID返回本節點的節點ID
  •  NODES 返回集羣的節點信息,格式爲:<id> <ip:port> <flags> <master> <pings> <pongs> <epoch> <link> <slot> ... <slot>
  •  REPLICATE <node-id> 將本節點設爲指定節點的從節點
  •  RESET [hard|soft] 重置本節點
  •  SET-config-epoch <epoch> 設置本節點配置的epoch值
  •  SETSLOT <slot> (importing|migrating|stable|node <node-id>) 設置槽的狀態,這些狀態的作用和效果會在下面介紹
  •  REPLICAS <node-id> 返回某個節點的副本
  •  SLOTS 返回每個節點的槽信息,包括:開始槽號、結束槽號、主從節點的IP地址、端口號、節點ID

集羣在使用單機命令時,有一些限制:

  • mset、mget等批量操作,只能限定在同一個槽上,而不能跨槽操作
  • 事務操作限定在同一節點上,不能跨節點操作
  • list、hash等集合元素,不能分散映射到不同節點上
  • 只允許有一個數據庫,即db 0
  • 不支持樹狀副本結構
  • 如果使用Pub/Sub,會向整個集羣廣播,非常消耗性能,不建議使用

此外,Redis Cluster增加了一個機制:哈希標籤,即如果key中某一部分被大括號包裹起來,則計算槽號時,僅對該部分進行計算,否則使用整個鍵進行計算,例如user{tom}這個鍵在計算時,計算式爲:crc16(tom)%16384,而usertom在計算時則是:crc16(usertom)%16384。該機制可以用來讓結構類似的key存儲於同一槽中,從而可以使用批量操作,優化IO性能,例如tom:{user}:age、tom:{user}:hobby這兩個鍵就會存放在同一個槽上。

不過,過度使用哈希標籤,也會導致一系列問題:首先是數據傾斜,這是由於大量的key落入同一槽中,使得某些節點負載過重;其次是請求傾斜,如果熱點key使用了相同的哈希標籤,就會導致請求集中到某幾個槽上,從而造成節點負載過大。

5 原理

5.1 節點通信

上面提到,集羣中如果使用廣播,會擴散到整個集羣,對帶寬造成很大壓力,因此,集羣的通信和副本不同,使用名爲Bus的機制完成。count

節點啓動時,會額外開啓一個端口(值爲節點端口+10000),以第3節的集羣爲例,如果使用netstat命令,可以看到16379、16380、16381、...等6個端口也被開啓了。在每個週期內,每個節點都會選擇幾個其他的節點,發送ping確活,接收到ping的節點也響應pong。除此之外,集羣內的信息交換也以類似的形式點對點直接交換,Redis採用的這種去中心化、P2P的協議稱爲Gossip協議。

常用的Gossip消息有:meet(通知有新節點加入集羣)、ping(確活,並交換彼此狀態)、pong(響應meet、ping)、fail(通知集羣,有節點客觀下線),此外還有:publish(用於傳播Pub/Sub消息)、update(更新槽分配配置)、module(用於模塊擴展)、failover_auth_request(詢問是否允許由本節點發起故障轉移)、failover_auth_ack(上一個消息的回覆,表示同意)、mfstart(開始手動故障轉移)、count(返回消息類型總數)。

集羣消息源碼如下:

typedef struct {
    char sig[4];        /* 信號標識 */
    uint32_t totlen;    /* 消息總長度 */
    uint16_t ver;       /* 協議版本,目前爲1 */
    uint16_t port;      /* TCP端口號 */
    uint16_t type;      /* 消息類型 */
    uint16_t count;     /* 用於部分消息類型 */
    uint64_t currentEpoch;  /* 發送節點的epoch */
    uint64_t configEpoch;   /* 發送節點自身or其所屬主節點的epoch */
    uint64_t offset;    /* 複製偏移量 */
    char sender[CLUSTER_NAMELEN]; /* 發送節點的ID */
    unsigned char myslots[CLUSTER_SLOTS/8]; /* 發送節點的槽信息 */
    char slaveof[CLUSTER_NAMELEN]; /*  發送節點所屬的主節點(如果發送節點是從節點) */
    char myip[NET_IP_STR_LEN];    /* 發送節點的IP */
    char notused1[34];  /* 保留空間 */
    uint16_t cport;      /* 發送節點的Bus 端口 */
    uint16_t flags;      /* 發送節點的標識,用來標記主從節點、是否下線等狀態 */
    unsigned char state; /* 集羣狀態 */
    unsigned char mflags[3]; /* 消息標識 */
    union clusterMsgData data; /* 消息體 */
} clusterMsg;

消息體是一個共用體,承載ping/meet/pong、fail、publish、update、module五種結構體,這裏以ping/meet/pong爲例,其結構體名爲 clusterMsgDataGossip:

typedef struct {
    char nodename[CLUSTER_NAMELEN]; //節點ID
    uint32_t ping_sent; //上一次發送ping的時間
    uint32_t pong_received; //上一次收到pong的時間
    char ip[NET_IP_STR_LEN];  /* IP */
    uint16_t port;              /* 端口 */
    uint16_t cport;             /* 集羣Bus端口 */
    uint16_t flags;             /* 節點標識,拷貝自clusterNode結構體 */
    uint32_t notused1; // 保留空間
} clusterMsgDataGossip;

以meet消息爲例, 其clusterMsg結構體的type值就是 CLUSTERMSG_TYPE_MEET ,於是接收方讀取消息體中的ip、port、cport等信息,並判斷是不是新節點,是則存入本地節點列表,否則更新本地節點列表。

在節點互相ping的時候,又是如何決定其頻率,以及如何選擇ping的對象的呢?該邏輯在cluster.c的clusterCron函數實現。

首先,在server.c的serverCron函數有如下片段,表明clusterCron每100ms觸發一次,即每秒觸發10次:

run_with_period(100) {
    if (server.cluster_enabled) clusterCron();
}

而在 clusterCron 中,首先會隨機挑選5個節點,選出其中最久沒有通信過的一個發送ping:

for (j = 0; j < 5; j++) {
    de = dictGetRandomKey(server.cluster->nodes);
    clusterNode *this = dictGetVal(de)
    /* Don't ping nodes disconnected or with a ping currently active. */
    if (this->link == NULL || this->ping_sent != 0) continue;
    if (this->flags & (CLUSTER_NODE_MYSELF|CLUSTER_NODE_HANDSHAKE))
        continue;
    if (min_pong_node == NULL || min_pong > this->pong_received) {
        min_pong_node = this;
        min_pong = this->pong_received;
    }
}
if (min_pong_node) {
    serverLog(LL_DEBUG,"Pinging node %.40s", min_pong_node->name);
    clusterSendPing(min_pong_node->link, CLUSTERMSG_TYPE_PING);
}

 假如本節點等待了 cluster_node_timeout/2 時間,但還沒收到pong,則嘗試再次發送:

if (node->link && node->ping_sent == 0 && (now - node->pong_received) > server.cluster_node_timeout/2)
{
    clusterSendPing(node->link, CLUSTERMSG_TYPE_PING);
    continue;
}

 如果本節點爲主節點,且從節點發起了一次手動故障轉移,則對其發送ping:

if (server.cluster->mf_end && nodeIsMaster(myself) && server.cluster->mf_slave == node && node->link)
{
    clusterSendPing(node->link, CLUSTERMSG_TYPE_PING);
    continue;
}

由上面的源碼可見,如果 cluster_node_timeout 設置的較大,可以有效降低節點通信對帶寬的壓力,不過可能會導致可用性降低。

5.2 槽的遷移

槽的遷移分爲伸、縮兩種情形。

5.2.1 集羣收縮

仍然以第3節的集羣爲例,這裏將6383、6381兩個節點先後下線。由於6383是從節點,可以直接使用redis-cli --cluster del-node命令下線:

root@Yhc-Surface:~# redis-cli --cluster del-node 127.0.0.1:6383 41503f4809a3fbb16fdac6f008fca2c6c1aaa389
>>> Removing node 41503f4809a3fbb16fdac6f008fca2c6c1aaa389 from cluster 127.0.0.1:6383
>>> Sending CLUSTER FORGET messages to the cluster...
>>> SHUTDOWN the node.

但是6381是主節點,需要先手動釋放槽才能下線:

root@Yhc-Surface:~# redis-cli --cluster reshard 127.0.0.1:6381 --cluster-to 65f40c78c2fa077483faecfe75a648ffd68a74c1
>>> Performing Cluster Check (using node 127.0.0.1:6381)
M: 7715a4d32b681bfb7ba4d0f9033f33c32c5117b2 127.0.0.1:6381
   slots:[10923-16383] (5461 slots) master
M: 65f40c78c2fa077483faecfe75a648ffd68a74c1 127.0.0.1:6379
   slots:[0-5460] (5461 slots) master
   1 additional replica(s)
S: 002c91cd8ea6581ed6ee0a43c677246f2428553d 127.0.0.1:6384
   slots: (0 slots) slave
   replicates 65f40c78c2fa077483faecfe75a648ffd68a74c1
S: 636f6eaf5c3f11942a4a6633e9bdfa9446b83686 127.0.0.1:6382
   slots: (0 slots) slave
   replicates 120bf876d9d30d9e9585f7161ad9d6820295efec
M: 120bf876d9d30d9e9585f7161ad9d6820295efec 127.0.0.1:6380
   slots:[5461-10922] (5462 slots) master
   1 additional replica(s)
[OK] All nodes agree about slots configuration.
>>> Check for open slots...
>>> Check slots coverage...
[OK] All 16384 slots covered.
How many slots do you want to move (from 1 to 16384)? 5461
Please enter all the source node IDs.
  Type 'all' to use all the nodes as source nodes for the hash slots.
  Type 'done' once you entered all the source nodes IDs.
Source node #1: 7715a4d32b681bfb7ba4d0f9033f33c32c5117b2
Source node #2: done
... //打印遷移計劃
Do you want to proceed with the proposed reshard plan (yes/no)? yes
... //執行遷移計劃

然後就可以下線6381節點了,不過由於我們上面是將6381的所有槽轉移到6379上,導致了極大的不平衡,這裏再使用rebalance重新均分下槽,最終結果如下:

root@Yhc-Surface:~# redis-cli --cluster rebalance 127.0.0.1:6379
>>> Performing Cluster Check (using node 127.0.0.1:6379)
[OK] All nodes agree about slots configuration.
>>> Check for open slots...
>>> Check slots coverage...
[OK] All 16384 slots covered.
>>> Rebalancing across 2 nodes. Total weight = 2.00
Moving 2730 slots from 127.0.0.1:6379 to 127.0.0.1:6380
...

root@Yhc-Surface:~# redis-cli --cluster call 127.0.0.1:6379 cluster nodes
>>> Calling cluster nodes
127.0.0.1:6379: 636f6eaf5c3f11942a4a6633e9bdfa9446b83686 127.0.0.1:6382@16382 slave 120bf876d9d30d9e9585f7161ad9d6820295efec 0 1557235430637 8 connected
65f40c78c2fa077483faecfe75a648ffd68a74c1 127.0.0.1:6379@16379 myself,master - 0 1557235428000 7 connected 2730-5460 10923-16383
120bf876d9d30d9e9585f7161ad9d6820295efec 127.0.0.1:6380@16380 master - 0 1557235429629 8 connected 0-2729 5461-10922
002c91cd8ea6581ed6ee0a43c677246f2428553d 127.0.0.1:6384@16384 slave 65f40c78c2fa077483faecfe75a648ffd68a74c1 0 1557235429000 7 connected
... //下略

可以看到,此時兩個副本組各負擔了一半的槽(略微相差了幾個,但是基本均衡),但是這些槽不連續。 

5.2.2 集羣擴容

這裏清空6381和6383的集羣配置,再先後恢復上線,並添加到集羣中(以6381爲例):

root@Yhc-Surface:~# redis-cli --cluster add-node 127.0.0.1:6381 127.0.0.1:6379
>>> Adding node 127.0.0.1:6381 to cluster 127.0.0.1:6379
>>> Performing Cluster Check (using node 127.0.0.1:6379)
M: 65f40c78c2fa077483faecfe75a648ffd68a74c1 127.0.0.1:6379
   slots:[2730-5460],[10923-16383] (8192 slots) master
   1 additional replica(s)
S: 636f6eaf5c3f11942a4a6633e9bdfa9446b83686 127.0.0.1:6382
   slots: (0 slots) slave
   replicates 120bf876d9d30d9e9585f7161ad9d6820295efec
M: 120bf876d9d30d9e9585f7161ad9d6820295efec 127.0.0.1:6380
   slots:[0-2729],[5461-10922] (8192 slots) master
   1 additional replica(s)
S: 002c91cd8ea6581ed6ee0a43c677246f2428553d 127.0.0.1:6384
   slots: (0 slots) slave
   replicates 65f40c78c2fa077483faecfe75a648ffd68a74c1
[OK] All nodes agree about slots configuration.
>>> Check for open slots...
>>> Check slots coverage...
[OK] All 16384 slots covered.
>>> Send CLUSTER MEET to node 127.0.0.1:6381 to make it join the cluster.
[OK] New node added correctly.

此時6381和6383上沒有任何槽(由於槽全部分配給了6379和6380,所以此時集羣是可用的) ,需要調用reshard,從6379、6380分別遷移2730個槽給6381,結果如下:

127.0.0.1:6379> cluster nodes
636f6eaf5c3f11942a4a6633e9bdfa9446b83686 127.0.0.1:6382@16382 slave 120bf876d9d30d9e9585f7161ad9d6820295efec 0 1557236208159 8 connected
65f40c78c2fa077483faecfe75a648ffd68a74c1 127.0.0.1:6379@16379 myself,master - 0 1557236209000 7 connected 4095-5460 10923-16383
6f7cb28adf73509dd55c0838e316179396a8ae20 127.0.0.1:6381@16381 master - 0 1557236208000 9 connected 0-1364 2730-4094
120bf876d9d30d9e9585f7161ad9d6820295efec 127.0.0.1:6380@16380 master - 0 1557236210178 8 connected 1365-2729 5461-10922
002c91cd8ea6581ed6ee0a43c677246f2428553d 127.0.0.1:6384@16384 slave 65f40c78c2fa077483faecfe75a648ffd68a74c1 0 1557236209168 7 connected
796bcc01b76a0e559a6a74c3ed727ce03f4a5efe 127.0.0.1:6383@16383 master - 0 1557236211186 0 connected

5.3 請求路由和重定向

當對鍵操作時,如果該鍵經計算落在本節點下屬的槽中,則可以直接處理,否則就會報錯:

127.0.0.1:6379> set test1 hello1
OK
127.0.0.1:6379> set test2 hello2
(error) MOVED 8899 127.0.0.1:6380

上面的操作中,test2落在8899槽中,位於6380節點,因此提示error ,該錯誤信息包含“MOVED”字樣,因此這種重定向稱爲MOVED重定向。此時需要登入6380節點再執行一遍命令纔會生效,很麻煩,可以在啓動客戶端時帶上 -c 選項,自動重定向:

root@Yhc-Surface:~# redis-cli -c
127.0.0.1:6379> get test2
-> Redirected to slot [8899] located at 127.0.0.1:6380
(nil)
127.0.0.1:6380>

可以看到此時客戶端連接的節點變成了6380,即客戶端收到MOVED信息後,斷開6379的連接,再重新連接到6380上。

判斷的代碼在cluster.c的getNodeByQuery函數中:

int thisslot = keyHashSlot((char*)thiskey->ptr, sdslen(thiskey->ptr));
...
n = server.cluster->slots[slot];
...
if (n != myself && error_code) *error_code = CLUSTER_REDIR_MOVED;
return n;

 keyHashSlot就是計算鍵所屬槽號的函數,可以看到,這裏只有大括號包裹起來的部分參與計算(沒有大括號或者大括號中間沒東西才用整個key計算):

unsigned int keyHashSlot(char *key, int keylen) {
    int s, e; /* start-end indexes of { and } */

    for (s = 0; s < keylen; s++)
        if (key[s] == '{') break;

    /* No '{' ? Hash the whole key. This is the base case. */
    if (s == keylen) return crc16(key,keylen) & 0x3FFF;

    /* '{' found? Check if we have the corresponding '}'. */
    for (e = s+1; e < keylen; e++)
        if (key[e] == '}') break;

    /* No '}' or nothing between {} ? Hash the whole key. */
    if (e == keylen || e == s+1) return crc16(key,keylen) & 0x3FFF;

    /* If we are here there is both a { and a } on its right. Hash
     * what is in the middle between { and }. */
    return crc16(key+s+1,e-s-1) & 0x3FFF;
}

當err_code爲 CLUSTER_REDIR_MOVED 或 CLUSTER_REDIR_ASK 時,觸發重定向。ASK重定向發生在槽遷移期間,由於槽數量比較多,在生產環境下,每個槽又都存儲了不少數據,使得遷移過程可能比較慢,如果這時候正好有客戶端發出請求,則可能遇到如下情況:

客戶端根據以往的請求,知道test2這個key存儲在6380節點,但是當槽遷移後,該槽可能移動到6381節點上,該變化客戶端並不知情。那麼當客戶端再次去6380請求test2時,6380節點本地已經查不到該槽,不過根據遷移計劃,該槽應該在6381節點上,那麼就會回覆(error) ASK 8899 127.0.0.1:6381,通知客戶端到正確的服務器上查詢數據。

ASK和MOVED的區別在於,ASK是遷移過程中的狀態,很有可能一個key從源節點遷出,但是還沒有到達目標節點,那麼此時去查詢還是會返回nil,而MOVED代表一個確定的結果,即要查找的槽一定在目標節點上。

由此帶來的一個問題是,如果在槽遷移過程中執行了mset、mget等批量操作,很有可能由於遷移的延遲導致數據暫時分離,從而使得命令執行產生TRYAGAIN錯誤。

ASK的判斷也是在getNodeByQuery函數中進行的:

if (n == myself && server.cluster->migrating_slots_to[slot] != NULL)
{
    migrating_slot = 1;
}
...
if (migrating_slot && missing_keys) {
    if (error_code) *error_code = CLUSTER_REDIR_ASK;
    return server.cluster->migrating_slots_to[slot];
}

在clusterState結構體中,有一個migrating_slots_to數組,記錄了正在遷移的槽的信息,如果請求的槽在這個數組中,則返回ASK,而目標節點會到importing_slots_from數組中找到目標槽執行命令。

5.4 故障轉移

首先回顧一下基於哨兵的故障轉移:當某個哨兵發現主節點下線後,將其標記爲主觀下線,然後通知其他哨兵,當quorum個哨兵都認爲主節點下線後,轉爲客觀下線,然後哨兵節點通過選舉產生一個Leader,由Leader按照標準選定一個從節點晉升爲主節點並更新epoch。

集羣的故障轉移基本一致,只不過執行者不是哨兵節點,而是存活的主節點。上面提到,Redis Cluster通過定時任務進行節點間的通信,當主節點A發現與主節點B的上一次通信時間超過cluster_node_timeout時,會在本地將該B設爲主觀下線(即將該節點的flags屬性修改爲CLUSTER_NODE_PFAIL):

delay = now - node->ping_sent;
if (delay > server.cluster_node_timeout) {
    /* Timeout reached. Set the node as possibly failing if it is
     * not already in this state. */
    if (!(node->flags & (CLUSTER_NODE_PFAIL|CLUSTER_NODE_FAIL))) {
        serverLog(LL_DEBUG,"*** NODE %.40s possibly failing",
            node->name);
        node->flags |= CLUSTER_NODE_PFAIL;
        update_state = 1;
    }
}

當A做出了主觀下線的判斷後,會通過ping消息將該判斷進行擴散:

if (flags & (CLUSTER_NODE_FAIL|CLUSTER_NODE_PFAIL)) {
    if (clusterNodeAddFailureReport(node,sender)) {
        serverLog(LL_VERBOSE,
            "Node %.40s reported node %.40s as not reachable.",
            sender->name, node->name);
    }
    markNodeAsFailingIfNeeded(node);
} 

某個主節點C收到該消息後,會將該判斷保存在fail_reports列表中,如果經過cluster_node_timeout*2時間,該列表沒有更新,則取消客觀下線:

void clusterNodeCleanupFailureReports(clusterNode *node) {
    list *l = node->fail_reports;
    listNode *ln;
    listIter li;
    clusterNodeFailReport *fr;
    mstime_t maxtime = server.cluster_node_timeout *
                     CLUSTER_FAIL_REPORT_VALIDITY_MULT;
    mstime_t now = mstime();

    listRewind(l,&li);
    while ((ln = listNext(&li)) != NULL) {
        fr = ln->value;
        if (now - fr->time > maxtime) listDelNode(l,ln);
    }
}

如果收到了quorum個節點的主觀下線判斷,此時觸發客觀下線流程。客觀下線觸發後,會將該節點的flags修改爲 CLUSTER_NODE_FAIL ,然後以廣播的形式傳遞FAIL判斷,收到判斷的節點會在本地標記節點客觀下線。需要注意的是,FAIL標記只有在下線節點恢復上線後才能清除,不會像PFAIL那樣,超時後自動清除。

接下來就需要進行從節點的晉升,首先要檢查從節點是否足夠新,即從節點最後一次和主節點正常數據同步時間是否超過cluster_node_time*cluster_slave_validity_factor,是則不允許晉升:

if (server.cluster_slave_validity_factor && data_age >
        (((mstime_t)server.repl_ping_slave_period * 1000) +
         (server.cluster_node_timeout * server.cluster_slave_validity_factor)))
{
    if (!manual_failover) {
        clusterLogCantFailover(CLUSTER_CANT_FAILOVER_DATA_AGE);
        return;
    }
}

接下來的過程和哨兵的不同,首先根據複製偏移量排名,並設置其選舉時間 ,公式爲:排名次序*1000+500+random()%500:

server.cluster->failover_auth_time = mstime() +
    500 + /* Fixed delay of 500 milliseconds, let FAIL msg propagate. */
    random() % 500; /* Random delay between 0 and 500 milliseconds. */

這樣做可以確保複製進度靠前的節點具有優先權。 當選舉時間到達後,從節點就會發起選舉,請求選票:

if (server.cluster->failover_auth_sent == 0) {
        server.cluster->currentEpoch++;
        server.cluster->failover_auth_epoch = server.cluster->currentEpoch;
        serverLog(LL_WARNING,"Starting a failover election for epoch %llu.",
            (unsigned long long) server.cluster->currentEpoch);
        clusterRequestFailoverAuth();
        server.cluster->failover_auth_sent = 1;
        clusterDoBeforeSleep(CLUSTER_TODO_SAVE_CONFIG|
                             CLUSTER_TODO_UPDATE_STATE|
                             CLUSTER_TODO_FSYNC_CONFIG);
        return; /* Wait for replies. */
}

這裏增大了本地的epoch,在集羣消息傳播過程中,如果出現衝突,以epoch更大的一方爲準,因此收到投票請求的主節點會同意投票,當票數到達quorum後,即可晉升爲主節點,並更新了集羣整體的epoch:

if (server.cluster->failover_auth_count >= needed_quorum) {
        /* We have the quorum, we can finally failover the master. */

        serverLog(LL_WARNING,
            "Failover election won: I'm the new master.");

        /* Update my configEpoch to the epoch of the election. */
        if (myself->configEpoch < server.cluster->failover_auth_epoch) {
            myself->configEpoch = server.cluster->failover_auth_epoch;
            serverLog(LL_WARNING,
                "configEpoch set to %llu after successful failover",
                (unsigned long long) myself->configEpoch);
        }

        /* Take responsibility for the cluster slots. */
        clusterFailoverReplaceYourMaster();
}

請求投票的消息爲FAILOVER_AUTH_REQUEST(請看5.1節), 當節點收到該消息後,觸發clusterSendFailoverAuthIfNeeded方法,不過從節點進入方法後立刻就退出了,該方法對重複投票、epoch過小等情況都進行了處理,最後對符合條件的節點調用clusterSendFailoverAuth方法投出一票。

如果經過 cluster_node_timeout*2 時間,還沒有收到足夠的票,則本次選舉失敗,再次自增epoch並嘗試下一次選舉。

當從節點成功獲得晉升資格後,做了以下幾件事:設置自己的身份爲master、撤銷原來的主節點負責的槽並委派給自己、更新本地集羣狀態及配置、廣播pong消息通知其他節點更新集羣狀態和配置,不過如果此時遇到了手動故障轉移,則撤銷之前的所有操作:

void clusterFailoverReplaceYourMaster(void) {
    int j;
    clusterNode *oldmaster = myself->slaveof;

    if (nodeIsMaster(myself) || oldmaster == NULL) return;

    /* 1) Turn this node into a master. */
    clusterSetNodeAsMaster(myself);
    replicationUnsetMaster();

    /* 2) Claim all the slots assigned to our master. */
    for (j = 0; j < CLUSTER_SLOTS; j++) {
        if (clusterNodeGetSlotBit(oldmaster,j)) {
            clusterDelSlot(j);
            clusterAddSlot(myself,j);
        }
    }

    /* 3) Update state and save config. */
    clusterUpdateState();
    clusterSaveConfigOrDie(1);

    /* 4) Pong all the other nodes so that they can update the state
     *    accordingly and detect that we switched to master role. */
    clusterBroadcastPong(CLUSTER_BROADCAST_ALL);

    /* 5) If there was a manual failover in progress, clear the state. */
    resetManualFailover();
}

而當故障節點恢復上線後,雖然最初還是會認爲自己是主節點,不過隨着Gossip消息的交換,它最終會認識到自己已經降級爲從節點。

故障轉移的一些注意點有:

  • 新加入且未分配槽的節點不參與故障轉移過程
  • 如果故障轉移期間發生分區,必然有一方無法成功恢復,不過當網絡恢復通暢後,最終可以成功恢復
  • 如果故障轉移期間發生分區,主節點數量小的一方將會丟失分區期間產生的寫入數據 
  • Redis集羣主節點寫入數據後立刻返回OK,然後才通過同步複製給從節點,因此即便是複製偏移量最大或者近期和主節點進行過通信的從節點,也不一定就包含了所有數據

6 JedisCluster

Jedis也對集羣提供了良好的支持。下面是一個使用示例:

public static void main(String[] args) {
    JedisCluster cluster=new JedisCluster(new HostAndPort("127.0.0.1",6379));
    System.out.println(cluster.get("test1"));
    System.out.println(cluster.get("test2"));
}

創建JedisCluster實例後,其他操作和Jedis實例基本一致,上面代碼將輸出“hello1”、“hello2”

6.1 集羣節點的自動發現

上面的例子中,只連接了6379節點,但是可以操作其他節點上的數據(test2位於6380節點),這是因爲JedisCluster支持節點自動發現。

JedisCluster繼承自BinaryJedisCluster,它內部包含一個JedisClusterConnectionHandler對象用於處理連接,該對象構造時,會調用initializeSlotsCache方法初始化槽信息緩存,該方法內部調用了discoverClusterNodesAndSlots用來發現集羣中的其他節點:

List<Integer> slotNums = this.getAssignedSlotArray(slotInfo);
int size = slotInfo.size();
for(int i = 2; i < size; ++i) {
    List<Object> hostInfos = (List)slotInfo.get(i);
    if (hostInfos.size() > 0) {
        HostAndPort targetNode = this.generateHostAndPort(hostInfos);
        this.setupNodeIfNotExist(targetNode);
        if (i == 2) {
            this.assignSlotsToNode(slotNums, targetNode);
        }
    }
}

6.2  JedisClusterCRC16

JedisCluster提供了JedisClusterCRC16類用來計算key對應的槽號:

int slot=JedisClusterCRC16.getSlot("test1");

 然後可以使用槽號獲取對應的Redis節點連接:

Jedis jedis=cluster.getConnectionFromSlot(slot);

 配合hashtag,可以實現事務操作,例如:

public static void main(String[] args) {
    JedisCluster cluster=new JedisCluster(new HostAndPort("127.0.0.1",6379));
    String hashtag="{user}";
    String zhangsanSexual=hashtag+":zhangsan:sexual";
    String zhangsanHeight=hashtag+":zhangsan:height";
    int slot=JedisClusterCRC16.getSlot(hashtag);
    Jedis jedis=cluster.getConnectionFromSlot(slot);
    Transaction transaction=jedis.multi();
    transaction.set(zhangsanSexual,"male");
    transaction.set(zhangsanHeight,"1.8");
    transaction.exec();
    jedis.close();
}

也可以用來實現批量操作。 

getConnectionFromSlot方法實際是從JedisClusterInfoCache中,獲取槽對應的連接池,再從連接池中取出一個連接:

public Jedis getConnectionFromSlot(int slot) {
    JedisPool connectionPool = this.cache.getSlotPool(slot);
    if (connectionPool != null) {
        return connectionPool.getResource();
    } else {
        this.renewSlotCache();
        connectionPool = this.cache.getSlotPool(slot);
        return connectionPool != null ? connectionPool.getResource() : this.getConnection();
    }
}

// in JedisClusterInfoCache.java
public JedisPool getSlotPool(int slot) {
    this.r.lock();

    JedisPool var2;
    try {
        var2 = (JedisPool)this.slots.get(slot);
    } finally {
        this.r.unlock();
    }
    return var2;
}

6.3 JedisCluster對重定向的支持

以set方法爲例,該方法源碼如下:

public String set(final String key, final String value) {
    return (String)(new JedisClusterCommand<String>(this.connectionHandler, this.maxAttempts) {
        public String execute(Jedis connection) {
            return connection.set(key, value);
        }
    }).run(key);
}

實際就是調用JedisClusterCommand的execute和run方法。

run方法計算了key所屬的槽號後,調用runWithRetries方法繼續處理,該方法實際就是調用上面一小節介紹過的getConnectionFromSlot方法獲取連接,然後利用該連接調用execute方法。

execute方法爲抽象方法,此處的實現爲connection.set(key,value)。connection是一個Jedis對象,其set方法如下:

public String set(String key, String value) {
    this.checkIsInMultiOrPipeline();
    this.client.set(key, value);
    return this.client.getStatusCodeReply();
}

client.set方法實際就是構造Redis協議報文,然後發送到服務器上,並將返回信息寫入RedisOutputStream,getStatusCodeReply實質就是讀取RedisOutputStream裏面的數據並進行解析,該方法間接調用的process方法源碼如下:

private static Object process(RedisInputStream is) {
    byte b = is.readByte();
    switch(b) {
    case 36:
        return processBulkReply(is);
    case 42:
        return processMultiBulkReply(is);
    case 43:
        return processStatusCodeReply(is);
    case 45:
        processError(is);
        return null;
    case 58:
        return processInteger(is);
    default:
        throw new JedisConnectionException("Unknown reply: " + (char)b);
    }
}

MOVED和ASK都是錯誤信息,所以由processError處理:

private static void processError(RedisInputStream is) {
    String message = is.readLine();
    String[] askInfo;
    if (message.startsWith("MOVED ")) {
        askInfo = parseTargetHostAndSlot(message);
        throw new JedisMovedDataException(message, new HostAndPort(askInfo[1], Integer.parseInt(askInfo[2])), Integer.parseInt(askInfo[0]));
    } else if (message.startsWith("ASK ")) {
        askInfo = parseTargetHostAndSlot(message);
        throw new JedisAskDataException(message, new HostAndPort(askInfo[1], Integer.parseInt(askInfo[2])), Integer.parseInt(askInfo[0]));
    }
    ...
}

這裏解析了錯誤信息中的數據,並組裝成askInfo對象,通過拋出異常的方式向上傳遞,拋出的兩個異常都繼承自JedisRedirectionException。在runWithRetries中,對這兩個異常進行了處理:

catch (JedisRedirectionException var15) {
    if (var15 instanceof JedisMovedDataException) {
        this.connectionHandler.renewSlotCache(connection);
    }
    this.releaseConnection(connection);
    connection = null;
    var7 = this.runWithRetries(slot, attempts - 1, false, var15);
    return var7;
}

MOVED和ASK的處理方法基本一致,只是前者多了一個更新本地槽信息緩存的步驟,renewSlotCache方法調用discoverClusterSlots方法完成槽信息的獲取和處理:

public void renewClusterSlots(Jedis jedis) {
    if (!this.rediscovering) {
        try {
            this.w.lock();
            if (!this.rediscovering) {
                this.rediscovering = true;
                try {
                    if (jedis != null) {
                        try {
                            this.discoverClusterSlots(jedis);
                            return;
                        } catch (JedisException var26) {
                        }
                    }
                    ...
                    } finally {
                        this.rediscovering = false;
                    }
                }
        } finally {
            this.w.unlock();
        }
    }
}

然後只需要釋放當前連接,重新建立正確的連接並請求數據即可。但是這裏有一個潛在的風險點:更新槽信息緩存時,獲取的是寫鎖,而JedisCluster的一切操作都需要藉助JedisClusterCommand的run方法,該方法內部獲取了讀鎖,讀寫鎖的基本性質就是讀鎖共享和讀鎖寫鎖互斥,也就是說,當更新緩存時,本地發起的一切操作都會被阻塞。

在JedisConnectionException的catch塊中,我們也可以看到更新緩存和重試的操作,由於該異常更容易出現(例如節點下線、請求超時等),Jedis使用了兩個機制減少該異常導致的緩存更新次數,避免讀寫鎖互斥導致的請求阻塞:

第一個是限制最後一次因JedisConnectionException異常重試纔會觸發緩存更新:

if (attempts <= 1) {
    this.connectionHandler.renewSlotCache();
}

第二個是在renewClusterSlots方法中,使用double-check保證同一時刻只有一個線程執行緩存更新操作,從而降低了寫鎖的平均持有時間,降低了互斥衝突的影響。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章