11.20 阿里P9架構師分享:通俗易懂Redis原理,都是你沒看過的

前言

Redis 是如今互聯網技術架構中,使用最廣泛的緩存。支持複雜的數據結構,支持持久化,支持主從集群,支持高可用,支持較大的value存儲...

阿里P9架構師分享:通俗易懂Redis原理,都是你沒看過的

同時, Redis 也是中高級後端工程師技術面試中,面試官最喜歡問的問題之一。特別是那些優秀的、競爭激烈的大廠,通常要求面試者不僅僅掌握 Redis 基礎使用,更要求深層理解 Redis 內部實現的細節原理。毫不誇張地說,能把 Redis 的知識點全部吃透,你的半隻腳就已經踏進心儀大公司的技術研發部。

然而,絕大部分開發者只會拿 Redis 做數據緩存,使用最簡單的 get/set 方法,除此之外幾乎一片茫然,對 Redis 內部實現的細節原理知之甚少。例如:

  1. 有同學知道 Redis 的分佈式鎖,但完全不清楚其內部實現機制
  2. 有同學知道 Redis 是單線程結構,但完全不理解 Redis 緣何單線程還可以支持高併發
  3. 有同學知道 Redis 支持主從,但完全不曉得內部的實現機制

Redis 特點如下:

  • 數據類型豐富
  • 支持數據磁盤持久化存儲
  • 支持主從
  • 支持分片

為什麼 Redis 能這麼快

Redis 的效率很高,官方給出的數據是 100000+QPS,這是因為:

  • Redis 完全基於內存,絕大部分請求是純粹的內存操作,執行效率高。
  • Redis 使用單進程單線程模型的(K,V)數據庫,將數據存儲在內存中,存取均不會受到硬盤 IO 的限制,因此其執行速度極快。

另外單線程也能處理高併發請求,還可以避免頻繁上下文切換和鎖的競爭,如果想要多核運行也可以啟動多個實例。

  • 數據結構簡單,對數據操作也簡單,Redis 不使用表,不會強制用戶對各個關係進行關聯,不會有複雜的關係限制,其存儲結構就是鍵值對,類似於 HashMap,HashMap 最大的優點就是存取的時間複雜度為 O(1)。
  • Redis 使用多路 I/O 複用模型,為非阻塞 IO。

注:Redis 採用的 I/O 多路複用函數:epoll/kqueue/evport/select。

選用策略:

  • 因地制宜,優先選擇時間複雜度為 O(1) 的 I/O 多路複用函數作為底層實現。
  • 由於 Select 要遍歷每一個 IO,所以其時間複雜度為 O(n),通常被作為保底方案。
  • 基於 React 設計模式監聽 I/O 事件。

Redis 的數據類型

String

最基本的數據類型,其值最大可存儲 512M,二進制安全(Redis 的 String 可以包含任何二進制數據,包含 jpg 對象等)。

阿里P9架構師分享:通俗易懂Redis原理,都是你沒看過的

注:如果重複寫入 key 相同的鍵值對,後寫入的會將之前寫入的覆蓋。

Hash

String 元素組成的字典,適用於存儲對象。

阿里P9架構師分享:通俗易懂Redis原理,都是你沒看過的

List

列表,按照 String 元素插入順序排序。其順序為後進先出。由於其具有棧的特性,所以可以實現如“最新消息排行榜”這類的功能。

Set

String 元素組成的無序集合,通過哈希表實現(增刪改查時間複雜度為 O(1)),不允許重複。

阿里P9架構師分享:通俗易懂Redis原理,都是你沒看過的

另外,當我們使用 Smembers 遍歷 Set 中的元素時,其順序也是不確定的,是通過 Hash 運算過後的結果。

Redis 還對集合提供了求交集、並集、差集等操作,可以實現如同共同關注,共同好友等功能。

Sorted Set

通過分數來為集合中的成員進行從小到大的排序。

阿里P9架構師分享:通俗易懂Redis原理,都是你沒看過的

更高級的 Redis 類型

用於計數的 HyperLogLog、用於支持存儲地理位置信息的 Geo。

從海量 Key 裡查詢出某一個固定前綴的 Key

假設 Redis 中有十億條 Key,如何從這麼多 Key 中找到固定前綴的 Key?

方法 1:使用 Keys [pattern]:查找所有符合給定模式 Pattern 的 Key

使用 Keys [pattern] 指令可以找到所有符合 Pattern 條件的 Key,但是 Keys 會一次性返回所有符合條件的 Key,所以會造成 Redis 的卡頓。

假設 Redis 此時正在生產環境下,使用該命令就會造成隱患,另外如果一次性返回所有 Key,對內存的消耗在某些條件下也是巨大的。

例:

keys test* //返回所有以test為前綴的key 

方法 2:使用 SCAN cursor [MATCH pattern] [COUNT count]

注:

  • cursor:遊標
  • MATCH pattern:查詢 Key 的條件
  • Count:返回的條數

SCAN 是一個基於遊標的迭代器,需要基於上一次的遊標延續之前的迭代過程。

SCAN 以 0 作為遊標,開始一次新的迭代,直到命令返回遊標 0 完成一次遍歷。

此命令並不保證每次執行都返回某個給定數量的元素,甚至會返回 0 個元素,但只要遊標不是 0,程序都不會認為 SCAN 命令結束,但是返回的元素數量大概率符合 Count 參數。另外,SCAN 支持模糊查詢。

例:

SCAN 0 MATCH test* COUNT 10 //每次返回10條以test為前綴的key 

如何通過 Redis 實現分佈式鎖

分佈式鎖

分佈式鎖是控制分佈式系統之間共同訪問共享資源的一種鎖的實現。如果一個系統,或者不同系統的不同主機之間共享某個資源時,往往需要互斥,來排除干擾,滿足數據一致性。

分佈式鎖需要解決的問題如下:

  • 互斥性:任意時刻只有一個客戶端獲取到鎖,不能有兩個客戶端同時獲取到鎖。
  • 安全性:鎖只能被持有該鎖的客戶端刪除,不能由其他客戶端刪除。
  • 死鎖:獲取鎖的客戶端因為某些原因而宕機繼而無法釋放鎖,其他客戶端再也無法獲取鎖而導致死鎖,此時需要有特殊機制來避免死鎖。
  • 容錯:當各個節點,如某個 Redis 節點宕機的時候,客戶端仍然能夠獲取鎖或釋放鎖。

如何使用 Redis 實現分佈式鎖

使用 SETNX 實現,SETNX key value:如果 Key 不存在,則創建並賦值。

該命令時間複雜度為 O(1),如果設置成功,則返回 1,否則返回 0。

阿里P9架構師分享:通俗易懂Redis原理,都是你沒看過的

由於 SETNX 指令操作簡單,且是原子性的,所以初期的時候經常被人們作為分佈式鎖,我們在應用的時候,可以在某個共享資源區之前先使用 SETNX 指令,查看是否設置成功。

如果設置成功則說明前方沒有客戶端正在訪問該資源,如果設置失敗則說明有客戶端正在訪問該資源,那麼當前客戶端就需要等待。

但是如果真的這麼做,就會存在一個問題,因為 SETNX 是長久存在的,所以假設一個客戶端正在訪問資源,並且上鎖,那麼當這個客戶端結束訪問時,該鎖依舊存在,後來者也無法成功獲取鎖,這個該如何解決呢?

由於 SETNX 並不支持傳入 EXPIRE 參數,所以我們可以直接使用 EXPIRE 指令來對特定的 Key 來設置過期時間。

用法:

EXPIRE key seconds 
阿里P9架構師分享:通俗易懂Redis原理,都是你沒看過的

程序:

RedisService redisService = SpringUtils.getBean(RedisService.class); 
long status = redisService.setnx(key,"1");
if(status == 1){
redisService.expire(key,expire);
doOcuppiedWork();
}

這段程序存在的問題:假設程序運行到第二行出現異常,那麼程序來不及設置過期時間就結束了,則 Key 會一直存在,等同於鎖一直被持有無法釋放。

出現此問題的根本原因為:原子性得不到滿足。

解決:從 Redis 2.6.12 版本開始,我們就可以使用 Set 操作,將 SETNX 和 EXPIRE 融合在一起執行,具體做法如下:

  • EX second:設置鍵的過期時間為 Second 秒。
  • PX millisecond:設置鍵的過期時間為 MilliSecond 毫秒。
  • NX:只在鍵不存在時,才對鍵進行設置操作。
  • XX:只在鍵已經存在時,才對鍵進行設置操作。
SET KEY value [EX seconds] [PX milliseconds] [NX|XX] 

注:SET 操作成功完成時才會返回 OK,否則返回 nil。

有了 SET 我們就可以在程序中使用類似下面的代碼實現分佈式鎖了:

RedisService redisService = SpringUtils.getBean(RedisService.class); 
String result = redisService.set(lockKey,requestId,SET_IF_NOT_EXIST,SET_WITH_EXPIRE_TIME,expireTime);
if("OK.equals(result)"){
doOcuppiredWork();
}

如何實現異步隊列

①使用 Redis 中的 List 作為隊列

使用上文所說的 Redis 的數據結構中的 List 作為隊列 Rpush 生產消息,LPOP 消費消息。

阿里P9架構師分享:通俗易懂Redis原理,都是你沒看過的

此時我們可以看到,該隊列是使用 Rpush 生產隊列,使用 LPOP 消費隊列。

在這個生產者-消費者隊列裡,當 LPOP 沒有消息時,證明該隊列中沒有元素,並且生產者還沒有來得及生產新的數據。

缺點:LPOP 不會等待隊列中有值之後再消費,而是直接進行消費。

彌補:可以通過在應用層引入 Sleep 機制去調用 LPOP 重試。

②使用 BLPOP key [key…] timeout

BLPOP key [key …] timeout:阻塞直到隊列有消息或者超時。

阿里P9架構師分享:通俗易懂Redis原理,都是你沒看過的

阿里P9架構師分享:通俗易懂Redis原理,都是你沒看過的

阿里P9架構師分享:通俗易懂Redis原理,都是你沒看過的

缺點:按照此種方法,我們生產後的數據只能提供給各個單一消費者消費。能否實現生產一次就能讓多個消費者消費呢?

③Pub/Sub:主題訂閱者模式

發送者(Pub)發送消息,訂閱者(Sub)接收消息。訂閱者可以訂閱任意數量的頻道。

阿里P9架構師分享:通俗易懂Redis原理,都是你沒看過的

Pub/Sub模式的缺點:消息的發佈是無狀態的,無法保證可達。對於發佈者來說,消息是“即發即失”的。

此時如果某個消費者在生產者發佈消息時下線,重新上線之後,是無法接收該消息的,要解決該問題需要使用專業的消息隊列,如 Kafka…此處不再贅述。

Redis 持久化

什麼是持久化

持久化,即將數據持久存儲,而不因斷電或其他各種複雜外部環境影響數據的完整性。

由於 Redis 將數據存儲在內存而不是磁盤中,所以內存一旦斷電,Redis 中存儲的數據也隨即消失,這往往是用戶不期望的,所以 Redis 有持久化機制來保證數據的安全性。

Redis 如何做持久化

Redis 目前有兩種持久化方式,即 RDB 和 AOF,RDB 是通過保存某個時間點的全量數據快照實現數據的持久化,當恢復數據時,直接通過 RDB 文件中的快照,將數據恢復。

RDB(快照)持久化

RDB持久化會在某個特定的間隔保存那個時間點的全量數據的快照。

RDB 配置文件,redis.conf:

save 900 1 #在900s內如果有1條數據被寫入,則產生一次快照。 save 300 10 #在300s內如果有10條數據被寫入,則產生一次快照 save 60 10000 #在60s內如果有10000條數據被寫入,則產生一次快照 stop-writes-on-bgsave-error yes #stop-writes-on-bgsave-error : 如果為yes則表示,當備份進程出錯的時候, 主進程就停止進行接受新的寫入操作,這樣是為了保護持久化的數據一致性的問題。

①RDB 的創建與載入

SAVE:阻塞 Redis 的服務器進程,直到 RDB 文件被創建完畢。SAVE 命令很少被使用,因為其會阻塞主線程來保證快照的寫入,由於 Redis 是使用一個主線程來接收所有客戶端請求,這樣會阻塞所有客戶端請求。

BGSAVE:該指令會 Fork 出一個子進程來創建 RDB 文件,不阻塞服務器進程,子進程接收請求並創建 RDB 快照,父進程繼續接收客戶端的請求。

子進程在完成文件的創建時會向父進程發送信號,父進程在接收客戶端請求的過程中,在一定的時間間隔通過輪詢來接收子進程的信號。

我們也可以通過使用 lastsave 指令來查看 BGSAVE 是否執行成功,lastsave 可以返回最後一次執行成功 BGSAVE 的時間。

②自動化觸發 RDB 持久化的方式

自動化觸發RDB持久化的方式如下:

  • 根據 redis.conf 配置裡的 SAVE m n 定時觸發(實際上使用的是 BGSAVE)。
  • 主從複製時,主節點自動觸發。
  • 執行 Debug Reload。
  • 執行 Shutdown 且沒有開啟 AOF 持久化。

③BGSAVE 的原理

阿里P9架構師分享:通俗易懂Redis原理,都是你沒看過的

啟動:

  • 檢查是否存在子進程正在執行 AOF 或者 RDB 的持久化任務。如果有則返回 false。
  • 調用 Redis 源碼中的 rdbSaveBackground 方法,方法中執行 fork() 產生子進程執行 RDB 操作。
  • 關於 fork() 中的 Copy-On-Write。

fork() 在 Linux 中創建子進程採用 Copy-On-Write(寫時拷貝技術),即如果有多個調用者同時要求相同資源(如內存或磁盤上的數據存儲)。

他們會共同獲取相同的指針指向相同的資源,直到某個調用者試圖修改資源的內容時,系統才會真正複製一份專用副本給調用者,而其他調用者所見到的最初的資源仍然保持不變。

④RDB 持久化方式的缺點

RDB 持久化方式的缺點如下:

  • 內存數據全量同步,數據量大的狀況下,會由於 I/O 而嚴重影響性能。
  • 可能會因為 Redis 宕機而丟失從當前至最近一次快照期間的數據。

AOF 持久化:保存寫狀態

AOF 持久化是通過保存 Redis 的寫狀態來記錄數據庫的。

相對 RDB 來說,RDB 持久化是通過備份數據庫的狀態來記錄數據庫,而 AOF 持久化是備份數據庫接收到的指令:

  • AOF 記錄除了查詢以外的所有變更數據庫狀態的指令。
  • 以增量的形式追加保存到 AOF 文件中。

開啟 AOF 持久化

①打開 redis.conf 配置文件,將 appendonly 屬性改為 yes。

②修改 appendfsync 屬性,該屬性可以接收三種參數,分別是 always,everysec,no。

always 表示總是即時將緩衝區內容寫入 AOF 文件當中,everysec 表示每隔一秒將緩衝區內容寫入 AOF 文件,no 表示將寫入文件操作交由操作系統決定。

一般來說,操作系統考慮效率問題,會等待緩衝區被填滿再將緩衝區數據寫入 AOF 文件中。

appendonly yes 

#appendsync always
appendfsync everysec
# appendfsync no

日誌重寫解決 AOF 文件不斷增大

隨著寫操作的不斷增加,AOF 文件會越來越大。假設遞增一個計數器 100 次,如果使用 RDB 持久化方式,我們只要保存最終結果 100 即可。

而 AOF 持久化方式需要記錄下這 100 次遞增操作的指令,而事實上要恢復這條記錄,只需要執行一條命令就行,所以那一百條命令實際可以精簡為一條。

Redis 支持這樣的功能,在不中斷前臺服務的情況下,可以重寫 AOF 文件,同樣使用到了 COW(寫時拷貝)。

重寫過程如下:

  • 調用 fork(),創建一個子進程。
  • 子進程把新的 AOF 寫到一個臨時文件裡,不依賴原來的 AOF 文件。
  • 主進程持續將新的變動同時寫到內存和原來的 AOF 裡。
  • 主進程獲取子進程重寫 AOF 的完成信號,往新 AOF 同步增量變動。
  • 使用新的 AOF 文件替換掉舊的 AOF 文件。

AOF 和 RDB 的優缺點

AOF 和 RDB 的優缺點如下:

  • RDB 優點:全量數據快照,文件小,恢復快。
  • RDB 缺點:無法保存最近一次快照之後的數據。
  • AOF 優點:可讀性高,適合保存增量數據,數據不易丟失。
  • AOF 缺點:文件體積大,恢復時間長。

RDB-AOF 混合持久化方式

Redis 4.0 之後推出了此種持久化方式,RDB 作為全量備份,AOF 作為增量備份,並且將此種方式作為默認方式使用。

在上述兩種方式中,RDB 方式是將全量數據寫入 RDB 文件,這樣寫入的特點是文件小,恢復快,但無法保存最近一次快照之後的數據,AOF 則將 Redis 指令存入文件中,這樣又會造成文件體積大,恢復時間長等弱點。

在 RDB-AOF 方式下,持久化策略首先將緩存中數據以 RDB 方式全量寫入文件,再將寫入後新增的數據以 AOF 的方式追加在 RDB 數據的後面,在下一次做 RDB 持久化的時候將 AOF 的數據重新以 RDB 的形式寫入文件。

這種方式既可以提高讀寫和恢復效率,也可以減少文件大小,同時可以保證數據的完整性。

在此種策略的持久化過程中,子進程會通過管道從父進程讀取增量數據,在以 RDB 格式保存全量數據時,也會通過管道讀取數據,同時不會造成管道阻塞。

可以說,在此種方式下的持久化文件,前半段是 RDB 格式的全量數據,後半段是 AOF 格式的增量數據。此種方式是目前較為推薦的一種持久化方式。

Redis 數據的恢復

RDB 和 AOF 文件共存情況下的恢復流程如下圖:

阿里P9架構師分享:通俗易懂Redis原理,都是你沒看過的

從圖可知,Redis 啟動時會先檢查 AOF 是否存在,如果 AOF 存在則直接加載 AOF,如果不存在 AOF,則直接加載 RDB 文件。

Pineline

Pipeline 和 Linux 的管道類似,它可以讓 Redis 批量執行指令。

Redis 基於請求/響應模型,單個請求處理需要一一應答。如果需要同時執行大量命令,則每條命令都需要等待上一條命令執行完畢後才能繼續執行,這中間不僅僅多了 RTT,還多次使用了系統 IO。

Pipeline 由於可以批量執行指令,所以可以節省多次 IO 和請求響應往返的時間。但是如果指令之間存在依賴關係,則建議分批發送指令。

Redis 的同步機制

主從同步原理

Redis 一般是使用一個 Master 節點來進行寫操作,而若干個 Slave 節點進行讀操作,Master 和 Slave 分別代表了一個個不同的 Redis Server 實例。

另外定期的數據備份操作也是單獨選擇一個 Slave 去完成,這樣可以最大程度發揮 Redis 的性能,為的是保證數據的弱一致性和最終一致性。

另外,Master 和 Slave 的數據不是一定要即時同步的,但是在一段時間後 Master 和 Slave 的數據是趨於同步的,這就是最終一致性。

阿里P9架構師分享:通俗易懂Redis原理,都是你沒看過的

全同步過程如下:

  • Slave 發送 Sync 命令到 Master。
  • Master 啟動一個後臺進程,將 Redis 中的數據快照保存到文件中。
  • Master 將保存數據快照期間接收到的寫命令緩存起來。
  • Master 完成寫文件操作後,將該文件發送給 Slave。
  • 使用新的 AOF 文件替換掉舊的 AOF 文件。
  • Master 將這期間收集的增量寫命令發送給 Slave 端。

增量同步過程如下:

  • Master 接收到用戶的操作指令,判斷是否需要傳播到 Slave。
  • 將操作記錄追加到 AOF 文件。
  • 將操作傳播到其他 Slave:對齊主從庫;往響應緩存寫入指令。
  • 將緩存中的數據發送給 Slave。

Redis Sentinel(哨兵)

主從模式弊端:當 Master 宕機後,Redis 集群將不能對外提供寫入操作。Redis Sentinel 可解決這一問題。

解決主從同步 Master 宕機後的主從切換問題:

監控:檢查主從服務器是否運行正常。

提醒:通過 API 向管理員或者其它應用程序發送故障通知。

自動故障遷移:主從切換(在 Master 宕機後,將其中一個 Slave 轉為 Master,其他的 Slave 從該節點同步數據)。

Redis 集群

如何從海量數據裡快速找到所需?

①分片

按照某種規則去劃分數據,分散存儲在多個節點上。通過將數據分到多個 Redis 服務器上,來減輕單個 Redis 服務器的壓力。

②一致性 Hash 算法

既然要將數據進行分片,那麼通常的做法就是獲取節點的 Hash 值,然後根據節點數求模。

但這樣的方法有明顯的弊端,當 Redis 節點數需要動態增加或減少的時候,會造成大量的 Key 無法被命中。所以 Redis 中引入了一致性 Hash 算法。

該算法對 2^32 取模,將 Hash 值空間組成虛擬的圓環,整個圓環按順時針方向組織,每個節點依次為 0、1、2…2^32-1。

之後將每個服務器進行 Hash 運算,確定服務器在這個 Hash 環上的地址,確定了服務器地址後,對數據使用同樣的 Hash 算法,將數據定位到特定的 Redis 服務器上。

如果定位到的地方沒有 Redis 服務器實例,則繼續順時針尋找,找到的第一臺服務器即該數據最終的服務器位置。

阿里P9架構師分享:通俗易懂Redis原理,都是你沒看過的

③Hash 環的數據傾斜問題

Hash 環在服務器節點很少的時候,容易遇到服務器節點不均勻的問題,這會造成數據傾斜,數據傾斜指的是被緩存的對象大部分集中在 Redis 集群的其中一臺或幾臺服務器上。

阿里P9架構師分享:通俗易懂Redis原理,都是你沒看過的

如上圖,一致性 Hash 算法運算後的數據大部分被存放在 A 節點上,而 B 節點只存放了少量的數據,久而久之 A 節點將被撐爆。

針對這一問題,可以引入虛擬節點解決。簡單地說,就是為每一個服務器節點計算多個 Hash,每個計算結果位置都放置一個此服務器節點,稱為虛擬節點,可以在服務器 IP 或者主機名後放置一個編號實現。

例如上圖:將 NodeA 和 NodeB 兩個節點分為 Node A#1-A#3,NodeB#1-B#3。

結語

這篇準(偷)備(懶)了相當久的時間,因為有些東西總感覺自己拿不準不敢往上寫,差點自閉,如果有同學覺得哪裡寫的不對勁的,評論區留言。


分享到:


相關文章: