一. 引言
《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字符串修改字符串時會有大量的內存重分配操作,如拼接字符串時,如果不進行內存重分配,可能會造成緩沖區溢出;進行縮短字符串操作時,不進行內存重分配釋放不再使用的那部分空間,則會產生內存泄露。
為了解決上述兩個問題,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),但真正造成這種情況出現的操作並不多見。