7.ziplist——redis基础数据结构之压缩列表

精髓总结(抄)

  • 压缩列表是一种为节约空间而实现的线性数据结构,本质上是字节数组。
  • 压缩列表元素可以为整数或字符串。
  • 压缩列表在快速列表、列表对象和哈希对象中都有使用。
  • 压缩列表添加(平均复杂度O(n))与删除节点(平均复杂度O(n)),可能会触发连锁更新(平均复杂度O(n^2)),因为触发机率不高所以不影响性能。
  • 因为节点存储数据可能为字符串,而字符串匹配为O(n)复杂度,所以压缩列表查找节点平均复杂度为O(n^2)。

压缩列表的构成

image.png

压缩列表由:

  • 总字节长度(4字节)
  • 尾节点偏移量(4字节)
  • 节点数量(2字节)
    节点以及值为255的特殊结束符(1字节)组成,通过列表的开始地址向后偏移尾节点偏移量个字节,可以以O(1)时间复杂度获取尾节点信息。

源码分析

这是创建压缩列表的函数和相关宏

#define ZIP_END 255         /* 末尾设为255 */
#define ZIPLIST_BYTES(zl)       (*((uint32_t*)(zl))) // 获取zl总字节长度指针
#define ZIPLIST_TAIL_OFFSET(zl) (*((uint32_t*)((zl)+sizeof(uint32_t)))) //获取zl尾节点偏移量指针
#define ZIPLIST_LENGTH(zl)      (*((uint16_t*)((zl)+sizeof(uint32_t)*2))) //获取zl节点数量指针
#define ZIPLIST_HEADER_SIZE     (sizeof(uint32_t)*2+sizeof(uint16_t)) //zl头的长度
#define ZIPLIST_END_SIZE        (sizeof(uint8_t)) //zl尾巴的长度

|uint32|uint32|uint16|.......entrys.....|uint8|
unsigned char *ziplistNew(void) {
    unsigned int bytes = ZIPLIST_HEADER_SIZE+ZIPLIST_END_SIZE;
    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;
}

列表节点构成

image.png

一段数组,从前往后分别是

  1. 前面节点长度信息,占1-5个字节,如果前面长度小于254就一个字节,如果大于254就5个字节,且第一个填254
  2. 本节点编码信息,占一个字节,表明数据是string or int
  3. 长度信息,如果存的是整数,0位,如果存的是string,1-8位,存int的话,直接根据encoding得到int类型就知道数据多长了,不需要再记录长度信息
  4. 本节点数据,长度不定


    image.png

注意一点!在实际存储中,并不是用zlentry结构体存节点,而是用一段连续数组的形式,做函数操作的时候调用zipEntry()把指向数组的指针p转化成zlentry结构体形式,相当于是一个解压

下面看源码


typedef struct zlentry {
    // 前一节点长度信息的长度,(有点绕,就是本节点prevrawlen的长度)
    unsigned int prevrawlensize;
    // 前一节点长度
    unsigned int prevrawlen;
    // 当前节点长度信息长度(len的长度)
    unsigned int lensize;  
    // 当前节点长度
    unsigned int len;
    // 当前节点头部信息长度
    unsigned int headersize;
    // 当前节点数据编码
    unsigned char encoding;     
    unsigned char *p;           
} zlentry;

// 相关宏,几个解析的j函数
// 解析节点指针ptr[0~4],得到要用几个字节来存ptr前面节点的长度信息
// 1个字节,或者5个字节
#define ZIP_DECODE_PREVLENSIZE(ptr, prevlensize) do {                          \
    if ((ptr)[0] < ZIP_BIG_PREVLEN) {                                          \
        (prevlensize) = 1;                                                     \
    } else {                                                                   \
        (prevlensize) = 5;                                                     \
    }                                                                          \
} while(0)
// 解析prevlensize后,解析prevlen
// 前一节点长度大于254时,使用5个字节保存前一节点的长度信息。
//   首个字节固定为254,后续的4个字节用来存储长度信息
#define ZIP_DECODE_PREVLEN(ptr, prevlensize, prevlen) do {                     \
    ZIP_DECODE_PREVLENSIZE(ptr, prevlensize);                                  \
    if ((prevlensize) == 1) {                                                  \
        (prevlen) = (ptr)[0];       
//                                           \
    } else { /* prevlensize == 5 */                                            \
        (prevlen) = ((ptr)[4] << 24) |                                         \
                    ((ptr)[3] << 16) |                                         \
                    ((ptr)[2] <<  8) |                                         \
                    ((ptr)[1]);                                                \
    }                                                                          \
} while(0)
// 解析encoding,如果小于1100 0000,就&11000000后得到encoding
#define ZIP_ENTRY_ENCODING(ptr, encoding) do {  \
    (encoding) = ((ptr)[0]); \
    if ((encoding) < ZIP_STR_MASK) (encoding) &= ZIP_STR_MASK; \
} while(0)
//把指针解压成结构体,上面的4部分依次解析
static inline void zipEntry(unsigned char *p, zlentry *e) {
    // 解析前节点长度信息
    ZIP_DECODE_PREVLEN(p, e->prevrawlensize, e->prevrawlen);
    // 解析本节点编码信息,编码信息从p[prevrawlensize]开始,占一个字节
    ZIP_ENTRY_ENCODING(p + e->prevrawlensize, e->encoding);
    //解析本节点长度信息
    ZIP_DECODE_LENGTH(p + e->prevrawlensize, e->encoding, e->lensize, e->len);
    //填写本节点数据
    assert(e->lensize != 0); /* check that encoding was valid. */
    e->headersize = e->prevrawlensize + e->lensize;
    e->p = p;
}

encoding规则

encoding表明了数组里长度信息的长度
ZIP_STR_MASK = 11000000
大于ZIP_STR_MASK的都是整数,小于ZIP_STR_MASK是字符串
看一下解析长度的宏定义

#define ZIP_DECODE_LENGTH(ptr, encoding, lensize, len) do {                    \
    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 {                                                               \
            (lensize) = 0; /* bad encoding, should be covered by a previous */ \
            (len) = 0;     /* ZIP_ASSERT_ENCODING / zipEncodingLenSize, or  */ \
                           /* match the lensize after this macro with 0.    */ \
        }                                                                      \
    } else {                                                                   \
        (lensize) = 1;                                                         \
        if ((encoding) == ZIP_INT_8B)  (len) = 1;                              \
        else if ((encoding) == ZIP_INT_16B) (len) = 2;                         \
        else if ((encoding) == ZIP_INT_24B) (len) = 3;                         \
        else if ((encoding) == ZIP_INT_32B) (len) = 4;                         \
        else if ((encoding) == ZIP_INT_64B) (len) = 8;                         \
        else if (encoding >= ZIP_INT_IMM_MIN && encoding <= ZIP_INT_IMM_MAX)   \
            (len) = 0; /* 4 bit immediate */                                   \
        else                                                                   \
            (lensize) = (len) = 0; /* bad encoding */                          \
    }                                                                          \
} while(0)

可以看到,如果是字符串,长度信息有6位,14位,32位情况

  • 如果编码是ZIP_STR_06B,说明长度信息6位,1字节
  • ZIP_STR_14B,说明长度信息14位,2字节,左移操作就是相加
  • 其他同理
    对于整数,1位就可以表示长度了,因为整数最大就64位,8字节,长度信息最大填个8就行,sizeof(8) = 1

压缩列表相关操作

插入

ziplist提供了两个插入接口,push和insert

  • push选择插入到头或者尾巴
  • insert插入到中间,插入节点至p指针指向元素前方
unsigned char *ziplistPush(unsigned char *zl, unsigned char *s, unsigned int slen, int where) {
    unsigned char *p;
    p = (where == ZIPLIST_HEAD) ? ZIPLIST_ENTRY_HEAD(zl) : ZIPLIST_ENTRY_END(zl);
    return __ziplistInsert(zl,p,s,slen);
}

unsigned char *ziplistInsert(unsigned char *zl, unsigned char *p, unsigned char *s, unsigned int slen) {
    //插入节点至p指针指向元素前方
    return __ziplistInsert(zl,p,s,slen);
}

两者都是调用__ziplistInsert 【可以把非接口函数写成__xxx的形式
一段一段看这个函数

    if (p[0] != ZIP_END) {
        ZIP_DECODE_PREVLEN(p, prevlensize, prevlen);
    } else {
        unsigned char *ptail = ZIPLIST_ENTRY_TAIL(zl);
        if (ptail[0] != ZIP_END) {
            prevlen = zipRawEntryLengthSafe(zl, curlen, ptail);
        }
    }

第一步,解析插入节点前面节点的长度,因为要插在p前面,插入前p存的就是前面节点长度信息

    if (zipTryEncoding(s,slen,&value,&encoding)) {
        /* 'encoding' is set to the appropriate integer encoding */
        reqlen = zipIntSize(encoding);
    } else {
        /* 'encoding' is untouched, however zipStoreEntryEncoding will use the
         * string length to figure out how to encode it. */
        reqlen = slen;
    }

第二步,尝试能不能编码成整数类型,reqlen初始化成数据的长度zipTryEncoding是编码函数

    reqlen += zipStorePrevEntryLength(NULL,prevlen);
    reqlen += zipStoreEntryEncoding(NULL,encoding,slen);

第三步,计算需要存储前节点长度和encoding的长度,

  • zipStorePrevEntryLength的答案是1或者5
  • 如果编码是整数,zipStoreEntryEncoding的答案是1,字符串则返回1+表示字符串的长度,到此,新节点的长度就全算出来了
   int forcelarge = 0;
    nextdiff = (p[0] != ZIP_END) ? zipPrevLenByteDiff(p,reqlen) : 0;
    if (nextdiff == -4 && reqlen < 4) { //1
        nextdiff = 0;
        forcelarge = 1;
    }

第四步zipPrevLenByteDiff(p,reqlen)算的是:
p用来编码插入新节点长度信息需要的长度 - p原来用来编码前一节点的长度
返回可能是0, 4, -4
代码1处,nextdiff = -4 说明p.prevrawlensize多了4字节,reqlen<4说明新节点正好填补上,不用向右偏移腾位置,这一步说到底就是计算偏移量

  • forcelarge代表着本来可以使用1个字节表示prevLength的情况下使用5个字节表示
  • 该操作在插入的时候只会让ziplist占用的bytes增加而不会减少
    offset = p-zl;
    newlen = curlen+reqlen+nextdiff;
    zl = ziplistResize(zl,newlen);
    p = zl+offset;

第五步,给zl重新分配内存,p指向新内存地址的对应节点(插入在p前面)

    memmove(p+reqlen,p-nextdiff,curlen-offset-1+nextdiff);

第六步,memmove()挪位置,给新插入节点腾地方,p被挪到了p+reqlen

        if (forcelarge)
            zipStorePrevEntryLengthLarge(p+reqlen,reqlen);
        else
            zipStorePrevEntryLength(p+reqlen,reqlen);

第七步,在新p的位置上填写插入节点的长度信息
-zipStorePrevEntryLengthLarge(p, len)是填写大节点的,把p[0]填上254,len填在p[1-4]上
-zipStorePrevEntryLength(p, len) 根据实际大小填写长度信息

ZIPLIST_TAIL_OFFSET(zl) =
            intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+reqlen);

第8步,更新zl记录的尾巴偏移量,加上reqlen即可

assert(zipEntrySafe(zl, newlen, p+reqlen, &tail, 1));

检查下偏移后的p是否越界

    if (nextdiff != 0) {
        offset = p-zl;
        zl = __ziplistCascadeUpdate(zl,p+reqlen);
        p = zl+offset;
    }

如果nextdiff不等于0,说明插入节点后的节点p的长度变了,后续节点都要更新,这叫连锁更新。下个小节详细介绍

    /* Write the entry */
    p += zipStorePrevEntryLength(p,prevlen);
    p += zipStoreEntryEncoding(p,encoding,slen);
    if (ZIP_IS_STR(encoding)) {
        memcpy(p,s,slen);
    } else {
        zipSaveInteger(p,value,encoding);
    }
    ZIPLIST_INCR_LENGTH(zl,1);
    return zl;

【这个时候p指向插入节点位置】

  • 写插入节点的前面节点长度信息
  • 写p的数据
  • 增加zl节点数

连锁更新

在zl中,如果前一节点长度发生变化,可能会引起后一节点的长度发生变化(如果前一节点的长度之前小于254,变更后大于254,则后一节点的 前一节点长度信息占用字节会从1个字节变为5个字节)。考虑一种极端情况,插入节点后续的节点长度都是介于250~253之间,此时插入一个节点长度大于254的节点,会引发连锁更新,后面的节点全都要变长

redis中连锁更新的函数

// 从p开始连锁更新zl ,p是处理好的,不用改变长度
unsigned char *__ziplistCascadeUpdate(unsigned char *zl, unsigned char *p) 

我们逐步看一下怎么进行连锁更新的

    zipEntry(p, &cur); /* no need for "safe" variant since the input pointer was validated by the function that returned it. */
    firstentrylen = prevlen = cur.headersize + cur.len;
    prevlensize = zipStorePrevEntryLength(NULL, prevlen);
    prevoffset = p - zl;
    p += prevlen;

第一步,把p解析道cur,算出所有需要改变节点前面那个节点的信息,存下来,然后p指向第一个需要更新的节点p1,如下图:


image.png
    while (p[0] != ZIP_END) {
        // 本轮循环p指向的节点信息更新到cur里,并检查是否越界
        assert(zipEntrySafe(zl, curlen, p, &cur, 0));

        // 发现记录正确,当前节点记录的前面节点的长度真的等于前面节点的长度 
        // 说明当前节点以及后续都无需更新,停止记录
        if (cur.prevrawlen == prevlen) break;

        // 如果当前节点用来存前面节点长度信息的长度够用,就把当前节点的前面长度信息更新成正确的,并且确保不会缩小当前节点长度
        // 更新后,后续也无需更新了,停止记录
        if (cur.prevrawlensize >= prevlensize) {
            if (cur.prevrawlensize == prevlensize) { 
                zipStorePrevEntryLength(p, prevlen);
            } else {
                zipStorePrevEntryLengthLarge(p, prevlen);
            }
            break; 
        }
        // 确保解析正确
        assert(cur.prevrawlen == 0 || cur.prevrawlen + delta == prevlen);

        rawlen = cur.headersize + cur.len; // 当前节点的长度,下一轮作为前一节点更新前长度
        prevlen = rawlen + delta;  // 当前节点更新后的长度,下一轮循环是根据这个来判断当前节点需要的长度
        prevlensize = zipStorePrevEntryLength(NULL, prevlen); // 记录当前节点更新后需要的长度 (1/5)
        prevoffset = p - zl;    // 当前节点更新前偏移,最后指向最后一个需要更新的节点的偏移量
        p += rawlen;    //下一个节点的位置
        extra += delta; // 最后需要扩大的总长度,每个+4字节
        cnt++;  //要更新的节点数
    }

第二步,记录总共有多少节点要更新,并且更新一些变量后续使用
这里需要注意这些变量

  • prevoffset: 每一轮更新成当前节点更新前偏移,最后指向最后一个需要更新的节点的偏移量
  • extra:最后需要扩大的总字节数,数值上等于需要更新的节点数乘4
  • cnt:需要更新的节点数
    if (tail == zl + prevoffset) { //如果更新前的tail(最后一个节点)正好是最后一个节点,更新tail偏移量时要减去4
        /* When the the last entry we need to update is also the tail, update tail offset
         * unless this is the only entry that was updated (so the tail offset didn't change). */
        if (extra - delta != 0) {
            ZIPLIST_TAIL_OFFSET(zl) =
                intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+extra-delta);
        }
    } else {
        /* Update the tail offset in cases where the last entry we updated is not the tail. */
        ZIPLIST_TAIL_OFFSET(zl) =
            intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+extra);
    }

判断最后一个要更新的节点是不是tail(最后一个节点),来更新zl的tail偏移
如果是tail,tail偏移量要减4,画个图就明白了


image.png

可以看到,上面的tail后移了3个delta,下面的后移2个delta

offset = p - zl;
zl = ziplistResize(zl, curlen + extra);
p = zl + offset;
memmove(p + extra, p, curlen - offset - 1);
p += extra;

这个时候,p指向的是第一个不需要更新的节点
这里为zl重新计算并分配了内存,并将后面不用更新的节点都往后移动


image.png

查找

ziplist中搜索元素是通过下面这个函数,参数在注释里:

// zl:列表头指针
// p:从p开始搜索
// vstr:查找的字符串
// vlen:查找字符串的长度
// skip:要跳过几个节点
unsigned char *ziplistFind(unsigned char *zl, unsigned char *p, unsigned char *vstr, unsigned int vlen, unsigned int skip)

函数不难,看一下就知道啥意思,需要注意的是:

  • 如果节点存储的是string,直接 memcmp(q, vstr, vlen)判定是否相同
  • 如果节点存储的是int,zipTryEncoding(vstr, vlen, &vll, &vencoding)把查找的char编码成long long存在vll,再long long ll = zipLoadInteger(q, e.encoding);解码节点,最后比较vll和ll

删除

zl提供了两个接口删除节点

// 1.删除*p,并且把p指向删除节点后面的节点,为了入参传入**p
unsigned char *ziplistDelete(unsigned char *zl, unsigned char **p)

// 2.删除下标在 [index,index+num)区间的节点
unsigned char *ziplistDeleteRange(unsigned char *zl, int index, unsigned int num)

删除的最后要连锁更新

if (nextdiff != 0)
            zl = __ziplistCascadeUpdate(zl,p);

Q&A

为什么要压缩列表?

参考

https://juejin.cn/post/6844904012819464206#heading-2
http://www.lucienxian.top/2019/08/16/redis%E8%AE%BE%E8%AE%A1%E4%B8%8E%E5%AE%9E%E7%8E%B0%E2%80%94%E2%80%94%E5%8E%8B%E7%BC%A9%E5%88%97%E8%A1%A8/

你可能感兴趣的:(7.ziplist——redis基础数据结构之压缩列表)