一. 引言
redis
《Redis設計與實現》一書主要分爲四個部分,其中第一個部分主要講的是Redis的底層數據結構與對象的相關知識。算法
Redis是一種基於C語言編寫的非關係型數據庫,它的五種基本對象類型分別爲:STRING,LIST,SET,HASH,ZSET。然而,對於每一種基本對象數據類型,底層都至少有2種不一樣的實現方式。數據庫
二. 簡單動態字符串(Simple Dynamic String, SDS)數組
SDS是Redis的默認字符串表示,包含字符串值的鍵值對底層都是由SDS實現的。除了保存數據庫中的字符串值以外,SDS還被用做緩衝區。服務器
示例:數據結構
redis>SET msg "hello world" OK
當執行上述代碼以後,Redis會建立一個STRING類型的鍵值對,其中鍵和值均是一個字符串對象,鍵對象的底層是一個保存着字符串"msg"的SDS,而值對象的底層是一個保存着字符串"hello world"的SDS。函數
每一個SDS都結構以下所示:性能
struct sdshdr{ //記錄buf數組中已使用的字節數量(也是SDS所保存的字符串長度) int len; //buf數組中未使用的字節數量 int free; //字節數組,用於存儲字符串 char buf[]; };
如上圖所示,SDS遵循C字符串以空字符結尾的慣例,可是保存空字符的一字節空間不計算在SDS的len屬性中。即對於SDS的結構知足:buf的長度 = len + free + 1。即當SDS的len=5,free=0字節時,則buf的長度爲 5+0+1=6字節。優化
C字符串自己的兩個問題有:1.獲取字符串長度的複雜度高 2.因爲C字符串不記錄自身長度容易形成緩衝區溢出等問題。C字符串修改字符串時會有大量的內存重分配操做,如拼接字符串時,若是不進行內存重分配,可能會形成緩衝區溢出;進行縮短字符串操做時,不進行內存重分配釋放再也不使用的那部分空間,則會產生內存泄露。ui
爲了解決上述兩個問題,SDS作了一系列的改進操做。
(1)因爲SDS將字符串的長度存儲在len屬性中,因此SDS獲取字符串長度的時間複雜度爲O(1)。
(2)SDS經過設計兩種空間分配策略來減小字符串修改時帶來的內存重分配次數,同時杜絕了緩衝區溢出的可能性。
SDS的兩種空間分配優化策略:
SDS的優化策略是經過未使用空間(即free標記的空間)實現的
(1)空間預分配:用於優化SDS字符串增加操做。當SDS的API對SDS進行修改而且須要進行空間擴展時,程序不只會爲SDS分配修改所必要的空間,還會爲SDS分配額外的未使用空間。其中主要分爲兩點:當len<1MB時,程序分配和len一樣大小的未使用空間,即free=len;當len>=1MB時,free=1MB。
(2)惰性空間釋放:用於優化SDS的字符串縮短操做。當SDS的API須要縮短SDS保存的字符串時,程序不會立刻使用內存重分配來回收縮短後多出來的空間,而是使用 free 屬性將這些字節的數量記錄起來,以供未來使用。(縮短重分配操做,並未未來可能有的增加操做進行了優化)。
三. 鏈表
Redis中鏈表能夠用來實現列表鍵、發佈與訂閱、慢查詢、監視器等功能。
鏈表由兩種結構組成,分別是list結構和listNode結構,它們的表示以下所示:
typedef struct list{ listNode *head;//指向表頭 listNode *tail;//指向表尾 unsigned long len;//節點數 void *(*dup)(void *ptr) void *(*free)(void *ptr) int (*match)(void *ptr, void *key) }list;
typedef struct listNode{ struct listNode *prev;//前置節點 struct listNode *next;//後置節點 void *value;//節點值 }listNode;
根據代碼能夠知道,list結構擁有一個指向鏈表表頭和一個指向鏈表表尾的指針,而listNode中有一個前置指針和後置指針,所以,鏈表得到頭、尾節點的時間複雜度爲O(1),且能夠從任意一端開始遍歷。此外,list中還存有len屬性保存鏈表長度,所以得到鏈表長度的時間複雜度僅爲O(1)。
四. 字典
Redis中字典可用於實現數據庫和哈希鍵等。
字典使用哈希表做爲底層實現,哈希表dictht和哈希表節點dictEntry結構以下所示:
typedef struct dictht{ disctEntry **table;//哈希表數組 unsigned long size;//哈希表大小 unsigned long sizemask;//哈希表大小掩碼,爲size-1,用於計算索引值 unsigned long used;//已有節點數 } dictht;
typedef struct dictEntry{ void *key;//鍵 union{//值 void *val; unint64_t u64; int64_t s64; } v; struct dicEntry *next;//指向下個哈希表節點 } dictEntry;
由結構代碼和圖可知,dictht結構中size屬性爲哈希表的總大小,used爲哈希表節點個數;dictEntry節點中存儲了鍵值對和指向下一個節點的指針。而dictht中sizemask屬性總等於size-1,該屬性值用於哈希算法。
字典結構則以下所示:
typedef struct dict{ dictType *type;//類型特定函數 void *privdata;//私有數據 dictht ht[2];//兩個哈希表 int rehashidx;//用於標記是否處於rehash狀態 } dict;
字典由dict結構表示,其屬性type是指向dictType結構的指針,該結構中保存了一簇用於操做特定類型鍵值對的函數;privdata屬性則保存了須要傳給這些函數的可選參數;rehashIdx則用於標記當前字典是否處於rehash(從新哈希)狀態,rehashidx=-1時未進行rehash。(圖示中略有錯誤,解決衝突時,鏈地址法是將新節點插入頭部,即頭插法,因此應當k2在前,k1在後)
字典的哈希算法:每當一個新鍵值對添加到字典中時,程序須要先根據鍵計算出哈希值和索引值,再根據索引值將包含鍵值對的哈希節點放到哈希表數組的指定位置。哈希值使用字典的type中存儲的哈希函數(hashFunction)計算(當字典被用做數據庫或哈希鍵(HASH-key)的底層實現時,Redis使用MurmurHash2算法),而索引值則根據哈希表的sizemask和哈希值計算,index = 哈希值 & sizemask。例,新增鍵的哈希值爲8,則上圖新增鍵在ht[0]索引值爲 8 & 3 = 0。
處理鍵衝突:Redis的哈希表採用鏈地址法解決鍵衝突的問題,且爲了速度考慮,每次都是將新節點添加到鏈表的表頭位置(複雜度爲O(1))。
哈希表的擴展與收縮:負載因子 load_factor = ht[0].used / ht[0].size
(1)當服務器未執行BGSAVE命令或者BGREWRITEAOF命令時,且哈希表的負載因子大於等於1時,自動擴展。
(2)當服務器正在執行BGSAVE命令或者BGREWRITEAOF命令時,且哈希表的負載因子大於等於5時,自動擴展。
(3)當哈希表的負載因子小於0.1時,程序對哈希表自動收縮。
之因此有(1)、(2)的區別,是由於在執行這些命令的過程當中,Reis須要建立當前服務器進程的子進程,而大多數操做系統都採用寫時複製技術來優化紫禁城的使用效率;所以,子進程存在期間,服務器會提升進行擴展操做所需的負載因子,儘量避免子進程存在期間進行哈希表擴張操做,避免沒必要要的內存寫入,最大限度的節約內存。
漸進式rehash:當程序須要對哈希表的大小進行擴展或者收縮時,須要經過rehash操做來完成。
(1)字典會爲ht[1]的哈希表分配空間(擴展操做,ht[1]大小爲第一個大於等於ht[0].used*2的2n;收縮操做,則ht[1]大小爲第一個大於等於ht[0].used的2n)。
(2)將保存在ht[0]上的鍵值對rehash到ht[1]上(即從新計算鍵的哈希值和索引值)。
(3)當ht[0]上的鍵值對所有遷移完畢後,釋放ht[0],並將ht[1]設置ht[0],再建立一個空白哈希表做爲ht[1],爲下次rehash準備。
值得注意的是,rehash操做並非一次性集中完成的,而是分屢次、漸進式的完成。爲了不rehas對服務器性能形成影響,rehash採起了分而治之的方式,將rehash鍵值對所需的計算工做平攤到對字典的添加、刪除、查找和更新操做上,從而避免集中式rehash帶來了龐大計算量。
五. 跳躍表
跳躍表是有序集合鍵的底層實現之一,它的結構由zskiplist和zskiplistNode組成,其結構和代碼以下圖所示
typedef struct zskiplistNode{ struct zskiplistNode *backward;//後退指針 double score;//分值 robj *obj;//成員對象 struct zskiplistLevel { struct zskiplistNode *forward;//前進指針 unsigned int span;//跨度 } } zskiplistNode;
zskiplist保存跳躍表信息,header指向表頭節點,tail指向表尾節點,level爲跳躍表中的最大層數(表頭節點層數不算在內),length爲跳躍表長度(不包含表頭節點)。
zskiplistNode爲跳躍表節點,level數組中能夠包含多個元素分爲多個層(每一個跳躍表層高都是1~32之間的整數),每一個層都有一個forward前進指針(用於表頭向表尾方向訪問)和一個span跨度(用於記錄兩個節點之間的距離以及記錄排位的,全部指向NULL的前進指針跨度都爲0);backward指針用於從表尾向表頭方向遍歷時使用(每次只能後退一個節點);score分值是一個double類型的浮點數,跳躍表中節點都按分值從小到大排序;obj屬性是一個指向字符串對象的指針,而字符串對象保存着一個SDS值。
同一個跳躍表中,多個節點能夠包含相同的分值,但每一個節點的成員對象必須是惟一的。
跳躍表中的節點按照分值大小順序排列,當分值相同時,按照成員對象的大小排列。
六. 整數集合
整數集合時Redis中用於保存整數值的集合抽象數據結構,其結構代碼和圖示以下所示:
typedef struct intset{ uint32_t encoding;//編碼方式 uint32_t length;//集合包含的元素數量 int8_t contents;//保存元素的數組 } intset;
其中,encoding爲intset的編碼方式,length存儲元素的數量,contents數組是整數集合的底層實現,其內的元素按從小到大的方式保存。contents數組的真正類型取決於encoding的值。
整數集合的升級操做:每當一個類型比整數集合現有全部元素的類型都要長的新元素添加到整數集合中時,整數集合都須要先進行升級操做。
(1)根據新元素的類型,擴展整數集合底層數組的空間大小,並未新元素分配空間。
(2)將原來元素轉換爲新元素相同的類型,並從後往前依次放置原來的元素(放置過程當中需位置底層數組的有序性質不變)
(3)將新元素添加到底層數組中
從上可知,向整數集合添加新元素的時間複雜度爲O(n)。
升級的好處:
(1)經過自動升級來使用不一樣類型元素的數組,提高了整數集合的靈活性
(2)儘量節省內存。(如組織有在將int32_t類型存入時,原來的int16_t類型數組纔會轉換,不須要預先設定好)
七. 壓縮列表
壓縮列表式列表建和哈希鍵的底層實現之一,是Redis爲了節約內存而開發的,是一系列特殊編碼的連續內存塊組成的順序型數據結構。其結構以下所示:
zlbytes表示壓縮列表總長度,zltail表示偏移量(用於記錄氣質地址到表尾節點的距離有多少字節),zllen爲壓縮列表節點個數,entry等都是壓縮列表的節點,zlend用於標記壓縮鏈表末端。而壓縮列表節點中,previous_entry_length表示前一個節點長度(該屬性長度能夠是1字節或者5字節),encoding表示content屬性保存的數據類型與長度,content負責保存節點值。
若是前一個節點長度小於254字節,previous_entry_length長度爲1字節;若是前一個節點長度大於等於254字節,previous_entry_length長度爲5字節,後面4個字節保存前一個節點長度,第一個字節的值被設置爲0x05。
壓縮列表從表尾向表頭的遍歷就是基於 previous_entry_length屬性實現的(先要得到起始地址,再根據zltail得到指向表尾節點的指針,而後previous_entry_length屬性計算出前一個節點的地址,即可依次從後往前遍歷)。
因爲previous_entry_length屬性記錄前一個節點的長度,且該屬性的長度由前一個節點的長度決定,所以在某些特殊狀況下,刪除或者增長節點可能會形成連鎖更新(即特殊狀況下產生的連續屢次空間擴展操做)。例如,原來壓縮列表節點長度都小於254(確切的說是250~253之間),此時將一個長度大於254的節點放到他們以前,便會引發後一個節點previous_entry_length的長度變化,從而使後一個節點長度大於等於254,依次類推,就想多米諾骨牌同樣形成連鎖反應。刪除節點時的特殊狀況則恰好相反。
連鎖更新在最壞狀況下複雜度爲O(N2),但真正形成這種狀況出現的操做並很少見。