Redis內部資料結構詳解——intset
本文是《Redis內部資料結構詳解》系列的第七篇。在本文中,我們圍繞一個Redis的內部資料結構——intset展開討論。
Redis裡面使用intset是為了實現集合(set)這種對外的資料結構。set結構類似於數學上的集合的概念,它包含的元素無序,且不能重複。Redis裡的set結構還實現了基礎的集合並、交、差的操作。與Redis對外暴露的其它資料結構類似,set的底層實現,隨著元素型別是否是整型以及新增的元素的數目多少,而有所變化。概括來講,當set中新增的元素都是整型且元素數目較少時,set使用intset作為底層資料結構,否則,set使用dict作為底層資料結構。
在本文中我們將大體分成三個部分進行介紹:
- 集中介紹intset資料結構。
- 討論set是如何在intset和dict基礎上構建起來的。
- 集中討論set的並、交、差的演算法實現以及時間複雜度。注意,其中差集的計算在Redis中實現了兩種演算法。
我們在討論中還會涉及到一個Redis配置(在redis.conf中的ADVANCED CONFIG部分):
set-max-intset-entries 512
注:本文討論的程式碼實現基於Redis原始碼的3.2分支。
intset資料結構簡介
intset顧名思義,是由整陣列成的集合。實際上,intset是一個由整陣列成的有序集合,從而便於在上面進行二分查詢,用於快速地判斷一個元素是否屬於這個集合。它在記憶體分配上與
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))
各個欄位含義如下:
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可能會隨著資料的新增而改變它的資料編碼:
- 最開始,新建立的intset使用佔記憶體最小的INTSET_ENC_INT16(值為2)作為資料編碼。
- 每新增一個新元素,則根據元素大小決定是否對資料編碼進行升級。
下圖給出了一個新增資料的具體例子(點選看大圖)。
在上圖中:
- 新建立的intset只有一個header,總共8個位元組。其中
encoding
= 2,length
= 0。 - 新增13, 5兩個元素之後,因為它們是比較小的整數,都能使用2個位元組表示,所以
encoding
不變,值還是2。 - 當新增32768的時候,它不再能用2個位元組來表示了(2個位元組能表達的資料範圍是-215~215-1,而32768等於215,超出範圍了),因此
encoding
必須升級到INTSET_ENC_INT32(值為4),即用4個位元組表示一個元素。 - 在新增每個元素的過程中,intset始終保持從小到大有序。
- 與ziplist類似,intset也是按小端(little endian)模式儲存的(參見維基百科詞條Endianness)。比如,在上圖中intset新增完所有資料之後,表示
encoding
欄位的4個位元組應該解釋成0x00000004,而第5個數據應該解釋成0x000186A0 = 100000。
intset與ziplist相比:
- ziplist可以儲存任意二進位制串,而intset只能儲存整數。
- ziplist是無序的,而intset是從小到大有序的。因此,在ziplist上查詢只能遍歷,而在intset上可以進行二分查詢,效能更高。
- ziplist可以對每個資料項進行不同的變長編碼(每個資料項前面都有資料長度欄位
len
),而intset只能整體使用一個統一的編碼(encoding
)。
intset的查詢和新增操作
要理解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
是對於二分查詢演算法的一個實現,它大致分為三個部分:- 特殊處理intset為空的情況。
- 特殊處理兩個邊界情況:當要查詢的
value
比最後一個元素還要大或者比第一個元素還要小的時候。實際上,這兩部分的特殊處理,在二分查詢中並不是必須的,但它們在這裡提供了特殊情況下快速失敗的可能。 - 真正執行二分查詢過程。注意:如果最後沒找到,插入位置在
min
指定的位置。
- 程式碼中出現的
intrev32ifbe
是為了在需要的時候做大小端轉換的。前面我們提到過,intset裡的資料是按小端(little endian)模式儲存的,因此在大端(big endian)機器上執行時,這裡的intrev32ifbe
會做相應的轉換。 - 這個查詢演算法的總的時間複雜度為O(log n)。
而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
(參見http://man.cx/realloc)。這有可能帶來一次資料拷貝。同時呼叫intsetMoveTail
將待插入位置後面的元素統一向後移動1個位置,這也涉及到一次資料拷貝。值得注意的是,在intsetMoveTail
中是呼叫memmove
完成這次資料拷貝的。memmove
保證了在拷貝過程中不會造成資料重疊或覆蓋,具體參見http://man.cx/memmove。 intsetUpgradeAndAdd
的實現中也會呼叫intsetResize
來完成記憶體擴充。在進行編碼升級時,intsetUpgradeAndAdd
的實現會把原來intset中的每個元素取出來,再用新的編碼重新寫入新的位置。- 注意一下
intsetAdd
的返回值,它返回一個新的intset指標。它可能與傳入的intset指標is
相同,也可能不同。呼叫方必須用這裡返回的新的intset,替換之前傳進來的舊的intset變數。類似這種介面使用模式,在Redis的實現程式碼中是很常見的,比如我們之前在介紹sds和ziplist的時候都碰到過類似的情況。 - 顯然,這個
intsetAdd
演算法總的時間複雜度為O(n)。
Redis的set
為了更好地理解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之外,還有兩種情況可能造成這種轉換:
- 添加了一個數字,但它無法用64bit的有符號數來表達。intset能夠表達的最大的整數範圍為-264~264-1,因此,如果新增的數字超出了這個範圍,這也會導致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的並、交、差演算法
Redis set的並、交、差演算法的實現程式碼,在t_set.c中。其中計算交集呼叫的是sinterGenericCommand
,計算並集和差集呼叫的是sunionDiffGenericCommand
。它們都能同時對多個(可以多於2個)集合進行運算。當對多個集合進行差集運算時,它表達的含義是:用第一個集合與第二個集合做差集,所得結果再與第三個集合做差集,依次向後類推。
我們在這裡簡要介紹一下三個演算法的實現思路。
交集
計算交集的過程大概可以分為三部分:
- 檢查各個集合,對於不存在的集合當做空集來處理。一旦出現空集,則不用繼續計算了,最終的交集就是空集。
- 對各個集合按照元素個數由少到多進行排序。這個排序有利於後面計算的時候從最小的集合開始,需要處理的元素個數較少。
- 對排序後第一個集合(也就是最小集合)進行遍歷,對於它的每一個元素,依次在後面的所有集合中進行查詢。只有在所有集合中都能找到的元素,才加入到最後的結果集合中。
需要注意的是,上述第3步在集合中進行查詢,對於intset和dict的儲存來說時間複雜度分別是O(log n)和O(1)。但由於只有小集合才使用intset,所以可以粗略地認為intset的查詢也是常數時間複雜度的。因此,如Redis官方文件上所說(http://redis.io/commands/sinter),sinter
命令的時間複雜度為:
O(N*M) worst case where N is the cardinality of the smallest set and M is the number of sets.
並集
計算並集最簡單,只需要遍歷所有集合,將每一個元素都新增到最後的結果集合中。向集合中新增元素會自動去重。
由於要遍歷所有集合的每個元素,所以Redis官方文件給出的sunion
命令的時間複雜度為(http://redis.io/commands/sunion):
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是所有集合的元素個數總和。
在計算差集的開始部分,會先分別估算一下兩種演算法預期的時間複雜度,然後選擇複雜度低的演算法來進行運算。還有兩點需要注意:
- 在一定程度上優先選擇第一種演算法,因為它涉及到的操作比較少,只用新增,而第二種演算法要先新增再刪除。
- 如果選擇了第一種演算法,那麼在執行該演算法之前,Redis的實現中對於第二個集合之後的所有集合,按照元素個數由多到少進行了排序。這個排序有利於以更大的概率查詢到元素,從而更快地結束查詢。
對於sdiff
的時間複雜度,Redis官方文件(http://redis.io/commands/sdiff)只給出了第二種演算法的結果,是不準確的。