1. 程式人生 > >redis底層設計(三)——redis資料型別

redis底層設計(三)——redis資料型別

今天我們來看一下redis的資料型別。既然redis的鍵值對可以儲存不同型別的值,那麼很自然就需要對鍵值對的型別進行檢查以及多型處理。下面我們將對redis所使用的物件系統進行了解,並分別觀察字串、雜湊表、列表、集合和有序集型別的底層實現。

3.1 物件處理機制

  在redis的命令中,用於對鍵進行處理的命令佔了很大一部分,而對於鍵所儲存的值的型別(鍵的型別),鍵能執行的命令又各不相同。如:LPUSH和LLEN只能用於列表鍵,而SADD和SRANDMEMBER只能用於集合鍵。又比如DEL、TTL和TRPE可以用於任何型別的鍵,所以要正確實現這些命令,必須為不同型別的鍵設定不同的處理方式;redis的每一種資料型別,比如字串、列表、有序集,它們都擁有不止一種底層實現,這說明每當對某種資料進行處理的時候,程式必須根據鍵所採取的編碼進行不同的操作。

  綜上:操作資料型別的命令除了要對鍵的型別進行檢查之外,還需要根據資料型別的不同編碼進行多型處理。

  3.1.1 redisObject 資料結構,以及redis的資料型別

  redisObject是redis型別系統的核心,資料庫中的每個鍵、值,以及redis本身處理的函式,都表示為這種資料型別。

/*
* Redis 物件
*/
typedef struct redisObject {
// 型別
unsigned type:4;
// 對齊位
unsigned notused:2;
// 編碼方式
unsigned encoding:4;
// LRU 時間(相對於server.lruclock)
unsigned lru:22; // 引用計數 int refcount; // 指向物件的值 void *ptr; } robj;

  type、encoding和ptr是最重要的三個屬性。

  type記錄了物件所儲存的值的型別,它的值可能是以下常量中的一個:

/*
* 物件型別
*/
#define REDIS_STRING 0 // 字串
#define REDIS_LIST 1 // 列表
#define REDIS_SET 2 // 集合
#define REDIS_ZSET 3 // 有序集
#define REDIS_HASH 4 // 雜湊表

  encoding記錄了物件所儲存的值的編碼,它的值可能是以下常量中的一個:

/*
* 物件編碼
*/
#define REDIS_ENCODING_RAW 0 // 編碼為字串
#define REDIS_ENCODING_INT 1 // 編碼為整數
#define REDIS_ENCODING_HT 2 // 編碼為雜湊表
#define REDIS_ENCODING_ZIPMAP 3 // 編碼為zipmap
#define REDIS_ENCODING_LINKEDLIST 4 // 編碼為雙端連結串列
#define REDIS_ENCODING_ZIPLIST 5 // 編碼為壓縮列表
#define REDIS_ENCODING_INTSET 6 // 編碼為整數集合
#define REDIS_ENCODING_SKIPLIST 7 // 編碼為跳躍表

  ptr是一個指標,指向實際儲存值的資料結構,這個資料結構由type和encoding屬性決定。舉個例子, 如果一個redisObject 的type 屬性為REDIS_LIST , encoding 屬性為REDIS_ENCODING_LINKEDLIST ,那麼這個物件就是一個Redis 列表,它的值儲存在一個雙端連結串列內,而ptr 指標就指向這個雙端連結串列;

  下圖展示了redisObject 、Redis 所有資料型別、以及Redis 所有編碼方式(底層實現)三者之間的關係:

 

  注意:REDIS_ENCODING_ZIPMAP沒有出現在圖中,因為在redis2.6開始,它不再是任何資料型別的底層結構。

  3.1.2 命令的型別檢查和多型

  當執行一個處理資料型別命令的時候,redis執行以下步驟:

    1)根據給定的key,在資料庫字典中查詢和他相對應的redisObject,如果沒找到,就返回NULL;

    2)檢查redisObject的type屬性和執行命令所需的型別是否相符,如果不相符,返回型別錯誤;

    3)根據redisObject的encoding屬性所指定的編碼,選擇合適的操作函式來處理底層的資料結構;

    4)返回資料結構的操作結果作為命令的返回值。

  比如現在執行LPOP命令:

  3.1.3 物件共享

  redis一般會把一些常見的值放到一個共享物件中,這樣可使程式避免了重複分配的麻煩,也節約了一些CPU時間。

  redis預分配的值物件如下:

    1)各種命令的返回值,比如成功時返回的OK,錯誤時返回的ERROR,命令入隊事務時返回的QUEUE,等等

    2)包括0 在內,小於REDIS_SHARED_INTEGERS的所有整數(REDIS_SHARED_INTEGERS的預設值是10000)

  注意:共享物件只能被字典和雙向連結串列這類能帶有指標的資料結構使用。像整數集合和壓縮列表這些只能儲存字串、整數等自勉之的記憶體資料結構

   3.1.4 引用計數以及物件的消毀:

    * 每個redisObject結構都帶有一個refcount屬性,指示這個物件被引用了多少次;

    * 當新建立一個物件時,它的refcount屬性被設定為1;

    * 當對一個物件進行共享時,redis將這個物件的refcount加一;

    * 當使用完一個物件後,或者消除對一個物件的引用之後,程式將物件的refcount減一;

    * 當物件的refcount降至0 時,這個RedisObject結構,以及它引用的資料結構的記憶體都會被釋放。

  3.1.5 小結:

    * redis使用自己實現的物件機制來實現型別判斷、命令多型和基於引用次數的垃圾回收;

    * redis會預分配一些常用的資料物件,並通過共享這些物件來減少記憶體佔用,和避免頻繁的為小物件分配記憶體。

  

3.2 字串  

  REDIS_STRING(字串)是redis使用最廣泛的資料型別,他除了是set、get等命令的操作物件之外,資料庫中的所有鍵,以及執行命令時提供給redis的引數都是用這種型別儲存的。

  3.2.1 字串編碼:

  字串型別分別使用REDIS_ENCODING_INT和REDIS_ENCODING_RAW兩種編碼:

    * REDIS_ENCODING_INT使用long型別來儲存long型別值;  

    * REDIS_ENCODING_RAW使用sdshdr 結構來儲存sds(即是 char*)、long long 、double 和 long double 型別值。

  換句話來說,在redis中,只有能表示為long型別的值,才會以整數的形式儲存,其他型別的整數、小數和字串,都是用sdshdr結構來儲存。

 

  新建立的字串預設使用REDIS_ENCODING_RAW 編碼,在將字串作為鍵或者值儲存進資料庫時,程式會嘗試將字串轉為REDIS_ENCODING_INT 編碼。

  

3.3 雜湊表

  REDIS_HASH(雜湊表)是HSET、HLEN等命令的操作物件。他使用REDIS_ENCODING_ZIPLIST 和 REDIS_ENCODING_HT 兩種編碼方式:

   

  3.3.1 字典編碼的雜湊表:

  雜湊表所使用的字典的鍵和值都是字串物件。

  

  3.3.2 壓縮列表編碼的雜湊表:

  當使用REDIS_ENCODING_ZIPLIST 編碼雜湊表時,程式通過將鍵和值一同推入壓縮列表,從而形成儲存雜湊表所需的鍵-值對結構:

 

  新新增的key-value會被新增到壓縮列表的表尾。當進行查詢/刪除或更新操作時,程式先定位到鍵的位置,然後再通過對鍵的位置來定位值的位置。

  建立空白雜湊表時,程式預設使用REDIS_ENCODING_ZIPLIST 編碼,當以下任何一個條件被滿足時,程式將編碼從切換為REDIS_ENCODING_HT :
    • 雜湊表中某個鍵或某個值的長度大於server.hash_max_ziplist_value (預設值為64)。
    • 壓縮列表中的節點數量大於server.hash_max_ziplist_entries (預設值為512 )。

 

3.4 列表

  REDIS_LIST(列表)是LPUSH、LRANGE等命令的操作物件,他使用REDIS_ENCODING_ZIPLIST和REDIS_ENCODING_LINKEDLIST這兩種方式編碼:

 

  3.4.1 編碼的選擇:   

  建立新列表時Redis 預設使用REDIS_ENCODING_ZIPLIST 編碼,當以下任意一個條件被滿足時,列表會被轉換成REDIS_ENCODING_LINKEDLIST 編碼:
    • 試圖往列表新新增一個字串值, 且這個字串的長度超過server.list_max_ziplist_value (預設值為64 )。
    • ziplist 包含的節點超過server.list_max_ziplist_entries (預設值為512 )。

  3.4.2 阻塞的條件:  

  BLPOP、LRPOP和BRPOPLPUSH三個命令都可能造成客戶端被阻塞,所以我們將這些命令統稱為列表的阻塞原語。

  阻塞原語並不是一定會造成客戶端阻塞:
    • 只有當這些命令被用於空列表時,它們才會阻塞客戶端。
    • 如果被處理的列表不為空的話,它們就執行無阻塞版本的LPOP 、RPOP 或RPOPLPUSH命令。

  如下:

  3.4.3 阻塞的過程:

  當一個阻塞原語的處理目標為空值時,執行該阻塞原語的客戶端就會被阻塞。阻塞一個客戶端需要執行以下步驟:

    1)將客戶端的狀態設定為“正在阻塞”,並記錄阻塞這個客戶端的各個鍵,以及阻塞的最長時限(timeout)等資料;

    2)將客戶端的資訊記錄到server.db[i]->blocking_keys中(其中i為客戶端所使用的資料庫號碼);

    3)繼續維持客戶端和伺服器之間的網路連線,但不再向客戶端傳送任何資訊,造成客戶端阻塞。

   步驟2 是將來解除阻塞的關鍵,server.db[i]->blocking_keys 是一個字典,字典的鍵是那些造成客戶端阻塞的鍵,而字典的值是一個連結串列,連結串列裡儲存了所有因為這個鍵而被阻塞的客戶端(被同一個鍵所阻塞的客戶端可能不止一個):

 

  當客戶端被阻塞後,脫離阻塞狀態有以下3種方法:

    1)被動脫離:有其他客戶端為造成阻塞的鍵推入了新元素;

    2)主動脫離:到達執行阻塞原語時設定的最大阻塞時間;

    3)強制脫離:客戶端強制終止和伺服器的連線,或者伺服器停機。

 

  3.4.4 阻塞因LPUSH、RPUSH、LINSERT等新增命令而被取消

  通過將新元素推入造成客戶端阻塞的某個鍵中,可以讓相應的客戶端從阻塞狀態中脫離出來(取消阻塞的客戶端數量取決於推入元素的數量);這3個新增元素命令在底層實現上都是pushGenericCommand函式去執行的。

  

  當向一個空鍵推入新元素時,pushGenericCommand 函式執行以下兩件事:
    1. 檢查這個鍵是否存在於前面提到的server.db[i]->blocking_keys 字典裡,如果是的話,那麼說明有至少一個客戶端因為這個key 而被阻塞,程式會為這個鍵建立一個redis.h/readyList 結構,並將它新增到server.ready_keys 連結串列中。
    2. 將給定的值新增到列表鍵中。

  readyList的結構如下:

typedef struct readyList {
redisDb *db;
robj *key;
} readyList;

  key屬性指向造成阻塞的鍵,而db則指向該鍵所在的資料庫。

  比如說:假設某個非阻塞客戶端正在使用0 號資料庫,而這個資料庫當前的blocking_keys屬性的值如下:

  

  如果這時客戶端對該資料庫執行PUSH key3 value ,那麼pushGenericCommand 將建立一個db 屬性指向0 號資料庫、key 屬性指向key3 鍵物件的readyList 結構,並將它新增到伺服器server.ready_keys 屬性的連結串列中:

 

  此時pushGenericCommand 函式完成了以下兩件事:

    1)將readyList新增到伺服器;

    2)將新元素value新增到鍵key3;

  雖然key3已經不再是空鍵,但到目前為止,被key3阻塞的客戶端還沒有任何一個唄解除阻塞狀態。這時redis會呼叫handleClientsBlockedOnLists函式,執行步驟如下: 

    1. 如果server.ready_keys 不為空, 那麼彈出該連結串列的表頭元素, 並取出元素中的readyList 值。
    2. 根據readyList 值所儲存的key 和db ,在server.blocking_keys 中查詢所有因為key而被阻塞的客戶端(以連結串列的形式儲存)。
    3. 如果key 不為空,那麼從key 中彈出一個元素,並彈出客戶端連結串列的第一個客戶端,然後將被彈出元素返回給被彈出客戶端作為阻塞原語的返回值。
    4. 根據readyList 結構的屬性,刪除server.blocking_keys 中相應的客戶端資料,取消客戶端的阻塞狀態。 

    5. 繼續執行步驟3 和4 ,直到key 沒有元素可彈出,或者所有因為key 而阻塞的客戶端都取消阻塞為止。
    6. 繼續執行步驟1 ,直到ready_keys 連結串列裡的所有readyList 結構都被處理完為止。

   3.4.5 先阻塞先服務(FBFS)策略

 

    值得一提的是,當程式新增一個新的被阻塞客戶端到server.blocking_keys 字典的連結串列中時,它將該客戶端放在連結串列的最後,而當handleClientsBlockedOnLists 取消客戶端的阻塞時,它從連結串列的最前面開始取消阻塞:這個連結串列形成了一個FIFO 佇列,最先被阻塞          的客戶端總值最先脫離阻塞狀態,Redis 文件稱這種模式為先阻塞先服務(FBFS,first-block-first-serve)。舉個例子,在下圖所示的阻塞狀況中,如果客戶端對資料庫執行PUSH key3 value ,那麼只有client3 會被取消阻塞,client6 和client4 仍然阻塞;如果客戶端          對資料庫執行PUSH key3 value1 value2 ,那麼client3 和client4 的阻塞都會被取消,而客戶端client6 依然處於阻塞狀態:

 

  3.4.6 阻塞因超過最大等待時間而被取消

    每次Redis 伺服器常規操作函式(server cron job)執行時,程式都會檢查所有連線到伺服器的客戶端,檢視那些處於“正在阻塞”狀態的客戶端的最大阻塞時限是否已經過期,如果是的話,就給客戶端返回一個空白回覆,然後撤銷對客戶端的阻塞。

 

3.5 集合

  REDIS_SET(集合) 是SADD。SRANGMEMBER等命令的操作物件,它使用REDIS_ENCODING_INTSET和REDIS_ENCODING_HT兩種方式編碼:

 

  3.5.1 編碼的選擇:

  第一個新增到集合的元素,決定了建立集合時所使用的編碼:
    • 如果第一個元素可以表示為long long 型別值(也即是,它是一個整數),那麼集合的初始編碼為REDIS_ENCODING_INTSET 。
    • 否則,集合的初始編碼為REDIS_ENCODING_HT 。

   3.5.2 編碼的切換: 

  如果一個集合使用REDIS_ENCODING_INTSET 編碼,那麼當以下任何一個條件被滿足時,這個集合會被轉換成REDIS_ENCODING_HT 編碼:
    • intset 儲存的整數值個數超過server.set_max_intset_entries (預設值為512 )。
    • 試圖往集合裡新增一個新元素,並且這個元素不能被表示為long long 型別(也即是,它不是一個整數)。

   3.5.3 字典編碼的集合:

  當使用REDIS_ENCODING_HT編碼時,集合將元素儲存到字典的鍵裡面,而字典的值則統一設為null,如下集合的成員分別是:elem1、elem2和elem3:

 

3.6 有序集

  REDIS_ZSET(有序集)是ZADD、ZCOUNT等命令的操作物件,它使用REDIS_ENCODING_ZIPLIST和REDIS_ENCODING_SKIPLIST兩種編碼方式:

  3.6.1 編碼的選擇:  

  在通過ZADD 命令新增第一個元素到空key 時,程式通過檢查輸入的第一個元素來決定該建立什麼編碼的有序集。如果第一個元素符合以下條件的話,就建立一個REDIS_ENCODING_ZIPLIST 編碼的有序集:
    • 伺服器屬性server.zset_max_ziplist_entries 的值大於0 (預設為128 )。
    • 元素的member 長度小於伺服器屬性server.zset_max_ziplist_value 的值(預設為64)。否則,程式就建立一個REDIS_ENCODING_SKIPLIST 編碼的有序集。

  3.6.2 編碼的裝換:  

  對於一個REDIS_ENCODING_ZIPLIST 編碼的有序集,只要滿足以下任一條件,就將它轉換為REDIS_ENCODING_SKIPLIST 編碼:
    • ziplist 所儲存的元素數量超過伺服器屬性server.zset_max_ziplist_entries 的值(預設值為128 )
    • 新新增元素的member 的長度大於伺服器屬性server.zset_max_ziplist_value 的值(預設值為64 )

   3.6.3 ZIPLIST編碼的有序集

  每個有序集元素以兩個相鄰的ziplist節點表示,第一個節點儲存元素的member域,第二個節點儲存元素的score值;多個元素之間按score值從小到大排序,如果兩個元素的score值相同,那麼就按字典對member進行對比,決定哪個元素排在前面,哪個元素排在後面

  3.6.4 SKIPLIST編碼的有序集

  當使用REDIS_ENCODING_SKIPLIST編碼時,有序集元素由redis.h/zset 結構來儲存

/*
* 有序集
*/
typedef struct zset {
// 字典
dict *dict;
// 跳躍表
zskiplist *zsl;
} zset;

  zset同時使用字典和跳躍表兩個資料結構來儲存有序集元素。

  其中,元素的成員由一個redisObject 結構表示,而元素的score 則是一個double 型別的浮點數,字典和跳躍表兩個結構通過將指標共同指向這兩個值來節約空間(不用每個元素都複製兩份)。

  

 

  通過使用字典結構,並將member 作為鍵,score 作為值,有序集可以在O(1) 複雜度內:
    • 檢查給定member 是否存在於有序集(被很多底層函式使用);
    • 取出member 對應的score 值(實現ZSCORE 命令)。
  另一方面,通過使用跳躍表,可以讓有序集支援以下兩種操作:
    • 在O(logN) 期望時間、O(N) 最壞時間內根據score 對member 進行定位(被很多底層函式使用);
    • 範圍性查詢和處理操作,這是(高效地)實現ZRANGE 、ZRANK 和ZINTERSTORE等命令的關鍵。
  通過同時使用字典和跳躍表,有序集可以高效地實現按成員查詢和按順序查詢兩種操作。