Redis原始碼解析八股
資料結構模組
鍵值對字串
-
char* 的不足:
-
操作效率低:獲取長度需遍歷,O(N)複雜度
-
二進位制不安全:無法儲存包含 \0 的資料
SDS 的優勢:
-
操作效率高:獲取長度無需遍歷,O(1)複雜度(通過len和alloc,快速獲取字元長度大小以及跳轉到字串末尾)
-
二進位制安全:因單獨記錄長度欄位,所以可儲存包含 \0 的資料
-
相容 C 字串函式,可直接使用字串 API
-
緊湊型記憶體設計(按照字串型別,len和alloc使用不同的型別節約記憶體,並且關閉記憶體對齊來達到記憶體高效利用,在redis中除了sds,intset和ziplist也有類似的目底)
-
避免頻繁的記憶體分配。除了sds部分型別存在預留空間,sds設計了sdsfree和sdsclear兩種字串清理函式,其中sdsclear,只是修改len為0以及buf為'\0',並不會實際釋放記憶體,避免下次使用帶來的記憶體開銷
-
-
Redis 在操作 SDS 時,為了避免頻繁操作字串時,每次「申請、釋放」記憶體的開銷,還做了這些優化:
-
記憶體預分配:SDS 擴容,會多申請一些記憶體(小於 1MB 翻倍擴容,大於 1MB 按 1MB 擴容)
-
多餘記憶體不釋放:SDS 縮容,不釋放多餘的記憶體,下次使用可直接複用這些記憶體
這種策略,是以多佔一些記憶體的方式,換取「追加」操作的速度。 這個記憶體預分配策略,詳細邏輯可以看 sds.c 的 sdsMakeRoomFor 函式。
-
-
SDS 字串在 Redis 內部模組實現中也被廣泛使用,在 Redis server 和客戶端的實現中,找到使用 SDS 字串的地方很多:
- Redis 中所有 key 的型別就是 SDS(詳見 db.c 的 dbAdd 函式)
- Redis Server 在讀取 Client 發來的請求時,會先讀到一個緩衝區中,這個緩衝區也是 SDS(詳見 server.h 中 struct client 的 querybuf 欄位)
- 寫操作追加到 AOF 時,也會先寫到 AOF 緩衝區,這個緩衝區也是 SDS (詳見 server.h 中 struct client 的 aof_buf 欄位)
Hash表
-
Redis 中的 dict 資料結構,採用「鏈式雜湊」的方式儲存,當雜湊衝突嚴重時,會開闢一個新的雜湊表,翻倍擴容,並採用「漸進式 rehash」的方式遷移資料
-
所謂「漸進式 rehash」是指,把很大塊遷移資料的開銷,平攤到多次小的操作中,目的是降低主執行緒的效能影響
-
Redis 中凡是需要 O(1) 時間獲取 k-v 資料的場景,都使用了 dict 這個資料結構,也就是說 dict 是 Redis 中重中之重的「底層資料結構」
-
dict 封裝好了友好的「增刪改查」API,並在適當時機「自動擴容、縮容」,這給上層資料型別(Hash/Set/Sorted Set)、全域性雜湊表的實現提供了非常大的便利
-
例如,Redis 中每個 DB 存放資料的「全域性雜湊表、過期key」都用到了 dict:
// server.h typedef struct redisDb { dict *dict; // 全域性雜湊表,資料鍵值對存在這 dict *expires; // 過期 key + 過期時間 存在這 ... }
-
「全域性雜湊表」在觸發漸進式 rehash 的情況有 2 個:
- 增刪改查雜湊表時:每次遷移 1 個雜湊桶( dict.c 中的 _dictRehashStep 函式)
- 定時 rehash:如果 dict 一直沒有操作,無法漸進式遷移資料,那主執行緒會預設每間隔 100ms 執行一次遷移操作。這裡一次會以 100 個桶為基本單位遷移資料,並限制如果一次操作耗時超時 1ms 就結束本次任務,待下次再次觸發遷移(dict.c 的 dictRehashMilliseconds 函式)
-
dict 在負載因子超過 1 時(used: bucket size >= 1),會觸發 rehash。但如果 Redis 正在 RDB 或 AOF rewrite,為避免父程序大量寫時複製,會暫時關閉觸發 rehash。但這裡有個例外,如果負載因子超過了 5(雜湊衝突已非常嚴重),依舊會強制做 rehash(重點)
-
dict 在 rehash 期間,查詢舊雜湊表找不到結果,還需要在新雜湊表查詢一次
-
SipHash 雜湊演算法是在 Redis 4.0 才開始使用的,3.0-4.0 使用的是 MurmurHash2 雜湊演算法,3.0 之前是 DJBX33A 雜湊演算法
-
redis的dict結構核心就是鏈式hash,其原理其實和JDK的HashMap類似(JDK1.7之前的版本,1.8開始是紅黑樹或連結串列),這裡就有一個問題為什麼Redis要使用鏈式而不引入紅黑樹呢,或者直接使用紅黑樹?
- hash衝突不使用紅黑樹:redis需要高效能,如果hash衝突使用紅黑樹,紅黑樹和連結串列的轉換會引起不必要的開銷(hash衝突不大的情況下紅黑樹其實比連結串列沉重,還會浪多餘的空間)
- dict不採用紅黑樹:在負載因子較低,hash衝突較低的情況下,hash表的效率O(1)遠遠高於紅黑樹
- 當採用漸進式rehash的時候,以上問題都可以解決
-
何為漸進式rehash?本質原理是什麼?當記憶體使用變小會縮容嗎?
- 漸進式rehash的本質是分治思想,通過把大任務劃分成一個個小任務,每個小任務只執行一小部分資料,最終完成整個大任務的過程
- 漸進式rehash可以在不影響執行中的redis使用來完成整改hash表的擴容(每次可以控制只執行1ms)
- 初步判定會,因為dictResize中用於計算hash表大小的minimal就是來源於實際使用的大小,並且htNeedsResize方法中(used*100/size < HASHTABLE_MIN_FILL)來判斷是否觸發縮容來節約記憶體,而縮容也是漸進式rehash
-
漸進式rehash怎麼去執行?
在瞭解漸進式rehash之前,我們需要了解一個事情,就是正在執行執行任務的redis,其實本身就是一個單執行緒的死迴圈(不考慮非同步以及其他fork的場景),其迴圈的方法為aeMain(),位於ae.c檔案中,在這個迴圈中每次執行都會去嘗試執行已經觸發的時間事件和檔案事件,而漸進式rehash的每個小任務就是位於redis,serverCron時間事件中,redis每次迴圈的時候其實都會經過如下所示的呼叫流程:
- serverCron -> updateDictResizePolicy (先判斷是否能執行rehash,當AOF重寫等高壓力操作時候不執行)
- serverCron -> databasesCron -> incrementallyRehash -> dictRehashMilliseconds -> dictRehash (dictRehashMilliseconds預設要求每次rehash最多隻能執行1ms)
通過這種方式最終完成整改hash表的擴容
SDS
-
要想理解 Redis 資料型別的設計,必須要先了解 redisObject。 Redis 的 key 是 String 型別,但 value 可以是很多型別(String/List/Hash/Set/ZSet等),所以 Redis 要想儲存多種資料型別,就要設計一個通用的物件進行封裝,這個物件就是 redisObject。
// server.h typedef struct redisObject { unsigned type:4; unsigned encoding:4; unsigned lru:LRU_BITS; int refcount; void *ptr; } robj;
其中,最重要的 2 個欄位:
-
type:面向使用者的資料型別(String/List/Hash/Set/ZSet等)
-
encoding:每一種資料型別,可以對應不同的底層資料結構來實現(SDS/ziplist/intset/hashtable/skiplist等)
例如 String,可以用 embstr(嵌入式字串,redisObject 和 SDS 一起分配記憶體),也可以用 rawstr(redisObject 和 SDS 分開儲存)實現。
又或者,當用戶寫入的是一個「數字」時,底層會轉成 long 來儲存,節省記憶體。
同理,Hash/Set/ZSet 在資料量少時,採用 ziplist 儲存,否則就轉為 hashtable 來存。
所以,redisObject 的作用在於:
-
為多種資料型別提供統一的表示方式
-
同一種資料型別,底層可以對應不同實現,節省記憶體
-
支援物件共享和引用計數,共享物件儲存一份,可多次使用,節省記憶體
redisObject 更像是連線「上層資料型別」和「底層資料結構」之間的橋樑。
-
-
關於 String 型別的實現,底層對應 3 種資料結構:
- embstr:小於 44 位元組,嵌入式儲存,redisObject 和 SDS 一起分配記憶體,只分配 1 次記憶體
- rawstr:大於 44 位元組,redisObject 和 SDS 分開儲存,需分配 2 次記憶體
- long:整數儲存(小於 10000,使用共享物件池儲存,但有個前提:Redis 沒有設定淘汰策略,詳見 object.c 的 tryObjectEncoding 函式)
-
ziplist 的特點:
- 連續記憶體儲存:每個元素緊湊排列,記憶體利用率高
- 變長編碼:儲存資料時,採用變長編碼(滿足資料長度的前提下,儘可能少分配記憶體)
- 尋找元素需遍歷:存放太多元素,效能會下降(適合少量資料儲存)
- 級聯更新:更新、刪除元素,會引發級聯更新(因為記憶體連續,前面資料膨脹/刪除了,後面要跟著一起動)
List、Hash、Set、ZSet 底層都用到了 ziplist。
-
intset 的特點:
- Set 儲存如果都是數字,採用 intset 儲存
- 變長編碼:數字範圍不同,intset 會選擇 int16/int32/int64 編碼(intset.c 的 _intsetValueEncoding 函式)
- 有序:intset 在儲存時是有序的,這意味著查詢一個元素,可使用「二分查詢」(intset.c 的 intsetSearch 函式)
- 編碼升級/降級:新增、更新、刪除元素,資料範圍發生變化,會引發編碼長度升級或降級
-
為什麼SDS 判斷是否使用嵌入式字串的條件是 44 位元組
jemalloc 分配記憶體機制,jemalloc 為了減少分配的記憶體空間大小不是2的冪次,在每次分配記憶體的時候都會返回2的冪次的空間大小,比如我需要分配5位元組空間,jemalloc 會返回8位元組,15位元組會返回16位元組。其常見的分配空間大小有: 8, 16, 32, 64, ..., 2kb, 4kb, 8kb。
但是這種方式也可能會造成,空間的浪費,比如我需要33位元組,結果給我64位元組,為了解決這個問題jemalloc將記憶體分配劃分為,小記憶體(small_class)和大記憶體(large_class)通過不同的記憶體大小使用不同階級策略,比如小記憶體允許存在48位元組等方式。
嵌入式字串會把 redisObject 和 SDS 一起分配記憶體,那在儲存時結構是這樣的:
- redisObject:16 個位元組
- SDS:sdshdr8(3 個位元組)+ SDS 字元陣列(N 位元組 + \0 結束符 1 個位元組)
Redis 規定嵌入式字串最大以 64 位元組儲存,所以 N = 64 - 16(redisObject) - 3(sdshr8) - 1(\0), N = 44 位元組。
-
redis為了充分提高記憶體利用率,從幾個方面入手:
-
淘汰不在使用的記憶體空間
-
緊湊型的記憶體設計
- 設計實現了SDS
- 設計實現了ziplist
- 設計實現了intset
- 搭配redisObject
- 設計了嵌入式字串
-
例項記憶體共享
- 設計了共享物件(共享記憶體大部是常量例項)
-
有序集合
-
ZSet 當資料比較少時,採用 ziplist 儲存,每個 member/score 元素緊湊排列,節省記憶體
-
當資料超過閾值(zset-max-ziplist-entries、zset-max-ziplist-value)後,轉為 hashtable + skiplist 儲存,降低查詢的時間複雜度
-
hashtable 儲存 member->score 的關係,所以 ZSCORE 的時間複雜度為 O(1)
-
skiplist 是一個「有序連結串列 + 多層索引」的結構,把查詢元素的複雜度降到了 O(logN),服務於 ZRANGE/ZREVRANGE 這類命令
-
skiplist 的多層索引,採用「隨機」的方式來構建,也就是說每次新增一個元素進來,要不要對這個元素建立「多層索引」?建立「幾層索引」?都要通過「隨機數」的方式來決定
-
每次隨機一個 0-1 之間的數,如果這個數小於 0.25(25% 概率),那就給這個元素加一層指標,持續隨機直到大於 0.25 結束,最終確定這個元素的層數(層數越高,概率越低,且限制最多 64 層,詳見 t_zset.c 的 zslRandomLevel 函式)
-
這個預設「概率」決定了一個跳錶的記憶體佔用和查詢複雜度:概率設定越低,層數越少,元素指標越少,記憶體佔用也就越少,但查詢複雜會變高,反之亦然。這也是 skiplist 的一大特點,可通過控制概率,進而控制記憶體和查詢效率
-
skiplist 新插入一個節點,只需修改這一層前後節點的指標,不影響其它節點的層數,降低了操作複雜度(相比平衡二叉樹的再平衡,skiplist 插入效能更優)
-
關於 Redis 的 ZSet 為什麼用 skiplist 而不用平衡二叉樹實現的問題,原因是:
- skiplist 更省記憶體:25% 概率的隨機層數,可通過公式計算出 skiplist 平均每個節點的指標數是 1.33 個,平衡二叉樹每個節點指標是 2 個(左右子樹)
- skiplist 遍歷更友好:skiplist 找到大於目標元素後,向後遍歷連結串列即可,平衡樹需要通過中序遍歷方式來完成,實現也略複雜
- skiplist 更易實現和維護:擴充套件 skiplist 只需要改少量程式碼即可完成,平衡樹維護起來較複雜
-
在使用跳錶和雜湊表相結合的雙索引機制時,在獲得高效範圍查詢和單點查詢的同時,有哪些不足之處?
這種發揮「多個數據結構」的優勢,來完成某個功能的場景,最大的特點就是「空間換時間」,所以記憶體佔用多是它的不足。
不過也沒辦法,想要高效率查詢,就得犧牲記憶體,魚和熊掌不可兼得。
不過 skiplist 在實現時,Redis 作者應該也考慮到這個問題了,就是上面提到的這個「隨機概率」,Redis 後期維護可以通過調整這個概率,進而達到「控制」查詢效率和記憶體平衡的結果。當然,這個預設值是固定寫死的,不可配置,應該是 Redis 作者經過測試和權衡後的設定,我們這裡只需要知曉原理就好。
-
redis作為一款優化到極致的中介軟體,不會單純使用一種資料型別去實現一個功能,而會根據當前的情況選擇最合適的資料結構,比如zset就是dict + skiplist,甚至當元素較少的時候zsetAdd方法會優先選擇ziplist而不直接使用skiplist,以到達節約記憶體的效果(當小key氾濫的時候很有效果),當一種資料結構存在不足的情況下,可以通過和其它資料結構搭配來彌補自身的不足(軟體設計沒有銀彈,只有最合適)
-
redis仰仗c語言指標的特性,通過層高level陣列實現的skiplist從記憶體和效率上來說都是非常優秀的,我對比了JDK的ConcurrentSkipListMap的實現(使用了大量引用和頻繁的new操作),指標的優勢無疑顯現出來了
-
skiplist的隨機率層高。既保證每層的數量相對為下一層的一半,又保證了程式碼執行效率
quicklist,listpack
-
ziplist 設計的初衷就是「節省記憶體」,在儲存資料時,把記憶體利用率發揮到了極致:
- 數字按「整型」編碼儲存,比直接當字串存記憶體佔用少
- 資料「長度」欄位,會根據內容的大小選擇最小的長度編碼
- 甚至對於極小的資料,乾脆把內容直接放到了「長度」欄位中(前幾個位表示長度,後幾個位存資料)
-
但 ziplist 的劣勢也很明顯:
- 尋找元素只能挨個遍歷,儲存過長資料,查詢效能很低
- 每個元素中儲存了「上一個」元素的長度(為了方便反向遍歷),這會導致上一個元素內容發生修改,長度超過了原來的編碼長度,下一個元素的內容也要跟著變,重新分配記憶體,進而就有可能再次引起下一級的變化,一級級更新下去,頻繁申請記憶體
-
想要緩解 ziplist 的問題,比較簡單直接的方案就是,多個數據項,不再用一個 ziplist 來存,而是分拆到多個 ziplist 中,每個 ziplist 用指標串起來,這樣修改其中一個數據項,即便發生級聯更新,也只會影響這一個 ziplist,其它 ziplist 不受影響,這種方案就是 quicklist
qucklist: ziplist1(也叫quicklistNode) <-> ziplist2 <-> ziplist3 <-> ...
-
List 資料型別底層實現,就是用的 quicklist,因為它是一個連結串列,所以 LPUSH/LPOP/RPUSH/RPOP 的複雜度是 O(1)
-
List 中每個 ziplist 節點可以存的元素個數/總大小,可以通過 list-max-ziplist-size 配置:
- 正數:ziplist 最多包含幾個資料項
- 負數:取值 -1 ~ -5,表示每個 ziplist 儲存最大的位元組數,預設 -2,每個ziplist 8KB
ziplist 超過上述配置,新增新元素就會新建 ziplist 插入到連結串列中。
-
List 因為更多是兩頭操作,為了節省記憶體,還可以把中間的 ziplist「壓縮」,具體可看 list-compress-depth 配置項,預設配置不壓縮
-
要想徹底解決 ziplist 級聯更新問題,本質上要修改 ziplist 的儲存結構,也就是不要讓每個元素儲存「上一個」元素的長度即可,所以才有了 listpack
-
listpack 每個元素項不再儲存上一個元素的長度,而是優化元素內欄位的順序,來保證既可以從前也可以向後遍歷
-
listpack 是為了替代 ziplist 為設計的,但因為 List/Hash/ZSet 都嚴重依賴 ziplist,所以這個替換之路很漫長,目前只有 Stream 資料型別用到了 listpack。set底層是intset和dict實現的,並沒有使用到ziplist。
Stream使用了Radix Tree
作為有序索引,Radix Tree 也能提供範圍查詢,和 B+ 樹、跳錶相比,你覺得 Radix Tree 有什麼優勢和不足麼?
-
Radix Tree 優勢
- 本質上是字首樹,所以儲存有「公共字首」的資料時,比 B+ 樹、跳錶節省記憶體
- 沒有公共字首的資料項,壓縮儲存,value 用 listpack 儲存,也可以節省記憶體
- 查詢複雜度是 O(K),只與「目標長度」有關,與總資料量無關
- 這種資料結構也經常用在搜尋引擎提示、文字自動補全等場景
Stream 在存訊息時,推薦使用預設自動生成的「時間戳+序號」作為訊息 ID,不建議自己指定訊息 ID,這樣才能發揮 Radix Tree 公共字首的優勢。
-
Radix Tree 不足
- 如果資料集公共字首較少,會導致記憶體佔用多
- 增刪節點需要處理其它節點的「分裂、合併」,跳錶只需調整前後指標即可
- B+ 樹、跳錶範圍查詢友好,直接遍歷連結串列即可,Radix Tree 需遍歷樹結構
- 實現難度高比 B+ 樹、跳錶複雜
每種資料結構都是在面對不同問題場景下,才被設計出來的,結合各自場景中的資料特點,使用優勢最大的資料結構才是正解。
B+樹和跳躍表有什麼關聯?
- B+樹和跳躍表這兩種資料結構在本身設計上是有親緣關係的,其實如果把B+樹拉直來看不難發現其結構和跳躍表很相似,甚至B+樹的父親結點其實類似跳躍表的level層級。
- 在當前計算機硬體儲存設計上,B+樹能比跳錶儲存更大量級的資料,因為跳錶需要通過增加層高來提高索引效率,而B+樹只需要增加樹的深度。此外B+樹同一葉子的連續性更加符合當代計算機的儲存結構。然而跳錶的層高具有隨機性,當層高較大的時候磁碟插入會帶來一定的開銷,且不利於分塊。
為什麼Redis不使用B+樹呢而選擇跳錶呢?
答:因為資料有序性的實現B+樹不如跳錶,跳錶的時間效能是優於B+樹的(B+樹不是二叉樹,二分的效率是比較高的)。此外跳錶最低層就是一條連結串列,對於需要實現範圍查詢的功能是比較有利的,而且Redis是基於記憶體設計的,無需考慮海量資料的場景。
事件驅動框架和執行模型模組
Redis server啟動後
- redis的整體啟動流程是按照 【初始化預設配置】->【解析啟動命令】->【初始化server】->【初始化並啟動事件驅動框架】 進行
- 整個執行中的redis其實就是一個永不停歇的while迴圈,位於aeMain中(執行中的事件驅動框架)
- 在事件驅動框架中有兩個鉤子函式 beforeSleep 和 aftersleep,在每次while迴圈中都會觸發這兩個函式,後面用來實現事件觸發的效果
Redis 啟動流程,主要的工作有:
- 初始化前置操作(設定時區、隨機種子)
- 初始化 Server 的各種預設配置(server.c 的 initServerConfig 函式),預設配置見 server.h 中的 CONFIG_DEFAULT_XXX,比較典型的配置有:
- 預設埠
- 定時任務頻率
- 資料庫數量
- AOF 刷盤策略
- 淘汰策略
- 資料結構轉換閾值
- 主從複製引數
- 載入配置啟動引數,覆蓋預設配置(config.c 的 loadServerConfig 函式):
- 解析命令列引數
- 解析配置檔案
- 初始化 Server(server.c 的 initServer 函式),例如會初始化:
- 處理請求
- 處理定時任務
- 啟動 3 類後臺執行緒(server.c 的 InitServerLast 函式),協助主執行緒工作(非同步釋放 fd、AOF 每秒刷盤、lazyfree)。
- 初始化並啟動事件驅動框架(啟動事件迴圈)(ae.c 的 aeMain 函式)
epoll
-
單執行緒伺服器模型,面臨的最大的問題就是,一個執行緒如何處理多個客戶端請求?解決這種問題的辦法就是「IO 多路複用」。它本質上是應用層不用維護多個客戶端的連線狀態,而是把它們「託管」給了作業系統,作業系統維護這些連線的狀態變化,之後應用層只管問作業系統,哪些 socket 有資料可讀/可寫就好了,大大簡化了應用層的複雜度
-
IO 多路複用機制要想高效使用,一般還需要把 socket 設定成「非阻塞」模式,即 socket 沒有資料可讀/可寫時,應用層去 read/write socket 也不會阻塞住(核心會返回指定錯誤,應用層可繼續重試),這樣應用層就可以去處理其它業務邏輯,不會阻塞影響效能
-
為什麼 Redis 要使用「單執行緒」處理客戶端請求?本質上是因為,Redis 操作的是記憶體,操作記憶體資料是極快的,所以 Redis 的瓶頸不在 CPU,優化的重點就在網路 IO 上,高效的 IO 多路複用機制,正好可以滿足這種需求,模型簡單,效能也極高
-
但成也蕭何敗也蕭何,因為 Redis 處理請求是「單執行緒」,所以如果有任意請求在 Server 端發生耗時(例如操作 bigkey,或一次請求資料過多),就會導致後面的請求發生「排隊」,業務端就會感知到延遲增大,效能下降
-
基於此,Redis 又做了很多優化:一些耗時的操作,不再放在主執行緒處理,而是丟到後臺執行緒慢慢執行。例如,非同步關閉 fd,非同步釋放記憶體、後臺 AOF 刷盤這些操作。所以 Redis Server 其實是「多執行緒」的,只不過最核心的處理請求邏輯是單執行緒的,這點一定要區分開
-
redis為了滿足各種系統實現了多套IO多路複用,分別有:epoll,select,evport,kqueue
-
redis在IO多路複用的程式碼實現進行了抽象,通過同一實現了aeApiState,aeApiCreate,aeApiResize,aeApiFree等等方法(類比介面)實現了多套IO複用,方便在編譯期間切換(檔案:ae_epoll.c,ae_evport.c,ae_kqueue.c,ae_select.c)
-
在 Redis 事件驅動框架程式碼中,分別使用了 Linux 系統上的 select 和 epoll 兩種機制,為什麼 Redis 沒有使用 poll 這一機制?
首先要明確一點,select 並不是只有 Linux 才支援的,Windows 平臺也支援。
而 Redis 針對不同作業系統,會選擇不同的 IO 多路複用機制來封裝事件驅動框架,具體程式碼見 ae.c。
// ae.c #ifdef HAVE_EVPORT #include "ae_evport.c" // Solaris #else #ifdef HAVE_EPOLL #include "ae_epoll.c" // Linux #else #ifdef HAVE_KQUEUE #include "ae_kqueue.c" // MacOS #else #include "ae_select.c" // Windows #endif #endif #endif
仔細看上面的程式碼邏輯,先判斷了 Solaris/Linux/MacOS 系統,選擇對應的多路複用模型,最後剩下的系統都用 select 模型。
所以我理解,select 並不是為 Linux 服務的,而是在 Windows 下使用的。 因為 epoll 效能優於 select 和 poll,所以 Linux 平臺下,Redis 直接會選擇 epoll。而 Windows 不支援 epoll 和 poll,所以會用 select 模型。
Reactor模型
-
為了高效處理網路 IO 的「連線事件」、「讀事件」、「寫事件」,演化出了 Reactor 模型
-
Reactor 模型主要有 reactor、acceptor、handler 三類角色:
- reactor:分配事件
- acceptor:接收連線請求
- handler:處理業務邏輯
-
Reactor 模型又分為 3 類:
- 單 Reactor 單執行緒:accept -> read -> 處理業務邏輯 -> write 都在一個執行緒
- 單 Reactor 多執行緒:accept/read/write 在一個執行緒,處理業務邏輯在另一個執行緒
- 多 Reactor 多執行緒 / 程序:accept 在一個執行緒/程序,read/處理業務邏輯/write 在另一個執行緒/程序
-
Redis 6.0 以下版本,屬於單 Reactor 單執行緒模型,監聽請求、讀取資料、處理請求、寫回資料都在一個執行緒中執行,這樣會有 3 個問題:
- 單執行緒無法利用多核
- 處理請求發生耗時,會阻塞整個執行緒,影響整體效能
- 併發請求過高,讀取/寫回資料存在瓶頸
-
針對問題 3,Redis 6.0 進行了優化,引入了 IO 多執行緒,把讀寫請求資料的邏輯,用多執行緒處理,提升併發效能,但處理請求的邏輯依舊是單執行緒處理
-
除了 Redis,你還了解什麼軟體系統使用了 Reactor 模型嗎?
Netty、Memcached 採用多 Reactor 多執行緒模型。
Nginx 採用多 Reactor 多程序模型,不過與標準的多 Reactor 多程序模型有些許差異。Nginx 的主程序只用來初始化 socket,不會 accept 連線,而是由子程序 accept 連線,之後這個連線的所有處理都在子程序中完成。
Redis事件
- Redis 事件迴圈主要處理兩類事件:檔案事件、時間事件
- 檔案事件包括:client 發起新連線、client 向 server 寫資料、server 向 client 響應資料
- 時間事件:Redis 的各種定時任務(主執行緒中執行)
- Redis 在啟動時,會建立 aeEventLoop,初始化 epoll 物件,監聽埠,之後會註冊檔案事件、時間事件:
- 檔案事件:把 listen socket fd 註冊到 epoll 中,回撥函式是 acceptTcpHandler(新連線事件)
- 時間事件:把 serverCron 函式註冊到 aeEventLoop 中,並指定執行頻率
- Redis Server 啟動後,會啟動一個死迴圈,持續處理事件(ae.c 的 aeProcessEvents 函式)
- 有檔案事件(網路 IO),則優先處理。例如,client 到 server 的新連線,會呼叫 acceptTcpHandler 函式,之後會註冊讀事件 readQueryFromClient 函式,client 發給 server 的資料,都會在這個函式處理,這個函式會解析 client 的資料,找到對應的 cmd 函式執行
- cmd 邏輯執行完成後,server 需要寫回資料給 client,會先把響應資料寫到對應 client 的 記憶體 buffer 中,在下一次處理 IO 事件之前,Redis 會把每個 client 的 buffer 資料寫到 client 的 socket 中,給 client 響應
- 如果響應給 client 的資料過多,則會分多次傳送,待發送的資料會暫存到 buffer,然後會向 epoll 註冊回撥函式 sendReplyToClient,待 socket 可寫時,繼續呼叫回撥函式向 client 寫回剩餘資料
- 在這個死迴圈中處理每次事件時,都會先檢查一下,時間事件是否需要執行,因為之前已經註冊好了時間事件的回撥函式 + 執行頻率,所以在執行 aeApiPoll 時,timeout 就是定時任務的週期,這樣即使沒有 IO 事件,epoll_wait 也可以正常返回,此時就可以執行一次定時任務 serverCron 函式,這樣就可以在一個執行緒中就完成 IO 事件 + 定時任務的處理
單執行緒
-
很多人認為 Redis 是單執行緒,這個描述是不準確的。準確來說 Redis 只有在處理「客戶端請求」比如
接收客戶端請求
、解析請求
和進行資料讀寫
等操作時,是單執行緒的。但整個 Redis Server 並不是單執行緒的,還有後臺執行緒,比如檔案關閉
、AOF 同步寫
和惰性刪除
在輔助處理一些工作。 -
Redis 選擇單執行緒處理請求,是因為 Redis 操作的是「記憶體」,加上設計了「高效」的資料結構,所以操作速度極快,利用 「IO 多路複用」機制,單執行緒依舊可以有非常高的效能。
-
但如果一個請求發生耗時,單執行緒的缺點就暴露出來了,後面的請求都要「排隊」等待,所以 Redis 在啟動時會啟動一些「後臺執行緒」來輔助工作,目的是把耗時的操作,放到後臺處理,避免主執行緒操作耗時影響整體效能
-
關閉 fd、AOF 刷盤、釋放 key 的記憶體,這些耗時操作,都可以放到後臺執行緒中處理,對主邏輯沒有任何影響
-
後臺執行緒處理這些任務,就相當於一個消費者,生產者(主執行緒)把耗時任務丟到佇列中(連結串列),消費者不停輪詢這個佇列,拿出任務就去執行對應的方法即可:
- BIO_CLOSE_FILE:close(fd) 檔案關閉後臺任務。
- BIO_AOF_FSYNC:fsync(fd) AOF 日誌同步寫回後臺任務
- BIO_LAZY_FREE:free(obj) / free(dict) / free(skiplist) 惰性刪除後臺任務
-
後臺執行緒有3個,後臺程序只有RDB和AOF rewrite時才會fork子程序。
-
Redis 後臺任務使用 bio_job 結構體來描述,該結構體用了三個指標變數來表示任務引數
struct bio_job { time_t time; void *arg1, *arg2, *arg3; //傳遞給任務的引數 };
如果我們建立的任務,所需要的引數大於 3 個,最直接的方法就是,使用指標陣列,因為指標陣列本身就是一個個指標,可以通過index的順序標記引數的含義型別,通過index就能快速獲取不同的引數對應的指標這樣就可以傳遞任意數量引數了。因為這裡 Redis 的後臺任務都比較簡單,最多 3 個引數就足夠滿足需求,所以 job 直接寫死了 3 個引數變數,這樣做的好處是維護起來簡單直接
-
Redis是一個多程序多執行緒的程式: 通過這篇文章也能很清晰的認識到,在Redis中不但有fork的方式建立程序,也有通過pthread_create的方式建立執行緒,二者都能起到非同步執行任務的效果
-
fork是一個沉重的方案:除了以守護程序的方式啟動時候會進行fork,bgsave也會進行fork。但是fork比thread的代價大的多,fork出來的子程序會複製一份父程序的虛擬地址表(虛擬記憶體技術,子程序複製父程序的地址表,複用原有的地址空間,當某個地址上的資料涉及修改的時候才會把資料複製一份到自己的地址空間)從而也可能會導致出現寫時複製等記憶體高損耗的開銷。
-
Thread需要解決併發問題:多執行緒雖然資源開銷沒有fork那麼沉重,但是由於多執行緒的地址空間都屬於同一個程序(執行緒屬於程序),那麼必然要解決併發問題。然而Redis的設計很巧妙,無論是bioInit的bioProcessBackgroundJobs使用分type的方式讓每給執行緒依次執行列表上的任務,還是initThreadedIO使用訊號量的方式控制執行緒的協調,都能避開記憶體共享帶來的併發問題,從而即享受了多執行緒的優勢,又避免了多執行緒的劣勢。
Redis 6.0多IO執行緒
- Redis 6.0 之前,處理客戶端請求是單執行緒,這種模型的缺點是,只能用到「單核」CPU。如果併發量很高,那麼在讀寫客戶端資料時,容易引發效能瓶頸,所以 Redis 6.0 引入了多 IO 執行緒解決這個問題
- 配置檔案開啟 io-threads N 後,Redis Server 啟動時,會啟動 N - 1 個 IO 執行緒(主執行緒也算一個 IO 執行緒),這些 IO 執行緒執行的邏輯是 networking.c 的 IOThreadMain 函式。但預設只開啟多執行緒「寫」client socket,如果要開啟多執行緒「讀」,還需配置 io-threads-do-reads = yes
- Redis 在讀取客戶端請求時,判斷如果開啟了 IO 多執行緒,則把這個 client 放到 clients_pending_read 連結串列中(postponeClientRead 函式),之後主執行緒在處理每次事件迴圈之前,把連結串列資料輪詢放到 IO 執行緒的連結串列(io_threads_list)中
- 同樣地,在寫回響應時,是把 client 放到 clients_pending_write 中(prepareClientToWrite 函式),執行事件迴圈之前把資料輪詢放到 IO 執行緒的連結串列(io_threads_list)中
- 主執行緒把 client 分發到 IO 執行緒時,自己也會讀寫客戶端 socket(主執行緒也要分擔一部分讀寫操作),之後「等待」所有 IO 執行緒完成讀寫,再由主執行緒「序列」執行後續邏輯
- 每個 IO 執行緒,不停地從 io_threads_list 連結串列中取出 client,並根據指定型別讀、寫 client socket
- IO 執行緒在處理讀、寫 client 時有些許差異,如果 write_client_pedding < io_threads * 2,則直接由「主執行緒」負責寫,不再交給 IO 執行緒處理,從而節省 CPU 消耗
- Redis 官方建議,伺服器最少 4 核 CPU 才建議開啟 IO 多執行緒,4 核 CPU 建議開 2-3 個 IO 執行緒,8 核 CPU 開 6 個 IO 執行緒,超過 8 個執行緒效能提升不大
- Redis 官方表示,開啟多 IO 執行緒後,效能可提升 1 倍。當然,如果 Redis 效能足夠用,沒必要開 IO 執行緒
分散式鎖的原子性保證
-
無論是 IO 多路複用,還是 Redis 6.0 的多 IO 執行緒,Redis 執行具體命令的主邏輯依舊是「單執行緒」的
-
執行命令是單執行緒,本質上就保證了每個命令必定是「序列」執行的,前面請求處理完成,後面請求才能開始處理
-
所以 Redis 在實現分散式鎖時,內部不需要考慮加鎖問題,直接在主執行緒中判斷 key 是否存在即可,實現起來非常簡單
-
如果將命令處理過程中的命令執行也交給多 IO 執行緒執行,除了對原子性會有影響,還會有什麼好處和壞處?
好處:
- 每個請求分配給不同的執行緒處理,一個請求處理慢,並不影響其它請求
- 請求操作的 key 越分散,效能會變高(並行處理比序列處理效能高)
- 可充分利用多核 CPU 資源
壞處:
- 操作同一個 key 需加鎖,加鎖會影響效能,如果是熱點 key,效能下降明顯
- 多執行緒上下文切換存在效能損耗
- 多執行緒開發和除錯不友好
快取模組
LRU
- 實現一個嚴格的 LRU 演算法,需要額外的記憶體構建 LRU 連結串列,同時維護連結串列也存在效能開銷,Redis 對於記憶體資源和效能要求極高,所以沒有采用嚴格 LRU 演算法,而是採用「近似」LRU 演算法實現資料淘汰策略
- 觸發資料淘汰的時機,是每次處理「請求」時判斷的。也就是說,執行一個命令之前,首先要判斷例項記憶體是否達到 maxmemory,是的話則先執行資料淘汰,再執行具體的命令
- 淘汰資料時,會「持續」判斷 Redis 記憶體是否下降到了 maxmemory 以下,不滿足的話會繼續淘汰資料,直到記憶體下降到 maxmemory 之下才會停止
- 可見,如果發生大量淘汰的情況,那麼處理客戶端請求就會發生「延遲」,影響效能
- Redis 計算例項記憶體時,不會把「主從複製」的緩衝區計算在內,也就是說不管一個例項後面掛了多少個從庫,主庫不會把主從複製所需的「緩衝區」記憶體,計算到例項記憶體中,即這部分記憶體增加,不會對資料淘汰產生影響
- 但如果 Redis 記憶體已達到 maxmemory,要謹慎執行 MONITOR 命令,因為 Redis Server 會向執行 MONITOR 的 client 緩衝區填充資料,這會導致緩衝區記憶體增長,進而引發資料淘汰
- 鍵值對的 LRU 時鐘值,不是直接通過呼叫 getLRUClock 函式來獲取,本質上是為了「效能」。 Redis 這種對效能要求極高的資料庫,在系統呼叫上的優化也做到了極致。 獲取機器時鐘本質上也是一個「系統呼叫」,對於 Redis 這種動不動每秒上萬的 QPS,如果每次都觸發一次系統呼叫,這麼頻繁的操作也是一筆不小的開銷。 所以,Redis 用一個定時任務(serverCron 函式),以固定頻率觸發系統呼叫獲取機器時鐘,然後把機器時鐘掛到 server 的全域性變數下,這相當於維護了一個「本地快取」,當需要獲取時鐘時,直接從全域性變數獲取即可,節省了大量的系統呼叫開銷。
LFU
-
LFU 是在 Redis 4.0 新增的淘汰策略,它涉及的巧妙之處在於,其複用了 redisObject 結構的 lru 欄位,把這個欄位「一分為二」,儲存最後訪問時間和訪問次數
-
key 的訪問次數不能只增不減,它需要根據時間間隔來做衰減,才能達到 LFU 的目的
-
每次在訪問一個 key 時,會「懶惰」更新這個 key 的訪問次數:先衰減訪問次數,再更新訪問次數
-
衰減訪問次數,會根據時間間隔計算,間隔時間越久,衰減越厲害
-
因為 redisObject lru 欄位寬度限制,這個訪問次數是有上限的(8 bit 最大值 255),所以遞增訪問次數時,會根據「當前」訪問次數和「概率」的方式做遞增,訪問次數越大,遞增因子越大,遞增概率越低
-
Redis 實現的 LFU 演算法也是「近似」LFU,是在效能和記憶體方面平衡的結果
-
LFU 演算法在初始化鍵值對的訪問次數時,會將訪問次數設定為 LFU_INIT_VAL,預設值是 5 次。如果 LFU_INIT_VAL 設定為 1,會發生什麼情況?
LFU_INIT_VAL的初始值為5主要是避免,剛剛建立的物件被立馬淘汰,而需要經歷一個衰減的過程後才會被淘汰。
如果開啟了 LFU,那在寫入一個新 key 時,需要初始化訪問時間、訪問次數(createObject 函式),如果訪問次數初始值太小,那這些新 key 的訪問次數,很有可能在短時間內就被「衰減」為 0,那就會面臨馬上被淘汰的風險。新 key 初始訪問次數 LFU_INIT_VAL = 5,就是為了避免一個 key 在建立後,不會面臨被立即淘汰的情況發生。
-
純粹的LFU演算法會累計歷史的訪問次數,然而在高QPS的情況下可能會出現以下幾個問題:
- 執行橫跨高峰期和低峰期,不同時期儲存的資料不一致,可能會導致部分高峰期產生的資料不容易被淘汰,甚至可能永遠淘汰不掉(因為在高峰獲得一個較高的count值,在計算淘汰的時候仍然存在)
- 需要long乃至更大的值去儲存count。對於高頻訪問的資料如果需要統計每一次的呼叫,可能需要使用更大的空間去儲存,還需要考慮溢位的問題。
- 可能存在,每次淘汰掉的幾乎是剛剛建立的新資料。
為了解決這些問題,Redis實現了一個近似LFU演算法,並做出了以下改進:
- count有上限值255。(避免高頻資料獲得一個較大的count值,還能節省空間)
- count值是會隨著時間衰減。(不再訪問的資料更加容易被淘汰,高16位記錄上一次訪問時間戳-分鐘,低8位記錄count)
- 剛剛建立的資料count值不為0。(避免剛剛建立的資料被淘汰)
- count值累加是概率隨機的。(避免高峰期資料都能一下就能累加到255,其中概率能人為調整)
Lazy Free
-
lazy-free 是 4.0 新增的功能,預設是關閉的,需要手動開啟
-
開啟 lazy-free 時,有多個「子選項」可以控制,分別對應不同場景下,是否開啟非同步釋放記憶體:
- lazyfree-lazy-expire:key 在過期刪除時嘗試非同步釋放記憶體
- lazyfree-lazy-eviction:記憶體達到 maxmemory 並設定了淘汰策略時嘗試非同步釋放記憶體
- lazyfree-lazy-server-del:執行 RENAME/MOVE 等命令或需要覆蓋一個 key 時,Redis 內部刪除舊 key 嘗試非同步釋放記憶體
- replica-lazy-flush:主從全量同步,從庫清空資料庫時非同步釋放記憶體
-
即使開啟了 lazy-free,但如果執行的是 DEL 命令,則還是會同步釋放 key 記憶體,只有使用 UNLINK 命令才「可能」非同步釋放記憶體
-
Redis 6.0 版本新增了一個新的選項 lazyfree-lazy-user-del,開啟後執行 DEL 就與 UNLINK 效果一樣了
-
最關鍵的一點,開啟 lazy-free 後,除 replica-lazy-flush 之外,其它選項都只是「可能」非同步釋放 key 的記憶體,並不是說每次釋放 key 記憶體都是丟到後臺執行緒的
-
開啟 lazy-free 後,Redis 在釋放一個 key 記憶體時,首先會評估「代價」,如果代價很小,那麼就直接在「主執行緒」操作了,「沒必要」放到後臺執行緒中執行(不同執行緒傳遞資料也會有效能消耗)
-
什麼情況才會真正非同步釋放記憶體?這和 key 的型別、編碼方式、元素數量都有關係(詳見 lazyfreeGetFreeEffort 函式)
- 當 Hash/Set 底層採用雜湊表儲存(非 ziplist/int 編碼儲存)時,並且元素數量超過 64 個
- 當 ZSet 底層採用跳錶儲存(非 ziplist 編碼儲存)時,並且元素數量超過 64 個
- 當 List 連結串列節點數量超過 64 個(注意,不是元素數量,而是連結串列節點的數量,List 底層實現是一個連結串列,連結串列每個節點是一個 ziplist,一個 ziplist 可能有多個元素資料)
只有滿足以上條件,在釋放 key 記憶體時,才會真正放到「後臺執行緒」中執行,其它情況一律還是在主執行緒操作。
也就是說 String(不管記憶體佔用多大)、List(少量元素)、Set(int 編碼儲存)、Hash/ZSet(ziplist 編碼儲存)這些情況下的 key,在釋放記憶體時,依舊在「主執行緒」中操作。
-
可見,即使打開了 lazy-free,String 型別的 bigkey,在刪除時依舊有「阻塞」主執行緒的風險。所以,即便 Redis 提供了 lazy-free,還是不建議在 Redis 儲存 bigkey
-
Redis 在釋放記憶體「評估」代價時,不是看 key 的記憶體大小,而是關注釋放記憶體時的「工作量」有多大。從上面分析可以看出,如果 key 記憶體是連續的,釋放記憶體的代價就比較低,則依舊放在「主執行緒」處理。如果 key 記憶體不連續(包含大量指標),這個代價就比較高,這才會放在「後臺執行緒」中執行
-
freeMemoryIfNeeded 函式在使用後臺執行緒,刪除被淘汰資料的過程中,主執行緒是否仍然可以處理外部請求?
肯定是可以繼續處理請求的。 主執行緒決定淘汰這個 key 之後,會先把這個 key 從「全域性雜湊表」中剔除,然後評估釋放記憶體的代價,如果符合條件,則丟到「後臺執行緒」中執行「釋放記憶體」操作。
之後就可以繼續處理客戶端請求,儘管後臺執行緒還未完成釋放記憶體,但因為 key 已被全域性雜湊表剔除,所以主執行緒已查詢不到這個 key 了,對客戶端來說無影響。
可靠性保證模組
生成和解讀RDB檔案
- RDB 檔案是 Redis 的資料快照,以「二進位制」格式儲存,相比 AOF 檔案更小,寫盤和載入時間更短
- RDB 在執行 SAVE / BGSAVE 命令、定時 BGSAVE、主從複製時產生
- RDB 檔案包含檔案頭、資料部分、檔案尾
- 檔案頭主要包括 Redis 的魔數、RDB 版本、Redis 版本、RDB 建立時間、鍵值對佔用的記憶體大小等資訊
- 檔案資料部分包括整個 Redis 資料庫中儲存的所有鍵值對資訊
- 資料庫資訊:db 編號、db 中 key 的數量、過期 key 的數量、鍵值資料
- 鍵值資料:過期標識、時間戳(絕對時間)、鍵值對型別、key 長度、key、value 長度、value
- 檔案尾儲存了 RDB 的結束標記、檔案校驗值
- RDB 儲存的資料,為了壓縮體積,還做了很多優化:
- 變長編碼儲存鍵值對資料
- 用操作碼標識不同的內容
- 可整數編碼的內容使用整數型別緊湊編碼
AOF重寫
-
AOF 記錄的是每個命令的「操作歷史」,隨著時間增長,AOF 檔案會越來越大,所以需要 AOF 重寫來「瘦身」,減小檔案體積
-
AOF 重寫時,會掃描整個例項中的資料,把資料以「命令 + 鍵值對」的格式,寫到 AOF 檔案中
-
觸發 AOF 重寫的時機有 4 個:
- 執行 bgrewriteaof 命令
- 手動開啟 AOF 開關(config set appendonly yes)
- 從庫載入完主庫 RDB 後(AOF 被啟動的前提下)
- 定時觸發:AOF 檔案大小比例超出閾值、AOF 檔案大小絕對值超出閾值(AOF 被啟動的前提下)
這 4 個時機,都不能有 RDB 子程序,否則 AOF 重寫會延遲執行。
-
AOF 重寫期間會禁用 rehash,不讓父程序調整雜湊表大小,目的是父程序「寫時複製」拷貝大量記憶體頁面
-
為什麼 Redis 原始碼中在有 RDB 子程序執行時,不會啟動 AOF 重寫子程序?
無論是生成 RDB 還是 AOF 重寫,都需要建立子程序,然後把例項中的所有資料寫到磁碟上,這個過程中涉及到兩塊:
- CPU:寫盤之前需要先迭代例項中的所有資料,在這期間會耗費比較多的 CPU 資源,兩者同時進行,CPU 資源消耗大
- 磁碟:同樣地,RDB 和 AOF 重寫,都是把記憶體資料落盤,在這期間 Redis 會持續寫磁碟,如果同時進行,磁碟 IO 壓力也會較大
整體來說都是為了資源考慮,所以不會讓它們同時進行。
-
AOF 重寫是在子程序中執行,但在此期間父程序還會接收寫操作,為了保證新的 AOF 檔案資料更完整,所以父程序需要把在這期間的寫操作快取下來,然後發給子程序,讓子程序追加到 AOF 檔案中
-
因為需要父子程序傳輸資料,所以需要用到作業系統提供的程序間通訊機制,這裡 Redis 用的是「管道」,管道只能是一個程序寫,另一個程序讀,特點是單向傳輸
-
AOF 重寫時,父子程序用了 3 個管道,分別傳輸不同類別的資料:
- 父程序傳輸資料給子程序的管道:傳送 AOF 重寫期間新的寫操作
- 子程序完成重寫後通知父程序的管道:讓父程序停止傳送新的寫操作
- 父程序確認收到子程序通知的管道:父程序通知子程序已收到通知
-
AOF 重寫的完整流程是:父程序 fork 出子程序,子程序迭代例項所有資料,寫到一個臨時 AOF 檔案,在寫檔案期間,父程序收到新的寫操作,會先快取到 buf 中,之後父程序把 buf 中的資料,通過管道發給子程序,子程序寫完 AOF 檔案後,會從管道中讀取這些命令,再追加到 AOF 檔案中,最後 rename 這個臨時 AOF 檔案為新檔案,替換舊的 AOF 檔案,重寫結束
主從複製
-
Redis 主從複製分為 4 個階段:
- 初始化
- 建立連線
- 主從握手
- 資料傳輸(全量/增量複製)
-
主從複製流程由於是是「從庫」發起的,所以重點要看從庫的執行流程
-
從庫發起複製的方式有 3 個:
- 執行 slaveof / replicaof 命令
- 配置檔案配置了主庫的 ip port
- 啟動例項時指定了主庫的 ip port
-
建議從 slaveof / replicaof 命令跟原始碼進去,來看整個主從複製的流程(入口在 replication.c 的 replicaofCommand 函式)
-
從庫執行這個命令後,會先在 server 結構體上,記錄主庫的 ip port,然後把 server.repl_state 從 REPL_STATE_NONE 改為 REPL_STATE_CONNECT,「複製狀態機」啟動
-
隨後從庫會在定時任務(server.c 的 serverCron 函式)中會檢測 server.repl_state 的狀態,然後向主庫發起複製請求(replication.c 的 replicationCron 函式),進入複製流程(replication.c 的 connectWithMaster 函式)
-
從庫會與主庫建立連線(REPL_STATE_CONNECTING),註冊讀事件(syncWithMaster 函式),之後主從進入握手認證階段,從庫會告知主庫自己的 ip port 等資訊,在這期間會流轉多個狀態(server.h 中定義的複製狀態):
#define REPL_STATE_RECEIVE_PONG 3 /* Wait for PING reply */ #define REPL_STATE_SEND_AUTH 4 /* Send AUTH to master */ #define REPL_STATE_RECEIVE_AUTH 5 /* Wait for AUTH reply */ #define REPL_STATE_SEND_PORT 6 /* Send REPLCONF listening-port */ #define REPL_STATE_RECEIVE_PORT 7 /* Wait for REPLCONF reply */ #define REPL_STATE_SEND_IP 8 /* Send REPLCONF ip-address */ #define REPL_STATE_RECEIVE_IP 9 /* Wait for REPLCONF reply */ #define REPL_STATE_SEND_CAPA 10 /* Send REPLCONF capa */ #define REPL_STATE_RECEIVE_CAPA 11 /* Wait for REPLCONF reply */
-
完成握手後,從庫向主庫傳送 PSYNC 命令和自己的 offset,首先嚐試「增量同步」,如果 offset = -1,主庫返回 FULLRESYNC 表示「全量同步」資料,否則返回 CONTINUE 增量同步
-
如果是全量同步,主庫會先生成 RDB,從庫等待,主庫完成 RDB 後發給從庫,從庫接收 RDB,然後清空例項資料,載入 RDB,之後讀取主庫發來的「增量」資料
-
如果是增量同步,從庫只需接收主庫傳來的增量資料即可
-
當一個例項是主庫時,為什麼不需要使用狀態機來實現主庫在主從複製時的流程流轉?
因為複製資料的發起方是從庫,從庫要求複製資料會經歷多個階段(發起連線、握手認證、請求資料),而主庫只需要「被動」接收從庫的請求,根據需要「響應資料」即可完成整個流程,所以主庫不需要狀態機流轉。
哨兵
- 哨兵和 Redis 例項是一套程式碼,只不過哨兵會根據啟動引數(redis-sentinel 或 redis-server --sentinel),設定當前例項為哨兵模式(server.sentinel_mode = 1),然後初始化哨兵相關資料
- 哨兵模式的例項,只能執行一部分命令(ping、sentinel、subscribe、unsubscribe、psubscribe、punsubscribe、publish、info、role、client、shutdown、auth),其中 sentinel、publish、info、role 都是針對哨兵專門實現的
- 之後哨兵會初始化各種屬性,例如哨兵例項 ID、用於故障切換的當前紀元、監聽的主節點、正在執行的指令碼數量、與其他哨兵例項傳送的 IP 和埠號等資訊
- 啟動哨兵後,會檢查配置檔案是否可寫(不可寫直接退出,哨兵需把監控的例項資訊寫入配置檔案)、是否配置了哨兵 ID(沒配置隨機生成一個)
- 最後哨兵會在監控的 master 例項的 PubSub(+monitor 頻道)釋出一條訊息,表示哨兵開始監控 Redis 例項
- 哨兵後續會通過 PubSub 的方式,與主從庫、其它哨兵例項進行通訊
哨兵選舉-Raft
-
Redis 為了實現故障自動切換,引入了一個外部「觀察者」檢測例項的狀態,這個觀察者就是「哨兵」
-
但一個哨兵檢測例項,有可能因為網路原因導致「誤判」,所以需要「多個」哨兵共同判定
-
多個哨兵共同判定出例項故障後(主觀下線、客觀下線),會進入故障切換流程,切換時需要「選舉」出一個哨兵「領導者」進行操作
-
這個選舉的過程,就是「分散式共識」,即多個哨兵通過「投票」選舉出一個都認可的例項當領導者,由這個領導者發起切換,這個選舉使用的演算法是 Raft 演算法
-
嚴格來說,Raft 演算法的核心流程是這樣的:
- 叢集正常情況下,Leader 會持續給 Follower 發心跳訊息,維護 Leader 地位
- 如果 Follower 一段時間內收不到 Leader 心跳訊息,則變為 Candidate 發起選舉
- Candidate 先給自己投一票,然後向其它節點發送投票請求
- Candidate 收到超過半數確認票,則提升為新的 Leader,新 Leader 給其它 Follower 發心跳訊息,維護新的 Leader 地位
- Candidate 投票期間,收到了 Leader 心跳訊息,則自動變為 Follower
- 投票結束後,沒有超過半數確認票的例項,選舉失敗,會再次發起選舉
-
哨兵例項執行的週期性函式 sentinelTimer 的最後,修改 server.hz 的目的是什麼?
server.hz 表示執行定時任務函式 serverCron 的頻率,哨兵在最後修改 server.hz 增加一個隨機值,是為了避免多個哨兵以「相同頻率」執行,引發每個哨兵同時發起選舉,進而導致沒有一個哨兵能拿到多數投票,領導者選舉失敗的問題。適當打散執行頻率,可以有效降低選舉失敗的概率
-
一個哨兵檢測判定主庫故障,這個過程是「主觀下線」,另外這個哨兵還會向其它哨兵詢問(傳送 sentinel is-master-down-by-addr 命令),多個哨兵都檢測主庫故障,數量達到配置的 quorum 值,則判定為「客觀下線」
-
首先判定為客觀下線的哨兵,會發起選舉,讓其它哨兵給自己投票成為「領導者」,成為領導者的條件是,拿到超過「半數」的確認票 + 超過預設的 quorum 閾值的贊成票
-
投票過程中會比較哨兵和主庫的「紀元」(主庫紀元 < 發起投票哨兵的紀元 + 發起投票哨兵的紀元 > 其它哨兵的紀元),保證一輪投票中一個哨兵只能投一次票
Pub/Sub
- 哨兵是通過 master 的 PubSub 發現其它哨兵的:每個哨兵向 master 的 PubSub(sentinel:hello 頻道)釋出訊息,同時也會訂閱這個頻道,這樣每個哨兵就能拿到其它哨兵的 IP、埠等資訊
- 每個哨兵有了其它哨兵的資訊後,在判定 Redis 例項狀態時,就可以互相通訊、交換資訊,共同判定例項是否真的故障
- 哨兵判定 Redis 例項故障、發起切換時,都會向 master 的 PubSub 的頻道釋出訊息
- 客戶端可以訂閱 master 的 PubSub,感知到哨兵工作到了哪個狀態節點,從而作出自己的反應
- PubSub 的實現,其實就是 Redis 在記憶體中維護了一個「釋出-訂閱」對映表,訂閱者執行 SUBSCRIBE 命令,Redis 會把訂閱者加入到指定頻道的「連結串列」下。釋出者執行 PUBLISH,Redis 就找到這個對映表中這個頻道的所有「訂閱者」,把訊息「實時轉發」給這些訂閱者
Redis Cluster模組
Gossip協議的實現
- 多個節點組成一個分散式系統,它們之間需要交換資料,可以採用中心化的方式(依賴第三方系統,例如ZK),也可以採用非中心化(分散式協議,例如 Gossip)的方式
- Redis Cluster 採用非中心化的方式 Gossip 協議,實現多個節點之間資訊交換
- 叢集中的每個例項,會按照固定頻率,從叢集中「隨機」挑選部分例項,傳送 PING 訊息(自身例項狀態、已知部分例項資訊、slots 分佈),用來交換彼此狀態資訊
- 收到 PING 的例項,會響應 PONG 訊息,PONG 訊息和 PING 訊息格式一樣,包含了自身例項狀態、已知部分例項資訊、slots 分佈
- 這樣經過幾次交換後,叢集中每個例項都能拿到其它例項的狀態資訊
- 即使有節點狀態發生變化(新例項加入、節點故障、資料遷移),也可以通過 Gossip 協議的 PING-PONG 訊息完成整個叢集狀態在每個例項上的同步
MOVED、ASK
- cluster 模式的 Redis,在執行命令階段,需要判斷 key 是否屬於本例項,不屬於會給客戶端返回請求重定向的資訊
- 判斷 key 是否屬於本例項,會先計算 key 所屬的 slot,再根據 slot 定位屬於哪個例項
- 找不到 key 所屬的例項,或者操作的多個 key 不在同一個 slot,則會給客戶端返回錯誤;ke y 正在做資料遷出,並且訪問的這個 key 不在本例項中,會給客戶端返回 ASK,讓客戶端去目標節點再次查詢一次(臨時重定向);key 所屬的 slot 不是本例項,而是其它節點,會給客戶端返回 MOVED,告知客戶端 key 不在本例項,以後都去目標節點查詢(永久重定向)
Redis Cluster資料遷移
- Redis Cluster 因為是多個例項共同組成的叢集,所以當叢集中有節點下線、新節點加入、資料不均衡時,需要做資料遷移,把某些例項中的資料,遷移到其它例項上
- 資料遷移分為 5 個階段
- 標記遷入、遷出節點
- 獲取待遷出的 keys
- 源節點實際遷移資料
- 目的節點處理遷移資料
- 標記遷移結果
- 獲取待遷出的 keys 會用 CLUSTER GETKEYSINSLOT 命令,可返回指定 slot 下的 keys
- 從源節點遷出資料,會呼叫 MIGRATE 命令,該命令可指定一批 key,遷移到目標 Redis 例項。遷移時,源節點會把 key-value 序列化,然後傳輸給目標節點
- 目標節點收到源節點發來的資料後,會執行 RESTORE 命令邏輯,校驗序列化的資料格式是否正確,正確則解析資料,把資料新增到例項中