Redis 之快速列表

快速列表(quicklist)是在 Redis 3.2 版本中引入的,之前版本用的是 listNode 组成的无环双链表实现的。

// adlist.h

typedef struct listNode {
	struct listNode *prev; /* 前置节点 */
	struct listNode *next; /* 后置节点 */
	void *value; /* 节点的值 */
} listNode;

typedef struct list {
	listNode *head; /* 表头节点 */
	listNode *tail; /* 表尾节点 */
	unsigned long len; /* 链表所包含的节点数量 */
	void (*dup)(void *ptr); /* 节点值复制函数 */
	void (*free)(void *ptr); /* 节点值释放函数 */
	void (*match)(void *ptr, void *key); /* 节点值对比函数 */
} list;

总体来说,list/listNode 结构体还是很简单的,看下内存布局。

在这里插入图片描述
在 3.2 版本之前,是以 ziplist 和 list 作为其底层实现,规则和字典类似,默认是以 ziplist 为底层实现,当 ziplist 里节点超过 512 个或节点值长度超过 64 就转为 list 实现。

// redis.conf

list-max-ziplist-entries 512
list-max-ziplist-value 64

这么做的好处是,当元素较少较小时,采用 ziplist 可以有效节省内存,但 ziplist 的内存是连续的,当节点过多且修改节点值触发连锁更新时就必须重新分配存储空间,这样会影响 Redis 的性能,因而当元素个数超过 512 时就升级到双向链表了。

Redis 3.2 版本后,对列表底层结构进行了改进,不再是 ziplist 动态变化到 list 结构,而是由双向链表和 ziplist 相结合而成的 quicklist 结构。这个 quicklist 其实是综合考虑了时间效率和空间效率,是对两者的折中。

  • 列表的常用操作有 lpush、lpop、rpush、rpop,其实这些都是正对链表的头部和尾部进行的操作,因为链表里保存了头部和尾部的指针,因此这些常用的操作都非常便捷。另外列表的每个节点都保存中前后两个指针,加大了节点内存占用,每个节点都是单独的内存,这就造成了节点的不连续,容易产生内存碎片。同时由于节点内存的不连续,造成读取节点时不能充分利用 CPU 的局部性原理。
  • ziplist 在内存中是一整块连续的内存,所以存储读取效率高。但是修改时,都需要重新分配存储空间,而且有时也会引起连锁更新。
// quicklist.h

/* quicklist is a 40 byte struct (on 64-bit systems) describing a quicklist.
 * 'count' is the number of total entries.
 * 'len' is the number of quicklist nodes.
 * 'compress' is: -1 if compression disabled, otherwise it's the number
 *                of quicklistNodes to leave uncompressed at ends of quicklist.
 * 'fill' is the user-requested (or default) fill factor. */
typedef struct quicklist {
    quicklistNode *head;
    quicklistNode *tail;
    unsigned long count;        /* total count of all entries in all ziplists */
    unsigned long len;          /* number of quicklistNodes */
    int fill : 16;              /* fill factor for individual nodes */
    unsigned int compress : 16; /* depth of end nodes not to compress;0=off */
} quicklist;

quicklist 结构在内存占用 40 个字节:头四个字段都是 8 个字节,最后 fill 和 compress 加起来 32 位共 4 个字节,由于需要内存对齐因此要再加上 4 个字节,这样就是共 40 个字节了。

  • head、tail 分别执行节点的头和尾;
  • count 为 quicklist 中元素的总个数,也就是所有节点里所有 ziplist 里的所有节点加起来的值,说白了,就是列表里的值的个数;
  • len 代表 quicklistNodes 的个数;
  • fill 用来指明每个 quicklistNodes 节点中 ziplist 的长度,为正数时,表明每个 ziplist 最多有多少数据节点,为负数时,参见配置文件。
redis.conf

# Lists are also encoded in a special way to save a lot of space.
# The number of entries allowed per internal list node can be specified
# as a fixed maximum size or a maximum number of elements.
# For a fixed maximum size, use -5 through -1, meaning:
# -5: max size: 64 Kb  <-- not recommended for normal workloads
# -4: max size: 32 Kb  <-- not recommended
# -3: max size: 16 Kb  <-- probably not recommended
# -2: max size: 8 Kb   <-- good
# -1: max size: 4 Kb   <-- good
# Positive numbers mean store up to _exactly_ that number of elements
# per list node.
# The highest performing option is usually -2 (8 Kb size) or -1 (4 Kb size),
# but if your use case is unique, adjust the settings as necessary.
list-max-ziplist-size -2

fill 默认为 -2,也就是每个 quicklistNodes 上的 ziplist 大小不能超过8 Kb(1Kb = 1024 个字节)。

由于 Redis 列表常被访问的是两端的数据,如果中间数据过多,会造成内存浪费,为了节省内存,则需要对中间节点进行压缩。compress 就代表了两端各有 compress 个节点不用压缩,此值可参见配置文件。

redis.conf

# Lists may also be compressed.
# Compress depth is the number of quicklist ziplist nodes from *each* side of
# the list to *exclude* from compression.  The head and tail of the list
# are always uncompressed for fast push/pop operations.  Settings are:
# 0: disable all list compression
# 1: depth 1 means "don't start compressing until after 1 node into the list,
#    going from either the head or tail"
#    So: [head]->node->node->...->node->[tail]
#    [head], [tail] will always be uncompressed; inner nodes will compress.
# 2: [head]->[next]->node->node->...->node->[prev]->[tail]
#    2 here means: don't compress head or head->next or tail->prev or tail,
#    but compress all nodes between them.
# 3: [head]->[next]->[next]->node->node->...->node->[prev]->[prev]->[tail]
# etc.
list-compress-depth 0

compress 默认为 0,也就是都不压缩。当值大于 0 表明,N 表示 quicklist 两端各有 N 个节点不压缩,中间的节点压缩。

// quicklist.h

/* quicklistNode is a 32 byte struct describing a ziplist for a quicklist.
 * We use bit fields keep the quicklistNode at 32 bytes.
 * count: 16 bits, max 65536 (max zl bytes is 65k, so max count actually < 32k).
 * encoding: 2 bits, RAW=1, LZF=2.
 * container: 2 bits, NONE=1, ZIPLIST=2.
 * recompress: 1 bit, bool, true if node is temporarry decompressed for usage.
 * attempted_compress: 1 bit, boolean, used for verifying during testing.
 * extra: 10 bits, free for future use; pads out the remainder of 32 bits */
typedef struct quicklistNode {
    struct quicklistNode *prev;
    struct quicklistNode *next;
    unsigned char *zl;
    unsigned int sz;             /* ziplist size in bytes */
    unsigned int count : 16;     /* count of items in ziplist */
    unsigned int encoding : 2;   /* RAW==1 or LZF==2 */
    unsigned int container : 2;  /* NONE==1 or ZIPLIST==2 */
    unsigned int recompress : 1; /* was this node previous compressed? */
    unsigned int attempted_compress : 1; /* node can't compress; too small */
    unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;

quicklistNode 内存占 32 个字节,三个指针(在 64 位的操作系统中,指针占 8 个字节的内存)和两个整型,正好 32 个字节。

  • prev、next 分别执行前后两个 quicklistNode 节点;
  • zl 指向 ziplist 结构,当然了,这是在 quicklist 的 compress 为 0 的情况,或正好在前后两端不被压缩的节点内,一旦节点被压缩,那么 zl 指向 quicklistLZF 结构(这个待会儿介绍);
  • sz 为整个 ziplist 的大小;
  • count 为 ziplist 中的节点数, 16 位那么就是最多 2^16 个节点;
  • encoding 表示 ziplist 是否压缩了(以及用了哪个压缩算法)。1 表示没有压缩,2 表示被压缩了(而且用的是 LZF压缩算法);
  • container 是一个预留字段,默认为 2,表示使用 ziplist 结构保存数据;
  • recompress 表示使用类似 lindex 这样的命令查看了某一项本来压缩的数据时,需要把数据暂时解压,这时就设置 recompress 置为 1 做一个标记,等有机会再把数据重新压缩;
  • attempted_compress 测试时使用,不用理会;
  • extra 预留字段。
// quicklist.h

/* quicklistLZF is a 4+N byte struct holding 'sz' followed by 'compressed'.
 * 'sz' is byte length of 'compressed' field.
 * 'compressed' is LZF data with total (compressed) length 'sz'
 * NOTE: uncompressed length is stored in quicklistNode->sz.
 * When quicklistNode->zl is compressed, node->zl points to a quicklistLZF */
typedef struct quicklistLZF {
    unsigned int sz; /* LZF size in bytes*/
    char compressed[];
} quicklistLZF;

quicklistLZF 表示 quicklist 中 compress 非 1 时,被压缩 quicklistNode 中 zl 指向的结构体,默认占 4 个字节,柔性数组 compressed 紧邻其后。

  • sz 为被压缩后占的字节数
  • compressed 表示被压缩后的节点

LZF 算法的大致为:遍历输入字符串,数据与前面重复的,记录重复位置以及重复长度,否则就直接记录原始数据内容。

在介绍压缩列表时说过,当 Redis 解析 ziplist 的中节点时,会使用一个 zlentry 结构体存储被解析的节点数据。这里解析 quicklistNode 中的 ziplist 里的节点时,也提供了一个 quicklistEntry 结构体来存放解析后的节点数据。

// quicklist.h

typedef struct quicklistEntry {
    const quicklist *quicklist;
    quicklistNode *node;
    unsigned char *zi;
    unsigned char *value;
    long long longval;
    unsigned int sz;
    int offset;
} quicklistEntry;

quicklistEntry 占 48 个字节,前 5 个字段都是 8 个字节,后面两个字段各 4 个字节。

  • quicklist 指向当前的快速列表
  • node 指向当前的 quicklistNode 节点
  • zi 指向元素所在的 ziplist
  • value 指向该节点的字符串成员
  • longval 为该节点的整型值 【因为节点既可存字符串又可存整型,所以就分别用 value 和 longval】
  • sz 表示当前 ziplist 结构的大小【占多少字节】
  • offset 表示相对 ziplist 的偏移量

当遍历 quicklist 结构中的节点时,快速列表实现了自己的迭代器。

typedef struct quicklistIter {
    const quicklist *quicklist;
    quicklistNode *current;
    unsigned char *zi;
    long offset; /* offset in current ziplist */
    int direction;
} quicklistIter;

整个 quicklistIter 占 40 个字节,前 4 个字段占 32 个字节,最后一个占 4 个字节,由于内存对齐的需要,额外补了 4 个字节。

  • quicklist 指向当前的快速列表
  • current 指向当前的 quicklistNode 节点
  • zi 指向元素所在的 ziplist
  • offset 表示相对 ziplist 的偏移量
  • direction 表示迭代的方向【压缩列表介绍过,可以正向和逆向迭代】

quicklist 整个内存布局大致如下。
在这里插入图片描述
最后看看常用的 API。

首先是 quicklist 的初始化。

// quicklist.c

/* Create a new quicklist.
 * Free with quicklistRelease(). */
quicklist *quicklistCreate(void) {
    struct quicklist *quicklist;

    quicklist = zmalloc(sizeof(*quicklist));
    quicklist->head = quicklist->tail = NULL;
    quicklist->len = 0;
    quicklist->count = 0;
    quicklist->compress = 0;
    quicklist->fill = -2;
    return quicklist;
}

默认初始化后,quicklistNode 中每个 ziplist 的大小限制为 8Kb,且不对 ziplist 进行压缩。

在这里插入图片描述

Redis 除了这个 API 外,还提供了其他三个 API 来实现配置文件中 fill、compress 属性设置。

// quicklist.h

void quicklistSetCompressDepth(quicklist *quicklist, int depth);
void quicklistSetFill(quicklist *quicklist, int fill);
void quicklistSetOptions(quicklist *quicklist, int fill, int depth);

// quicklist.c

#define COMPRESS_MAX (1 << 16)
void quicklistSetCompressDepth(quicklist *quicklist, int compress) {
    if (compress > COMPRESS_MAX) {
        compress = COMPRESS_MAX;
    } else if (compress < 0) {
        compress = 0;
    }
    quicklist->compress = compress;
}

#define FILL_MAX (1 << 15)
void quicklistSetFill(quicklist *quicklist, int fill) {
    if (fill > FILL_MAX) {
        fill = FILL_MAX;
    } else if (fill < -5) {
        fill = -5;
    }
    quicklist->fill = fill;
}

void quicklistSetOptions(quicklist *quicklist, int fill, int depth) {
    quicklistSetFill(quicklist, fill);
    quicklistSetCompressDepth(quicklist, depth);
}

代码很简单,就是初始化 quicklist 后,分别设置对应的属性。

Redis 中列表添加元素对外提供的命令时 lpush、rpush,对应到 API 就是 quicklistPush。

// quicklist.c

/* Wrapper to allow argument-based switching between HEAD/TAIL pop */
void quicklistPush(quicklist *quicklist, void *value, const size_t sz,
                   int where) {
    if (where == QUICKLIST_HEAD) {
        quicklistPushHead(quicklist, value, sz);
    } else if (where == QUICKLIST_TAIL) {
        quicklistPushTail(quicklist, value, sz);
    }
}

其中 QUICKLIST_HEAD 对应 lpush,把元素插入到头部;QUICKLIST_TAIL 对应 rpush,把元素插入到尾部。

// quicklist.c

/* Add new entry to head node of quicklist.
 *
 * Returns 0 if used existing head.
 * Returns 1 if new head created. */
int quicklistPushHead(quicklist *quicklist, void *value, size_t sz) {
    quicklistNode *orig_head = quicklist->head;
    if (likely( /* 头部节点可以插入 */
            _quicklistNodeAllowInsert(quicklist->head, quicklist->fill, sz))) {
        quicklist->head->zl = /* 将节点 push 到 quicklist->head->zl */
            ziplistPush(quicklist->head->zl, value, sz, ZIPLIST_HEAD);
        quicklistNodeUpdateSz(quicklist->head); /* 更新 头部节点中记录 ziplist大小的 sz 字段 */
    } else { /* 头部节点满了,或是超过限制了 */
        quicklistNode *node = quicklistCreateNode(); /* 新建 quicklistNode  */
        node->zl = ziplistPush(ziplistNew(), value, sz, ZIPLIST_HEAD); /* 把 node->zl 指向初始化 ziplist 结构 */

        quicklistNodeUpdateSz(node); /* 更新 头部节点中记录 ziplist大小的 sz 字段 */
        _quicklistInsertNodeBefore(quicklist, quicklist->head, node); /* 将新创建的节点插入到头节点前 */
    }
    quicklist->count++; /* 更新 quicklist 中元素的个数 */
    quicklist->head->count++; /* 更新头部节点的 ziplist 的元素的个数 */
    return (orig_head != quicklist->head); /* 是否新建了头部节点,1 是 0 否 */
}

/* Add new entry to tail node of quicklist.
 *
 * Returns 0 if used existing tail.
 * Returns 1 if new tail created.
 * 这里和头部插入类似,就不一一注释了
 */
int quicklistPushTail(quicklist *quicklist, void *value, size_t sz) {
    quicklistNode *orig_tail = quicklist->tail;
    if (likely(
            _quicklistNodeAllowInsert(quicklist->tail, quicklist->fill, sz))) {
        quicklist->tail->zl =
            ziplistPush(quicklist->tail->zl, value, sz, ZIPLIST_TAIL);
        quicklistNodeUpdateSz(quicklist->tail);
    } else {
        quicklistNode *node = quicklistCreateNode();
        node->zl = ziplistPush(ziplistNew(), value, sz, ZIPLIST_TAIL);

        quicklistNodeUpdateSz(node);
        _quicklistInsertNodeAfter(quicklist, quicklist->tail, node);
    }
    quicklist->count++;
    quicklist->tail->count++;
    return (orig_tail != quicklist->tail);
}

lpush、rpush 在添加元素的时候,都需要判断对应的头节点/尾结点是否能继续添加元素,不能的话则新创建 quicklistNode 和 ziplist,再添加元素,并且更新 quicklist 的头节点/尾结点。

Redis 添加元素还提供了个 LINSERT 命令,也就是任意位置写入。

127.0.0.1:6379> RPUSH mylist Hello World
(integer) 2
127.0.0.1:6379> LINSERT mylist BEFORE World There
(integer) 3
127.0.0.1:6379> lrange mylist 0 -1
1) "Hello"
2) "There"
3) "World"

查看相关代码

// quicklist.c


void quicklistInsertBefore(quicklist *quicklist, quicklistEntry *entry,
                           void *value, const size_t sz) {
    _quicklistInsert(quicklist, entry, value, sz, 0);
}

void quicklistInsertAfter(quicklist *quicklist, quicklistEntry *entry,
                          void *value, const size_t sz) {
    _quicklistInsert(quicklist, entry, value, sz, 1);
}

before 和 after 都调用了 _quicklistInsert 函数。

// quicklist.c

/* Insert a new entry before or after existing entry 'entry'.
 *
 * If after==1, the new value is inserted after 'entry', otherwise
 * the new value is inserted before 'entry'. */
REDIS_STATIC void _quicklistInsert(quicklist *quicklist, quicklistEntry *entry,
                                   void *value, const size_t sz, int after) {
    int full = 0, at_tail = 0, at_head = 0, full_next = 0, full_prev = 0;
    int fill = quicklist->fill;
    quicklistNode *node = entry->node;
    quicklistNode *new_node = NULL;

    if (!node) {
        /* we have no reference node, so let's create only node in the list */
        D("No node given!");
        new_node = quicklistCreateNode();
        new_node->zl = ziplistPush(ziplistNew(), value, sz, ZIPLIST_HEAD);
        __quicklistInsertNode(quicklist, NULL, new_node, after);
        new_node->count++;
        quicklist->count++;
        return;
    }

    /* Populate accounting flags for easier boolean checks later */
    if (!_quicklistNodeAllowInsert(node, fill, sz)) {
        D("Current node is full with count %d with requested fill %lu",
          node->count, fill);
        full = 1;
    }

    if (after && (entry->offset == node->count)) {
        D("At Tail of current ziplist");
        at_tail = 1;
        if (!_quicklistNodeAllowInsert(node->next, fill, sz)) {
            D("Next node is full too.");
            full_next = 1;
        }
    }

    if (!after && (entry->offset == 0)) {
        D("At Head");
        at_head = 1;
        if (!_quicklistNodeAllowInsert(node->prev, fill, sz)) {
            D("Prev node is full too.");
            full_prev = 1;
        }
    }

    /* Now determine where and how to insert the new element */
    if (!full && after) {
        D("Not full, inserting after current position.");
        quicklistDecompressNodeForUse(node);
        unsigned char *next = ziplistNext(node->zl, entry->zi);
        if (next == NULL) {
            node->zl = ziplistPush(node->zl, value, sz, ZIPLIST_TAIL);
        } else {
            node->zl = ziplistInsert(node->zl, next, value, sz);
        }
        node->count++;
        quicklistNodeUpdateSz(node);
        quicklistRecompressOnly(quicklist, node);
    } else if (!full && !after) {
        D("Not full, inserting before current position.");
        quicklistDecompressNodeForUse(node);
        node->zl = ziplistInsert(node->zl, entry->zi, value, sz);
        node->count++;
        quicklistNodeUpdateSz(node);
        quicklistRecompressOnly(quicklist, node);
    } else if (full && at_tail && node->next && !full_next && after) {
        /* If we are: at tail, next has free space, and inserting after:
         *   - insert entry at head of next node. */
        D("Full and tail, but next isn't full; inserting next node head");
        new_node = node->next;
        quicklistDecompressNodeForUse(new_node);
        new_node->zl = ziplistPush(new_node->zl, value, sz, ZIPLIST_HEAD);
        new_node->count++;
        quicklistNodeUpdateSz(new_node);
        quicklistRecompressOnly(quicklist, new_node);
    } else if (full && at_head && node->prev && !full_prev && !after) {
        /* If we are: at head, previous has free space, and inserting before:
         *   - insert entry at tail of previous node. */
        D("Full and head, but prev isn't full, inserting prev node tail");
        new_node = node->prev;
        quicklistDecompressNodeForUse(new_node);
        new_node->zl = ziplistPush(new_node->zl, value, sz, ZIPLIST_TAIL);
        new_node->count++;
        quicklistNodeUpdateSz(new_node);
        quicklistRecompressOnly(quicklist, new_node);
    } else if (full && ((at_tail && node->next && full_next && after) ||
                        (at_head && node->prev && full_prev && !after))) {
        /* If we are: full, and our prev/next is full, then:
         *   - create new node and attach to quicklist */
        D("\tprovisioning new node...");
        new_node = quicklistCreateNode();
        new_node->zl = ziplistPush(ziplistNew(), value, sz, ZIPLIST_HEAD);
        new_node->count++;
        quicklistNodeUpdateSz(new_node);
        __quicklistInsertNode(quicklist, node, new_node, after);
    } else if (full) {
        /* else, node is full we need to split it. */
        /* covers both after and !after cases */
        D("\tsplitting node...");
        quicklistDecompressNodeForUse(node);
        new_node = _quicklistSplitNode(node, entry->offset, after);
        new_node->zl = ziplistPush(new_node->zl, value, sz,
                                   after ? ZIPLIST_HEAD : ZIPLIST_TAIL);
        new_node->count++;
        quicklistNodeUpdateSz(new_node);
        __quicklistInsertNode(quicklist, node, new_node, after);
        _quicklistMergeNodes(quicklist, node);
    }

    quicklist->count++;
}

因为任意插入考虑的情况要多,表现最为明显的就是代码分支较多,这里罗列下相关的场景逻辑。

  • 插入位置所在的 ziplist 大小没有超过限制时(可以参见 fill 的配置),直接插入;
  • 插入位置所在的 ziplist 大小超过了限制,但插入的位置位于 ziplist 两端,并且相邻的 quicklistNode 的 ziplist 大小没有超过限制,那么就转而插入到相邻的 quicklistNode 的 ziplist 中;
  • 插入位置所在的 ziplist 大小超过了限制,但插入的位置位于 ziplist 两端,并且相邻的 quicklistNode 的 ziplist 大小也超过限制,于是新建一个 quicklistNode 节点、一个 ziplist ,并把 zl 指向 ziplist,然后插入到 ziplist 中【当然了这里还得考虑是否启用 compress,也就是节点压缩】。
  • 以上情况不满足,主要对应于插入在 ziplist 中间的任意位置,需要分割当前 quicklistNode 节点。最后如果能够合并,还要合并。

【注】 此博文中的 Redis 版本为 5.0。

参考书籍 :

【1】redis设计与实现(第二版)
【2】Redis 5设计与源码分析

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值