Redis數據結構列表實現


 

 

前言

在版本3.2之前,Redis 列表list使用兩種數據結構作為底層實現:

  • 壓縮列表ziplist
  • 雙向鏈表linkedlist
  • 默認為linkedlist
在3.2之后,由quicklist實現。
 
 

雙向鏈表linkedlist

Redis實現的是標准的雙向鏈表。

鏈表節點定義:

 

 

 

鏈表定義:

 

 總結鏈表實現:

1.每個節點有前后節點指針,且第一個節點的指針為NULL,最后一個節點的指針為NULL(無環)。

2.對雙鏈表進行封裝,鏈表第一個節點和最后一個節點指針,以及鏈表長度。

優點:

在鏈表兩端進行push和pop操作都是O(1)。

獲取鏈表的長度操作O(1)。

多態,可以存儲c語言支持的任何數據類型,通過void *萬能指針。

缺點:

內存開銷比較大。每個節點上除了要保存數據之外,還要額外保存兩個指針

各個節點是單獨的內存塊,地址不連續,節點多了容易產生內存碎片。

 

壓縮列表轉化成雙向鏈表條件

創建新列表時 redis 默認使用 redis_encoding_ziplist 編碼, 當以下任意一個條件被滿足時, 列表會被轉換成 redis_encoding_linkedlist 編碼:

  • 試圖往列表新添加一個字符串值,且這個字符串的長度超過 server.list_max_ziplist_value (默認值為 64 )。
  • ziplist 包含的節點超過 server.list_max_ziplist_entries (默認值為 512 )。

注意:這兩個條件是可以修改的,在 redis.conf 中

list-max-ziplist-value 64 
list-max-ziplist-entries 512 

 

ziplist

壓縮列表 ziplist 是為 Redis 節約內存而開發的。

ziplist 是由一系列特殊編碼的內存塊構成的列表(像內存連續的數組,但每個元素長度不同), 一個 ziplist 可以包含多個節點(entry)。
ziplist 將表中每一項存放在前后連續的地址空間內,每一項因占用的空間不同,而采用變長編碼。

當元素個數較少時,Redis 用 ziplist 來存儲數據,當元素個數超過某個值時,鏈表鍵中會把 ziplist 轉化為 linkedlist,字典鍵中會把 ziplist 轉化為 hashtable。
由於內存是連續分配的,所以遍歷速度很快。

在3.2之后,ziplist被quicklist替代。但是仍然是zset底層實現之一。

ziplist內存布局

ziplist使用連續的內存塊,每一個節點(entry)都是連續存儲的;ziplist 存儲分布如下:

area        |<---- ziplist header ---->|<----------- entries ------------->|<-end->| size 4 bytes 4 bytes 2 bytes ? ? ? ? 1 byte +---------+--------+-------+--------+--------+--------+--------+-------+ component | zlbytes | zltail | zllen | entry1 | entry2 | ... | entryN | zlend | +---------+--------+-------+--------+--------+--------+--------+-------+ ^ ^ ^ address | | | ZIPLIST_ENTRY_HEAD | ZIPLIST_ENTRY_END | ZIPLIST_ENTRY_TAIL

常態的壓縮列表內存布局如上圖所示,整個內存塊區域內分為五個部分,下面分別介紹着五個部分:

  • zlbytes:存儲一個無符號整數,固定四個字節長度,用於存儲壓縮列表所占用的字節,當重新分配內存的時候使用,不需要遍歷整個列表來計算內存大小。
  • zltail:存儲一個無符號整數,固定四個字節長度,代表指向列表尾部的偏移量,偏移量是指壓縮列表的起始位置到指定列表節點的起始位置的距離。
  • zllen:壓縮列表包含的節點個數,固定兩個字節長度,源碼中指出當節點個數大於2^16-2個數的時候,該值將無效,此時需要遍歷列表來計算列表節點的個數。
  • entries:列表節點區域,長度不定,由列表節點緊挨着組成。每個節點可以保存一個字節數組或者是一個整數值。
  • zlend:一字節長度固定值為255,用於表示列表結束。

上面介紹了壓縮列表的總體內存布局,對於初entries區域以外的四個區域的長度都是固定的,下面再看看此區域中每個節點的布局情況。

每個列表節點由三部分組成:

  • previous length:記錄前一個節點所占有的內存字節數,通過該值,我們可以從當前節點計算前一個節點的地址,可以用來實現從表尾向表頭節點遍歷;
  • len/encoding:記錄了當前節點content占有的內存字節數及其存儲類型,用來解析content用;
  • content:保存了當前節點的值。
    最關鍵的是prevrawlen和len/encoding,content只是實際存儲數值的比特位。

為了節省內存,根據上一個節點的長度prevlength 可以將entry節點分為兩類:

 

 

  • entry的前8位小於254,則這8位就表示上一個節點的長度
  • entry的前8位等於254,則意味着上一個節點的長度無法用8位表示,后面32位才是真實的prevlength。用254 不用255(11111111)作為分界是因為255是zlend的值,它用於判斷ziplist是否到達尾部。

根據當前節點存儲的數據類型及長度,可以將ziplist節點分為9類
其中整數節點分為6類:

 
整數節點的encoding的長度為8位,其中 高2位用來區分整數節點和字符串節點(高2位為11時是整數節點), 低6位用來區分整數節點的類型,定義如下:
#define ZIP_INT_16B (0xc0 | 0<<4)//整數data,占16位(2字節)
#define ZIP_INT_32B (0xc0 | 1<<4)//整數data,占32位(4字節)
#define ZIP_INT_64B (0xc0 | 2<<4)//整數data,占64位(8字節)
#define ZIP_INT_24B (0xc0 | 3<<4)//整數data,占24位(3字節)
#define ZIP_INT_8B 0xfe //整數data,占8位(1字節)
/* 4 bit integer immediate encoding */
//整數值1~13的節點沒有data,encoding的低四位用來表示data
#define ZIP_INT_IMM_MASK 0x0f
#define ZIP_INT_IMM_MIN 0xf1    /* 11110001 */
#define ZIP_INT_IMM_MAX 0xfd    /* 11111101 */

值得注意的是 最后一種encoding是存儲整數0~12的節點的encoding,它沒有額外的data部分,encoding的高4位表示這個類型,低4位就是它的data。這種類型的節點的encoding大小介於ZIP_INT_24B與ZIP_INT_8B之間(1~13),但是為了表示整數0,取出低四位xxxx之后會將其-1作為實際的data值(0~12)。在函數zipLoadInteger中,我們可以看到這種類型節點的取值方法:

  • 當data小於63字節時(2^6),節點存為上圖的第一種類型,高2位為00,低6位表示data的長度。
  • 當data小於16383字節時(2^14),節點存為上圖的第二種類型,高2位為01,后續14位表示data的長度。
  • 當data小於4294967296字節時(2^32),節點存為上圖的第二種類型,高2位為10,下一字節起連續32位表示data的長度。

字符串節點分為3類:

 

  • 當data小於63字節時(2^6),節點存為上圖的第一種類型,高2位為00,低6位表示data的長度。
  • 當data小於16383字節時(2^14),節點存為上圖的第二種類型,高2位為01,后續14位表示data的長度。
  • 當data小於4294967296字節時(2^32),節點存為上圖的第二種類型,高2位為10,下一字節起連續32位表示data的長度。

上圖可以看出:不同於整數節點encoding永遠是8位,字符串節點的encoding可以有8位、16位、40位三種長度
相同encoding類型的整數節點 data長度是固定的,但是相同encoding類型的字符串節點,data長度取決於encoding后半部分的值。

#define ZIP_STR_06B (0 << 6)//字符串data,最多有2^6字節(encoding后半部分的length有6位,length決定data有多少字節)
#define ZIP_STR_14B (1 << 6)//字符串data,最多有2^14字節
#define ZIP_STR_32B (2 << 6)//字符串data,最多有2^32字節

 

如何通過一個節點向前跳轉到另一個節點?
從尾部向頭部遍歷(利用 ztail 和privious_entry_length),用指向當前節點的指針 e , 減去前一個 entry的長度, 得出的結果就是指向前一個節點的地址 p 。
 

已知節點的位置,求data的值

entry布局 可以看出,若要算出data的偏移量,得先計算出prevlength所占內存大小(1字節和5字節):

//根據ptr指向的entry,返回這個entry的prevlensize
#define ZIP_DECODE_PREVLENSIZE(ptr, prevlensize) do {                          \
    if ((ptr)[0] < ZIP_BIGLEN) {                                               \
        (prevlensize) = 1;                                                     \
    } else {                                                                   \
        (prevlensize) = 5;                                                     \
    }                                                                          \
} while(0);

接着再用ZIP_DECODE_LENGTH(ptr + prevlensize, encoding, lensize, len)算出encoding所占的字節,返回給lensize;data所占的字節返回給len

//根據ptr指向的entry求出該entry的len(encoding里存的 data所占字節)和lensize(encoding所占的字節)
#define ZIP_DECODE_LENGTH(ptr, encoding, lensize, len) do {                    \
    ZIP_ENTRY_ENCODING((ptr), (encoding));                                     \
    if ((encoding) < ZIP_STR_MASK) {                                           \
        if ((encoding) == ZIP_STR_06B) {                                       \
            (lensize) = 1;                                                     \
            (len) = (ptr)[0] & 0x3f;                                           \
        } else if ((encoding) == ZIP_STR_14B) {                                \
            (lensize) = 2;                                                     \
            (len) = (((ptr)[0] & 0x3f) << 8) | (ptr)[1];                       \
        } else if (encoding == ZIP_STR_32B) {                                  \
            (lensize) = 5;                                                     \
            (len) = ((ptr)[1] << 24) |                                         \
                    ((ptr)[2] << 16) |                                         \
                    ((ptr)[3] <<  8) |                                         \
                    ((ptr)[4]);                                                \
        } else {                                                               \
            assert(NULL);                                                      \
        }                                                                      \
    } else {                                                                   \
        (lensize) = 1;                                                         \
        (len) = zipIntSize(encoding);                                          \
    }                                                                          \
} while(0);

//將ptr的encoding解析成1個字節:00000000、01000000、10000000(字符串類型)和11??????(整數類型)
//如果是整數類型,encoding直接照抄ptr的;如果是字符串類型,encoding被截斷成一個字節並清零后6位
#define ZIP_ENTRY_ENCODING(ptr, encoding) do {  \
    (encoding) = (ptr[0]); \
    if ((encoding) < ZIP_STR_MASK) (encoding) &= ZIP_STR_MASK; \
} while(0)

//根據encoding返回數據(整數)所占字節數
unsigned int zipIntSize(unsigned char encoding) {
    switch(encoding) {
    case ZIP_INT_8B:  return 1;
    case ZIP_INT_16B: return 2;
    case ZIP_INT_24B: return 3;
    case ZIP_INT_32B: return 4;
    case ZIP_INT_64B: return 8;
    default: return 0; /* 4 bit immediate */
    }
    assert(NULL);
    return 0;
}

完成以上步驟之后,即可算出data的位置:ptr+prevlensize+lensize,以及data的長度len

 

連鎖更新

每個節點的previous_entry_length屬性都記錄了前一個節點的長度

如果前一個節點的長度小於254,那么previous_entry_length屬性需要用1字節長的空間來保存這個長度值
如果前一個節點的長度大於等於254,那么previous_entry_length屬性需要5字節長的空間來保存這個長度值
考慮這樣一種情況:在一個壓縮列表中,有多個連續的、長度介於250字節到253字節之間的節點e1至eN
|zlbytes|zltail|zllen|e1|e2|e3|...|eN|zlend|
因為e1至eN的所有節點的長度都小於254字節,所以記錄這些節點的長度只需要1字節長的previous_entry_length屬性,換句話說,e1至eN的所有節點的previous_entry_length屬性都是1字節長的。
如果我們將一個長度大於等於254字節的新節點new設置到壓縮列表的表頭節點,那么new將成為e1的潛質節點。
此時e1到eN的每個節點的previous_entry_length屬性都要擴展為5字節以符合壓縮列表對節點的要求,程序需要不斷的對壓縮列表進行空間重分配操作。
Redis將這種在特殊情況下產生的多次空間擴展操作稱之為“連鎖更新”。
除了添加新節點可能會引發連鎖更新之外,刪除節點也可能會連鎖更新。
因為連鎖更新在最壞情況下需要對壓縮列表執行N次空間重分配操作,而每次空間重分配的最壞復雜度為O(N),所以連鎖更新的最壞復雜度為O(N2)。
注意的是,盡管連鎖更新的復雜度較高,但它真正趙成性能問題的幾率是很低的:
首先,壓縮列表里要恰好有多個連續的、長度介於250字節至253字節之間的節點,連鎖更新才有可能被引發,在實際中,這種情況並不多見;
其次,即使出現連鎖更新,但只要更新的節點數量不多,就不會對性能造成任何影響:比如說,對三五個節點進行連鎖更新是絕對不會影響性能的;

 

 
 
 

Redis中壓縮列表的應用
Redis中,不同的數據類型廣泛地應用了壓縮列表編碼,整理如下表:

ziplist總結

ziplist的主要優點是節省內存,且ziplist存儲在一段連續的內存上,所以存儲效率很高。但是,它不利於修改操作,插入和刪除操作需要頻繁的申請和釋放內存。
查找操作只能按順序查找(可以是從前往后、也可以從后往前)
一旦數據發生改動,就會引發內存realloc,可能導致內存拷貝。當ziplist長度很長的時候,一次realloc可能會導致大批量的數據拷貝。
 

quickList

可以認為quickList,是ziplist和linkedlist二者的結合;quickList將二者的優點結合起來。
官方給出的定義:

A generic doubly linked quicklist implementation
A doubly linked list of ziplists

quickList是一個ziplist組成的雙向鏈表。每個節點使用ziplist來保存數據。
本質上來說,quicklist里面保存着一個一個小的ziplist。結構如下:

 
 
/* quicklistNode is a 32 byte struct describing a ziplist for a quicklist.
 * We use bit fields keep the quicklistNode at 32 bytes.
 * count: 16 bits, max 65536 (max zl bytes is 65k, so max count actually < 32k).
 * encoding: 2 bits, RAW=1, LZF=2.
 * container: 2 bits, NONE=1, ZIPLIST=2.
 * recompress: 1 bit, bool, true if node is temporarry decompressed for usage.
 * attempted_compress: 1 bit, boolean, used for verifying during testing.
 * extra: 12 bits, free for future use; pads out the remainder of 32 bits */
typedef struct quicklistNode {
    struct quicklistNode *prev; //上一個node節點
    struct quicklistNode *next; //下一個node
    unsigned char *zl;            //保存的數據 壓縮前ziplist 壓縮后壓縮的數據
    unsigned int sz;             /* ziplist size in bytes */
    unsigned int count : 16;     /* count of items in ziplist */
    unsigned int encoding : 2;   /* RAW==1 or LZF==2 */
    unsigned int container : 2;  /* NONE==1 or ZIPLIST==2 */
    unsigned int recompress : 1; /* was this node previous compressed? */
    unsigned int attempted_compress : 1; /* node can't compress; too small */
    unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;

/* quicklistLZF is a 4+N byte struct holding 'sz' followed by 'compressed'.
 * 'sz' is byte length of 'compressed' field.
 * 'compressed' is LZF data with total (compressed) length 'sz'
 * NOTE: uncompressed length is stored in quicklistNode->sz.
 * When quicklistNode->zl is compressed, node->zl points to a quicklistLZF */
typedef struct quicklistLZF {
    unsigned int sz; /* LZF size in bytes*/
    char compressed[];
} quicklistLZF;

/* quicklist is a 32 byte struct (on 64-bit systems) describing a quicklist.
 * 'count' is the number of total entries.
 * 'len' is the number of quicklist nodes.
 * 'compress' is: -1 if compression disabled, otherwise it's the number
 *                of quicklistNodes to leave uncompressed at ends of quicklist.
 * 'fill' is the user-requested (or default) fill factor. */
typedef struct quicklist {
    quicklistNode *head; //頭結點
    quicklistNode *tail; //尾節點
    unsigned long count;        /* total count of all entries in all ziplists */
    unsigned int len;           /* number of quicklistNodes */
    int fill : 16;              /* fill factor for individual nodes *///負數代表級別,正數代表個數
    unsigned int compress : 16; /* depth of end nodes not to compress;0=off *///壓縮級別
} quicklist;
 

quickList就是一個標准的雙向鏈表的配置,有head 有tail;
每一個節點是一個quicklistNode,包含prev和next指針。
每一個quicklistNode 包含 一個ziplist,*zp 壓縮鏈表里存儲鍵值。
所以quicklist是對ziplist進行一次封裝,使用小塊的ziplist來既保證了少使用內存,也保證了性能。



refer:

《Redis設計與實現》

Redis列表list 底層原理

Redis源碼分析-壓縮列表ziplist


免責聲明!

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



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