Redis基础系列(三)——列表对象

197 阅读9分钟

这是我参与更文挑战的第5天,活动详情查看:更文挑战

1、列表对象简介

  Redis列表是简单的字符串列表,按照插入顺序进行排序,支持从头部或者尾部添加元素,因此列表对象也经常用作为队列。一个列表键最多可以含23212^{32-1}个元素。

列表对象常用命令如下:

lpush key value1[value2] //将一个或多个值插入列表头中并返回列表长度,如果key不存在则创建key后插入,如果key存在但不是列表类型则返回一个错误信息
lpushsx key value1[value2] //将一个或多个值插入到已存在的列表头部并返回列表长度,列表不存在则操作无效
lindex key index //获取指定位置元素
lrange key start stop //获取列表指定范围内的元素
lpop key //移除并返回列表第一个元素,key不存在时返回nil
lrem key count value //移除值为value的元素,count>0 则从表头开始移除count个元素,count<0 则从表尾开始移除count绝对值个元素,count=0 则移除所有值为value的元素
lset key index value //通过索引设置列表元素的值
llen key //获取列表长度
ltrim key start stop //对一个列表进行修剪,让列表只保留指定区间内的元素,不在指定区间之内的元素都将被删除。
blpop key1 [key2 ] timeout //移出并获取列表的第一个元素, 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。
linsert key BEFORE|AFTER pivot value //在列表的元素前或者后插入元素
rpush key value1 [value2] //在列表尾中添加一个或多个值
rpush key value //为已存在的列表尾添加值
rpop key //移除并获取列表最后一个元素
brpop key1 [key2 ] timeout //移出并获取列表的最后一个元素, 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。
rpoplpush source destination //移除列表的最后一个元素,并将该元素添加到另一个列表并返回
brpoplpush source destination timeout //从列表中弹出一个值,将弹出的元素插入到另外一个列表中并返回它; 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。

2、列表对象编码

  Redis列表对象的编码可以是ziplist 或者 linkedlistziplist编码的底层结构为压缩列表linkedlist编码的底层为双端链表的数据结构。

如下为ziplist编码的列表对象示意图

ziplist编码的列表对象.png

如下为linkedlist编码的列表对象示意图

注:链表中的每个节点是一个字符串对象,而不是一个简单的字符串。

linkedlist编码的列表对象示意图.png

2.1 编码转换

当列表对象可以同时满足以下两个条件时, 列表对象使用 ziplist 编码:

  • 列表对象保存的所有字符串元素的长度都小于 64 字节;

  • 列表对象保存的元素数量小于 512 个;

不能满足这两个条件的列表对象需要使用 linkedlist 编码。

注:以上两个条件的上限值是可以修改的, 具体请看配置文件中关于 list-max-ziplist-value 选项和 list-max-ziplist-entries 选项的说明。

3、压缩列表(ziplist)

  压缩列表是 Redis 为了节约内存而开发的, 由一系列特殊编码的连续内存块组成的顺序型数据结构。

3.1 压缩列表构成

  一个压缩列表可以包含任意多个节点(entry), 每个节点可以保存一个字节数组或者一个整数值。如下,展示了压缩列表的各个组成部分。

ziplist.png

属性类型长度用途
zlbytesuint32_t4 字节记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重分配, 或者计算 zlend 的位置时使用。
zltailuint32_t4 字节记录压缩列表表尾节点距离压缩列表的起始地址有多少字节: 通过这个偏移量,程序无须遍历整个压缩列表就可以确定表尾节点的地址。
zllenuint16_t2 字节记录了压缩列表包含的节点数量: 当这个属性的值小于 UINT16_MAX65535)时, 这个属性的值就是压缩列表包含节点的数量; 当这个值等于 UINT16_MAX 时, 节点的真实数量需要遍历整个压缩列表才能计算得出。
entryX列表节点不定压缩列表包含的各个节点,节点的长度由节点保存的内容决定。
zlenduint8_t1 字节特殊值 0xFF (十进制 255 ),用于标记压缩列表的末端。

3.2 压缩列表节点的构成

  每个压缩列表节点可以保存一个字节数组或者一个整数值

字节数组长度可以有以下3种

  • 长度小于等于 632612^{6}-1)字节的字节数组;
  • 长度小于等于 1638321412^{14}-1)字节的字节数组;
  • 长度小于等于 429496729523212^{32}-1)字节的字节数组;

整数值则可以有以下6种长度

  • 4 位长,介于 012 之间的无符号整数;
  • 1 字节长的有符号整数;
  • 3 字节长的有符号整数;
  • int16_t 类型整数;
  • int32_t 类型整数;
  • int64_t 类型整数。

  每个压缩列表节点都由 previous_entry_lengthencodingcontent 三个部分组成。

3.2.1 previos_entry_length属性

  previos_entry_length 以字节为单位,记录压缩列表中前一个节点的长度,previos_entry_length属性长度可以是1字节或5字节

  • 如果前一个节点长度小于254字节,previos_entry_length长度为1字节
  • 如果前一个节点长度大于等于254字节,previos_entry_length长度为5字节

  通过previos_entry_length属性可以实现ziplist从表尾向表头遍历。

3.2.2 encoding 属性

  节点的 encoding 属性记录了节点的 content 属性所保存数据的类型以及长度:

  • 一字节、两字节或者五字节长, 值的最高位为 0001 或者 10 的是字节数组编码: 这种编码表示节点的 content 属性保存着字节数组, 数组的长度由编码除去最高两位之后的其他位记录;

  • 一字节长, 值的最高位以 11 开头的是整数编码: 这种编码表示节点的 content 属性保存着整数值, 整数值的类型和长度由编码除去最高两位之后的其他位记录;

    编码编码长度content 属性保存的值
    110000001 字节int16_t 类型的整数。
    110100001 字节int32_t 类型的整数。
    111000001 字节int64_t 类型的整数。
    111100001 字节24 位有符号整数。
    111111101 字节8 位有符号整数。
    1111xxxx1 字节使用这一编码的节点没有相应的 content 属性, xxxx 保存 content

3.2.3 content属性

  节点的 content 属性负责保存节点的值, 节点值可以是一个字节数组或者整数, 值的类型和长度由节点的 encoding 属性决定。

3.3 连锁更新

  在一个压缩列表中, 有多个连续的、长度介于 250 字节到 253 字节之间的节点 e1eN 。因为 e1eN 的所有节点的长度都小于 254 字节, 所以记录这些节点的长度只需要 1 字节长的 previous_entry_length 属性, 换句话说, e1eN 的所有节点的 previous_entry_length 属性都是 1 字节长的。

  如果我们将一个长度大于等于 254 字节的新节点 new 设置为压缩列表的表头节点, 那么 new 将成为 e1 的前置节点,因为 e1previous_entry_length 属性仅长 1 字节, 它没办法保存新节点 new 的长度, 所以程序将对压缩列表执行空间重分配操作, 并将 e1 节点的 previous_entry_length 属性从原来的 1 字节长扩展为 5 字节长。

  现在, 麻烦的事情来了 —— e1 原本的长度介于 250 字节至 253 字节之间, 在为 previous_entry_length 属性新增四个字节的空间之后, e1 的长度就变成了介于 254 字节至 257 字节之间, 而这种长度使用 1 字节长的 previous_entry_length 属性是没办法保存的。

  因此, 为了让 e2previous_entry_length 属性可以记录下 e1 的长度, 程序需要再次对压缩列表执行空间重分配操作, 并将 e2 节点的 previous_entry_length 属性从原来的 1 字节长扩展为 5 字节长。

  正如扩展 e1 引发了对 e2 的扩展一样, 扩展 e2 也会引发对 e3 的扩展, 而扩展 e3 又会引发对 e4 的扩展……为了让每个节点的 previous_entry_length 属性都符合压缩列表对节点的要求, 程序需要不断地对压缩列表执行空间重分配操作, 直到 eN 为止。

  这就是连锁更新,因为一次操作,导致连续多次的空间扩展操作。除了添加节点会引发连锁更新,删除节点也是有可能引起连锁更新的。因为连锁更新在最坏情况下需要对压缩列表执行 N 次空间重分配操作, 而每次空间重分配的最坏复杂度为 O(N) , 所以连锁更新的最坏复杂度为 O(N^2) 。

要注意的是, 尽管连锁更新的复杂度较高, 但它真正造成性能问题的几率是很低的:

  • 首先, 压缩列表里要恰好有多个连续的、长度介于 250 字节至 253 字节之间的节点, 连锁更新才有可能被引发, 在实际中, 这种情况并不多见;
  • 其次, 即使出现连锁更新, 但只要被更新的节点数量不多, 就不会对性能造成任何影响: 比如说, 对三五个节点进行连锁更新是绝对不会影响性能的;

4、双端列表(linkedlist)

  双端链表中的每一个节点都有两个指针指向其前驱节点和后继节点。**表头节点前驱节点指向NULL,表尾节点后继节点指向NULL。**链表和链表节点的结构如下。

//链表节点结构,双端链表结构
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)

  //节点值对比函数,对比链表节点所保存的值和另一个输入值是否相等
  int (*match)(void *ptr,void *key)
}list;

linkedlist.png

Redis的链表实现特性

  • 双端,每个节点带有prev和next指针,获取某个节点的前后节点复杂度都是O(1)。
  • 无环,表头节点的prev指针和表尾节点的next指针都指向NULL。
  • 带有表头节点指针和表尾指针,获取表头表尾节点复杂度都是O(1)。
  • 带有链表长度计数器,获取链表长度复杂度为O(1)。
  • 多态,节点使用void*指针保存节点值,并且可以通过dup、free、match三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。