今天我們來看一下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等命令的關鍵。
通過同時使用字典和跳躍表,有序集可以高效地實現按成員查找和按順序查找兩種操作。
