本文將介紹Redis中一種重要的數據結構,這種數據結構是爲了節省內存而設計的,這就是壓縮列表(ZipList)。redis
壓縮列表(ziplist)本質上就是一個字節數組,是Redis爲了節約內存而設計的一種線性數據結構,能夠包含任意多個元素,每一個元素能夠是一個字節數組或一個整數。
Redis的有序集合、哈希以及列表都直接或者間接使用了壓縮列表。當有序集合或哈希的元素數目比較少,且元素都是短字符串時,Redis便使用壓縮列表做爲其底層數據存儲方式。列表使用快速鏈表(quicklist)數據結構存儲,而快速鏈表就是雙向鏈表與壓縮列表的組合。
例如,使用下面命令建立一個哈希鍵並查看其編碼:shell
127.0.0.1:6379> hmset person name zhangsan gender 1 age 22 OK 127.0.0.1:6379> object encoding person "ziplist"
Redis使用字節數組表示一個壓縮列表,字節數組邏輯劃分爲多個字段,如圖所示:segmentfault
各字段含義以下:數組
假設char * zl指向壓縮列表首地址,Redis經過如下宏定義實現了壓縮列表各個字段的操做存取:緩存
//zl指向zlbytes字段 #define ZIPLIST_BYTES(zl) (*((uint32_t*)(zl))) //zl+4指向zltail字段 #define ZIPLIST_TAIL_OFFSET(zl) (*((uint32_t*)((zl)+sizeof(uint32_t)))) //zl+zltail指向尾元素首地址;intrev32ifbe使得數據存取統一按照小端法 #define ZIPLIST_ENTRY_TAIL(zl) ((zl)+intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))) //zl+8指向zllen字段 #define ZIPLIST_LENGTH(zl) (*((uint16_t*)((zl)+sizeof(uint32_t)*2))) //壓縮列表最後一個字節即爲zlend字段 #define ZIPLIST_ENTRY_END(zl) ((zl)+intrev32ifbe(ZIPLIST_BYTES(zl))-1)
瞭解了壓縮列表的基本結構,咱們能夠很容易得到壓縮列表的字節長度,元素數目等,那麼如何遍歷壓縮列表的全部元素呢?咱們已經知道對於每個entry元素,存儲的多是字節數組或整數值;那麼對任一個元素,咱們如何判斷存儲的是什麼類型?對於字節數組,咱們又如何獲取字節數組的長度?
回答這些問題以前,須要先看看壓縮列表元素的編碼結構,如圖所示:數據結構
previous_entry_length字段表示前一個元素的字節長度,佔1個或者5個字節;當前一個元素的長度小於254字節時,previous_entry_length字段用一個字節表示;當前一個元素的長度大於等於254字節時,previous_entry_length字段用5個字節來表示;而這時候previous_entry_length的第一個字節是固定的標誌0xFE,後面4個字節才真正表示前一個元素的長度;假設已知當前元素的首地址爲p,那麼(p-previous_entry_length)就是前一個元素的首地址,從而實現壓縮列表從尾到頭的遍歷;
encoding字段表示當前元素的編碼,即content字段存儲的數據類型(整數或者字節數組),數據內容存儲在content字段;爲了節約內存,encoding字段一樣是可變長度,編碼如表-1所示:
<center>表-1 壓縮列表元素編碼表格</center>curl
encoding編碼 | encoding長度 | content類型 |
---|---|---|
00 bbbbbb(6比特表示content長度) | 1字節 | 最大長度63的字節數組 |
01 bbbbbb xxxxxxxx(14比特表示content長度) | 2字節 | 最大長度(2^14)-1字節最大長度的字節數組 |
10 __ aaaaaaaa bbbbbbbb cccccccc dddddddd(32比特表示content長度) | 5字節 | 最大長度(2^32)-1的字節數組 |
11 00 0000 | 1字節 | int16整數 |
11 01 0000 | 1字節 | int32整數 |
11 10 0000 | 1字節 | int64整數 |
11 11 0000 | 1字節 | 24比特整數 |
11 11 1110 | 1字節 | 8比特整數 |
11 11 xxxx | 1字節 | 沒有content字段;xxxx表示0~12之間的整數 |
能夠看出,根據encoding字段第一個字節的前2個比特,能夠判斷content字段存儲的是整數,或者字節數組(以及字節數組最大長度);當content存儲的是字節數組時,後續字節標識字節數組的實際長度;當content存儲的是整數時,根據第三、4比特才能判斷整數的具體類型;而當encoding字段標識當前元素存儲的是0~12的當即數時,數據直接存儲在encoding字段的最後4個比特,此時沒有content字段。參照encoding字段的編碼表格,Redis預約義瞭如下常量:函數
#define ZIP_STR_06B (0 << 6) #define ZIP_STR_14B (1 << 6) #define ZIP_STR_32B (2 << 6) #define ZIP_INT_16B (0xc0 | 0<<4) #define ZIP_INT_32B (0xc0 | 1<<4) #define ZIP_INT_64B (0xc0 | 2<<4) #define ZIP_INT_24B (0xc0 | 3<<4) #define ZIP_INT_8B 0xfe
2.1節分析了壓縮列表的底層存儲結構。可是咱們發現對於任意的壓縮列表元素,獲取前一個元素的長度,判斷存儲的數據類型,獲取數據內容,都須要通過複雜的解碼運算才行,那麼解碼後的結果應該被緩存起來,爲此定義告終構體zlentry,用於表示解碼後的壓縮列表元素:ui
typedef struct zlentry { unsigned int prevrawlensize; unsigned int prevrawlen; unsigned int lensize; unsigned int len; unsigned int headersize; unsigned char encoding; unsigned char *p; } zlentry;
咱們看到結構體定義了7個字段,而2.1節顯示每一個元素只包含3個字段。回顧壓縮列表元素的編碼結構,可變因素實際上不止三個;previous_entry_length字段的長度(字段prevrawlensize表示)、previous_entry_length字段存儲的內容(字段prevrawlen表示)、encoding字段的長度(字段lensize表示)、encoding字段的內容(字段len表示數據內容長度,字段encoding表示數據類型)、和當前元素首地址(字段p表示)。而headersize字段則表示當前元素的首部長度,即previous_entry_length字段長度與encoding字段長度之和。
函數zipEntry用來解碼壓縮列表的元素,存儲於zlentry結構體:編碼
void zipEntry(unsigned char *p, zlentry *e) { ZIP_DECODE_PREVLEN(p, e->prevrawlensize, e->prevrawlen); ZIP_DECODE_LENGTH(p + e->prevrawlensize, e->encoding, e->lensize, e->len); e->headersize = e->prevrawlensize + e->lensize; e->p = p; }
解碼過程主要能夠分爲如下兩個步驟:
#define ZIP_BIG_PREVLEN 254 #define ZIP_DECODE_PREVLEN(ptr, prevlensize, prevlen) do { \ if ((ptr)[0] < ZIP_BIG_PREVLEN) { \ (prevlensize) = 1; \ (prevlen) = (ptr)[0]; \ } else { \ (prevlensize) = 5; \ memcpy(&(prevlen), ((char*)(ptr)) + 1, 4); \ memrev32ifbe(&prevlen); \ } \ } while(0);
#define ZIP_STR_MASK 0xc0 #define ZIP_DECODE_LENGTH(ptr, encoding, lensize, len) do { \ (encoding) = (ptr[0]); \ // ptr[0]小於11000000說明是字節數組,前兩個比特爲編碼 if ((encoding) < ZIP_STR_MASK) (encoding) &= ZIP_STR_MASK; \ 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 { \ panic("Invalid string encoding 0x%02X", (encoding));\ } \ } else { \ (lensize) = 1; \ (len) = zipIntSize(encoding); \ } \ } while(0);
字節數組只根據ptr[0]的前2個比特便可判類型,而判斷整數類型須要ptr[0]的前4個比特,代碼以下:
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; } // 4比特當即數 if (encoding >= ZIP_INT_IMM_MIN && encoding <= ZIP_INT_IMM_MAX) return 0; panic("Invalid integer encoding 0x%02X", encoding); return 0; }
建立壓縮列表的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);
插入元素時,能夠簡要分爲三個步驟:第一步須要將元素內容編碼爲壓縮列表的元素,第二步從新分配空間,第三步拷貝數據。下面分別討論每一個步驟的實現邏輯。
編碼即計算previous_entry_length字段、encoding字段和content字段的內容。如何獲取前一個元素的長度呢?這時候就須要根據插入元素的位置分狀況討論了,如圖所示:
當壓縮列表爲空插入位置爲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字段所需空間大小。
因爲新插入元素,壓縮列表所需空間增大,所以須要從新分配存儲空間。那麼空間大小是不是添加元素前的壓縮列表長度與新添加元素元素長度之和呢?並不徹底是,如圖中所示的例子。
插入元素前,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節介紹。
從新分配空間以後,須要將位置P後的元素移動到指定位置,將新元素插入到位置P。咱們假設entryX+1元素的長度增長4(即nextdiff等於4),此時數據拷貝示意圖如圖所示:
從圖中能夠看到,位置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; }
刪除元素一樣能夠簡要分爲三個步驟:第一步計算待刪除元素總長度,第二步數據拷貝,第三步從新分配空間。下面分別討論每一個步驟的實現邏輯。
//解碼第一個待刪除元素 zipEntry(p, &first); //遍歷全部待刪除元素,同時指針p向後偏移 for (i = 0; p[0] != ZIP_END && i < num; i++) { p += zipRawEntryLength(p); deleted++; } //totlen即爲待刪除元素總長度 totlen = p-first.p;
第一步驟計算完成以後,指針first與指針p之間的元素都是待刪除的。很顯然,當指針p剛好指向zlend字段,再也不須要數據的拷貝了,只須要更新尾節點的偏移量便可。下面分析另一種狀況,即指針p指向的是某一個元素而不是zlend字段。
分析相似於3.2節插入元素。刪除元素時,壓縮列表所需空間減小,減小的量是否僅僅是待刪除元素總長度呢?答案一樣是否認的,舉個簡單的例子,下圖是通過第一步驟計算以後的示意圖:
刪除元素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的值。
邏輯與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,此時會出現什麼狀況呢?
壓縮列表zl1,元素entryX以後的全部元素entryX+一、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+一、entryX+2等長度都必須擴展,而每次元素的擴展都將致使從新分配內存,效率是很低下的。壓縮列表zl2,插入元素entryY一樣會產生上面的問題。
上面的狀況稱之爲連鎖更新。從上面分析能夠看出,連鎖更新會致使屢次從新分配內存以及數據拷貝,效率是很低下的。可是出現這種狀況的機率是很低的,所以對於刪除元素與插入元素的操做,redis並無爲了不連鎖更新而採起措施。redis只是在刪除元素與插入元素操做的末尾,檢查是否須要更新後續元素的previous_entry_length字段,其實現函數_ziplistCascadeUpdate,主要邏輯以下圖所示:
本文首先介紹了壓縮列表的編碼與數據結構,隨後介紹了壓縮列表的基本操做:建立壓縮列表、插入元素、刪除元素與遍歷,最後分析了壓縮列表連鎖更新的出現以及解決方案。