高效能的Redis資料結構小結
一、概述
Redis 作為一種 KV 快取伺服器,有著極高的效能,相對於 Memcache,Redis 支援更多種資料型別,因此在業界應用廣泛。
記得剛畢業那會參加面試,面試官會問我 Redis 為什麼快,由於當時技術水平有限,我只能回答出如下兩點:
- 資料是儲存在記憶體中的。
- Redis 是單執行緒的。
當然,將資料儲存在記憶體中,讀取的時候不需要進行磁碟的 IO,單執行緒也保證了系統沒有執行緒的上下文切換。
但這兩點只是 Redis 高效能原因的很小一部分,下面從資料儲存層面上為大家分析 Redis 效能為何如此高。
Redis效能如此高的原因,我總結了如下幾點:
- 純記憶體操作
- 單執行緒
- 高效的資料結構
- 合理的資料編碼
- 其他方面的優化
在 Redis 中,常用的 5 種資料結構和應用場景如下:
- String:快取、計數器、分散式鎖等。
- List:連結串列、佇列、微博關注人時間軸列表等。
- Hash:使用者資訊、Hash 表等。
- Set:去重、贊、踩、共同好友等。
- Zset:訪問量排行榜、點選量排行榜等。
二、資料結構型別
1. 字串型別 SDS
Redis 是用 C 語言開發完成的,但在 Redis 字串中,並沒有使用 C 語言中的字串,而是用一種稱為 SDS(Simple Dynamic String)的結構體來儲存字串。
struct sdshdr { int len; int free; char buf[]; }
SDS 的結構如上圖:
- len:用於記錄 buf 中已使用空間的長度。
- free:buf 中空閒空間的長度。
- buf[]:儲存實際內容。
例如:執行命令 set key value,key 和 value 都是一個 SDS 型別的結構儲存在記憶體中。
SDS 與 C 字串的區別
①常數時間內獲得字串長度
C 字串本身不記錄長度資訊,每次獲取長度資訊都需要遍歷整個字串,複雜度為 O(n);C 字串遍歷時遇到 '\0' 時結束。
SDS 中 len 欄位儲存著字串的長度,所以總能在常數時間內獲取字串長度,複雜度是 O(1)。
②避免緩衝區溢位
假設在記憶體中有兩個緊挨著的兩個字串,s1=“xxxxx”和 s2=“yyyyy”。
由於在記憶體上緊緊相連,當我們對 s1 進行擴充的時候,將 s1=“xxxxxzzzzz”後,由於沒有進行相應的記憶體重新分配,導致 s1 把 s2 覆蓋掉,導致 s2 被莫名其妙的修改。
但 SDS 的 API 對 zfc 修改時首先會檢查空間是否足夠,若不充足則會分配新空間,避免了緩衝區溢位問題。
③減少字串修改時帶來的記憶體重新分配的次數
在 C 中,當我們頻繁的對一個字串進行修改(append 或 trim)操作的時候,需要頻繁的進行記憶體重新分配的操作,十分影響效能。
如果不小心忘記,有可能會導致記憶體溢位或記憶體洩漏,對於 Redis 來說,本身就會很頻繁的修改字串,所以使用 C 字串並不合適。而 SDS 實現了空間預分配和惰性空間釋放兩種優化策略:
空間預分配:當 SDS 的 API 對一個 SDS 修改後,並且對 SDS 空間擴充時,程式不僅會為 SDS 分配所需要的必須空間,還會分配額外的未使用空間。
分配規則如下:如果對 SDS 修改後,len 的長度小於 1M,那麼程式將分配和 len 相同長度的未使用空間。舉個例子,如果 len=10,重新分配後,buf 的實際長度會變為 10(已使用空間)+10(額外空間)+1(空字元)=21。如果對 SDS 修改後 len 長度大於 1M,那麼程式將分配 1M 的未使用空間。
惰性空間釋放:當對 SDS 進行縮短操作時,程式並不會回收多餘的記憶體空間,而是使用 free 欄位將這些位元組數量記錄下來不釋放,後面如果需要 append 操作,則直接使用 free 中未使用的空間,減少了記憶體的分配。
④二進位制安全
在 Redis 中不僅可以儲存 String 型別的資料,也可能儲存一些二進位制資料。
二進位制資料並不是規則的字串格式,其中會包含一些特殊的字元如 '\0',在 C 中遇到 '\0' 則表示字串的結束,但在 SDS 中,標誌字串結束的是 len 屬性。
2. 字典
與 Java 中的 HashMap 類似,Redis 中的 Hash 比 Java 中的更高階一些。
Redis 本身就是 KV 伺服器,除了 Redis 本身資料庫之外,字典也是雜湊鍵的底層實現。
字典的資料結構如下所示:
typedef struct dict{ dictType *type; void *privdata; dictht ht[2]; int trehashidx; } typedef struct dictht{ //雜湊表陣列 dectEntrt **table; //雜湊表大小 unsigned long size; // unsigned long sizemask; //雜湊表已有節點數量 unsigned long used; }
重要的兩個欄位是 dictht 和 trehashidx,這兩個欄位與 rehash 有關,下面重點介紹 rehash。
Rehash
學過 Java 的朋友都應該知道 HashMap 是如何 rehash 的,在此處我就不過多贅述,下面介紹 Redis 中 Rehash 的過程。
由上段程式碼,我們可知 dict 中儲存了一個 dictht 的陣列,長度為 2,表明這個資料結構中實際儲存著兩個雜湊表 ht[0] 和 ht[1],為什麼要儲存兩張 hash 表呢?
當然是為了 Rehash,Rehash 的過程如下:
- 為 ht[1] 分配空間。如果是擴容操作,ht[1] 的大小為第一個大於等於 ht[0].used*2 的 2^n;如果是縮容操作,ht[1] 的大小為第一個大於等於 ht[0].used 的 2^n。
- 將 ht[0] 中的鍵值 Rehash 到 ht[1] 中。
- 當 ht[0] 全部遷移到 ht[1] 中後,釋放 ht[0],將 ht[1] 置為 ht[0],併為 ht[1] 建立一張新表,為下次 Rehash 做準備。
漸進式 Rehash
由於 Redis 的 Rehash 操作是將 ht[0] 中的鍵值全部遷移到 ht[1],如果資料量小,則遷移過程很快。但如果資料量很大,一個 Hash 表中儲存了幾萬甚至幾百萬幾千萬的鍵值時,遷移過程很慢並會影響到其他使用者的使用。
為了避免 Rehash 對伺服器效能造成影響,Redis 採用了一種漸進式 Rehash 的策略,分多次、漸進的將 ht[0] 中的資料遷移到 ht[1] 中。
前一過程如下:
- 為 ht[1] 分配空間,讓字典同時擁有 ht[0] 和 ht[1] 兩個雜湊表。
- 字典中維護一個 rehashidx,並將它置為 0,表示 Rehash 開始。
- 在 Rehash 期間,每次對字典操作時,程式還順便將 ht[0] 在 rehashidx 索引上的所有鍵值對 rehash 到 ht[1] 中,當 Rehash 完成後,將 rehashidx 屬性+1。當全部 rehash 完成後,將 rehashidx 置為 -1,表示 rehash 完成。
注意,由於維護了兩張 Hash 表,所以在 Rehash 的過程中記憶體會增長。另外,在 Rehash 過程中,字典會同時使用 ht[0] 和 ht[1]。
所以在刪除、查詢、更新時會在兩張表中操作,在查詢時會現在第一張表中查詢,如果第一張表中沒有,則會在第二張表中查詢。但新增時一律會在 ht[1] 中進行,確保 ht[0] 中的資料只會減少不會增加。
3.跳躍表
Zset 是一個有序的連結串列結構,其底層的資料結構是跳躍表 skiplist,其結構如下:
typedef struct zskiplistNode { //成員物件 robj *obj; //分值 double score; //後退指標 struct zskiplistNode *backward; //層 struct zskiplistLevel { struct zskiplistNode *forward;//前進指標 unsigned int span;//跨度 } level[]; } zskiplistNode; typedef struct zskiplist { //表頭節點和表尾節點 struct zskiplistNode *header, *tail; //表中節點的的數量 unsigned long length; //表中層數最大的節點層數 int level; } zskiplist;
前進指標:用於從表頭向表尾方向遍歷。
後退指標:用於從表尾向表頭方向回退一個節點,和前進指標不同的是,前進指標可以一次性跳躍多個節點,後退指標每次只能後退到上一個節點。
跨度:表示當前節點和下一個節點的距離,跨度越大,兩個節點中間相隔的元素越多。
在查詢過程中跳躍著前進。由於存在後退指標,如果查詢時超出了範圍,通過後退指標回退到上一個節點後仍可以繼續向前遍歷。
4.壓縮列表
壓縮列表 ziplist 是為 Redis 節約記憶體而開發的,是列表鍵和字典鍵的底層實現之一。
當元素個數較少時,Redis 用 ziplist 來儲存資料,當元素個數超過某個值時,連結串列鍵中會把 ziplist 轉化為 linkedlist,字典鍵中會把 ziplist 轉化為 hashtable。
ziplist 是由一系列特殊編碼的連續記憶體塊組成的順序型的資料結構,ziplist 中可以包含多個 entry 節點,每個節點可以存放整數或者字串。
由於記憶體是連續分配的,所以遍歷速度很快。
三、物件的組成
Redis 使用物件(redisObject)來表示資料庫中的鍵值,當我們在 Redis 中建立一個鍵值對時,至少建立兩個物件,一個物件是用做鍵值對的鍵物件,另一個是鍵值對的值物件。
例如我們執行 SET MSG XXX 時,鍵值對的鍵是一個包含了字串“MSG“的物件,鍵值對的值物件是包含字串"XXX"的物件。
redisObject 的結構如下:
typedef struct redisObject{ //型別 unsigned type:4; //編碼 unsigned encoding:4; //指向底層資料結構的指標 void *ptr; //... }robj;
其中 type 欄位記錄了物件的型別,包含字串物件、列表物件、雜湊物件、集合物件、有序集合物件。
當我們執行 type key 命令時返回的結果如下:
ptr 指標欄位指向物件底層實現的資料結構,而這些資料結構是由 encoding 欄位決定的,每種物件至少有兩種資料編碼:
可以通過 object encoding key 來檢視物件所使用的編碼:
細心的讀者可能注意到,list、hash、zset 三個鍵使用的是 ziplist 壓縮列表編碼,這就涉及到了 Redis 底層的編碼轉換。
上面介紹到,ziplist 是一種結構緊湊的資料結構,當某一鍵值中所包含的元素較少時,會優先儲存在 ziplist 中,當元素個數超過某一值後,才將 ziplist 轉化為標準儲存結構,而這一值是可配置的。
四、編碼轉化
1. String 物件的編碼轉化
String 物件的編碼可以是 int 或 raw,對於 String 型別的鍵值,如果我們儲存的是純數字,Redis 底層採用的是 int 型別的編碼,如果其中包括非數字,則會立即轉為 raw 編碼:
127.0.0.1:6379> set str 1 OK 127.0.0.1:6379> object encoding str "int" 127.0.0.1:6379> set str 1a OK 127.0.0.1:6379> object encoding str "raw" 127.0.0.1:6379>
2.List 物件的編碼轉化
List 物件的編碼可以是 ziplist 或 linkedlist,對於 List 型別的鍵值,當列表物件同時滿足以下兩個條件時,採用 ziplist 編碼:
- 列表物件儲存的所有字串元素的長度都小於 64 位元組。
- 列表物件儲存的元素個數小於 512 個。
如果不滿足這兩個條件的任意一個,就會轉化為 linkedlist 編碼。注意:這兩個條件是可以修改的,在 redis.conf 中:
list-max-ziplist-entries 512 list-max-ziplist-value 64
3.Set 型別的編碼轉化
Set 物件的編碼可以是 intset 或 hashtable,intset 編碼的結婚物件使用整數集合作為底層實現,把所有元素都儲存在一個整數集合裡面。
127.0.0.1:6379> sadd set 1 2 3 (integer) 3 127.0.0.1:6379> object encoding set "intset" 127.0.0.1:6379>
如果 set 集合中儲存了非整數型別的資料時,Redis 會將 intset 轉化為 hashtable:
127.0.0.1:6379> sadd set 1 2 3 (integer) 3 127.0.0.1:6379> object encoding set "intset" 127.0.0.1:6379> sadd set a (integer) 1 127.0.0.1:6379> object encoding set "hashtable" 127.0.0.1:6379>
當 Set 物件同時滿足以下兩個條件時,物件採用 intset 編碼:
- 儲存的所有元素都是整數值(小數不行)。
- Set 物件儲存的所有元素個數小於 512 個。
不能滿足這兩個條件的任意一個,Set 都會採用 hashtable 儲存。注意:第兩個條件是可以修改的,在 redis.conf 中:
set-max-intset-entries 512
4.Hash 物件的編碼轉化
Hash 物件的編碼可以是 ziplist 或 hashtable,當 Hash 以 ziplist 編碼儲存的時候,儲存同一鍵值對的兩個節點總是緊挨在一起,鍵節點在前,值節點在後:
當 Hash 物件同時滿足以下兩個條件時,Hash 物件採用 ziplist 編碼:
- Hash 物件儲存的所有鍵值對的鍵和值的字串長度均小於 64 位元組。
- Hash 物件儲存的鍵值對數量小於 512 個。
如果不滿足以上條件的任意一個,ziplist 就會轉化為 hashtable 編碼。注意:這兩個條件是可以修改的,在 redis.conf 中:
hash-max-ziplist-entries 512 hash-max-ziplist-value 64
5.Zset 物件的編碼轉化
Zset 物件的編碼可以是 ziplist 或 zkiplist,當採用 ziplist 編碼儲存時,每個集合元素使用兩個緊挨在一起的壓縮列表來儲存。
第一個節點儲存元素的成員,第二個節點儲存元素的分值,並且按分值大小從小到大有序排列。
當 Zset 物件同時滿足一下兩個條件時,採用 ziplist 編碼:
- Zset 儲存的元素個數小於 128。
- Zset 元素的成員長度都小於 64 位元組。
如果不滿足以上條件的任意一個,ziplist 就會轉化為 zkiplist 編碼。注意:這兩個條件是可以修改的,在 redis.conf 中:
zset-max-ziplist-entries 128 zset-max-ziplist-value 64
思考:Zset 如何做到 O(1) 複雜度內元素並且快速進行範圍操作?Zset 採用 skiplist 編碼時使用 zset 結構作為底層實現,該資料結構同時包含了一個跳躍表和一個字典。
其結構如下:
typedef struct zset{ zskiplist *zsl; dict *dict; }
Zset 中的 dict 字典為集合建立了一個從成員到分值之間的對映,字典中的鍵儲存了成員,字典中的值儲存了成員的分值,這樣定位元素時時間複雜度是 O(1)。
Zset 中的 zsl 跳躍表適合範圍操作,比如 ZRANK、ZRANGE 等,程式使用 zkiplist。
另外,雖然 Zset 中使用了 dict 和 skiplist 儲存資料,但這兩種資料結構都會通過指標來共享相同的記憶體,所以沒有必要擔心記憶體的浪費。
五、過期資料的刪除對 Redis 效能影響
當我們對某些 key 設定了 expire 時,資料到了時間會自動刪除。如果一個鍵過期了,它會在什麼時候刪除呢?
下面介紹三種刪除策略:
- 定時刪除:在這是鍵的過期時間的同時,建立一個定時器 Timer,讓定時器在鍵過期時間來臨時立即執行對過期鍵的刪除。
- 惰性刪除:鍵過期後不管,每次讀取該鍵時,判斷該鍵是否過期,如果過期刪除該鍵返回空。
- 定期刪除:每隔一段時間對資料庫中的過期鍵進行一次檢查。
定時刪除:對記憶體友好,對 CPU 不友好。如果過期刪除的鍵比較多的時候,刪除鍵這一行為會佔用相當一部分 CPU 效能,會對 Redis 的吞吐量造成一定影響。
惰性刪除:對 CPU 友好,記憶體不友好。如果很多鍵過期了,但在將來很長一段時間內沒有很多客戶端訪問該鍵導致過期鍵不會被刪除,佔用大量記憶體空間。
定期刪除:是定時刪除和惰性刪除的一種折中。每隔一段時間執行一次刪除過期鍵的操作,並且限制刪除操作執行的時長和頻率。
具體的操作如下:
- Redis 會將每一個設定了 expire 的鍵儲存在一個獨立的字典中,以後會定時遍歷這個字典來刪除過期的 key。除了定時遍歷外,它還會使用惰性刪除策略來刪除過期的 key。
- Redis 預設每秒進行十次過期掃描,過期掃描不會掃描所有過期字典中的 key,而是採用了一種簡單的貪心策略。
從過期字典中隨機選擇 20 個 key;刪除這 20 個 key 中已過期的 key;如果過期 key 比例超過 1/4,那就重複步驟 1。 - 同時,為了保證在過期掃描期間不會出現過度迴圈,導致執行緒卡死,演算法還增加了掃描時間上限,預設不會超過 25ms。
六、總結
總而言之,Redis 為了高效能,從各方各面都進行了優化,這裡的總結到這裡就結束了,想要了解更詳細內容請參閱之前文章。