通俗易懂Redis - list数据类型详解

231 阅读7分钟

1、前言

欢迎大家来到通俗易懂Redis,先从redis的基础数据结构说起,给大家简单的进行剖析。Redis有5种基础数据类型,分别为:string (字符串)、list (列表)、set (集合)、hash (哈希) 和 zset (有序集合)。Redis 底层的数据结构一共有6种,如下图第二排部分,它和数据类型对应关系也如下图: 未命名文件 (3).png 今天主要给大家分享我对list数据类型的一点见解,有错误的地方请大家指正。

2、list(列表)介绍

Redis的列表相当于Java语言里面的LinkedList,注意它是链表而不是数组。这意味着list的插入和删除操作非常快,时间复杂度为 O(1)。通过上面的关系对应图,我们发现list对应的底层数据结构不只是链表一种,还有压缩列表。两种底层数据结构分别是在不同的场景下使用,一会我们详细说明。Redis 的列表结构常用来做异步队列使用。将需要延后处理的任务结构体序列化成字符串塞进Redis的列表,另一个线程从这个列表中轮询数据进行处理。

3、list(列表)基本使用

未命名文件 (4).png

3.1、右边进左边出:队列
//rpush添加数据到列表
> rpush animals cat dog pig 
(integer) 3 
> llen animals 
(integer) 3 
//lpop取出列表左边第一个数据(移除并返回列表的头元素)
> lpop animals 
"cat" 
> lpop animals 
"dog" 
> lpop animals 
"pig" 
> lpop animals 
(nil)
3.2、右边进右边出:栈
> rpush animals cat dog pig 
(integer) 3 
//rpop取出列表右边第一个数据(移除并返回列表的头元素)
> rpop animals 
"cat" 
> rpop animals 
"dog" 
> rpop animals 
"pig" 
> rpop animals 
(nil)
3.3、获取指定区间的数据,列表的长度
> lrange animal 0 2
1) "cat" 
2) "dog" 
3) "pig"
> llen animal 
(integer) 3
//index可以为负数,`index=-1`表示倒数第一个元素,同样`index=-2`表示倒数第二个元素。
//获取所有元素,O(n) 慢操作 慎用 
> lrange books 0 -1 
1) "cat" 
2) "dog" 
3) "pig"

4、list(列表)原理

list列表的底层数据结构分别用了双向链表压缩列表,下面分别介绍一下。

4.1、redis双向列表

我们先看一下链表节点结构:

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

上面代码中展示了一个最基本的双向链表,每一个listNode都包含它的前置节点后置节点当前节点的值三部分。Redis在双向链表的listNode结构体基础上又封装了一个list结构,这样操作起来会更方便,封装的list结构如下:

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

未命名文件 (5).png 通过上面的示例,我们可以总结一下redis封装的双向链表的优点:

1)list结构记录了了表头指针head和表尾节点tail,所以获取链表的表头节点和表尾节点的时间复杂度是O(1)

2)list结构提供len,用来表示链表节点数量,所以获取链表中的节点数量的时间复杂度也是O(1)

3)list 结构的 dup、free、match 函数指针为节点设置该节点类型特定的函数,因此链表节点可以保存各种不同类型的值

4)listNode 链表节点带有 prev 和 next 指针,获取某个节点的前置节点或后置节点的时间复杂度只需O(1)

既然redis封装了list结构,使得列表中的很多操作都非常的高效,又为什么提出了压缩列表(zipList)呢?原因是链表的缺陷也是比较明显的,我们知道对比数组,链表在内存空间上是不连续的,这样无法很好的利用CPU缓存,非连续的存储也不能很好的利用内存。为了高效的利用内存,节省空间,又设计出了非常有用的压缩列表

4.2、redis压缩列表(ziplist)

Redis为了节约内存空间使用,list、zset和hash在元素个数较少的时候,采用压缩列表 (ziplist) 进行存储,压缩列表是一块连续的内存空间,元素之间紧挨着存储。说白了压缩列表是Redis为了节约内存而开发的,它是由连续内存块组成的顺序型数据结构,有点类似于数组。

4.2.1、压缩列表(ziplist)结构介绍

压缩列表的结构如下:

struct ziplist<T> { 
    int32 zlbytes; // 记录整个压缩列表占用对内存字节数;
    int32 zltail_offset; // 记录压缩列表「尾部」节点距离起始地址由多少字节,也就是列表尾的偏移量
    int16 zllength; // 记录压缩列表包含的节点数量;
    T[] entries; // 元素内容列表,挨个挨个紧凑存储 
    int8 zlend; // 标记压缩列表的结束点,特殊值 OxFF(十进制255)。
}

在内存上表现形式,我们可以理解为下图所示结构:

未命名文件 (7).png 通过压缩列表的结构我们可以总结一下压缩列表的优势:

  1. 查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是 O(1)
  2. 内存空间连续,可以节约空间,充分利用空间,最大的优势

压缩列表的优点是节省内存,缺点是插入元素的复杂度较高平均O(N)最坏O(N^2), 但是在小数据量的情况下,这种复杂度也是可以接受的。所以,压缩列表适合用在数据量比较少的时候,数据量大的时候,使用压缩列表就不太合适,一个是因为插入时间复杂度比较高,再就是可能会发生链锁更新。

4.2.2、连锁更新

通过结构图我们可以知道,压缩列表里的每个节点中都用prevlen属性都记录了前一个节点的长度,而且prevlen属性的空间大小跟前一个节点长度值有关,比如:

  • 如果前一个节点的长度小于254字节,那么prevlen属性需要用1字节的空间来保存这个长度值;
  • 如果前一个节点的长度大于等于254字节,那么prevlen属性需要用5 字节的空间来保存这个长度值。第一个字节是 0xFE(254),剩余四个字节表示字符串长度

因为prevlen是一个变长的整数,所以更新或者新增数据的时候就会产生连锁更新 现在假设有一个压缩列表,列表中每个节点的长度都是253字节,因为这些节点长度值小于 254 字节,所以prevlen属性需要用 1 字节的空间来保存这个长度值。如下图:

未命名文件 (8).png 现在新增一个长度大于等于254字节的节点entry0加入到表头,因为entry1节点的prevlen属性只有1字节大小,无法保存新节点的长度,此时需要将entry1节点的prevlen属性从原来的1字节大小扩展为5字节大小。

未命名文件 (9).png entry1字节扩展长度后,它本身的长度也超过了254字节,所以导致entry2节点的prevlen属性也得从原来的1字节大小扩展为5字节大小。

未命名文件 (10).png 说到这里,大家应该会意识到一件事,因为例子中每个节点的长度都很长,前置节点的改变,会引起后边节点的改变,就是扩展entry1引发了对entry2扩展一样,扩展entry2也会引发对entry3的扩展,而扩展entry3又会引发对entry4的扩展…. 一直持续到结尾。这种在特殊情况下产生的连续多次空间扩展操作就叫做连锁更新

试想一下,如果列表很长,发生连锁更新的话,占用的内存空间要多次重新分配,这就会直接影响到压缩列表的访问性能。因此,就像前面所说的压缩列表只会用于保存的节点数量不多的场景,只要节点数量足够小,即使发生连锁更新,也是能接受的。数据量大的话还是会使用双向链表来存储列表。

以上就是今天全部的内容了,主要目的是让大家了解redis的list列表基本数据类型,后续有新的知识点会继续补充!!!感谢大家!!!