Redis的高可用:哨兵和集群
我們在討論分布式系統的時候,曾經談過分布式系統要解決的是高並發、大數量和快速響應的問題。事實上,在互聯網中,大部分的業務還是以查詢數據為主,而非更改數據為主。在互聯網出現高並發的時刻,查詢關系數據庫,會造成關系數據庫的壓力增大,容易導致系統宕機的嚴重后果。為了解決這個問題,一些開發者提出了數據緩存技術,數據緩存和關系數據庫最大的不同在於,緩存的數據是保存在計算機內存上的,而關系數據庫的數據主要保存在磁盤上。計算機檢索內存的速度是遠超過檢索磁盤的,所以緩存技術可以在很大程度上提高整個系統的性能,降低數據庫的壓力。現今最流行的緩存技術當屬NoSQL技術,其中又以現今的主流技術Redis最為成功,所以本章會從Redis的角度來討論分布式緩存技術的應用,如果你使用的是其他緩存技術,也可參考其中的思想。
使用緩存技術最大的問題是數據的一致性問題,緩存中存儲的數據是關系數據庫中數據的副本,因為緩存機制與數據庫機制不同,所以它們的數據未必是同步的。雖然我們可以使用弱一致性去同步數據,但是現實很少會那么做,因為在互聯網系統中,往往查詢是可以允許部分數據不實時的,甚至是失真的,例如,一件商品的真實庫存是100件,而現在顯示是99件,這並不會妨礙用戶繼續購買。如果使用弱一致性,一方面會造成性能損失,另外一方面也會造成開發者工作量的大量增加。
緩存技術可以極大提升讀寫數據的速度,但是也有弊端,這就如同人類發明的水庫一樣。在平時,對水庫進行蓄水,當干旱時,就可以把水庫的水放出來,維持正常的工作和生活。如果水庫設計得太大,那么顯然會造成資源的浪費。水庫還有另外一個功能,就是當水量過大,在下游有被淹沒的危險的時候,關閉閘門,不讓水流淹沒下游。不過水庫也會造成威脅,當水量實在太大,超過有限的水庫容量的時候,就會溢出,這時會以更強的沖擊力沖毀下游。緩存技術也是一樣的,因為它是基於內存的,內存的大小要比磁盤小得多,同時成本也比磁盤高得多。因此緩存太大會浪費資源,過小,則在面臨高並發的時候,可能會被快速填滿,從而導致內存溢出、緩存服務失敗,進而引發一系列的嚴重問題。
在一般情況下,單服務器緩存已經很難滿足分布式系統大數量的要求,因為單服務器的內存空間是有限的,所以當前也會使用分布式緩存來應對。分布式緩存的分片算法和分布式數據庫系統的算法大同小異,這里就不再討論分片算法了。一般情況下,緩存技術使用起來比關系數據庫簡單,因為分布式數據庫還會有事務和協議,而緩存數據一般不要求一致性,數據類型也遠不如關系數據庫豐富。緩存數據的用途大多是查詢,查詢和更新不同,對實時性沒有那么高的要求,允許有一定的失真,這就給性能的優化帶來了更大的空間。
當然相對關系數據庫來說,緩存技術速度更快,正常來說,使用Redis的速度會是使用MySQL的幾倍到十幾倍。可見緩存能極大地優化分布式系統的性能,但是並不是說緩存可以代替關系數據庫。首先,緩存主要基於內存的形式存儲數據,而關系數據庫主要是基於磁盤;內存空間相對有限,價格相對較高,而磁盤空間相對較大,價格相對較低。其次,內存一旦失去電源,數據就會丟失。雖然Redis提供了快照(RDB)和記錄追加寫命令(AOF)這兩種形式進行持久化,但是機制相對簡單,難以保證數據不丟失。關系數據庫則有其完整的理論和實現,能夠有效使用事務和其他機制保證數據的完整性和一致性。因此,當前用緩存技術代替關系數據庫技術是不太現實的,但是可以使用緩存技術來實現網站常見的數據查詢,這能大幅度地提升性能。一般來說,適合使用緩存的場景包含以下幾種。
- 大部分是讀業務數據的系統(一般互聯網系統都滿足該條件)。
- 需要快速響應的系統。
- 需要預備數據(在系統或者某項業務前准備那些經常訪問的數據到緩存中,以便於系統開始就能夠快速響應,也稱為預熱數據)的系統。
- 對數據安全和一致性要求不太嚴格的系統。
有適合使用緩存的場景,當然也會有不適合使用緩存的場景。
- 讀業務數據少且寫入頻繁的系統。
- 對數據安全和一致性有嚴格要求的系統。
在使用緩存前,我會從3個方面進行考慮。
- 業務數據常用嗎?后續命中率如何?命中率很低的數據,沒有必要寫入緩存。
- 該業務數據是讀的多還是寫的多,如果是寫的多,需要頻繁寫入關系數據庫,也沒有必要使用緩存。
- 業務數據大小如何?如果要存儲很龐大的內容,就會給緩存系統帶來很大的壓力,有沒有必要?能截取最有價值的部分進行緩存而不全部緩存嗎?
經過以上考慮,覺得有必要使用緩存,就可以啟動緩存了。在當前互聯網中,緩存系統一般由Redis來完成,所以后續我們會集中討論Redis,就不再討論其他緩存系統了。本書采用的是Redis的5.0.5版本,如果采用別的版本,在配置項上會有少量不同,不過也大同小異,不會有太大的問題。
16.1 Redis的高可用
在Redis中,緩存的高可用分兩種,一種是哨兵,另外一種是集群,下面我們會用兩節分別討論它們。不過在討論它們之前,需要引入對Redis的依賴,如代碼清單16-1所示。
代碼清單16-1 引入spring-boot-redis依賴(chapter16模塊)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<!--不依賴Redis的異步客戶端lettuce-->
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<!--引入Redis的客戶端驅動jedis-->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
這里引入了Redis的依賴,並且選用Jedis作為客戶端,沒有使用Lettuce。這里解釋一下不使用Lettuce的原因。Lettuce是一個可伸縮的線程安全的Redis客戶端,多個線程可以共享同一個Redis連接,因為線程安全,所以會犧牲一部分的性能。但是一般來說,使用緩存並不需要很高的線程安全,更注重的是性能。Jedis是一種多線程非安全的客戶端,具備更高的性能,所以企業選擇的時候往往還是以使用它為主。
16.1.1 哨兵模式
在Redis的服務中,可以有多台服務器,還可以配置主從服務器,通過配置使得從機能夠從主機同步數據。在這種配置下,當主Redis服務器出現故障時,只需要執行故障切換(failover)即可,也就是作廢當前出故障的主Redis服務器,將從Redis服務器切換為主Redis服務器即可。這個過程可以由人工完成,也可以由程序完成,如果由人工完成,則需要增加人力成本,且容易產生人工錯誤,還會造成一段時間的程序不可用,所以一般來說,我們會選擇使用程序完成。這個程序就是我們所說的哨兵(sentinel),哨兵是一個程序進程,它運行於系統中,通過發送命令去檢測各個Redis服務器(包括主從Redis服務器),如圖16-1所示。

圖16-1 單個哨兵模式
圖16-1中有2個Redis從服務器,它們會通過復制Redis主服務器的數據來完成同步。此外還有一個哨兵進程,它會通過發送命令來監測各個Redis主從服務器是否可用。當主服務器出現故障不可用時,哨兵監測到這個故障后,就會啟動故障切換機制,作廢當前故障的主Redis服務器,將其中的一台Redis從服務器修改為主服務器,然后將這個消息發給各個從服務器,使得它們也能做出對應的修改,這樣就可以保證系統繼續正常工作了。通過這段論述大家可以看出,哨兵進程實際就是代替人工,保證Redis的高可用,使得系統更加健壯。
然而有時候單個哨兵也可能不太可靠,因為哨兵本身也可能出現故障,所以Redis還提供了多哨兵模式。多哨兵模式可以有效地防止單哨兵不可用的情況,如圖16-2所示。

圖16-2 多哨兵模式
在圖16-2中,多個哨兵會相互監控,使得哨兵模式更為健壯,在這個機制中,即使某個哨兵出現故障不可用,其他哨兵也會監測整個Redis主從服務器,使得服務依舊可用。不過,故障切換方式和單哨兵模式的完全不同,這里我們通過假設舉例進行說明。假設Redis主服務器不可用,哨兵1首先監測到了這個情況,這個時候哨兵1不會立即進行故障切換,而是僅僅自己認為主服務器不可用而已,這個過程被稱為主觀下線。因為Redis主服務器不可用,跟着后續的哨兵(如哨兵2和3)也會監測到這個情況,所以它們也會做主觀下線的操作。如果哨兵的主觀下線達到了一定的數量,各個哨兵就會發起一次投票,選舉出新的Redis主服務器,然后將原來故障的主服務器作廢,將新的主服務器的信息發送給各個從Redis服務器做調整,這個時候就能順利地切換到可用的Redis服務器,保證系統持續可用了,這個過程被稱為客觀下線。
為了演示這個過程,我先給出自己的哨兵和Redis服務器的情況,如表16-1所示。
表16-1 服務分配情況(略)
這樣設計的架構,就如同圖16-2一樣,下面我們需要對各個服務進行配置。首先修改Redis主服務器配置(192.168.224.131)的內容,在Redis安裝目錄中找到redis.config文件,打開它,可以發現有很多配置項和注釋。只需要對某些配置項進行修改即可,需要修改的配置項代碼如下:
# 禁用保護模式
protected-mode no
# 修改可以訪問的IP,0.0.0.0代表可以跨域訪問
bind 0.0.0.0
# 設置Redis服務密碼
requirepass 123456
然后再修改兩台從服務器的配置,請注意,它們倆的配置是相同的。在Redis安裝目錄中找到redis.config文件,然后也是對相關的配置項進行修改,代碼如下:
# 禁用保護模式
protected-mode no
# 修改可以訪問的IP,0.0.0.0代表可以跨域訪問
bind 0.0.0.0
# 設置Redis服務密碼
requirepass 123456
# 配置從哪里復制數據(也就是配置主Redis服務器)
replicaof 192.168.224.131 6379
# 配置主Redis服務器密碼
masterauth 123456
以上的配置都有清晰的注釋,請自行參考。從服務器的配置只是比主服務器多了replicaof和masterauth兩個配置項。
上述的兩個配置只是在配置Redis的服務器,此外我們還需要配置哨兵。同樣,在Redis安裝目錄下,找到sentinel.conf文件,然后把3個哨兵服務的配置都改成以下配置。
# 禁止保護模式
protected-mode no
# 配置監聽的主服務器,這里sentinel monitor 代表監控,
# mymaster 代表服務器名稱,可以自定義
# 192.168.224.131 代表監控的主服務器
# 6379代表端口
# 2 代表只有在2個或者2個以上的哨兵認為主服務器不可用的時候,才進行客觀下線
sentinel monitor mymaster 192.168.224.131 6379 2
# sentinel auth-pass定義服務的密碼
# mymaster 服務名稱
# 123456 Redis服務器密碼
sentinel auth-pass mymaster 123456
上述的配置只是在原有的其他配置項上按需進行修改。代碼中已經給出了清晰的注釋,請讀者自行參考。
有了這些配置,我們就可以進入Redis的安裝目錄,使用下面的命令啟動服務了。
# 啟動Redis服務
./src/redis-server ./redis.conf
# 啟動哨兵進程服務
./src/redis-sentinel ./sentinel.conf
需要注意的是啟動的順序,首先是主Redis服務器,然后是從Redis服務器,最后才是3個哨兵。啟動之后,觀察最后一個啟動的哨兵,可以看到圖16-3所示的信息。

圖16-3 哨兵進程輸出信息
從圖16-3中可以看到主從服務器和哨兵的相關信息,說明我們的多哨兵模式已經搭建好了。
上述的哨兵模式配置好后,就可以在Spring Boot環境中使用了。首先需要配置YAML文件,如代碼清單16-2所示。
代碼清單16-2 在Spring Boot中配置哨兵(chapter16模塊)
spring:
redis:
# 配置哨兵
sentinel:
# 主服務器名稱
master: mymaster
# 哨兵節點
nodes: 192.168.224.131:26379,192.168.224.133:26379,192.168.224.134:26379
# 登錄密碼
password: 123456
# Jedis配置
jedis:
# 連接池配置
pool:
# 最大等待1秒
max-wait: 1s
# 最大空閑連接數
max-idle: 10
# 最大活動連接數
max-active: 20
# 最小空閑連接數
min-idle: 5
這樣就配置好了哨兵模式下的Redis,為了測試它,可以修改Spring Boot的啟動類,如代碼清單16-3所示。
代碼清單16-3 測試哨兵(chapter16模塊)
package com.spring.cloud.chapter16.main;
/**** imports ****/
@SpringBootApplication
@RestController
@RequestMapping("/redis")
public class Chapter16Application {
public static void main(String[] args) {
SpringApplication.run(Chapter16Application.class, args);
}
// 注入StringRedisTemplate對象,該對象操作字符串,由Spring Boot機制自動裝配
@Autowired
private StringRedisTemplate stringRedisTemplate = null;
// 測試Redis寫入
@GetMapping("/write")
public Map<String, String> testWrite() {
Map<String, String> result = new HashMap<>();
result.put("key1", "value1");
stringRedisTemplate.opsForValue().multiSet(result);
return result;
}
// 測試Redis讀出
@GetMapping("/read")
public Map<String, String> testRead() {
Map<String, String> result = new HashMap<>();
result.put("key1", stringRedisTemplate.opsForValue().get("key1"));
return result;
}
}
這里的testWrite方法是寫入一個鍵值對,testRead方法是讀出鍵值對。我們先在瀏覽器請求http://localhost:8080/redis/write,然后到各個Redis主從服務器中查看,都可以看到鍵值對(key1->value1)。當某個哨兵、Redis服務器或者主Redis服務器出現故障時,哨兵都會進行監測,並且通過主觀下線或者客觀下線進行修復,使得Redis服務能夠具備高可用的特性。只是,在進行客觀下線的時候,也需要一個時間間隔進行修復,這是我們需要注意的。默認是30秒,可以通過Redis的sentinel.conf文件的sentinel down-after-milliseconds進行修改,例如修改為60秒:
sentinel down-after-milliseconds mymaster 60000
16.1.2 Redis集群
除了可以使用哨兵模式外,還可以使用Redis集群(cluster)技術來實現高可用,不過Redis集群是3.0版本之后才提供的,所以在使用集群前,請注意你的Redis版本。不過在學習Redis集群前,我們需要了解哈希槽(slot)的概念,為此先看一下圖16-4。

圖16-4 哈希槽概念
圖16-4中有整數1~6的圖形為一個哈希槽,哈希槽中的數字決定了數據將發送到哪台主Redis服務器進行存儲。每台主服務器會配置1台到多台從Redis服務器,從服務器會同步主服務器的數據。那么它的工作機制是什么樣的呢?下面我們來進行解釋。
我們知道Redis是一個key-value緩存,假如計算key的哈希值,得到一個整數,記為hashcode。如果此時執行:
n = hashcode % 6 + 1
得到的n就是一個1到6之間的整數,然后通過哈希槽就能找到對應的服務器。例如,n=4時就會找到主服務器1的Redis服務器,而從服務器1就是其從服務器,會對數據進行同步。
在Redis集群中,大體也是通過相同的機制定位服務器的,只是Redis集群的哈希槽大小為(214=16 384),也就是取值范圍為區間[0, 16383],最多能夠支持16 384個節點,Redis設計師認為這個節點數已經足夠了。對於key,Redis集群會采用CRC16算法計算key的哈希值,關於CRC16算法,本書就不論述了,感興趣的讀者可以自行查閱其他資料進行了解。當計算出key的哈希值(記為hashcode)后,通過對16 384求余就可以得到結果(記為n),根據它來尋找哈希槽,就可以找到對應的Redis服務器進行存儲了。它們的計算公式為:
# key為Redis的鍵,通過CRC16算法求哈希值
hashcode = CRC16(key);
# 求余得到哈希槽中的數字,從而找到對應的Redis服務器
n = hashcode % 16384
這樣n就會落入Redis集群哈希槽的區間[0, 16383]內,從而進一步找到數據。下面舉例進行說明,如圖16-5所示。

圖16-5 Redis集群工作原理
這里假設有3個Redis主服務器(或者稱為節點),用來存儲緩存的數據,每一個主服務器都有一個從服務器,用來復制主服務器的數據,保證高可用。其中哈希槽分配如下。
- Redis主服務器1:分配哈希槽區間為[0, 5460]。
- Redis主服務器2:分配哈希槽區間為[5461, 10922]。
- Redis主服務器3:分配哈希槽區間為[10923, 16383]。
這樣通過CRC16算法求出key的哈希值,再對16 384求余數,就知道n會落入哪個哈希槽里,進而決定數據存儲在哪個Redis主服務器上。
注意,集群中各個Redis服務器不是隔絕的,而是相互連通的,采用的是PING-PONG機制,內部使用了二進制協議優化傳輸速度和帶寬,如圖16-6所示。
從圖16-6中可以看出,客戶端與Redis節點是直連的,不需要中間代理層,並且不需要連接集群所有節點,只需連接集群中任何一個可用節點即可。在Redis集群中,要判定某個主節點不可用,需要各個主節點進行投票,如果半數以上主節點認為該節點不可用,該節點就會從集群中被剔除,然后由其從節點代替,這樣就可以容錯了。因為這個投票機制需要半數以上,所以一般來說,要求節點數大於3,且為單數。因為如果是雙數,如4,投票結果可能會為2:2,就會陷入僵局,不利於這個機制的執行。

圖16-6 Redis集群中各個節點是聯通的
在某些情況下,Redis集群會不可用,當集群不可用時,所有對集群的操作做都不可用。那么什么時候集群不可用呢?一般來說,分為兩種情況。
- 如果某個主節點被認為不可用,並且沒有從節點可以代替它,那么就構建不成哈希槽區間[0, 16383],此時集群將不可用。
- 如果原有半數以上的主節點發生故障,那么無論是否存在可代替的從節點,都認為該集群不可用。
Redis集群是不保證數據一致性的,這也就意味着,它可能存在一定概率的數據丟失現象,所以更多地使用它作為緩存,會更加合理。
有了上述的理論知識,下面讓我們來搭建Redis集群環境。我使用的是Ubuntu來搭建Redis環境,首先進入root用戶,然后執行以下命令:
cd /usr
# 創建Redis目錄,並進入目錄
mkdir redis
cd ./redis
# 下載Redis
wget http://download.redis.io/releases/redis-5.0.5.tar.gz
# 解壓縮安裝包
tar xzf redis-5.0.5.tar.gz
# 進入安裝目錄
cd redis-5.0.5
# 編譯安裝Redis
make
執行上述命令就安裝好了Redis,然后在/usr/redis/redis-5.0.5下創建文件夾cluster,並在其下面創建目錄7001、7002、7003、7004、7005和7006,接着將/usr/redis/redis-5.0.5/redis.conf文件復制到目錄7001、7002、7003、7004、7005下,最后執行如下命令。
# 進入安裝目錄
cd /usr/redis/redis-5.0.5
# 創建文件夾cluster和其子目錄
mkdir cluster
cd ./cluster
mkdir 7001 7002 7003 7004 7005 7006
# 復制文件
cp ../redis.conf ./7001
cp ../redis.conf ./7002
cp ../redis.conf ./7003
cp ../redis.conf ./7004
cp ../redis.conf ./7005
cp ../redis.conf ./7006
# 賦予目錄下所有文件全部權限
chmod -R 777 ./
這樣從7001到7006的目錄下就都有一份Redis的啟動配置文件了,之所以讓目錄起名為這些數字,是因為我將會使用這些數字作為端口來分別啟動Redis服務。下面,我們首先來修改7001下的redis.conf文件,只修改文件的部分配置,修改的內容如下:
# 關閉保護模式
protected-mode no
# 允許跨域訪問
bind 0.0.0.0
# 主機密碼
masterauth 123456
# 登錄密碼
requirepass 123456
# 端口7001
port 7001
# 啟用集群模式
cluster-enabled yes
# 集群配置文件
cluster-config-file nodes-7001.conf
# 和集群節點通信的超時時間
cluster-node-timeout 5000
# 采用添加寫命令的模式備份
appendonly yes
# 備份文件名稱
appendfilename "appendonly-7001.aof"
# 采用后台運行Redis服務
daemonize yes
# PID命令文件
pidfile /var/run/redis_7001.pid
然后再修改7002到7006目錄下的redis.conf文件,修改時將所有配置項中的“7001”替換為對應的數字即可,這樣我們就可以得到6個啟動Redis服務的配置文件了。
接下來就是配置和創建集群了,這里Redis 5也為此提供了工具,並且放在Redis安裝目錄的子文件夾/utils/create-cluster(我使用的系統全路徑為/usr/redis/redis-5.0.5/utils/create-cluster)中。打開這個目錄,就可以發現一個create-cluster文件,我們修改它的權限(命令chmod 777 eate-cluster),然后打開它,修改它的內容,代碼如下:
#!/bin/bash
# Settings
# 端口,從7000開始,SHELL會自動加1后,找到7001到7006的Redis服務實例
PORT=7000
# 創建超時時間
TIMEOUT=2000
# Redis節點數
NODES=6
# 每台主機的從機數
REPLICAS=1 # ①
# 密碼,和我們配置的一致
PASSWORD=123456
......
#### 以下給redis-cli 命令添加配置的密碼 ####
if [ "$1" == "create" ]
then
HOSTS=""
while [ $((PORT < ENDPORT)) != "0" ]; do
PORT=$((PORT+1))
HOSTS="$HOSTS 192.168.224.135:$PORT"
done
../../src/redis-cli --cluster create $HOSTS -a $PASSWORD --cluster-replicas $REPLICAS
exit 0
fi
if [ "$1" == "stop" ]
then
while [ $((PORT < ENDPORT)) != "0" ]; do
PORT=$((PORT+1))
echo "Stopping $PORT"
../../src/redis-cli -p $PORT -a $PASSWORD shutdown nosave
done
exit 0
fi
if [ "$1" == "watch" ]
then
PORT=$((PORT+1))
while [ 1 ]; do
clear
date
../../src/redis-cli -p $PORT -a $PASSWORD cluster nodes | head -30
sleep 1
done
exit 0
fi
......
if [ "$1" == "call" ]
then
while [ $((PORT < ENDPORT)) != "0" ]; do
PORT=$((PORT+1))
../../src/redis-cli -p $PORT -a $PASSWORD $2 $3 $4 $5 $6 $7 $8 $9
done
exit 0
fi
......
這段配置看起來挺復雜,實際是很簡單的,我修改的是代碼中加粗的部分,其余的並未改動。首先修改了端口,例如,端口從7000開始遍歷,這樣循環加1,就可以找到7001到7006的服務實例。其次給redis-cli命令,加入配置的密碼,修改IP。這里盡量不要使用localhost和127.0.01指向本機IP,應該使用該服務器在網絡中的IP,否則不在本機客戶端登錄時,就會出現一些沒有必要的錯誤。至此,所有的配置就都完成了。
跟着我們需要編寫腳本,使得我們能夠創建、停止和啟動集群。為此,在Linux中以root用戶登錄,然后執行以下命令:
# 進入集群目錄
cd /usr/redis/redis-5.0.5/cluster
# 創建3個腳本文件
touch create.sh start.sh shutdown.sh
# 賦予腳本文件全部權限
chmod 777 *.sh
從命令中可以看出,我們創建了3個Shell腳本文件。
- create.sh:用來啟動Redis服務,然后創建集群。
- start.sh:用來在集群關閉后,啟動集群的各個節點。
- shutdown.sh:關閉運行中的集群的各個節點。
跟着來編寫start.sh,代碼如下:
# 進入集群工具目錄
cd /usr/redis/redis-5.0.5/utils/create-cluster
# 啟動集群各個Redis實例,參數為start
./create-cluster start
這個腳本是運行集群的各個節點,只是此時集群還沒有被創建,所以還不能運行這個腳本。跟着是shutdown.sh的編寫,代碼如下:
# 進入集群工具目錄
cd /usr/redis/redis-5.0.5/utils/create-cluster
# 停止集群各個Redis實例,參數為stop
./create-cluster stop
這個腳本是停止集群中的各個實例,當然集群現在沒有創建和運行,所以它暫時也不能運行。
為了讓start.sh和shutdown.sh能夠運行,我們需要創建Redis集群,下面編寫create.sh,內容如下:
# 在不同端口啟動各個Redis服務 ①
/usr/redis/redis-5.0.5/src/redis-server /usr/redis/redis-5.0.5/cluster/7001/redis.conf
/usr/redis/redis-5.0.5/src/redis-server /usr/redis/redis-5.0.5/cluster/7002/redis.conf
/usr/redis/redis-5.0.5/src/redis-server /usr/redis/redis-5.0.5/cluster/7003/redis.conf
/usr/redis/redis-5.0.5/src/redis-server /usr/redis/redis-5.0.5/cluster/7004/redis.conf
/usr/redis/redis-5.0.5/src/redis-server /usr/redis/redis-5.0.5/cluster/7005/redis.conf
/usr/redis/redis-5.0.5/src/redis-server /usr/redis/redis-5.0.5/cluster/7006/redis.conf
# 創建集群,使用參數create ②
cd /usr/redis/redis-5.0.5/utils/create-cluster
./create-cluster create
這里分為兩段,其中第①段是讓Redis在各個端口下啟動實例,第②段是創建集群。然后我們運行create.sh腳本,就可以看到圖16-7所示的提示。

圖16-7 創建Redis集群的提示信息
注意圖16-7中框中的信息,信息類型大致分為兩種。第一種是哈希槽的分配情況,這里提示了分為3個主節點,然后第一個的哈希槽區間為[0, 5460],第二個的為[5461, 10922],第三個的為[10923, 16383]。第二種是從節點的情況,7005端口為7001端口的從節點,7006端口為7002端口的從節點,7004端口為7003端口的從節點。然后它詢問我們是否接受該配置,只要輸入“yes”回車后,稍等一會兒,它就會創建Redis集群了。
創建好了Redis集群,可以通過命令來驗證它,我們先通過redis-cli登錄集群,在Linux中執行如下命令。
# 進入目錄
cd /usr/redis/redis-5.0.5
# 登錄Redis集群:
# -c代表以集群方式登錄
# -p 選定登錄的端口
# -a 登錄集群的密碼
./src/redis-cli -c -p 7001 -a 123456
這樣就能夠登錄Redis集群了,然后我們可以執行幾個Redis的命令來觀察執行的情況,執行的命令如下:
set key1 value1
Set key2 value2
set key3 value3
Set key4 value4
set key5 value5
我執行的結果如圖16-8所示。

圖16-8 驗證集群
在圖16-8中可以看到,在執行命令的時候,Redis會打印出一個哈希槽的數字,然后重新定位到具體的Redis服務器。這些都是Redis集群機制完成的,對於客戶端來說,一切都是透明的。
至此,Redis集群我們就搭建成功了。當我們想停止集群的時候,可以執行之前創建好的shutdown.sh。當我們需要啟動已經停止的集群的時候,只需要執行start.sh即可。
上述我們搭建了Redis的集群,跟着就要在Spring Boot中使用它了。在Spring Boot中使用它並不麻煩,只需要先注釋掉代碼清單16-3中的配置,然后在application.yml文件中加入代碼清單16-4所示的代碼即可。
代碼清單16-4 Spring Boot配置Redis集群(chapter16模塊)
spring:
redis:
# 登錄密碼
# Jedis配置
jedis:
# 連接池配置
pool:
# 最大等待1秒
max-wait: 1s
# 最大空閑連接數
max-idle: 10
# 最大活動連接數
max-active: 20
# 最小空閑連接數
min-idle: 5
# 配置Redis集群信息
cluster:
# 集群節點信息
nodes: 192.168.224.135:7001,192.168.224.135:7002,192.168.224.135:7003,192.168.224.135:7004,192.168.224.135:7005,192.168.224.135:7006
# 最大重定向數,一般設置為5,
# 不建議設置過大,過大容易引發重定向過多的異常
max-redirects: 5
password: 123456
這樣就在Spring Boot中配置好了,可以像往常一樣通過RedisTemplate或者StringRedisTemplate來操作Redis集群了。
16.2 使用一致性哈希(ShardedJedis)
在我們討論了Redis集群后,大家可以知道,集群實際包含了高可用,也包含了緩存分片兩個功能。但是對於集群來說,分片算法是固定且不透明的,可能會因為某種原因使得多數的數據,落入同一個Redis服務中,使負荷不同。有時候,我們還希望使用一致性哈希算法,關於該算法,我們在分布式數據庫分片算法中也進行了詳盡的介紹,所以這里就不再重復了。在Jedis中還提供了類ShardedJedis,有了這個類,我們可以很容易地在Jedis客戶端中使用一致性哈希算法。
ShardedJedis內部已經采用了一致性哈希算法,並且為每個Redis服務器提供了虛擬節點(虛擬節點個數為權重×160)。下面讓我們通過代碼來學習如何使用ShardedJedis。首先,我們需要創建一個ShardedJedis連接池,於是在Spring Boot的啟動類(Chapter16Application.java)中加入代碼清單16-5所示的代碼。
代碼清單16-5 使用ShardedJedis(chapter16模塊)
// ShardedJedis 連接池
private ShardedJedisPool pool = null;
@Bean
public ShardedJedisPool initJedisPool() {
// 端口數組
int[] ports = {7001, 7002, 7003};
// 權重數組
int[] weights = {1, 2, 1};
// 服務器
String host = "192.168.224.136";
// 密碼
String password = "123456";
// 連接超時時間
int connectionTimeout = 2000;
// 讀超時時間
int soTimeout = 2000;
List<JedisShardInfo> shardList = new ArrayList<>();
for (int i=0; i < ports.length; i++) {
// 創建JedisShard信息
JedisShardInfo shard = new JedisShardInfo(
host, ports[i], connectionTimeout, soTimeout,weights[i]); //①
// 設置密碼
shard.setPassword(password);
// 加入到列表中
shardList.add(shard);
}
// 連接池配置
JedisPoolConfig poolCfg = new JedisPoolConfig();
poolCfg.setMaxIdle(10);
poolCfg.setMinIdle(5);
poolCfg.setMaxIdle(10);
poolCfg.setMaxTotal(30);
poolCfg.setMaxWaitMillis(2000);
// 創建ShardedJedis連接池
pool = new ShardedJedisPool(poolCfg, shardList); // ②
return pool;
}
這里我在一台機器上模擬了3個Redis服務,它們的端口分別為7001、7002和7003。現實中每台服務器的性能都可能是不同的,這里假設7002端口的服務性能要好很多,所以在權重數組中將它的權重設置為2,這樣數據緩存到7002服務中的概率就更高了。在代碼①處,創建了單個JedisShardInfo對象,然后將它放到一個列表中。代碼②處創建了一個JedisShard連接池對象。
上面的代碼創建了JedisShard連接池,這樣就可以從中取出ShardedJedis對象去操作Redis了。下面讓我們在啟動類(Chapter16Application.java)中加入代碼清單16-6所示的代碼來進行演示。
代碼清單16-6 使用ShardedJedis(chapter16模塊)
// 測試Redis寫入
@GetMapping("/test2")
public Map<String, String> test2() {
Map<String, String> result = new HashMap<>();
ShardedJedis jedis = null;
try {
// 獲得ShardedJedis對象 ①
jedis = pool.getResource();
// 寫入Redis
jedis.set("key1", "value1");
// 從Redis讀出
result.put("key1", jedis.get("key1"));
return result;
} finally {
// 最后釋放連接
jedis.close(); // ②
}
}
代碼也比較簡單,其中①處是獲取ShardedJedis對象,然后設置一個鍵值對,再從中讀出來放到Map中。②處是關閉連接,以避免過多的空閑連接得不到釋放。
ShardedJedis使用起來也比較方便,但是無法與Spring提供RedisTemplate和StringRedisTemplate結合。同時,也沒有類似哨兵模式和集群模式下主從機主動修復的機制,所以在高可用方面較差。因為它的缺點,所以選擇它時需要慎重。
ShardedJedis的原理其實也不難,我們知道Redis是鍵值對(key-value)緩存,要操作數據就必須要有鍵(key),所以在做Redis命令操作時,會先根據key求出其哈希值(hashcode),然后再根據哈希值和一致性哈希算法,選擇具體的Redis節點。在ShardedJedis的一致性哈希算法中,會給每一個真實的Redis節點制造出“160×權重”個虛擬節點,使數據盡可能平均地分布到每一個節點中。
16.3 分布式緩存實踐
在分布式緩存中,還會遇到許多的問題。例如,保存的對象過大,網絡傳輸較慢,又如緩存雪崩等,所以要用好分布式緩存也需要考慮一些常見的問題。
16.3.1 大對象的緩存
在Java中,有些對象可能很大,尤其是那些讀取文件的對象。對於大的對象,一次性讀出來需要使用很多的網絡傳輸資源,這樣會引發性能瓶頸。在Redis官網中,建議我們使用Redis的哈希(Hash)結構去緩存大對象的內容,把它的屬性保存到哈希結構的字段(field)中。在讀取很大的對象時,往往只需要先讀取部分內容,后續再根據需要讀取對應的字段即可,如圖16-9所示。

圖16-9 將大對象以哈希結構緩存到Redis中
也許還有一種可能,就是哈希結構中的某個字段的值也是大對象,例如一本書有幾十萬字。一般來說,這個時候會做兩方面的考慮。一方面是有必要全部保存嗎?是否保存部分最常用的即可?另一方面,可以拆分字符串,將原有的字段拆分為多個字段,拿圖16-3來說,假如field3需要存儲的是很大的字符串,我們可以將其拆分為field3_1, field3_2, …, field3_n,分段保存字符串,然后讀取的時候,也分段讀取即可。
16.3.2 緩存穿透、並發和雪崩
當客戶端通過一個鍵去訪問緩存時,緩存沒有數據,跟着又去訪問數據庫,數據庫也沒有數據,這時因為數據庫返回也為空,所以不會將該數據放到緩存中,我們把這樣的情況稱為緩存穿透,如圖16-10所示。

圖16-10 緩存穿透
如果我們再次請求這個的鍵,還是會按照此流程再走一遍。如果出現高並發訪問這個鍵的情況,數據就會頻繁訪問數據庫,給數據庫帶來很大的壓力,甚至可能導致數據庫出現故障,這便是緩存穿透帶來的危害。
為了解決這個問題,相信大家很快想到,如果在訪問數據庫后也得到控制,可以在緩存中記錄一個字符串(如“null”,代表是空值),即可解決這個問題。但是這樣會引發一個問題,就是在很多時候我們訪問數據庫也得不到數據,這樣就會在緩存中存儲大量的空值,這顯然也會給緩存帶來一定的浪費。為此可以增加一個判斷,就是判斷該鍵是否是一個常用的數據,如果是常用的,就將它也寫入緩存中,這樣就不會出現緩存穿透導致數據庫被頻繁訪問的情況了,如圖16-11所示。

圖16-11 解決緩存穿透問題
在使用緩存的過程中,我們往往還會設置超時時間,當數據超時的時候,就不能從緩存中讀取數據了,而是到數據庫中讀取。有些數據是熱點數據,例如我們最暢銷的產品,假如在高並發期間,這個產品和它的關聯信息在緩存中超時失效了,就會導致大量的請求訪問數據庫,給數據庫帶來很大的壓力,甚至可能導致數據庫宕機,類似這樣的情況,我們稱為緩存並發,如圖16-12所示。

圖16-12 緩存並發
為了防止出現緩存並發的情況,一般來說,我們可以采用以下幾種方式避免緩存並發。
- 限流:也就是防止過多的請求來訪問緩存系統,從而導致壓垮數據庫,例如使用Resilience4j進行限流,但是這會影響並發線程數量。
- 加鎖:對緩存數據加鎖,使得線程只能一條條地通過去訪問,而不能並發訪問,這樣就能避免緩存並發的現象,但是分布式鎖比較難以實現,所以一般來說我們不會考慮這個辦法。
- 錯峰失效:網站一般是在上網高峰期或者熱門商品搶購時,才會出現高並發現象,而這是有規律的,所以可以自己設置那些需要經常訪問的緩存,錯過這段時間失效,一般就不會出現緩存並發的現象了,這個做法的成本相對低,也容易實現,所以我比較推薦它。
上述我們談了緩存穿透和緩存並發,事實上,還有一種緩存雪崩,那什么是緩存雪崩呢?典型的情況是,我們在啟動系統的時候,一般會把最常用的數據放入緩存中,並且設置一個固定的超時時間,這便是我們常說的預熱數據,它有助於系統性能的提高。但是,因為設置了一個固定的超時時間,所以會導致在某個時間點有大量緩存的鍵值對數據超時,如果在這個時間點出現高並發,就會導致請求大量訪問數據庫,造成數據庫壓力過大,甚至宕機,這便是緩存雪崩,如圖16-13所示。

圖16-13 緩存雪崩
這里容易混淆的是緩存並發和緩存雪崩的概念,緩存並發是針對一個鍵值對來說的,而緩存雪崩是針對多個鍵值對在某個時間點同時超時來說的。一般來說,為了避免緩存雪崩,我們需要在預熱數據的時候,防止所有數據都在一個時間點上超時。為此,可以設置不同的超時時間,來避免多個鍵值對同時失效。例如,key1失效是1小時,key2是1.5小時、key3是30分鍾……這樣就能夠避免數據同時失效了。
16.3.3 緩存實踐的一些建議
對於緩存的使用,我們需要遵循一定的規則,避免一些沒有必要的麻煩。下面是我的一些建議。
- 對於采用了微服務架構的系統,建議緩存服務器只存儲某項業務的數據,不摻雜其他業務的數據,這樣可以避免業務數據的耦合。
- 對於存入緩存的預熱數據,盡量設置不同的超時時間,以避免同時超時引發緩存雪崩。
- 在使用緩存前,要判斷應不應該使用緩存。
- 對於大數據對象的緩存,應該考慮分而治之的辦法,化簡為零。
- 緩存會造成數據的不一致,也可能存在一定的失真,但是性能好,能夠支持高並發的訪問,所以多用於讀取數據,而對於更新數據,一定要以數據庫為基准,不要輕信緩存。
- 對於熱門數據,應該考慮錯峰失效,錯峰更新,避免出現緩存並發現象。
- 在需要大量操作Redis的時候,可以考慮采用流水線(pipeline)的方式,這樣可以在很大程度上提高傳輸的效率。
- 在讀數據的時候,先讀緩存再讀數據庫。在寫數據的時候,先寫數據庫再寫緩存。
有了這些良好的習慣,相信在使用分布式緩存的時候,會減少許多不必要的麻煩。
本文摘自《Spring Cloud微服務和分布式系統實踐》

國內流行的早期的微服務解決方案是阿里巴巴的Dubbo,但這是一個不完整的方案,當前Spring Cloud已成為業界流行的微服務搭建方案。因此,本書以講解Spring Cloud為主。
Pivotal團隊收集了各個企業成功的分布式組件,用Spring Boot的形式對其進行封裝,最終得到了Spring Cloud,簡化了開發者的工作。Spring Cloud當前主要是通過Netflix(網飛)公司的組件來實施微服務架構,但是因為Netflix的組件更新較慢(如Zuul 2.x版本經常不能如期發布,最后取消),並且只按自身企業需要進行更新(如Hystrix停止增加新功能),所以Spring Cloud有“去Netflix組件”的趨勢。不過,“去Netflix組件”也需要一定的時間,所以當前還是以Netflix組件為主,這也是本書的核心內容之一。從另外一個角度來看,組件的目的是完成分布式的某些功能,雖類別不同但思想相近,也就是“換湯不換葯”。因此,現在學了Netflix組件,即使將來不再使用,也可以吸收其思想和經驗,通過這些來對比將來需要學習的新組件,也是大有裨益的。
為什么還要講微服務之外的分布式系統的知識
在編寫本書的時候,我考慮了很久,除了Spring Cloud微服務的內容外,還要不要加入其他分布式系統的內容,如分布式發號機、分布式數據庫、分布式事務和緩存等。加入這些內容,本書似乎就沒有鮮明的特點了,內容會顯得有點雜;不加入這些內容,企業構建分布式系統的講解就會不全面。
反復思考之后,我最終決定將一些常用的分布式知識也納入本書進行討論。換一個角度來考慮,微服務作為分布式系統的一種,其自身也是為了簡化分布式系統的開發,滿足企業生產實踐的需要,同樣,加入這些知識的講解也是為了讓企業能更好地搭建網站,和微服務架構的目的是一致的