redis底層設計(一)——內部數據結構


  redis是一個key-value存儲系統。和Memcached類似,它支持存儲的value類型相對更多,包括string(字符串)、list(鏈表)、set(集合)、zset(sorted set --有序集合)和hash(哈希類型)。這些數據類型都支持push/pop、add/remove及取交集並集和差集及更豐富的操作,而且這些操作都是原子性的。在此基礎上,redis支持各種不同方式的排序。與memcached一樣,為了保證效率,數據都是緩存在內存中。區別的是redis會周期性的把更新的數據寫入磁盤或者把修改操作寫入追加的記錄文件,並且在此基礎上實現了master-slave(主從)同步。

1 內部數據結構

1.1 簡單動態字符串sds:

  Sds (Simple Dynamic String,簡單動態字符串)是Redis 底層所使用的字符串表示,它被用在幾乎所有的Redis 模塊中。

1.1.1 sds的用途:

  a.實現字符串對象(StringObject):數據庫的鍵總是包含一個sds值,而數據庫的值保存的是String類型的時候值中包含sds值,否則包含的是long類型的值。

  b.在redis程序內部用作char*類型的替代品:char*類型的功能單一抽象層次低不能支持redis的一些常用操作(長度計算和追加操作),

1.1.2 redis中的字符串:

  在C 語言中,字符串可以用一個\0 結尾的char 數組來表示。比如說,hello world 在C 語言中就可以表示為"hello world\0" 。這種簡單的字符串表示在大多數情況下都能滿足要求,但是,它並不能高效地支持長度計算和追加(append)這兩種操作:
  • 每次計算字符串長度(strlen(s))的復雜度為θ(N) 。

  • 對字符串進行N 次追加,必定需要對字符串進行N 次內存重分配(realloc)。

  在Redis 內部,字符串的追加和長度計算並不少見,而APPEND 和STRLEN 更是這兩種操作在Redis 命令中的直接映射,這兩個簡單的操作不應該成為性能的瓶頸。Redis 除了處理C 字符串之外,還需要處理單純的字節數組,以及服務器協議等內容,所以為了方便起見,Redis 的字符串表示還應該是二進制安全的:程序不應對字符串里面保存的數據做任何假設,數據可以是以\0 結尾的C 字符串,也可以是單純的字節數組,或者其他格式的數據。(這就是redis用sds替換char*的原因:sds可以高效的實現追加和長度計算,並且還是二進制安全的)

typedef char *sds;
  struct sdshdr {
  // buf 已占用長度
  int len;
  // buf 剩余可用長度
  int free;
  // 實際保存字符串數據的地方
  char buf[];
};

其實類型sds是char*的別名,而結構sdshdr則保存了len、free、bug這三個參數屬性。通過len 屬性,sdshdr 可以實現復雜度為θ(1) 的長度計算操作。另一方面,通過對buf 分配一些額外的空間,並使用free 記錄未使用空間的大小,sdshdr 可以讓執行追加操作所需的內存重分配次數大大減少

1.1.3 優化追加操作:

  當執行追加操作時,比如現在給key=‘Hello World’的字符串后追加‘ again!’則這時的len=18,free由0變成了18,此時的buf='Hello World again!\0                  ',也就是buf的內存空間是18+18+1=37個字節,其中‘\0’占1個字節redis給字符串多分配了18個字節的預分配空間,所以下次還有append追加的時候,如果預分配空間足夠,就無須在進行空間分配了。在當前版本中,當新字符串的長度小於1M時,redis會分配他們所需大小一倍的空間,當大於1M的時候,就為他們額外多分配1M的空間。

思考:這種分配策略會浪費內存資源嗎?

答:執行過APPEND 命令的字符串會帶有額外的預分配空間,這些預分配空間不會被釋放,除非該字符串所對應的鍵被刪除,或者等到關閉Redis 之后,再次啟動時重新載入的字符串對象將不會有預分配空間。因為執行APPEND 命令的字符串鍵數量通常並不多,占用內存的體積通常也不大,所以這一般並不算什么問題。另一方面,如果執行APPEND 操作的鍵很多,而字符串的體積又很大的話,那可能就需要修改Redis 服務器,讓它定時釋放一些字符串鍵的預分配空間,從而更有效地使用內存。

1.1.4 小結:

  a.redis的字符串表示為sds,而不是C字符串(以\0結尾的char*)。

  b.對比C字符串,sds有以下特性:高效的執行長度計算;高效的執行追加操作;二進制安全

  c.sds會為追加操作進行優化,加快追加操作的速度,並降低內存分配的次數,代價是多占用了一些內存 ,而且這些內存不會被主動釋放。

 

1.2 雙向鏈表

  鏈表作為數組之外的一種常用序列抽象,是大多數高級語言的基本數據類型,因為C 語言本身不支持鏈表類型,大部分C 程序都會自己實現一種鏈表類型,Redis 也不例外——它實現了一個雙向鏈表結構。

1.2.1 雙向鏈表的應用:

  雙向鏈表作為一種通用的數據結構,在Redis 內部使用得非常多:它既是Redis 列表結構的底層實現之一,還被大量Redis 模塊所使用,用於構建Redis 的其他功能。

注意:redis列表使用兩種數據結構作為底層實現:雙向鏈表和壓縮列表。因為雙向鏈表占用的內存比壓縮列表的要多,所以在創建新的列表鍵時,列表會優先考慮使用壓縮列表作為底層實現,並且在有需要的時候,才會從壓縮列表實現轉換到雙向鏈表實現。

  除了實現列表類型以外,雙向列表還被很多redis內部模塊所應用:

  a.事務模塊使用雙向鏈表來按順序保存輸入的命令;

  b.服務器模塊使用雙向鏈表來保存多個客戶端;

  c.訂閱/發送模塊使用雙向鏈表來保存訂閱模式的多個客戶端;

  d.時間模塊使用雙向鏈表來保存時間事件(time event)

除此之外,其實類似的應用還有很多。

1.2.2 雙向鏈表的實現:

  雙向鏈表是由listNode和list兩個數據結構組成,如下:

其中listNode是雙向鏈表的節點,包含prev(前驅指針)、next(后繼指針)和value(數值);list是雙向鏈表本身,包含head(表頭指針)、tail(表尾指針)、len(節點數量)、dup(復制函數)、free(釋放函數)和match(對比函數)

舉個例子:當刪除一個listNode時,如果包含這個節點的list的list->free函數不為空,那么刪除函數就會先調用list->free(listNode->value)清空節點的值,再執行余下的刪除操作(比如說釋放節點)。

從結構上總結出他們的性能特征:

a. listNode帶有prev和next兩個指針,因此對鏈表的遍歷可以在兩個方向上進行:從表頭到表尾,或者從表尾到表頭。

b.list保存了head和tail兩個指針,因此對鏈表的表頭和表尾進行插入的復雜度都是θ(1)——這是實現LPUSH、RPOP、RPUSH、LPOP的關鍵。

c.list帶有保存九點數量的len屬性,所以計算鏈表長度的復雜度為θ(1),所以LLEN的命令性能很高。

 1.2.3 迭代器:

  redis為雙向鏈表實現了一個迭代器,這個迭代器可以從兩個方向對雙向鏈表進行迭代:

  *沿着節點的next指針前進,從表頭向表尾迭代;

  *沿着節點的prev指針前進,從表尾向表頭迭代;

1.2.4 小結:

  *redis實現了自己的雙向鏈表結構;

  *雙向鏈表主要有兩個作用:

    -作為redis列表類型的底層實現之一;

    -作為通用數據結構,被其他功能模塊所使用;

  *雙向鏈表及其節點的性能特性如下:

    -節點帶有前驅和后繼指針,訪問前驅節點和后繼節點的時間復雜度為θ(1),並且堆鏈表的迭代可以在從表頭到表尾和從表尾到表頭兩個方向進行;

    -鏈表帶有只想表頭和表尾的指針,因此對表頭和表尾進行處理的復雜度為θ(1);

    -鏈表帶有記錄節點數量的屬性,所以可以在θ(1)復雜度內返回鏈表的節點數量(長度);

 

1.3 字典

  字典,又名映射(map)或關聯數組(associative array),他是一種抽象的數據結構,由一集鍵值對組成,各個鍵值對的鍵各不相同,程序可以將新的鍵值對添加到字典中,或者基於鍵進行查找、更新或刪除操作。

1.3.1字典的應用

  字典的主要用途有以下兩個:

  1)實現數據庫鍵空間(key space)

  redis是一個鍵值對數據庫,數據庫中的鍵值對就是由字典保存:每個數據庫都有一個與之相對應的字典,這個字典被稱為鍵空間(key space)。當用戶添加一個鍵值對到數據庫時(不論數據庫是什么類型),程序就將該鍵值對添加到鍵空間;當用戶從數據庫刪除一個鍵值對時,程序就會將這個鍵值對從鍵空間刪除

  2)用作Hash類型鍵的其中一種底層實現

  redis的Hush類型鍵使用以下兩種數據結構作為底層實現:字典、壓縮列表。因為壓縮列表比字典更節省內存,所以程序在創建新Hush鍵時,默認使用壓縮列表作為底層實現,當有需要時,程序才會將底層實現從壓縮列表轉換為字典。

1.3.2 字典的實現

  在眾多可能的實現中,redis選擇了高效且實現簡單的哈希表作為字典的底層實現。

/*
* 字典
**
每個字典使用兩個哈希表,用於實現漸進式rehash
*/
typedef struct dict {
// 特定於類型的處理函數
dictType *type;
// 類型處理函數的私有數據
void *privdata;
// 哈希表(2 個)
dictht ht[2];
// 記錄rehash 進度的標志,值為-1 表示rehash 未進行
int rehashidx;
// 當前正在運作的安全迭代器數量
int iterators;
} dict;

以下是用於處理dict 類型的API ,它們的作用及相應的算法復雜度:

注意:dict 類型使用了兩個指針分別指向兩個哈希表,其中0號哈希表(ht[0])表示字典主要使用的哈希表,而1號哈希表(ht[1])表示只有在程序對0號哈希表進行rehash時才使用。

 

/*
* 哈希表
*/
typedef struct dictht {
// 哈希表節點指針數組(俗稱桶,bucket)
dictEntry **table;
// 指針數組的大小
unsigned long size;
// 指針數組的長度掩碼,用於計算索引值
unsigned long sizemask;
// 哈希表現有的節點數量
unsigned long used;
} dictht;

table屬性是一個數組,數組的每個元素都是一個指向dictEntry結構的指針。每個dictEntry都保存着一個鍵值對,以及一個指向另一個dictEntry結構的指針:

/*
* 哈希表節點
*/
typedef struct dictEntry {
//
void *key;
//
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 鏈往后繼節點
struct dictEntry *next;
} dictEntry;

next屬性指向另一個dictEntry結構,多個dictEntry可以通過next指針串連成鏈表,dictht使用鏈地址法來處理鍵碰撞(鏈地址法:將全部具有同樣哈希地址的而不同keyword的數據元素連接到同一個單鏈表中。假設選定的哈希表長度為m,則可將哈希表定義為一個有m個頭指針組成的指針數組T[0..m-1]。凡是哈希地址為i的數據元素,均以節點的形式插入到T[i]為頭指針的單鏈表中。而且新的元素插入到鏈表的前端,這不僅由於方便。還由於常常發生這種事實:新近插入的元素最有可能不久又被訪問。)哈希表例子:

1.3.3 添加鍵值對到字典

  根據字典所處的狀態,將一個給定的鍵值對添加到字典可能會引起一系列復雜的操作:

  *如果字典未初始化(字典的0號哈希表的table屬性為空),那么程序需要懟0號哈希表進行初始化;

  *如果在插入時發生了鍵碰撞,那么程序需要處理碰撞;

  *如果新插入的元素使得字典滿足了rehash條件,那么需要啟動相應的rehash程序;

下面分別介紹添加操作在以上三種情況下的執行:

  (1)字典為空

  程序會根據dict.h/DICT_HT_INITIAL_SIZE 里指定的大小為d->ht[0]->table 分配空間(在目前的版本中,DICT_HT_INITIAL_SIZE 的值為4 )。下面是添加鍵值對后的樣子:

  (2)添加新鍵值對時發生碰撞處理

  在哈希表實現中,當兩個不同的鍵擁有相同的哈希值時,我們稱這兩個鍵發生碰撞,而哈希表實現必須對碰撞進行處理。一般會采用鏈地址法(使用鏈表將多個哈希值相同的節點串連在一起),如下:

  對於一個新的鍵值對key4 和value4 ,如果key4 的哈希值和key1 的哈希值相同,那么它們將在哈希表的0 號索引上發生碰撞。通過將key4-value4 和key1-value1 兩個鍵值對用鏈表連接起來,就可以解決碰撞的問題:

 

  (3)觸發rehash操作:

  對於使用鏈地址法來解決碰撞問題的哈希表dictht 來說,哈希表的性能依賴於它的大小(size屬性)和它所保存的節點的數量(used 屬性)之間的比率:

    • 比率在1:1 時,哈希表的性能最好;
    • 如果節點數量比哈希表的大小要大很多的話,那么哈希表就會退化成多個鏈表,哈希表本身的性能優勢就不再存在;

  如下:

  對於上面這個哈希表,平均每次失敗查詢需要5個節點,效率極低。為了在字典的鍵值對不斷增多的情況下保持良好的性能,字典需要對所使用的哈希表(ht[0])進行rehash 操作:在不修改任何鍵值對的情況下,對哈希表進行擴容,盡量將比率維持在1:1左右。dictAdd 在每次向字典添加新鍵值對之前,都會對哈希表ht[0] 進行檢查,對於ht[0] 的size 和used 屬性,如果它們之間的比率ratio = used / size 滿足以下任何一個條件的話,rehash 過程就會被激活:

  1. 自然rehash :ratio >= 1 ,且變量dict_can_resize 為真。
  2. 強制rehash : ratio 大於變量dict_force_resize_ratio (目前版本中,dict_force_resize_ratio 的值為5 )。

1.3.4 rehash執行過程:

  1.創建一個比ht[0]->table 更大的ht[1]->table;

  2.將ht[0]->table中的所有鍵值對前一代ht[1]->table;

  3.將原有ht[0]的數據清空,並將ht[1]替換成新的ht[0];

 

  下面具體介紹rehash的完整過程:

  a.開始rehash

  設置字典的rehashidx為0,標志着rehash的開始;為ht[1]->table分配空間,大小至少是ht[0]->table 的兩倍;

  b.rehash進行中

  在這個階段,ht[0]->table 的節點會被逐漸遷移到ht[1]->table ,因為rehash 是分多次進行的,字典的rehashidx 變量會記錄rehash 進行到ht[0] 的哪個索引位置上:

  c.rehash完畢:

  在rehash 的最后階段,程序會執行以下工作:
  1. 釋放ht[0] 的空間;
  2. 用ht[1] 來代替ht[0] ,使原來的ht[1] 成為新的ht[0] ;
  3. 創建一個新的空哈希表,並將它設置為ht[1] ;
  4. 將字典的rehashidx 屬性設置為-1 ,標識rehash 已停止;
  以下是字典rehash 完畢之后的樣子:

  

  對比字典rehash之前和之后,新的ht[0]空間更大,並且字典原有的鍵值對也沒有被修改或者刪除。

  1.3.5 漸進式rehash:

  在一個有很多鍵值對的字典里,某個用戶在添加新鍵值對時觸發了rehash  過程,如果這個rehash 過程必須將所有鍵值對遷移完畢之后才將結果返回給用戶,這樣的處理  方式將是非常不友好的。另一方面,要求服務器必須阻塞直到rehash 完成,這對於Redis 服務器本身也是不能接受的。為了解決這個問題,Redis 使用了漸進式(incremental)的rehash 方式:通過將rehash 分散到多個步驟中進行,從而避免了集中式的計算。
  漸進式rehash 主要由_dictRehashStep 和dictRehashMilliseconds 兩個函數進行:
  • _dictRehashStep 用於對數據庫字典、以及哈希鍵的字典進行被動rehash ;
  • dictRehashMilliseconds 則由Redis 服務器常規任務程序(server cron job)執行,用於對數據庫字典進行主動rehash ;

   _dictRehashStep:每次執行_dictRehashStep ,ht[0]->table 哈希表第一個不為空的索引上的所有節點就會全部遷移到ht[1]->table 。在rehash 開始進行之后(d->rehashidx 不為-1),每次執行一次添加、查找、刪除操作,_dictRehashStep 都會被執行一次。因為字典會保持哈希表大小和節點數的比率在一個很小的范圍內,所以每個索引上的節點數量不會很多(從目前版本的rehash 條件來看,平均只有一個,最多通常也不會超過五個),所以在執行操作的同時,對單個索引上的節點進行遷移,幾乎不會對響應時間造成影響。

  dictRehashMilliseconds: 可以在指定的毫秒數內,對字典進行rehash 。當Redis 的服務器常規任務執行時,dictRehashMilliseconds 會被執行,在規定的時間內,盡可能地對數據庫字典中那些需要rehash 的字典進行rehash ,從而加速數據庫字典的rehash進程。

  在哈希表進行rehash 時,字典還會采取一些特別的措施,確保rehash 順利、正確地進行:
  • 因為在rehash 時,字典會同時使用兩個哈希表,所以在這期間的所有查找、刪除等操作,除了在ht[0] 上進行,還需要在ht[1] 上進行。
  • 在執行添加操作時,新的節點會直接添加到ht[1] 而不是ht[0] ,這樣保證ht[0] 的節點數量在整個rehash 過程中都只減不增。

   1.3.6 字典的收縮:

  上面描述了通過rehash對字典的擴展,如果哈希表的也用節點數比已用節點數搭很多,那么也可以通過哈希表進行rehash來收縮字典。執行步驟如下:

  1)創建一個比ht[0]->table 小的ht[1]->table ;

  2)將ht[0]->table中的所有鍵值對遷移到ht[1]->table ;

  3)將原有的ht[0]的數據清空,並將ht[1]替換成ht[0];  

  字典的收縮規則由htNeedsResize函數定義:

/*
* 檢查字典的使用率是否低於系統允許的最小比率
**
是的話返回1 ,否則返回0 。
*/
int htNeedsResize(dict *dict) {
long long size, used;
// 哈希表已用節點數量
size = dictSlots(dict);
// 哈希表大小
used = dictSize(dict);
// 當哈希表的大小大於DICT_HT_INITIAL_SIZE
// 並且字典的填充率低於REDIS_HT_MINFILL 時
// 返回1
return (size && used && size > DICT_HT_INITIAL_SIZE &&
(used*100/size < REDIS_HT_MINFILL));
}

  在默認情況下,REDIS_HT_MINFILL 的值為10 ,也即是說,當字典的填充率低於10% 時,程序就可以對這個字典進行收縮操作了。
  字典收縮和字典擴展的一個區別是:
    • 字典的擴展操作是自動觸發的(不管是自動擴展還是強制擴展);
    • 而字典的收縮操作則是由程序手動執行。
  因此,使用字典的程序可以決定何時對字典進行收縮:
    • 當字典用於實現哈希鍵的時候,每次從字典中刪除一個鍵值對,程序就會執行一次htNeedsResize 函數,如果字典達到了收縮的標准,程序將立即對字典進行收縮;
    • 當字典用於實現數據庫鍵空間(key space) 的時候, 收縮的時機由redis.c/tryResizeHashTables 函數決定。

  1.3.7 字典的迭代:

  字典有自己的迭代器實現——對字典進行迭代實際上就是對字典所使用的哈希表進行迭代:

    • 迭代器首先迭代字典的第一個哈希表,然后,如果rehash正在進行的話,就繼續對第二個哈希表進行迭代;

    • 當迭代哈希表時,找到第一個不為空的索引,然后迭代這個索引上的所有節點。

    • 當這個索引迭代完了,繼續查找下一個不為空的索引,如此循環,一直到整個哈希表都迭代完為止。

  字典的迭代器有兩種:

    • 安全迭代器

    • 不安全迭代器

/*
* 字典迭代器
*/
typedef struct dictIterator {
dict *d; // 正在迭代的字典
int table, // 正在迭代的哈希表的號碼(0 或者1)
index, // 正在迭代的哈希表數組的索引
safe; // 是否安全?
dictEntry *entry, // 當前哈希節點
*nextEntry; // 當前哈希節點的后繼節點
} dictIterator;

以下是這個迭代器的api:

  1.3.8 小結:    

    • 字典是由鍵值對構成的抽象數據結構;

    • Redis 中的數據庫和哈希鍵都是基於字典來實現的;

    • Redis 字典的底層實現為哈希表,每個字典使用兩個哈希表,一般情況下只使用0號哈希表,只有在rehash進行時,才會使用0號和1號哈希表;

    • 哈希表使用鏈地址法來解決鍵沖突的問題;

    • rehash可以用於擴展和收縮哈希表;

    • 對哈希表的rehash是分多次、漸進式地進行。

 

 1.4 跳躍表

  跳躍表是一種隨機化的數據,這種數據結構以有序的方式在層次化的鏈表中保存元素,如下圖:

  從上圖中我們可以看出跳躍表的結構組成:

    • 表頭(head):負責維護跳躍表的節點指針。
    • 跳躍表節點:保存着元素值,以及多個層。
    • 層:保存着指向其他元素的指針。高層的指針越過的元素數量大於等於低層的指針,為了提高查找的效率,程序總是從高層先開始訪問,然后隨着元素值范圍的縮小,慢慢降低層次。
    • 表尾:全部由NULL 組成,表示跳躍表的末尾。

  1.4.1 跳躍表的實現:

  a.允許重復的score值:多個不同的member的score值可以相同;

  b.進行對比操作時,不僅要檢查score值,還要檢查member:當score值可以重復時,單靠score值無法判斷一個元素的身份,所以需要連member域都一並檢查才行;

  c.每個節點都帶有一個高度為1層的后腿指針,用於從表頭方向向表尾方向迭代:當執行ZERVRANGE或ZREVRSNGEBYSCORE這類以逆序處理有序集的命令時,就會用到這個屬性。

typedef struct zskiplist {
// 頭節點,尾節點
struct zskiplistNode *header, *tail;
// 節點數量
unsigned long length;
// 目前表內節點的最大層數
int level;
} zskiplist;
跳躍表的節點由redis.h/zskiplistNode 定義:
typedef struct zskiplistNode {
// member 對象
robj *obj;
// 分值
double score;
// 后退指針
struct zskiplistNode *backward;
//
struct zskiplistLevel {
// 前進指針
struct zskiplistNode *forward;
// 這個層跨越的節點數量
unsigned int span;
} level[];
} zskiplistNode;

  1.4.2 跳躍表的應用:

  和字典、鏈表或者字符串這幾種在redis中大量使用的數據結構不同,跳躍表在redis的唯一作用就是實現有序集數據類型。

  跳躍表將指向有序集的score值和member域的指針作為元素,並以score值為索引,對有序集元素進行排序。

  1.4.3 小結:

  跳躍表是一種隨機化數據結構,它的查找、添加、刪除操作都可以在對數期望時間下完成;

  跳躍表目前在redis的唯一作用就是作為有序集類型的底層數據結構(之一,另一個構成有序集的結構是字典);

  為了適應自身的需求,redis基於William Pugh 論文中描述的跳躍表進行了修改,包括:

  a.score值可重復;

  b.對比一個元素需要同時檢查它的score值和member域;

  c. 每個節點帶有高度為1層的后退指針,用於從表尾方向向表頭方向迭代。 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM