本篇文章為大家展示了redis中內(nèi)部數(shù)據(jù)結(jié)構(gòu)intset的作用是什么,內(nèi)容簡明扼要并且容易理解,絕對能使你眼前一亮,通過這篇文章的詳細介紹希望你能有所收獲。
創(chuàng)新互聯(lián)專注于上猶企業(yè)網(wǎng)站建設(shè),響應(yīng)式網(wǎng)站,購物商城網(wǎng)站建設(shè)。上猶網(wǎng)站建設(shè)公司,為上猶等地區(qū)提供建站服務(wù)。全流程定制網(wǎng)站設(shè)計,專業(yè)設(shè)計,全程項目跟蹤,創(chuàng)新互聯(lián)專業(yè)和態(tài)度為您提供的服務(wù)
intset顧名思義,是由整數(shù)組成的集合。實際上,intset是一個由整數(shù)組成的有序集合,從而便于在上面進行二分查找,用于快速地判斷一個元素是否屬于這個集合。它在內(nèi)存分配上與 ziplist有些類似,是連續(xù)的一整塊內(nèi)存空間,而且對于大整數(shù)和小整數(shù)(按絕對值)采取了不同的編碼,盡量對內(nèi)存的使用進行了優(yōu)化。
intset的數(shù)據(jù)結(jié)構(gòu)定義如下(出自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
: 數(shù)據(jù)編碼,表示intset中的每個數(shù)據(jù)元素用幾個字節(jié)來存儲。它有三種可能的取值:INTSET_ENC_INT16表示每個元素用2個字節(jié)存儲,INTSET_ENC_INT32表示每個元素用4個字節(jié)存儲,INTSET_ENC_INT64表示每個元素用8個字節(jié)存儲。因此,intset中存儲的整數(shù)最多只能占用64bit。
length
: 表示intset中的元素個數(shù)。encoding
和length
兩個字段構(gòu)成了intset的頭部(header)。
contents
: 是一個柔性數(shù)組(
flexible array member),表示intset的header后面緊跟著數(shù)據(jù)元素。這個數(shù)組的總長度(即總字節(jié)數(shù))等于encoding * length
。柔性數(shù)組在Redis的很多數(shù)據(jù)結(jié)構(gòu)的定義中都出現(xiàn)過(例如
sds,
quicklist,
skiplist),用于表達一個偏移量。contents
需要單獨為其分配空間,這部分內(nèi)存不包含在intset結(jié)構(gòu)當中。
其中需要注意的是,intset可能會隨著數(shù)據(jù)的添加而改變它的數(shù)據(jù)編碼:
最開始,新創(chuàng)建的intset使用占內(nèi)存最小的INTSET_ENC_INT16(值為2)作為數(shù)據(jù)編碼。
每添加一個新元素,則根據(jù)元素大小決定是否對數(shù)據(jù)編碼進行升級。
下圖給出了一個添加數(shù)據(jù)的具體例子(點擊看大圖)。
在上圖中:
新創(chuàng)建的intset只有一個header,總共8個字節(jié)。其中encoding
= 2,
length
= 0。
添加13, 5兩個元素之后,因為它們是比較小的整數(shù),都能使用2個字節(jié)表示,所以encoding
不變,值還是2。
當添加32768的時候,它不再能用2個字節(jié)來表示了(2個字節(jié)能表達的數(shù)據(jù)范圍是-215~215-1,而32768等于215,超出范圍了),因此encoding
必須升級到INTSET_ENC_INT32(值為4),即用4個字節(jié)表示一個元素。
在添加每個元素的過程中,intset始終保持從小到大有序。
與
ziplist類似,intset也是按小端(little endian)模式存儲的(參見維基百科詞條
Endianness)。比如,在上圖中intset添加完所有數(shù)據(jù)之后,表示encoding
字段的4個字節(jié)應(yīng)該解釋成0x00000004,而第5個數(shù)據(jù)應(yīng)該解釋成0x000186A0 = 100000。
intset與 ziplist相比:
ziplist可以存儲任意二進制串,而intset只能存儲整數(shù)。
ziplist是無序的,而intset是從小到大有序的。因此,在ziplist上查找只能遍歷,而在intset上可以進行二分查找,性能更高。
ziplist可以對每個數(shù)據(jù)項進行不同的變長編碼(每個數(shù)據(jù)項前面都有數(shù)據(jù)長度字段len
),而intset只能整體使用一個統(tǒng)一的編碼(encoding
)。
要理解intset的一些實現(xiàn)細節(jié),只需要關(guān)注intset的兩個關(guān)鍵操作基本就可以了:查找(intsetFind
)和添加(intsetAdd
)元素。
intsetFind
的關(guān)鍵代碼如下所示(出自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; } }
關(guān)于以上代碼,我們需要注意的地方包括:
intsetFind
在指定的intset中查找指定的元素value
,找到返回1,沒找到返回0。
_intsetValueEncoding
函數(shù)會根據(jù)要查找的value
落在哪個范圍而計算出相應(yīng)的數(shù)據(jù)編碼(即它應(yīng)該用幾個字節(jié)來存儲)。
如果value
所需的數(shù)據(jù)編碼比當前intset的編碼要大,則它肯定在當前intset所能存儲的數(shù)據(jù)范圍之外(特別大或特別?。赃@時會直接返回0;否則調(diào)用intsetSearch
執(zhí)行一個二分查找算法。
intsetSearch
在指定的intset中查找指定的元素value
,如果找到,則返回1并且將參數(shù)pos
指向找到的元素位置;如果沒找到,則返回0并且將參數(shù)pos
指向能插入該元素的位置。
intsetSearch
是對于二分查找算法的一個實現(xiàn),它大致分為三個部分:
特殊處理intset為空的情況。
特殊處理兩個邊界情況:當要查找的value
比最后一個元素還要大或者比第一個元素還要小的時候。實際上,這兩部分的特殊處理,在二分查找中并不是必須的,但它們在這里提供了特殊情況下快速失敗的可能。
真正執(zhí)行二分查找過程。注意:如果最后沒找到,插入位置在min
指定的位置。
代碼中出現(xiàn)的intrev32ifbe
是為了在需要的時候做大小端轉(zhuǎn)換的。前面我們提到過,intset里的數(shù)據(jù)是按小端(little endian)模式存儲的,因此在大端(big endian)機器上運行時,這里的intrev32ifbe
會做相應(yīng)的轉(zhuǎn)換。
這個查找算法的總的時間復(fù)雜度為O(log n)。
而intsetAdd
的關(guān)鍵代碼如下所示(出自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; }
關(guān)于以上代碼,我們需要注意的地方包括:
intsetAdd
在intset中添加新元素value
。如果value
在添加前已經(jīng)存在,則不會重復(fù)添加,這時參數(shù)success
被置為0;如果value
在原來intset中不存在,則將value
插入到適當位置,這時參數(shù)success
被置為0。
如果要添加的元素value
所需的數(shù)據(jù)編碼比當前intset的編碼要大,那么則調(diào)用intsetUpgradeAndAdd
將intset的編碼進行升級后再插入value
。
調(diào)用intsetSearch
,如果能查到,則不會重復(fù)添加。
如果沒查到,則調(diào)用intsetResize
對intset進行內(nèi)存擴充,使得它能夠容納新添加的元素。因為intset是一塊連續(xù)空間,因此這個操作會引發(fā)內(nèi)存的realloc
(參見
http://man.cx/realloc)。這有可能帶來一次數(shù)據(jù)拷貝。同時調(diào)用intsetMoveTail
將待插入位置后面的元素統(tǒng)一向后移動1個位置,這也涉及到一次數(shù)據(jù)拷貝。值得注意的是,在intsetMoveTail
中是調(diào)用memmove
完成這次數(shù)據(jù)拷貝的。memmove
保證了在拷貝過程中不會造成數(shù)據(jù)重疊或覆蓋,具體參見
http://man.cx/memmove。
intsetUpgradeAndAdd
的實現(xiàn)中也會調(diào)用intsetResize
來完成內(nèi)存擴充。在進行編碼升級時,intsetUpgradeAndAdd
的實現(xiàn)會把原來intset中的每個元素取出來,再用新的編碼重新寫入新的位置。
注意一下intsetAdd
的返回值,它返回一個新的intset指針。它可能與傳入的intset指針is
相同,也可能不同。調(diào)用方必須用這里返回的新的intset,替換之前傳進來的舊的intset變量。類似這種接口使用模式,在Redis的實現(xiàn)代碼中是很常見的,比如我們之前在介紹
sds和
ziplist的時候都碰到過類似的情況。
顯然,這個intsetAdd
算法總的時間復(fù)雜度為O(n)。
為了更好地理解Redis對外暴露的set數(shù)據(jù)結(jié)構(gòu),我們先看一下set的一些關(guān)鍵的命令。下面是一些命令舉例:
上面這些命令的含義:
sadd
用于分別向集合s1
和s2
中添加元素。添加的元素既有數(shù)字,也有非數(shù)字(”a”和”b”)。
sismember
用于判斷指定的元素是否在集合內(nèi)存在。
sinter
,
sunion
和sdiff
分別用于計算集合的交集、并集和差集。
我們前面提到過,set的底層實現(xiàn),隨著元素類型是否是整型以及添加的元素的數(shù)目多少,而有所變化。例如,具體到上述命令的執(zhí)行過程中,集合s1
的底層數(shù)據(jù)結(jié)構(gòu)會發(fā)生如下變化:
在開始執(zhí)行完sadd s1 13 5
之后,由于添加的都是比較小的整數(shù),所以s1
底層是一個intset,其數(shù)據(jù)編碼encoding
= 2。
在執(zhí)行完sadd s1 32768 10 100000
之后,s1
底層仍然是一個intset,但其數(shù)據(jù)編碼encoding
從2升級到了4。
在執(zhí)行完sadd s1 a b
之后,由于添加的元素不再是數(shù)字,s1
底層的實現(xiàn)會轉(zhuǎn)成一個dict。
我們知道,dict是一個用于維護key和value映射關(guān)系的數(shù)據(jù)結(jié)構(gòu),那么當set底層用dict表示的時候,它的key和value分別是什么呢?實際上,key就是要添加的集合元素,而value是NULL。
除了前面提到的由于添加非數(shù)字元素造成集合底層由intset轉(zhuǎn)成dict之外,還有兩種情況可能造成這種轉(zhuǎn)換:
添加了一個數(shù)字,但它無法用64bit的有符號數(shù)來表達。intset能夠表達的最大的整數(shù)范圍為-264~264-1,因此,如果添加的數(shù)字超出了這個范圍,這也會導(dǎo)致intset轉(zhuǎn)成dict。
添加的集合元素個數(shù)超過了set-max-intset-entries
配置的值的時候,也會導(dǎo)致intset轉(zhuǎn)成dict(具體的觸發(fā)條件參見t_set.c中的setTypeAdd
相關(guān)代碼)。
對于小集合使用intset來存儲,主要的原因是節(jié)省內(nèi)存。特別是當存儲的元素個數(shù)較少的時候,dict所帶來的內(nèi)存開銷要大得多(包含兩個哈希表、鏈表指針以及大量的其它元數(shù)據(jù))。所以,當存儲大量的小集合而且集合元素都是數(shù)字的時候,用intset能節(jié)省下一筆可觀的內(nèi)存空間。
實際上,從時間復(fù)雜度上比較,intset的平均情況是沒有dict性能高的。以查找為例,intset是O(log n)的,而dict可以認為是O(1)的。但是,由于使用intset的時候集合元素個數(shù)比較少,所以這個影響不大。
Redis set的并、交、差算法的實現(xiàn)代碼,在t_set.c中。其中計算交集調(diào)用的是sinterGenericCommand
,計算并集和差集調(diào)用的是sunionDiffGenericCommand
。它們都能同時對多個(可以多于2個)集合進行運算。當對多個集合進行差集運算時,它表達的含義是:用第一個集合與第二個集合做差集,所得結(jié)果再與第三個集合做差集,依次向后類推。
我們在這里簡要介紹一下三個算法的實現(xiàn)思路。
計算交集的過程大概可以分為三部分:
檢查各個集合,對于不存在的集合當做空集來處理。一旦出現(xiàn)空集,則不用繼續(xù)計算了,最終的交集就是空集。
對各個集合按照元素個數(shù)由少到多進行排序。這個排序有利于后面計算的時候從最小的集合開始,需要處理的元素個數(shù)較少。
對排序后第一個集合(也就是最小集合)進行遍歷,對于它的每一個元素,依次在后面的所有集合中進行查找。只有在所有集合中都能找到的元素,才加入到最后的結(jié)果集合中。
需要注意的是,上述第3步在集合中進行查找,對于intset和dict的存儲來說時間復(fù)雜度分別是O(log n)和O(1)。但由于只有小集合才使用intset,所以可以粗略地認為intset的查找也是常數(shù)時間復(fù)雜度的。因此,如Redis官方文檔上所說(
http://redis.io/commands/sinter),sinter
命令的時間復(fù)雜度為:
O(N*M) worst case where N is the cardinality of the smallest set and M is the number of sets.
計算并集最簡單,只需要遍歷所有集合,將每一個元素都添加到最后的結(jié)果集合中。向集合中添加元素會自動去重。
由于要遍歷所有集合的每個元素,所以Redis官方文檔給出的sunion
命令的時間復(fù)雜度為(
http://redis.io/commands/sunion):
O(N) where N is the total number of elements in all given sets.
注意,這里同前面討論交集計算一樣,將元素插入到結(jié)果集合的過程,忽略intset的情況,認為時間復(fù)雜度為O(1)。
計算差集有兩種可能的算法,它們的時間復(fù)雜度有所區(qū)別。
第一種算法:
對第一個集合進行遍歷,對于它的每一個元素,依次在后面的所有集合中進行查找。只有在所有集合中都找不到的元素,才加入到最后的結(jié)果集合中。
這種算法的時間復(fù)雜度為O(N*M),其中N是第一個集合的元素個數(shù),M是集合數(shù)目。
第二種算法:
將第一個集合的所有元素都加入到一個中間集合中。
遍歷后面所有的集合,對于碰到的每一個元素,從中間集合中刪掉它。
最后中間集合剩下的元素就構(gòu)成了差集。
這種算法的時間復(fù)雜度為O(N),其中N是所有集合的元素個數(shù)總和。
在計算差集的開始部分,會先分別估算一下兩種算法預(yù)期的時間復(fù)雜度,然后選擇復(fù)雜度低的算法來進行運算。還有兩點需要注意:
在一定程度上優(yōu)先選擇第一種算法,因為它涉及到的操作比較少,只用添加,而第二種算法要先添加再刪除。
如果選擇了第一種算法,那么在執(zhí)行該算法之前,Redis的實現(xiàn)中對于第二個集合之后的所有集合,按照元素個數(shù)由多到少進行了排序。這個排序有利于以更大的概率查找到元素,從而更快地結(jié)束查找。
上述內(nèi)容就是Redis中內(nèi)部數(shù)據(jù)結(jié)構(gòu)intset的作用是什么,你們學(xué)到知識或技能了嗎?如果還想學(xué)到更多技能或者豐富自己的知識儲備,歡迎關(guān)注創(chuàng)新互聯(lián)行業(yè)資訊頻道。