壓縮列表(ziplist)是由一系列特殊編碼的記憶體區塊構成的列表,它對於Redis的資料儲存優化有著非常重要的作用本文主要和大家分享redis中使用非常多的一個資料結構壓縮鍊錶ziplist。這個資料結構在redis中說是無所不在也毫不過分,除了鍊錶以外,許多其他資料結構也是用它來過渡的,例如前面文章提到的SortedSet。下面話不多說了,來一起看看詳細的介紹吧。
一、壓縮鍊錶ziplist資料結構簡介
首先從整體來看下ziplist的架構,如下圖:
# 壓縮鍊錶ziplist結構圖
# 可以看出字段很多,位元組大小也不同,但這也就是壓縮鍊錶的精髓所在了,我們依序總結一下。
字段 | 含義 |
---|---|
# zlbytes | 此字段是壓縮鍊錶的第一個字段,是無符號整數,佔用4個位元組。用於表示整個壓縮鍊錶所佔用的位元組數(包括它自己)。 |
# zltail | 無符號整數,佔用4個位元組。用於儲存從壓縮鍊錶頭部到最後一個entry(不是尾元素zlend)的偏移量,在快速跳到鍊錶尾部的場景使用。 |
# zllen | 無符號整數,佔用2個位元組。用於儲存壓縮鍊錶中包含的entry總數。 |
# zlend | 特殊的entry,用來表示壓縮鍊錶的末端。佔用一個位元組,值恆為255。 |
總結為ziplist的頭跟尾,以下再總結一下重中之重的entry欄位。
一般來說,一個entry由prevlen,encoding,entry-data三個字段組成,但當entry是個很小的整數時,會根據編碼省略掉entry-data字段。以下依序進行總結:
首先是字段prevlen:表示前一個entry的長度,有兩種編碼方式。
當長度小於255位元組時,用一個位元組儲存。
當長度大於等於255時,用五個位元組進行存儲,其中第一個位元組會被設定為255表示前一個entry的長度由後面四個位元組表示。
然後是字段encoding:它會根據當前元素內容的不同會採用不同的編碼方式,如下:
1.若元素內容為字串,encoding的值分別為:
# 00xx xxxx :00開頭表示字串的長度以6個bit表示。
01xx xxxx | xxxx xxxx :01開頭表示字串的長度由14bit表示,這14個bit採用大端儲存。
1000 0000 | xxxx xxxx | xxxx xxxx | xxxx xxxx | xxxx xxxx :10開頭表示後續的四個位元組為字串長度,這32個bit採用大端儲存。
2.若元素內容為數字,encoding的值分別為:
# 1100 0000:表示數字佔用後面2個位元組。
1101 0000:表示數字佔用後面4個位元組。
1110 0000:表示數字佔用後面8個位元組。
1111 0000 :表示數字佔用後面3個位元組。
1111 1110 :表示數字佔用後面1個位元組。
1111 1111 :表示壓縮鍊錶中最後一個元素(特殊編碼)。
1111 xxxx :表示只用後4位表示0~12的整數,由於0000,1110跟1111三種已經被佔用,也就是說這裡的xxxx四位只能表示0001~1101,轉換成十進制就是數字1~13,但是redis規定它用來表示0~12,因此當遇到這個編碼時,我們需要取出後四位然後減1來得到正確的值。
最後是字段entry-data:如果元素的值為字串,則保存元素本身的值。如果元素的值為很小的數字(按上方編碼規則即0~12),則沒有該欄位。
壓縮鍊錶的編碼非常複雜,但這也正是這個資料結構的精髓所在,一起來看一個例子:
# 註:這個例子是redis原始碼中提到的
//由元素2,5组成的压缩链表 [0f 00 00 00] [0c 00 00 00] [02 00] [00 f3] [02 f6] [ff] | | | | | | zlbytes zltail entries "2" "5" end //字符串"Hello World"编码后的内容 [02] [0b] [48 65 6c 6c 6f 20 57 6f 72 6c 64]
上面是一段用十六進位表示2,5兩個元素組成的壓縮鍊錶。
首先前四個位元組表示整個ziplist佔用的位元組數,因為redis採用小端存儲,所以15個位元組表示為0f 00 00 00。
接下來的4個位元組表示末尾元素偏移量,是從ziplist頭(zlbytes)開始到最後一個元素(註:不是尾節點)的偏移量,也是採用小端存儲,因此表示為0c 00 00 00。
再後面是由兩個位元組組成的表示元素個數的zllen字段,在我們這個例子中有兩個元素,加上小端存儲,所以表示為02 00。
接下來是元素本身,首先由一個變長的字端表示前一個元素的長度,2作為第一個元素,它前一個元素的大小就是0,因此佔用一個位元組00。依照我們上面所說的編碼規則,元素2跟5屬於0~12之間的數字,需要使用1111 xxxx格式進行編碼,2轉成二進位為0010,再加上1就是0011(加1的原因上面已經說明),組合起來元素2就表示為00 f3。同理元素5表示為02 f6。
最後就是尾元素,使用未被佔用的編碼1111 1111即255。
接下來我們在這個壓縮鍊錶末尾插入一個字串元素Hello World,先看看該如何編碼該字串,按照約定的編碼規則,首先要用一個字節表示前一個元素的長度,這裡前一個元素時5,總共佔用了兩個字節,因此首先用一個字節節表示前一個元素的長度02,接下來是字串的編碼,我們要加入的字串長度為11(空格也算),轉換成二進位就是1011,依照字串的編碼規則,使用0000 1011表示,轉為十六進位就是0b,最後再加上我們字串本身的ASCII碼,綜合起來就得出該字串的編碼:[02] [0b] [48 65 6c 6c 6f 20 57 6f 72 6c 64]。
此時整個壓縮鍊錶變成:
[0f 00 00 00] [0c 00 00 00] [02 00] [00 f3] [02 f6] [02 0b 48 65 6c 6c 6f 20 57 6f 72 6c 64] [ff] | | | | | | | zlbytes zltail entries "2" "5" "Hello World" end
二、壓縮鍊錶ziplist指令原始碼分析
搞清楚上面的編碼規則以後,我們再來看下壓縮鍊錶ziplist的部分操作源碼,本文就創建壓縮鍊錶,插入元素,刪除元素,查找元素四個操作來總結一下壓縮鍊錶的基本原理。
首先是創建:
//定义由zlbytes,zltail跟zllen组成的压缩链表的头大小 #define ZIPLIST_HEADER_SIZE (sizeof(uint32_t)*2+sizeof(uint16_t)) //创建一个压缩链表,并且返回指向该链表的指针 unsigned char *ziplistNew(void) { //这里之所以+1是因为尾元素占用一个字节,这也是一个压缩链表最小尺寸 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; //设置尾元素(上面只是申请空间) zl[bytes-1] = ZIP_END; return zl; }
建立壓縮鍊錶的邏輯很簡單,就是申請固定的包含頭尾節點的空間,然後初始化鍊錶上下文。
与创建相比,添加元素的源码就非常冗长了,为了便于理解,在看源码之前我们先自己梳理一下添加元素的逻辑。
首先我们要找到指定插入位置的前一个元素的大小,因为该属性是新元素的组成部分之一。
然后我们要对当前元素进行编码来获得相应的encoding字段跟实际元素值的字段。
新插入元素的后继元素的prevlen字段要更新,因为它前面的元素已经改变。这里可能引起级联更新(删除元素也有该问题),原因就是prevlen字段大小是可变的。
上面三步是核心步骤,其余的还有更新尾节点偏移量,修改链表元素个数等操作。当然,由于压缩链表是基于数组实现的,因此在插入或删除元素的时候内存拷贝也是必不可少的。
总结好上面的步骤以后,我们开始一步一步分析源码,比较长,慢慢看:
//四个参数依次是:压缩链表,插入位置(新元素插入p元素后面),元素值,元素长度 unsigned char *__ziplistInsert(unsigned char *zl, unsigned char *p, unsigned char *s, unsigned int slen) { //这里是保存当前链表的长度 size_t curlen = intrev32ifbe(ZIPLIST_BYTES(zl)), reqlen; unsigned int prevlensize, prevlen = 0; size_t offset; int nextdiff = 0; unsigned char encoding = 0; long long value = 123456789; zlentry tail; //1. 这段逻辑目的就是获取前置元素的长度 if (p[0] != ZIP_END) { //如果插入位置的元素不是尾元素,则获取该元素的长度 //这里为了后面使用方便进行了拆分,prevlensize保存encoding字段的长度,prevlen保存元素本身的长度 ZIP_DECODE_PREVLEN(p, prevlensize, prevlen); } else { //如果插入位置的元素是尾元素,那么需要把新元素插入链表尾端 //获取到链表最后一个元素(注:最后一个元素不等于尾元素) unsigned char *ptail = ZIPLIST_ENTRY_TAIL(zl); if (ptail[0] != ZIP_END) { //如果最后一个元素不是尾元素,则该元素为新元素的前置元素,获取该元素长度 prevlen = zipRawEntryLength(ptail); } //否则说明链表还没有任何元素,即新元素的前置元素长度为0 } //2. 对新元素进行编码,获取新元素的总大小 if (zipTryEncoding(s,slen,&value,&encoding)) { //如果是数字,则按数字进行编码 reqlen = zipIntSize(encoding); } else { //元素长度即为字符串长度 reqlen = slen; } //新元素总长度为值的长度加上前置元素跟encoding元素的长度 reqlen += zipStorePrevEntryLength(NULL,prevlen); reqlen += zipStoreEntryEncoding(NULL,encoding,slen); //如果插入的位置不是链表尾,则要对新元素的后续元素的prevlen字段进行判断 //根据上面的编码规则,该字段可能需要扩容 int forcelarge = 0; nextdiff = (p[0] != ZIP_END) ? zipPrevLenByteDiff(p,reqlen) : 0; if (nextdiff == -4 && reqlen <p> 分析完插入元素的逻辑,长舒一口气,真的很长,细节也很多。</p><p> 接下来在再看下删除元素的过程,与添加相比,删除相对要简单一些,清空当前元素以后,需要把后继元素一个一个拷贝上来(这也是数组跟链表两个数据结构的差别),然后注意是否需要级联更新,上代码:</p><pre class="brush:php;toolbar:false">//参数依次为:压缩链表,删除元素的其实位置,删除元素的个数 unsigned char *__ziplistDelete(unsigned char *zl, unsigned char *p, unsigned int num) { unsigned int i, totlen, deleted = 0; size_t offset; int nextdiff = 0; zlentry first, tail; //读取p指向的元素保存在first中 zipEntry(p, &first); for (i = 0; p[0] != ZIP_END && i 0) { if (p[0] != ZIP_END) { //判断元素大小是否有改变 nextdiff = zipPrevLenByteDiff(p,first.prevrawlen); //修改删除元素之后的元素的prevlen字段 p -= nextdiff; zipStorePrevEntryLength(p,first.prevrawlen); //更新末尾元素的偏移量 ZIPLIST_TAIL_OFFSET(zl) =intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))-totlen); //当删除元素的后继元素不止有一个时,新的末尾元素偏移量需要加上nextdiff 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); } else { //直接删除到链表末尾,因此不需要内存拷贝,只需修改最后一个元素的偏移量 ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe((first.p-zl)-first.prevrawlen); } //resize数组大小 offset = first.p-zl; zl = ziplistResize(zl, intrev32ifbe(ZIPLIST_BYTES(zl))-totlen+nextdiff); //修改链表元素个数 ZIPLIST_INCR_LENGTH(zl,-deleted); p = zl+offset; //nextdiff != 0表示元素大小发生变化,需要进行级联更新 if (nextdiff != 0) zl = __ziplistCascadeUpdate(zl,p); } return zl; }
最后我们看下元素的查找操作:
//参数依次为:压缩链表,要查找元素的值,要查找元素的长度,每次比较之间跳过的元素个数 unsigned char *ziplistFind(unsigned char *p, unsigned char *vstr, unsigned int vlen, unsigned int skip) { int skipcnt = 0; unsigned char vencoding = 0; long long vll = 0; //只要还没到尾元素就不断循环 while (p[0] != ZIP_END) { unsigned int prevlensize, encoding, lensize, len; unsigned char *q; //查询链表当前元素的prevlen字段 ZIP_DECODE_PREVLENSIZE(p, prevlensize); //查询链表当前元素的encoding字段 ZIP_DECODE_LENGTH(p + prevlensize, encoding, lensize, len); q = p + prevlensize + lensize; //已到达需要比较的元素位置 if (skipcnt == 0) { //如果链表中的当前元素时字符串 if (ZIP_IS_STR(encoding)) { //跟要查找的字符串进行比较 if (len == vlen && memcmp(q, vstr, vlen) == 0) { //匹配成功,则要查找元素的指针 return p; } } else { //如果当前元素为数字且vencoding为0 if (vencoding == 0) { //尝试对要查找的值进行数字编码 if (!zipTryEncoding(vstr, vlen, &vll, &vencoding)) { //如果编码失败,则说明要查找的元素根本不是数字 //然后把vencoding设置为最大值,起一个标记作用 //也就是说后面就不用尝试把要查找的值编码成数字了 vencoding = UCHAR_MAX; } assert(vencoding); } //如果vencoding != UCHAR_MAX,则说明要查找的元素成功编码为数字 if (vencoding != UCHAR_MAX) { //按数字取出当前链表中的元素 long long ll = zipLoadInteger(q, encoding); if (ll == vll) { //如果两个数字相等,则返回元素指针 return p; } } } //重置需要跳过的元素个数 skipcnt = skip; } else { //跳过元素 skipcnt--; } //遍历下个元素 p = q + len; } //遍历完整个链表,没有找到元素 return NULL; }
到这里就把压缩链表的创建,添加,删除,查找四个基本操作原理总结完了。
三、压缩链表ziplist数据结构总结
压缩链表ziplist在redis中的应用非常广泛,它算是redis中最具特色的数据结构了。该数据结构的精髓其实就在于文章第一部分总结的编码规则,先理清楚这部分内容,后面的源码可以简单看下加深理解。
不得不说源码部分着实有点冗长,确实需要耐心,我自己在读的过程中也很头大。如果对源码感兴趣的话,建议按我的方法,先自己梳理某个操作(例如上面提到的插入元素)需要做哪些事情,然后再看代码可能会更好理解一些。
相关推荐:
以上是redis壓縮鍊錶ziplist源碼詳解的詳細內容。更多資訊請關注PHP中文網其他相關文章!