从源码看redis的list结构

1,252 阅读6分钟

rpush用来往list的队尾加入值

> rpush mylist "a" "b"
(integer) 2

使用lrange可以查看插入的值

> lrange mylist 0 2
1) "a"
2) "b"

linsert可以在指定的元素之前或者之后插入值

> linsert mylist before "m" "l"
-1
> linsert mylist before "d" "e"
5
> lrange mylist 0 -1
1) "e"
2) "d"
3) "c"
4) "a"
5) "b"

指定的元素不存在则不会插入

rpop可以对应弹出队尾的值

> lrange mylist 0 -1
1) "e"
2) "d"
3) "c"
4) "a"
5) "b"
6) "a"
7) "b"
8) "c"
> rpop mylist
"c"

rpush命令执行追踪

rpush的入口在 rpushCommand

Code.SLICE.source("robj *lobj = lookupKeyWrite(c->db,c->argv[1]);\n" +
        "\n" +
        "    if (lobj && lobj->type != OBJ_LIST) {\n" +
        "        addReply(c,shared.wrongtypeerr);\n" +
        "        return;\n" +
        "    }")
        .interpretation("查找之前是不是有过同名的key,如果有,但是key的编码方式不是 OBJ_LIST直接报错返回");
Code.SLICE.source("for (j = 2; j < c->argc; j++) ")
        .interpretation("遍历所有的value,一个个的插入");
Code.SLICE.source("if (!lobj) {\n" +
        "            lobj = createQuicklistObject();\n" +
        "            quicklistSetOptions(lobj->ptr, server.list_max_ziplist_size,\n" +
        "                                server.list_compress_depth);\n" +
        "            dbAdd(c->db,c->argv[1],lobj);\n" +
        "        }\n" +
        "        listTypePush(lobj,c->argv[j],where);\n" +
        "        pushed++;")
        .interpretation("如果之前没有存在一模一样的key,重新创建一个,它的类型是 quicklist,然后存起来,再执行插入");

执行插入,和一个数据结构相关,就是quicklist,quicklist的每一个节点为quicklistNode

doubly linked list

一个常规的redis双向列表形式如下

[0] <-> [1] <-> [2] <-> ... <-> [N]
  • 每一个节点的listNode包含3个指针:prev/next/value(3个指针的长度为24字节)。- 每个数据指向一个 redisObject 对象,它包括32bit的元数据,1个int的引用,1个指向内容的指针(总共16字节)
  • 在redisObject里面的值是sds,它包括两个int的字段和string内容(总共 4字节+contents)

也就是说,每个节点,至少包含40个字节的元数据内容,还有其它的一些内部为了计算的分配,那么如果只往内部 插入 10个字符的string,显然元素据的空间超过了存储的内容,这显得有些浪费

ziplist

redis使用ziplist来解决存储小量数据 常规双向链表 的问题。它的结构如下

[total size][tail offset][cached element count][entry 0]...[entry N][END]

一个空的ziplist只占据了11 bytes

[size=4 bytes][tail offset=4 bytes][count=2 bytes][END=1 byte]

对于每一个entry来说,它的结构为

[length of previous entry][length of this entry][contents]
  1. 前一个entry的长度用来保证可以做逆向遍历。
  2. ziplist使用变长的编码,如果存储小的内容,偏移也更小

但是这种方式也带来了问题

  1. 每次插入元素需要将后面的元素后移,同时插入意味着需要重新分配内存
  2. 删除元素的时候,所有元素要往前移

这意味着ziplist最好保持一定的大小来做到空间和时间的最有效利用

quicklist

一个quicklist的结构大致如下

[ziplist 0] <-> [ziplist 1] <-> ... <-> [ziplist N]

通过 list-max-ziplist-entries 来控制每个节点的 ziplist的数目,超过限定则新建一个 quicklistnode。 优势

  1. 任何长度的list都能有效的利用内存
  2. 仍然是O(1)获取head和tail
  3. 删除某个区域的list效率提升
  4. 维持了原有的RDB和AOF格式
  5. 如果限制每个ziplist只保留1个entry,它就转换成了原始的linked list但却有更好的内存利用率

这种方式也带来了额外的操作

  1. 在quicklist的中间插入元素,可能需要拆开原有的ziplist并创建额外的quicklistNOde
  2. 从quicklist中删除元素,需要把多个ziplist进行合并
  3. 所有的插入意味着需要重新分配ziplist
  4. 在头部插入需要把原有的ziplist实体后移

quicklist的结构如下

Code.SLICE.source("typedef struct quicklist {" +
        "    quicklistNode *head;        /*头结点*/" +
        "    quicklistNode *tail;        /*尾结点*/" +
        "    unsigned long count;        /* 所有ziplists中的所有entry的个数 */\n" +
        "    unsigned long len;          /* quicklistNodes节点的个数 */\n" +
        "    int fill : 16;              /* ziplist大小设置,存放配置 list-max-ziplist-size */\n" +
        "    unsigned int compress : 16; /* 节点压缩深度设置,存放配置 list_compress_depth */\n" +
        "} quicklist;")
        .interpretation("head和tail两个函数指针最多8字节,count和len属于无符号long最多8字节,最后两字段共32bits,总共40字节")
        .interpretation("list-max-ziplist-size 取正数按照个数来限制ziplist的大小,比如5表示每个quicklist节点ziplist最多包含5个数据项,最大为 1 << 15" +
                "-1表示每个quicklist节点上的ziplist大小不能超过 4kb,-2(默认值)表示不能超过 8kb依次类推,最大为 -5,不能超过 64kb")
        .interpretation("list_compress_depth 0表示不压缩,1表示quicklist两端各有1个节点不压缩,其余压缩,2表示quicklist两端各有2个节点不压缩,其余压缩,依次类推,最大为 1 << 16");
//...
Code.SLICE.source("typedef struct quicklistNode {\n" +
        "    struct quicklistNode *prev;  /*当前节点的前一个结点*/" +
        "    struct quicklistNode *next;  /*当前节点的下一个结点*/" +
        "    unsigned char *zl;           /*数据指针。如果当前节点没有被压缩,它指向的是一个ziplist,否则是 quicklistLZF*/" +
        "    unsigned int sz;             /* zl所指向的 ziplist 的总大小,计算被压缩了,指向的也是压缩前的大小*/\n" +
        "    unsigned int count : 16;     /* ziplist中数据项的个数 */\n" +
        "    unsigned int encoding : 2;   /* RAW==1(没有压缩) or LZF==2(压缩了) */\n" +
        "    unsigned int container : 2;  /* NONE==1 or ZIPLIST==2 */\n" +
        "    unsigned int recompress : 1; /* 识别这个数据之前是不是压缩过,比如再检查数据的过程中是要解压缩的过后需要还原*/\n" +
        "    unsigned int attempted_compress : 1; /* node can't compress; too small */\n" +
        "    unsigned int extra : 10; /* 扩展字段,目前没有用*/\n" +
        "} quicklistNode;")
        .interpretation("从前向和后项来看,quickList 本身就是一个 双向链表")
        .interpretation("1:结构自身的大小 prev、next、zl 各8字节,sz无符号 int 为4字节,其余按照后面的bit算一共32bits共4字节,总共32字节");

quicklistnode本身还可以根据节点离head/tail的距离做压缩,达到更高的空间节约

结论

list在底层会使用quicklist的结构来存储,每一个quicklistNode的节点都会存储一个可配置的ziplist大小量,如果有多个quicklistNode,它会根据配置的压缩深度,来使用lzf算法进行压缩

附录

rpush源码追踪
quicklist与其它list实现方式的对比以及性能测试说明 matt.sh
Redis内部数据结构详解(5)——quicklist 张铁蕾
Redis内部数据结构详解(4)——ziplist 张铁蕾
redis设计与实现
redis开发与运维