經過前幾天的學習,大家已經掌握了微服務相關技術的實際應用,能夠應對企業開發的要求了。不過大家都知道在IT領域往往都是面試造火箭,實際工作擰螺絲。
為了更好的應對面試,讓大家能拿到更高的offer,我們接下來就講講“造火箭”的事情。
接下來的內容主要包括以下幾方面:
Redis高級:
- Redis主從
- Redis哨兵
- Redis分片集群
- Redis數據結構
- Redis內存回收
- Redis緩存一致性
微服務高級:
- Eureka和Nacos對比
- Ribbon和SpringCloudLoadBalancer
- Hystix和Sentinel
- 限流算法
1.Redis主從
單節點Redis的并發能力是有上限的,要進一步提高Redis的并發能力,就需要搭建主從集群,實現讀寫分離。
1.1.主從集群結構
下圖就是一個簡單的Redis主從集群結構:
如圖所示,集群中有一個master節點、兩個slave節點(現在叫replica)。當我們通過Redis的Java客戶端訪問主從集群時,應該做好路由:
- 如果是寫操作,應該訪問master節點,master會自動將數據同步給兩個slave節點
- 如果是讀操作,建議訪問各個slave節點,從而分擔并發壓力
1.2.搭建主從集群
我們會在同一個虛擬機中利用3個Docker容器來搭建主從集群,容器信息如下:
容器名 | 角色 | IP | 映射端口 |
r1 | master | 192.168.150.101 | 7001 |
r2 | slave | 192.168.150.101 | 7002 |
r3 | slave | 192.168.150.101 | 7003 |
1.2.1.啟動多個Redis實例
我們利用課前資料提供的docker-compose文件來構建主從集群:
文件內容如下:
version: "3.2"services:r1:image: rediscontainer_name: r1network_mode: "host"entrypoint: ["redis-server", "--port", "7001"]r2:image: rediscontainer_name: r2network_mode: "host"entrypoint: ["redis-server", "--port", "7002"]r3:image: rediscontainer_name: r3network_mode: "host"entrypoint: ["redis-server", "--port", "7003"]
將其上傳至虛擬機的/root/redis
目錄下:
執行命令,運行集群:
docker compose up -d
結果:
查看docker容器,發現都正常啟動了:
由于采用的是host模式,我們看不到端口映射。不過能直接在宿主機通過ps命令查看到Redis進程:
1.2.2.建立集群
雖然我們啟動了3個Redis實例,但是它們并沒有形成主從關系。我們需要通過命令來配置主從關系:
# Redis5.0以前
slaveof <masterip> <masterport>
# Redis5.0以后
replicaof <masterip> <masterport>
有臨時和永久兩種模式:
- 永久生效:在redis.conf文件中利用
slaveof
命令指定master
節點 - 臨時生效:直接利用redis-cli控制臺輸入
slaveof
命令,指定master
節點
我們測試臨時模式,首先連接r2
,讓其以r1
為master
# 連接r2
docker exec -it r2 redis-cli -p 7002
# 認r1主,也就是7001
slaveof 192.168.150.101 7001
然后連接r3
,讓其以r1
為master
# 連接r3
docker exec -it r3 redis-cli -p 7003
# 認r1主,也就是7001
slaveof 192.168.150.101 7001
然后連接r1
,查看集群狀態:
# 連接r1
docker exec -it r1 redis-cli -p 7001
# 查看集群狀態
info replication
結果如下:
127.0.0.1:7001> info replication
# Replication
role:master
connected_slaves:2
slave0:ip=192.168.150.101,port=7002,state=online,offset=140,lag=1
slave1:ip=192.168.150.101,port=7003,state=online,offset=140,lag=1
master_failover_state:no-failover
master_replid:16d90568498908b322178ca12078114e6c518b86
master_replid2:0000000000000000000000000000000000000000
master_repl_offset:140
second_repl_offset:-1
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:1
repl_backlog_histlen:140
可以看到,當前節點r1:7001
的角色是master
,有兩個slave與其連接:
slave0
:port
是7002
,也就是r2
節點slave1
:port
是7003
,也就是r3
節點
1.2.3.測試
依次在r1
、r2
、r3
節點上執行下面命令:
set num 123get num
你會發現,只有在r1
這個節點上可以執行set
命令(寫操作),其它兩個節點只能執行get
命令(讀操作)。也就是說讀寫操作已經分離了。
1.3.主從同步原理
在剛才的主從測試中,我們發現r1
上寫入Redis的數據,在r2
和r3
上也能看到,這說明主從之間確實完成了數據同步。
那么這個同步是如何完成的呢?
1.3.1.全量同步
主從第一次建立連接時,會執行全量同步,將master節點的所有數據都拷貝給slave節點,流程:
這里有一個問題,master
如何得知salve
是否是第一次來同步呢??
有幾個概念,可以作為判斷依據:
Replication Id
:簡稱replid
,是數據集的標記,replid一致則是同一數據集。每個master
都有唯一的replid
,slave
則會繼承master
節點的replid
offset
:偏移量,隨著記錄在repl_baklog
中的數據增多而逐漸增大。slave
完成同步時也會記錄當前同步的offset
。如果slave
的offset
小于master
的offset
,說明slave
數據落后于master
,需要更新。
因此slave
做數據同步,必須向master
聲明自己的replication id
和offset
,master
才可以判斷到底需要同步哪些數據。
由于我們在執行slaveof
命令之前,所有redis節點都是master
,有自己的replid
和offset
。
當我們第一次執行slaveof
命令,與master
建立主從關系時,發送的replid
和offset
是自己的,與master
肯定不一致。
master
判斷發現slave
發送來的replid
與自己的不一致,說明這是一個全新的slave,就知道要做全量同步了。
master
會將自己的replid
和offset
都發送給這個slave
,slave
保存這些信息到本地。自此以后slave
的replid
就與master
一致了。
因此,master判斷一個節點是否是第一次同步的依據,就是看replid是否一致。流程如圖:
完整流程描述:
slave
節點請求增量同步master
節點判斷replid
,發現不一致,拒絕增量同步master
將完整內存數據生成RDB
,發送RDB
到slave
slave
清空本地數據,加載master
的RDB
master
將RDB
期間的命令記錄在repl_baklog
,并持續將log中的命令發送給slave
slave
執行接收到的命令,保持與master
之間的同步
來看下r1
節點的運行日志:
再看下r2
節點執行replicaof
命令時的日志:
與我們描述的完全一致。
1.3.2.增量同步
全量同步需要先做RDB,然后將RDB文件通過網絡傳輸個slave,成本太高了。因此除了第一次做全量同步,其它大多數時候slave與master都是做增量同步。
什么是增量同步?就是只更新slave與master存在差異的部分數據。如圖:
那么master怎么知道slave與自己的數據差異在哪里呢?
1.3.3.repl_baklog原理
master怎么知道slave與自己的數據差異在哪里呢?
這就要說到全量同步時的repl_baklog
文件了。這個文件是一個固定大小的數組,只不過數組是環形,也就是說角標到達數組末尾后,會再次從0開始讀寫,這樣數組頭部的數據就會被覆蓋。
repl_baklog
中會記錄Redis處理過的命令及offset
,包括master當前的offset
,和slave已經拷貝到的offset
:
slave與master的offset之間的差異,就是salve需要增量拷貝的數據了。
隨著不斷有數據寫入,master的offset逐漸變大,slave也不斷的拷貝,追趕master的offset:
直到數組被填滿:
此時,如果有新的數據寫入,就會覆蓋數組中的舊數據。不過,舊的數據只要是綠色的,說明是已經被同步到slave的數據,即便被覆蓋了也沒什么影響。因為未同步的僅僅是紅色部分:
但是,如果slave出現網絡阻塞,導致master的offset
遠遠超過了slave的offset
:
如果master繼續寫入新數據,master的offset
就會覆蓋repl_baklog
中舊的數據,直到將slave現在的offset
也覆蓋:
棕色框中的紅色部分,就是尚未同步,但是卻已經被覆蓋的數據。此時如果slave恢復,需要同步,卻發現自己的offset
都沒有了,無法完成增量同步了。只能做全量同步。
repl_baklog
大小有上限,寫滿后會覆蓋最早的數據。如果slave斷開時間過久,導致尚未備份的數據被覆蓋,則無法基于repl_baklog
做增量同步,只能再次全量同步。
1.4.主從同步優化
主從同步可以保證主從數據的一致性,非常重要。
可以從以下幾個方面來優化Redis主從就集群:
- 在master中配置
repl-diskless-sync yes
啟用無磁盤復制,避免全量同步時的磁盤IO。 - Redis單節點上的內存占用不要太大,減少RDB導致的過多磁盤IO
- 適當提高
repl_baklog
的大小,發現slave宕機時盡快實現故障恢復,盡可能避免全量同步 - 限制一個master上的slave節點數量,如果實在是太多slave,則可以采用
主-從-從
鏈式結構,減少master壓力
主-從-從
架構圖:
簡述全量同步和增量同步區別?
- 全量同步:master將完整內存數據生成RDB,發送RDB到slave。后續命令則記錄在repl_baklog,逐個發送給slave。
- 增量同步:slave提交自己的offset到master,master獲取repl_baklog中從offset之后的命令給slave
什么時候執行全量同步?
- slave節點第一次連接master節點時
- slave節點斷開時間太久,repl_baklog中的offset已經被覆蓋時
什么時候執行增量同步?
- slave節點斷開又恢復,并且在
repl_baklog
中能找到offset時
2.Redis哨兵
主從結構中master節點的作用非常重要,一旦故障就會導致集群不可用。那么有什么辦法能保證主從集群的高可用性呢?
2.1.哨兵工作原理
Redis提供了哨兵
(Sentinel
)機制來監控主從集群監控狀態,確保集群的高可用性。
2.1.1.哨兵作用
哨兵集群作用原理圖:
哨兵的作用如下:
- 狀態監控:
Sentinel
會不斷檢查您的master
和slave
是否按預期工作 - 故障恢復(failover):如果
master
故障,Sentinel
會將一個slave
提升為master
。當故障實例恢復后會成為slave
- 狀態通知:
Sentinel
充當Redis
客戶端的服務發現來源,當集群發生failover
時,會將最新集群信息推送給Redis
的客戶端
那么問題來了,Sentinel
怎么知道一個Redis節點是否宕機呢?
2.1.2.狀態監控
Sentinel
基于心跳機制監測服務狀態,每隔1秒向集群的每個節點發送ping命令,并通過實例的響應結果來做出判斷:
- 主觀下線(sdown):如果某sentinel節點發現某Redis節點未在規定時間響應,則認為該節點主觀下線。
- 客觀下線(odown):若超過指定數量(通過
quorum
設置)的sentinel都認為該節點主觀下線,則該節點客觀下線。quorum值最好超過Sentinel節點數量的一半,Sentinel節點數量至少3臺。
如圖:
一旦發現master故障,sentinel需要在salve中選擇一個作為新的master,選擇依據是這樣的:
- 首先會判斷slave節點與master節點斷開時間長短,如果超過
down-after-milliseconds * 10
則會排除該slave節點 - 然后判斷slave節點的
slave-priority
值,越小優先級越高,如果是0則永不參與選舉(默認都是1)。 - 如果
slave-prority
一樣,則判斷slave節點的offset
值,越大說明數據越新,優先級越高 - 最后是判斷slave節點的
run_id
大小,越小優先級越高(通過info server可以查看run_id
)。
對應的官方文檔如下:
https://redis.io/docs/management/sentinel/#replica-selection-and-priority
問題來了,當選出一個新的master后,該如何實現身份切換呢?
大概分為兩步:
- 在多個
sentinel
中選舉一個leader
- 由
leader
執行failover
2.1.3.選舉leader
首先,Sentinel集群要選出一個執行failover
的Sentinel節點,可以成為leader
。要成為leader
要滿足兩個條件:
- 最先獲得超過半數的投票
- 獲得的投票數不小于
quorum
值
而sentinel投票的原則有兩條:
- 優先投票給目前得票最多的
- 如果目前沒有任何節點的票,就投給自己
比如有3個sentinel節點,s1
、s2
、s3
,假如s2
先投票:
- 此時發現沒有任何人在投票,那就投給自己。
s2
得1票 - 接著
s1
和s3
開始投票,發現目前s2
票最多,于是也投給s2
,s2
得3票 s2
稱為leader
,開始故障轉移
不難看出,誰先投票,誰就會稱為leader,那什么時候會觸發投票呢?
答案是第一個確認master客觀下線的人會立刻發起投票,一定會成為leader。
OK,sentinel
找到leader
以后,該如何完成failover
呢?
2.1.4.failover
我們舉個例子,有一個集群,初始狀態下7001為master
,7002和7003為slave
:
假如master發生故障,slave1當選。則故障轉移的流程如下:
1)sentinel
給備選的slave1
節點發送slaveof no one
命令,讓該節點成為master
2)sentinel
給所有其它slave
發送slaveof 192.168.150.101 7002
命令,讓這些節點成為新master
,也就是7002
的slave
節點,開始從新的master
上同步數據。
3)最后,當故障節點恢復后會接收到哨兵信號,執行slaveof 192.168.150.101 7002
命令,成為slave
:
2.2.搭建哨兵集群
首先,我們停掉之前的redis集群:
# 老版本DockerCompose
docker-compose down# 新版本Docker
docker compose down
然后,我們找到課前資料提供的sentinel.conf文件:
其內容如下:
sentinel announce-ip "192.168.150.101"
sentinel monitor hmaster 192.168.150.101 7001 2
sentinel down-after-milliseconds hmaster 5000
sentinel failover-timeout hmaster 60000
說明:
sentinel announce-ip "192.168.150.101"
:聲明當前sentinel的ipsentinel monitor hmaster 192.168.150.101 7001 2
:指定集群的主節點信息
hmaster
:主節點名稱,自定義,任意寫192.168.150.101 7001
:主節點的ip和端口2
:認定master
下線時的quorum
值
sentinel down-after-milliseconds hmaster 5000
:聲明master節點超時多久后被標記下線sentinel failover-timeout hmaster 60000
:在第一次故障轉移失敗后多久再次重試
我們在虛擬機的/root/redis
目錄下新建3個文件夾:s1
、s2
、s3
:
將課前資料提供的sentinel.conf
文件分別拷貝一份到3個文件夾中。
接著修改docker-compose.yaml
文件,內容如下:
version: "3.2"services:r1:image: rediscontainer_name: r1network_mode: "host"entrypoint: ["redis-server", "--port", "7001"]r2:image: rediscontainer_name: r2network_mode: "host"entrypoint: ["redis-server", "--port", "7002", "--slaveof", "192.168.150.101", "7001"]r3:image: rediscontainer_name: r3network_mode: "host"entrypoint: ["redis-server", "--port", "7003", "--slaveof", "192.168.150.101", "7001"]s1:image: rediscontainer_name: s1volumes:- /root/redis/s1:/etc/redisnetwork_mode: "host"entrypoint: ["redis-sentinel", "/etc/redis/sentinel.conf", "--port", "27001"]s2:image: rediscontainer_name: s2volumes:- /root/redis/s2:/etc/redisnetwork_mode: "host"entrypoint: ["redis-sentinel", "/etc/redis/sentinel.conf", "--port", "27002"]s3:image: rediscontainer_name: s3volumes:- /root/redis/s3:/etc/redisnetwork_mode: "host"entrypoint: ["redis-sentinel", "/etc/redis/sentinel.conf", "--port", "27003"]
直接運行命令,啟動集群:
docker-compose up -d
運行結果:
我們以s1節點為例,查看其運行日志:
# Sentinel ID is 8e91bd24ea8e5eb2aee38f1cf796dcb26bb88acf
# +monitor master hmaster 192.168.150.101 7001 quorum 2
* +slave slave 192.168.150.101:7003 192.168.150.101 7003 @ hmaster 192.168.150.101 7001
* +sentinel sentinel 5bafeb97fc16a82b431c339f67b015a51dad5e4f 192.168.150.101 27002 @ hmaster 192.168.150.101 7001
* +sentinel sentinel 56546568a2f7977da36abd3d2d7324c6c3f06b8d 192.168.150.101 27003 @ hmaster 192.168.150.101 7001
* +slave slave 192.168.150.101:7002 192.168.150.101 7002 @ hmaster 192.168.150.101 7001
可以看到sentinel
已經聯系到了7001
這個節點,并且與其它幾個哨兵也建立了鏈接。哨兵信息如下:
27001
:Sentinel ID
是8e91bd24ea8e5eb2aee38f1cf796dcb26bb88acf
27002
:Sentinel ID
是5bafeb97fc16a82b431c339f67b015a51dad5e4f
27003
:Sentinel ID
是56546568a2f7977da36abd3d2d7324c6c3f06b8d
2.3.演示failover
接下來,我們演示一下當主節點故障時,哨兵是如何完成集群故障恢復(failover)的。
我們連接7001
這個master
節點,然后通過命令讓其休眠60秒,模擬宕機:
# 連接7001這個master節點,通過sleep模擬服務宕機,60秒后自動恢復
docker exec -it r1 redis-cli -p 7001 DEBUG sleep 60
稍微等待一段時間后,會發現sentinel節點觸發了failover
:
2.4.總結
Sentinel的三個作用是什么?
- 集群監控
- 故障恢復
- 狀態通知
Sentinel如何判斷一個redis實例是否健康?
- 每隔1秒發送一次ping命令,如果超過一定時間沒有相向則認為是主觀下線(
sdown
) - 如果大多數sentinel都認為實例主觀下線,則判定服務客觀下線(
odown
)
故障轉移步驟有哪些?
- 首先要在
sentinel
中選出一個leader
,由leader執行failover
- 選定一個
slave
作為新的master
,執行slaveof noone
,切換到master模式 - 然后讓所有節點都執行
slaveof
新master - 修改故障節點配置,添加
slaveof
新master
sentinel選舉leader的依據是什么?
- 票數超過sentinel節點數量1半
- 票數超過quorum數量
- 一般情況下最先發起failover的節點會當選
sentinel從slave中選取master的依據是什么?
- 首先會判斷slave節點與master節點斷開時間長短,如果超過
down-after-milliseconds
* 10
則會排除該slave節點 - 然后判斷slave節點的
slave-priority
值,越小優先級越高,如果是0則永不參與選舉(默認都是1)。 - 如果
slave-prority
一樣,則判斷slave節點的offset
值,越大說明數據越新,優先級越高 - 最后是判斷slave節點的
run_id
大小,越小優先級越高(通過info server可以查看run_id
)。
2.5.RedisTemplate連接哨兵集群(自學)
分為三步:
- 1)引入依賴
- 2)配置哨兵地址
- 3)配置讀寫分離
2.5.1.引入依賴
就是SpringDataRedis的依賴:
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
2.5.2.配置哨兵地址
連接哨兵集群與傳統單點模式不同,不再需要設置每一個redis的地址,而是直接指定哨兵地址:
spring:redis:sentinel:master: hmaster # 集群名nodes: # 哨兵地址列表- 192.168.150.101:27001- 192.168.150.101:27002- 192.168.150.101:27003
2.5.3.配置讀寫分離
最后,還要配置讀寫分離,讓java客戶端將寫請求發送到master節點,讀請求發送到slave節點。定義一個bean即可:
@Bean
public LettuceClientConfigurationBuilderCustomizer clientConfigurationBuilderCustomizer(){return clientConfigurationBuilder -> clientConfigurationBuilder.readFrom(ReadFrom.REPLICA_PREFERRED);
}
這個bean中配置的就是讀寫策略,包括四種:
MASTER
:從主節點讀取MASTER_PREFERRED
:優先從master
節點讀取,master
不可用才讀取slave
REPLICA
:從slave
節點讀取REPLICA_PREFERRED
:優先從slave
節點讀取,所有的slave
都不可用才讀取master
3.Redis分片集群
主從模式可以解決高可用、高并發讀的問題。但依然有兩個問題沒有解決:
- 海量數據存儲
- 高并發寫
要解決這兩個問題就需要用到分片集群了。分片的意思,就是把數據拆分存儲到不同節點,這樣整個集群的存儲數據量就更大了。
Redis分片集群的結構如圖:
分片集群特征:
- 集群中有多個master,每個master保存不同分片數據 ,解決海量數據存儲問題
- 每個master都可以有多個slave節點 ,確保高可用
- master之間通過ping監測彼此健康狀態 ,類似哨兵作用
- 客戶端請求可以訪問集群任意節點,最終都會被轉發到數據所在節點
3.1.搭建分片集群
Redis分片集群最少也需要3個master節點,由于我們的機器性能有限,我們只給每個master配置1個slave,形成最小的分片集群:
計劃部署的節點信息如下:
表格 還在加載中,請等待加載完成后再嘗試復制
3.1.1.集群配置
分片集群中的Redis節點必須開啟集群模式,一般在配置文件中添加下面參數:
port 7000
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 5000
appendonly yes
其中有3個我們沒見過的參數:
cluster-enabled
:是否開啟集群模式cluster-config-file
:集群模式的配置文件名稱,無需手動創建,由集群自動維護cluster-node-timeout
:集群中節點之間心跳超時時間
一般搭建部署集群肯定是給每個節點都配置上述參數,不過考慮到我們計劃用docker-compose
部署,因此可以直接在啟動命令中指定參數,偷個懶。
在虛擬機的/root
目錄下新建一個redis-cluster
目錄,然后在其中新建一個docker-compose.yaml
文件,內容如下:
version: "3.2"services:r1:image: rediscontainer_name: r1network_mode: "host"entrypoint: ["redis-server", "--port", "7001", "--cluster-enabled", "yes", "--cluster-config-file", "node.conf"]r2:image: rediscontainer_name: r2network_mode: "host"entrypoint: ["redis-server", "--port", "7002", "--cluster-enabled", "yes", "--cluster-config-file", "node.conf"]r3:image: rediscontainer_name: r3network_mode: "host"entrypoint: ["redis-server", "--port", "7003", "--cluster-enabled", "yes", "--cluster-config-file", "node.conf"]r4:image: rediscontainer_name: r4network_mode: "host"entrypoint: ["redis-server", "--port", "7004", "--cluster-enabled", "yes", "--cluster-config-file", "node.conf"]r5:image: rediscontainer_name: r5network_mode: "host"entrypoint: ["redis-server", "--port", "7005", "--cluster-enabled", "yes", "--cluster-config-file", "node.conf"]r6:image: rediscontainer_name: r6network_mode: "host"entrypoint: ["redis-server", "--port", "7006", "--cluster-enabled", "yes", "--cluster-config-file", "node.conf"]
注意:使用Docker部署Redis集群,network模式必須采用host
3.1.2.啟動集群
進入/root/redis-cluster
目錄,使用命令啟動redis:
docker-compose up -d
啟動成功,可以通過命令查看啟動進程:
ps -ef | grep redis
# 結果:
root 4822 4743 0 14:29 ? 00:00:02 redis-server *:7002 [cluster]
root 4827 4745 0 14:29 ? 00:00:01 redis-server *:7005 [cluster]
root 4897 4778 0 14:29 ? 00:00:01 redis-server *:7004 [cluster]
root 4903 4759 0 14:29 ? 00:00:01 redis-server *:7006 [cluster]
root 4905 4775 0 14:29 ? 00:00:02 redis-server *:7001 [cluster]
root 4912 4732 0 14:29 ? 00:00:01 redis-server *:7003 [cluster]
可以發現每個redis節點都以cluster模式運行。不過節點與節點之間并未建立連接。
接下來,我們使用命令創建集群:
# 進入任意節點容器
docker exec -it r1 bash
# 然后,執行命令
redis-cli --cluster create --cluster-replicas 1 \
192.168.150.101:7001 192.168.150.101:7002 192.168.150.101:7003 \
192.168.150.101:7004 192.168.150.101:7005 192.168.150.101:7006
命令說明:
redis-cli --cluster
:代表集群操作命令create
:代表是創建集群--cluster-replicas 1
:指定集群中每個master
的副本個數為1
- 此時
節點總數 ÷ (replicas + 1)
得到的就是master
的數量n
。因此節點列表中的前n
個節點就是master
,其它節點都是slave
節點,隨機分配到不同master
- 此時
輸入命令后控制臺會彈出下面的信息:
這里展示了集群中master
與slave
節點分配情況,并詢問你是否同意。節點信息如下:
7001
是master
,節點id
后6位是da134f
7002
是master
,節點id
后6位是862fa0
7003
是master
,節點id
后6位是ad5083
7004
是slave
,節點id
后6位是391f8b
,認ad5083
(7003)為master
7005
是slave
,節點id
后6位是e152cd
,認da134f
(7001)為master
7006
是slave
,節點id
后6位是4a018a
,認862fa0
(7002)為master
輸入yes
然后回車。會發現集群開始創建,并輸出下列信息:
接著,我們可以通過命令查看集群狀態:
redis-cli -p 7001 cluster nodes
結果:
3.2.散列插槽
數據要分片存儲到不同的Redis節點,肯定需要有分片的依據,這樣下次查詢的時候才能知道去哪個節點查詢。很多數據分片都會采用一致性hash算法。而Redis則是利用散列插槽(hash slot
)的方式實現數據分片。
詳見官方文檔:
https://redis.io/docs/management/scaling/#redis-cluster-101
在Redis集群中,共有16384個hash slots
,集群中的每一個master節點都會分配一定數量的hash slots
。具體的分配在集群創建時就已經指定了:
如圖中所示:
- Master[0],本例中就是7001節點,分配到的插槽是0~5460
- Master[1],本例中就是7002節點,分配到的插槽是5461~10922
- Master[2],本例中就是7003節點,分配到的插槽是10923~16383
當我們讀寫數據時,Redis基于CRC16
算法對key
做hash
運算,得到的結果與16384
取余,就計算出了這個key
的slot
值。然后到slot
所在的Redis節點執行讀寫操作。
不過hash slot
的計算也分兩種情況:
- 當
key
中包含{}
時,根據{}
之間的字符串計算hash slot
- 當
key
中不包含{}
時,則根據整個key
字符串計算hash slot
例如:
- key是
user
,則根據user
來計算hash slot - key是
user:{age}
,則根據age
來計算hash slot
我們來測試一下,先于7001
建立連接:
# 進入容器
docker exec -it r1 bash
# 進入redis-cli
redis-cli -p 7001
# 測試
set user jack
會發現報錯了:
提示我們MOVED 5474
,其實就是經過計算,得出user
這個key
的hash slot
是5474
,而5474
是在7002
節點,不能在7001
上寫入!!
說好的任意節點都可以讀寫呢?
這是因為我們連接的方式有問題,連接集群時,要加-c
參數:
# 通過7001連接集群
redis-cli -c -p 7001
# 存入數據
set user jack
結果如下:
可以看到,客戶端自動跳轉到了5474
這個slot
所在的7002
節點。
現在,我們添加一個新的key,這次加上{}
:
# 試一下key中帶{}
set user:{age} 21# 再試一下key中不帶{}
set age 20
結果如下:
可以看到user:{age}
和age
計算出的slot
都是741
。
3.3.故障轉移
分片集群的節點之間會互相通過ping的方式做心跳檢測,超時未回應的節點會被標記為下線狀態。當發現master下線時,會將這個master的某個slave提升為master。
我們先打開一個控制臺窗口,利用命令監測集群狀態:
watch docker exec -it r1 redis-cli -p 7001 cluster nodes
命令前面的watch可以每隔一段時間刷新執行結果,方便我們實時監控集群狀態變化。
接著,我們故技重施,利用命令讓某個master節點休眠。比如這里我們讓7002
節點休眠,打開一個新的ssh控制臺,輸入下面命令:
docker exec -it r2 redis-cli -p 7002 DEBUG sleep 30
可以觀察到,集群發現7002宕機,標記為下線:
過了一段時間后,7002原本的小弟7006變成了master
:
而7002被標記為slave
,而且其master
正好是7006,主從地位互換。
3.4.總結
Redis分片集群如何判斷某個key應該在哪個實例?
- 將16384個插槽分配到不同的實例
- 根據key計算哈希值,對16384取余
- 余數作為插槽,尋找插槽所在實例即可
如何將同一類數據固定的保存在同一個Redis實例?
- Redis計算key的插槽值時會判斷key中是否包含
{}
,如果有則基于{}
內的字符計算插槽 - 數據的key中可以加入
{類型}
,例如key都以{typeId}
為前綴,這樣同類型數據計算的插槽一定相同
3.5.Java客戶端連接分片集群(選學)
RedisTemplate底層同樣基于lettuce實現了分片集群的支持,而使用的步驟與哨兵模式基本一致,參考2.5節
:
1)引入redis的starter依賴
2)配置分片集群地址
3)配置讀寫分離
與哨兵模式相比,其中只有分片集群的配置方式略有差異,如下:
spring:redis:cluster:nodes:- 192.168.150.101:7001- 192.168.150.101:7002- 192.168.150.101:7003- 192.168.150.101:8001- 192.168.150.101:8002- 192.168.150.101:8003
4.Redis數據結構
我們常用的Redis數據類型有5種,分別是:
- String
- List
- Set
- SortedSet
- Hash
還有一些高級數據類型,比如Bitmap、HyperLogLog、GEO等,其底層都是基于上述5種基本數據類型。因此在Redis的源碼中,其實只有5種數據類型。
4.1.RedisObject
不管是任何一種數據類型,最終都會封裝為RedisObject格式,它是一種結構體,C語言中的一種結構,可以理解為Java中的類。
結構大概是這樣的:
可以看到整個結構體中并不包含真實的數據,僅僅是對象頭信息,內存占用的大小為4+4+24+32+64 = 128bit
也就是16字節,然后指針ptr
指針指向的才是真實數據存儲的內存地址。所以RedisObject的內存開銷是很大的。
屬性中的encoding
就是當前對象底層采用的數據結構或編碼方式,可選的有11種之多:
編號 | 編碼方式 | 說明 |
0 | OBJ_ENCODING_RAW | raw編碼動態字符串 |
1 | OBJ_ENCODING_INT | long類型的整數的字符串 |
2 | OBJ_ENCODING_HT | hash表(也叫dict) |
3 | OBJ_ENCODING_ZIPMAP | 已廢棄 |
4 | OBJ_ENCODING_LINKEDLIST | 雙端鏈表 |
5 | OBJ_ENCODING_ZIPLIST | 壓縮列表 |
6 | OBJ_ENCODING_INTSET | 整數集合 |
7 | OBJ_ENCODING_SKIPLIST | 跳表 |
8 | OBJ_ENCODING_EMBSTR | embstr編碼的動態字符串 |
9 | OBJ_ENCODING_QUICKLIST | 快速列表 |
10 | OBJ_ENCODING_STREAM | Stream流 |
11 | OBJ_ENCODING_LISTPACK | 緊湊列表 |
Redis中的5種不同的數據類型采用的底層數據結構和編碼方式如下:
數據類型 | 編碼方式 |
STRING |
|
LIST |
|
SET |
|
ZSET |
|
HASH |
|
這些數據類型比較復雜,我們重點講解幾個面試會問的,其它的大家可以查看黑馬程序員發布的Redis專業課程:
暫時無法在飛書文檔外展示此內容
4.2.SkipList
SkipList(跳表)首先是鏈表,但與傳統鏈表相比有幾點差異:
- 元素按照升序排列存儲
- 節點可能包含多個指針,指針跨度不同。
傳統鏈表只有指向前后元素的指針,因此只能順序依次訪問。如果查找的元素在鏈表中間,查詢的效率會比較低。而SkipList則不同,它內部包含跨度不同的多級指針,可以讓我們跳躍查找鏈表中間的元素,效率非常高。
其結構如圖:
我們可以看到1號元素就有指向3、5、10的多個指針,查詢時就可以跳躍查找。例如我們要找大小為14的元素,查找的流程是這樣的:
- 首先找元素1節點最高級指針,也就是4級指針,起始元素大小為1,指針跨度為9,可以判斷出目標元素大小為10。由于14比10大,肯定要從10這個元素向下接著找。
- 找到10這個元素,發現10這個元素的最高級指針跨度為5,判斷出目標元素大小為15,大于14,需要判斷下級指針
- 10這個元素的2級指針跨度為3,判斷出目標元素為13,小于14,因此要基于元素13接著找
- 13這個元素最高級級指針跨度為2,判斷出目標元素為15,比14大,需要判斷下級指針。
- 13的下級指針跨度為1,因此目標元素是14,剛好于目標一致,找到。
這種多級指針的查詢方式就避免了傳統鏈表的逐個遍歷導致的查詢效率下降問題。在對有序數據做隨機查詢和排序時效率非常高。
跳表的結構體如下:
typedef struct zskiplist {// 頭尾節點指針struct zskiplistNode *header, *tail;// 節點數量unsigned long length;// 最大的索引層級int level;
} zskiplist;
可以看到SkipList主要屬性是header和tail,也就是頭尾指針,因此它是支持雙向遍歷的。
跳表中節點的結構體如下:
typedef struct zskiplistNode {sds ele; // 節點存儲的字符串double score;// 節點分數,排序、查找用struct zskiplistNode *backward; // 前一個節點指針struct zskiplistLevel {struct zskiplistNode *forward; // 下一個節點指針unsigned long span; // 索引跨度} level[]; // 多級索引數組
} zskiplistNode;
每個節點中都包含ele和score兩個屬性,其中score是得分,也就是節點排序的依據。ele則是節點存儲的字符串數據指針。
其內存結構如下:
4.3.SortedSet
面試題:Redis的SortedSet
底層的數據結構是怎樣的?
答:SortedSet是有序集合,底層的存儲的每個數據都包含element和score兩個值。score是得分,element則是字符串值。SortedSet會根據每個element的score值排序,形成有序集合。
它支持的操作很多,比如:
- 根據element查詢score值
- 按照score值升序或降序查詢element
要實現根據element查詢對應的score值,就必須實現element與score之間的鍵值映射。SortedSet底層是基于HashTable來實現的。
要實現對score值排序,并且查詢效率還高,就需要有一種高效的有序數據結構,SortedSet是基于跳表實現的。
加分項:因為SortedSet底層需要用到兩種數據結構,對內存占用比較高。因此Redis底層會對SortedSet中的元素大小做判斷。如果元素大小小于128且每個元素都小于64字節,SortedSet底層會采用ZipList,也就是壓縮列表來代替HashTable和SkipList
不過,ZipList
存在連鎖更新問題,因此而在Redis7.0版本以后,ZipList
又被替換為Listpack(緊湊列表)。
Redis源碼中zset
,也就是SortedSet
的結構體如下:
typedef struct zset {dict *dict; // dict,底層就是HashTablezskiplist *zsl; // 跳表
} zset;
其內存結構如圖:
5.Redis內存回收
Redis之所以性能強,最主要的原因就是基于內存存儲。然而單節點的Redis其內存大小不宜過大,會影響持久化或主從同步性能。
我們可以通過修改redis.conf文件,添加下面的配置來配置Redis的最大內存:
maxmemory 1gb
當內存達到上限,就無法存儲更多數據了。因此,Redis內部會有兩套內存回收的策略:
- 內存過期策略
- 內存淘汰策略
5.1.內存過期處理
存入Redis中的數據可以配置過期時間,到期后再次訪問會發現這些數據都不存在了,也就是被過期清理了。
5.1.1.過期命令
Redis中通過expire
命令可以給KEY設置TTL
(過期時間),例如:
# 寫入一條數據
set num 123
# 設置20秒過期時間
expire num 20
不過set命令本身也可以支持過期時間的設置:
# 寫入一條數據并設置20s過期時間
set num EX 20
當過期時間到了以后,再去查詢數據,會發現數據已經不存在。
5.1.2.過期策略
那么問題來了:
- Redis如何判斷一個KEY是否過期呢?
- Redis又是何時刪除過期KEY的呢?
Redis不管有多少種數據類型,本質是一個KEY-VALUE
的鍵值型數據庫,而這種鍵值映射底層正式基于HashTable來實現的,在Redis中叫做Dict.
來看下RedisDB的底層源碼:
typedef struct redisDb {dict dict; / The keyspace for this DB , 也就是存放KEY和VALUE的哈希表*/dict *expires; /* 同樣是哈希表,但保存的是設置了TTL的KEY,及其到期時間*/dict *blocking_keys; /* Keys with clients waiting for data (BLPOP)*/dict *ready_keys; /* Blocked keys that received a PUSH */dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS /int id; / Database ID, 0 ~ 15 /long long avg_ttl; / Average TTL, just for stats /unsigned long expires_cursor; / Cursor of the active expire cycle. */list *defrag_later; /* List of key names to attempt to defrag one by one, gradually. */
} redisDb;
現在回答第一個問題:
面試題:Redis如何判斷KEY是否過期呢?
答:在Redis中會有兩個Dict,也就是HashTable,其中一個記錄KEY-VALUE鍵值對,另一個記錄KEY和過期時間。要判斷一個KEY是否過期,只需要到記錄過期時間的Dict中根據KEY查詢即可。
Redis是何時刪除過期KEY的呢?
Redis并不會在KEY過期時立刻刪除KEY,因為要實現這樣的效果就必須給每一個過期的KEY設置時鐘,并監控這些KEY的過期狀態。無論對CPU還是內存都會帶來極大的負擔。
Redis的過期KEY刪除策略有兩種:
- 惰性刪除
- 周期刪除
惰性刪除,顧明思議就是過期后不會立刻刪除。那在什么時候刪除呢?
Redis會在每次訪問KEY的時候判斷當前KEY有沒有設置過期時間,如果有,過期時間是否已經到期。對應的源碼如下:
// db.c
// 尋找要執行寫操作的key
robj *lookupKeyWriteWithFlags(redisDb *db, robj *key, int flags) {// 檢查key是否過期,如果過期則刪除expireIfNeeded(db,key);return lookupKey(db,key,flags);
}// 尋找要執行讀操作的key
robj *lookupKeyReadWithFlags(redisDb *db, robj *key, int flags) {robj *val;// 檢查key是否過期,如果過期則刪除if (expireIfNeeded(db,key) == 1) {// 略 ...}val = lookupKey(db,key,flags);if (val == NULL)goto keymiss;server.stat_keyspace_hits++;return val;
}
周期刪除:顧明思議是通過一個定時任務,周期性的抽樣部分過期的key,然后執行刪除。
執行周期有兩種:
- SLOW模式:Redis會設置一個定時任務
serverCron()
,按照server.hz
的頻率來執行過期key清理 - FAST模式:Redis的每個事件循環前執行過期key清理(事件循環就是NIO事件處理的循環)。
SLOW模式規則:
- ① 執行頻率受
server.hz
影響,默認為10,即每秒執行10次,每個執行周期100ms。 - ② 執行清理耗時不超過一次執行周期的25%,即25ms.
- ③ 逐個遍歷db,逐個遍歷db中的bucket,抽取20個key判斷是否過期
- ④ 如果沒達到時間上限(25ms)并且過期key比例大于10%,再進行一次抽樣,否則結束
FAST模式規則(過期key比例小于10%不執行):
- ① 執行頻率受
beforeSleep()
調用頻率影響,但兩次FAST模式間隔不低于2ms - ② 執行清理耗時不超過1ms
- ③ 逐個遍歷db,逐個遍歷db中的bucket,抽取20個key判斷是否過期
- ④ 如果沒達到時間上限(1ms)并且過期key比例大于10%,再進行一次抽樣,否則結束
5.2.內存淘汰策略
對于某些特別依賴于Redis的項目而言,僅僅依靠過期KEY清理是不夠的,內存可能很快就達到上限。因此Redis允許設置內存告警閾值,當內存使用達到閾值時就會主動挑選部分KEY刪除以釋放更多內存。這叫做內存淘汰機制。
5.2.1.內存淘汰時機
那么問題來了,當內存達到閾值時執行內存淘汰,但問題是Redis什么時候會執去判斷內存是否達到預警呢?
Redis每次執行任何命令時,都會判斷內存是否達到閾值:
// server.c中處理命令的部分源碼
int processCommand(client *c) {// ... 略if (server.maxmemory && !server.lua_timedout) {// 調用performEvictions()方法嘗試進行內存淘汰int out_of_memory = (performEvictions() == EVICT_FAIL);// ... 略if (out_of_memory && reject_cmd_on_oom) {// 如果內存依然不足,直接拒絕命令rejectCommand(c, shared.oomerr);return C_OK;}}
}
5.2.2.淘汰策略
好了,知道什么時候嘗試淘汰了,那具體Redis是如何判斷該淘汰哪些Key
的呢?
Redis支持8種不同的內存淘汰策略:
noeviction
: 不淘汰任何key,但是內存滿時不允許寫入新數據,默認就是這種策略。volatile
-ttl
: 對設置了TTL的key,比較key的剩余TTL值,TTL越小越先被淘汰allkeys
-random
:對全體key ,隨機進行淘汰。也就是直接從db->dict中隨機挑選volatile-random
:對設置了TTL的key ,隨機進行淘汰。也就是從db->expires中隨機挑選。allkeys-lru
: 對全體key,基于LRU算法進行淘汰volatile-lru
: 對設置了TTL的key,基于LRU算法進行淘汰allkeys-lfu
: 對全體key,基于LFU算法進行淘汰volatile-lfu
: 對設置了TTL的key,基于LFI算法進行淘汰
比較容易混淆的有兩個算法:
- LRU(
L
east
R
ecently
U
sed
),最近最久未使用。用當前時間減去最后一次訪問時間,這個值越大則淘汰優先級越高。 - LFU(
L
east
F
requently
U
sed
),最少頻率使用。會統計每個key的訪問頻率,值越小淘汰優先級越高。
Redis怎么知道某個KEY的最近一次訪問時間
或者是訪問頻率
呢?
還記不記得之前講過的RedisObject的結構?
回憶一下:
其中的lru
就是記錄最近一次訪問時間和訪問頻率的。當然,你選擇LRU
和LFU
時的記錄方式不同:
- LRU:以秒為單位記錄最近一次訪問時間,長度24bit
- LFU:高16位以分鐘為單位記錄最近一次訪問時間,低8位記錄邏輯訪問次數
時間就不說了,那么邏輯訪問次數又是怎么回事呢?8位無符號數字最大才255,訪問次數超過255怎么辦?
這就要聊起Redis的邏輯訪問次數算法了,LFU的訪問次數之所以叫做邏輯訪問次數,是因為并不是每次key被訪問都計數,而是通過運算:
- ① 生成
[0,1)
之間的隨機數R
- ② 計算
1/(
舊次數
* lfu_log_factor + 1)
,記錄為P
,lfu_log_factor
默認為10 - ③ 如果
R
<P
,則計數器+1
,且最大不超過255 - ④ 訪問次數會隨時間衰減,距離上一次訪問時間每隔
lfu_decay_time
分鐘(默認1) ,計數器-1
顯然LFU的基于訪問頻率的統計更符合我們的淘汰目標,因此官方推薦使用LFU算法。
算法我們弄明白了,不過這里大家要注意一下:Redis中的KEY
可能有數百萬甚至更多,每個KEY都有自己訪問時間或者邏輯訪問次數。我們要找出時間最早的或者訪問次數最小的,難道要把Redis中所有數據排序?
要知道Redis的內存淘汰是在每次執行命令時處理的。如果每次執行命令都先對全量數據做內存排序,那命令的執行時長肯定會非常長,這是不現實的。
所以Redis采取的是抽樣法,即每次抽樣一定數量(maxmemory_smples
)的key,然后基于內存策略做排序,找出淘汰優先級最高的,刪除這個key。這就導致Redis的算法并不是真正的LRU,而是一種基于抽樣的近似LRU算法。
不過,在Redis3.0以后改進了這個算法,引入了一個淘汰候選池,抽樣的key要與候選池中的key比較淘汰優先級,優先級更高的才會被放入候選池。然后在候選池中找出優先級最高的淘汰掉,這就使算法的結果更接近與真正的LRU算法了。特別是在抽樣值較高的情況下(例如10),可以達到與真正的LRU接近的效果。
這也是官方給出的真正LRU與近似LRU的結果對比:
你可以在圖表中看到三種顏色的點形成三個不同的帶,每個點就是一個加入的KEY
。
- 淺灰色帶是被驅逐的對象
- 灰色帶是沒有被驅逐的對象
- 綠色帶是被添加的對象
5.3.總結
面試題:Redis如何判斷KEY是否過期呢?
答:在Redis中會有兩個Dict,也就是HashTable,其中一個記錄KEY-VALUE鍵值對,另一個記錄KEY和過期時間。要判斷一個KEY是否過期,只需要到記錄過期時間的Dict中根據KEY查詢即可。
面試題:Redis何時刪除過期KEY?如何刪除?
答:Redis的過期KEY處理有兩種策略,分別是惰性刪除和周期刪除。
惰性刪除是指在每次用戶訪問某個KEY時,判斷KEY的過期時間:如果過期則刪除;如果未過期則忽略。
周期刪除有兩種模式:
- SLOW模式:通過一個定時任務,定期的抽樣部分帶有TTL的KEY,判斷其是否過期。默認情況下定時任務的執行頻率是每秒10次,但每次執行不能超過25毫秒。如果執行抽樣后發現時間還有剩余,并且過期KEY的比例較高,則會多次抽樣。
- FAST模式:在Redis每次處理NIO事件之前,都會抽樣部分帶有TTL的KEY,判斷是否過期,因此執行頻率較高。但是每次執行時長不能超過1ms,如果時間充足并且過期KEY比例過高,也會多次抽樣
面試題:當Redis內存不足時會怎么做?
答:這取決于配置的內存淘汰策略,Redis支持很多種內存淘汰策略,例如LRU、LFU、Random. 但默認的策略是直接拒絕新的寫入請求。而如果設置了其它策略,則會在每次執行命令后判斷占用內存是否達到閾值。如果達到閾值則會基于配置的淘汰策略嘗試進行內存淘汰,直到占用內存小于閾值為止。
面試題:那你能聊聊LRU和LFU嗎?
答:LRU
是最近最久未使用。Redis的Key都是RedisObject,當啟用LRU算法后,Redis會在Key的頭信息中使用24個bit記錄每個key的最近一次使用的時間lru
。每次需要內存淘汰時,就會抽樣一部分KEY,找出其中空閑時間最長的,也就是now - lru
結果最大的,然后將其刪除。如果內存依然不足,就重復這個過程。
由于采用了抽樣來計算,這種算法只能說是一種近似LRU算法。因此在Redis4.0以后又引入了LFU
算法,這種算法是統計最近最少使用,也就是按key的訪問頻率來統計。當啟用LFU算法后,Redis會在key的頭信息中使用24bit記錄最近一次使用時間和邏輯訪問頻率。其中高16位是以分鐘為單位的最近訪問時間,后8位是邏輯訪問次數。與LFU類似,每次需要內存淘汰時,就會抽樣一部分KEY,找出其中邏輯訪問次數最小的,將其淘汰。
面試題:邏輯訪問次數是如何計算的?
答:由于記錄訪問次數的只有8bit
,即便是無符號數,最大值只有255,不可能記錄真實的訪問次數。因此Redis統計的其實是邏輯訪問次數。這其中有一個計算公式,會根據當前的訪問次數做計算,結果要么是次數+1
,要么是次數不變。但隨著當前訪問次數越大,+1
的概率也會越低,并且最大值不超過255.
除此以外,邏輯訪問次數還有一個衰減周期,默認為1分鐘,即每隔1分鐘邏輯訪問次數會-1
。這樣邏輯訪問次數就能基本反映出一個key
的訪問熱度了。
6.緩存問題
Redis經常被用作緩存,而緩存在使用的過程中存在很多問題需要解決。例如:
- 緩存的數據一致性問題
- 緩存擊穿
- 緩存穿透
- 緩存雪崩
6.1.緩存一致性
我們先看下目前企業用的最多的緩存模型。緩存的通用模型有三種:
Cache Aside
:有緩存調用者自己維護數據庫與緩存的一致性。即:
- 查詢時:命中則直接返回,未命中則查詢數據庫并寫入緩存
- 更新時:更新數據庫并刪除緩存,查詢時自然會更新緩存
Read/Write Through
:數據庫自己維護一份緩存,底層實現對調用者透明。底層實現:
- 查詢時:命中則直接返回,未命中則查詢數據庫并寫入緩存
- 更新時:判斷緩存是否存在,不存在直接更新數據庫。存在則更新緩存,同步更新數據庫
Write Behind Cahing
:讀寫操作都直接操作緩存,由線程異步的將緩存數據同步到數據庫
目前企業中使用最多的就是Cache Aside
模式,因為實現起來非常簡單。但缺點也很明顯,就是無法保證數據庫與緩存的強一致性。為什么呢?我們一起來分析一下。
Cache Aside
的寫操作是要在更新數據庫的同時刪除緩存,那為什么不選擇更新數據庫的同時更新緩存,而是刪除呢?
原因很簡單,假如一段時間內無人查詢,但是有多次更新,那這些更新都屬于無效更新。采用刪除方案也就是延遲更新,什么時候有人查詢了,什么時候更新。
那到底是先更新數據庫再刪除緩存,還是先刪除緩存再更新數據庫呢?
現在假設有兩個線程,一個來更新數據,一個來查詢數據。我們分別分析兩種策略的表現。
我們先分析策略1,先更新數據庫再刪除緩存:
正常情況
異常情況
異常情況說明:
- 線程1刪除緩存后,還沒來得及更新數據庫,
- 此時線程2來查詢,發現緩存未命中,于是查詢數據庫,寫入緩存。由于此時數據庫尚未更新,查詢的是舊數據。也就是說剛才的刪除白刪了,緩存又變成舊數據了。
- 然后線程1更新數據庫,此時數據庫是新數據,緩存是舊數據
由于更新數據庫的操作本身比較耗時,在期間有線程來查詢數據庫并更新緩存的概率非常高。因此不推薦這種方案。
再來看策略2,先更新數據庫再刪除緩存:
正常情況
異常情況
異常情況說明:
- 線程1查詢緩存未命中,于是去查詢數據庫,查詢到舊數據
- 線程1將數據寫入緩存之前,線程2來了,更新數據庫,刪除緩存
- 線程1執行寫入緩存的操作,寫入舊數據
可以發現,異常狀態發生的概率極為苛刻,線程1必須是查詢數據庫已經完成,但是緩存尚未寫入之前。線程2要完成更新數據庫同時刪除緩存的兩個操作。要知道線程1執行寫緩存的速度在毫秒之間,速度非常快,在這么短的時間要完成數據庫和緩存的操作,概率非常之低。
綜上,添加緩存的目的是為了提高系統性能,而你要付出的代價就是緩存與數據庫的強一致性。如果你要求數據庫與緩存的強一致,那就需要加鎖避免并行讀寫。但這就降低了性能,與緩存的目標背道而馳。
因此不管任何緩存同步方案最終的目的都是盡可能保證最終一致性,降低發生不一致的概率。我們采用先更新數據庫再刪除緩存的方案,已經將這種概率降到足夠低,目的已經達到了。
同時我們還要給緩存加上過期時間,一旦發生緩存不一致,當緩存過期后會重新加載,數據最終還是能保證一致。這就可以作為一個兜底方案。
6.2.緩存穿透
什么是緩存穿透呢?
我們知道,當請求查詢緩存未命中時,需要查詢數據庫以加載緩存。但是大家思考一下這樣的場景:
如果我訪問一個數據庫中也不存在的數據。會出現什么現象?
由于數據庫中不存在該數據,那么緩存中肯定也不存在。因此不管請求該數據多少次,緩存永遠不可能建立,請求永遠會直達數據庫。
假如有不懷好意的人,開啟很多線程頻繁的訪問一個數據庫中也不存在的數據。由于緩存不可能生效,那么所有的請求都訪問數據庫,可能就會導致數據庫因過高的壓力而宕機。
解決這個問題有兩種思路:
- 緩存空值
- 布隆過濾器
6.2.1.緩存空值
簡單來說,就是當我們發現請求的數據即不存在與緩存,也不存在與數據庫時,將空值緩存到Redis,避免頻繁查詢數據庫。實現思路如下:
優點:
- 實現簡單,維護方便
缺點:
- 額外的內存消耗
6.2.2.布隆過濾器
布隆過濾是一種數據統計的算法,用于檢索一個元素是否存在一個集合中。
一般我們判斷集合中是否存在元素,都會先把元素保存到類似于樹、哈希表等數據結構中,然后利用這些結構查詢效率高的特點來快速匹配判斷。但是隨著元素數量越來越多,這種模式對內存的占用也越來越大,檢索的速度也會越來越慢。而布隆過濾的內存占用小,查詢效率卻很高。
布隆過濾首先需要一個很長的bit數組,默認數組中每一位都是0.
然后還需要K
個hash
函數,將元素基于這些hash函數做運算的結果映射到bit數組的不同位置,并將這些位置置為1,例如現在k=3:
hello
經過運算得到3個角標:1、5、12world
經過運算得到3個角標:8、17、21java
經過運算得到3個角標:17、25、28
則需要將每個元素對應角標位置置為1:
此時,我們要判斷元素是否存在,只需要再次基于K
個hash
函數做運算, 得到K
個角標,判斷每個角標的位置是不是1:
- 只要全是1,就證明元素存在
- 任意位置為0,就證明元素一定不存在
假如某個元素本身并不存在,也沒添加到布隆過濾器過。但是由于存在hash碰撞的可能性,這就會出現這個元素計算出的角標已經被其它元素置為1的情況。那么這個元素也會被誤判為已經存在。
因此,布隆過濾器的判斷存在誤差:
- 當布隆過濾器認為元素不存在時,它肯定不存在
- 當布隆過濾器認為元素存在時,它可能存在,也可能不存在
當bit
數組越大、Hash
函數K
越復雜,K
越大時,這個誤判的概率也就越低。由于采用bit
數組來標示數據,即便4,294,967,296
個bit
位,也只占512mb
的空間
我們可以把數據庫中的數據利用布隆過濾器標記出來,當用戶請求緩存未命中時,先基于布隆過濾器判斷。如果不存在則直接拒絕請求,存在則去查詢數據庫。盡管布隆過濾存在誤差,但一般都在0.01%左右,可以大大減少數據庫壓力。
使用布隆過濾后的流程如下:
6.3.緩存雪崩
緩存雪崩是指在同一時段大量的緩存key同時失效或者Redis服務宕機,導致大量請求到達數據庫,帶來巨大壓力。
常見的解決方案有:
- 給不同的Key的TTL添加隨機值,這樣KEY的過期時間不同,不會大量KEY同時過期
- 利用Redis集群提高服務的可用性,避免緩存服務宕機
- 給緩存業務添加降級限流策略
- 給業務添加多級緩存,比如先查詢本地緩存,本地緩存未命中再查詢Redis,Redis未命中再查詢數據庫。即便Redis宕機,也還有本地緩存可以抗壓力
6.4.緩存擊穿
緩存擊穿問題也叫熱點Key問題,就是一個被高并發訪問并且緩存重建業務較復雜的key突然失效了,無數的請求訪問會在瞬間給數據庫帶來巨大的沖擊。
由于我們采用的是Cache Aside
模式,當緩存失效時需要下次查詢時才會更新緩存。當某個key緩存失效時,如果這個key是熱點key,并發訪問量比較高。就會在一瞬間涌入大量請求,都發現緩存未命中,于是都會去查詢數據庫,嘗試重建緩存。可能一瞬間就把數據庫壓垮了。
如上圖所示:
- 線程1發現緩存未命中,準備查詢數據庫,重建緩存,但是因為數據比較復雜,導致查詢數據庫耗時較久
- 在這個過程中,一下次來了3個新的線程,就都會發現緩存未命中,都去查詢數據庫
- 數據庫壓力激增
常見的解決方案有兩種:
- 互斥鎖:給重建緩存邏輯加鎖,避免多線程同時指向
- 邏輯過期:熱點key不要設置過期時間,在活動結束后手動刪除。
基于互斥鎖的方案如圖:
邏輯過期的思路如圖:
6.5.面試總結
面試題:如何保證緩存的雙寫一致性?
答:緩存的雙寫一致性很難保證強一致,只能盡可能降低不一致的概率,確保最終一致。我們項目中采用的是Cache Aside
模式。簡單來說,就是在更新數據庫之后刪除緩存;在查詢時先查詢緩存,如果未命中則查詢數據庫并寫入緩存。同時我們會給緩存設置過期時間作為兜底方案,如果真的出現了不一致的情況,也可以通過緩存過期來保證最終一致。
追問:為什么不采用延遲雙刪機制?
答:延遲雙刪的第一次刪除并沒有實際意義,第二次采用延遲刪除主要是解決數據庫主從同步的延遲問題,我認為這是數據庫主從的一致性問題,與緩存同步無關。既然主節點數據已經更新,Redis的緩存理應更新。而且延遲雙刪會增加緩存業務復雜度,也沒能完全避免緩存一致性問題,投入回報比太低。
面試題:如何解決緩存穿透問題?
答:緩存穿透也可以說是穿透攻擊,具體來說是因為請求訪問到了數據庫不存在的值,這樣緩存無法命中,必然訪問數據庫。如果高并發的訪問這樣的接口,會給數據庫帶來巨大壓力。
我們項目中都是基于布隆過濾器來解決緩存穿透問題的,當緩存未命中時基于布隆過濾器判斷數據是否存在。如果不存在則不去訪問數據庫。
當然,也可以使用緩存空值的方式解決,不過這種方案比較浪費內存。
面試題:如何解決緩存雪崩問題?
答:緩存雪崩的常見原因有兩個,第一是因為大量key同時過期。針對問這個題我們可以可以給緩存key設置不同的TTL值,避免key同時過期。
第二個原因是Redis宕機導致緩存不可用。針對這個問題我們可以利用集群提高Redis的可用性。也可以添加多級緩存,當Redis宕機時還有本地緩存可用。
面試題:如何解決緩存擊穿問題?
答:緩存擊穿往往是由熱點Key引起的,當熱點Key過期時,大量請求涌入同時查詢,發現緩存未命中都會去訪問數據庫,導致數據庫壓力激增。解決這個問題的主要思路就是避免多線程并發去重建緩存,因此方案有兩種。
第一種是基于互斥鎖,當發現緩存未命中時需要先獲取互斥鎖,再重建緩存,緩存重建完成釋放鎖。這樣就可以保證緩存重建同一時刻只會有一個線程執行。不過這種做法會導致緩存重建時性能下降嚴重。
第二種是基于邏輯過期,也就是不給熱點Key設置過期時間,而是給數據添加一個過期時間的字段。這樣熱點Key就不會過期,緩存中永遠有數據。
查詢到數據時基于其中的過期時間判斷key是否過期,如果過期開啟獨立新線程異步的重建緩存,而查詢請求先返回舊數據即可。當然,這個過程也要加互斥鎖,但由于重建緩存是異步的,而且獲取鎖失敗也無需等待,而是返回舊數據,這樣性能幾乎不受影響。
需要注意的是,無論是采用哪種方式,在獲取互斥鎖后一定要再次判斷緩存是否命中,做dubbo check. 因為當你獲取鎖成功時,可能是在你之前有其它線程已經重建緩存了。