騰訊高級面試題:請談談Redis 9種數據結構以及它們的內部編碼實現

90%的人知道Redis 5種最基本的數據結構,只有不到10%的人知道8種基本數據結構(5種基本+bitmap+GeoHash+HyperLogLog),只有不到5%的人知道9種基本數據結構(5.0最新版本數據結構Streams),只有不到1%的人掌握了所有9種基本數據結構以及8種內部編碼,掌握這篇文章的知識點,讓你成爲面試官眼中Redis方面最靚的仔

說明:本文基於Redis-3.2.11版本源碼進行分析。

5種普通數據結構

這個沒什麼好說的,對Redis稍微有點了解的都知道5種最基本的數據結構:String,List,Hash,Set,Sorted Set。不過,需要注意的是,這裏依然有幾個高頻面試題。

  • Set和Hash的關係

答案就是Set是一個特殊的value爲空的Hash。Set類型操作的源碼在t_set.c中。以新增一個元素爲例(int setTypeAdd(robj *subject, sds value)),如果編碼類型是OBJ_ENCODING_HT,那麼新增源碼的源碼如下,事實上就是對dict即Hash數據結構進行操作,並且dictSetVal時value是NULL:

dictEntry*de=dictAddRaw(ht,value,NULL);
if(de){
dictSetKey(ht,de,sdsdup(value));
dictSetVal(ht,de,NULL);
return1;
}

同樣的,我們在t_hash.c中看到Hash類型新增元素時,當判斷編碼類型是OBJ_ENCODING_HT時,也是調用dict的方法:dictAdd(o->ptr,f,v),dictAdd最終也是調用dictSetVal()方法,只不過v即value不爲NULL:

/*Addanelementtothetargethashtable*/
intdictAdd(dict*d,void*key,void*val)
{
dictEntry*entry=dictAddRaw(d,key,NULL);

if(!entry)returnDICT_ERR;
dictSetVal(d,entry,val);
returnDICT_OK;
}

所以,Redis中Set和Hash的關係就很清楚了,當編碼是OBJ_ENCODING_HT時,兩者都是dict數據類型,只不過Set是value爲NULL的特殊的dict。

  • 談談你對Sorted Set的理解

Sorted Set的數據結構是一種跳錶,即SkipList,如下圖所示,紅線是查找10的過程:

 

頭條高級面試題:請談談Redis 9種數據結構以及它們的內部編碼實現

SkipList

  • 如何藉助Sorted set實現多維排序

Sorted Set默認情況下只能根據一個因子score進行排序。如此一來,侷限性就很大,舉個栗子:熱門排行榜需要按照下載量&最近更新時間排序,即類似數據庫中的ORDER BY download_count, update_time DESC。那這樣的需求如果用Redis的Sorted Set實現呢?

事實上很簡單,思路就是將涉及排序的多個維度的列通過一定的方式轉換成一個特殊的列,即result = function(x, y, z),即x,y,z是三個排序因子,例如下載量、時間等,通過自定義函數function()計算得到result,將result作爲Sorted Set中的score的值,就能實現任意維度的排序需求了。可以參考筆者之前的文章:《Redis高級玩法:如何利用SortedSet實現多維度排序》。

Redis內部編碼

我們常說的String,List,Hash,Set,Sorted Set只是對外的編碼,實際上每種數據結構都有自己底層的內部編碼實現,而且是多種實現,這樣Redis可以在合適的場景選擇更合適的內部編碼。

如下圖所示(圖片糾正:intset編碼,而不是inset編碼),可以看到每種數據結構都有2種以上的內部編碼實現,例如String數據結構就包含了raw、int和embstr三種內部編碼。同時,有些內部編碼可以作爲多種外部數據結構的內部實現,例如ziplist就是hash、list和zset共有的內部編碼,而set的內部編碼可能是hashtable或者intset:

頭條高級面試題:請談談Redis 9種數據結構以及它們的內部編碼實現

Redis內部編碼

 

Redis這樣設計有兩個好處:

  1. 可以偷偷的改進內部編碼,而對外的數據結構和命令沒有影響,這樣一旦開發出更優秀的內部編碼,無需改動對外數據結構和命令。
  2. 多種內部編碼實現可以在不同場景下發揮各自的優勢。例如ziplist比較節省內存,但是在列表元素比較多的情況下,性能會有所下降。這時候Redis會根據配置選項將列表類型的內部實現轉換爲linkedlist。

String的3種內部編碼

由上圖可知,String的3種內部編碼分別是:int、embstr、raw。int類型很好理解,當一個key的value是整型時,Redis就將其編碼爲int類型(另外還有一個條件:把這個value當作字符串來看,它的長度不能超過20)。如下所示。這種編碼類型爲了節省內存。Redis默認會緩存10000個整型值(#define OBJ_SHARED_INTEGERS 10000),這就意味着,如果有10個不同的KEY,其value都是10000以內的值,事實上全部都是共享同一個對象:

127.0.0.1:6379>setnumber"7890"
OK
127.0.0.1:6379>objectencodingnumber
"int"

接下來就是ebmstr和raw兩種內部編碼的長度界限,請看下面的源碼:

#defineOBJ_ENCODING_EMBSTR_SIZE_LIMIT44
robj*createStringObject(constchar*ptr,size_tlen){
if(len<=OBJ_ENCODING_EMBSTR_SIZE_LIMIT)
returncreateEmbeddedStringObject(ptr,len);
else
returncreateRawStringObject(ptr,len);
}

也就是說,embstr和raw編碼的長度界限是44,我們可以做如下驗證。長度超過44以後,就是raw編碼類型,不會有任何優化,是多長,就要消耗多少內存:

127.0.0.1:6379>setname"a1234567890123456789012345678901234567890123"
OK
127.0.0.1:6379>objectencodingname
"embstr"
127.0.0.1:6379>setname"a12345678901234567890123456789012345678901234"
OK
127.0.0.1:6379>objectencodingname
"raw"

那麼爲什麼有embstr編碼呢?它相比raw的優勢在哪裏?embstr編碼將創建字符串對象所需的空間分配的次數從raw編碼的兩次降低爲一次。因爲embstr編碼的字符串對象的所有數據都保存在一塊連續的內存裏面,所以這種編碼的字符串對象比起raw編碼的字符串對象能更好地利用緩存帶來的優勢。並且釋放embstr編碼的字符串對象只需要調用一次內存釋放函數,而釋放raw編碼對象的字符串對象需要調用兩次內存釋放函數。如下圖所示,左邊是embstr編碼,右邊是raw編碼:

 

頭條高級面試題:請談談Redis 9種數據結構以及它們的內部編碼實現

embstr V.S. raw

 

ziplist

由前面的圖可知,List,Hash,Sorted Set三種對外結構,在特殊情況下的內部編碼都是ziplist,那麼這個ziplist有什麼神奇之處呢?

以Hash爲例,我們首先看一下什麼條件下它的內部編碼是ziplist:

  1. 當哈希類型元素個數小於hash-max-ziplist-entries配置(默認512個);
  2. 所有值都小於hash-max-ziplist-value配置(默認64個字節);

如果是sorted set的話,同樣需要滿足兩個條件:

  1. 元素個數小於zset-max-ziplist-entries配置,默認128;
  2. 所有值都小於zset-max-ziplist-value配置,默認64。

實際上,ziplist充分體現了Redis對於存儲效率的追求。一個普通的雙向鏈表,鏈表中每一項都佔用獨立的一塊內存,各項之間用地址指針(或引用)連接起來。這種方式會帶來大量的內存碎片,而且地址指針也會佔用額外的內存。而ziplist卻是將表中每一項存放在前後連續的地址空間內,一個ziplist整體佔用一大塊內存。它是一個表(list),但其實不是一個鏈表(linked list)。

ziplist的源碼在ziplist.c這個文件中,其中有一段這樣的描述 -- The general layout of the ziplist is as follows::

<zlbytes><zltail><zllen><entry><entry>...<entry><zlend>
  • zlbytes:表示這個ziplist佔用了多少空間,或者說佔了多少字節,這其中包括了zlbytes本身佔用的4個字節;
  • zltail:表示到ziplist中最後一個元素的偏移量,有了這個值,pop操作的時間複雜度就是O(1)了,即不需要遍歷整個ziplist;
  • zllen:表示ziplist中有多少個entry,即保存了多少個元素。由於這個字段佔用16個字節,所以最大值是2^16-1,也就意味着,如果entry的數量超過2^16-1時,需要遍歷整個ziplist才知道entry的數量;
  • entry:真正保存的數據,有它自己的編碼;
  • zlend:專門用來表示ziplist尾部的特殊字符,佔用8個字節,值固定爲255,即8個字節每一位都是1。

如下就是一個真實的ziplist編碼,包含了2和5兩個元素:

[0f000000][0c000000][0200][00f3][02f6][ff]
||||||
zlbyteszltailentries"2""5"end

linkedlist

這是List的一種編碼數據結構非常簡單,就是我們非常熟悉的雙向鏈表,對應Java中的LinkedList。

skiplist

這個前面也已經提及,就是經典的跳錶數據結構。

hashtable

這個也很容易,對應Java中的HashMap。

intset

Set特殊內部編碼,當滿足下面的條件時Set的內部編碼就是intset而不是hashtable:

  1. Set集合中必須是64位有符號的十進制整型;
  2. 元素個數不能超過set-max-intset-entries配置,默認512;

驗證如下:

127.0.0.1:6379>saddscores135
(integer)0
127.0.0.1:6379>saddscores128
(integer)1
127.0.0.1:6379>objectencodingscores
"intset"

那麼intset編碼到底是個什麼東西呢?看它的源碼定義如下,很明顯,就是整型數組,並且是一個有序的整型數組。它在內存分配上與ziplist有些類似,是連續的一整塊內存空間,而且對於大整數和小整數採取了不同的編碼,儘量對內存的使用進行了優化。這樣的數據結構,如果執行SISMEMBER命令,即查看某個元素是否在集合中時,事實上使用的是二分查找法:

typedefstructintset{
uint32_tencoding;
uint32_tlength;
int8_tcontents[];
}intset;

// intset編碼查找方法源碼(人爲簡化),標準的二分查找法:
staticuint8_tintsetSearch(intset*is,int64_tvalue,uint32_t*pos){
intmin=0,max=intrev32ifbe(is->length)-1,mid=-1;
int64_tcur=-1;

while(max>=min){
mid=((unsignedint)min+(unsignedint)max)>>1;
cur=_intsetGet(is,mid);
if(value>cur){
min=mid+1;
}elseif(value<cur){
max=mid-1;
}else{
break;
}
}

if(value==cur){
if(pos)*pos=mid;
return1;
}else{
if(pos)*pos=min;
return0;
}
}

#defineINTSET_ENC_INT16(sizeof(int16_t))
#defineINTSET_ENC_INT32(sizeof(int32_t))
#defineINTSET_ENC_INT64(sizeof(int64_t))

 

3種高級數據結構

Redis中3種高級數據結構分別是bitmap、GEO、HyperLogLog,針對這3種數據結構,筆者之前也有文章介紹過。其中,最重要的就是bitmap

bitmap

這個就是Redis實現的BloomFilter,BloomFilter非常簡單,如下圖所示,假設已經有3個元素a、b和c,分別通過3個hash算法h1()、h2()和h2()計算然後對一個bit進行賦值,接下來假設需要判斷d是否已經存在,那麼也需要使用3個hash算法h1()、h2()和h2()對d進行計算,然後得到3個bit的值,恰好這3個bit的值爲1,這就能夠說明:d可能存在集合中。再判斷e,由於h1(e)算出來的bit之前的值是0,那麼說明:e一定不存在集合中

頭條高級面試題:請談談Redis 9種數據結構以及它們的內部編碼實現

BloomFilter

需要說明的是,bitmap並不是一種真實的數據結構,它本質上是String數據結構,只不過操作的粒度變成了位,即bit。因爲String類型最大長度爲512MB,所以bitmap最多可以存儲2^32個bit。

GEO

GEO數據結構可以在Redis中存儲地理座標,並且座標有限制,由EPSG:900913 / EPSG:3785 / OSGEO:41001 規定如下:

  1. 有效的經度從-180度到180度。
  2. 有效的緯度從-85.05112878度到85.05112878度。

當座標位置超出上述指定範圍時,該命令將會返回一個錯誤。添加地理位置命令如下:

redis>GEOADDcity114.03104022.324386"shenzhen"112.57215422.267832"guangzhou"
(integer)2
redis>GEODISTcityshenzhenguangzhou
"150265.8106"

但是,需要說明的是,Geo本身不是一種數據結構,它本質上還是藉助於Sorted Set(ZSET),並且使用GeoHash技術進行填充。Redis中將經緯度使用52位的整數進行編碼,放進zset中,score就是GeoHash的52位整數值。在使用Redis進行Geo查詢時,其內部對應的操作其實就是zset(skiplist)的操作。通過zset的score進行排序就可以得到座標附近的其它元素,通過將score還原成座標值就可以得到元素的原始座標。

總之,Redis中處理這些地理位置座標點的思想是:二維平面座標點 --> 一維整數編碼值 --> zset(score爲編碼值) --> zrangebyrank(獲取score相近的元素)、zrangebyscore --> 通過score(整數編碼值)反解座標點 --> 附近點的地理位置座標。

  • GEOHASH原理

使用wiki上的例子,緯度爲42.6,經度爲-5.6的點,轉化爲base32的話要如何轉呢?
首先拿緯度來進行說明,緯度的範圍爲-90到90,將這個範圍劃爲兩段,則爲[-90,0]、[0,90],然後看給定的緯度在哪個範圍,在前面的範圍的話,就設當前位爲0,後面的話值便爲1.然後繼續將確定的範圍1分爲2,繼續以確定值在前段還是後段來確定bit的值。就這樣慢慢的縮小範圍,一般最多縮小13次就可以了(經緯度的二進制位相加最多25位,經度13位,緯度12位)。這時的中間值,將跟給定的值最相近。如下圖所示:

頭條高級面試題:請談談Redis 9種數據結構以及它們的內部編碼實現

Geohash

第1行,緯度42.6位於[0, 90]之間,所以bit=1;第2行,緯度42.6位於[0, 45]之間,所以bit=0;第3行,緯度42.6位於[22.5, 45]之間,所以bit=1,以此類推。這樣,取出圖中的bit位:1011 1100 1001,同樣的方法,將經度(範圍-180到180)算出來爲 :0111 1100 0000 0。結果對其如下:

#經度
0111110000000
#緯度
101111001001

得到了經緯度的二進制位後,下面需要將兩者進行結合:從經度、緯度的循環,每次取其二進制的一位(不足位取0),合併爲新的二進制數:01101111 11110000 01000001 0。每5位爲一個十進制數,結合base32對應表映射爲base32值爲:ezs42。這樣就完成了encode的過程。

Streams

這是Redis5.0引入的全新數據結構,這種數據結構筆者之前也有文章對其進行詳細解讀,鏈接地址:《Streams:深入剖析Redis5.0全新數據結構》,用一句話概括Streams就是Redis實現的內存版kafka。而且,Streams也有Consumer Groups的概念。通過Redis源碼中對stream的定義我們可知,streams底層的數據結構是radix tree

typedefstructstream{
rax*rax;/*Theradixtreeholdingthestream.*/
uint64_tlength;/*Numberofelementsinsidethisstream.*/
streamIDlast_id;/*Zeroifthereareyetnoitems.*/
rax*cgroups;/*Consumergroupsdictionary:name->streamCG*/
}stream;

那麼這個radix tree長啥樣呢?在Redis源碼的rax.h文件中有一段這樣的描述,這樣看起來是不是就比較直觀了:

*(f)""
*/
*(io)"f"
*/\
*"firs"("rst")(o)"fo"
*/\
*"first"[][tb]"foo"
*/\
*"foot"("er")("ar")"foob"
*/\
*"footer"[][]"foobar"

Radix Tree(基數樹) 事實上就幾乎相同是傳統的二叉樹。僅僅是在尋找方式上,以一個unsigned int類型數爲例,利用這個數的每個比特位作爲樹節點的推斷。能夠這樣說,比方一個數10001010101010110101010,那麼依照Radix 樹的插入就是在根節點,假設遇到0,就指向左節點,假設遇到1就指向右節點,在插入過程中構造樹節點,在刪除過程中刪除樹節點。如下是一個保存了7個單詞的Radix Tree:

頭條高級面試題:請談談Redis 9種數據結構以及它們的內部編碼實現

素質三連後可私信"學習”免費獲取更多資料

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章