Redis中内部数据结构ziplist的作用是什么

发布时间:2021-08-07 16:49:42 作者:Leah
来源:亿速云 阅读:140

本篇文章为大家展示了Redis中内部数据结构ziplist的作用是什么,内容简明扼要并且容易理解,绝对能使你眼前一亮,通过这篇文章的详细介绍希望你能有所收获。

什么是ziplist

Redis官方对于ziplist的定义是(出自ziplist.c的文件头部注释):

The ziplist is a specially encoded dually linked list that is designed to be very memory efficient. It stores both strings and integer values, where integers are encoded as actual integers instead of a series of characters. It allows push and pop operations on either side of the list in O(1) time.

翻译一下就是说:ziplist是一个经过特殊编码的双向链表,它的设计目标就是为了提高存储效率。ziplist可以用于存储字符串或整数,其中整数是按真正的二进制表示进行编码的,而不是编码成字符串序列。它能以O(1)的时间复杂度在表的两端提供pushpop操作。

实际上,ziplist充分体现了Redis对于存储效率的追求。一个普通的双向链表,链表中每一项都占用独立的一块内存,各项之间用地址指针(或引用)连接起来。这种方式会带来大量的内存碎片,而且地址指针也会占用额外的内存。而ziplist却是将表中每一项存放在前后连续的地址空间内,一个ziplist整体占用一大块内存。它是一个表(list),但其实不是一个链表(linked list)。

另外,ziplist为了在细节上节省内存,对于值的存储采用了变长的编码方式,大概意思是说,对于大的整数,就多用一些字节来存储,而对于小的整数,就少用一些字节来存储。我们接下来很快就会讨论到这些实现细节。

ziplist的数据结构定义

ziplist的数据结构组成是本文要讨论的重点。实际上,ziplist还是稍微有点复杂的,它复杂的地方就在于它的数据结构定义。一旦理解了数据结构,它的一些操作也就比较容易理解了。

我们接下来先从总体上介绍一下ziplist的数据结构定义,然后举一个实际的例子,通过例子来解释ziplist的构成。如果你看懂了这一部分,本文的任务就算完成了一大半了。

从宏观上看,ziplist的内存结构如下:

<zlbytes><zltail><zllen><entry>...<entry><zlend>

各个部分在内存上是前后相邻的,它们分别的含义如下:

上面的定义中还值得注意的一点是:<zlbytes>, <zltail>, <zllen>既然占据多个字节,那么在存储的时候就有大端(big endian)和小端(little endian)的区别。ziplist采取的是小端模式来存储,这在下面我们介绍具体例子的时候还会再详细解释。

我们再来看一下每一个数据项<entry>的构成:

<prevrawlen><len><data>

我们看到在真正的数据(<data>)前面,还有两个字段:

那么<prevrawlen><len>是怎么进行变长编码的呢?各位读者打起精神了,我们终于讲到了ziplist的定义中最繁琐的地方了。

先说<prevrawlen>。它有两种可能,或者是1个字节,或者是5个字节:

  1. 如果前一个数据项占用字节数小于254,那么<prevrawlen>就只用一个字节来表示,这个字节的值就是前一个数据项的占用字节数。

  2. 如果前一个数据项占用字节数大于等于254,那么<prevrawlen>就用5个字节来表示,其中第1个字节的值是254(作为这种情况的一个标记),而后面4个字节组成一个整型值,来真正存储前一个数据项的占用字节数。

有人会问了,为什么没有255的情况呢?

这是因为:255已经定义为ziplist结束标记<zlend>的值了。在ziplist的很多操作的实现中,都会根据数据项的第1个字节是不是255来判断当前是不是到达ziplist的结尾了,因此一个正常的数据的第1个字节(也就是<prevrawlen>的第1个字节)是不能够取255这个值的,否则就冲突了。

<len>字段就更加复杂了,它根据第1个字节的不同,总共分为9种情况(下面的表示法是按二进制表示):

  1. |00pppppp| - 1 byte。第1个字节最高两个bit是00,那么<len>字段只有1个字节,剩余的6个bit用来表示长度值,最高可以表示63 (2^6-1)。

  2. |01pppppp|qqqqqqqq| - 2 bytes。第1个字节最高两个bit是01,那么<len>字段占2个字节,总共有14个bit用来表示长度值,最高可以表示16383 (2^14-1)。

  3. |10__|qqqqqqqq|rrrrrrrr|ssssssss|tttttttt| - 5 bytes。第1个字节最高两个bit是10,那么len字段占5个字节,总共使用32个bit来表示长度值(6个bit舍弃不用),最高可以表示2^32-1。需要注意的是:在前三种情况下,<data>都是按字符串来存储的;从下面第4种情况开始,<data>开始变为按整数来存储了。

  4. |11000000| - 1 byte。<len>字段占用1个字节,值为0xC0,后面的数据<data>存储为2个字节的int16_t类型。

  5. |11010000| - 1 byte。<len>字段占用1个字节,值为0xD0,后面的数据<data>存储为4个字节的int32_t类型。

  6. |11100000| - 1 byte。<len>字段占用1个字节,值为0xE0,后面的数据<data>存储为8个字节的int64_t类型。

  7. |11110000| - 1 byte。<len>字段占用1个字节,值为0xF0,后面的数据<data>存储为3个字节长的整数。

  8. |11111110| - 1 byte。<len>字段占用1个字节,值为0xFE,后面的数据<data>存储为1个字节的整数。

  9. |1111xxxx| - - (xxxx的值在0001和1101之间)。这是一种特殊情况,xxxx从1到13一共13个值,这时就用这13个值来表示真正的数据。注意,这里是表示真正的数据,而不是数据长度了。也就是说,在这种情况下,后面不再需要一个单独的<data>字段来表示真正的数据了,而是<len><data>合二为一了。另外,由于xxxx只能取0001和1101这13个值了(其它可能的值和其它情况冲突了,比如0000和1110分别同前面第7种第8种情况冲突,1111跟结束标记冲突),而小数值应该从0开始,因此这13个值分别表示0到12,即xxxx的值减去1才是它所要表示的那个整数数据的值。

好了,ziplist的数据结构定义,我们介绍完了,现在我们看一个具体的例子。

Redis中内部数据结构ziplist的作用是什么

上图是一份真实的ziplist数据。我们逐项解读一下:

总结一下,这个ziplist里存了4个数据项,分别为:

(好吧,被你发现了~~tielei实际上当然不是20岁,他哪有那么年轻啊……)

实际上,这个ziplist是通过两个hset命令创建出来的。这个我们后半部分会再提到。

好了,既然你已经阅读到这里了,说明你还是很有耐心的(其实我写到这里也已经累得不行了)。可以先把本文收藏,休息一下,回头再看后半部分。

接下来我要贴一些代码了。

ziplist的接口

我们先不着急看实现,先来挑几个ziplist的重要的接口,看看它们长什么样子:

unsigned char *ziplistNew(void);
unsigned char *ziplistMerge(unsigned char **first, unsigned char **second);
unsigned char *ziplistPush(unsigned char *zl, unsigned char *s, unsigned int slen, int where);
unsigned char *ziplistIndex(unsigned char *zl, int index);
unsigned char *ziplistNext(unsigned char *zl, unsigned char *p);
unsigned char *ziplistPrev(unsigned char *zl, unsigned char *p);
unsigned char *ziplistInsert(unsigned char *zl, unsigned char *p, unsigned char *s, unsigned int slen);
unsigned char *ziplistDelete(unsigned char *zl, unsigned char **p);
unsigned char *ziplistFind(unsigned char *p, unsigned char *vstr, unsigned int vlen, unsigned int skip);
unsigned int ziplistLen(unsigned char *zl);

我们从这些接口的名字就可以粗略猜出它们的功能,下面简单解释一下:

ziplist的插入逻辑解析

ziplist的相关接口的具体实现,还是有些复杂的,限于篇幅的原因,我们这里只结合代码来讲解插入的逻辑。插入是很有代表性的操作,通过这部分来一窥ziplist内部的实现,其它部分的实现我们也就会很容易理解了。

ziplistPush和ziplistInsert都是插入,只是对于插入位置的限定不同。它们在内部实现都依赖一个名为__ziplistInsert的内部函数,其代码如下(出自ziplist.c):

static 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; /* initialized to avoid warning. Using a value
                                    that is easy to see if for some reason
                                    we use it uninitialized. */
    zlentry tail;
    /* Find out prevlen for the entry that is inserted. */
    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 = zipRawEntryLength(ptail);
        }
    }
    /* See if the entry can be encoded */
    if (zipTryEncoding(s,slen,&value,&encoding)) {
        /* 'encoding' is set to the appropriate integer encoding */
        reqlen = zipIntSize(encoding);
    } else {
        /* 'encoding' is untouched, however zipEncodeLength will use the
         * string length to figure out how to encode it. */
        reqlen = slen;
    }
    /* We need space for both the length of the previous entry and
     * the length of the payload. */
    reqlen += zipPrevEncodeLength(NULL,prevlen);
    reqlen += zipEncodeLength(NULL,encoding,slen);
    /* When the insert position is not equal to the tail, we need to
     * make sure that the next entry can hold this entry's length in
     * its prevlen field. */
    nextdiff = (p[0] != ZIP_END) ? zipPrevLenByteDiff(p,reqlen) : 0;
    /* Store offset because a realloc may change the address of zl. */
    offset = p-zl;
    zl = ziplistResize(zl,curlen+reqlen+nextdiff);
    p = zl+offset;
    /* Apply memory move when necessary and update tail offset. */
    if (p[0] != ZIP_END) {
        /* Subtract one because of the ZIP_END bytes */
        memmove(p+reqlen,p-nextdiff,curlen-offset-1+nextdiff);
        /* Encode this entry's raw length in the next entry. */
        zipPrevEncodeLength(p+reqlen,reqlen);
        /* Update offset for tail */
        ZIPLIST_TAIL_OFFSET(zl) =
            intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+reqlen);
        /* When the tail contains more than one entry, we need to take
         * "nextdiff" in account as well. Otherwise, a change in the
         * size of prevlen doesn't have an effect on the *tail* offset. */
        zipEntry(p+reqlen, &tail);
        if (p[reqlen+tail.headersize+tail.len] != ZIP_END) {
            ZIPLIST_TAIL_OFFSET(zl) =
                intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+nextdiff);
        }
    } else {
        /* This element will be the new tail. */
        ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(p-zl);
    }
    /* When nextdiff != 0, the raw length of the next entry has changed, so
     * we need to cascade the update throughout the ziplist */
    if (nextdiff != 0) {
        offset = p-zl;
        zl = __ziplistCascadeUpdate(zl,p+reqlen);
        p = zl+offset;
    }
    /* Write the entry */
    p += zipPrevEncodeLength(p,prevlen);
    p += zipEncodeLength(p,encoding,slen);
    if (ZIP_IS_STR(encoding)) {
        memcpy(p,s,slen);
    } else {
        zipSaveInteger(p,value,encoding);
    }
    ZIPLIST_INCR_LENGTH(zl,1);
    return zl;
}

我们来简单解析一下这段代码:

hash与ziplist

hash是Redis中可以用来存储一个对象结构的比较理想的数据类型。一个对象的各个属性,正好对应一个hash结构的各个field。

我们在网上很容易找到这样一些技术文章,它们会说存储一个对象,使用hash比string要节省内存。实际上这么说是有前提的,具体取决于对象怎么来存储。如果你把对象的多个属性存储到多个key上(各个属性值存成string),当然占的内存要多。但如果你采用一些序列化方法,比如 Protocol Buffers,或者 Apache Thrift,先把对象序列化为字节数组,然后再存入到Redis的string中,那么跟hash相比,哪一种更省内存,就不一定了。

当然,hash比序列化后再存入string的方式,在支持的操作命令上,还是有优势的:它既支持多个field同时存取(hmset/hmget),也支持按照某个特定的field单独存取(hset/hget)。

实际上,hash随着数据的增大,其底层数据结构的实现是会发生变化的,当然存储效率也就不同。在field比较少,各个value值也比较小的时候,hash采用ziplist来实现;而随着field增多和value值增大,hash可能会变成dict来实现。当hash底层变成dict来实现的时候,它的存储效率就没法跟那些序列化方式相比了。

当我们为某个key第一次执行 hset key field value 命令的时候,Redis会创建一个hash结构,这个新创建的hash底层就是一个ziplist。

robj *createHashObject(void) {
    unsigned char *zl = ziplistNew();
    robj *o = createObject(OBJ_HASH, zl);
    o->encoding = OBJ_ENCODING_ZIPLIST;
    return o;
}

上面的createHashObject函数,出自object.c,它负责的任务就是创建一个新的hash结构。可以看出,它创建了一个type = OBJ_HASHencoding = OBJ_ENCODING_ZIPLIST的robj对象。

实际上,本文前面给出的那个ziplist实例,就是由如下两个命令构建出来的。

hset user:100 name tielei
hset user:100 age 20

每执行一次hset命令,插入的field和value分别作为一个新的数据项插入到ziplist中(即每次hset产生两个数据项)。

当随着数据的插入,hash底层的这个ziplist就可能会转成dict。那么到底插入多少才会转呢?

还记得本文开头提到的两个Redis配置吗?

hash-max-ziplist-entries 512
hash-max-ziplist-value 64

这个配置的意思是说,在如下两个条件之一满足的时候,ziplist会转成dict:

Redis的hash之所以这样设计,是因为当ziplist变得很大的时候,它有如下几个缺点:

上述内容就是Redis中内部数据结构ziplist的作用是什么,你们学到知识或技能了吗?如果还想学到更多技能或者丰富自己的知识储备,欢迎关注亿速云行业资讯频道。

推荐阅读:
  1. redis的作用是什么
  2. Redis中内部数据结构intset的作用是什么

免责声明:本站发布的内容(图片、视频和文字)以原创、转载和分享为主,文章观点不代表本网站立场,如果涉及侵权请联系站长邮箱:is@yisu.com进行举报,并提供相关证据,一经查实,将立刻删除涉嫌侵权内容。

redis ziplist

上一篇:CSS中background属性的应用

下一篇:如何解决某些HTML字符打不出来的问题

相关阅读

您好,登录后才能下订单哦!

密码登录
登录注册
其他方式登录
点击 登录注册 即表示同意《亿速云用户服务条款》