本文是《Redis內部數據結構詳解》系列的第七篇。在本文中,咱們圍繞一個Redis的內部數據結構——intset展開討論。javascript
Redis裏面使用intset是爲了實現集合(set)這種對外的數據結構。set結構相似於數學上的集合的概念,它包含的元素無序,且不能重複。Redis裏的set結構還實現了基礎的集合並、交、差的操做。與Redis對外暴露的其它數據結構相似,set的底層實現,隨着元素類型是不是整型以及添加的元素的數目多少,而有所變化。歸納來說,當set中添加的元素都是整型且元素數目較少時,set使用intset做爲底層數據結構,不然,set使用dict做爲底層數據結構。java
在本文中咱們將大致分紅三個部分進行介紹:redis
咱們在討論中還會涉及到一個Redis配置(在redis.conf中的ADVANCED CONFIG部分):算法
set-max-intset-entries 512複製代碼
注:本文討論的代碼實現基於Redis源碼的3.2分支。數組
intset顧名思義,是由整數組成的集合。實際上,intset是一個由整數組成的有序集合,從而便於在上面進行二分查找,用於快速地判斷一個元素是否屬於這個集合。它在內存分配上與ziplist有些相似,是連續的一整塊內存空間,並且對於大整數和小整數(按絕對值)採起了不一樣的編碼,儘可能對內存的使用進行了優化。網絡
intset的數據結構定義以下(出自intset.h和intset.c):數據結構
typedef struct intset {
uint32_t encoding;
uint32_t length;
int8_t contents[];
} intset;
#define INTSET_ENC_INT16 (sizeof(int16_t))
#define INTSET_ENC_INT32 (sizeof(int32_t))
#define INTSET_ENC_INT64 (sizeof(int64_t))複製代碼
各個字段含義以下:app
encoding
: 數據編碼,表示intset中的每一個數據元素用幾個字節來存儲。它有三種可能的取值:INTSET_ENC_INT16表示每一個元素用2個字節存儲,INTSET_ENC_INT32表示每一個元素用4個字節存儲,INTSET_ENC_INT64表示每一個元素用8個字節存儲。所以,intset中存儲的整數最多隻能佔用64bit。length
: 表示intset中的元素個數。encoding
和length
兩個字段構成了intset的頭部(header)。contents
: 是一個柔性數組(flexible array member),表示intset的header後面緊跟着數據元素。這個數組的總長度(即總字節數)等於encoding * length
。柔性數組在Redis的不少數據結構的定義中都出現過(例如sds, quicklist, skiplist),用於表達一個偏移量。contents
須要單獨爲其分配空間,這部份內存不包含在intset結構當中。其中須要注意的是,intset可能會隨着數據的添加而改變它的數據編碼:ide
下圖給出了一個添加數據的具體例子(點擊看大圖)。函數
在上圖中:
encoding
= 2, length
= 0。encoding
不變,值仍是2。encoding
必須升級到INTSET_ENC_INT32(值爲4),即用4個字節表示一個元素。encoding
字段的4個字節應該解釋成0x00000004,而第5個數據應該解釋成0x000186A0 = 100000。intset與ziplist相比:
len
),而intset只能總體使用一個統一的編碼(encoding
)。要理解intset的一些實現細節,只須要關注intset的兩個關鍵操做基本就能夠了:查找(intsetFind
)和添加(intsetAdd
)元素。
intsetFind
的關鍵代碼以下所示(出自intset.c):
uint8_t intsetFind(intset *is, int64_t value) {
uint8_t valenc = _intsetValueEncoding(value);
return valenc <= intrev32ifbe(is->encoding) && intsetSearch(is,value,NULL);
}
static uint8_t intsetSearch(intset *is, int64_t value, uint32_t *pos) {
int min = 0, max = intrev32ifbe(is->length)-1, mid = -1;
int64_t cur = -1;
/* The value can never be found when the set is empty */
if (intrev32ifbe(is->length) == 0) {
if (pos) *pos = 0;
return 0;
} else {
/* Check for the case where we know we cannot find the value, * but do know the insert position. */
if (value > _intsetGet(is,intrev32ifbe(is->length)-1)) {
if (pos) *pos = intrev32ifbe(is->length);
return 0;
} else if (value < _intsetGet(is,0)) {
if (pos) *pos = 0;
return 0;
}
}
while(max >= min) {
mid = ((unsigned int)min + (unsigned int)max) >> 1;
cur = _intsetGet(is,mid);
if (value > cur) {
min = mid+1;
} else if (value < cur) {
max = mid-1;
} else {
break;
}
}
if (value == cur) {
if (pos) *pos = mid;
return 1;
} else {
if (pos) *pos = min;
return 0;
}
}複製代碼
關於以上代碼,咱們須要注意的地方包括:
intsetFind
在指定的intset中查找指定的元素value
,找到返回1,沒找到返回0。_intsetValueEncoding
函數會根據要查找的value
落在哪一個範圍而計算出相應的數據編碼(即它應該用幾個字節來存儲)。value
所需的數據編碼比當前intset的編碼要大,則它確定在當前intset所能存儲的數據範圍以外(特別大或特別小),因此這時會直接返回0;不然調用intsetSearch
執行一個二分查找算法。intsetSearch
在指定的intset中查找指定的元素value
,若是找到,則返回1而且將參數pos
指向找到的元素位置;若是沒找到,則返回0而且將參數pos
指向能插入該元素的位置。intsetSearch
是對於二分查找算法的一個實現,它大體分爲三個部分:
value
比最後一個元素還要大或者比第一個元素還要小的時候。實際上,這兩部分的特殊處理,在二分查找中並非必須的,但它們在這裏提供了特殊狀況下快速失敗的可能。min
指定的位置。intrev32ifbe
是爲了在須要的時候作大小端轉換的。前面咱們提到過,intset裏的數據是按小端(little endian)模式存儲的,所以在大端(big endian)機器上運行時,這裏的intrev32ifbe
會作相應的轉換。而intsetAdd
的關鍵代碼以下所示(出自intset.c):
intset *intsetAdd(intset *is, int64_t value, uint8_t *success) {
uint8_t valenc = _intsetValueEncoding(value);
uint32_t pos;
if (success) *success = 1;
/* Upgrade encoding if necessary. If we need to upgrade, we know that * this value should be either appended (if > 0) or prepended (if < 0), * because it lies outside the range of existing values. */
if (valenc > intrev32ifbe(is->encoding)) {
/* This always succeeds, so we don't need to curry *success. */
return intsetUpgradeAndAdd(is,value);
} else {
/* Abort if the value is already present in the set. * This call will populate "pos" with the right position to insert * the value when it cannot be found. */
if (intsetSearch(is,value,&pos)) {
if (success) *success = 0;
return is;
}
is = intsetResize(is,intrev32ifbe(is->length)+1);
if (pos < intrev32ifbe(is->length)) intsetMoveTail(is,pos,pos+1);
}
_intsetSet(is,pos,value);
is->length = intrev32ifbe(intrev32ifbe(is->length)+1);
return is;
}複製代碼
關於以上代碼,咱們須要注意的地方包括:
intsetAdd
在intset中添加新元素value
。若是value
在添加前已經存在,則不會重複添加,這時參數success
被置爲0;若是value
在原來intset中不存在,則將value
插入到適當位置,這時參數success
被置爲0。value
所需的數據編碼比當前intset的編碼要大,那麼則調用intsetUpgradeAndAdd
將intset的編碼進行升級後再插入value
。intsetSearch
,若是能查到,則不會重複添加。intsetResize
對intset進行內存擴充,使得它可以容納新添加的元素。由於intset是一塊連續空間,所以這個操做會引起內存的realloc
(參見man.cx/realloc)。這有可能帶來一次數據拷貝。同時調用intsetMoveTail
將待插入位置後面的元素統一貫後移動1個位置,這也涉及到一次數據拷貝。值得注意的是,在intsetMoveTail
中是調用memmove
完成此次數據拷貝的。memmove
保證了在拷貝過程當中不會形成數據重疊或覆蓋,具體參見man.cx/memmove。intsetUpgradeAndAdd
的實現中也會調用intsetResize
來完成內存擴充。在進行編碼升級時,intsetUpgradeAndAdd
的實現會把原來intset中的每一個元素取出來,再用新的編碼從新寫入新的位置。intsetAdd
的返回值,它返回一個新的intset指針。它可能與傳入的intset指針is
相同,也可能不一樣。調用方必須用這裏返回的新的intset,替換以前傳進來的舊的intset變量。相似這種接口使用模式,在Redis的實現代碼中是很常見的,好比咱們以前在介紹sds和ziplist的時候都碰到過相似的狀況。intsetAdd
算法總的時間複雜度爲O(n)。爲了更好地理解Redis對外暴露的set數據結構,咱們先看一下set的一些關鍵的命令。下面是一些命令舉例:
上面這些命令的含義:
sadd
用於分別向集合s1
和s2
中添加元素。添加的元素既有數字,也有非數字("a"和"b")。sismember
用於判斷指定的元素是否在集合內存在。sinter
, sunion
和sdiff
分別用於計算集合的交集、並集和差集。咱們前面提到過,set的底層實現,隨着元素類型是不是整型以及添加的元素的數目多少,而有所變化。例如,具體到上述命令的執行過程當中,集合s1
的底層數據結構會發生以下變化:
sadd s1 13 5
以後,因爲添加的都是比較小的整數,因此s1
底層是一個intset,其數據編碼encoding
= 2。sadd s1 32768 10 100000
以後,s1
底層仍然是一個intset,但其數據編碼encoding
從2升級到了4。sadd s1 a b
以後,因爲添加的元素再也不是數字,s1
底層的實現會轉成一個dict。咱們知道,dict是一個用於維護key和value映射關係的數據結構,那麼當set底層用dict表示的時候,它的key和value分別是什麼呢?實際上,key就是要添加的集合元素,而value是NULL。
除了前面提到的因爲添加非數字元素形成集合底層由intset轉成dict以外,還有兩種狀況可能形成這種轉換:
set-max-intset-entries
配置的值的時候,也會致使intset轉成dict(具體的觸發條件參見t_set.c中的setTypeAdd
相關代碼)。對於小集合使用intset來存儲,主要的緣由是節省內存。特別是當存儲的元素個數較少的時候,dict所帶來的內存開銷要大得多(包含兩個哈希表、鏈表指針以及大量的其它元數據)。因此,當存儲大量的小集合並且集合元素都是數字的時候,用intset能節省下一筆可觀的內存空間。
實際上,從時間複雜度上比較,intset的平均狀況是沒有dict性能高的。以查找爲例,intset是O(log n)的,而dict能夠認爲是O(1)的。可是,因爲使用intset的時候集合元素個數比較少,因此這個影響不大。
Redis set的並、交、差算法的實現代碼,在t_set.c中。其中計算交集調用的是sinterGenericCommand
,計算並集和差集調用的是sunionDiffGenericCommand
。它們都能同時對多個(能夠多於2個)集合進行運算。當對多個集合進行差集運算時,它表達的含義是:用第一個集合與第二個集合作差集,所得結果再與第三個集合作差集,依次向後類推。
咱們在這裏簡要介紹一下三個算法的實現思路。
計算交集的過程大概能夠分爲三部分:
須要注意的是,上述第3步在集合中進行查找,對於intset和dict的存儲來講時間複雜度分別是O(log n)和O(1)。但因爲只有小集合才使用intset,因此能夠粗略地認爲intset的查找也是常數時間複雜度的。所以,如Redis官方文檔上所說(redis.io/commands/si…),sinter
命令的時間複雜度爲:
O(N*M) worst case where N is the cardinality of the smallest set and M is the number of sets.
計算並集最簡單,只須要遍歷全部集合,將每個元素都添加到最後的結果集合中。向集合中添加元素會自動去重。
因爲要遍歷全部集合的每一個元素,因此Redis官方文檔給出的sunion
命令的時間複雜度爲(redis.io/commands/su…):
O(N) where N is the total number of elements in all given sets.
注意,這裏同前面討論交集計算同樣,將元素插入到結果集合的過程,忽略intset的狀況,認爲時間複雜度爲O(1)。
計算差集有兩種可能的算法,它們的時間複雜度有所區別。
第一種算法:
這種算法的時間複雜度爲O(N*M),其中N是第一個集合的元素個數,M是集合數目。
第二種算法:
這種算法的時間複雜度爲O(N),其中N是全部集合的元素個數總和。
在計算差集的開始部分,會先分別估算一下兩種算法預期的時間複雜度,而後選擇複雜度低的算法來進行運算。還有兩點須要注意:
對於sdiff
的時間複雜度,Redis官方文檔(redis.io/commands/sd…)只給出了第二種算法的結果,是不許確的。
系列下一篇待續,敬請期待。
(完)
其它精選文章: