Redis 概覽#
基礎數據結構#
string字符串
list列表
set集合
hash哈希字典
zset有序集合
string 字符串#
- Redis 中的字符串是可以修改的字符串,在內存中是以字節數組的形式存在,內部結構實現上類似於
Java中的ArrayList
- 采用預分配冗餘空間的方式來減少內存的分配,不過剛開始創建字符串的時候不會多分配冗餘空間,因為大多時候不會使用
append
操作來修改字符串,也就不會存在字符串擴容導致的內存重新分配 - 擴容的時候,1M 以下擴容是直接加倍,1M 以上每次擴容只會增加 1M 內存,字符串最大長度為 512M
- 字符串底層有兩種存儲方式,長度特別短的時候,使用
emb(embstr)
形式存儲,長度比較長的情況下采用raw
形式存儲
list 列表#
- Redis 中的列表是一種鏈表結構,不是數組,所以插入和刪除比較快,但是索引定位較慢,相對來說查找元素也就比較慢了
- 早期版本的話列表元素較少的情況下會使用一塊連續的內存存儲,也就是
ziplist 壓縮列表
的結構實現。元素較多的情況下,使用的是普通雙向鏈表 LinkedList 的結構。但因為鏈表附加的指針占用空間消耗問題,後面代替使用了quicklist快速鏈表
的結構
quicklist 快速鏈表#
quicklist
是ziplist
和linkedlist
的混合體,它linkedlist
按段切分,每一段使用ziplist
來緊湊存儲,多個ziplist
用雙向指針串接起來
- quicklist 內部默認單個 ziplist 長度為 8k 字節,由配置參數
list-max-ziplist-size
決定,超出規定長度的話就會新起一個 - 為了進一步節約內存空間,Redis 還會對 ziplist 進行壓縮存儲,使用
LZF算法
壓縮, 壓縮的實際深度可以通過配置參數list-compress-depth
決定
常用操作:
Redis 的列表結構常用來做異步隊列使用。將需要延後處理的任務結構體序列化成字符串塞進 Redis 的列表,另一個線程從這個列表中輪詢數據進行處理
> rpush
> lpop
> rpop
# 以下操作為O(n)操作,慎用
> lindex # 獲取指定索引位置的值
> lrange # 遍歷指定索引範圍的值
> ltrim books 1 -1 # 保留books列表索引【1 到 -1】區間的值
hash 哈希#
Redis 中的 hash 實現跟 Java 中的 HashMap 實現基本類似,底層也是基於數組 + 鏈表的數據結構
不同的是:
- Redis 字典中的值只能是字符串
- 擴容條件不一樣
- Java 的 HashMap 的 rehash 操作是一次性全部 rehash。Redis 為了高性能,不能堵塞服務,所以采用了
漸進式rehash策略
擴容實現#
擴容條件:
一般來說,當元素個數達到一維數組的長度時就會進行擴容,擴容的新數組是原來的兩倍。不過如果 Redis 正在做bgsave
(後台異步執行快照操作進行 RDB 持久化),為了減少內存頁的過多分離(Copy On Write機制
,詳細參考: COW 奶牛!Copy On Write 機制了解一下),Redis 儘量不去擴容,但是如果 hash 表已經非常滿了,達到了數組長度的 5 倍還是會進行強制擴容
擴容實現:
漸進式 rehash 會在 rehash 的同時,保留新舊兩個 hash 結構,查詢時會同時查詢兩個 hash 結構,然後在後續的定時任務中以及 hash 操作指令中,循序漸進地將舊 hash 的內容一點點遷移到新的 hash 結構中,當搬遷完成了,就會使用新的 hash 結構取而代之
常用操作:
> hset
> hmset # 批量set
> hget
> hgetall # entries(), 同時獲取所有的key、value
set 集合#
Redis 中的 set 集合類似於 Java 中的HashSet
, 內部實現相當於一個特殊的字典,元素全部當作 key 來存放,value 值全部為空值,其內部鍵值對是無序且唯一的。
常用操作: sadd,spop,smembers,sunion 等
set 結構可以用來存儲活動中獲獎的用戶 ID,因為有去重功能
zset#
類似於 Java 的 SortedSet 和 HashMap 的結合體,一方面它是一個 set,保證了內部 value 的唯一性,另一方面它可以給每個 value 賦予一個 score,代表這個 value 的排序權重。
實現方式:
- ziplist:滿足元素數量少於 128 的時候每個元素的長度小於 64 字兩個條件的時候使用此結構
- skiplist:不滿足上述兩個條件就會使用跳表,具體來說是組合了 map 和 skiplistmap 用來存儲 member 到 score 的映射,這樣就可以在 O (1) 時間內找到 member 對應的分數 skiplist 按從小到大的順序存儲分數 skiplist 每個元素的值都是 [score,value] 對
常用操作: zadd,zrange,zrem,zcard等
跳躍列表#
鏈表 + 多層索引
思考:為什麼使用跳表而不是紅黑樹 (平衡二叉樹)?
- 兩者時間複雜度一樣,跳表實現起來更為簡單
- 並發插入或者刪除元素的時候,平衡二叉樹可能需要做一些 rebalance 的操作,這樣的操作可能會涉及到整個樹的其他部分,而跳表的操作影響更局部一點,這方面性能稍微好點
- 在使用
ZRANGE
命令這種範圍查找的情況下利用跳表裡面的雙向鏈表,可以方便地操作。另外還有緩存區域化(cache locality),所以效率不會比平衡樹差
參考鏈接:
Redis 的線程 IO 模型#
Redis 是單線程的,雖然在新版本中引入了多線程,但多線程部分只是用來處理網絡數據的讀寫和協議解析, 提升網絡 IO 效率
單線程的 Redis 為什麼那麼快#
- 絕大部分是內存操作,非常快
- 因為是單線程,也就避免了上下文切換和資源競爭的問題
- 采用了非阻塞 IO - 多路復用
參考鏈接#
Redis 持久化#
Redis 為持久化提供了兩種方式:
- RDB 快照:在指定時間間隔內對數據進行快照存儲,是一次全量備份
- AOF 日誌文件:內存記錄數據修改的指令日誌記錄,是連續增量備份
Redis4.0 後提供了混合持久化的功能
RDB#
相關配置:
# 時間策略, 定時自動觸發
save 900 1
save 300 10
save 60 10000
# 文件名稱
dbfilename dump.rdb
# 文件保存路徑
dir /home/work/app/redis/data/
# 如果持久化出錯,主進程是否停止寫入
stop-writes-on-bgsave-error yes
# 是否壓縮
rdbcompression yes
# 導入時是否檢查
rdbchecksum yes
RDB 持久化的觸發有兩種:自己手動觸發與 Redis 定時觸發
手動觸發#
save
: 會阻塞當前 Redis 服務器,直到持久化完成,線上禁止使用bgsave
: 會 fork 出一個子進程,由子進程負責持久化過程,因此阻塞只會發生在 fork 子進程的時候
save 命令不常用,bgsave 的執行流程如下:
bgsave 的原理
fork
: redis 通過 fork 創建子進程來進行 bgsave 操作cow
:copy on write,子進程創建後,父子進程共享數據段,父進程繼續提供讀寫服務,寫脫的頁面數據會逐漸和子進程分離開來
自動觸發#
自動觸發的場景:
- 根據配置文件裡配置的
save m n
規則自動觸發 - 從節點全量複製時,主節點發送 rdb 文件給從節點完成複製操作,主節點會觸發
bgsave
- 執行
debug reload
時 - 執行
shutdown
時,如果沒有開啟 aof,也會觸發
AOF#
相關配置:
# 是否開啟aof
appendonly yes
# 文件名稱
appendfilename "appendonly.aof"
# 同步方式
appendfsync everysec
# aof重寫期間是否同步
no-appendfsync-on-rewrite no
# 重寫觸發配置
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
# 加載aof時如果有錯如何處理
aof-load-truncated yes
# 文件重寫策略
aof-rewrite-incremental-fsync yes
AOF 的整個流程大體來看可以分為兩步,一步是命令的實時寫入(如果是 appendfsync everysec
配置,會有 1s 損耗),第二步是對 aof 文件的重寫。
Redis 會在收到客戶端修改指令後,進行參數校驗進行邏輯處理後,如果沒問題,就立即將該指令文本存儲到 AOF 日誌中,也就是先執行指令才將日誌存盤。這點不同於 leveldb、HBase 等存儲引擎,它們都是先存儲日誌再做邏輯處理。
AOF 重寫#
Redis 在長期運行的過程中,AOF 的日誌會越變越長。如果實例宕機重啟,重放整個 AOF 日誌會非常耗時,導致長時間 Redis 無法對外提供服務。所以需要對 AOF 日誌瘦身,也就是重寫。
Redis 提供了 bgrewriteaof
指令用於對 AOF 日誌進行瘦身。其原理就是開辟一個子進程對內存進行遍歷轉換成一系列 Redis 的操作指令,序列化到一個新的 AOF 日誌文件中。序列化完畢後再將操作期間發生的增量 AOF 日誌追加到這個新的 AOF 日誌文件中,追加完畢後就立即替代舊的 AOF 日誌文件了
日誌丟失#
AOF 日誌是以文件的形式存在的,當程序對 AOF 日誌文件進行寫操作時,實際上是將內容寫到了內核為文件描述符分配的一個內存緩存中,然後內核異步將數據刷回到磁碟。那麼機器如果突然宕機,日誌內容還沒完全寫入到磁碟中就會出現日誌丟失的情況。
Linux 的glibc
提供了fsync(int fd)
函數可以將指定文件的內容強制從內核緩存刷到磁碟。在生產環境的服務器中,Redis 通常是每隔 1s 左右執行一次 fsync 操作,周期 1s 是可以配置的。這是在數據安全性和性能之間做了一个折中,在保持高性能的同時,盡可能使得數據少丟失
參考鏈接#
Redis 的同步機制#
Redis 可以使用主從同步,從從同步。第一次同步時,主節點做一次 bgsave,並同時將後續修改操作記錄到內存 buffer,待完成後將 rdb 文件全量同步到複製節點,複製節點接受完成後將 rdb 鏡像加載到內存。加載完成後,再通知主節點將期間修改的操作記錄同步到複製節點進行重放就完成了同步過程
Redis 事務#
得益於 Redis 的單線程模型,所以其事務模型使用起來很簡單。
每個事務的操作都有 begin、commit 和 rollback,begin 指示事務的開始,commit 指示事務的提交,rollback 指示事務的回滾。
Redis 在形式上看起來也差不多,分別是 multi/exec/discard
。multi
指示事務的開始,exec
指示事務的執行,discard
指示事務的丟棄
Redis 的緩存失效機制#
作為緩存系統都要定期清理無效數據,就需要一個主鍵失效和淘汰策略
過期刪除策略#
redis 會將每個設置了過期時間的 key 放入到一個獨立的字典中,以後會定期遍歷
這個字典來刪除到期的 key。除了定期刪除之外,它還會使用惰性策略
來刪除過期的 key,所謂惰性策略就是在客戶端訪問這個 key 的時候,redis 對 key 的過期時間進行檢查,如果過期了就立即刪除。定時刪除是集中處理,惰性刪除是零散處理
6 種淘汰策略#
當 Redis 內存超出 maxmemory
限制時,內存的數據會開始和磁碟產生頻繁的交換 (swap),這會讓 Redis 的性能急劇下降,因此 Redis 提供了幾種淘汰策略:
-
noeviction
: 不會繼續服務寫請求 (DEL 請求可以繼續服務),讀請求可以繼續進行。這樣可以保證不會丟失數據,但是會讓線上的業務不能持續進行。這是默認的淘汰策略。 -
volatile-lru
: 嘗試淘汰設置了過期時間的 key,最少使用的 key 優先被淘汰。沒有設置過期時間的 key 不會被淘汰,這樣可以保證需要持久化的數據不會突然丟失。 -
volatile-ttl
: 淘汰設置了過期時間的 key, key 的剩餘壽命ttl
的值越小越優先被淘汰。 -
volatile-random
: 淘汰設置了過期時間的 key, 不過淘汰的 key 是過期 key 集合中隨機的 key。 -
allkeys-lru
: 區別於 volatile-lru,這個策略要淘汰的 key 對象是全體的 key 集合,而不只是過期的 key 集合。這意味著沒有設置過期時間的 key 也會被淘汰。 -
allkeys-random
: 從全體的 key 集合中隨機挑選淘汰
Redis 的哈希槽#
Redis 集群沒有使用一致性 hash, 而是引入了哈希槽的概念,Redis 集群有 16384 個哈希槽,每個 key 通過 CRC16 校驗後對 16384 取模來決定放置哪個槽,集群的每個節點負責一部分 hash 槽
Redis 中的管道#
將多個命令發送到服務器,而不用等待回覆,最後在一個步驟中讀取該答復,提升 IO 效率。
Redis 的集群#
-
Redis Sentinal 著眼於高可用,在 master 宕機時會自動將 slave 提升為 master,繼續提供服務。
-
Redis Cluster 著眼於擴展性,在單個 redis 內存不足時,使用 Cluster 進行分片存儲
緩存場景問題#
緩存穿透#
要查詢的數據在緩存和數據庫中都不存在,可能因此惡意高併發請求攻擊導致後台 DB 崩潰
解決思路:
- 緩存空值:第一次查詢後,針對不存在的 key 設置其值為空值,後續對這個 key 的請求直接就返回空,但要注意設置好過期時間
- BloomFilter: 利用布隆過濾器判斷元素(某個 key)是否存在
緩存雪崩#
設置緩存時采用了相同的過期時間,然後某一時刻出現大規模的緩存失效,從而導致去查詢數據庫,進一步可能導致數據庫壓力過大的情況
解決思路:
- 打散過期時間設置:比如在原有過期時間設置的基礎上加上一個隨機值,降低緩存過期時間的重複率
- 限流 && 降級,避免後台 DB 因請求壓力過大而崩潰
- 使用鎖或隊列,避免過多請求同一時刻落到後台 DB 上
- 給每一個緩存數據 value 值添加一個緩存標記時間,這個時間比實際設置的緩存時間要早,當讀取到緩存標記失效時,會通知其他線程去更新緩存,實際還能返回舊緩存的數據直到更新緩存完成
緩存擊穿#
某個時間點這個緩存過期了,恰好此時有很多針對這個緩存的查詢請求,發現緩存失效後會從後端 DB 加載數據並更新到緩存,請求量過大的情況下可能會導致 DB 壓力過大而崩潰
注意:
緩存擊穿和緩存雪崩是不同的,前者針對的是單個 key,而雪崩是很多 key
解決思路:
參考緩存雪崩,類似
緩存預熱#
系統上線後,先將相關的緩存數據加載到緩存中,避免了第一次請求查詢從 DB 中加載更新緩存
緩存更新#
先更新數據庫再更新緩存#
不適用,有以下問題:
- 線程不安全,可能造成髒數據。比如多個寫請求到達,本來應該是將最後的寫請求數據更新到緩存,但可能因為網絡波動原因,實際更新到緩存中的數據是前面其中某一次寫的數據
- 不適合寫多讀少的場景,緩存被頻繁更新,浪費性能
先刪緩存,再更新數據庫#
問題分析:
同時有一個請求 A 進行更新操作,另一個請求 B 進行查詢操作,那麼會出現如下情形:
- 請求 A 進行寫操作,刪除緩存
- 請求 B 查詢發現緩存不存在
- 請求 B 去數據庫查詢得到舊值
- 請求 B 將舊值寫入緩存
- 請求 A 將新值寫入數據庫
上述情況就會導致不一致的情形出現。而且,如果不采用給緩存設置過期時間策略,該數據永遠都是髒數據
解決方案: 延時雙刪策略
- 先淘汰緩存
- 再寫數據庫(這兩步和原來一樣)
- 休眠一個短時間比如 1s,再次淘汰緩存
這樣就可以將 1s 內產生的髒數據清除了,另外這個休眠時間需要根據業務實際場景評估
先更新數據庫,再刪緩存 (常用策略)#
失效
:應用程序先從 cache 取數據,沒有得到,則從數據庫中取數據,成功後,放到緩存中。命中
:應用程序從 cache 中取數據,取到後返回。更新
:先把數據存到數據庫中,成功後,再讓緩存失效,刪除緩存
問題分析:
同樣假設有兩個請求,一個請求 A 做查詢操作,一個請求 B 做更新操作,那麼會有如下情形產生
- 緩存剛好失效
- 請求 A 查詢數據庫,得一個舊值
- 請求 B 將新值寫入數據庫
- 請求 B 刪除緩存
- 請求 A 將查到的舊值寫入緩存
發生數據不一致的前提條件是需要步驟 3 的寫數據庫操作比 2 的讀數據庫操作耗時更短,但數據庫的讀操作的速度遠快於寫操作的,這種概率還是很低的。