1. 程式人生 > >Redis為何這麼快——資料儲存角度

Redis為何這麼快——資料儲存角度

Redis為何這麼快——資料儲存角度

 

一、簡介和應用

Redis是一個由ANSI C語言編寫,效能優秀、支援網路、可持久化的K-K記憶體資料庫並提供多種語言的API。它常用的型別主要是 String、List、Hash、Set、ZSet 這5種

Redis為何這麼快——資料儲存角度

 

Redis在網際網路公司一般有以下應用:

  • String:快取、限流、計數器、分散式鎖、分散式Session
  • Hash:儲存使用者資訊、使用者主頁訪問量、組合查詢
  • List:微博關注人時間軸列表、簡單佇列
  • Set:贊、踩、標籤、好友關係
  • Zset:排行榜

再比如電商在大促銷時,會用一些特殊的設計來保證系統穩定,扣減庫存可以考慮如下設計:

Redis為何這麼快——資料儲存角度

 

上圖中,直接在Redis中扣減庫存,記錄日誌後通過Worker同步到資料庫,在設計同步Worker時需要考慮併發處理和重複處理的問題。

通過上面的應用場景可以看出Redis是非常高效和穩定的,那Redis底層是如何實現的呢?

二、Redis的物件redisObject

當我們執行set hello world命令時,會有以下資料模型:

Redis為何這麼快——資料儲存角度

 

  • dictEntry:Redis給每個key-value鍵值對分配一個dictEntry,裡面有著key和val的指標,next指向下一個dictEntry形成連結串列,這個指標可以將多個雜湊值相同的鍵值對連結在一起,由此來解決雜湊衝突問題(鏈地址法)。
  • sds:鍵key“hello”是以SDS(簡單動態字串)儲存,後面詳細介紹。
  • redisObject:值val“world”儲存在redisObject中。實際上,redis常用5中型別都是以redisObject來儲存的;而redisObject中的type欄位指明瞭Value物件的型別,ptr欄位則指向物件所在的地址。

redisObject物件非常重要,Redis物件的型別、內部編碼、記憶體回收、共享物件等功能,都需要redisObject支援。這樣設計的好處是,可以針對不同的使用場景,對5中常用型別設定多種不同的資料結構實現,從而優化物件在不同場景下的使用效率。

無論是dictEntry物件,還是redisObject、SDS物件,都需要記憶體分配器(如jemalloc)分配記憶體進行儲存。jemalloc作為Redis的預設記憶體分配器,在減小記憶體碎片方面做的相對比較好。

比如jemalloc在64位系統中,將記憶體空間劃分為小、大、巨大三個範圍;每個範圍內又劃分了許多小的記憶體塊單位;當Redis儲存資料時,會選擇大小最合適的記憶體塊進行儲存。

前面說過,Redis每個物件由一個redisObject結構表示,它的ptr指標指向底層實現的資料結構,而資料結構由encoding屬性決定。比如我們執行以下命令得到儲存“hello”對應的編碼:

Redis為何這麼快——資料儲存角度

 

redis所有的資料結構型別如下(重要,後面會用):

Redis為何這麼快——資料儲存角度

 

三、String

字串物件的底層實現可以是int、raw、embstr(上面的表對應有名稱介紹)。embstr編碼是通過呼叫一次記憶體分配函式來分配一塊連續的空間,而raw需要呼叫兩次。

Redis為何這麼快——資料儲存角度

 

int編碼字串物件和embstr編碼字串物件在一定條件下會轉化為raw編碼字串物件。embstr:<=39位元組的字串。int:8個位元組的長整型。raw:大於39個位元組的字串。

簡單動態字串(SDS),這種結構更像C++的String或者Java的ArrayList<Character>,長度動態可變:

Redis為何這麼快——資料儲存角度

 

  • get:sdsrange---O(n)
  • set:sdscpy—O(n)
  • create:sdsnew---O(1)
  • len:sdslen---O(1)

常數複雜度獲取字串長度:因為SDS在len屬性中記錄了長度,所以獲取一個SDS長度時間複雜度僅為O(1)。

預空間分配:如果對一個SDS進行修改,分為一下兩種情況:

  • SDS長度(len的值)小於1MB,那麼程式將分配和len屬性同樣大小的未使用空間,這時free和len屬性值相同。舉個例子,SDS的len將變成15位元組,則程式也會分配15位元組的未使用空間,SDS的buf陣列的實際長度變成15+15+1=31位元組(額外一個位元組使用者儲存空字元)。
  • SDS長度(len的值)大於等於1MB,程式會分配1MB的未使用空間。比如進行修改之後,SDS的len變成30MB,那麼它的實際長度是30MB+1MB+1byte。

惰性釋放空間:當執行sdstrim(擷取字串)之後,SDS不會立馬釋放多出來的空間,如果下次再進行拼接字串操作,且拼接的沒有剛才釋放的空間大,則那些未使用的空間就會排上用場。通過惰性釋放空間避免了特定情況下操作字串的記憶體重新分配操作。

杜絕緩衝區溢位:使用C字串的操作時,如果字串長度增加(如strcat操作)而忘記重新分配記憶體,很容易造成緩衝區的溢位;而SDS由於記錄了長度,相應的操作在可能造成緩衝區溢位時會自動重新分配記憶體,杜絕了緩衝區溢位。

四、List

List物件的底層實現是quicklist(快速列表,是ziplist 壓縮列表 和linkedlist 雙端連結串列 的組合)。Redis中的列表支援兩端插入和彈出,並可以獲得指定位置(或範圍)的元素,可以充當陣列、佇列、棧等。

Redis為何這麼快——資料儲存角度

 

  • rpush: listAddNodeHead ---O(1)
  • lpush: listAddNodeTail ---O(1)
  • push:listInsertNode ---O(1)
  • index : listIndex ---O(N)
  • pop:ListFirst/listLast ---O(1)
  • llen:listLength ---O(N)

4.1 linkedlist(雙端連結串列)

此結構比較像Java的LinkedList,有興趣可以閱讀一下原始碼。

Redis為何這麼快——資料儲存角度

 

從圖中可以看出Redis的linkedlist雙端連結串列有以下特性:節點帶有prev、next指標、head指標和tail指標,獲取前置節點、後置節點、表頭節點和表尾節點的複雜度都是O(1)。len屬性獲取節點數量也為O(1)

與雙端連結串列相比,壓縮列表可以節省記憶體空間,但是進行修改或增刪操作時,複雜度較高;因此當節點數量較少時,可以使用壓縮列表;但是節點數量多時,還是使用雙端連結串列划算。

4.2 ziplist(壓縮列表)

當一個列表鍵只包含少量列表項,且是小整數值或長度比較短的字串時,那麼redis就使用ziplist(壓縮列表)來做列表鍵的底層實現。

Redis為何這麼快——資料儲存角度

 

ziplist是Redis為了節約記憶體而開發的,是由一系列特殊編碼的連續記憶體塊(而不是像雙端連結串列一樣每個節點是指標)組成的順序型資料結構;具體結構相對比較複雜,有興趣讀者可以看 Redis 雜湊結構記憶體模型剖析。在新版本中list連結串列使用 quicklist 代替了 ziplist和 linkedlist

Redis為何這麼快——資料儲存角度

 

quickList 是 zipList 和 linkedList 的混合體。它將 linkedList 按段切分,每一段使用 zipList 來緊湊儲存,多個 zipList 之間使用雙向指標串接起來。因為連結串列的附加空間相對太高,prev 和 next 指標就要佔去 16 個位元組 (64bit 系統的指標是 8 個位元組),另外每個節點的記憶體都是單獨分配,會加劇記憶體的碎片化,影響記憶體管理效率。

Redis為何這麼快——資料儲存角度

 

quicklist 預設的壓縮深度是 0,也就是不壓縮。為了支援快速的 push/pop 操作,quicklist 的首尾兩個 ziplist 不壓縮,此時深度就是 1。為了進一步節約空間,Redis 還會對 ziplist 進行壓縮儲存,使用 LZF 演算法壓縮。

五、Hash

Hash物件的底層實現可以是ziplist(壓縮列表)或者hashtable(字典或者也叫雜湊表)。

Redis為何這麼快——資料儲存角度

 

Hash物件只有同時滿足下面兩個條件時,才會使用ziplist(壓縮列表):1.雜湊中元素數量小於512個;2.雜湊中所有鍵值對的鍵和值字串長度都小於64位元組。

hashtable雜湊表可以實現O(1)複雜度的讀寫操作,因此效率很高。原始碼如下

Redis為何這麼快——資料儲存角度

 

上面原始碼可以簡化成如下結構:

Redis為何這麼快——資料儲存角度

 

這個結構類似於JDK7以前的HashMap<String,Object>,當有兩個或以上的鍵被分配到雜湊陣列的同一個索引上時,會產生雜湊衝突。Redis也使用鏈地址法來解決鍵衝突。即每個雜湊表節點都有一個next指標,多個雜湊表節點用next指標構成一個單項鍊表,鏈地址法就是將相同hash值的物件組織成一個連結串列放在hash值對應的槽位。

Redis中的字典使用hashtable作為底層實現的話,每個字典會帶有兩個雜湊表,一個平時使用,另一個僅在rehash(重新雜湊)時使用。隨著對雜湊表的操作,鍵會逐漸增多或減少。為了讓雜湊表的負載因子維持在一個合理範圍內,Redis會對雜湊表的大小進行擴充套件或收縮(rehash),也就是將ht【0】裡面所有的鍵值對分多次、漸進式的rehash到ht【1】裡

六、Set

Set集合物件的底層實現可以是intset(整數集合)或者hashtable(字典或者也叫雜湊表)。

Redis為何這麼快——資料儲存角度

 

intset(整數集合)當一個集合只含有整數,並且元素不多時會使用intset(整數集合)作為Set集合物件的底層實現。

Redis為何這麼快——資料儲存角度

 

  • sadd:intsetAdd---O(1)
  • smembers:intsetGetO(1)---O(N)
  • srem:intsetRemove---O(N)
  • slen:intsetlen ---O(1)

intset底層實現為有序,無重複陣列儲存集合元素。 intset這個結構裡的整數陣列的型別可以是16位的,32位的,64位的。如果數組裡所有的整數都是16位長度的,如果新加入一個32位的整數,那麼整個16的陣列將升級成一個32位的陣列。升級可以提升intset的靈活性,又可以節約記憶體,但不可逆。

7.ZSet

ZSet有序集合物件底層實現可以是ziplist(壓縮列表)或者skiplist(跳躍表)。

Redis為何這麼快——資料儲存角度

 

當一個有序集合的元素數量比較多或者成員是比較長的字串時,Redis就使用skiplist(跳躍表)作為ZSet物件的底層實現。

Redis為何這麼快——資料儲存角度

 

zadd---zslinsert---平均O(logN), 最壞O(N)

zrem---zsldelete---平均O(logN), 最壞O(N)

zrank--zslGetRank---平均O(logN), 最壞O(N)

Redis為何這麼快——資料儲存角度

 

skiplist的查詢時間複雜度是LogN,可以和平衡二叉樹相當,但實現起來又比它簡單。跳躍表(skiplist)是一種有序資料結構,它通過在某個節點中維持多個指向其他節點的指標,從而達到快速訪問節點的目的。