文章目錄
- 1. Redis
- 1.1 Redis可以用來做什么?
- 1.2 Redis和傳統的關系型數據庫有什么不同?
- 1.3 Redis有哪些數據類型?
- 1.4 Redis是單線程的,為什么還能這么快?
- 1.5 Redis在持久化時fork出一個子進程,這時已經有兩個進程了,怎么能說是單線程呢?
- 1.6 set和zset有什么區別?
- 1.7 說一下Redis中的watch命令
- 1.8 說說Redis中List結構的相關操作
- 1.9 你要如何設計Redis的過期時間?
- 1.10 Redis中,sexnx命令的返回值是什么,如何使用該命令實現分布式鎖?
- 1.11 說一說Redis的持久化策略
- 1.12 如何實現Redis的高可用?
- 1.13 Redis的主從同步是如何實現的?
- 1.14 Redis為什么存的快,內存斷電數據怎么恢復?
- 1.15 說一說Redis的緩存淘汰策略
- 1.16 請介紹一下Redis的過期策略
- 1.17 緩存穿透、緩存擊穿、緩存雪崩有什么區別,該如何解決?
- 1.18 如何保證緩存與數據庫的雙寫一致性?
- 1.19 請介紹Redis集群的實現方案
- 1.20 說一說Redis集群的分片機制
- 1.21 說一說Redis集群的應用和優劣勢
- 1.22 說一說hash類型底層的數據結構
- 1.23 介紹一下zset類型底層的數據結構
- 1.24 如何利用Redis實現分布式Session?
- 1.25 如何利用Redis實現一個分布式鎖?
- 1.26 說一說你對布隆過濾器的理解
- 1.27 多臺Redis抗高并發訪問該怎么設計?
- 1.28 如果并發量超過30萬,怎么設計Redis架構?
- 1.27 多臺Redis抗高并發訪問該怎么設計?
- 1.28 如果并發量超過30萬,怎么設計Redis架構?
1. Redis
1.1 Redis可以用來做什么?
參考答案
- Redis最常用來做緩存,是實現分布式緩存的首先中間件;
- Redis可以作為數據庫,實現諸如點贊、關注、排行等對性能要求極高的互聯網需求;
- Redis可以作為計算工具,能用很小的代價,統計諸如PV/UV、用戶在線天數等數據;
- Redis還有很多其他的使用場景,例如:可以實現分布式鎖,可以作為消息隊列使用。
1.2 Redis和傳統的關系型數據庫有什么不同?
參考答案
Redis是一種基于鍵值對的NoSQL數據庫,而鍵值對的值是由多種數據結構和算法組成的。Redis的數據都存儲于內存中,因此它的速度驚人,讀寫性能可達10萬/秒,遠超關系型數據庫。
關系型數據庫是基于二維數據表來存儲數據的,它的數據格式更為嚴謹,并支持關系查詢。關系型數據庫的數據存儲于磁盤上,可以存放海量的數據,但性能遠不如Redis。
1.3 Redis有哪些數據類型?
參考答案
- Redis支持5種核心的數據類型,分別是字符串、哈希、列表、集合、有序集合;
- Redis還提供了Bitmap、HyperLogLog、Geo類型,但這些類型都是基于上述核心數據類型實現的;
- Redis在5.0新增加了Streams數據類型,它是一個功能強大的、支持多播的、可持久化的消息隊列。
1.4 Redis是單線程的,為什么還能這么快?
參考答案
- 對服務端程序來說,線程切換和鎖通常是性能殺手,而單線程避免了線程切換和競爭所產生的消耗;
- Redis的大部分操作是在內存上完成的,這是它實現高性能的一個重要原因;
- Redis采用了IO多路復用機制,使其在網絡IO操作中能并發處理大量的客戶端請求,實現高吞吐率。
關于Redis的單線程架構實現,如下圖:
1.5 Redis在持久化時fork出一個子進程,這時已經有兩個進程了,怎么能說是單線程呢?
參考答案
Redis是單線程的,主要是指Redis的網絡IO和鍵值對讀寫是由一個線程來完成的。而Redis的其他功能,如持久化、異步刪除、集群數據同步等,則是依賴其他線程來執行的。所以,說Redis是單線程的只是一種習慣的說法,事實上它的底層不是單線程的。
1.6 set和zset有什么區別?
參考答案
set:
- 集合中的元素是無序、不可重復的,一個集合最多能存儲232-1個元素;
- 集合除了支持對元素的增刪改查之外,還支持對多個集合取交集、并集、差集。
zset:
- 有序集合保留了集合元素不能重復的特點;
- 有序集合會給每個元素設置一個分數,并以此作為排序的依據;
- 有序集合不能包含相同的元素,但是不同元素的分數可以相同。
1.7 說一下Redis中的watch命令
參考答案
很多時候,要確保事務中的數據沒有被其他客戶端修改才執行該事務。Redis提供了watch命令來解決這類問題,這是一種樂觀鎖的機制。客戶端通過watch命令,要求服務器對一個或多個key進行監視,如果在客戶端執行事務之前,這些key發生了變化,則服務器將拒絕執行客戶端提交的事務,并向它返回一個空值。
1.8 說說Redis中List結構的相關操作
參考答案
列表是線性有序的數據結構,它內部的元素是可以重復的,并且一個列表最多能存儲2^32-1個元素。列表包含如下的常用命令:
- lpush/rpush:從列表的左側/右側添加數據;
- lrange:指定索引范圍,并返回這個范圍內的數據;
- lindex:返回指定索引處的數據;
- lpop/rpop:從列表的左側/右側彈出一個數據;
- blpop/brpop:從列表的左側/右側彈出一個數據,若列表為空則進入阻塞狀態。
1.9 你要如何設計Redis的過期時間?
參考答案
- 熱點數據不設置過期時間,使其達到“物理”上的永不過期,可以避免緩存擊穿問題;
- 在設置過期時間時,可以附加一個隨機數,避免大量的key同時過期,導致緩存雪崩。
1.10 Redis中,sexnx命令的返回值是什么,如何使用該命令實現分布式鎖?
參考答案
setnx命令返回整數值,當返回1時表示設置值成果,當返回0時表示設置值失敗(key已存在)。
一般我們不建議直接使用setnx命令來實現分布式鎖,因為為了避免出現死鎖,我們要給鎖設置一個自動過期時間。而setnx命令和設置過期時間的命令不是原子的,可能加鎖成果而設置過期時間失敗,依然存在死鎖的隱患。對于這種情況,Redis改進了set命令,給它增加了nx選項,啟用該選項時set命令的效果就會setnx一樣了。
采用Redis實現分布式鎖,就是在Redis里存一份代表鎖的數據,通常用字符串即可。采用改進后的setnx命令(即set...nx...
命令)實現分布式鎖的思路,以及優化的過程如下:
加鎖:
第一版,這種方式的缺點是容易產生死鎖,因為客戶端有可能忘記解鎖,或者解鎖失敗。
setnx key value
第二版,給鎖增加了過期時間,避免出現死鎖。但這兩個命令不是原子的,第二步可能會失敗,依然無法避免死鎖問題。
setnx key value``expire key seconds
第三版,通過“set…nx…”命令,將加鎖、過期命令編排到一起,它們是原子操作了,可以避免死鎖。
set key value nx ex seconds
解鎖:
解鎖就是刪除代表鎖的那份數據。
del key
問題:
看起來已經很完美了,但實際上還有隱患,如下圖。進程A在任務沒有執行完畢時,鎖已經到期被釋放了。等進程A的任務執行結束后,它依然會嘗試釋放鎖,因為它的代碼邏輯就是任務結束后釋放鎖。但是,它的鎖早已自動釋放過了,它此時釋放的可能是其他線程的鎖。
想要解決這個問題,我們需要解決兩件事情:
- 在加鎖時就要給鎖設置一個標識,進程要記住這個標識。當進程解鎖的時候,要進行判斷,是自己持有的鎖才能釋放,否則不能釋放。可以為key賦一個隨機值,來充當進程的標識。
- 解鎖時要先判斷、再釋放,這兩步需要保證原子性,否則第二步失敗的話,就會出現死鎖。而獲取和刪除命令不是原子的,這就需要采用Lua腳本,通過Lua腳本將兩個命令編排在一起,而整個Lua腳本的執行是原子的。
按照以上思路,優化后的命令如下:
# 加鎖
set key random-value nx ex seconds # 解鎖
if redis.call("get",KEYS[1]) == ARGV[1] thenreturn redis.call("del",KEYS[1])
elsereturn 0
end
1.11 說一說Redis的持久化策略
參考答案
Redis支持RDB持久化、AOF持久化、RDB-AOF混合持久化這三種持久化方式。
RDB:
RDB(Redis Database)是Redis默認采用的持久化方式,它以快照的形式將進程數據持久化到硬盤中。RDB會創建一個經過壓縮的二進制文件,文件以“.rdb”結尾,內部存儲了各個數據庫的鍵值對數據等信息。RDB持久化的觸發方式有兩種:
- 手動觸發:通過SAVE或BGSAVE命令觸發RDB持久化操作,創建“.rdb”文件;
- 自動觸發:通過配置選項,讓服務器在滿足指定條件時自動執行BGSAVE命令。
其中,SAVE命令執行期間,Redis服務器將阻塞,直到“.rdb”文件創建完畢為止。而BGSAVE命令是異步版本的SAVE命令,它會使用Redis服務器進程的子進程,創建“.rdb”文件。BGSAVE命令在創建子進程時會存在短暫的阻塞,之后服務器便可以繼續處理其他客戶端的請求。總之,BGSAVE命令是針對SAVE阻塞問題做的優化,Redis內部所有涉及RDB的操作都采用BGSAVE的方式,而SAVE命令已經廢棄!
BGSAVE命令的執行流程,如下圖:
BGSAVE命令的原理,如下圖:
RDB持久化的優缺點如下:
-
優點:RDB生成緊湊壓縮的二進制文件,體積小,使用該文件恢復數據的速度非常快;
-
缺點:BGSAVE每次運行都要執行fork操作創建子進程,屬于重量級操作,不宜頻繁執行,
所以RDB持久化沒辦法做到實時的持久化。
AOF:
AOF(Append Only File),解決了數據持久化的實時性,是目前Redis持久化的主流方式。AOF以獨立日志的方式,記錄了每次寫入命令,重啟時再重新執行AOF文件中的命令來恢復數據。AOF的工作流程包括:命令寫入(append)、文件同步(sync)、文件重寫(rewrite)、重啟加載(load),如下圖:
AOF默認不開啟,需要修改配置項來啟用它:
appendonly yes # 啟用AOF
appendfilename "appendonly.aof" # 設置文件名
AOF以文本協議格式寫入命令,如:
*3\r\n$3\r\nset\r\n$5\r\nhello\r\n$5\r\nworld\r\n
文本協議格式具有如下的優點:
- 文本協議具有很好的兼容性;
- 直接采用文本協議格式,可以避免二次處理的開銷;
- 文本協議具有可讀性,方便直接修改和處理。
AOF持久化的文件同步機制:
為了提高程序的寫入性能,現代操作系統會把針對硬盤的多次寫操作優化為一次寫操作。
- 當程序調用write對文件寫入時,系統不會直接把書記寫入硬盤,而是先將數據寫入內存的緩沖區中;
- 當達到特定的時間周期或緩沖區寫滿時,系統才會執行flush操作,將緩沖區中的數據沖洗至硬盤中;
這種優化機制雖然提高了性能,但也給程序的寫入操作帶來了不確定性。
- 對于AOF這樣的持久化功能來說,沖洗機制將直接影響AOF持久化的安全性;
- 為了消除上述機制的不確定性,Redis向用戶提供了appendfsync選項,來控制系統沖洗AOF的頻率;
- Linux的glibc提供了fsync函數,可以將指定文件強制從緩沖區刷到硬盤,上述選項正是基于此函數。
appendfsync選項的取值和含義如下:
AOF持久化的優缺點如下:
- 優點:與RDB持久化可能丟失大量的數據相比,AOF持久化的安全性要高很多。通過使用everysec選項,用戶可以將數據丟失的時間窗口限制在1秒之內。
- 缺點:AOF文件存儲的是協議文本,它的體積要比二進制格式的”.rdb”文件大很多。AOF需要通過執行AOF文件中的命令來恢復數據庫,其恢復速度比RDB慢很多。AOF在進行重寫時也需要創建子進程,在數據庫體積較大時將占用大量資源,會導致服務器的短暫阻塞。
RDB-AOF混合持久化:
Redis從4.0開始引入RDB-AOF混合持久化模式,這種模式是基于AOF持久化構建而來的。用戶可以通過配置文件中的“aof-use-rdb-preamble yes”配置項開啟AOF混合持久化。Redis服務器在執行AOF重寫操作時,會按照如下原則處理數據:
- 像執行BGSAVE命令一樣,根據數據庫當前的狀態生成相應的RDB數據,并將其寫入AOF文件中;
- 對于重寫之后執行的Redis命令,則以協議文本的方式追加到AOF文件的末尾,即RDB數據之后。
通過使用RDB-AOF混合持久化,用戶可以同時獲得RDB持久化和AOF持久化的優點,服務器既可以通過AOF文件包含的RDB數據來實現快速的數據恢復操作,又可以通過AOF文件包含的AOF數據來將丟失數據的時間窗口限制在1s之內。
1.12 如何實現Redis的高可用?
參考答案
實現Redis的高可用,主要有哨兵和集群兩種方式。
哨兵:
Redis Sentinel(哨兵)是一個分布式架構,它包含若干個哨兵節點和數據節點。每個哨兵節點會對數據節點和其余的哨兵節點進行監控,當發現節點不可達時,會對節點做下線標識。如果被標識的是主節點,它就會與其他的哨兵節點進行協商,當多數哨兵節點都認為主節點不可達時,它們便會選舉出一個哨兵節點來完成自動故障轉移的工作,同時還會將這個變化實時地通知給應用方。整個過程是自動的,不需要人工介入,有效地解決了Redis的高可用問題!
一組哨兵可以監控一個主節點,也可以同時監控多個主節點,兩種情況的拓撲結構如下圖:
哨兵節點包含如下的特征:
- 哨兵節點會定期監控數據節點,其他哨兵節點是否可達;
- 哨兵節點會將故障轉移的結果通知給應用方;
- 哨兵節點可以將從節點晉升為主節點,并維護后續正確的主從關系;
- 哨兵模式下,客戶端連接的是哨兵節點集合,從中獲取主節點信息;
- 節點的故障判斷是由多個哨兵節點共同完成的,可有效地防止誤判;
- 哨兵節點集合是由多個哨兵節點組成的,即使個別哨兵節點不可用,整個集合依然是健壯的;
- 哨兵節點也是獨立的Redis節點,是特殊的Redis節點,它們不存儲數據,只支持部分命令。
集群:
Redis集群采用虛擬槽分區來實現數據分片,它把所有的鍵根據哈希函數映射到0-16383
整數槽內,計算公式為slot=CRC16(key)&16383
,每一個節點負責維護一部分槽以及槽所映射的鍵值數據。虛擬槽分區具有如下特點:
- 解耦數據和節點之間的關系,簡化了節點擴容和收縮的難度;
- 節點自身維護槽的映射關系,不需要客戶端或者代理服務維護槽分區元數據;
- 支持節點、槽、鍵之間的映射查詢,用于數據路由,在線伸縮等場景。
Redis集群中數據的分片邏輯如下圖:
1.13 Redis的主從同步是如何實現的?
參考答案
從2.8版本開始,Redis使用psync命令完成主從數據同步,同步過程分為全量復制和部分復制。全量復制一般用于初次復制的場景,部分復制則用于處理因網絡中斷等原因造成數據丟失的場景。psync命令需要以下參數的支持:
- 復制偏移量:主節點處理寫命令后,會把命令長度做累加記錄,從節點在接收到寫命令后,也會做累加記錄;從節點會每秒鐘上報一次自身的復制偏移量給主節點,而主節點則會保存從節點的復制偏移量。
- 積壓緩沖區:保存在主節點上的一個固定長度的隊列,默認大小為1M,當主節點有連接的從節點時被創建;主節點處理寫命令時,不但會把命令發送給從節點,還會寫入積壓緩沖區;緩沖區是先進先出的隊列,可以保存最近已復制的數據,用于部分復制和命令丟失的數據補救。
- 主節點運行ID:每個Redis節點啟動后,都會動態分配一個40位的十六進制字符串作為運行ID;如果使用IP和端口的方式標識主節點,那么主節點重啟變更了數據集(RDB/AOF),從節點再基于復制偏移量復制數據將是不安全的,因此當主節點的運行ID變化后,從節點將做全量復制。
psync命令的執行過程以及返回結果,如下圖:
全量復制的過程,如下圖:
部分復制的過程,如下圖:
1.14 Redis為什么存的快,內存斷電數據怎么恢復?
參考答案
Redis存的快是因為它的數據都存放在內存里,并且為了保證數據的安全性,Redis還提供了三種數據的持久化機制,即RDB持久化、AOF持久化、RDB-AOF混合持久化。若服務器斷電,那么我們可以利用持久化文件,對數據進行恢復。理論上來說,AOF/RDB-AOF持久化可以將丟失數據的窗口控制在1S之內。
1.15 說一說Redis的緩存淘汰策略
參考答案
當寫入數據將導致超出maxmemory限制時,Redis會采用maxmemory-policy所指定的策略進行數據淘汰,該策略一共包含如下8種選項:
策略 | 描述 | 版本 |
---|---|---|
noeviction | 直接返回錯誤; | |
volatile-ttl | 從設置了過期時間的鍵中,選擇過期時間最小的鍵,進行淘汰; | |
volatile-random | 從設置了過期時間的鍵中,隨機選擇鍵,進行淘汰; | |
volatile-lru | 從設置了過期時間的鍵中,使用LRU算法選擇鍵,進行淘汰; | |
volatile-lfu | 從設置了過期時間的鍵中,使用LFU算法選擇鍵,進行淘汰; | 4.0 |
allleys-random | 從所有的鍵中,隨機選擇鍵,進行淘汰; | |
allkeys-lru | 從所有的鍵中,使用LRU算法選擇鍵,進行淘汰; | |
allkeys-lfu | 從所有的鍵中,使用LFU算法選擇鍵,進行淘汰; | 4.0 |
其中,volatile前綴代表從設置了過期時間的鍵中淘汰數據,allkeys前綴代表從所有的鍵中淘汰數據。關于后綴,ttl代表選擇過期時間最小的鍵,random代表隨機選擇鍵,需要我們額外關注的是lru和lfu后綴,它們分別代表采用lru算法和lfu算法來淘汰數據。
LRU(Least Recently Used)是按照最近最少使用原則來篩選數據,即最不常用的數據會被篩選出來!
- 標準LRU:把所有的數據組成一個鏈表,表頭和表尾分別表示MRU和LRU端,即最常使用端和最少使用端。剛被訪問的數據會被移動到MRU端,而新增的數據也是剛被訪問的數據,也會被移動到MRU端。當鏈表的空間被占滿時,它會刪除LRU端的數據。
- 近似LRU:Redis會記錄每個數據的最近一次訪問的時間戳(LRU)。Redis執行寫入操作時,若發現內存超出maxmemory,就會執行一次近似LRU淘汰算法。近似LRU會隨機采樣N個key,然后淘汰掉最舊的key,若淘汰后內存依然超出限制,則繼續采樣淘汰。可以通過maxmemory_samples配置項,設置近似LRU每次采樣的數據個數,該配置項的默認值為5。
LRU算法的不足之處在于,若一個key很少被訪問,只是剛剛偶爾被訪問了一次,則它就被認為是熱點數據,短時間內不會被淘汰。
LFU算法正式用于解決上述問題,LFU(Least Frequently Used)是Redis4新增的淘汰策略,它根據key的最近訪問頻率進行淘汰。LFU在LRU的基礎上,為每個數據增加了一個計數器,來統計這個數據的訪問次數。當使用LFU策略淘汰數據時,首先會根據數據的訪問次數進行篩選,把訪問次數最低的數據淘汰出內存。如果兩個數據的訪問次數相同,LFU再比較這兩個數據的訪問時間,把訪問時間更早的數據淘汰出內存。
1.16 請介紹一下Redis的過期策略
參考答案
Redis支持如下兩種過期策略:
惰性刪除:客戶端訪問一個key的時候,Redis會先檢查它的過期時間,如果發現過期就立刻刪除這個key。
定期刪除:Redis會將設置了過期時間的key放到一個獨立的字典中,并對該字典進行每秒10次的過期掃描,
過期掃描不會遍歷字典中所有的key,而是采用了一種簡單的貪心策略。該策略的刪除邏輯如下:
- 從過期字典中隨機選擇20個key;
- 刪除這20個key中已過期的key;
- 如果已過期key的比例超過25%,則重復步驟1。
1.17 緩存穿透、緩存擊穿、緩存雪崩有什么區別,該如何解決?
參考答案
緩存穿透:
問題描述:
客戶端查詢根本不存在的數據,使得請求直達存儲層,導致其負載過大,甚至宕機。出現這種情況的原因,可能是業務層誤將緩存和庫中的數據刪除了,也可能是有人惡意攻擊,專門訪問庫中不存在的數據。
解決方案:
- 緩存空對象:存儲層未命中后,仍然將空值存入緩存層,客戶端再次訪問數據時,緩存層會直接返回空值。
- 布隆過濾器:將數據存入布隆過濾器,訪問緩存之前以過濾器攔截,若請求的數據不存在則直接返回空值。
緩存擊穿:
問題描述:
一份熱點數據,它的訪問量非常大。在其緩存失效的瞬間,大量請求直達存儲層,導致服務崩潰。
解決方案:
- 永不過期:熱點數據不設置過期時間,所以不會出現上述問題,這是“物理”上的永不過期。或者為每個數據設置邏輯過期時間,當發現該數據邏輯過期時,使用單獨的線程重建緩存。
- 加互斥鎖:對數據的訪問加互斥鎖,當一個線程訪問該數據時,其他線程只能等待。這個線程訪問過后,緩存中的數據將被重建,屆時其他線程就可以直接從緩存中取值。
緩存雪崩:
問題描述:
在某一時刻,緩存層無法繼續提供服務,導致所有的請求直達存儲層,造成數據庫宕機。可能是緩存中有大量數據同時過期,也可能是Redis節點發生故障,導致大量請求無法得到處理。
解決方案:
- 避免數據同時過期:設置過期時間時,附加一個隨機數,避免大量的key同時過期。
- 啟用降級和熔斷措施:在發生雪崩時,若應用訪問的不是核心數據,則直接返回預定義信息/空值/錯誤信息。或者在發生雪崩時,對于訪問緩存接口的請求,客戶端并不會把請求發給Redis,而是直接返回。
- 構建高可用的Redis服務:采用哨兵或集群模式,部署多個Redis實例,個別節點宕機,依然可以保持服務的整體可用。
1.18 如何保證緩存與數據庫的雙寫一致性?
參考答案
四種同步策略:
想要保證緩存與數據庫的雙寫一致,一共有4種方式,即4種同步策略:
- 先更新緩存,再更新數據庫;
- 先更新數據庫,再更新緩存;
- 先刪除緩存,再更新數據庫;
- 先更新數據庫,再刪除緩存。
從這4種同步策略中,我們需要作出比較的是:
- 更新緩存與刪除緩存哪種方式更合適?
- 應該先操作數據庫還是先操作緩存?
更新緩存還是刪除緩存:
下面,我們來分析一下,應該采用更新緩存還是刪除緩存的方式。
-
更新緩存
優點:每次數據變化都及時更新緩存,所以查詢時不容易出現未命中的情況。
缺點:更新緩存的消耗比較大。如果數據需要經過復雜的計算再寫入緩存,那么頻繁的更新緩存,就會影響服務器的性能。如果是寫入數據頻繁的業務場景,那么可能頻繁的更新緩存時,卻沒有業務讀取該數據。
-
刪除緩存
優點:操作簡單,無論更新操作是否復雜,都是將緩存中的數據直接刪除。
缺點:刪除緩存后,下一次查詢緩存會出現未命中,這時需要重新讀取一次數據庫。
從上面的比較來看,一般情況下,刪除緩存是更優的方案。
先操作數據庫還是緩存:
下面,我們再來分析一下,應該先操作數據庫還是先操作緩存。
首先,我們將先刪除緩存與先更新數據庫,在出現失敗時進行一個對比:
如上圖,是先刪除緩存再更新數據庫,在出現失敗時可能出現的問題:
- 進程A刪除緩存成功;
- 進程A更新數據庫失敗;
- 進程B從緩存中讀取數據;
- 由于緩存被刪,進程B無法從緩存中得到數據,進而從數據庫讀取數據;
- 進程B從數據庫成功獲取數據,然后將數據更新到了緩存。
最終,緩存和數據庫的數據是一致的,但仍然是舊的數據。而我們的期望是二者數據一致,并且是新的數據。
如上圖,是先更新數據庫再刪除緩存,在出現失敗時可能出現的問題:
- 進程A更新數據庫成功;
- 進程A刪除緩存失敗;
- 進程B讀取緩存成功,由于緩存刪除失敗,所以進程B讀取到的是舊的數據。
最終,緩存和數據庫的數據是不一致的。
經過上面的比較,我們發現在出現失敗的時候,是無法明確分辨出先刪緩存和先更新數據庫哪個方式更好,以為它們都存在問題。后面我們會進一步對這兩種方式進行比較,但是在這里我們先探討一下,上述場景出現的問題,應該如何解決呢?
實際上,無論上面我們采用哪種方式去同步緩存與數據庫,在第二步出現失敗的時候,都建議采用重試機制解決,因為最終我們是要解決掉這個錯誤的。而為了避免重試機制影響主要業務的執行,一般建議重試機制采用異步的方式執行,如下圖:
這里我們按照先更新數據庫,再刪除緩存的方式,來說明重試機制的主要步驟:
- 更新數據庫成功;
- 刪除緩存失敗;
- 將此數據加入消息隊列;
- 業務代碼消費這條消息;
- 業務代碼根據這條消息的內容,發起重試機制,即從緩存中刪除這條記錄。
好了,下面我們再將先刪緩存與先更新數據庫,在沒有出現失敗時進行對比:
如上圖,是先刪除緩存再更新數據庫,在沒有出現失敗時可能出現的問題:
- 進程A刪除緩存成功;
- 進程B讀取緩存失敗;
- 進程B讀取數據庫成功,得到舊的數據;
- 進程B將舊的數據成功地更新到了緩存;
- 進程A將新的數據成功地更新到數據庫。
可見,進程A的兩步操作均成功,但由于存在并發,在這兩步之間,進程B訪問了緩存。最終結果是,緩存中存儲了舊的數據,而數據庫中存儲了新的數據,二者數據不一致。
如上圖,是先更新數據庫再刪除緩存,再沒有出現失敗時可能出現的問題:
- 進程A更新數據庫成功;
- 進程B讀取緩存成功;
- 進程A更新數據庫成功。
可見,最終緩存與數據庫的數據是一致的,并且都是最新的數據。但進程B在這個過程里讀到了舊的數據,可能還有其他進程也像進程B一樣,在這兩步之間讀到了緩存中舊的數據,但因為這兩步的執行速度會比較快,所以影響不大。對于這兩步之后,其他進程再讀取緩存數據的時候,就不會出現類似于進程B的問題了。
最終結論:
經過對比你會發現,先更新數據庫、再刪除緩存是影響更小的方案。如果第二步出現失敗的情況,則可以采用重試機制解決問題。
擴展閱讀
延時雙刪
上面我們提到,如果是先刪緩存、再更新數據庫,在沒有出現失敗時可能會導致數據的不一致。如果在實際的應用中,出于某些考慮我們需要選擇這種方式,那有辦法解決這個問題嗎?答案是有的,那就是采用延時雙刪的策略,延時雙刪的基本思路如下:
- 刪除緩存;
- 更新數據庫;
- sleep N毫秒;
- 再次刪除緩存。
阻塞一段時間之后,再次刪除緩存,就可以把這個過程中緩存中不一致的數據刪除掉。而具體的時間,要評估你這項業務的大致時間,按照這個時間來設定即可。
采用讀寫分離的架構怎么辦?
如果數據庫采用的是讀寫分離的架構,那么又會出現新的問題,如下圖:
進程A先刪除緩存,再更新主數據庫,然后主庫將數據同步到從庫。而在主從數據庫同步之前,可能會有進程B訪問了緩存,發現數據不存在,進而它去訪問從庫獲取到舊的數據,然后同步到緩存。這樣,最終也會導致緩存與數據庫的數據不一致。這個問題的解決方案,依然是采用延時雙刪的策略,但是在評估延長時間的時候,要考慮到主從數據庫同步的時間。
第二次刪除失敗了怎么辦?
如果第二次刪除依然失敗,則可以增加重試的次數,但是這個次數要有限制,當超出一定的次數時,要采取報錯、記日志、發郵件提醒等措施。
1.19 請介紹Redis集群的實現方案
參考答案
Redis集群的分區方案:
Redis集群采用虛擬槽分區來實現數據分片,它把所有的鍵根據哈希函數映射到0-16383
整數槽內,計算公式為slot=CRC16(key)&16383
,每一個節點負責維護一部分槽以及槽所映射的鍵值數據。虛擬槽分區具有如下特點:
- 解耦數據和節點之間的關系,簡化了節點擴容和收縮的難度;
- 節點自身維護槽的映射關系,不需要客戶端或者代理服務維護槽分區元數據;
- 支持節點、槽、鍵之間的映射查詢,用于數據路由,在線伸縮等場景。
Redis集群中數據的分片邏輯如下圖:
Redis集群的功能限制:
Redis集群方案在擴展了Redis處理能力的同時,也帶來了一些使用上的限制:
- key批量操作支持有限。如mset、mget,目前只支持具有相同slot值的key執行批量操作。對于映射為不同slot值的key由于執行mset、mget等操作可能存在于多個節點上所以不被支持。
- key事務操作支持有限。同理只支持多key在同一節點上的事務操作,當多個key分布在不同的節點上時無法使用事務功能。
- key作為數據分區的最小粒度,因此不能將一個大的鍵值對象(如hash、list等)映射到不同的節點。
- 不支持多數據庫空間。單機下的Redis可以支持16個數據庫,集群模式下只能使用一個數據庫空間,即DB0。
- 復制結構只支持一層,從節點只能復制主節點,不支持嵌套樹狀復制結構。
Redis集群的通信方案:
在分布式存儲中需要提供維護節點元數據信息的機制,所謂元數據是指:節點負責哪些數據,是否出現故障等狀態信息。常見的元數據維護方式分為:集中式和P2P方式。
Redis集群采用P2P的Gossip(流言)協議,Gossip協議的工作原理就是節點彼此不斷通信交換信息,一段時間后所有的節點都會知道集群完整的信息,這種方式類似流言傳播。通信的大致過程如下:
- 集群中每個節點都會單獨開辟一個TCP通道,用于節點之間彼此通信,通信端口號在基礎端口號上加10000;
- 每個節點再固定周期內通過特定規則選擇幾個節點發送ping消息;
- 接收ping消息的節點用pong消息作為響應。
其中,Gossip協議的主要職責就是信息交換,而信息交換的載體就是節點彼此發送的Gossip消息,Gossip消息分為:meet消息、ping消息、pong消息、fail消息等。
- meet消息:用于通知新節點加入,消息發送者通知接受者加入到當前集群。meet消息通信正常完成后,接收節點會加入到集群中并進行周期性的ping、pong消息交換。
- ping消息:集群內交換最頻繁的消息,集群內每個節點每秒向多個其他節點發送ping消息,用于檢測節點是否在線和交換彼此狀態信息。ping消息封裝了自身節點和一部分其他節點的狀態數據。
- pong消息:當接收到meet、ping消息時,作為響應消息回復給發送方確認消息正常通信。pong消息內封裝了自身狀態數據,節點也可以向集群內廣播自身的pong消息來通知整個集群對自身狀態進行更新。
- fail消息:當節點判定集群內另一個節點下線時,會向集群內廣播一個fail消息,其他節點接收到fail消息之后把對應節點更新為下線狀態。
雖然Gossip協議的信息交換機制具有天然的分布式特性,但它是有成本的。因為Redis集群內部需要頻繁地進行節點信息交換,而ping/pong消息會攜帶當前節點和部分其他節點的狀態數據,勢必會加重帶寬和計算的負擔。所以,Redis集群的Gossip協議需要兼顧信息交換的實時性和成本的開銷。
- 集群里的每個節點默認每隔一秒鐘就會從已知節點列表中隨機選出五個節點,然后對這五個節點中最長時間沒有發送過PING消息的節點發送PING消息,以此來檢測被選中的節點是否在線。
- 如果節點A最后一次收到節點B發送的PONG消息的時間,距離當前時間已經超過了節點A的超時選項設置時長的一半(cluster-node-timeout/2),那么節點A也會向節點B發送PING消息,這可以防止節點A因為長時間沒有隨機選中節點B作為PING消息的發送對象而導致對節點B的信息更新滯后。
- 每個消息主要的數據占用:slots槽數組(2KB)和整個集群1/10的狀態數據(10個節點狀態數據約1KB)。
1.20 說一說Redis集群的分片機制
參考答案
Redis集群采用虛擬槽分區來實現數據分片,它把所有的鍵根據哈希函數映射到0-16383
整數槽內,計算公式為slot=CRC16(key)&16383
,每一個節點負責維護一部分槽以及槽所映射的鍵值數據。虛擬槽分區具有如下特點:
- 解耦數據和節點之間的關系,簡化了節點擴容和收縮的難度;
- 節點自身維護槽的映射關系,不需要客戶端或者代理服務維護槽分區元數據;
- 支持節點、槽、鍵之間的映射查詢,用于數據路由,在線伸縮等場景。
Redis集群中數據的分片邏輯如下圖:
1.21 說一說Redis集群的應用和優劣勢
參考答案
優勢:
Redis Cluster是Redis的分布式解決方案,在3.0版本正式推出,有效地解決了Redis分布式方面的需求。當遇到單機內存、并發、流量等瓶頸時,可以采用Cluster架構方案達到負載均衡的目的。
劣勢:
Redis集群方案在擴展了Redis處理能力的同時,也帶來了一些使用上的限制:
- key批量操作支持有限。如mset、mget,目前只支持具有相同slot值的key執行批量操作。對于映射為不同slot值的key由于執行mset、mget等操作可能存在于多個節點上所以不被支持。
- key事務操作支持有限。同理只支持多key在同一節點上的事務操作,當多個key分布在不同的節點上時無法使用事務功能。
- key作為數據分區的最小粒度,因此不能將一個大的鍵值對象(如hash、list等)映射到不同的節點。
- 不支持多數據庫空間。單機下的Redis可以支持16個數據庫,集群模式下只能使用一個數據庫空間,即DB0。
- 復制結構只支持一層,從節點只能復制主節點,不支持嵌套樹狀復制結構。
1.22 說一說hash類型底層的數據結構
參考答案
哈希對象有兩種編碼方案,當同時滿足以下條件時,哈希對象采用ziplist編碼,否則采用hashtable編碼:
- 哈希對象保存的鍵值對數量小于512個;
- 哈希對象保存的所有鍵值對中的鍵和值,其字符串長度都小于64字節。
其中,ziplist編碼采用壓縮列表作為底層實現,而hashtable編碼采用字典作為底層實現。
壓縮列表:
壓縮列表(ziplist),是Redis為了節約內存而設計的一種線性數據結構,它是由一系列具有特殊編碼的連續內存塊構成的。一個壓縮列表可以包含任意多個節點,每個節點可以保存一個字節數組或一個整數值。
壓縮列表的結構如下圖所示:
該結構當中的字段含義如下表所示:
屬性 | 類型 | 長度 | 說明 |
---|---|---|---|
zlbytes | uint32_t | 4字節 | 壓縮列表占用的內存字節數; |
zltail | uint32_t | 4字節 | 壓縮列表表尾節點距離列表起始地址的偏移量(單位字節); |
zllen | uint16_t | 2字節 | 壓縮列表包含的節點數量,等于UINT16_MAX時,需遍歷列表計算真實數量; |
entryX | 列表節點 | 不固定 | 壓縮列表包含的節點,節點的長度由節點所保存的內容決定; |
zlend | uint8_t | 1字節 | 壓縮列表的結尾標識,是一個固定值0xFF; |
其中,壓縮列表的節點由以下字段構成:
previous_entry_length(pel)屬性以字節為單位,記錄當前節點的前一節點的長度,其自身占據1字節或5字節:
- 如果前一節點的長度小于254字節,則“pel”屬性的長度為1字節,前一節點的長度就保存在這一個字節內;
- 如果前一節點的長度達到254字節,則“pel”屬性的長度為5字節,其中第一個字節被設置為0xFE,之后的四個字節用來保存前一節點的長度;
基于“pel”屬性,程序便可以通過指針運算,根據當前節點的起始地址計算出前一節點的起始地址,從而實現從表尾向表頭的遍歷操作。
content屬性負責保存節點的值(字節數組或整數),其類型和長度則由encoding屬性決定,它們的關系如下:
encoding | 長度 | content |
---|---|---|
00 xxxxxx | 1字節 | 最大長度為26 -1的字節數組; |
01 xxxxxx bbbbbbbb | 2字節 | 最大長度為214-1的字節數組; |
10 __ bbbbbbbb … … … | 5字節 | 最大長度為232-1的字節數組; |
11 000000 | 1字節 | int16_t類型的整數; |
11 010000 | 1字節 | int32_t類型的整數; |
11 100000 | 1字節 | int64_t類型的整數; |
11 110000 | 1字節 | 24位有符號整數; |
11 111110 | 1字節 | 8位有符號整數; |
11 11xxxx | 1字節 | 沒有content屬性,xxxx直接存[0,12]范圍的整數值; |
字典:
字典(dict)又稱為散列表,是一種用來存儲鍵值對的數據結構。C語言沒有內置這種數據結構,所以Redis構建了自己的字典實現。
Redis字典的實現主要涉及三個結構體:字典、哈希表、哈希表節點。其中,每個哈希表節點保存一個鍵值對,每個哈希表由多個哈希表節點構成,而字典則是對哈希表的進一步封裝。這三個結構體的關系如下圖所示:
其中,dict代表字典,dictht代表哈希表,dictEntry代表哈希表節點。可以看出,dictEntry是一個數組,這很好理解,因為一個哈希表里要包含多個哈希表節點。而dict里包含2個dictht,多出的哈希表用于REHASH。當哈希表保存的鍵值對數量過多或過少時,需要對哈希表的大小進行擴展或收縮操作,在Redis中,擴展和收縮哈希表是通過REHASH實現的,執行REHASH的大致步驟如下:
-
為字典的ht[1]哈希表分配內存空間
如果執行的是擴展操作,則ht[1]的大小為第1個大于等于ht[0].used*2的2n。如果執行的是收縮操作,則ht[1]的大小為第1個大于等于ht[0].used的2n。
-
將存儲在ht[0]中的數據遷移到ht[1]上
重新計算鍵的哈希值和索引值,然后將鍵值對放置到ht[1]哈希表的指定位置上。
-
將字典的ht[1]哈希表晉升為默認哈希表
遷移完成后,清空ht[0],再交換ht[0]和ht[1]的值,為下一次REHASH做準備。
當滿足以下任何一個條件時,程序會自動開始對哈希表執行擴展操作:
- 服務器目前沒有執行bgsave或bgrewriteof命令,并且哈希表的負載因子大于等于1;
- 服務器目前正在執行bgsave或bgrewriteof命令,并且哈希表的負載因子大于等于5。
為了避免REHASH對服務器性能造成影響,REHASH操作不是一次性地完成的,而是分多次、漸進式地完成的。漸進式REHASH的詳細過程如下:
- 為ht[1]分配空間,讓字典同時持有ht[0]和ht[1]兩個哈希表;
- 在字典中的索引計數器rehashidx設置為0,表示REHASH操作正式開始;
- 在REHASH期間,每次對字典執行添加、刪除、修改、查找操作時,程序除了執行指定的操作外,還會順帶將ht[0]中位于rehashidx上的所有鍵值對遷移到ht[1]中,再將rehashidx的值加1;
- 隨著字典不斷被訪問,最終在某個時刻,ht[0]上的所有鍵值對都被遷移到ht[1]上,此時程序將rehashidx屬性值設置為-1,標識REHASH操作完成。
REHSH期間,字典同時持有兩個哈希表,此時的訪問將按照如下原則處理:
- 新添加的鍵值對,一律被保存到ht[1]中;
- 刪除、修改、查找等其他操作,會在兩個哈希表上進行,即程序先嘗試去ht[0]中訪問要操作的數據,若不存在則到ht[1]中訪問,再對訪問到的數據做相應的處理。
1.23 介紹一下zset類型底層的數據結構
參考答案
有序集合對象有2種編碼方案,當同時滿足以下條件時,集合對象采用ziplist編碼,否則采用skiplist編碼:
- 有序集合保存的元素數量不超過128個;
- 有序集合保存的所有元素的成員長度都小于64字節。
其中,ziplist編碼的有序集合采用壓縮列表作為底層實現,skiplist編碼的有序集合采用zset結構作為底層實現。
其中,zset是一個復合結構,它的內部采用字典和跳躍表來實現,其源碼如下。其中,dict保存了從成員到分支的映射關系,zsl則按分值由小到大保存了所有的集合元素。這樣,當按照成員來訪問有序集合時可以直接從dict中取值,當按照分值的范圍訪問有序集合時可以直接從zsl中取值,采用了空間換時間的策略以提高訪問效率。
typedef struct zset {dict *dict; // 字典,保存了從成員到分值的映射關系;zskiplist *zsl; // 跳躍表,按分值由小到大保存所有集合元素;
} zset;
綜上,zset對象的底層數據結構包括:壓縮列表、字典、跳躍表。
壓縮列表:
壓縮列表(ziplist),是Redis為了節約內存而設計的一種線性數據結構,它是由一系列具有特殊編碼的連續內存塊構成的。一個壓縮列表可以包含任意多個節點,每個節點可以保存一個字節數組或一個整數值。
壓縮列表的結構如下圖所示:
該結構當中的字段含義如下表所示:
屬性 | 類型 | 長度 | 說明 |
---|---|---|---|
zlbytes | uint32_t | 4字節 | 壓縮列表占用的內存字節數; |
zltail | uint32_t | 4字節 | 壓縮列表表尾節點距離列表起始地址的偏移量(單位字節); |
zllen | uint16_t | 2字節 | 壓縮列表包含的節點數量,等于UINT16_MAX時,需遍歷列表計算真實數量; |
entryX | 列表節點 | 不固定 | 壓縮列表包含的節點,節點的長度由節點所保存的內容決定; |
zlend | uint8_t | 1字節 | 壓縮列表的結尾標識,是一個固定值0xFF; |
其中,壓縮列表的節點由以下字段構成:
previous_entry_length(pel)屬性以字節為單位,記錄當前節點的前一節點的長度,其自身占據1字節或5字節:
- 如果前一節點的長度小于254字節,則“pel”屬性的長度為1字節,前一節點的長度就保存在這一個字節內;
- 如果前一節點的長度達到254字節,則“pel”屬性的長度為5字節,其中第一個字節被設置為0xFE,之后的四個字節用來保存前一節點的長度;
基于“pel”屬性,程序便可以通過指針運算,根據當前節點的起始地址計算出前一節點的起始地址,從而實現從表尾向表頭的遍歷操作。
content屬性負責保存節點的值(字節數組或整數),其類型和長度則由encoding屬性決定,它們的關系如下:
encoding | 長度 | content |
---|---|---|
00 xxxxxx | 1字節 | 最大長度為26 -1的字節數組; |
01 xxxxxx bbbbbbbb | 2字節 | 最大長度為214-1的字節數組; |
10 __ bbbbbbbb … … … | 5字節 | 最大長度為232-1的字節數組; |
11 000000 | 1字節 | int16_t類型的整數; |
11 010000 | 1字節 | int32_t類型的整數; |
11 100000 | 1字節 | int64_t類型的整數; |
11 110000 | 1字節 | 24位有符號整數; |
11 111110 | 1字節 | 8位有符號整數; |
11 11xxxx | 1字節 | 沒有content屬性,xxxx直接存[0,12]范圍的整數值; |
字典:
字典(dict)又稱為散列表,是一種用來存儲鍵值對的數據結構。C語言沒有內置這種數據結構,所以Redis構建了自己的字典實現。
Redis字典的實現主要涉及三個結構體:字典、哈希表、哈希表節點。其中,每個哈希表節點保存一個鍵值對,每個哈希表由多個哈希表節點構成,而字典則是對哈希表的進一步封裝。這三個結構體的關系如下圖所示:
其中,dict代表字典,dictht代表哈希表,dictEntry代表哈希表節點。可以看出,dictEntry是一個數組,這很好理解,因為一個哈希表里要包含多個哈希表節點。而dict里包含2個dictht,多出的哈希表用于REHASH。當哈希表保存的鍵值對數量過多或過少時,需要對哈希表的大小進行擴展或收縮操作,在Redis中,擴展和收縮哈希表是通過REHASH實現的,執行REHASH的大致步驟如下:
-
為字典的ht[1]哈希表分配內存空間
如果執行的是擴展操作,則ht[1]的大小為第1個大于等于ht[0].used*2的2n。如果執行的是收縮操作,則ht[1]的大小為第1個大于等于ht[0].used的2n。
-
將存儲在ht[0]中的數據遷移到ht[1]上
重新計算鍵的哈希值和索引值,然后將鍵值對放置到ht[1]哈希表的指定位置上。
-
將字典的ht[1]哈希表晉升為默認哈希表
遷移完成后,清空ht[0],再交換ht[0]和ht[1]的值,為下一次REHASH做準備。
當滿足以下任何一個條件時,程序會自動開始對哈希表執行擴展操作:
- 服務器目前沒有執行bgsave或bgrewriteof命令,并且哈希表的負載因子大于等于1;
- 服務器目前正在執行bgsave或bgrewriteof命令,并且哈希表的負載因子大于等于5。
為了避免REHASH對服務器性能造成影響,REHASH操作不是一次性地完成的,而是分多次、漸進式地完成的。漸進式REHASH的詳細過程如下:
- 為ht[1]分配空間,讓字典同時持有ht[0]和ht[1]兩個哈希表;
- 在字典中的索引計數器rehashidx設置為0,表示REHASH操作正式開始;
- 在REHASH期間,每次對字典執行添加、刪除、修改、查找操作時,程序除了執行指定的操作外,還會順帶將ht[0]中位于rehashidx上的所有鍵值對遷移到ht[1]中,再將rehashidx的值加1;
- 隨著字典不斷被訪問,最終在某個時刻,ht[0]上的所有鍵值對都被遷移到ht[1]上,此時程序將rehashidx屬性值設置為-1,標識REHASH操作完成。
REHSH期間,字典同時持有兩個哈希表,此時的訪問將按照如下原則處理:
- 新添加的鍵值對,一律被保存到ht[1]中;
- 刪除、修改、查找等其他操作,會在兩個哈希表上進行,即程序先嘗試去ht[0]中訪問要操作的數據,若不存在則到ht[1]中訪問,再對訪問到的數據做相應的處理。
跳躍表:
跳躍表的查找復雜度為平均O(logN),最壞O(N),效率堪比紅黑樹,卻遠比紅黑樹實現簡單。跳躍表是在鏈表的基礎上,通過增加索引來提高查找效率的。
有序鏈表插入、刪除的復雜度為O(1),而查找的復雜度為O(N)。例:若要查找值為60的元素,需要從第1個元素依次向后比較,共需比較6次才行,如下圖:
跳躍表是從有序鏈表中選取部分節點,組成一個新鏈表,并以此作為原始鏈表的一級索引。再從一級索引中選取部分節點,組成一個新鏈表,并以此作為原始鏈表的二級索引。以此類推,可以有多級索引,如下圖:
跳躍表在查找時,優先從高層開始查找,若next節點值大于目標值,或next指針指向NULL,則從當前節點下降一層繼續向后查找,這樣便可以提高查找的效率了。
跳躍表的實現主要涉及2個結構體:zskiplist、zskiplistNode,它們的關系如下圖所示:
其中,藍色的表格代表zskiplist,紅色的表格代表zskiplistNode。zskiplist有指向頭尾節點的指針,以及列表的長度,列表中最高的層級。zskiplistNode的頭節點是空的,它不存儲任何真實的數據,它擁有最高的層級,但這個層級不記錄在zskiplist之內。
1.24 如何利用Redis實現分布式Session?
參考答案
在web開發中,我們會把用戶的登錄信息存儲在session里。而session是依賴于cookie的,即服務器創建session時會給它分配一個唯一的ID,并且在響應時創建一個cookie用于存儲這個SESSIONID。當客戶端收到這個cookie之后,就會自動保存這個SESSIONID,并且在下次訪問時自動攜帶這個SESSIONID,屆時服務器就可以通過這個SESSIONID得到與之對應的session,從而識別用戶的身。如下圖:
現在的互聯網應用,基本都是采用分布式部署方式,即將應用程序部署在多臺服務器上,并通過nginx做統一的請求分發。而服務器與服務器之間是隔離的,它們的session是不共享的,這就存在session同步的問題了,如下圖:
如果客戶端第一次訪問服務器,請求被分發到了服務器A上,則服務器A會為該客戶端創建session。如果客戶端再次訪問服務器,請求被分發到服務器B上,則由于服務器B中沒有這個session,所以用戶的身份無法得到驗證,從而產生了不一致的問題。
解決這個問題的辦法有很多,比如可以協調多個服務器,讓他們的session保持同步。也可以在分發請求時做綁定處理,即將某一個IP固定分配給同一個服務器。但這些方式都比較麻煩,而且性能上也有一定的消耗。更合理的方式就是采用類似于Redis這樣的高性能緩存服務器,來實現分布式session。
從上面的敘述可知,我們使用session保存用戶的身份信息,本質上是要做兩件事情。第一是保存用戶的身份信息,第二是驗證用戶的身份信息。如果利用其它手段實現這兩個目標,那么就可以不用session,或者說我們使用的是廣義上的session了。
具體實現的思路如下圖,我們在服務端增加兩段程序:
第一是創建令牌的程序,就是在用戶初次訪問服務器時,給它創建一個唯一的身份標識,并且使用cookie封裝這個標識再發送給客戶端。那么當客戶端下次再訪問服務器時,就會自動攜帶這個身份標識了,這和SESSIONID的道理是一樣的,只是改由我們自己來實現了。另外,在返回令牌之前,我們需要將它存儲起來,以便于后續的驗證。而這個令牌是不能保存在服務器本地的,因為其他服務器無法訪問它。因此,我們可以將其存儲在服務器之外的一個地方,那么Redis便是一個理想的場所。
第二是驗證令牌的程序,就是在用戶再次訪問服務器時,我們獲取到了它之前的身份標識,那么我們就要驗證一下這個標識是否存在了。驗證的過程很簡單,我們從Redis中嘗試獲取一下就可以知道結果。
1.25 如何利用Redis實現一個分布式鎖?
參考答案
何時需要分布式鎖?
在分布式的環境下,當多個server并發修改同一個資源時,為了避免競爭就需要使用分布式鎖。那為什么不能使用Java自帶的鎖呢?因為Java中的鎖是面向多線程設計的,它只局限于當前的JRE環境。而多個server實際上是多進程,是不同的JRE環境,所以Java自帶的鎖機制在這個場景下是無效的。
如何實現分布式鎖?
采用Redis實現分布式鎖,就是在Redis里存一份代表鎖的數據,通常用字符串即可。實現分布式鎖的思路,以及優化的過程如下:
-
加鎖:
第一版,這種方式的缺點是容易產生死鎖,因為客戶端有可能忘記解鎖,或者解鎖失敗。
setnx key value
第二版,給鎖增加了過期時間,避免出現死鎖。但這兩個命令不是原子的,第二步可能會失敗,依然無法避免死鎖問題。
setnx key value expire key seconds
第三版,通過“set…nx…”命令,將加鎖、過期命令編排到一起,它們是原子操作了,可以避免死鎖。
set key value nx ex seconds
-
解鎖:
解鎖就是刪除代表鎖的那份數據。
del key
-
問題:
看起來已經很完美了,但實際上還有隱患,如下圖。進程A在任務沒有執行完畢時,鎖已經到期被釋放了。等進程A的任務執行結束后,它依然會嘗試釋放鎖,因為它的代碼邏輯就是任務結束后釋放鎖。但是,它的鎖早已自動釋放過了,它此時釋放的可能是其他線程的鎖。
想要解決這個問題,我們需要解決兩件事情:
- 在加鎖時就要給鎖設置一個標識,進程要記住這個標識。當進程解鎖的時候,要進行判斷,是自己持有的鎖才能釋放,否則不能釋放。可以為key賦一個隨機值,來充當進程的標識。
- 解鎖時要先判斷、再釋放,這兩步需要保證原子性,否則第二步失敗的話,就會出現死鎖。而獲取和刪除命令不是原子的,這就需要采用Lua腳本,通過Lua腳本將兩個命令編排在一起,而整個Lua腳本的執行是原子的。
按照以上思路,優化后的命令如下:
# 加鎖
set key random-value nx ex seconds # 解鎖
if redis.call("get",KEYS[1]) == ARGV[1] thenreturn redis.call("del",KEYS[1])
elsereturn 0
end
基于RedLock算法的分布式鎖:
上述分布式鎖的實現方案,是建立在單個主節點之上的。它的潛在問題如下圖所示,如果進程A在主節點上加鎖成功,然后這個主節點宕機了,則從節點將會晉升為主節點。若此時進程B在新的主節點上加鎖成果,之后原主節點重啟,成為了從節點,系統中將同時出現兩把鎖,這是違背鎖的唯一性原則的。
總之,就是在單個主節點的架構上實現分布式鎖,是無法保證高可用的。若要保證分布式鎖的高可用,則可以采用多個節點的實現方案。這種方案有很多,而Redis的官方給出的建議是采用RedLock算法的實現方案。該算法基于多個Redis節點,它的基本邏輯如下:
- 這些節點相互獨立,不存在主從復制或者集群協調機制;
- 加鎖:以相同的KEY向N個實例加鎖,只要超過一半節點成功,則認定加鎖成功;
- 解鎖:向所有的實例發送DEL命令,進行解鎖;
RedLock算法的示意圖如下,我們可以自己實現該算法,也可以直接使用Redisson框架。
1.26 說一說你對布隆過濾器的理解
參考答案
布隆過濾器可以用很低的代價,估算出數據是否真實存在。例如:給用戶推薦新聞時,要去掉重復的新聞,就可以利用布隆過濾器,判斷該新聞是否已經推薦過。
布隆過濾器的核心包括兩部分:
- 一個大型的位數組;
- 若干個不一樣的哈希函數,每個哈希函數都能將哈希值算的比較均勻。
布隆過濾器的工作原理:
- 添加key時,每個哈希函數都利用這個key計算出一個哈希值,再根據哈希值計算一個位置,并將位數組中這個位置的值設置為1。
- 詢問key時,每個哈希函數都利用這個key計算出一個哈希值,再根據哈希值計算一個位置。然后對比這些哈希函數在位數組中對應位置的數值:
- 如果這幾個位置中,有一個位置的值是0,就說明這個布隆過濾器中,不存在這個key。
- 如果這幾個位置中,所有位置的值都是1,就說明這個布隆過濾器中,極有可能存在這個key。之所以不是百分之百確定,是因為也可能是其他的key運算導致該位置為1。
1.27 多臺Redis抗高并發訪問該怎么設計?
參考答案
Redis Cluster是Redis的分布式解決方案,在3.0版本正式推出,有效地解決了Redis分布式方面的需求。當遇到單機內存、并發、流量等瓶頸時,可以采用Cluster架構方案達到負載均衡的目的。
Redis集群采用虛擬槽分區來實現數據分片,它把所有的鍵根據哈希函數映射到0-16383
整數槽內,計算公式為slot=CRC16(key)&16383
,每一個節點負責維護一部分槽以及槽所映射的鍵值數據。虛擬槽分區具有如下特點:
- 解耦數據和節點之間的關系,簡化了節點擴容和收縮的難度;
- 節點自身維護槽的映射關系,不需要客戶端或者代理服務維護槽分區元數據;
- 支持節點、槽、鍵之間的映射查詢,用于數據路由,在線伸縮等場景。
Redis集群中數據的分片邏輯如下圖:
1.28 如果并發量超過30萬,怎么設計Redis架構?
參考答案
Redis Cluster是Redis的分布式解決方案,在3.0版本正式推出,有效地解決了Redis分布式方面的需求。當遇到單機內存、并發、流量等瓶頸時,可以采用Cluster架構方案達到負載均衡的目的。
Redis集群采用虛擬槽分區來實現數據分片,它把所有的鍵根據哈希函數映射到0-16383
整數槽內,計算公式為slot=CRC16(key)&16383
,每一個節點負責維護一部分槽以及槽所映射的鍵值數據。虛擬槽分區具有如下特點:
- 解耦數據和節點之間的關系,簡化了節點擴容和收縮的難度;
- 節點自身維護槽的映射關系,不需要客戶端或者代理服務維護槽分區元數據;
- 支持節點、槽、鍵之間的映射查詢,用于數據路由,在線伸縮等場景。
Redis集群中數據的分片邏輯如下圖:
,就說明這個布隆過濾器中,不存在這個key。
- 如果這幾個位置中,所有位置的值都是1,就說明這個布隆過濾器中,極有可能存在這個key。之所以不是百分之百確定,是因為也可能是其他的key運算導致該位置為1。
1.27 多臺Redis抗高并發訪問該怎么設計?
參考答案
Redis Cluster是Redis的分布式解決方案,在3.0版本正式推出,有效地解決了Redis分布式方面的需求。當遇到單機內存、并發、流量等瓶頸時,可以采用Cluster架構方案達到負載均衡的目的。
Redis集群采用虛擬槽分區來實現數據分片,它把所有的鍵根據哈希函數映射到0-16383
整數槽內,計算公式為slot=CRC16(key)&16383
,每一個節點負責維護一部分槽以及槽所映射的鍵值數據。虛擬槽分區具有如下特點:
- 解耦數據和節點之間的關系,簡化了節點擴容和收縮的難度;
- 節點自身維護槽的映射關系,不需要客戶端或者代理服務維護槽分區元數據;
- 支持節點、槽、鍵之間的映射查詢,用于數據路由,在線伸縮等場景。
Redis集群中數據的分片邏輯如下圖:
[外鏈圖片轉存中…(img-W0cUgnt5-1641471982130)]
1.28 如果并發量超過30萬,怎么設計Redis架構?
參考答案
Redis Cluster是Redis的分布式解決方案,在3.0版本正式推出,有效地解決了Redis分布式方面的需求。當遇到單機內存、并發、流量等瓶頸時,可以采用Cluster架構方案達到負載均衡的目的。
Redis集群采用虛擬槽分區來實現數據分片,它把所有的鍵根據哈希函數映射到0-16383
整數槽內,計算公式為slot=CRC16(key)&16383
,每一個節點負責維護一部分槽以及槽所映射的鍵值數據。虛擬槽分區具有如下特點:
- 解耦數據和節點之間的關系,簡化了節點擴容和收縮的難度;
- 節點自身維護槽的映射關系,不需要客戶端或者代理服務維護槽分區元數據;
- 支持節點、槽、鍵之間的映射查詢,用于數據路由,在線伸縮等場景。
Redis集群中數據的分片邏輯如下圖: