大家好,我是小林。
不知不覺《圖解 Redis》系列文章寫了很多了,考慮到一些同學面試突擊 Redis,所以我整理了 3 萬字 + 40 張圖的 Redis 八股文,共收集了 40 多個面試題。
發(fā)車!
認識 Redis
什么是 Redis?
我們直接看 Redis 官方是怎么介紹自己的。
Redis 官方的介紹原版是英文的,我翻譯成了中文后截圖的,所以有些文字讀起來會比較拗口,沒關系,我會把里面比較重要的特性抽出來講一下。
Redis 是一種基于內(nèi)存的數(shù)據(jù)庫,對數(shù)據(jù)的讀寫操作都是在內(nèi)存中完成,因此讀寫速度非???/strong>,常用于緩存,消息隊列、分布式鎖等場景。
Redis 提供了多種數(shù)據(jù)類型來支持不同的業(yè)務場景,比如 String(字符串)、Hash(哈希)、 List (列表)、Set(集合)、Zset(有序集合)、Bitmaps(位圖)、HyperLogLog(基數(shù)統(tǒng)計)、GEO(地理信息)、Stream(流),并且對數(shù)據(jù)類型的操作都是原子性的,因為執(zhí)行命令由單線程負責的,不存在并發(fā)競爭的問題。
除此之外,Redis 還支持事務 、持久化、Lua 腳本、多種集群方案(主從復制模式、哨兵模式、切片機群模式)、發(fā)布/訂閱模式,內(nèi)存淘汰機制、過期刪除機制等等。
Redis 和 Memcached 有什么區(qū)別?
很多人都說用 Redis 作為緩存,但是 Memcached 也是基于內(nèi)存的數(shù)據(jù)庫,為什么不選擇它作為緩存呢?要解答這個問題,我們就要弄清楚 Redis 和 Memcached 的區(qū)別。
Redis 與 Memcached 共同點:
- 都是基于內(nèi)存的數(shù)據(jù)庫,一般都用來當做緩存使用。都有過期策略。兩者的性能都非常高。
Redis 與 Memcached 區(qū)別:
- Redis 支持的數(shù)據(jù)類型更豐富(String、Hash、List、Set、ZSet),而 Memcached 只支持最簡單的 key-value 數(shù)據(jù)類型;Redis 支持數(shù)據(jù)的持久化,可以將內(nèi)存中的數(shù)據(jù)保持在磁盤中,重啟的時候可以再次加載進行使用,而 Memcached 沒有持久化功能,數(shù)據(jù)全部存在內(nèi)存之中,Memcached 重啟或者掛掉后,數(shù)據(jù)就沒了;Redis 原生支持集群模式,Memcached 沒有原生的集群模式,需要依靠客戶端來實現(xiàn)往集群中分片寫入數(shù)據(jù);Redis 支持發(fā)布訂閱模型、Lua 腳本、事務等功能,而 Memcached 不支持;
為什么用 Redis 作為 MySQL 的緩存?
主要是因為 Redis 具備「高性能」和「高并發(fā)」兩種特性。
1、Redis 具備高性能
假如用戶第一次訪問 MySQL 中的某些數(shù)據(jù)。這個過程會比較慢,因為是從硬盤上讀取的。將該用戶訪問的數(shù)據(jù)緩存在 Redis 中,這樣下一次再訪問這些數(shù)據(jù)的時候就可以直接從緩存中獲取了,操作 Redis 緩存就是直接操作內(nèi)存,所以速度相當快。
如果 MySQL 中的對應數(shù)據(jù)改變的之后,同步改變 Redis 緩存中相應的數(shù)據(jù)即可,不過這里會有 Redis 和 MySQL 雙寫一致性的問題,后面我們會提到。
2、 Redis 具備高并發(fā)
單臺設備的 Redis 的 QPS(Query Per Second,每秒鐘處理完請求的次數(shù)) 是 MySQL 的 10 倍,Redis 單機的 QPS 能輕松破 10w,而 MySQL 單機的 QPS 很難破 ?1w。
所以,直接訪問 Redis 能夠承受的請求是遠遠大于直接訪問 MySQL 的,所以我們可以考慮把數(shù)據(jù)庫中的部分數(shù)據(jù)轉(zhuǎn)移到緩存中去,這樣用戶的一部分請求會直接到緩存這里而不用經(jīng)過數(shù)據(jù)庫。
Redis 數(shù)據(jù)結(jié)構(gòu)
Redis 數(shù)據(jù)類型以及使用場景分別是什么?
Redis 提供了豐富的數(shù)據(jù)類型,常見的有五種數(shù)據(jù)類型:String(字符串),Hash(哈希),List(列表),Set(集合)、Zset(有序集合)。
隨著 Redis 版本的更新,后面又支持了四種數(shù)據(jù)類型: BitMap(2.2 版新增)、HyperLogLog(2.8 版新增)、GEO(3.2 版新增)、Stream(5.0 版新增)。
Redis 五種數(shù)據(jù)類型的應用場景:
- String 類型的應用場景:緩存對象、常規(guī)計數(shù)、分布式鎖、共享 session 信息等。List 類型的應用場景:消息隊列(但是有兩個問題:1. 生產(chǎn)者需要自行實現(xiàn)全局唯一 ID;2. 不能以消費組形式消費數(shù)據(jù))等。Hash 類型:緩存對象、購物車等。Set 類型:聚合計算(并集、交集、差集)場景,比如點贊、共同關注、抽獎活動等。Zset 類型:排序場景,比如排行榜、電話和姓名排序等。
Redis 后續(xù)版本又支持四種數(shù)據(jù)類型,它們的應用場景如下:
- BitMap(2.2 版新增):二值狀態(tài)統(tǒng)計的場景,比如簽到、判斷用戶登陸狀態(tài)、連續(xù)簽到用戶總數(shù)等;HyperLogLog(2.8 版新增):海量數(shù)據(jù)基數(shù)統(tǒng)計的場景,比如百萬級網(wǎng)頁 UV 計數(shù)等;GEO(3.2 版新增):存儲地理位置信息的場景,比如滴滴叫車;Stream(5.0 版新增):消息隊列,相比于基于 List 類型實現(xiàn)的消息隊列,有這兩個特有的特性:自動生成全局唯一消息ID,支持以消費組形式消費數(shù)據(jù)。
::: tip
想深入了解這 9 種數(shù)據(jù)類型,可以看這篇:2萬字 + 20 張圖 | 細說 Redis 常見數(shù)據(jù)類型和應用場景
五種常見的 Redis 數(shù)據(jù)類型是怎么實現(xiàn)?
我畫了一張 Redis 數(shù)據(jù)類型和底層數(shù)據(jù)結(jié)構(gòu)的對應關圖,左邊是 Redis 3.0版本的,也就是《Redis 設計與實現(xiàn)》這本書講解的版本,現(xiàn)在看還是有點過時了,右邊是現(xiàn)在 Redis 7.0 版本的。
String 類型內(nèi)部實現(xiàn)
String 類型的底層的數(shù)據(jù)結(jié)構(gòu)實現(xiàn)主要是 SDS(簡單動態(tài)字符串)。
SDS 和我們認識的 C 字符串不太一樣,之所以沒有使用 C 語言的字符串表示,因為 SDS 相比于 C 的原生字符串:
SDS 不僅可以保存文本數(shù)據(jù),還可以保存二進制數(shù)據(jù)
-
- 。因為 SDS 使用 len 屬性的值而不是空字符來判斷字符串是否結(jié)束,并且 SDS 的所有 API 都會以處理二進制的方式來處理 SDS 存放在 buf[] 數(shù)組里的數(shù)據(jù)。所以 SDS 不光能存放文本數(shù)據(jù),而且能保存圖片、音頻、視頻、壓縮文件這樣的二進制數(shù)據(jù)。**SDS 獲取字符串長度的時間復雜度是 O(1)**。因為 C 語言的字符串并不記錄自身長度,所以獲取長度的復雜度為 O(n);而 SDS 結(jié)構(gòu)里用 len 屬性記錄了字符串長度,所以復雜度為 O(1)。
Redis 的 SDS API 是安全的,拼接字符串不會造成緩沖區(qū)溢出
- 。因為 SDS 在拼接字符串之前會檢查 SDS 空間是否滿足要求,如果空間不夠會自動擴容,所以不會導致緩沖區(qū)溢出的問題。
List 類型內(nèi)部實現(xiàn)
List 類型的底層數(shù)據(jù)結(jié)構(gòu)是由雙向鏈表或壓縮列表實現(xiàn)的:
-
- 如果列表的元素個數(shù)小于 512 個(默認值,可由 list-max-ziplist-entries 配置),列表每個元素的值都小于 64 字節(jié)(默認值,可由 list-max-ziplist-value 配置),Redis 會使用
壓縮列表
-
- 作為 List 類型的底層數(shù)據(jù)結(jié)構(gòu);如果列表的元素不滿足上面的條件,Redis 會使用
雙向鏈表
- 作為 List 類型的底層數(shù)據(jù)結(jié)構(gòu);
但是在 Redis 3.2 版本之后,List 數(shù)據(jù)類型底層數(shù)據(jù)結(jié)構(gòu)就只由 quicklist 實現(xiàn)了,替代了雙向鏈表和壓縮列表。
Hash 類型內(nèi)部實現(xiàn)
Hash 類型的底層數(shù)據(jù)結(jié)構(gòu)是由壓縮列表或哈希表實現(xiàn)的:
-
- 如果哈希類型元素個數(shù)小于 512 個(默認值,可由 hash-max-ziplist-entries 配置),所有值小于 64 字節(jié)(默認值,可由 hash-max-ziplist-value 配置)的話,Redis 會使用
壓縮列表
-
- 作為 Hash 類型的底層數(shù)據(jù)結(jié)構(gòu);如果哈希類型元素不滿足上面條件,Redis 會使用
哈希表
- 作為 Hash 類型的底層數(shù)據(jù)結(jié)構(gòu)。
在 Redis 7.0 中,壓縮列表數(shù)據(jù)結(jié)構(gòu)已經(jīng)廢棄了,交由 listpack 數(shù)據(jù)結(jié)構(gòu)來實現(xiàn)了。
Set 類型內(nèi)部實現(xiàn)
Set 類型的底層數(shù)據(jù)結(jié)構(gòu)是由哈希表或整數(shù)集合實現(xiàn)的:
-
- 如果集合中的元素都是整數(shù)且元素個數(shù)小于 512 (默認值,set-maxintset-entries配置)個,Redis 會使用
整數(shù)集合
-
- 作為 Set 類型的底層數(shù)據(jù)結(jié)構(gòu);如果集合中的元素不滿足上面條件,則 Redis 使用
哈希表
- 作為 Set 類型的底層數(shù)據(jù)結(jié)構(gòu)。
ZSet 類型內(nèi)部實現(xiàn)
Zset 類型的底層數(shù)據(jù)結(jié)構(gòu)是由壓縮列表或跳表實現(xiàn)的:
-
- 如果有序集合的元素個數(shù)小于 128 個,并且每個元素的值小于 64 字節(jié)時,Redis 會使用
壓縮列表
-
- 作為 Zset 類型的底層數(shù)據(jù)結(jié)構(gòu);如果有序集合的元素不滿足上面的條件,Redis 會使用
跳表
- 作為 Zset 類型的底層數(shù)據(jù)結(jié)構(gòu);
在 Redis 7.0 中,壓縮列表數(shù)據(jù)結(jié)構(gòu)已經(jīng)廢棄了,交由 listpack 數(shù)據(jù)結(jié)構(gòu)來實現(xiàn)了。
::: tip
想深入了解這 9 種數(shù)據(jù)結(jié)構(gòu),可以看這篇:2萬字 + 40 張圖 | 細說 Redis 數(shù)據(jù)結(jié)構(gòu)
Redis 線程模型
Redis 是單線程嗎?
Redis 單線程指的是「接收客戶端請求->解析請求 ->進行數(shù)據(jù)讀寫等操作->發(fā)送數(shù)據(jù)給客戶端」這個過程是由一個線程(主線程)來完成的,這也是我們常說 Redis 是單線程的原因。
但是,Redis 程序并不是單線程的,Redis 在啟動的時候,是會啟動后臺線程(BIO)的:
Redis 在 2.6 版本
-
- ,會啟動 2 個后臺線程,分別處理關閉文件、AOF 刷盤這兩個任務;
Redis 在 4.0 版本之后
- ,新增了一個新的后臺線程,用來異步釋放 Redis 內(nèi)存,也就是 lazyfree 線程。例如執(zhí)行 unlink key / flushdb async / flushall async 等命令,會把這些刪除操作交給后臺線程來執(zhí)行,好處是不會導致 Redis 主線程卡頓。因此,當我們要刪除一個大 key 的時候,不要使用 del 命令刪除,因為 del 是在主線程處理的,這樣會導致 Redis 主線程卡頓,因此我們應該使用 unlink 命令來異步刪除大key。
之所以 Redis 為「關閉文件、AOF 刷盤、釋放內(nèi)存」這些任務創(chuàng)建單獨的線程來處理,是因為這些任務的操作都是很耗時的,如果把這些任務都放在主線程來處理,那么 Redis 主線程就很容易發(fā)生阻塞,這樣就無法處理后續(xù)的請求了。
后臺線程相當于一個消費者,生產(chǎn)者把耗時任務丟到任務隊列中,消費者(BIO)不停輪詢這個隊列,拿出任務就去執(zhí)行對應的方法即可。
關閉文件、AOF 刷盤、釋放內(nèi)存這三個任務都有各自的任務隊列:
- BIO_CLOSE_FILE,關閉文件任務隊列:當隊列有任務后,后臺線程會調(diào)用 close(fd) ,將文件關閉;BIO_AOF_FSYNC,AOF刷盤任務隊列:當 AOF 日志配置成 everysec 選項后,主線程會把 AOF 寫日志操作封裝成一個任務,也放到隊列中。當發(fā)現(xiàn)隊列有任務后,后臺線程會調(diào)用 fsync(fd),將 AOF 文件刷盤,BIO_LAZY_FREE,lazy free 任務隊列:當隊列有任務后,后臺線程會 free(obj) 釋放對象 / free(dict) 刪除數(shù)據(jù)庫所有對象 / free(skiplist) 釋放跳表對象;
Redis 單線程模式是怎樣的?
Redis 6.0 版本之前的單線模式如下圖:
圖中的藍色部分是一個事件循環(huán),是由主線程負責的,可以看到網(wǎng)絡 I/O 和命令處理都是單線程。
Redis 初始化的時候,會做下面這幾件事情:
- 首先,調(diào)用 epoll_create() 創(chuàng)建一個 epoll 對象和調(diào)用 socket() 創(chuàng)建一個服務端 socket然后,調(diào)用 bind() 綁定端口和調(diào)用 listen() 監(jiān)聽該 socket;然后,將調(diào)用 epoll_ctl() 將 listen socket 加入到 epoll,同時注冊「連接事件」處理函數(shù)。
初始化完后,主線程就進入到一個事件循環(huán)函數(shù),主要會做以下事情:
-
- 首先,先調(diào)用
處理發(fā)送隊列函數(shù)
-
- ,看是發(fā)送隊列里是否有任務,如果有發(fā)送任務,則通過 write 函數(shù)將客戶端發(fā)送緩存區(qū)里的數(shù)據(jù)發(fā)送出去,如果這一輪數(shù)據(jù)沒有發(fā)送完,就會注冊寫事件處理函數(shù),等待 epoll_wait 發(fā)現(xiàn)可寫后再處理 。接著,調(diào)用 epoll_wait 函數(shù)等待事件的到來:
-
- 如果是
-
連接事件
-
-
- 到來,則會調(diào)用
-
連接事件處理函數(shù)
-
-
- ,該函數(shù)會做這些事情:調(diào)用 accpet 獲取已連接的 socket -> ?調(diào)用 epoll_ctl 將已連接的 socket 加入到 epoll -> 注冊「讀事件」處理函數(shù);如果是
-
讀事件
-
-
- 到來,則會調(diào)用
-
讀事件處理函數(shù)
-
-
- ,該函數(shù)會做這些事情:調(diào)用 read 獲取客戶端發(fā)送的數(shù)據(jù) -> 解析命令 -> 處理命令 -> 將客戶端對象添加到發(fā)送隊列 -> 將執(zhí)行結(jié)果寫到發(fā)送緩存區(qū)等待發(fā)送;如果是
-
寫事件
-
-
- 到來,則會調(diào)用
-
寫事件處理函數(shù)
-
- ,該函數(shù)會做這些事情:通過 write 函數(shù)將客戶端發(fā)送緩存區(qū)里的數(shù)據(jù)發(fā)送出去,如果這一輪數(shù)據(jù)沒有發(fā)送完,就會繼續(xù)注冊寫事件處理函數(shù),等待 epoll_wait 發(fā)現(xiàn)可寫后再處理 。
以上就是 Redis 單線模式的工作方式,如果你想看源碼解析,可以參考這一篇:為什么單線程的 Redis 如何做到每秒數(shù)萬 QPS ?
Redis 采用單線程為什么還這么快?
官方使用基準測試的結(jié)果是,單線程的 Redis 吞吐量可以達到 10W/每秒,如下圖所示:
之所以 Redis 采用單線程(網(wǎng)絡 I/O 和執(zhí)行命令)那么快,有如下幾個原因:
-
- Redis 的大部分操作
都在內(nèi)存中完成
-
- ,并且采用了高效的數(shù)據(jù)結(jié)構(gòu),因此 Redis 瓶頸可能是機器的內(nèi)存或者網(wǎng)絡帶寬,而并非 CPU,既然 CPU 不是瓶頸,那么自然就采用單線程的解決方案了;Redis 采用單線程模型可以
避免了多線程之間的競爭
-
- ,省去了多線程切換帶來的時間和性能上的開銷,而且也不會導致死鎖問題。Redis 采用了
I/O 多路復用機制
- 處理大量的客戶端 Socket 請求,IO 多路復用機制是指一個線程處理多個 IO 流,就是我們經(jīng)常聽到的 select/epoll 機制。簡單來說,在 Redis 只運行單線程的情況下,該機制允許內(nèi)核中,同時存在多個監(jiān)聽 Socket 和已連接 Socket。內(nèi)核會一直監(jiān)聽這些 Socket 上的連接請求或數(shù)據(jù)請求。一旦有請求到達,就會交給 Redis 線程處理,這就實現(xiàn)了一個 Redis 線程處理多個 IO 流的效果。
Redis 6.0 之前為什么使用單線程?
我們都知道單線程的程序是無法利用服務器的多核 CPU 的,那么早期 Redis 版本的主要工作(網(wǎng)絡 I/O 和執(zhí)行命令)為什么還要使用單線程呢?我們不妨先看一下Redis官方給出的FAQ。
核心意思是:CPU 并不是制約 Redis 性能表現(xiàn)的瓶頸所在,更多情況下是受到內(nèi)存大小和網(wǎng)絡I/O的限制,所以 Redis 核心網(wǎng)絡模型使用單線程并沒有什么問題,如果你想要使用服務的多核CPU,可以在一臺服務器上啟動多個節(jié)點或者采用分片集群的方式。
除了上面的官方回答,選擇單線程的原因也有下面的考慮。
使用了單線程后,可維護性高,多線程模型雖然在某些方面表現(xiàn)優(yōu)異,但是它卻引入了程序執(zhí)行順序的不確定性,帶來了并發(fā)讀寫的一系列問題,增加了系統(tǒng)復雜度、同時可能存在線程切換、甚至加鎖解鎖、死鎖造成的性能損耗。
Redis 6.0 之后為什么引入了多線程?
雖然 Redis 的主要工作(網(wǎng)絡 I/O 和執(zhí)行命令)一直是單線程模型,但是在 Redis 6.0 版本之后,也采用了多個 I/O 線程來處理網(wǎng)絡請求,這是因為隨著網(wǎng)絡硬件的性能提升,Redis 的性能瓶頸有時會出現(xiàn)在網(wǎng)絡 I/O 的處理上。
所以為了提高網(wǎng)絡 I/O 的并行度,Redis 6.0 對于網(wǎng)絡 I/O 采用多線程來處理。但是對于命令的執(zhí)行,Redis 仍然使用單線程來處理,所以大家不要誤解 Redis 有多線程同時執(zhí)行命令。
Redis 官方表示,Redis 6.0 版本引入的多線程 I/O 特性對性能提升至少是一倍以上。
Redis 6.0 版本支持的 I/O ?多線程特性,默認情況下 I/O 多線程只針對發(fā)送響應數(shù)據(jù)(write client socket),并不會以多線程的方式處理讀請求(read client socket)。要想開啟多線程處理客戶端讀請求,就需要把 ?Redis.conf ?配置文件中的 io-threads-do-reads 配置項設為 yes。
//讀請求也使用io多線程
io-threads-do-reads?yes?
同時, Redis.conf ?配置文件中提供了 ?IO 多線程個數(shù)的配置項。
//?io-threads?N,表示啟用?N-1?個?I/O?多線程(主線程也算一個?I/O?線程)
io-threads?4?
關于線程數(shù)的設置,官方的建議是如果為 4 核的 CPU,建議線程數(shù)設置為 2 或 3,如果為 8 核 CPU 建議線程數(shù)設置為 6,線程數(shù)一定要小于機器核數(shù),線程數(shù)并不是越大越好。
因此, Redis 6.0 版本之后,Redis 在啟動的時候,默認情況下會額外創(chuàng)建 6 個線程(這里的線程數(shù)不包括主線程):
- Redis-server : Redis的主線程,主要負責執(zhí)行命令;bio_close_file、bio_aof_fsync、bio_lazy_free:三個后臺線程,分別異步處理關閉文件任務、AOF刷盤任務、釋放內(nèi)存任務;io_thd_1、io_thd_2、io_thd_3:三個 I/O 線程,io-threads 默認是 4 ,所以會啟動 3(4-1)個 I/O 多線程,用來分擔 Redis 網(wǎng)絡 I/O 的壓力。
Redis 持久化
Redis 如何實現(xiàn)數(shù)據(jù)不丟失?
Redis 的讀寫操作都是在內(nèi)存中,所以 Redis 性能才會高,但是當 Redis 重啟后,內(nèi)存中的數(shù)據(jù)就會丟失,那為了保證內(nèi)存中的數(shù)據(jù)不會丟失,Redis 實現(xiàn)了數(shù)據(jù)持久化的機制,這個機制會把數(shù)據(jù)存儲到磁盤,這樣在 ?Redis 重啟就能夠從磁盤中恢復原有的數(shù)據(jù)。
Redis 共有三種數(shù)據(jù)持久化的方式:
AOF 日志
-
- :每執(zhí)行一條寫操作命令,就把該命令以追加的方式寫入到一個文件里;
RDB 快照
-
- :將某一時刻的內(nèi)存數(shù)據(jù),以二進制的方式寫入磁盤;
混合持久化方式
- :Redis ?4.0 新增的方式,集成了 AOF 和 RBD 的優(yōu)點;
AOF 日志是如何實現(xiàn)的?
Redis 在執(zhí)行完一條寫操作命令后,就會把該命令以追加的方式寫入到一個文件里,然后 Redis 重啟時,會讀取該文件記錄的命令,然后逐一執(zhí)行命令的方式來進行數(shù)據(jù)恢復。
我這里以「_set name xiaolin_」命令作為例子,Redis 執(zhí)行了這條命令后,記錄在 AOF 日志里的內(nèi)容如下圖:
我這里給大家解釋下。
「*3」表示當前命令有三個部分,每部分都是以「數(shù)字」開頭,后面緊跟著具體的命令、鍵或值。然后,這里的「數(shù)字」表示這部分中的命令、鍵或值一共有多少字節(jié)。例如,「3 set」表示這部分有 3 個字節(jié),也就是「set」命令這個字符串的長度。
為什么先執(zhí)行命令,再把數(shù)據(jù)寫入日志呢?
Reids 是先執(zhí)行寫操作命令后,才將該命令記錄到 AOF 日志里的,這么做其實有兩個好處。
避免額外的檢查開銷
-
- :因為如果先將寫操作命令記錄到 AOF 日志里,再執(zhí)行該命令的話,如果當前的命令語法有問題,那么如果不進行命令語法檢查,該錯誤的命令記錄到 AOF 日志里后,Redis 在使用日志恢復數(shù)據(jù)時,就可能會出錯。
不會阻塞當前寫操作命令的執(zhí)行
- :因為當寫操作命令執(zhí)行成功后,才會將命令記錄到 AOF 日志。
當然,這樣做也會帶來風險:
數(shù)據(jù)可能會丟失:
-
- 執(zhí)行寫操作命令和記錄日志是兩個過程,那當 Redis 在還沒來得及將命令寫入到硬盤時,服務器發(fā)生宕機了,這個數(shù)據(jù)就會有丟失的風險。
可能阻塞其他操作:
- 由于寫操作命令執(zhí)行成功后才記錄到 AOF 日志,所以不會阻塞當前命令的執(zhí)行,但因為 AOF 日志也是在主線程中執(zhí)行,所以當 Redis 把日志文件寫入磁盤的時候,還是會阻塞后續(xù)的操作無法執(zhí)行。
AOF 寫回策略有幾種?
先來看看,Redis 寫入 AOF 日志的過程,如下圖:
具體說說:
- Redis 執(zhí)行完寫操作命令后,會將命令追加到 server.aof_buf 緩沖區(qū);然后通過 write() 系統(tǒng)調(diào)用,將 aof_buf 緩沖區(qū)的數(shù)據(jù)寫入到 AOF 文件,此時數(shù)據(jù)并沒有寫入到硬盤,而是拷貝到了內(nèi)核緩沖區(qū) page cache,等待內(nèi)核將數(shù)據(jù)寫入硬盤;具體內(nèi)核緩沖區(qū)的數(shù)據(jù)什么時候?qū)懭氲接脖P,由內(nèi)核決定。
Redis 提供了 3 種寫回硬盤的策略,控制的就是上面說的第三步的過程。
在 Redis.conf 配置文件中的 appendfsync 配置項可以有以下 3 種參數(shù)可填:
Always,這個單詞的意思是「總是」,所以它的意思是每次寫操作命令執(zhí)行完后,同步將 AOF 日志數(shù)據(jù)寫回硬盤;
Everysec,這個單詞的意思是「每秒」,所以它的意思是每次寫操作命令執(zhí)行完后,先將命令寫入到 AOF 文件的內(nèi)核緩沖區(qū),然后每隔一秒將緩沖區(qū)里的內(nèi)容寫回到硬盤;
No,意味著不由 Redis 控制寫回硬盤的時機,轉(zhuǎn)交給操作系統(tǒng)控制寫回的時機,也就是每次寫操作命令執(zhí)行完后,先將命令寫入到 AOF 文件的內(nèi)核緩沖區(qū),再由操作系統(tǒng)決定何時將緩沖區(qū)內(nèi)容寫回硬盤。
我也把這 3 個寫回策略的優(yōu)缺點總結(jié)成了一張表格:
AOF 日志過大,會觸發(fā)什么機制?
AOF 日志是一個文件,隨著執(zhí)行的寫操作命令越來越多,文件的大小會越來越大。
如果當 AOF 日志文件過大就會帶來性能問題,比如重啟 Redis 后,需要讀 AOF 文件的內(nèi)容以恢復數(shù)據(jù),如果文件過大,整個恢復的過程就會很慢。
所以,Redis 為了避免 AOF 文件越寫越大,提供了 AOF 重寫機制,當 AOF 文件的大小超過所設定的閾值后,Redis 就會啟用 AOF 重寫機制,來壓縮 AOF 文件。
AOF 重寫機制是在重寫時,讀取當前數(shù)據(jù)庫中的所有鍵值對,然后將每一個鍵值對用一條命令記錄到「新的 AOF 文件」,等到全部記錄完后,就將新的 AOF 文件替換掉現(xiàn)有的 AOF 文件。
舉個例子,在沒有使用重寫機制前,假設前后執(zhí)行了「_set name xiaolin_」和「_set name xiaolincoding_」這兩個命令的話,就會將這兩個命令記錄到 AOF 文件。
但是在使用重寫機制后,就會讀取 name 最新的 value(鍵值對) ,然后用一條 「set name xiaolincoding」命令記錄到新的 AOF 文件,之前的第一個命令就沒有必要記錄了,因為它屬于「歷史」命令,沒有作用了。這樣一來,一個鍵值對在重寫日志中只用一條命令就行了。
重寫工作完成后,就會將新的 AOF 文件覆蓋現(xiàn)有的 AOF 文件,這就相當于壓縮了 AOF 文件,使得 AOF 文件體積變小了。
重寫 AOF 日志的過程是怎樣的?
Redis 的重寫 AOF 過程是由后臺子進程 bgrewriteaof 來完成的,這么做可以達到兩個好處:
- 子進程進行 AOF 重寫期間,主進程可以繼續(xù)處理命令請求,從而避免阻塞主進程;子進程帶有主進程的數(shù)據(jù)副本,這里使用子進程而不是線程,因為如果是使用線程,多線程之間會共享內(nèi)存,那么在修改共享內(nèi)存數(shù)據(jù)的時候,需要通過加鎖來保證數(shù)據(jù)的安全,而這樣就會降低性能。而使用子進程,創(chuàng)建子進程時,父子進程是共享內(nèi)存數(shù)據(jù)的,不過這個共享的內(nèi)存只能以只讀的方式,而當父子進程任意一方修改了該共享內(nèi)存,就會發(fā)生「寫時復制」,于是父子進程就有了獨立的數(shù)據(jù)副本,就不用加鎖來保證數(shù)據(jù)安全。
觸發(fā)重寫機制后,主進程就會創(chuàng)建重寫 AOF 的子進程,此時父子進程共享物理內(nèi)存,重寫子進程只會對這個內(nèi)存進行只讀,重寫 AOF 子進程會讀取數(shù)據(jù)庫里的所有數(shù)據(jù),并逐一把內(nèi)存數(shù)據(jù)的鍵值對轉(zhuǎn)換成一條命令,再將命令記錄到重寫日志(新的 AOF 文件)。
但是重寫過程中,主進程依然可以正常處理命令,那問題來了,重寫 AOF 日志過程中,如果主進程修改了已經(jīng)存在 key-value,那么會發(fā)生寫時復制,此時這個 key-value 數(shù)據(jù)在子進程的內(nèi)存數(shù)據(jù)就跟主進程的內(nèi)存數(shù)據(jù)不一致了,這時要怎么辦呢?
為了解決這種數(shù)據(jù)不一致問題,Redis 設置了一個 AOF 重寫緩沖區(qū),這個緩沖區(qū)在創(chuàng)建 bgrewriteaof 子進程之后開始使用。
在重寫 AOF 期間,當 Redis 執(zhí)行完一個寫命令之后,它會同時將這個寫命令寫入到 「AOF 緩沖區(qū)」和 「AOF 重寫緩沖區(qū)」。
也就是說,在 bgrewriteaof 子進程執(zhí)行 AOF 重寫期間,主進程需要執(zhí)行以下三個工作:
- 執(zhí)行客戶端發(fā)來的命令;將執(zhí)行后的寫命令追加到 「AOF 緩沖區(qū)」;將執(zhí)行后的寫命令追加到 「AOF 重寫緩沖區(qū)」;
當子進程完成 AOF 重寫工作(_掃描數(shù)據(jù)庫中所有數(shù)據(jù),逐一把內(nèi)存數(shù)據(jù)的鍵值對轉(zhuǎn)換成一條命令,再將命令記錄到重寫日志_)后,會向主進程發(fā)送一條信號,信號是進程間通訊的一種方式,且是異步的。
主進程收到該信號后,會調(diào)用一個信號處理函數(shù),該函數(shù)主要做以下工作:
- 將 AOF 重寫緩沖區(qū)中的所有內(nèi)容追加到新的 AOF 的文件中,使得新舊兩個 AOF 文件所保存的數(shù)據(jù)庫狀態(tài)一致;新的 AOF 的文件進行改名,覆蓋現(xiàn)有的 AOF 文件。
信號函數(shù)執(zhí)行完后,主進程就可以繼續(xù)像往常一樣處理命令了。
::: tip
AOF 日志的內(nèi)容就暫時提這些,想更詳細了解 AOF 日志的工作原理,可以詳細看這篇:AOF 持久化是怎么實現(xiàn)的
RDB 快照是如何實現(xiàn)的呢?
因為 AOF 日志記錄的是操作命令,不是實際的數(shù)據(jù),所以用 AOF 方法做故障恢復時,需要全量把日志都執(zhí)行一遍,一旦 AOF 日志非常多,勢必會造成 Redis 的恢復操作緩慢。
為了解決這個問題,Redis 增加了 RDB 快照。所謂的快照,就是記錄某一個瞬間東西,比如當我們給風景拍照時,那一個瞬間的畫面和信息就記錄到了一張照片。
所以,RDB 快照就是記錄某一個瞬間的內(nèi)存數(shù)據(jù),記錄的是實際數(shù)據(jù),而 AOF 文件記錄的是命令操作的日志,而不是實際的數(shù)據(jù)。
因此在 Redis 恢復數(shù)據(jù)時, RDB 恢復數(shù)據(jù)的效率會比 AOF 高些,因為直接將 RDB 文件讀入內(nèi)存就可以,不需要像 AOF 那樣還需要額外執(zhí)行操作命令的步驟才能恢復數(shù)據(jù)。
RDB 做快照時會阻塞線程嗎?
Redis 提供了兩個命令來生成 RDB 文件,分別是 save 和 bgsave,他們的區(qū)別就在于是否在「主線程」里執(zhí)行:
-
- 執(zhí)行了 save 命令,就會在主線程生成 RDB 文件,由于和執(zhí)行操作命令在同一個線程,所以如果寫入 RDB 文件的時間太長,
會阻塞主線程;執(zhí)行了 bgsave 命令,會創(chuàng)建一個子進程來生成 RDB 文件,這樣可以
避免主線程的阻塞;
Redis 還可以通過配置文件的選項來實現(xiàn)每隔一段時間自動執(zhí)行一次 bgsave 命令,默認會提供以下配置:
save?900?1
save?300?10
save?60?10000
別看選項名叫 save,實際上執(zhí)行的是 bgsave 命令,也就是會創(chuàng)建子進程來生成 RDB 快照文件。
只要滿足上面條件的任意一個,就會執(zhí)行 bgsave,它們的意思分別是:
- 900 秒之內(nèi),對數(shù)據(jù)庫進行了至少 1 次修改;300 秒之內(nèi),對數(shù)據(jù)庫進行了至少 10 次修改;60 秒之內(nèi),對數(shù)據(jù)庫進行了至少 10000 次修改。
這里提一點,Redis 的快照是全量快照,也就是說每次執(zhí)行快照,都是把內(nèi)存中的「所有數(shù)據(jù)」都記錄到磁盤中。所以執(zhí)行快照是一個比較重的操作,如果頻率太頻繁,可能會對 Redis 性能產(chǎn)生影響。如果頻率太低,服務器故障時,丟失的數(shù)據(jù)會更多。
RDB 在執(zhí)行快照的時候,數(shù)據(jù)能修改嗎?
可以的,執(zhí)行 bgsave 過程中,Redis 依然可以繼續(xù)處理操作命令的,也就是數(shù)據(jù)是能被修改的,關鍵的技術就在于寫時復制技術(Copy-On-Write, COW)。
執(zhí)行 bgsave 命令的時候,會通過 fork() 創(chuàng)建子進程,此時子進程和父進程是共享同一片內(nèi)存數(shù)據(jù)的,因為創(chuàng)建子進程的時候,會復制父進程的頁表,但是頁表指向的物理內(nèi)存還是一個,此時如果主線程執(zhí)行讀操作,則主線程和 bgsave 子進程互相不影響。
如果主線程執(zhí)行寫操作,則被修改的數(shù)據(jù)會復制一份副本,然后 bgsave 子進程會把該副本數(shù)據(jù)寫入 RDB 文件,在這個過程中,主線程仍然可以直接修改原來的數(shù)據(jù)。
::: tip
RDB 快照的內(nèi)容就暫時提這些,想更詳細了解 RDB 快照的工作原理,可以詳細看這篇:RDB 快照是怎么實現(xiàn)的?
為什么會有混合持久化?
RDB 優(yōu)點是數(shù)據(jù)恢復速度快,但是快照的頻率不好把握。頻率太低,丟失的數(shù)據(jù)就會比較多,頻率太高,就會影響性能。
AOF 優(yōu)點是丟失數(shù)據(jù)少,但是數(shù)據(jù)恢復不快。
為了集成了兩者的優(yōu)點, Redis 4.0 提出了混合使用 AOF 日志和內(nèi)存快照,也叫混合持久化,既保證了 Redis 重啟速度,又降低數(shù)據(jù)丟失風險。
混合持久化工作在 AOF 日志重寫過程,當開啟了混合持久化時,在 AOF 重寫日志時,fork 出來的重寫子進程會先將與主線程共享的內(nèi)存數(shù)據(jù)以 RDB 方式寫入到 AOF 文件,然后主線程處理的操作命令會被記錄在重寫緩沖區(qū)里,重寫緩沖區(qū)里的增量命令會以 AOF 方式寫入到 AOF 文件,寫入完成后通知主進程將新的含有 RDB 格式和 AOF 格式的 AOF 文件替換舊的的 AOF 文件。
也就是說,使用了混合持久化,AOF 文件的前半部分是 RDB 格式的全量數(shù)據(jù),后半部分是 AOF 格式的增量數(shù)據(jù)。
這樣的好處在于,重啟 Redis 加載數(shù)據(jù)的時候,由于前半部分是 RDB 內(nèi)容,這樣加載的時候速度會很快。
加載完 RDB 的內(nèi)容后,才會加載后半部分的 AOF 內(nèi)容,這里的內(nèi)容是 Redis 后臺子進程重寫 AOF 期間,主線程處理的操作命令,可以使得數(shù)據(jù)更少的丟失。
混合持久化優(yōu)點:混合持久化結(jié)合了 RDB 和 AOF 持久化的優(yōu)點,開頭為 RDB 的格式,使得 Redis 可以更快的啟動,同時結(jié)合 AOF 的優(yōu)點,有減低了大量數(shù)據(jù)丟失的風險。
混合持久化缺點:AOF 文件中添加了 RDB 格式的內(nèi)容,使得 AOF 文件的可讀性變得很差;兼容性差,如果開啟混合持久化,那么此混合持久化 AOF 文件,就不能用在 Redis 4.0 之前版本了。
Redis 集群
Redis 如何實現(xiàn)服務高可用?
要想設計一個高可用的 Redis 服務,一定要從 Redis 的多服務節(jié)點來考慮,比如 Redis 的主從復制、哨兵模式、切片集群。
主從復制
主從復制是 Redis 高可用服務的最基礎的保證,實現(xiàn)方案就是將從前的一臺 Redis 服務器,同步數(shù)據(jù)到多臺從 Redis 服務器上,即一主多從的模式,且主從服務器之間采用的是「讀寫分離」的方式。
主服務器可以進行讀寫操作,當發(fā)生寫操作時自動將寫操作同步給從服務器,而從服務器一般是只讀,并接受主服務器同步過來寫操作命令,然后執(zhí)行這條命令。
也就是說,所有的數(shù)據(jù)修改只在主服務器上進行,然后將最新的數(shù)據(jù)同步給從服務器,這樣就使得主從服務器的數(shù)據(jù)是一致的。
注意,主從服務器之間的命令復制是異步進行的。
具體來說,在主從服務器命令傳播階段,主服務器收到新的寫命令后,會發(fā)送給從服務器。但是,主服務器并不會等到從服務器實際執(zhí)行完命令后,再把結(jié)果返回給客戶端,而是主服務器自己在本地執(zhí)行完命令后,就會向客戶端返回結(jié)果了。如果從服務器還沒有執(zhí)行主服務器同步過來的命令,主從服務器間的數(shù)據(jù)就不一致了。
所以,無法實現(xiàn)強一致性保證(主從數(shù)據(jù)時時刻刻保持一致),數(shù)據(jù)不一致是難以避免的。
::: tip
想更詳細了解 ?Redis 主從復制的工作原理,可以詳細看這篇:主從復制是怎么實現(xiàn)的?
哨兵模式
在使用 Redis 主從服務的時候,會有一個問題,就是當 Redis 的主從服務器出現(xiàn)故障宕機時,需要手動進行恢復。
為了解決這個問題,Redis 增加了哨兵模式(Redis Sentinel),因為哨兵模式做到了可以監(jiān)控主從服務器,并且提供主從節(jié)點故障轉(zhuǎn)移的功能。
切片集群模式
當 Redis 緩存數(shù)據(jù)量大到一臺服務器無法緩存時,就需要使用 Redis 切片集群(Redis Cluster )方案,它將數(shù)據(jù)分布在不同的服務器上,以此來降低系統(tǒng)對單主節(jié)點的依賴,從而提高 Redis 服務的讀寫性能。
Redis Cluster 方案采用哈希槽(Hash Slot),來處理數(shù)據(jù)和節(jié)點之間的映射關系。在 Redis Cluster 方案中,一個切片集群共有 16384 個哈希槽,這些哈希槽類似于數(shù)據(jù)分區(qū),每個鍵值對都會根據(jù)它的 key,被映射到一個哈希槽中,具體執(zhí)行過程分為兩大步:
- 根據(jù)鍵值對的 key,按照 CRC16 算法計算一個 16 bit 的值。再用 16bit 值對 16384 取模,得到 0~16383 范圍內(nèi)的模數(shù),每個模數(shù)代表一個相應編號的哈希槽。
接下來的問題就是,這些哈希槽怎么被映射到具體的 Redis 節(jié)點上的呢?有兩種方案:
平均分配:
-
- 在使用 cluster create 命令創(chuàng)建 Redis 集群時,Redis 會自動把所有哈希槽平均分布到集群節(jié)點上。比如集群中有 9 個節(jié)點,則每個節(jié)點上槽的個數(shù)為 16384/9 個。
手動分配:
- 可以使用 cluster meet 命令手動建立節(jié)點間的連接,組成集群,再使用 cluster addslots 命令,指定每個節(jié)點上的哈希槽個數(shù)。
為了方便你的理解,我通過一張圖來解釋數(shù)據(jù)、哈希槽,以及節(jié)點三者的映射分布關系。
上圖中的切片集群一共有 2 個節(jié)點,假設有 4 個哈希槽(Slot 0~Slot 3)時,我們就可以通過命令手動分配哈希槽,比如節(jié)點 1 保存哈希槽 0 和 1,節(jié)點 2 保存哈希槽 2 和 3。
redis-cli?-h?192.168.1.10?–p?6379?cluster?addslots?0,1
redis-cli?-h?192.168.1.11?–p?6379?cluster?addslots?2,3
然后在集群運行的過程中,key1 和 key2 計算完 CRC16 值后,對哈希槽總個數(shù) 4 進行取模,再根據(jù)各自的模數(shù)結(jié)果,就可以被映射到哈希槽 1(對應節(jié)點1) 和 哈希槽 2(對應節(jié)點2)。
需要注意的是,在手動分配哈希槽時,需要把 16384 個槽都分配完,否則 Redis 集群無法正常工作。
集群腦裂導致數(shù)據(jù)丟失怎么辦?
什么是腦裂?
先來理解集群的腦裂現(xiàn)象,這就好比一個人有兩個大腦,那么到底受誰控制呢?
那么在 Redis 中,集群腦裂產(chǎn)生數(shù)據(jù)丟失的現(xiàn)象是怎樣的呢?
在 Redis 主從架構(gòu)中,部署方式一般是「一主多從」,主節(jié)點提供寫操作,從節(jié)點提供讀操作。
如果主節(jié)點的網(wǎng)絡突然發(fā)生了問題,它與所有的從節(jié)點都失聯(lián)了,但是此時的主節(jié)點和客戶端的網(wǎng)絡是正常的,這個客戶端并不知道 Redis 內(nèi)部已經(jīng)出現(xiàn)了問題,還在照樣的向這個失聯(lián)的主節(jié)點寫數(shù)據(jù)(過程A),此時這些數(shù)據(jù)被舊主節(jié)點緩存到了緩沖區(qū)里,因為主從節(jié)點之間的網(wǎng)絡問題,這些數(shù)據(jù)都是無法同步給從節(jié)點的。
這時,哨兵也發(fā)現(xiàn)主節(jié)點失聯(lián)了,它就認為主節(jié)點掛了(但實際上主節(jié)點正常運行,只是網(wǎng)絡出問題了),于是哨兵就會在「從節(jié)點」中選舉出一個 leader 作為主節(jié)點,這時集群就有兩個主節(jié)點了 —— 腦裂出現(xiàn)了。
然后,網(wǎng)絡突然好了,哨兵因為之前已經(jīng)選舉出一個新主節(jié)點了,它就會把舊主節(jié)點降級為從節(jié)點(A),然后從節(jié)點(A)會向新主節(jié)點請求數(shù)據(jù)同步,因為第一次同步是全量同步的方式,此時的從節(jié)點(A)會清空掉自己本地的數(shù)據(jù),然后再做全量同步。所以,之前客戶端在過程 A 寫入的數(shù)據(jù)就會丟失了,也就是集群產(chǎn)生腦裂數(shù)據(jù)丟失的問題。
總結(jié)一句話就是:由于網(wǎng)絡問題,集群節(jié)點之間失去聯(lián)系。主從數(shù)據(jù)不同步;重新平衡選舉,產(chǎn)生兩個主服務。等網(wǎng)絡恢復,舊主節(jié)點會降級為從節(jié)點,再與新主節(jié)點進行同步復制的時候,由于會從節(jié)點會清空自己的緩沖區(qū),所以導致之前客戶端寫入的數(shù)據(jù)丟失了。
解決方案
當主節(jié)點發(fā)現(xiàn)從節(jié)點下線或者通信超時的總數(shù)量小于閾值時,那么禁止主節(jié)點進行寫數(shù)據(jù),直接把錯誤返回給客戶端。
在 Redis 的配置文件中有兩個參數(shù)我們可以設置:
- min-slaves-to-write x,主節(jié)點必須要有至少 x 個從節(jié)點連接,如果小于這個數(shù),主節(jié)點會禁止寫數(shù)據(jù)。min-slaves-max-lag x,主從數(shù)據(jù)復制和同步的延遲不能超過 x 秒,如果超過,主節(jié)點會禁止寫數(shù)據(jù)。
我們可以把 min-slaves-to-write 和 min-slaves-max-lag 這兩個配置項搭配起來使用,分別給它們設置一定的閾值,假設為 N 和 T。
這兩個配置項組合后的要求是,主庫連接的從庫中至少有 N 個從庫,和主庫進行數(shù)據(jù)復制時的 ACK 消息延遲不能超過 T 秒,否則,主庫就不會再接收客戶端的寫請求了。
即使原主庫是假故障,它在假故障期間也無法響應哨兵心跳,也不能和從庫進行同步,自然也就無法和從庫進行 ACK 確認了。這樣一來,min-slaves-to-write 和 min-slaves-max-lag 的組合要求就無法得到滿足,原主庫就會被限制接收客戶端寫請求,客戶端也就不能在原主庫中寫入新數(shù)據(jù)了。
等到新主庫上線時,就只有新主庫能接收和處理客戶端請求,此時,新寫的數(shù)據(jù)會被直接寫到新主庫中。而原主庫會被哨兵降為從庫,即使它的數(shù)據(jù)被清空了,也不會有新數(shù)據(jù)丟失。
再來舉個例子。
假設我們將 min-slaves-to-write 設置為 1,把 min-slaves-max-lag 設置為 12s,把哨兵的 down-after-milliseconds 設置為 10s,主庫因為某些原因卡住了 15s,導致哨兵判斷主庫客觀下線,開始進行主從切換。
同時,因為原主庫卡住了 15s,沒有一個從庫能和原主庫在 12s 內(nèi)進行數(shù)據(jù)復制,原主庫也無法接收客戶端請求了。
這樣一來,主從切換完成后,也只有新主庫能接收請求,不會發(fā)生腦裂,也就不會發(fā)生數(shù)據(jù)丟失的問題了。
Redis 過期刪除與內(nèi)存淘汰
Redis 使用的過期刪除策略是什么?
Redis 是可以對 key 設置過期時間的,因此需要有相應的機制將已過期的鍵值對刪除,而做這個工作的就是過期鍵值刪除策略。
每當我們對一個 key 設置了過期時間時,Redis 會把該 key 帶上過期時間存儲到一個過期字典(expires dict)中,也就是說「過期字典」保存了數(shù)據(jù)庫中所有 key 的過期時間。
當我們查詢一個 key 時,Redis 首先檢查該 key 是否存在于過期字典中:
- 如果不在,則正常讀取鍵值;如果存在,則會獲取該 key 的過期時間,然后與當前系統(tǒng)時間進行比對,如果比系統(tǒng)時間大,那就沒有過期,否則判定該 key 已過期。
Redis 使用的過期刪除策略是「惰性刪除+定期刪除」這兩種策略配和使用。
什么是惰性刪除策略?
惰性刪除策略的做法是,不主動刪除過期鍵,每次從數(shù)據(jù)庫訪問 key 時,都檢測 key 是否過期,如果過期則刪除該 key。
惰性刪除的流程圖如下:
惰性刪除策略的優(yōu)點:因為每次訪問時,才會檢查 key 是否過期,所以此策略只會使用很少的系統(tǒng)資源,因此,惰性刪除策略對 CPU 時間最友好。
惰性刪除策略的缺點:如果一個 key 已經(jīng)過期,而這個 key 又仍然保留在數(shù)據(jù)庫中,那么只要這個過期 key 一直沒有被訪問,它所占用的內(nèi)存就不會釋放,造成了一定的內(nèi)存空間浪費。所以,惰性刪除策略對內(nèi)存不友好。
什么是定期刪除策略?
定期刪除策略的做法是,每隔一段時間「隨機」從數(shù)據(jù)庫中取出一定數(shù)量的 key 進行檢查,并刪除其中的過期key。
Redis 的定期刪除的流程:
- 從過期字典中隨機抽取 20 個 key;檢查這 20 個 key 是否過期,并刪除已過期的 key;如果本輪檢查的已過期 key 的數(shù)量,超過 5 個(20/4),也就是「已過期 key 的數(shù)量」占比「隨機抽取 key 的數(shù)量」大于 25%,則繼續(xù)重復步驟 1;如果已過期的 key 比例小于 25%,則停止繼續(xù)刪除過期 key,然后等待下一輪再檢查。
可以看到,定期刪除是一個循環(huán)的流程。那 Redis 為了保證定期刪除不會出現(xiàn)循環(huán)過度,導致線程卡死現(xiàn)象,為此增加了定期刪除循環(huán)流程的時間上限,默認不會超過 25ms。
定期刪除的流程如下:
定期刪除策略的優(yōu)點:通過限制刪除操作執(zhí)行的時長和頻率,來減少刪除操作對 CPU 的影響,同時也能刪除一部分過期的數(shù)據(jù)減少了過期鍵對空間的無效占用。
定期刪除策略的缺點:難以確定刪除操作執(zhí)行的時長和頻率。如果執(zhí)行的太頻繁,就會對 CPU 不友好;如果執(zhí)行的太少,那又和惰性刪除一樣了,過期 key 占用的內(nèi)存不會及時得到釋放。
可以看到,惰性刪除策略和定期刪除策略都有各自的優(yōu)點,所以 Redis 選擇「惰性刪除+定期刪除」這兩種策略配和使用,以求在合理使用 CPU 時間和避免內(nèi)存浪費之間取得平衡。
Redis 持久化時,對過期鍵會如何處理的?
Redis 持久化文件有兩種格式:RDB(Redis Database)和 AOF(Append Only File),下面我們分別來看過期鍵在這兩種格式中的呈現(xiàn)狀態(tài)。
RDB 文件分為兩個階段,RDB 文件生成階段和加載階段。
RDB 文件生成階段:從內(nèi)存狀態(tài)持久化成 RDB(文件)的時候,會對 key 進行過期檢查,
過期的鍵「不會」被保存到新的 RDB 文件中,因此 Redis 中的過期鍵不會對生成新 RDB 文件產(chǎn)生任何影響。
RDB 加載階段:RDB 加載階段時,要看服務器是主服務器還是從服務器,分別對應以下兩種情況:
如果 Redis 是「主服務器」運行模式的話,在載入 RDB 文件時,程序會對文件中保存的鍵進行檢查,過期鍵「不會」被載入到數(shù)據(jù)庫中。所以過期鍵不會對載入 RDB 文件的主服務器造成影響;
如果 Redis 是「從服務器」運行模式的話,在載入 RDB 文件時,不論鍵是否過期都會被載入到數(shù)據(jù)庫中。但由于主從服務器在進行數(shù)據(jù)同步時,從服務器的數(shù)據(jù)會被清空。所以一般來說,過期鍵對載入 RDB 文件的從服務器也不會造成影響。
AOF 文件分為兩個階段,AOF 文件寫入階段和 AOF 重寫階段。
AOF 文件寫入階段:當 Redis 以 AOF 模式持久化時,如果數(shù)據(jù)庫某個過期鍵還沒被刪除,那么 AOF 文件會保留此過期鍵,當此過期鍵被刪除后,Redis 會向 AOF 文件追加一條 DEL 命令來顯式地刪除該鍵值。
AOF 重寫階段:執(zhí)行 AOF 重寫時,會對 Redis 中的鍵值對進行檢查,已過期的鍵不會被保存到重寫后的 AOF 文件中,因此不會對 AOF 重寫造成任何影響。
Redis 主從模式中,對過期鍵會如何處理?
當 Redis 運行在主從模式下時,從庫不會進行過期掃描,從庫對過期的處理是被動的。也就是即使從庫中的 key 過期了,如果有客戶端訪問從庫時,依然可以得到 key 對應的值,像未過期的鍵值對一樣返回。
從庫的過期鍵處理依靠主服務器控制,主庫在 key 到期時,會在 AOF 文件里增加一條 del 指令,同步到所有的從庫,從庫通過執(zhí)行這條 del 指令來刪除過期的 key。
Redis 內(nèi)存滿了,會發(fā)生什么?
在 Redis 的運行內(nèi)存達到了某個閥值,就會觸發(fā)內(nèi)存淘汰機制,這個閥值就是我們設置的最大運行內(nèi)存,此值在 Redis 的配置文件中可以找到,配置項為 maxmemory。
Redis 內(nèi)存淘汰策略有哪些?
Redis 內(nèi)存淘汰策略共有八種,這八種策略大體分為「不進行數(shù)據(jù)淘汰」和「進行數(shù)據(jù)淘汰」兩類策略。
1、不進行數(shù)據(jù)淘汰的策略
noeviction(Redis3.0之后,默認的內(nèi)存淘汰策略) :它表示當運行內(nèi)存超過最大設置內(nèi)存時,不淘汰任何數(shù)據(jù),而是不再提供服務,直接返回錯誤。
2、進行數(shù)據(jù)淘汰的策略
針對「進行數(shù)據(jù)淘汰」這一類策略,又可以細分為「在設置了過期時間的數(shù)據(jù)中進行淘汰」和「在所有數(shù)據(jù)范圍內(nèi)進行淘汰」這兩類策略。
在設置了過期時間的數(shù)據(jù)中進行淘汰:
volatile-random:隨機淘汰設置了過期時間的任意鍵值;
volatile-ttl:優(yōu)先淘汰更早過期的鍵值。
volatile-lru(Redis3.0 之前,默認的內(nèi)存淘汰策略):淘汰所有設置了過期時間的鍵值中,最久未使用的鍵值;
volatile-lfu(Redis 4.0 后新增的內(nèi)存淘汰策略):淘汰所有設置了過期時間的鍵值中,最少使用的鍵值;
在所有數(shù)據(jù)范圍內(nèi)進行淘汰:
allkeys-random:隨機淘汰任意鍵值;
allkeys-lru:淘汰整個鍵值中最久未使用的鍵值;
allkeys-lfu(Redis 4.0 后新增的內(nèi)存淘汰策略):淘汰整個鍵值中最少使用的鍵值。
LRU 算法和 LFU 算法有什么區(qū)別?
什么是 LRU 算法?
LRU 全稱是 Least Recently Used 翻譯為最近最少使用,會選擇淘汰最近最少使用的數(shù)據(jù)。
傳統(tǒng) LRU 算法的實現(xiàn)是基于「鏈表」結(jié)構(gòu),鏈表中的元素按照操作順序從前往后排列,最新操作的鍵會被移動到表頭,當需要內(nèi)存淘汰時,只需要刪除鏈表尾部的元素即可,因為鏈表尾部的元素就代表最久未被使用的元素。
Redis 并沒有使用這樣的方式實現(xiàn) LRU 算法,因為傳統(tǒng)的 LRU 算法存在兩個問題:
- 需要用鏈表管理所有的緩存數(shù)據(jù),這會帶來額外的空間開銷;當有數(shù)據(jù)被訪問時,需要在鏈表上把該數(shù)據(jù)移動到頭端,如果有大量數(shù)據(jù)被訪問,就會帶來很多鏈表移動操作,會很耗時,進而會降低 Redis 緩存性能。
Redis 是如何實現(xiàn) LRU 算法的?
Redis 實現(xiàn)的是一種近似 LRU 算法,目的是為了更好的節(jié)約內(nèi)存,它的實現(xiàn)方式是在 Redis 的對象結(jié)構(gòu)體中添加一個額外的字段,用于記錄此數(shù)據(jù)的最后一次訪問時間。
當 Redis 進行內(nèi)存淘汰時,會使用隨機采樣的方式來淘汰數(shù)據(jù),它是隨機取 5 個值(此值可配置),然后淘汰最久沒有使用的那個。
Redis 實現(xiàn)的 LRU 算法的優(yōu)點:
- 不用為所有的數(shù)據(jù)維護一個大鏈表,節(jié)省了空間占用;不用在每次數(shù)據(jù)訪問時都移動鏈表項,提升了緩存的性能;
但是 LRU 算法有一個問題,無法解決緩存污染問題,比如應用一次讀取了大量的數(shù)據(jù),而這些數(shù)據(jù)只會被讀取這一次,那么這些數(shù)據(jù)會留存在 Redis 緩存中很長一段時間,造成緩存污染。
因此,在 Redis 4.0 之后引入了 LFU 算法來解決這個問題。
什么是 LFU 算法?
LFU 全稱是 Least Frequently Used 翻譯為最近最不常用的,LFU 算法是根據(jù)數(shù)據(jù)訪問次數(shù)來淘汰數(shù)據(jù)的,它的核心思想是“如果數(shù)據(jù)過去被訪問多次,那么將來被訪問的頻率也更高”。
所以, LFU 算法會記錄每個數(shù)據(jù)的訪問次數(shù)。當一個數(shù)據(jù)被再次訪問時,就會增加該數(shù)據(jù)的訪問次數(shù)。這樣就解決了偶爾被訪問一次之后,數(shù)據(jù)留存在緩存中很長一段時間的問題,相比于 LRU 算法也更合理一些。
Redis 是如何實現(xiàn) LFU 算法的?
LFU 算法相比于 LRU 算法的實現(xiàn),多記錄了「數(shù)據(jù)的訪問頻次」的信息。Redis 對象的結(jié)構(gòu)如下:
typedef?struct?redisObject?{
????...
??????
????//?24?bits,用于記錄對象的訪問信息
????unsigned?lru:24;??
????...
}?robj;
Redis 對象頭中的 lru 字段,在 LRU 算法下和 LFU 算法下使用方式并不相同。
在 LRU 算法中,Redis 對象頭的 24 bits 的 lru 字段是用來記錄 key 的訪問時間戳,因此在 LRU 模式下,Redis可以根據(jù)對象頭中的 lru 字段記錄的值,來比較最后一次 key 的訪問時間長,從而淘汰最久未被使用的 key。
在 LFU 算法中,Redis對象頭的 24 bits 的 lru 字段被分成兩段來存儲,高 16bit 存儲 ldt(Last Decrement Time),用來記錄 key 的訪問時間戳;低 8bit 存儲 logc(Logistic Counter),用來記錄 key 的訪問頻次。
Redis 緩存設計
如何避免緩存雪崩、緩存擊穿、緩存穿透?
如何避免緩存雪崩?
通常我們?yōu)榱吮WC緩存中的數(shù)據(jù)與數(shù)據(jù)庫中的數(shù)據(jù)一致性,會給 Redis 里的數(shù)據(jù)設置過期時間,當緩存數(shù)據(jù)過期后,用戶訪問的數(shù)據(jù)如果不在緩存里,業(yè)務系統(tǒng)需要重新生成緩存,因此就會訪問數(shù)據(jù)庫,并將數(shù)據(jù)更新到 Redis 里,這樣后續(xù)請求都可以直接命中緩存。
那么,當大量緩存數(shù)據(jù)在同一時間過期(失效)時,如果此時有大量的用戶請求,都無法在 Redis 中處理,于是全部請求都直接訪問數(shù)據(jù)庫,從而導致數(shù)據(jù)庫的壓力驟增,嚴重的會造成數(shù)據(jù)庫宕機,從而形成一系列連鎖反應,造成整個系統(tǒng)崩潰,這就是緩存雪崩的問題。
對于緩存雪崩問題,我們可以采用兩種方案解決。
將緩存失效時間隨機打散:我們可以在原有的失效時間基礎上增加一個隨機值(比如 1 到 10 分鐘)這樣每個緩存的過期時間都不重復了,也就降低了緩存集體失效的概率。
設置緩存不過期:我們可以通過后臺服務來更新緩存數(shù)據(jù),從而避免因為緩存失效造成的緩存雪崩,也可以在一定程度上避免緩存并發(fā)問題。
如何避免緩存擊穿?
我們的業(yè)務通常會有幾個數(shù)據(jù)會被頻繁地訪問,比如秒殺活動,這類被頻地訪問的數(shù)據(jù)被稱為熱點數(shù)據(jù)。
如果緩存中的某個熱點數(shù)據(jù)過期了,此時大量的請求訪問了該熱點數(shù)據(jù),就無法從緩存中讀取,直接訪問數(shù)據(jù)庫,數(shù)據(jù)庫很容易就被高并發(fā)的請求沖垮,這就是緩存擊穿的問題。
可以發(fā)現(xiàn)緩存擊穿跟緩存雪崩很相似,你可以認為緩存擊穿是緩存雪崩的一個子集。
應對緩存擊穿可以采取前面說到兩種方案:
- 互斥鎖方案(Redis 中使用 setNX 方法設置一個狀態(tài)位,表示這是一種鎖定狀態(tài)),保證同一時間只有一個業(yè)務線程請求緩存,未能獲取互斥鎖的請求,要么等待鎖釋放后重新讀取緩存,要么就返回空值或者默認值。不給熱點數(shù)據(jù)設置過期時間,由后臺異步更新緩存,或者在熱點數(shù)據(jù)準備要過期前,提前通知后臺線程更新緩存以及重新設置過期時間;
如何避免緩存穿透?
當發(fā)生緩存雪崩或擊穿時,數(shù)據(jù)庫中還是保存了應用要訪問的數(shù)據(jù),一旦緩存恢復相對應的數(shù)據(jù),就可以減輕數(shù)據(jù)庫的壓力,而緩存穿透就不一樣了。
當用戶訪問的數(shù)據(jù),既不在緩存中,也不在數(shù)據(jù)庫中,導致請求在訪問緩存時,發(fā)現(xiàn)緩存缺失,再去訪問數(shù)據(jù)庫時,發(fā)現(xiàn)數(shù)據(jù)庫中也沒有要訪問的數(shù)據(jù),沒辦法構(gòu)建緩存數(shù)據(jù),來服務后續(xù)的請求。那么當有大量這樣的請求到來時,數(shù)據(jù)庫的壓力驟增,這就是緩存穿透的問題。
緩存穿透的發(fā)生一般有這兩種情況:
- 業(yè)務誤操作,緩存中的數(shù)據(jù)和數(shù)據(jù)庫中的數(shù)據(jù)都被誤刪除了,所以導致緩存和數(shù)據(jù)庫中都沒有數(shù)據(jù);黑客惡意攻擊,故意大量訪問某些讀取不存在數(shù)據(jù)的業(yè)務;
應對緩存穿透的方案,常見的方案有三種。
非法請求的限制:當有大量惡意請求訪問不存在的數(shù)據(jù)的時候,也會發(fā)生緩存穿透,因此在 API 入口處我們要判斷求請求參數(shù)是否合理,請求參數(shù)是否含有非法值、請求字段是否存在,如果判斷出是惡意請求就直接返回錯誤,避免進一步訪問緩存和數(shù)據(jù)庫。
設置空值或者默認值:當我們線上業(yè)務發(fā)現(xiàn)緩存穿透的現(xiàn)象時,可以針對查詢的數(shù)據(jù),在緩存中設置一個空值或者默認值,這樣后續(xù)請求就可以從緩存中讀取到空值或者默認值,返回給應用,而不會繼續(xù)查詢數(shù)據(jù)庫。
使用布隆過濾器快速判斷數(shù)據(jù)是否存在,避免通過查詢數(shù)據(jù)庫來判斷數(shù)據(jù)是否存在:我們可以在寫入數(shù)據(jù)庫數(shù)據(jù)時,使用布隆過濾器做個標記,然后在用戶請求到來時,業(yè)務線程確認緩存失效后,可以通過查詢布隆過濾器快速判斷數(shù)據(jù)是否存在,如果不存在,就不用通過查詢數(shù)據(jù)庫來判斷數(shù)據(jù)是否存在,即使發(fā)生了緩存穿透,大量請求只會查詢 Redis 和布隆過濾器,而不會查詢數(shù)據(jù)庫,保證了數(shù)據(jù)庫能正常運行,Redis 自身也是支持布隆過濾器的。
如何設計一個緩存策略,可以動態(tài)緩存熱點數(shù)據(jù)呢?
由于數(shù)據(jù)存儲受限,系統(tǒng)并不是將所有數(shù)據(jù)都需要存放到緩存中的,而只是將其中一部分熱點數(shù)據(jù)緩存起來,所以我們要設計一個熱點數(shù)據(jù)動態(tài)緩存的策略。
熱點數(shù)據(jù)動態(tài)緩存的策略總體思路:通過數(shù)據(jù)最新訪問時間來做排名,并過濾掉不常訪問的數(shù)據(jù),只留下經(jīng)常訪問的數(shù)據(jù)。
以電商平臺場景中的例子,現(xiàn)在要求只緩存用戶經(jīng)常訪問的 Top 1000 的商品。具體細節(jié)如下:
- 先通過緩存系統(tǒng)做一個排序隊列(比如存放 1000 個商品),系統(tǒng)會根據(jù)商品的訪問時間,更新隊列信息,越是最近訪問的商品排名越靠前;同時系統(tǒng)會定期過濾掉隊列中排名最后的 200 個商品,然后再從數(shù)據(jù)庫中隨機讀取出 200 個商品加入隊列中;這樣當請求每次到達的時候,會先從隊列中獲取商品 ID,如果命中,就根據(jù) ID 再從另一個緩存數(shù)據(jù)結(jié)構(gòu)中讀取實際的商品信息,并返回。
在 Redis 中可以用 zadd 方法和 zrange 方法來完成排序隊列和獲取 200 個商品的操作。
說說常見的緩存更新策略?
常見的緩存更新策略共有3種:
- Cache Aside(旁路緩存)策略;Read/Write Through(讀穿 / 寫穿)策略;Write Back(寫回)策略;
實際開發(fā)中,Redis 和 MySQL 的更新策略用的是 Cache Aside,另外兩種策略應用不了。
Cache Aside(旁路緩存)策略
Cache Aside(旁路緩存)策略是最常用的,應用程序直接與「數(shù)據(jù)庫、緩存」交互,并負責對緩存的維護,該策略又可以細分為「讀策略」和「寫策略」。
寫策略的步驟:先更新數(shù)據(jù)庫中的數(shù)據(jù),再刪除緩存中的數(shù)據(jù)。
讀策略的步驟:如果讀取的數(shù)據(jù)命中了緩存,則直接返回數(shù)據(jù);如果讀取的數(shù)據(jù)沒有命中緩存,則從數(shù)據(jù)庫中讀取數(shù)據(jù),然后將數(shù)據(jù)寫入到緩存,并且返回給用戶。
注意,寫策略的步驟的順序不能倒過來,即不能先刪除緩存再更新數(shù)據(jù)庫,原因是在「讀+寫」并發(fā)的時候,會出現(xiàn)緩存和數(shù)據(jù)庫的數(shù)據(jù)不一致性的問題。
舉個例子,假設某個用戶的年齡是 20,請求 A 要更新用戶年齡為 21,所以它會刪除緩存中的內(nèi)容。這時,另一個請求 B 要讀取這個用戶的年齡,它查詢緩存發(fā)現(xiàn)未命中后,會從數(shù)據(jù)庫中讀取到年齡為 20,并且寫入到緩存中,然后請求 A 繼續(xù)更改數(shù)據(jù)庫,將用戶的年齡更新為 21。
最終,該用戶年齡在緩存中是 20(舊值),在數(shù)據(jù)庫中是 21(新值),緩存和數(shù)據(jù)庫的數(shù)據(jù)不一致。
為什么「先更新數(shù)據(jù)庫再刪除緩存」不會有數(shù)據(jù)不一致的問題?
繼續(xù)用「讀 + 寫」請求的并發(fā)的場景來分析。
假如某個用戶數(shù)據(jù)在緩存中不存在,請求 A 讀取數(shù)據(jù)時從數(shù)據(jù)庫中查詢到年齡為 20,在未寫入緩存中時另一個請求 B 更新數(shù)據(jù)。它更新數(shù)據(jù)庫中的年齡為 21,并且清空緩存。這時請求 A 把從數(shù)據(jù)庫中讀到的年齡為 20 的數(shù)據(jù)寫入到緩存中。
最終,該用戶年齡在緩存中是 20(舊值),在數(shù)據(jù)庫中是 21(新值),緩存和數(shù)據(jù)庫數(shù)據(jù)不一致。
從上面的理論上分析,先更新數(shù)據(jù)庫,再刪除緩存也是會出現(xiàn)數(shù)據(jù)不一致性的問題,但是在實際中,這個問題出現(xiàn)的概率并不高。
因為緩存的寫入通常要遠遠快于數(shù)據(jù)庫的寫入,所以在實際中很難出現(xiàn)請求 B 已經(jīng)更新了數(shù)據(jù)庫并且刪除了緩存,請求 A 才更新完緩存的情況。而一旦請求 A 早于請求 B 刪除緩存之前更新了緩存,那么接下來的請求就會因為緩存不命中而從數(shù)據(jù)庫中重新讀取數(shù)據(jù),所以不會出現(xiàn)這種不一致的情況。
Cache Aside 策略適合讀多寫少的場景,不適合寫多的場景,因為當寫入比較頻繁時,緩存中的數(shù)據(jù)會被頻繁地清理,這樣會對緩存的命中率有一些影響。如果業(yè)務對緩存命中率有嚴格的要求,那么可以考慮兩種解決方案:
- 一種做法是在更新數(shù)據(jù)時也更新緩存,只是在更新緩存前先加一個分布式鎖,因為這樣在同一時間只允許一個線程更新緩存,就不會產(chǎn)生并發(fā)問題了。當然這么做對于寫入的性能會有一些影響;另一種做法同樣也是在更新數(shù)據(jù)時更新緩存,只是給緩存加一個較短的過期時間,這樣即使出現(xiàn)緩存不一致的情況,緩存的數(shù)據(jù)也會很快過期,對業(yè)務的影響也是可以接受。
Read/Write Through(讀穿 / 寫穿)策略
Read/Write Through(讀穿 / 寫穿)策略原則是應用程序只和緩存交互,不再和數(shù)據(jù)庫交互,而是由緩存和數(shù)據(jù)庫交互,相當于更新數(shù)據(jù)庫的操作由緩存自己代理了。
1、Read Through 策略
先查詢緩存中數(shù)據(jù)是否存在,如果存在則直接返回,如果不存在,則由緩存組件負責從數(shù)據(jù)庫查詢數(shù)據(jù),并將結(jié)果寫入到緩存組件,最后緩存組件將數(shù)據(jù)返回給應用。
2、Write Through 策略
當有數(shù)據(jù)更新的時候,先查詢要寫入的數(shù)據(jù)在緩存中是否已經(jīng)存在:
- 如果緩存中數(shù)據(jù)已經(jīng)存在,則更新緩存中的數(shù)據(jù),并且由緩存組件同步更新到數(shù)據(jù)庫中,然后緩存組件告知應用程序更新完成。如果緩存中數(shù)據(jù)不存在,直接更新數(shù)據(jù)庫,然后返回;
下面是 Read Through/Write Through 策略的示意圖:
Read Through/Write Through 策略的特點是由緩存節(jié)點而非應用程序來和數(shù)據(jù)庫打交道,在我們開發(fā)過程中相比 Cache Aside 策略要少見一些,原因是我們經(jīng)常使用的分布式緩存組件,無論是 Memcached 還是 Redis 都不提供寫入數(shù)據(jù)庫和自動加載數(shù)據(jù)庫中的數(shù)據(jù)的功能。而我們在使用本地緩存的時候可以考慮使用這種策略。
Write Back(寫回)策略
Write Back(寫回)策略在更新數(shù)據(jù)的時候,只更新緩存,同時將緩存數(shù)據(jù)設置為臟的,然后立馬返回,并不會更新數(shù)據(jù)庫。對于數(shù)據(jù)庫的更新,會通過批量異步更新的方式進行。
實際上,Write Back(寫回)策略也不能應用到我們常用的數(shù)據(jù)庫和緩存的場景中,因為 Redis 并沒有異步更新數(shù)據(jù)庫的功能。
Write Back 是計算機體系結(jié)構(gòu)中的設計,比如 CPU 的緩存、操作系統(tǒng)中文件系統(tǒng)的緩存都采用了 Write Back(寫回)策略。
Write Back 策略特別適合寫多的場景,因為發(fā)生寫操作的時候, 只需要更新緩存,就立馬返回了。比如,寫文件的時候,實際上是寫入到文件系統(tǒng)的緩存就返回了,并不會寫磁盤。
但是帶來的問題是,數(shù)據(jù)不是強一致性的,而且會有數(shù)據(jù)丟失的風險,因為緩存一般使用內(nèi)存,而內(nèi)存是非持久化的,所以一旦緩存機器掉電,就會造成原本緩存中的臟數(shù)據(jù)丟失。所以你會發(fā)現(xiàn)系統(tǒng)在掉電之后,之前寫入的文件會有部分丟失,就是因為 Page Cache 還沒有來得及刷盤造成的。
這里貼一張 CPU 緩存與內(nèi)存使用 Write Back 策略的流程圖:
有沒有覺得這個流程很熟悉?因為我在寫 CPU 緩存文章的時候提到過。
如何保證緩存和數(shù)據(jù)庫數(shù)據(jù)的一致性?
Redis 實戰(zhàn)
Redis 如何實現(xiàn)延遲隊列?
延遲隊列是指把當前要做的事情,往后推遲一段時間再做。延遲隊列的常見使用場景有以下幾種:
- 在淘寶、京東等購物平臺上下單,超過一定時間未付款,訂單會自動取消;打車的時候,在規(guī)定時間沒有車主接單,平臺會取消你的單并提醒你暫時沒有車主接單;點外賣的時候,如果商家在10分鐘還沒接單,就會自動取消訂單;
在 Redis 可以使用有序集合(ZSet)的方式來實現(xiàn)延遲消息隊列的,ZSet 有一個 Score 屬性可以用來存儲延遲執(zhí)行的時間。
使用 zadd score1 value1 命令就可以一直往內(nèi)存中生產(chǎn)消息。再利用 zrangebysocre 查詢符合條件的所有待處理的任務, 通過循環(huán)執(zhí)行隊列任務即可。
Redis 的大 key 如何處理?
什么是 Redis 大 key?
大 key 并不是指 key 的值很大,而是 key 對應的 value 很大。
一般而言,下面這兩種情況被稱為大 key:
- String 類型的值大于 10 KB;Hash、List、Set、ZSet 類型的元素的個數(shù)超過 5000個;
大 key 會造成什么問題?
大 key 會帶來以下四種影響:
客戶端超時阻塞。由于 Redis 執(zhí)行命令是單線程處理,然后在操作大 key 時會比較耗時,那么就會阻塞 Redis,從客戶端這一視角看,就是很久很久都沒有響應。
引發(fā)網(wǎng)絡阻塞。每次獲取大 key 產(chǎn)生的網(wǎng)絡流量較大,如果一個 key 的大小是 1 MB,每秒訪問量為 1000,那么每秒會產(chǎn)生 1000MB 的流量,這對于普通千兆網(wǎng)卡的服務器來說是災難性的。
阻塞工作線程。如果使用 del 刪除大 key 時,會阻塞工作線程,這樣就沒辦法處理后續(xù)的命令。
內(nèi)存分布不均。集群模型在 slot 分片均勻情況下,會出現(xiàn)數(shù)據(jù)和查詢傾斜情況,部分有大 key 的 Redis 節(jié)點占用內(nèi)存多,QPS 也會比較大。
如何找到大 key ?
1、redis-cli --bigkeys 查找大key
可以通過 redis-cli --bigkeys 命令查找大 key:
redis-cli?-h?127.0.0.1?-p6379?-a?"password"?--?bigkeys
使用的時候注意事項:
- 最好選擇在從節(jié)點上執(zhí)行該命令。因為主節(jié)點上執(zhí)行時,會阻塞主節(jié)點;如果沒有從節(jié)點,那么可以選擇在 Redis 實例業(yè)務壓力的低峰階段進行掃描查詢,以免影響到實例的正常運行;或者可以使用 -i 參數(shù)控制掃描間隔,避免長時間掃描降低 Redis 實例的性能。
該方式的不足之處:
- 這個方法只能返回每種類型中最大的那個 bigkey,無法得到大小排在前 N 位的 bigkey;對于集合類型來說,這個方法只統(tǒng)計集合元素個數(shù)的多少,而不是實際占用的內(nèi)存量。但是,一個集合中的元素個數(shù)多,并不一定占用的內(nèi)存就多。因為,有可能每個元素占用的內(nèi)存很小,這樣的話,即使元素個數(shù)有很多,總內(nèi)存開銷也不大;
2、使用 SCAN 命令查找大 key
使用 SCAN 命令對數(shù)據(jù)庫掃描,然后用 TYPE 命令獲取返回的每一個 key 的類型。
對于 String 類型,可以直接使用 STRLEN 命令獲取字符串的長度,也就是占用的內(nèi)存空間字節(jié)數(shù)。
對于集合類型來說,有兩種方法可以獲得它占用的內(nèi)存大?。?/p>
-
- 如果能夠預先從業(yè)務層知道集合元素的平均大小,那么,可以使用下面的命令獲取集合元素的個數(shù),然后乘以集合元素的平均大小,這樣就能獲得集合占用的內(nèi)存大小了。List 類型:
LLEN
-
- 命令;Hash 類型:
HLEN
-
- 命令;Set 類型:
SCARD
-
- 命令;Sorted Set 類型:
ZCARD
-
- 命令;如果不能提前知道寫入集合的元素大小,可以使用
MEMORY USAGE
- 命令(需要 Redis 4.0 及以上版本),查詢一個鍵值對占用的內(nèi)存空間。
3、使用 RdbTools 工具查找大 key
使用 RdbTools 第三方開源工具,可以用來解析 Redis 快照(RDB)文件,找到其中的大 key。
比如,下面這條命令,將大于 10 kb 的 ?key ?輸出到一個表格文件。
rdb?dump.rdb?-c?memory?--bytes?10240?-f?redis.csv
如何刪除大 key?
刪除操作的本質(zhì)是要釋放鍵值對占用的內(nèi)存空間,不要小瞧內(nèi)存的釋放過程。
釋放內(nèi)存只是第一步,為了更加高效地管理內(nèi)存空間,在應用程序釋放內(nèi)存時,操作系統(tǒng)需要把釋放掉的內(nèi)存塊插入一個空閑內(nèi)存塊的鏈表,以便后續(xù)進行管理和再分配。這個過程本身需要一定時間,而且會阻塞當前釋放內(nèi)存的應用程序。
所以,如果一下子釋放了大量內(nèi)存,空閑內(nèi)存塊鏈表操作時間就會增加,相應地就會造成 Redis 主線程的阻塞,如果主線程發(fā)生了阻塞,其他所有請求可能都會超時,超時越來越多,會造成 Redis 連接耗盡,產(chǎn)生各種異常。
因此,刪除大 key 這一個動作,我們要小心。具體要怎么做呢?這里給出兩種方法:
- 分批次刪除異步刪除(Redis 4.0版本以上)
1、分批次刪除
對于刪除大 Hash,使用 hscan
命令,每次獲取 100 個字段,再用 hdel
命令,每次刪除 1 個字段。
Python代碼:
def?del_large_hash():
??r?=?redis.StrictRedis(host='redis-host1',?port=6379)
????large_hash_key?="xxx"?#要刪除的大hash鍵名
????cursor?=?'0'
????while?cursor?!=?0:
????????#?使用?hscan?命令,每次獲取?100?個字段
????????cursor,?data?=?r.hscan(large_hash_key,?cursor=cursor,?count=100)
????????for?item?in?data.items():
????????????????#?再用?hdel?命令,每次刪除1個字段
????????????????r.hdel(large_hash_key,?item[0])
對于刪除大 List,通過 ltrim
命令,每次刪除少量元素。
Python代碼:
def?del_large_list():
??r?=?redis.StrictRedis(host='redis-host1',?port=6379)
??large_list_key?=?'xxx'??#要刪除的大list的鍵名
??while?r.llen(large_list_key)>0:
??????#每次只刪除最右100個元素
??????r.ltrim(large_list_key,?0,?-101)?
對于刪除大 Set,使用 sscan
命令,每次掃描集合中 100 個元素,再用 srem
命令每次刪除一個鍵。
Python代碼:
def?del_large_set():
??r?=?redis.StrictRedis(host='redis-host1',?port=6379)
??large_set_key?=?'xxx'???#?要刪除的大set的鍵名
??cursor?=?'0'
??while?cursor?!=?0:
????#?使用?sscan?命令,每次掃描集合中?100?個元素
????cursor,?data?=?r.sscan(large_set_key,?cursor=cursor,?count=100)
????for?item?in?data:
??????#?再用?srem?命令每次刪除一個鍵
??????r.srem(large_size_key,?item)
對于刪除大 ZSet,使用 zremrangebyrank
命令,每次刪除 top 100個元素。
Python代碼:
def?del_large_sortedset():
??r?=?redis.StrictRedis(host='large_sortedset_key',?port=6379)
??large_sortedset_key='xxx'
??while?r.zcard(large_sortedset_key)>0:
????#?使用?zremrangebyrank?命令,每次刪除?top?100個元素
????r.zremrangebyrank(large_sortedset_key,0,99)?
2、異步刪除
從 Redis 4.0 版本開始,可以采用異步刪除法,用 unlink 命令代替 del 來刪除。
這樣 Redis 會將這個 key 放入到一個異步線程中進行刪除,這樣不會阻塞主線程。
除了主動調(diào)用 unlink 命令實現(xiàn)異步刪除之外,我們還可以通過配置參數(shù),達到某些條件的時候自動進行異步刪除。
主要有 4 種場景,默認都是關閉的:
lazyfree-lazy-eviction?no
lazyfree-lazy-expire?no
lazyfree-lazy-server-del
noslave-lazy-flush?no
它們代表的含義如下:
lazyfree-lazy-eviction:表示當 Redis 運行內(nèi)存超過 maxmeory 時,是否開啟 lazy free 機制刪除;
lazyfree-lazy-expire:表示設置了過期時間的鍵值,當過期之后是否開啟 lazy free 機制刪除;
lazyfree-lazy-server-del:有些指令在處理已存在的鍵時,會帶有一個隱式的 del 鍵的操作,比如 rename 命令,當目標鍵已存在,Redis 會先刪除目標鍵,如果這些目標鍵是一個 big key,就會造成阻塞刪除的問題,此配置表示在這種場景中是否開啟 lazy free 機制刪除;
slave-lazy-flush:針對 slave (從節(jié)點) 進行全量數(shù)據(jù)同步,slave 在加載 master 的 RDB 文件前,會運行 flushall 來清理自己的數(shù)據(jù),它表示此時是否開啟 lazy free 機制刪除。
建議開啟其中的 lazyfree-lazy-eviction、lazyfree-lazy-expire、lazyfree-lazy-server-del 等配置,這樣就可以有效的提高主線程的執(zhí)行效率。
Redis 管道有什么用?
管道技術(Pipeline)是客戶端提供的一種批處理技術,用于一次處理多個 Redis 命令,從而提高整個交互的性能。
普通命令模式,如下圖所示:
管道模式,如下圖所示:
使用管道技術可以解決多個命令執(zhí)行時的網(wǎng)絡等待,它是把多個命令整合到一起發(fā)送給服務器端處理之后統(tǒng)一返回給客戶端,這樣就免去了每條命令執(zhí)行后都要等待的情況,從而有效地提高了程序的執(zhí)行效率。
但使用管道技術也要注意避免發(fā)送的命令過大,或管道內(nèi)的數(shù)據(jù)太多而導致的網(wǎng)絡阻塞。
要注意的是,管道技術本質(zhì)上是客戶端提供的功能,而非 Redis 服務器端的功能。
Redis 事務支持回滾嗎?
MySQL 在執(zhí)行事務時,會提供回滾機制,當事務執(zhí)行發(fā)生錯誤時,事務中的所有操作都會撤銷,已經(jīng)修改的數(shù)據(jù)也會被恢復到事務執(zhí)行前的狀態(tài)。
Redis 中并沒有提供回滾機制,雖然 Redis 提供了 DISCARD 命令,但是這個命令只能用來主動放棄事務執(zhí)行,把暫存的命令隊列清空,起不到回滾的效果。
下面是 DISCARD 命令用法:
#讀取?count?的值4
127.0.0.1:6379>?GET?count
"1"
#開啟事務
127.0.0.1:6379>?MULTI?
OK
#發(fā)送事務的第一個操作,對count減1
127.0.0.1:6379>?DECR?count
QUEUED
#執(zhí)行DISCARD命令,主動放棄事務
127.0.0.1:6379>?DISCARD
OK
#再次讀取a:stock的值,值沒有被修改
127.0.0.1:6379>?GET?count
"1"
事務執(zhí)行過程中,如果命令入隊時沒報錯,而事務提交后,實際執(zhí)行時報錯了,正確的命令依然可以正常執(zhí)行,所以這可以看出 Redis 并不一定保證原子性(原子性:事務中的命令要不全部成功,要不全部失?。?/p>
比如下面這個例子:
#獲取name原本的值
127.0.0.1:6379>?GET?name
"xiaolin"
#開啟事務
127.0.0.1:6379>?MULTI
OK
#設置新值
127.0.0.1:6379(TX)>?SET?name?xialincoding
QUEUED
#注意,這條命令是錯誤的
#?expire?過期時間正確來說是數(shù)字,并不是‘10s’字符串,但是還是入隊成功了
127.0.0.1:6379(TX)>?EXPIRE?name?10s
QUEUED
#提交事務,執(zhí)行報錯
#可以看到?set?執(zhí)行成功,而?expire?執(zhí)行錯誤。
127.0.0.1:6379(TX)>?EXEC
1)?OK
2)?(error)?ERR?value?is?not?an?integer?or?out?of?range
#可以看到,name?還是被設置為新值了
127.0.0.1:6379>?GET?name
"xialincoding"
為什么Redis 不支持事務回滾?
Redis 官方文檔的解釋如下:
大概的意思是,作者不支持事務回滾的原因有以下兩個:
- 他認為 Redis 事務的執(zhí)行時,錯誤通常都是編程錯誤造成的,這種錯誤通常只會出現(xiàn)在開發(fā)環(huán)境中,而很少會在實際的生產(chǎn)環(huán)境中出現(xiàn),所以他認為沒有必要為 Redis 開發(fā)事務回滾功能;不支持事務回滾是因為這種復雜的功能和 Redis 追求的簡單高效的設計主旨不符合。
這里不支持事務回滾,指的是不支持事務運行時錯誤的事務回滾。
如何用 Redis 實現(xiàn)分布式鎖的?
分布式鎖是用于分布式環(huán)境下并發(fā)控制的一種機制,用于控制某個資源在同一時刻只能被一個應用所使用。如下圖所示:
Redis 本身可以被多個客戶端共享訪問,正好就是一個共享存儲系統(tǒng),可以用來保存分布式鎖,而且 Redis 的讀寫性能高,可以應對高并發(fā)的鎖操作場景。
Redis 的 SET 命令有個 NX 參數(shù)可以實現(xiàn)「key不存在才插入」,所以可以用它來實現(xiàn)分布式鎖:
- 如果 key 不存在,則顯示插入成功,可以用來表示加鎖成功;如果 key 存在,則會顯示插入失敗,可以用來表示加鎖失敗。
基于 Redis 節(jié)點實現(xiàn)分布式鎖時,對于加鎖操作,我們需要滿足三個條件。
- 加鎖包括了讀取鎖變量、檢查鎖變量值和設置鎖變量值三個操作,但需要以原子操作的方式完成,所以,我們使用 SET 命令帶上 NX 選項來實現(xiàn)加鎖;鎖變量需要設置過期時間,以免客戶端拿到鎖后發(fā)生異常,導致鎖一直無法釋放,所以,我們在 SET 命令執(zhí)行時加上 EX/PX 選項,設置其過期時間;鎖變量的值需要能區(qū)分來自不同客戶端的加鎖操作,以免在釋放鎖時,出現(xiàn)誤釋放操作,所以,我們使用 SET 命令設置鎖變量值時,每個客戶端設置的值是一個唯一值,用于標識客戶端;
滿足這三個條件的分布式命令如下:
SET?lock_key?unique_value?NX?PX?10000?
- lock_key 就是 key 鍵;unique_value 是客戶端生成的唯一的標識,區(qū)分來自不同客戶端的鎖操作;NX 代表只在 lock_key 不存在時,才對 lock_key 進行設置操作;PX 10000 表示設置 lock_key 的過期時間為 10s,這是為了避免客戶端發(fā)生異常而無法釋放鎖。
而解鎖的過程就是將 lock_key 鍵刪除(del lock_key),但不能亂刪,要保證執(zhí)行操作的客戶端就是加鎖的客戶端。所以,解鎖的時候,我們要先判斷鎖的 unique_value 是否為加鎖客戶端,是的話,才將 lock_key 鍵刪除。
可以看到,解鎖是有兩個操作,這時就需要 Lua 腳本來保證解鎖的原子性,因為 Redis 在執(zhí)行 Lua 腳本時,可以以原子性的方式執(zhí)行,保證了鎖釋放操作的原子性。
//?釋放鎖時,先比較?unique_value?是否相等,避免鎖的誤釋放
if?redis.call("get",KEYS[1])?==?ARGV[1]?then
????return?redis.call("del",KEYS[1])
else
????return?0
end
這樣一來,就通過使用 SET 命令和 Lua 腳本在 Redis 單節(jié)點上完成了分布式鎖的加鎖和解鎖。
基于 Redis 實現(xiàn)分布式鎖有什么優(yōu)缺點?
基于 Redis 實現(xiàn)分布式鎖的優(yōu)點:
- 性能高效(這是選擇緩存實現(xiàn)分布式鎖最核心的出發(fā)點)。實現(xiàn)方便。很多研發(fā)工程師選擇使用 Redis 來實現(xiàn)分布式鎖,很大成分上是因為 Redis 提供了 setnx 方法,實現(xiàn)分布式鎖很方便。避免單點故障(因為 Redis 是跨集群部署的,自然就避免了單點故障)。
基于 Redis 實現(xiàn)分布式鎖的缺點:
超時時間不好設置
-
- 。如果鎖的超時時間設置過長,會影響性能,如果設置的超時時間過短會保護不到共享資源。比如在有些場景中,一個線程 A 獲取到了鎖之后,由于業(yè)務代碼執(zhí)行時間可能比較長,導致超過了鎖的超時時間,自動失效,注意 A 線程沒執(zhí)行完,后續(xù)線程 B 又意外的持有了鎖,意味著可以操作共享資源,那么兩個線程之間的共享資源就沒辦法進行保護了。
那么如何合理設置超時時間呢?
-
-
- 我們可以基于續(xù)約的方式設置超時時間:先給鎖設置一個超時時間,然后啟動一個守護線程,讓守護線程在一段時間后,重新設置這個鎖的超時時間。實現(xiàn)方式就是:寫一個守護線程,然后去判斷鎖的情況,當鎖快失效的時候,再次進行續(xù)約加鎖,當主線程執(zhí)行完成后,銷毀續(xù)約鎖即可,不過這種方式實現(xiàn)起來相對復雜。
-
Redis 主從復制模式中的數(shù)據(jù)是異步復制的,這樣導致分布式鎖的不可靠性。如果在 Redis 主節(jié)點獲取到鎖后,在沒有同步到其他節(jié)點時,Redis 主節(jié)點宕機了,此時新的 Redis 主節(jié)點依然可以獲取鎖,所以多個應用服務就可以同時獲取到鎖。
Redis 如何解決集群情況下分布式鎖的可靠性?
為了保證集群環(huán)境下分布式鎖的可靠性,Redis 官方已經(jīng)設計了一個分布式鎖算法 Redlock(紅鎖)。
它是基于多個 Redis 節(jié)點的分布式鎖,即使有節(jié)點發(fā)生了故障,鎖變量仍然是存在的,客戶端還是可以完成鎖操作。官方推薦是至少部署 ?5 個 Redis 節(jié)點,而且都是主節(jié)點,它們之間沒有任何關系,都是一個個孤立的節(jié)點。
Redlock 算法的基本思路,是讓客戶端和多個獨立的 Redis 節(jié)點依次請求申請加鎖,如果客戶端能夠和半數(shù)以上的節(jié)點成功地完成加鎖操作,那么我們就認為,客戶端成功地獲得分布式鎖,否則加鎖失敗。
這樣一來,即使有某個 Redis 節(jié)點發(fā)生故障,因為鎖的數(shù)據(jù)在其他節(jié)點上也有保存,所以客戶端仍然可以正常地進行鎖操作,鎖的數(shù)據(jù)也不會丟失。
Redlock 算法加鎖三個過程:
-
- 第一步是,客戶端獲取當前時間(t1)。第二步是,客戶端按順序依次向 N 個 Redis 節(jié)點執(zhí)行加鎖操作:
-
- 加鎖操作使用 SET 命令,帶上 NX,EX/PX 選項,以及帶上客戶端的唯一標識。如果某個 Redis 節(jié)點發(fā)生故障了,為了保證在這種情況下,Redlock 算法能夠繼續(xù)運行,我們需要給「加鎖操作」設置一個超時時間(不是對「鎖」設置超時時間,而是對「加鎖操作」設置超時時間),加鎖操作的超時時間需要遠遠地小于鎖的過期時間,一般也就是設置為幾十毫秒。
-
第三步是,一旦客戶端從超過半數(shù)(大于等于 N/2+1)的 Redis 節(jié)點上成功獲取到了鎖,就再次獲取當前時間(t2),然后計算計算整個加鎖過程的總耗時(t2-t1)。如果 t2-t1 < 鎖的過期時間,此時,認為客戶端加鎖成功,否則認為加鎖失敗。
可以看到,加鎖成功要同時滿足兩個條件(簡述:如果有超過半數(shù)的 Redis 節(jié)點成功的獲取到了鎖,并且總耗時沒有超過鎖的有效時間,那么就是加鎖成功):
- 條件一:客戶端從超過半數(shù)(大于等于 N/2+1)的 Redis 節(jié)點上成功獲取到了鎖;條件二:客戶端從大多數(shù)節(jié)點獲取鎖的總耗時(t2-t1)小于鎖設置的過期時間。
加鎖成功后,客戶端需要重新計算這把鎖的有效時間,計算的結(jié)果是「鎖最初設置的過期時間」減去「客戶端從大多數(shù)節(jié)點獲取鎖的總耗時(t2-t1)」。如果計算的結(jié)果已經(jīng)來不及完成共享數(shù)據(jù)的操作了,我們可以釋放鎖,以免出現(xiàn)還沒完成數(shù)據(jù)操作,鎖就過期了的情況。
加鎖失敗后,客戶端向所有 Redis 節(jié)點發(fā)起釋放鎖的操作,釋放鎖的操作和在單節(jié)點上釋放鎖的操作一樣,只要執(zhí)行釋放鎖的 Lua 腳本就可以了。
參考資料:
- 《Redis 設計與實現(xiàn)》《Redis 實戰(zhàn)》《Redis 核心技術與實戰(zhàn)》《Redis 核心原理與實戰(zhàn) 》