1. 程式人生 > 其它 >第一章 資料結構

第一章 資料結構

  1. 簡單動態字串

  下圖是簡單動態字串(simple dynamic string, SDS)的結構表示

  

  • free屬性的值為0,表示這個SDS沒有分配任何未使用空間
  • len屬性的值為5,表示這個SDS儲存了一個5位元組長的字串
  • buf屬性是一個char型別的陣列,陣列的前五個位元組分別儲存了R, e, d, i, s五個字元,最後一個位元組則儲存了空字元'\o'

  SDS遵循C語言字串以空字元結尾的慣例,儲存空字元的1位元組空間不計算在SDS的len屬性裡面,這個空字元對SDS的使用者來說是完全透明的。遵循空字元結尾的好處是,可以重用一些C字串函式庫中的函式。例如,有一個指向圖2-1所示SDS的指標s,那麼可以直接使用<stdio.h>/printf函式,通過執行以下語句:printf("%s", s->buf);

  1.1 SDS與C字串的區別

  1.1.1 常數複雜度獲得字串長度

  因為C字串並不記錄自身長度資訊,所以為了獲取一個C字串長度,程式必須遍歷整個字串,對遇到的每個字元進行計數,直到遇到代表字串結尾的空字元為止,這個操作的複雜度為O(N),如下圖所示。

  

  通過訪問SDS的len屬性,Redis將獲取字串長度所需的複雜度從O(N)降低到了O(1)。

  1.1.2 杜絕緩衝區溢位

  <string.h>/strcat函式可以將src字串中的內容拼接到dest字串的末尾: char *strcat (char *dest, const char *src)

  strcat函式假定使用者在執行這個函式時,已經為dest分配了足夠多的記憶體,如果假設不成立,則會造成緩衝區溢位,導致s2儲存的內容被意外修改了。

  

  當SDS API需要對SDS進行修改時,API先檢查SDS的空間是否滿足修改所需的要求,如果不滿足,API會自動將SDS的空間擴充套件至執行修改所需的大小,然後再進行修改。使用者不需要手動修改SDS空間的大小。

  1.1.3 減少修改字串時帶來的記憶體重分配次數

  對於一個包含N個字元的C字串,底層實現是一個N+1個字元長的陣列。每次縮短或者增加C字串,都需要對陣列做一次記憶體重分配:

  • 如果程式執行的是增長字串的操作,比如拼接(append),那麼執行這個操作之前,程式需要先通過記憶體重分配來擴充套件底層陣列的空間大小——不然會造成緩衝區溢位
  • 對於縮短字串操作,比如截斷(trim)操作,需要釋放不再需要的記憶體空間,不然會造成記憶體洩漏

  由於記憶體重分配涉及複雜的演算法,比較耗時。SDS通過空間預分配和惰性空間釋放兩種優化策略減少記憶體重分配次數

  1. 空間預分配

  空間預分配用於優化SDS的字串增長操作:當SDS的API對一個SDS進行修改,並需要對SDS進行空間擴充套件,程式不僅會分配必須的空間,還會為SDS分配額外的未使用空間。如果SDS長度小於1MB,程式分配和len屬性同樣大小的未使用空間;如果長度大於1MB,程式分配1MB的未使用空間。

  

  

  2. 惰性空間釋放

  當SDS的API需要縮短SDS儲存的字串時,程式並不立即使用記憶體重分配來回收縮短後多出來的位元組,而是使用free屬性將這些位元組的數量記錄起來,以備將來使用。

  1.1.4 二進位制安全

  C字串中的字元必須符合某種編碼(比如ASCII),並且除了字串的末尾之外,字串裡面不能包含空字元,否則最先被程式讀入的空字元將被誤認為是字串結尾。這些限制使得C字串只能儲存文字資料,而不能儲存圖片、音訊、視訊、壓縮檔案這樣的二進位制資料。

  SDS API都會以處理二進位制的方式來處理SDS存放在buf數組裡的資料,程式不會對其中的資料做任何限制、過濾或者假設,資料寫入是什麼樣子,讀取時就是什麼樣子。

  1.1.5 相容部分C字串函式

  通過在buf陣列分配空間時多分配一個位元組來容納空字元,使得SDS可以重用一部分<string.h>庫定義的函式。表2-2列出了SDS主要操作API。

  

  2. 連結串列

  每個連結串列節點使用一個adlist.h/listNode結構來表示:

  

  多個listNode可以通過prev和next指標組成雙端連結串列:

  

  list結構表示:

  

  Redis連結串列特性:

  • 雙端:連結串列節點帶有prev和next指標,獲取某個節點的前置節點和後置節點的複雜度都是O(1)
  • 無環:表頭節點的prev指標和表尾節點的next指標都指向NULL,對連結串列的訪問以NULL為終點
  • 帶表頭指標和表尾指標:通過list結構的head指標和tail指標,程式獲取連結串列的表頭節點和表尾節點的複雜度為O(1)
  • 帶連結串列長度計數器:程式獲取連結串列中節點數量的複雜度為O(1)
  • 多型:節點使用void*指標儲存節點值,所以連結串列可以儲存各種不同型別的值

  

  3. 字典

  3.1 字典結構

  Redis字典使用雜湊表作為底層實現,一個雜湊表裡面可以有多個雜湊表節點,而每個雜湊表節點就儲存了字典中的一個鍵值對。

  雜湊表結構及雜湊表節點定義:

  資料存放如下所示:

   

  Redis中字典由dict.h/dict結構表示:

  

  type屬性和privdata屬性是針對不同型別的鍵值對,為建立多型字典而設定的:

  • type屬性是一個指向dictType結構的指標,每個dictType結構儲存了一簇用於操作特定型別鍵值對的函式,Redis會為用途不同的字典設定不同的型別特定函式
  • privdata屬性則儲存了需要傳給那些型別特定函式的可選引數

  

  ht[1]雜湊表會在對ht[0]雜湊表進行rehash時使用。

  

  3.2 雜湊演算法

  Redis計算雜湊值和索引值的方法如下:

  hash = dict->type->hashFunction(key);

  index = hash & dict->ht[x].sizemask;

  index表示放在dictEntry中的哪個槽內

  3.3 解決鍵衝突

  當兩個或以上的鍵被分配到了雜湊表陣列的同一個索引上面時,稱為發生了雜湊碰撞。Redis的雜湊表通過使用鏈地址法來解決鍵衝突,被分配到同一個索引上的多個節點可以用這個單向連結串列連線起來。

  因為dictEntry節點組成的連結串列沒有指向連結串列表尾的指標,所以為了速度考慮,程式總是將節點新增到連結串列的表頭位置(複雜度為O(1)),排在其他所有節點前面。

  

  3.4 rehash

  雜湊表的擴充套件和收縮

  滿足下面任一條件,雜湊表會進行擴充套件操作:

  • 伺服器沒有執行BGSAVE或BGREWRITEAOF命令,並且雜湊表的負載因子大於等於1
  • 伺服器正在執行BGSAVE或BGREWRITEAOF命令,並且雜湊表的負載因子大於等於5

  負載因子的計算:雜湊表已儲存節點數量/雜湊表大小

  當負載因子小於0.1時,程式自動對雜湊表進行收縮操作。

  Redis對字典的雜湊表執行rehash的步驟如下:

  1) 為字典ht[1]雜湊表分配空間:

  • 如果執行的是擴充套件操作,那麼ht[1]的大小為第一個大於等於ht[0].used*2n
  • 如果執行的是收縮操作,那麼ht[1]的大小也為第一個大於等於ht[0].used*2n

  2) 將儲存在ht[0]中的所有鍵值對rehash到ht[1]上面

  3) 當ht[0]包含的所有鍵值對都遷移到ht[1]後,釋放ht[0],將ht[1]設定為ht[0],並在ht[1]新建立一個空白雜湊表,為下一次rehash做準備

  示例步驟如下所示:

  3.5 漸進式rehash

  擴充套件或收縮雜湊表需要將ht[0]裡面的所有鍵值對rehash到ht[1]裡面,但是,這個rehash動作並不是一次性、集中式地完成的,而是多次、漸進式地完成的。具體步驟如下:

  1)為ht[1]分配空間,讓字典同時持有ht[0]和ht[1]兩個雜湊表

  2)在字典中維持一個索引計數器變數rehashidx,並將它的值設為0,表示rehash工作正式開始

  3) 在rehash期間,每次對字典執行新增、刪除、查詢或者更新操作時,程式除了執行指定的操作以外,還會順帶將ht[0]雜湊表在rehashidx索引上的所有鍵值對rehash到ht[1],當rehash完成後,將rehashidx屬性值加一

  4) 隨著字典操作不斷執行,最後在某個時間點上,ht[0]的所有鍵值對都會被rehash到ht[1],這時將rehashidx設定為-1,表示rehash操作結束。

  

 

  字典主要API操作

  

  4. 跳躍表

  如果一個有序集合包含的元素數量比較多,或者有序集合中元素的成員是比較長的字串時,Redis就會使用跳躍表來作為有序集合鍵的底層實現。例如,fruit-price是一個有序集合鍵,以水果名為成員,水果價錢為分值。fruit-price有序集合的所有資料都儲存在一個跳躍表裡面,其中每個跳躍表節點會儲存一款水果的資訊,所有水果按價錢由低到高排序。

  4.1 跳躍表的實現

  Redis跳躍表由redis.h/zskiplistNode(用於表示跳躍表節點)和redis.h/zskiplist(儲存跳躍表節點相關資訊)兩個結構定義,示例如下:

  4.2 跳躍表節點

  跳躍表節點的實現由redis.h/zskiplistNode結構定義:

  

  1. 層

  每次建立一個新的跳躍表節點時,程式都根據冪次定律(power low,越大的數出現的概率越小)隨機生成一個介於1和32之間的值作為level陣列的大小,即層的高度,如圖5-2所示。

  跳躍表節點中每個元素包含一個指向其他節點的指標,程式可以通過這些層加快訪問其他節點的速度,一般來說,層的數量越多,訪問其他節點的速度就越快。

  2. 前進指標

  每個層都有一個指向表尾方向的前進指標(level[i].forward屬性),用於從表頭向表尾方向訪問節點。圖5-3用虛線表示了程式從表頭向表尾方向,遍歷跳躍表中所有節點的路徑。

  3. 跨度

  層的跨度(level[i].span)用於記錄兩個節點之間的距離:

  • 兩個節點之間的跨度越大,相距的越遠
  • 指向NULL的所有前進指標的跨度都為0

  遍歷操作使用前進指標即可完成,跨度是用來計算排位的(節點在跳躍表中的位置)。

  4. 後退指標

  節點的後退指標(backward屬性)用於從表尾向表頭訪問節點。

  5. 分值和成員

  節點的分值(score屬性)是一個double型別的浮點數,跳躍表中的所有節點都按分值從小到大來排序。

  節點的成員物件(obj屬性,唯一的)是一個指標,它指向一個字串物件,而字串物件則儲存一個SDS值。

  4.3 跳躍表

  zskiplist結構的定義如下:

  

  header和tail指標分別指向跳躍表的表頭和表尾節點,通過這兩個指標,程式定位表頭節點和表尾節點的複雜度為O(1).

  length屬性記錄節點的數量,程式可以在O(1)複雜度內返回跳躍表的長度。

  level屬性用於獲得跳躍表中層高最大的節點的層數量,表頭節點的層高不計算在內。  

  4.4 跳躍表常用API

  

  5. 整數集合

  5.1 整數集合的實現

  其中encoding有三種取值:

  • INTSET_ENC_INT16 每個項是int16_t型別的整數值(取值範圍:-215~215-1)
  • INTSET_ENC_INT32 每個項是int32_t型別的整數值(取值範圍:-231~231-1) 
  • INTSET_ENC_INT64 每個項是int64_t型別的整數值(取值範圍:-263~263-1)

  

  5.2 升級

  如果新新增的元素型別比整數集合現有所有元素的型別都要長時,整數集合需要先進行升級(upgrade),然後再將新元素新增到整數集合裡面。升級分三步進行:

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

  2) 將底層陣列現有的所有元素都轉換成與新元素相同的型別,並將型別轉換後的元素放置到正確的位置上,而且在放置元素過程中,需要維持底層陣列的有序性質不變

  3) 將元素新增到底層數組裡面

  contents陣列中原先包含3個,每個佔用16位空間的元素。之後需要插入一個佔用32位空間的元素65535,其過程如下所示:

  

  5.3 升級的好處

  5.3.1 提升靈活性

  整數集合可以通過自動升級底層陣列來適應新元素,可以隨意地將int16_t,int32_t或int64_t型別的整數新增到集合中,不必擔心出現型別錯誤。

  5.3.2 節約記憶體

  儘量先用int16_t型別來儲存元素,在必要時進行升級,以節約記憶體。

  5.4 降級

  整數集合不支援降級。

  5.5 整數集合API

   

  6. 壓縮列表

  6.1 壓縮列表的構成

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

  下圖為壓縮列表各組成部分:

  

  

  圖7-2展示了一個壓縮列表例項:

  • zlbytes屬性地值位0x50(十進位制80),表示壓縮列表總長80位元組
  • zltail屬性值為0x3c(十進位制60),這表示如果我們有一個指向壓縮列表起始地址地指標p,那麼p+60,就可以計算出表尾節點entry3的地址
  • zllen屬性值為0x3,表示壓縮列表包含三個節點

  

  6.2 壓縮列表節點的構成

  下圖為壓縮列表各個組成部分:

  

  1. previous_entry_length

  節點的previous_entry_length屬性以位元組為單位,記錄了壓縮列表中前一個節點的長度:

  • 如果前一個節點長度小於254位元組,previous_entry_length屬性的長度為1位元組
  • 如果前一個位元組長度大於等於254位元組,previous_entry_length的長度為5位元組,第一個位元組會被設定為0xFE(十進位制254),之後的四個位元組用於儲存前一節點的長度

  

  如果有一個指向當前節點起始地址的指標c,可以根據下圖計算出前一個節點的指標p。壓縮列表從表尾向表頭遍歷操作就是基於這一原理實現的。

  

  2. encoding

  節點encoding屬性記錄了節點的content屬性所儲存資料的型別及長度。

  值的最高位為00、01、10是位元組陣列編碼,陣列的長度由編碼除去最高兩位之後的其他位記錄

  

  值的最高位以11開頭的是整數編碼

  

  3. content  

  

  6.3 連鎖更新

  下面討論一種對列表插入或刪除節點時,會發生的極端情況——連鎖更新。

  前面小節提到過,如果前一個節點長度小於254位元組,那麼previous_entry_length屬性需要用1位元組來儲存長度值;如果前一個節點長度大於254位元組,那麼previous_entry_length屬性需要用5位元組來儲存長度值。

  現在,考慮這樣一種情況,在一個壓縮列表中,存在多個連續的、長度介於250位元組到253位元組之間的節點e1至eN,如下圖所示:

  

  如果將一個長度大於等於254位元組的新節點new設定為壓縮列表的表頭節點,如下圖所示:

  

  此時e1節點的previous_entry_length屬性從原來的1位元組擴充套件為5位元組,e1原本的長度介於250位元組到253位元組之間,由於previous_entry_length長度改變,e1的長度變為介於254位元組到257位元組,從而引發e2的更新,e2又會引發e3的擴充套件,直到eN為止,並將此過程稱為連鎖更新。下圖是另一種引起連鎖更新的情況:

  

  因為連鎖更新在最壞的情況下需要對壓縮列表執行N次空間重分配操作,而每次空間重分配的最壞複雜度是O(N),所以連鎖更新的最壞複雜度為O(N2)。

  但是,儘管連鎖更新的複雜度較高,但是真正造成效能問題的機率很低:

  • 壓縮列表裡面需要恰好有多個連續的、長度介於250位元組到253位元組之間的節點
  • 即使出現連鎖更新,只要被更新的節點不多,就不會對效能造成太大影響

  壓縮列表API