1. 程式人生 > 實用技巧 >Redis詳解(四)- redis的底層資料結構

Redis詳解(四)- redis的底層資料結構

目錄


  上一篇部落格我們介紹了redis的五大資料型別詳細用法,但是在 Redis 中,這幾種資料型別底層是由什麼資料結構構造的呢?本篇部落格我們就來詳細介紹Redis中五大資料型別的底層實現。

回到頂部

1、演示資料型別的實現

  上篇部落格我們在介紹 key 相關命令的時候,介紹瞭如下命令:

OBJECT ENCODING    key 

  該命令是用來顯示那五大資料型別的底層資料結構。

  比如對於 string 資料型別:

  

  我們可以看到實現string資料型別的資料結構有 embstr 以及 int。

  再比如 list 資料型別:

  

  這裡我們就不做過多的演示了,那麼上次出現的 embstr 以及 int 還有 quicklist 是什麼資料結構呢?下面我們就來介紹Redis中幾種主要的資料結構。

回到頂部

2、簡單動態字串

  第一篇文章我們就說過 Redis 是用 C 語言寫的,但是對於Redis的字串,卻不是 C 語言中的字串(即以空字元’\0’結尾的字元陣列),它是自己構建了一種名為 簡單動態字串(simple dynamic string,SDS)的抽象型別,並將 SDS 作為 Redis的預設字串表示。

  SDS 定義:

1 2 3 4 5 6 7 8 9 structsdshdr{ //記錄buf陣列中已使用位元組的數量 //等於 SDS 儲存字串的長度 intlen; //記錄 buf 陣列中未使用位元組的數量 intfree; //位元組陣列,用於儲存字串 charbuf[]; }

  用SDS儲存字串 “Redis”具體圖示如下:

  

         圖片來源:《Redis設計與實現》

  我們看上面對於 SDS 資料型別的定義:

  1、len 儲存了SDS儲存字串的長度

  2、buf[] 陣列用來儲存字串的每個元素

  3、free j記錄了 buf 陣列中未使用的位元組數量

  上面的定義相對於 C 語言對於字串的定義,多出了 len 屬性以及 free 屬性。為什麼不使用C語言字串實現,而是使用 SDS呢?這樣實現有什麼好處?

  ①、常數複雜度獲取字串長度

  由於 len 屬性的存在,我們獲取 SDS 字串的長度只需要讀取 len 屬性,時間複雜度為 O(1)。而對於 C 語言,獲取字串的長度通常是經過遍歷計數來實現的,時間複雜度為 O(n)。通過 strlen key 命令可以獲取 key 的字串長度。

  ②、杜絕緩衝區溢位

  我們知道在 C 語言中使用 strcat 函式來進行兩個字串的拼接,一旦沒有分配足夠長度的記憶體空間,就會造成緩衝區溢位。而對於 SDS 資料型別,在進行字元修改的時候,會首先根據記錄的 len 屬性檢查記憶體空間是否滿足需求,如果不滿足,會進行相應的空間擴充套件,然後在進行修改操作,所以不會出現緩衝區溢位。

  ③、減少修改字串的記憶體重新分配次數

  C語言由於不記錄字串的長度,所以如果要修改字串,必須要重新分配記憶體(先釋放再申請),因為如果沒有重新分配,字串長度增大時會造成記憶體緩衝區溢位,字串長度減小時會造成記憶體洩露。

  而對於SDS,由於len屬性和free屬性的存在,對於修改字串SDS實現了空間預分配和惰性空間釋放兩種策略:

  1、空間預分配:對字串進行空間擴充套件的時候,擴充套件的記憶體比實際需要的多,這樣可以減少連續執行字串增長操作所需的記憶體重分配次數。

  2、惰性空間釋放:對字串進行縮短操作時,程式不立即使用記憶體重新分配來回收縮短後多餘的位元組,而是使用 free 屬性將這些位元組的數量記錄下來,等待後續使用。(當然SDS也提供了相應的API,當我們有需要時,也可以手動釋放這些未使用的空間。)

  ④、二進位制安全

  因為C字串以空字元作為字串結束的標識,而對於一些二進位制檔案(如圖片等),內容可能包括空字串,因此C字串無法正確存取;而所有 SDS 的API 都是以處理二進位制的方式來處理 buf 裡面的元素,並且 SDS 不是以空字串來判斷是否結束,而是以 len 屬性表示的長度來判斷字串是否結束。

  ⑤、相容部分 C 字串函式

  雖然 SDS 是二進位制安全的,但是一樣遵從每個字串都是以空字串結尾的慣例,這樣可以重用 C 語言庫<string.h> 中的一部分函式。

  ⑥、總結

  

  一般來說,SDS 除了儲存資料庫中的字串值以外,SDS 還可以作為緩衝區(buffer):包括 AOF 模組中的AOF緩衝區以及客戶端狀態中的輸入緩衝區。後面在介紹Redis的持久化時會進行介紹。

回到頂部

3、連結串列

  連結串列是一種常用的資料結構,C 語言內部是沒有內建這種資料結構的實現,所以Redis自己構建了連結串列的實現。

  連結串列定義:

1 2 3 4 5 6 7 8 typedefstructlistNode{ //前置節點 structlistNode *prev; //後置節點 structlistNode *next; //節點的值 void*value; }listNode

  通過多個 listNode 結構就可以組成連結串列,這是一個雙向連結串列,Redis還提供了操作連結串列的資料結構:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 typedefstructlist{ //表頭節點 listNode *head; //表尾節點 listNode *tail; //連結串列所包含的節點數量 unsignedlonglen; //節點值複製函式 void(*free) (void*ptr); //節點值釋放函式 void(*free) (void*ptr); //節點值對比函式 int(*match) (void*ptr,void*key); }list;

  

  Redis連結串列特性:

  ①、雙端:連結串列具有前置節點和後置節點的引用,獲取這兩個節點時間複雜度都為O(1)。

  ②、無環:表頭節點的 prev 指標和表尾節點的 next 指標都指向 NULL,對連結串列的訪問都是以 NULL 結束。  

  ③、帶連結串列長度計數器:通過 len 屬性獲取連結串列長度的時間複雜度為 O(1)。

  ④、多型:連結串列節點使用 void* 指標來儲存節點值,可以儲存各種不同型別的值。

回到頂部

4、字典

  字典又稱為符號表或者關聯陣列、或對映(map),是一種用於儲存鍵值對的抽象資料結構。字典中的每一個鍵 key 都是唯一的,通過 key 可以對值來進行查詢或修改。C 語言中沒有內建這種資料結構的實現,所以字典依然是 Redis自己構建的。

  Redis 的字典使用雜湊表作為底層實現

  雜湊表結構定義:

1 2 3 4 5 6 7 8 9 10 11 12 typedefstructdictht{ //雜湊表陣列 dictEntry **table; //雜湊表大小 unsignedlongsize; //雜湊表大小掩碼,用於計算索引值 //總是等於 size-1 unsignedlongsizemask; //該雜湊表已有節點的數量 unsignedlongused; }dictht

  雜湊表是由陣列 table 組成,table 中每個元素都是指向 dict.h/dictEntry 結構,dictEntry 結構定義如下:

1 2 3 4 5 6 7 8 9 10 11 12 13 typedefstructdictEntry{ //鍵 void*key; //值 union{ void*val; uint64_tu64; int64_ts64; }v; //指向下一個雜湊表節點,形成連結串列 structdictEntry *next; }dictEntry

  key 用來儲存鍵,val 屬性用來儲存值,值可以是一個指標,也可以是uint64_t整數,也可以是int64_t整數。

  注意這裡還有一個指向下一個雜湊表節點的指標,我們知道雜湊表最大的問題是存在雜湊衝突,如何解決雜湊衝突,有開放地址法和鏈地址法。這裡採用的便是鏈地址法,通過next這個指標可以將多個雜湊值相同的鍵值對連線在一起,用來解決雜湊衝突

  

  ①、雜湊演算法:Redis計算雜湊值和索引值方法如下:

1 2 3 4 #1、使用字典設定的雜湊函式,計算鍵 key 的雜湊值 hash = dict->type->hashFunction(key); #2、使用雜湊表的sizemask屬性和第一步得到的雜湊值,計算索引值 index = hash & dict->ht[x].sizemask;

  ②、解決雜湊衝突:這個問題上面我們介紹了,方法是鏈地址法。通過字典裡面的 *next 指標指向下一個具有相同索引值的雜湊表節點。

  ③、擴容和收縮:當雜湊表儲存的鍵值對太多或者太少時,就要通過 rerehash(重新雜湊)來對雜湊表進行相應的擴充套件或者收縮。具體步驟:

      1、如果執行擴充套件操作,會基於原雜湊表建立一個大小等於 ht[0].used*2n 的雜湊表(也就是每次擴充套件都是根據原雜湊表已使用的空間擴大一倍建立另一個雜湊表)。相反如果執行的是收縮操作,每次收縮是根據已使用空間縮小一倍建立一個新的雜湊表。

      2、重新利用上面的雜湊演算法,計算索引值,然後將鍵值對放到新的雜湊表位置上。

      3、所有鍵值對都遷徙完畢後,釋放原雜湊表的記憶體空間。

  ④、觸發擴容的條件:

      1、伺服器目前沒有執行 BGSAVE 命令或者 BGREWRITEAOF 命令,並且負載因子大於等於1。

      2、伺服器目前正在執行 BGSAVE 命令或者 BGREWRITEAOF 命令,並且負載因子大於等於5。

    ps:負載因子 = 雜湊表已儲存節點數量 / 雜湊表大小。

  ⑤、漸近式 rehash

    什麼叫漸進式 rehash?也就是說擴容和收縮操作不是一次性、集中式完成的,而是分多次、漸進式完成的。如果儲存在Redis中的鍵值對只有幾個幾十個,那麼 rehash 操作可以瞬間完成,但是如果鍵值對有幾百萬,幾千萬甚至幾億,那麼要一次性的進行 rehash,勢必會造成Redis一段時間內不能進行別的操作。所以Redis採用漸進式 rehash,這樣在進行漸進式rehash期間,字典的刪除查詢更新等操作可能會在兩個雜湊表上進行,第一個雜湊表沒有找到,就會去第二個雜湊表上進行查詢。但是進行 增加操作,一定是在新的雜湊表上進行的。

回到頂部

5、跳躍表

  跳躍表(skiplist)是一種有序資料結構,它通過在每個節點中維持多個指向其它節點的指標,從而達到快速訪問節點的目的。具有如下性質:

  1、由很多層結構組成;

  2、每一層都是一個有序的連結串列,排列順序為由高層到底層,都至少包含兩個連結串列節點,分別是前面的head節點和後面的nil節點;

  3、最底層的連結串列包含了所有的元素;

  4、如果一個元素出現在某一層的連結串列中,那麼在該層之下的連結串列也全都會出現(上一層的元素是當前層的元素的子集);

  5、連結串列中的每個節點都包含兩個指標,一個指向同一層的下一個連結串列節點,另一個指向下一層的同一個連結串列節點;

  

  Redis中跳躍表節點定義如下:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 typedefstructzskiplistNode { //層 structzskiplistLevel{ //前進指標 structzskiplistNode *forward; //跨度 unsignedintspan; }level[]; //後退指標 structzskiplistNode *backward; //分值 doublescore; //成員物件 robj *obj; } zskiplistNode

  多個跳躍表節點構成一個跳躍表:

1 2 3 4 5 6 7 8 9 typedefstructzskiplist{ //表頭節點和表尾節點 structz skiplistNode *header, *tail; //表中節點的數量 unsignedlonglength; //表中層數最大的節點的層數 intlevel; }zskiplist;

  

  ①、搜尋:從最高層的連結串列節點開始,如果比當前節點要大和比當前層的下一個節點要小,那麼則往下找,也就是和當前層的下一層的節點的下一個節點進行比較,以此類推,一直找到最底層的最後一個節點,如果找到則返回,反之則返回空。

  ②、插入:首先確定插入的層數,有一種方法是假設拋一枚硬幣,如果是正面就累加,直到遇見反面為止,最後記錄正面的次數作為插入的層數。當確定插入的層數k後,則需要將新元素插入到從底層到k層。

  ③、刪除:在各個層中找到包含指定值的節點,然後將節點從連結串列中刪除即可,如果刪除以後只剩下頭尾兩個節點,則刪除這一層。

回到頂部

6、整數集合

  整數集合(intset)是Redis用於儲存整數值的集合抽象資料型別,它可以儲存型別為int16_t、int32_t 或者int64_t 的整數值,並且保證集合中不會出現重複元素。

  定義如下:

1 2 3 4 5 6 7 8 9 typedefstructintset{ //編碼方式 uint32_t encoding; //集合包含的元素數量 uint32_t length; //儲存元素的陣列 int8_t contents[]; }intset;

  整數集合的每個元素都是 contents 陣列的一個數據項,它們按照從小到大的順序排列,並且不包含任何重複項。

  length 屬性記錄了 contents 陣列的大小。

  需要注意的是雖然 contents 陣列宣告為 int8_t 型別,但是實際上contents 陣列並不儲存任何 int8_t 型別的值,其真正型別有 encoding 來決定。

  ①、升級

  當我們新增的元素型別比原集合元素型別的長度要大時,需要對整數集合進行升級,才能將新元素放入整數集合中。具體步驟:

  1、根據新元素型別,擴充套件整數集合底層陣列的大小,併為新元素分配空間。

  2、將底層陣列現有的所有元素都轉成與新元素相同型別的元素,並將轉換後的元素放到正確的位置,放置過程中,維持整個元素順序都是有序的。

  3、將新元素新增到整數集合中(保證有序)。

  升級能極大地節省記憶體。

  ②、降級

  整數集合不支援降級操作,一旦對陣列進行了升級,編碼就會一直保持升級後的狀態。

回到頂部

7、壓縮列表

  壓縮列表(ziplist)是Redis為了節省記憶體而開發的,是由一系列特殊編碼的連續記憶體塊組成的順序型資料結構,一個壓縮列表可以包含任意多個節點(entry),每個節點可以儲存一個位元組陣列或者一個整數值。

  壓縮列表的原理:壓縮列表並不是對資料利用某種演算法進行壓縮,而是將資料按照一定規則編碼在一塊連續的記憶體區域,目的是節省記憶體。

  

  壓縮列表的每個節點構成如下:

  

  ①、previous_entry_ength:記錄壓縮列表前一個位元組的長度。previous_entry_ength的長度可能是1個位元組或者是5個位元組,如果上一個節點的長度小於254,則該節點只需要一個位元組就可以表示前一個節點的長度了,如果前一個節點的長度大於等於254,則previous length的第一個位元組為254,後面用四個位元組表示當前節點前一個節點的長度。利用此原理即當前節點位置減去上一個節點的長度即得到上一個節點的起始位置,壓縮列表可以從尾部向頭部遍歷。這麼做很有效地減少了記憶體的浪費。

  ②、encoding:節點的encoding儲存的是節點的content的內容型別以及長度,encoding型別一共有兩種,一種位元組陣列一種是整數,encoding區域長度為1位元組、2位元組或者5位元組長。

  ③、content:content區域用於儲存節點的內容,節點內容型別和長度由encoding決定。

回到頂部

8、總結

  大多數情況下,Redis使用簡單字串SDS作為字串的表示,相對於C語言字串,SDS具有常數複雜度獲取字串長度,杜絕了快取區的溢位,減少了修改字串長度時所需的記憶體重分配次數,以及二進位制安全能儲存各種型別的檔案,並且還相容部分C函式。

  通過為連結串列設定不同型別的特定函式,Redis連結串列可以儲存各種不同型別的值,除了用作列表鍵,還在釋出與訂閱、慢查詢、監視器等方面發揮作用(後面會介紹)。

  Redis的字典底層使用雜湊表實現,每個字典通常有兩個雜湊表,一個平時使用,另一個用於rehash時使用,使用鏈地址法解決雜湊衝突。

  跳躍表通常是有序集合的底層實現之一,表中的節點按照分值大小進行排序。

  整數集合是集合鍵的底層實現之一,底層由陣列構成,升級特效能儘可能的節省記憶體。

  壓縮列表是Redis為節省記憶體而開發的順序型資料結構,通常作為列表鍵和雜湊鍵的底層實現之一。

  以上介紹的簡單字串、連結串列、字典、跳躍表、整數集合、壓縮列表等資料結構就是Redis底層的一些資料結構,用來實現上一篇部落格介紹的Redis五大資料型別,那麼每種資料型別是由哪些資料結構實現的呢?下一篇部落格進行介紹。

參考文件:《Redis設計與實現》