上一篇博客咱們介紹了 redis的五大數據類型詳細用法,可是在 Redis 中,這幾種數據類型底層是由什麼數據結構構造的呢?本篇博客咱們就來詳細介紹Redis中五大數據類型的底層實現。html
上篇博客咱們在介紹 key 相關命令的時候,介紹了以下命令:redis
OBJECT ENCODING key
該命令是用來顯示那五大數據類型的底層數據結構。算法
好比對於 string 數據類型:數據庫
咱們能夠看到實現string數據類型的數據結構有 embstr 以及 int。數組
再好比 list 數據類型:緩存
這裏咱們就不作過多的演示了,那麼上次出現的 embstr 以及 int 還有 quicklist 是什麼數據結構呢?下面咱們就來介紹Redis中幾種主要的數據結構。安全
第一篇文章咱們就說過 Redis 是用 C 語言寫的,可是對於Redis的字符串,卻不是 C 語言中的字符串(即以空字符’\0’結尾的字符數組),它是本身構建了一種名爲 簡單動態字符串(simple dynamic string,SDS)的抽象類型,並將 SDS 做爲 Redis的默認字符串表示。服務器
SDS 定義:數據結構
struct sdshdr{ //記錄buf數組中已使用字節的數量 //等於 SDS 保存字符串的長度 int len; //記錄 buf 數組中未使用字節的數量 int free; //字節數組,用於保存字符串 char buf[]; }
用SDS保存字符串 「Redis」具體圖示以下:函數
圖片來源:《Redis設計與實現》
咱們看上面對於 SDS 數據類型的定義:
一、len 保存了SDS保存字符串的長度
二、buf[] 數組用來保存字符串的每一個元素
三、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實現了空間預分配和惰性空間釋放兩種策略:
一、空間預分配:對字符串進行空間擴展的時候,擴展的內存比實際須要的多,這樣能夠減小連續執行字符串增加操做所需的內存重分配次數。
二、惰性空間釋放:對字符串進行縮短操做時,程序不當即使用內存從新分配來回收縮短後多餘的字節,而是使用 free 屬性將這些字節的數量記錄下來,等待後續使用。(固然SDS也提供了相應的API,當咱們有須要時,也能夠手動釋放這些未使用的空間。)
④、二進制安全
由於C字符串以空字符做爲字符串結束的標識,而對於一些二進制文件(如圖片等),內容可能包括空字符串,所以C字符串沒法正確存取;而全部 SDS 的API 都是以處理二進制的方式來處理 buf 裏面的元素,而且 SDS 不是以空字符串來判斷是否結束,而是以 len 屬性表示的長度來判斷字符串是否結束。
⑤、兼容部分 C 字符串函數
雖然 SDS 是二進制安全的,可是同樣聽從每一個字符串都是以空字符串結尾的慣例,這樣能夠重用 C 語言庫<string.h> 中的一部分函數。
⑥、總結
通常來講,SDS 除了保存數據庫中的字符串值之外,SDS 還能夠做爲緩衝區(buffer):包括 AOF 模塊中的AOF緩衝區以及客戶端狀態中的輸入緩衝區。後面在介紹Redis的持久化時會進行介紹。
鏈表是一種經常使用的數據結構,C 語言內部是沒有內置這種數據結構的實現,因此Redis本身構建了鏈表的實現。關於鏈表的詳細介紹能夠參考個人這篇博客。
鏈表定義:
typedef struct listNode{ //前置節點 struct listNode *prev; //後置節點 struct listNode *next; //節點的值 void *value; }listNode
經過多個 listNode 結構就能夠組成鏈表,這是一個雙端鏈表,Redis還提供了操做鏈表的數據結構:
typedef struct list{ //表頭節點 listNode *head; //表尾節點 listNode *tail; //鏈表所包含的節點數量 unsigned long len; //節點值複製函數 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* 指針來保存節點值,能夠保存各類不一樣類型的值。
字典又稱爲符號表或者關聯數組、或映射(map),是一種用於保存鍵值對的抽象數據結構。字典中的每個鍵 key 都是惟一的,經過 key 能夠對值來進行查找或修改。C 語言中沒有內置這種數據結構的實現,因此字典依然是 Redis本身構建的。
Redis 的字典使用哈希表做爲底層實現,關於哈希表的詳細講解能夠參考我這篇博客。
哈希表結構定義:
typedef struct dictht{ //哈希表數組 dictEntry **table; //哈希表大小 unsigned long size; //哈希表大小掩碼,用於計算索引值 //老是等於 size-1 unsigned long sizemask; //該哈希表已有節點的數量 unsigned long used; }dictht
哈希表是由數組 table 組成,table 中每一個元素都是指向 dict.h/dictEntry 結構,dictEntry 結構定義以下:
typedef struct dictEntry{ //鍵 void *key; //值 union{ void *val; uint64_tu64; int64_ts64; }v; //指向下一個哈希表節點,造成鏈表 struct dictEntry *next; }dictEntry
key 用來保存鍵,val 屬性用來保存值,值能夠是一個指針,也能夠是uint64_t整數,也能夠是int64_t整數。
注意這裏還有一個指向下一個哈希表節點的指針,咱們知道哈希表最大的問題是存在哈希衝突,如何解決哈希衝突,有開放地址法和鏈地址法。這裏採用的即是鏈地址法,經過next這個指針能夠將多個哈希值相同的鍵值對鏈接在一塊兒,用來解決哈希衝突。
①、哈希算法:Redis計算哈希值和索引值方法以下:
#一、使用字典設置的哈希函數,計算鍵 key 的哈希值 hash = dict->type->hashFunction(key); #二、使用哈希表的sizemask屬性和第一步獲得的哈希值,計算索引值 index = hash & dict->ht[x].sizemask;
②、解決哈希衝突:這個問題上面咱們介紹了,方法是鏈地址法。經過字典裏面的 *next 指針指向下一個具備相同索引值的哈希表節點。
③、擴容和收縮:當哈希表保存的鍵值對太多或者太少時,就要經過 rerehash(從新散列)來對哈希表進行相應的擴展或者收縮。具體步驟:
一、若是執行擴展操做,會基於原哈希表建立一個大小等於 ht[0].used*2n 的哈希表(也就是每次擴展都是根據原哈希表已使用的空間擴大一倍建立另外一個哈希表)。相反若是執行的是收縮操做,每次收縮是根據已使用空間縮小一倍建立一個新的哈希表。
二、從新利用上面的哈希算法,計算索引值,而後將鍵值對放到新的哈希表位置上。
三、全部鍵值對都遷徙完畢後,釋放原哈希表的內存空間。
④、觸發擴容的條件:
一、服務器目前沒有執行 BGSAVE 命令或者 BGREWRITEAOF 命令,而且負載因子大於等於1。
二、服務器目前正在執行 BGSAVE 命令或者 BGREWRITEAOF 命令,而且負載因子大於等於5。
ps:負載因子 = 哈希表已保存節點數量 / 哈希表大小。
⑤、漸近式 rehash
什麼叫漸進式 rehash?也就是說擴容和收縮操做不是一次性、集中式完成的,而是分屢次、漸進式完成的。若是保存在Redis中的鍵值對只有幾個幾十個,那麼 rehash 操做能夠瞬間完成,可是若是鍵值對有幾百萬,幾千萬甚至幾億,那麼要一次性的進行 rehash,勢必會形成Redis一段時間內不能進行別的操做。因此Redis採用漸進式 rehash,這樣在進行漸進式rehash期間,字典的刪除查找更新等操做可能會在兩個哈希表上進行,第一個哈希表沒有找到,就會去第二個哈希表上進行查找。可是進行 增長操做,必定是在新的哈希表上進行的。
關於跳躍表的趣味介紹:http://blog.jobbole.com/111731/
跳躍表(skiplist)是一種有序數據結構,它經過在每一個節點中維持多個指向其它節點的指針,從而達到快速訪問節點的目的。具備以下性質:
一、由不少層結構組成;
二、每一層都是一個有序的鏈表,排列順序爲由高層到底層,都至少包含兩個鏈表節點,分別是前面的head節點和後面的nil節點;
三、最底層的鏈表包含了全部的元素;
四、若是一個元素出如今某一層的鏈表中,那麼在該層之下的鏈表也全都會出現(上一層的元素是當前層的元素的子集);
五、鏈表中的每一個節點都包含兩個指針,一個指向同一層的下一個鏈表節點,另外一個指向下一層的同一個鏈表節點;
Redis中跳躍表節點定義以下:
typedef struct zskiplistNode { //層 struct zskiplistLevel{ //前進指針 struct zskiplistNode *forward; //跨度 unsigned int span; }level[]; //後退指針 struct zskiplistNode *backward; //分值 double score; //成員對象 robj *obj; } zskiplistNode
多個跳躍表節點構成一個跳躍表:
typedef struct zskiplist{ //表頭節點和表尾節點 structz skiplistNode *header, *tail; //表中節點的數量 unsigned long length; //表中層數最大的節點的層數 int level; }zskiplist;
①、搜索:從最高層的鏈表節點開始,若是比當前節點要大和比當前層的下一個節點要小,那麼則往下找,也就是和當前層的下一層的節點的下一個節點進行比較,以此類推,一直找到最底層的最後一個節點,若是找到則返回,反之則返回空。
②、插入:首先肯定插入的層數,有一種方法是假設拋一枚硬幣,若是是正面就累加,直到碰見反面爲止,最後記錄正面的次數做爲插入的層數。當肯定插入的層數k後,則須要將新元素插入到從底層到k層。
③、刪除:在各個層中找到包含指定值的節點,而後將節點從鏈表中刪除便可,若是刪除之後只剩下頭尾兩個節點,則刪除這一層。
整數集合(intset)是Redis用於保存整數值的集合抽象數據類型,它能夠保存類型爲int16_t、int32_t 或者int64_t 的整數值,而且保證集合中不會出現重複元素。
定義以下:
typedef struct intset{ //編碼方式 uint32_t encoding; //集合包含的元素數量 uint32_t length; //保存元素的數組 int8_t contents[]; }intset;
整數集合的每一個元素都是 contents 數組的一個數據項,它們按照從小到大的順序排列,而且不包含任何重複項。
length 屬性記錄了 contents 數組的大小。
須要注意的是雖然 contents 數組聲明爲 int8_t 類型,可是實際上contents 數組並不保存任何 int8_t 類型的值,其真正類型有 encoding 來決定。
①、升級
當咱們新增的元素類型比原集合元素類型的長度要大時,須要對整數集合進行升級,才能將新元素放入整數集合中。具體步驟:
一、根據新元素類型,擴展整數集合底層數組的大小,併爲新元素分配空間。
二、將底層數組現有的全部元素都轉成與新元素相同類型的元素,並將轉換後的元素放到正確的位置,放置過程當中,維持整個元素順序都是有序的。
三、將新元素添加到整數集合中(保證有序)。
升級能極大地節省內存。
②、降級
整數集合不支持降級操做,一旦對數組進行了升級,編碼就會一直保持升級後的狀態。
壓縮列表(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決定。
大多數狀況下,Redis使用簡單字符串SDS做爲字符串的表示,相對於C語言字符串,SDS具備常數複雜度獲取字符串長度,杜絕了緩存區的溢出,減小了修改字符串長度時所需的內存重分配次數,以及二進制安全能存儲各類類型的文件,而且還兼容部分C函數。
經過爲鏈表設置不一樣類型的特定函數,Redis鏈表能夠保存各類不一樣類型的值,除了用做列表鍵,還在發佈與訂閱、慢查詢、監視器等方面發揮做用(後面會介紹)。
Redis的字典底層使用哈希表實現,每一個字典一般有兩個哈希表,一個平時使用,另外一個用於rehash時使用,使用鏈地址法解決哈希衝突。
跳躍表一般是有序集合的底層實現之一,表中的節點按照分值大小進行排序。
整數集合是集合鍵的底層實現之一,底層由數組構成,升級特性能儘量的節省內存。
壓縮列表是Redis爲節省內存而開發的順序型數據結構,一般做爲列表鍵和哈希鍵的底層實現之一。
以上介紹的簡單字符串、鏈表、字典、跳躍表、整數集合、壓縮列表等數據結構就是Redis底層的一些數據結構,用來實現上一篇博客介紹的Redis五大數據類型,那麼每種數據類型是由哪些數據結構實現的呢?下一篇博客進行介紹。
參考文檔:《Redis設計與實現》