Redis數據類型


五大數據類型

string (動態字符串)

hash (哈希表、壓縮鏈表)

list (雙端鏈表、壓縮鏈表)

set (哈希表、整數集合)

zset (跳表、壓縮鏈表)

動態字符串

存儲數字用int類型編碼(8字節長整型)、存非數字小於39字節用embstr、大於39字節大於用raw編碼。

struct sdsdr{
	int len;// o(1)時間獲取字符長度  空間換時間
	int free; // 惰性釋放, 將多余的內存記錄下來
	char buf[]; 
}

壓縮鏈表

ziplist的結構

...

  • zlbytes:32bit無符號整數,表示ziplist占用的字節總數(包括本身占用的4個字節);
  • zltail:32bit無符號整數,記錄最后一個entry的偏移量,方便快速定位到最后一個entry;
  • zllen:16bit無符號整數,記錄entry的個數;
  • entry:存儲的若干個元素,可以為字節數組或者整數;
  • zlend:ziplist最后一個字節,是一個結束的標記位,值固定為255。
// 假設char *zl 指向ziplist首地址
// 指向zlbytes字段
#define ZIPLIST_BYTES(zl)       (*((uint32_t*)(zl)))

// 指向zltail字段(zl+4)
#define ZIPLIST_TAIL_OFFSET(zl) (*((uint32_t*)((zl)+sizeof(uint32_t))))

// 指向zllen字段(zl+(4*2))
#define ZIPLIST_LENGTH(zl)      (*((uint16_t*)((zl)+sizeof(uint32_t)*2)))

// 指向ziplist中尾元素的首地址
#define ZIPLIST_ENTRY_TAIL(zl)  ((zl)+intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl)))

// 指向zlend字段,指恆為255(0xFF)
#define ZIPLIST_ENTRY_END(zl)   ((zl)+intrev32ifbe(ZIPLIST_BYTES(zl))-1)

entry的結構

<prevlen> <encoding> <entry-data>
  • prevlen:前一個元素的字節長度,便於快速找到前一個元素的首地址,假如當前元素的首地址是x,那么(x-prevlen)就是前一個元素的首地址。

  • encoding:當前元素的編碼,Redis根據encoding字段的前兩位來判斷存儲的數據是字符串(字節數組)還是整型,如果是字符串,還可以通過encoding字段的前兩位來判斷字符串的長度;如果是整形,則要通過后面的位來判斷具體長度;

    1. 00xxxxxx 最大長度位 63 的短字符串,后面的6個位存儲字符串的位數;
    2. 01xxxxxx xxxxxxxx 中等長度的字符串,后面14個位來表示字符串的長度;
    3. 10000000 aaaaaaaa bbbbbbbb cccccccc dddddddd 特大字符串,需要使用額外 4 個字節來表示長度。第一個字節前綴是10,剩余 6 位沒有使用,統一置為零;
    4. 11000000 表示 int16;
    5. 11010000 表示 int32;
    6. 11100000 表示 int64;
    7. 11110000 表示 int24;
    8. 11111110 表示 int8;
    9. 11111111 表示 ziplist 的結束,也就是 zlend 的值 0xFF;
    10. 1111xxxx 表示極小整數,xxxx 的范圍只能是 (0001~1101), 也就是1~13
  • entry-data:實際存儲的數據。

/* We use this function to receive information about a ziplist entry.
 * Note that this is not how the data is actually encoded, is just what we
 * get filled by a function in order to operate more easily. */
typedef struct zlentry {
    unsigned int prevrawlensize; /* Bytes used to encode the previous entry len*/
    unsigned int prevrawlen;     /* Previous entry len. */
    unsigned int lensize;        /* Bytes used to encode this entry type/len.
                                    For example strings have a 1, 2 or 5 bytes
                                    header. Integers always use a single byte.*/
    unsigned int len;            /* Bytes used to represent the actual entry.
                                    For strings this is just the string length
                                    while for integers it is 1, 2, 3, 4, 8 or
                                    0 (for 4 bit immediate) depending on the
                                    number range. */
    unsigned int headersize;     /* prevrawlensize + lensize. */
    unsigned char encoding;      /* Set to ZIP_STR_* or ZIP_INT_* depending on
                                    the entry encoding. However for 4 bits
                                    immediate integers this can assume a range
                                    of values and must be range-checked. */
    unsigned char *p;            /* Pointer to the very start of the entry, that
                                    is, this points to prev-entry-len field. */
} zlentry;

我們看到結構體定義了7個字段,而上面entry結構體3個字段。回顧壓縮列表元素的編碼結構,可變因素實際上不止三個;previous_entry_length字段的長度(字段prevrawlensize表示)、previous_entry_length字段存儲的內容(字段prevrawlen表示)、encoding字段的長度(字段lensize表示)、encoding字段的內容(字段len表示數據內容長度,字段encoding表示數據類型)、和當前元素首地址(字段p表示)。而headersize字段則表示當前元素的首部長度,即previous_entry_length字段長度與encoding字段長度之和。

創建壓縮列表

創建壓縮列表的API定義如下,函數無輸入參數,返回參數為壓縮列表首地址:

unsigned char *ziplistNew(void);
創建空的壓縮列表,只需要分配初始存儲空間(11=4+4+2+1個字節),並對zlbytes、zltail、zllen和zlend字段初始化即可。
unsigned char *ziplistNew(void) {
    //ZIPLIST_HEADER_SIZE = zlbytes + zltail + zllen;
    unsigned int bytes = ZIPLIST_HEADER_SIZE+1;        
    unsigned char *zl = zmalloc(bytes);
 
    ZIPLIST_BYTES(zl) = intrev32ifbe(bytes);
    ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(ZIPLIST_HEADER_SIZE);
    ZIPLIST_LENGTH(zl) = 0;
    
    //結尾標識0XFF
    zl[bytes-1] = ZIP_END;             
    return zl;
}

插入元素

壓縮列表插入元素的API定義如下,函數輸入參數zl表示壓縮列表首地址,p指向新元素的插入位置,s表示數據內容,slen表示數據長度,返回參數為壓縮列表首地址。

unsigned char *ziplistInsert(unsigned char *zl, unsigned char *p, unsigned char *s, unsigned int slen);

插入元素時,可以簡要分為三個步驟:第一步需要將元素內容編碼為壓縮列表的元素,第二步重新分配空間,第三步拷貝數據。下面分別討論每個步驟的實現邏輯。

    1. 編碼

編碼即計算previous_entry_length字段、encoding字段和content字段的內容。如何獲取前一個元素的長度呢?這時候就需要根據插入元素的位置分情況討論了,如圖所示:

image

當壓縮列表為空插入位置為P0時,此時不存在前一個元素,即前一個元素的長度為0;
  當插入位置為P1時,此時需要獲取entryX元素的長度,而entryX+1元素的previous_entry_length字段存儲的就是entryX元素的長度,比較容易獲取;
  當插入位置為P2時,此時需要獲取entryN元素的長度,entryN是壓縮列表的尾元素,計算其元素長度需要將其三個字段長度相加,函數實現如下:

unsigned int zipRawEntryLength(unsigned char *p) {
    unsigned int prevlensize, encoding, lensize, len;
    ZIP_DECODE_PREVLENSIZE(p, prevlensize);
    ZIP_DECODE_LENGTH(p + prevlensize, encoding, lensize, len);
    return prevlensize + lensize + len;
}

其中ZIP_DECODE_PREVLENSIZE和ZIP_DECODE_LENGTH在2.2節已經講過,這里不再贅述。
  encoding字段標識的是當前元素存儲的數據類型以及數據長度,編碼時首先會嘗試將數據內容解析為整數,如果解析成功則按照壓縮列表整數類型編碼存儲,解析失敗的話按照壓縮列表字節數組類型編碼存儲。

if (zipTryEncoding(s,slen,&value,&encoding)) {
    reqlen = zipIntSize(encoding);
} else {
    reqlen = slen;
}
 
reqlen += zipStorePrevEntryLength(NULL,prevlen);
reqlen += zipStoreEntryEncoding(NULL,encoding,slen);

程序首先嘗試按照整數解析新添加元素的數據內容,數值存儲在變量value,編碼存儲在變量encoding。如果解析成功,還需要計算整數所占字節數。
  變量reqlen最終存儲的是當前元素所需空間大小,初始賦值為元素content字段所需空間大小,再累加previous_entry_length所需空間大小與encoding字段所需空間大小。

    1. 重新分配空間

image

由於新插入元素,壓縮列表所需空間增大,因此需要重新分配存儲空間。那么空間大小是否是添加元素前的壓縮列表長度與新添加元素元素長度之和呢?並不完全是,如圖中所示的例子。
  插入元素前,entryX元素長度為128字節,entryX+1元素的previous_entry_length字段占1個字節;添加元素entryNEW元素,元素長度為1024字節,此時entryX+1元素的previous_entry_length字段需要占5個字節;即壓縮列表的長度不僅僅是增加了1024字節,還有entryX+1元素擴展的4字節。我們很容易知道,entryX+1元素長度可能增加4字節,也可能減小4字節,也可能不變。而由於重新分配空間,新元素插入的位置指針P會失效,因此需要預先計算好指針P相對於壓縮列表首地址的偏移量,待分配空間之后再偏移即可。

size_t curlen = intrev32ifbe(ZIPLIST_BYTES(zl));
 
int forcelarge = 0;
nextdiff = (p[0] != ZIP_END) ? zipPrevLenByteDiff(p,reqlen) : 0;
if (nextdiff == -4 && reqlen < 4) {
    nextdiff = 0;
    forcelarge = 1;
}
 
//存儲偏移量
offset = p-zl;
//調用realloc重新分配空間
zl = ziplistResize(zl,curlen+reqlen+nextdiff);
//重新偏移到插入位置P
p = zl+offset;

那么nextdiff與forcelarge在這里有什么用呢?分析ziplistResize函數的3個輸入參數,curlen表示插入元素前壓縮列表的長度,reqlen表示插入元素元素的長度,而nextdiff表示的是entryX+1元素長度的變化,取值可能為0(長度不變)、4(長度增加4)和-4(長度減小4)。我們再思考下,當nextdiff等於-4,而reqlen小於4時會發生什么呢?沒錯,插入元素導致壓縮列表所需空間減少了,即函數ziplistResize底層調用realloc重新分配的空間小於指針zl指向的空間。這可能會存在問題,我們都知道realloc重新分配空間時,返回的地址可能不變,當重新分配的空間大小反而減少時,realloc底層實現可能會將多余的空間回收,此時可能會導致數據的丟失。因此需要避免這種情況的發生,即重新賦值nextdiff等於0,同時使用forcelarge標記這種情況。
  可以再思考下,nextdiff等於-4時,reqlen會小於4嗎?答案是可能的,連鎖更新可能會導致這種情況的發生。連鎖更新將在第4節介紹。

    1. 數據拷貝

重新分配空間之后,需要將位置P后的元素移動到指定位置,將新元素插入到位置P。我們假設entryX+1元素的長度增加4(即nextdiff等於4),此時數據拷貝示意圖如圖所示:

image

從圖中可以看到,位置P后的所有元素都需要移動,移動的偏移量是插入元素entryNew的長度,移動的數據塊長度是位置P后所有元素長度之和再加上nextdiff的值,數據移動之后還需要更新entryX+1元素的previous_entry_length字段。

memmove(p+reqlen,p-nextdiff,curlen-offset-1+nextdiff); 
//更新entryX+1元素的previous_entry_length字段字段
if (forcelarge)
    //entryX+1元素的previous_entry_length字段依然占5個字節;
    //但是entryNEW元素長度小於4字節
    zipStorePrevEntryLengthLarge(p+reqlen,reqlen);
else
    zipStorePrevEntryLength(p+reqlen,reqlen);
 
//更新zltail字段
ZIPLIST_TAIL_OFFSET(zl) =
    intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+reqlen);
 
zipEntry(p+reqlen, &tail);
if (p[reqlen+tail.headersize+tail.len] != ZIP_END) {
    ZIPLIST_TAIL_OFFSET(zl) =
        intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+nextdiff);
}
 
//更新zllen字段
ZIPLIST_INCR_LENGTH(zl,1);

思考一下,第一次更新尾元素偏移量之后,為什么指向的元素可能不是尾元素呢?很顯然,當entryX+1元素就是尾元素時,只需要更新一次尾元素的偏移量;但是當entryX+1元素不知尾元素,且entryX+1元素長度發生了改變,此時尾元素偏移量還需要加上nextdiff的值。

刪除元素

壓縮列表刪除元素的API定義如下,函數輸入參數zl指向壓縮列表首地址,*p指向待刪除元素的首地址(參數p同時可以作為輸出參數),返回參數為壓縮列表首地址。

unsigned char *ziplistDelete(unsigned char *zl, unsigned char **p);

ziplistDelete函數只是簡單調用底層__ziplistDelete函數實現刪除功能;__ziplistDelete函數可以同時刪除多個元素,輸入參數p指向的是首個刪除元素的首地址,num表示待刪除元素數目。

unsigned char *ziplistDelete(unsigned char *zl, unsigned char **p) {
    size_t offset = *p-zl;
    zl = __ziplistDelete(zl,*p,1);
    *p = zl+offset;
    return zl;
}

刪除元素同樣可以簡要分為三個步驟:第一步計算待刪除元素總長度,第二步數據拷貝,第三步重新分配空間。下面分別討論每個步驟的實現邏輯。

    1. 計算待刪除元素總長度,其中zipRawEntryLength函數在3.2節已經講過,這里不再詳述;
//解碼第一個待刪除元素
zipEntry(p, &first);
 
//遍歷所有待刪除元素,同時指針p向后偏移
for (i = 0; p[0] != ZIP_END && i < num; i++) {
    p += zipRawEntryLength(p);
    deleted++;
}
//totlen即為待刪除元素總長度
totlen = p-first.p;
    1. 數據拷貝;

第一步驟計算完成之后,指針first與指針p之間的元素都是待刪除的。很顯然,當指針p恰好指向zlend字段,不再需要數據的拷貝了,只需要更新尾節點的偏移量即可。下面分析另外一種情況,即指針p指向的是某一個元素而不是zlend字段。
分析類似於3.2節插入元素。刪除元素時,壓縮列表所需空間減少,減少的量是否僅僅是待刪除元素總長度呢?答案同樣是否定的,舉個簡單的例子,下圖是經過第一步驟計算之后的示意圖:

image

刪除元素entryX+1到元素entryN-1之間的N-X-1個元素,元素entryN-1的長度為12字節,因此元素entryN的previous_entry_length字段占1個字節;刪除這些元素之后,entryX稱為了entryN的前一個元素,元素entryX的長度為512字節,因此元素entryN的previous_entry_length字段需要占5個字節。即刪除元素之后的壓縮列表的總長度,還與entryN元素長度的變化量有關。

//計算元素entryN長度的變化量
nextdiff = zipPrevLenByteDiff(p,first.prevrawlen);
 
//更新元素entryN的previous_entry_length字段
p -= nextdiff;
zipStorePrevEntryLength(p,first.prevrawlen);
 
//更新zltail
ZIPLIST_TAIL_OFFSET(zl) =
    intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))-totlen);
 
zipEntry(p, &tail);
if (p[tail.headersize+tail.len] != ZIP_END) {
    ZIPLIST_TAIL_OFFSET(zl) =
       intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+nextdiff);
}
 
//數據拷貝
memmove(first.p,p,
    intrev32ifbe(ZIPLIST_BYTES(zl))-(p-zl)-1);

與3.2節插入元素更新zltail字段相同,當entryX+1元素就是尾元素時,只需要更新一次尾元素的偏移量;但是當entryX+1元素不是尾元素時,且entryX+1元素長度發生了改變,此時尾元素偏移量還需要加上nextdiff的值。

    1. 重新分配空間

邏輯與3.2節插入元素邏輯基本類似,這里就不再詳述。代碼如下:

offset = first.p-zl;
zl = ziplistResize(zl, intrev32ifbe(ZIPLIST_BYTES(zl))-totlen+nextdiff);
p = zl+offset;
ZIPLIST_INCR_LENGTH(zl,-deleted);

思考一下:在3.2節我們提到,調用ziplistResize函數重新分配空間時,如果重新分配的空間小於指針zl指向的空間大小時,可能會出現問題。而這里由於是刪除元素,壓縮列表的長度肯定是減少的。為什么又能這樣使用呢?
根本原因在於刪除元素時,我們是先拷貝數據,再重新分配空間,即調用ziplistResize函數時,多余的那部分空間存儲的數據已經被拷貝了,此時回收這部分空間並不會造成數據的丟失。

遍歷壓縮列表

遍歷就是從頭到尾(前向遍歷)或者從尾到頭(后向遍歷)訪問壓縮列表中的每一個元素。壓縮列表的遍歷API定義如下,函數輸入參數zl指向壓縮列表首地址,p指向當前訪問元素的首地址;ziplistNext函數返回后一個元素的首地址,ziplistPrev返回前一個元素的首地址。

//后向遍歷
unsigned char *ziplistNext(unsigned char *zl, unsigned char *p);
//前向遍歷
unsigned char *ziplistPrev(unsigned char *zl, unsigned char *p);

我們已經知道壓縮列表每個元素的previous_entry_length字段存儲的是前一個元素的長度,因此壓縮列表的前向遍歷相對簡單,表達式(p-previous_entry_length)即可獲取前一個元素的首地址,這里不做詳述。后向遍歷時,需要解碼當前元素,計算當前元素長度,才能獲取后一個元素首地址;ziplistNext函數實現如下:

unsigned char *ziplistNext(unsigned char *zl, unsigned char *p) {
    //zl參數無用;這里只是為了避免警告
    ((void) zl);
 
    if (p[0] == ZIP_END) {
        return NULL;
    }
 
    p += zipRawEntryLength(p);
    if (p[0] == ZIP_END) {
        return NULL;
    }
 
    return p;
}

連鎖更新

如下圖所示,刪除壓縮列表zl1位置P1的元素entryX,或者在壓縮列表zl2位置P2插入元素entryY,此時會出現什么情況呢?

image

壓縮列表zl1,元素entryX之后的所有元素entryX+1、entryX+2等長度都是253字節,顯然這些元素的previous_entry_length字段的長度都是1字節。當刪除元素entryX時,元素entryX+1的前驅節點改為元素entryX-1,長度為512字節,此時元素entryX+1的previous_entry_length字段需要5字節才能存儲元素entryX-1的長度,則元素entryX+1的長度需要擴展至257字節;而由於元素entryX+1長度的增加,元素entryX+2的previous_entry_length字段同樣需要改變。以此類推,由於刪除了元素entryX,之后的所有元素entryX+1、entryX+2等長度都必須擴展,而每次元素的擴展都將導致重新分配內存,效率是很低下的。壓縮列表zl2,插入元素entryY同樣會產生上面的問題。
  上面的情況稱之為連鎖更新。從上面分析可以看出,連鎖更新會導致多次重新分配內存以及數據拷貝,效率是很低下的。但是出現這種情況的概率是很低的,因此對於刪除元素與插入元素的操作,redis並沒有為了避免連鎖更新而采取措施。redis只是在刪除元素與插入元素操作的末尾,檢查是否需要更新后續元素的previous_entry_length字段,其實現函數_ziplistCascadeUpdate,主要邏輯如下圖所示:

image

https://segmentfault.com/a/1190000017328042

哈希表

//散列函數 偽代碼 
int Hash(string key) {
  // 獲取后四位字符
  string hashValue =int.parse(key.Substring(key.Length-4, 4));
  // 將后兩位字符轉換為整數
  return hashValue;
}

key通過哈希函數計算 得出數據所在位置 o(1) 時間復雜度

跳表

typedef struct zskiplist {
    // 頭節點,尾節點
    struct zskiplistNode *header, *tail;
    // 節點數量
    unsigned long length;
    // 目前表內節點的最大層數
    int level;
} zskiplist;
多級索引

基於偶數節點增加索引並且只有兩層的情況下,最高層的節點數是n/2,整體來看搜索的復雜度降低為O(n/2),並不要小看這個1/2的系數,看到這里會想 增加索引層數到k,那么復雜度將指數降低為O(n/2^k)。

索引層數不是無休止增加的,取決於該層索引的節點數量,如果該層的索引的節點數量等於2了,那么再往上加層也就沒有意義了,畫個圖看一下:

image

這個非常好理解,如果所在層索引結點只有1個,比如4層索引的結點16,只能順着16向下遍歷,無法向后跳到4層其他結點,因此當所在層索引結點數量等於2,則到達最高索引層,這個約束在分析跳表復雜度時很重要。

索引層數和索引結點密度

跳表的復雜度和索引層數、索引結點的稀疏程度有很大關系。

索引層數我們從上面也看到了,稀疏程度相當於索引結點的數量比例,如果跳表的索引結點數量很少,那么將接近退化為普通鏈表,這種情況在數據量是較大時非常明顯,畫圖看下(藍色部分表示有很多結點):

image

圖中可以看到雖然有索引層,但是索引結點數量相對全部數據比例較低,這種情況下搜索35相比無索引情況優勢並不明顯。

所以跳表的效率和索引層數和索引結點的密度有密切的關系,當然索引結點太多也就等於沒有索引了。

太少的索引結點和太多的索引結點都是一樣的低效。
復雜度分析

從前面的分析可知,跳表的復雜度和索引層數m以及索引結點間隙d有直接關系,其中索引結點間隙理解為相隔幾個結點出現索引結點,體現了對應層索引結點的稀疏程度,在無索引結點時只能遍歷無法跳躍。

如何確定最高索引層數m呢?

如果一個鏈表有 n 個結點,如果每兩個結點取出一個結點建立索引,那么第一級索引的結點數是 n/2,第二級索引的結點數是n/4,以此類推第 m 級索引的結點數為 n/(2^m),前面說過最高層結點數為2,因此存在關系:image

算上最底層的原始鏈表,整個跳表的高度為h=logn(底數為2),每一層需要遍歷的結點數是d,那么整個過程的復雜度為:O(d*logn)。

d表明了層間結點的稀疏程度,也就是每隔2個結點選取索引結點、或者每隔3個結點選取索引結點,每個4個結點選取索引結點......

最密集的情況下d=2,借用知乎某大佬的文章的圖片:

image

但是索引結點密集也意味着存儲空間的增加,跳表相比較普通鏈表就是典型的用空間換時間的數據結構,這樣就達到了AVL的復雜度O(logn)。

跳表的空間存儲

以d=2的最密集情況為例,計算跳表的索引結點總數:2+4+8+......n/8+n/4+n/2=n-2image

由等比數列求和公式得d=2的跳表額外空間為O(n-2)。

跳表的插入和刪除

工程中的跳表並不嚴格要求索引層結點數量遵循2:1的關系,因為這種要求將導致插入和刪除數據時的調整,成本很大.

跳表的每個插入的結點在插入時進行選擇是否作為索引結點,如果作為索引結點則隨機出層數,整個過程都是基於概率的,但是在大數據量時卻能很好地解決索引層數和結點數的權衡。

我們針對插入和刪除來看下基本的操作過程吧!

跳表元素17插入:

鏈表的插入和刪除是結合搜索過程完成的,貼一張William Pugh在論文中給出的在跳表中插入元素17的過程圖(暫時忽略結點17是否作為索引結點以及索引層數,后面會詳細說明):

image

跳表元素1刪除:

image

跳表元素的刪除與普通鏈表相比增加了索引層的判斷,如果結點是非索引結點則正常處理,如果結點是索引結點那邊需要進行索引層結點的處理。

跳躍鏈表的應用

一般討論查找問題時首先想到的是平衡樹和哈希表,但是跳表這種數據結構也非常犀利,性能和實現復雜度都可以和紅黑樹媲美,甚至某些場景由於紅黑樹,從1990年被發明目前廣泛應用於多種場景中,包括Redis、LevelDB等數據存儲引擎中。

跳表在Redis中的應用

ZSet結構同時包含一個字典和一個跳躍表,跳躍表按score從小到大保存所有集合元素。字典保存着從member到score的映射。這兩種結構通過指針共享相同元素的member和score,不會浪費額外內存。

typedef struct zset {
    dict *dict;
    zskiplist *zsl;
} zset;

ZSet中的字典和跳表布局:

image

ZSet中跳表的實現細節
隨機層數的實現原理

跳表是一個概率型的數據結構,元素的插入層數是隨機指定的。Willam Pugh在論文中描述了它的計算過程如下:指定節點最大層數 MaxLevel,指定概率 p, 默認層數 lvl 為1

生成一個0~1的隨機數r,若r<p,且lvl<MaxLevel ,則lvl ++

重復第 2 步,直至生成的r >p 為止,此時的 lvl 就是要插入的層數。

論文中生成隨機層數的偽碼:

image

在Redis中對跳表的實現基本上也是遵循這個思想的,只不過有微小差異,看下Redis關於跳表層數的隨機源碼src/z_set.c:

/* Returns a random level for the new skiplist node we are going to create.
 * The return value of this function is between 1 and ZSKIPLIST_MAXLEVEL
 * (both inclusive), with a powerlaw-alike distribution where higher
 * levels are less likely to be returned. */
int zslRandomLevel(void) {
    int level = 1;
    while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
        level += 1;
    return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

其中兩個宏的定義在redis.h中:

#define ZSKIPLIST_MAXLEVEL 32 /* Should be enough for 2^32 elements */
#define ZSKIPLIST_P 0.25      /* Skiplist P = 1/4 */

可以看到while中的:

(random()&0xFFFF) < (ZSKIPLIST_P*0xFFFF)
跳表結點的平均層數

我們很容易看出,產生越高的節點層數出現概率越低,無論如何層數總是滿足冪次定律越大的數出現的概率越小。

如果某件事的發生頻率和它的某個屬性成冪關系,那么這個頻率就可以稱之為符合冪次定律。

冪次定律的表現是少數幾個事件的發生頻率占了整個發生頻率的大部分, 而其余的大多數事件只占整個發生頻率的一個小部分。

image

冪次定律應用到跳表的隨機層數來說就是大部分的節點層數都是黃色部分,只有少數是綠色部分,並且概率很低。

定量的分析如下:

  • 節點層數至少為1,大於1的節點層數滿足一個概率分布。
  • 節點層數恰好等於1的概率為p^0(1-p)
  • 節點層數恰好等於2的概率為p^1(1-p)
  • 節點層數恰好等於3的概率為p^2(1-p)
  • 節點層數恰好等於4的概率為p^3(1-p)

依次遞推節點層數恰好等於K的概率為p^(k-1)(1-p)

因此如果我們要求節點的平均層數,那么也就轉換成了求概率分布的期望問題了,靈魂畫手的我再次上線:

image

表中P為概率,V為對應取值,給出了所有取值和概率的可能,因此就可以求這個概率分布的期望了。

方括號里面的式子其實就是高一年級學的等比數列,常用技巧錯位相減求和,從中可以看到結點層數的期望值與1-p成反比。

對於Redis而言,當p=0.25時結點層數的期望是1.33。

在Redis源碼中有詳盡的關於插入和刪除調整跳表的過程,本文就不再展開了,代碼並不算難懂,都是純C寫的沒有那么多炫技的特效,放心大膽讀起來。

雙端鏈表

typedef struct list { 
listNode *head; //鏈表頭結點指針 
listNode *tail; //鏈表尾結點指針
unsigned long len; //鏈表長度計數器
 
//下面的三個函數指針就像類中的成員函數一樣
void *(*dup)(void *ptr); //復制鏈表節點保存的值 
void (*free)(void *ptr); //釋放鏈表節點保存的值
int (*match)(void *ptr, void *key); //比較鏈表節點所保存的節點值和另一個輸入的值是否相等 
} list

typedef struct listNode {
 struct listNode *prev; //前驅節點,如果是list的頭結點,則prev指向NULL 
 struct listNode *next;//后繼節點,如果是list尾部結點,則next指向NULL 
 void *value; //萬能指針,能夠存放任何信息 
} listNode

image

集合

Set 集合采用了整數集合和字典兩種方式來實現的,當滿足如下兩個條件的時候,采用整數集合實現;一旦有一個條件不滿足時則采用字典來實現。

  • Set 集合中的所有元素都為整數
  • Set 集合中的元素個數不大於 512(默認 512,可以通過修改 set-max-intset-entries 配置調整集合大小)

img

整數集合實現原理圖

img

整數集合

整數集合(intset)是Redis用於保存整數值的集合抽象數據結構,它可以保存類型為int16_t、int32_t或者int64_t的整數值,並且保證集合中不會出現重復元素。

//每個intset結構表示一個整數集合
typedef struct intset{
    //編碼方式
    uint32_t encoding;
    //集合中包含的元素數量
    uint32_t length;
    //保存元素的數組
    int8_t contents[];
} intset;
  • contents數組是整數集合的底層實現,整數集合的每個元素都是 contents數組的個數組項(item),各個項在數組中按值的大小從小到大有序地排列,並且數組中不包含任何重復項。
  • length屬性記錄了數組的長度。
  • intset結構將contents屬性聲明為int8_t類型的數組,但實際上 contents數組並不保存任何int8t類型的值, contents數組的真正類型取決於encoding屬性的值。encoding屬性的值為INTSET_ENC_INT16則數組就是uint16_t類型,數組中的每一個元素都是int16_t類型的整數值(-32768——32767),encoding屬性的值為INTSET_ENC_INT32則數組就是uint32_t類型,數組中的每一個元素都是int16_t類型的整數值(-2147483648——2147483647)。

String

存儲數字用int類型編碼(8字節長整型)、存非數字小於39字節用embstr、大於39字節大於用raw編碼。

Hash

ziplist當哈希元素個數小於hash-max-ziplist-entries配置(默認512個),同時所有值都小於hash-max-ziplist-value配置(默認64字節),redis會使用ziplist作為哈希內部實現。

hash-max-ziplist-entries 512  # hash 的元素個數超過 512 就必須用標准結構存儲
hash-max-ziplist-value 64  # hash 的任意元素的 key/value 的長度超過 64 就必須用標准結構存儲

hashtable當哈希類型無法滿足ziplist的條件時, redis會使用hashtable作為內部實現。

Zset

ziplist

zset-max-ziplist-entries 128  # zset 的元素個數超過 128 就必須用標准結構存儲
zset-max-ziplist-value 64  # zset 的任意元素的長度超過 64 就必須用標准結構存儲

skiplist

Set

intset

普通set

List

ziplist

雙端鏈表

擴展類型

geo

bighash

loglog


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM