二.Redis中那些你不知道的秘密-五大基本结构List的实现原理

本文涉及的产品
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
云数据库 Tair(兼容Redis),内存型 2GB
简介: Redis中的List也是一种非常常用的存储结构,它和Java中的List结构类似,通常用来存储一个列表或者作为队列实现,在Redis 3.2之前,list采用了两种数据结构作为底层实现:压缩列表ziplist以及双向链表adlist,在3.2之后,使用quicklist替代,本篇文章将带你了解Redis底层的三种存储结构。

前言

Redis中的List也是一种非常常用的存储结构,它和Java中的List结构类似,通常用来存储一个列表或者作为队列实现,在Redis 3.2之前,list采用了两种数据结构作为底层实现:压缩列表ziplist以及双向链表adlist,在3.2之后,使用quicklist替代,本篇文章将带你了解Redis底层的三种存储结构。

双向链表adlist

C 语言没有内置这种数据结构的实现,Redis构建了自己的链表结构,一个元素结构如下:(点我查看源码地址)

/* Node, List, and Iterator are the only data structures used currently. */
typedef struct listNode {
   
   
    struct listNode *prev;    //上一元素
    struct listNode *next;    //下一元素
    void *value;    //元素值
} listNode;

多个listNode组成了一个双向链表结构,以及提供了相关的链表操作的函数,如下:

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;

图例:
在这里插入图片描述
Redis的链表是一个双端链表结构,它有如下特点:

  • 无环的结构(尾的next没有指向头,头的prev没有指向尾)
  • 在list中维护了len很方便的可以取到链表的长度,复杂度是o(1)
  • 使用void* 指针保存元素的值可以保存各种类型的值
  • 对链表的表头和表尾进行插入的复杂度都为 o(1)

但是链表的缺点也是比较明显的,就是链表的附加空间比较高,在数据较小的时候会造成空间的浪费,prev和next就需要占用16个字节(64bit的系统,指针8字节),每个元素的内存是独立分配的,不要求连续性,会增加内存的碎片化,影响内存管理效率。所以它不适合存储少量或者小数据。所以Redis在列表对象中小数据量的时候使用压缩列表ziplist作为底层实现。

压缩列表ziplist

ziplist本质上就是一个字节数组,是Redis为了解决内存而设计的一种线性数据结构,Redis中的有序集合,散列,列表都直接或者间接使用到了压缩列表。在Redis源码中有这么一段注释对ziplist做了解释:ziplist源码

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. However, because every operation requires a > reallocation of the memory used by the ziplist, the actual complexity is related to the amount of memory used by the ziplist.

大致意思是:ziplist是一个特殊编码的双向链表,它非常节省内存,一个压缩列表可以包含任意多个元素(entry),每个元素可以保存一个字节数组或者一个整数值,它允许在列表的任意一侧进行push 和pop 操作,时间复杂度是o(1)。

注意:搞清楚一个概念,压缩列表并不是按照某种算法对数据进行压缩,而是通过一定的规则把数据编码在一段连续的内存空间,来达到节约内存的目的。

ziplist的结构

那么这个压缩列表到底长成什么样子呢?这里我从Redis源码的注释中找到了答案,ziplist的总体布局如下:

The general layout of the ziplist is as follows:

  • <zlbytes> <zltail> <zllen> <entry> <entry> ... <entry> <zlend>
    NOTE: all fields are stored in little endian, if not specified otherwise.
  • is an unsigned integer to hold the number of bytes that
    the ziplist occupies, including the four bytes of the zlbytes field itself.
    This value needs to be stored to be able to resize the entire structure
    without the need to traverse it first.
    是一个无符号整数,用于保存ziplist数据,zlbytes字段本身的四个字节
  • is the offset to the last entry in the list. This allows
    a pop operation on the far side of the list without the need for full
    traversal.
    是到列表中最后一个条目的偏移量。这允许在列表的远端进行的弹出操作,不需要完全遍历
  • is the number of entries. When there are more than
    2^16-2 entries, this value is set to 2^16-1 and we need to traverse the
    entire list to know how many items it holds.
    是条目数。当有超过 2^16-2个条目,此值设置为2^16-1,我们需要遍历完整的列表来知道它包含多少项
  • is a special entry representing the end of the ziplist.
    Is encoded as a single byte equal to 255. No other normal entry starts
    with a byte set to the value of 255.
    是表示ziplist结尾的特殊条目

图例:
在这里插入图片描述

解释:

  • zlbytes: 压缩列表的字节长度,占4个字节
  • zltail:压缩列表尾元素相对于压缩列表起始地址的偏移量,占4字节
  • zllen: 压缩列表的元素个数,占用2个字节
  • entry:压缩列表存储的元素,可以是字节数组或者整数
  • zlend:压缩列表的结尾,占用1字节

ziplist元素结构

根据压缩列表的结构,我们可以很容易的获取到列表的长度,元素个数。那么压缩列表是如何遍历的呢?在此之前我们来了解一下压缩列表元素的编码结构,下面是Redis源码中对元素的描述:

ZIPLIST ENTRIES 压缩列表的元素条目
Every entry in the ziplist is prefixed by metadata that contains two pieces of information. First, the length of the previous entry is stored to be able to traverse the list from back to front. Second, the entry encoding is provided. It represents the entry type, integer or string, and in the case of strings it also represents the length of the string payload. So a complete entry is stored like this:
<prevlen> <encoding> <entry-data>
ziplist中的每个元素都以包含两部分的元数据为前缀信息,首先,将前一个元素的长度存储为
previous entry lenth ,能够实现从后到前遍历列表。其次,条目编码endoding是表示条目类型,整数或字符串,在case中它还表示字符串负载的长度。所以一个完整的条目是这样存储的<prevlen> <encoding> <entry-data>

在这里插入图片描述
解释:

  • previous_entry_length

    表示的是前一个元素的字节长度,如果前一元素的长度小于254 字节,用1字节长的空间来保存这个长度值。如果前一元素的长度大于等于254 字节,用5 字节长的空间来保存这个长度值。

    根据该长度可以计算出前一个元素的地址,当前元素的地址减去上一个元素的长度previous_entry_length即得到上一个元素的起始地址(思考一下如何向后遍历呢?),所以该值可以用来实现表为向表头遍历。这种设计非常节约内存,不需要额外的空间维护prev和next指向。

  • encoding:表示当前元素的编码,即conent字段存储的数据类型和长度,整数或者字节数组
  • content:数据的内容存储在content中,值的类型和长度由元素的encoding属性决定。

所以你应该明白了为什么说ziplist 是一个特殊的双向链表,它没有维护双向指针:prev nex,依然可以实现如同双向链表一样向前或者向后的遍历效果,就是因为previous_entry_length的设计。

这里需要注意一下,对于压缩列表的元素而言,获取前一个元素长度,判断存储类型,获取数据内容都是需要进行解码运算的,因此Redis定义了zlentry 来缓存解码后的结构体,看一下他的源码


/*
我们使用此功能来接收有关ziplist条目的信息。请注意,这并不是实际编码数据的方式,这只是我们
由函数填充以便更轻松地操作。
 We use this function to receive information about a ziplist entry.
 * Note that this is not how the data is actually encoded, is just what we
 * get filled by a function in order to operate more easily. */
 //压缩列表元素
typedef struct zlentry {
   
   
    //prevrawlensize是previous_entry_length字段的长度,有1字节和5字节两种
    unsigned int prevrawlensize; /* Bytes used to encode the previous entry len*/
    //上一个元素的长度
    unsigned int prevrawlen;     /* Previous entry len. */
    //encoding字段的长度
    unsigned int lensize;        /* Bytes used to encode this entry type/len.
                                    For example strings have a 1, 2 or 5 bytes
                                    header. Integers always use a single byte.*/
    //当前元素的长度                                
    unsigned int len;            /* Bytes used to represent the actual entry.
                                    For strings this is just the string length
                                    while for integers it is 1, 2, 3, 4, 8 or
                                    0 (for 4 bit immediate) depending on the
                                    number range. */
    //当前元素的首部元素大小 prevrawlensize + lensize
    unsigned int headersize;     /* prevrawlensize + lensize. */
    //元素编码方式
    unsigned char encoding;      /* Set to ZIP_STR_* or ZIP_INT_* depending on
                                    the entry encoding. However for 4 bits
                                    immediate integers this can assume a range
                                    of values and must be range-checked. */
    //指向元素的指针                                
    unsigned char *p;            /* Pointer to the very start of the entry, that
                                    is, this points to prev-entry-len field. */
} zlentry;

解释:

  • prevrawlen : 是指前一个元素的长度
  • prevrawlensize : 编码前元素prevrawlen所需要的字节大小
  • lensize :当前元素值长度len所需要的字节数,
  • len :当前元素长度,
  • headersize :当前元素header大小(lensize+prevrawlensize),
  • encoding 为当前元素的编码格式,
  • *p 只想当前元素的指针

ziplist连锁更新

什么是连锁更新?之前说到 previous_entry_length:表示的是前一个元素的字节长度,如果前一元素的长度小于254 字节,用1字节长的空间来保存这个长度值。如果前一元素的长度大于等于254 字节,用5 字节长的空间来保存这个长度值。

那如果我们将大于254的新元素设置为表头,会出现什么情况?由于previous entry length大小不够用(1b不够要扩充5b),那么后面所有的元素都需要重新分配内存来保证连续性,这会导致效率很低,这种情况我们叫做连锁更新,当然我们大可不必考虑这种性能问题,因为实际上这种情况发生的几率很低,即使发生了,如果元素个数不多也没有太多的性能影响,所以Redis本身也没有去处理 这种情况,放心的用即可。

需要注意的是:虽然ziplist是一种节约内存的设计,但是只是适合存储少量数据,以及短字符串内容,因为它的ziplist空间是连续的,如果元素多伴随着增加,删除操作需要重新分配存储空间,这会给Redis的执行效率带来很大的影响,故而一般采用双向链表结构。

quicklist 快排列表

quicklist是redis 3.2之后出现的,在3.2之前采用的是 ziplist 压缩列表和adlist 双向链表。当元素个数比较少且元素长度比较小的时候,Redis采用ziplist来存储可以有效的节省内存空,但是由于ziplist内存空间的连续性导致在修改元素时需要重新分配存储空间,会造成一定的性能影响,所以当上面两个条件任意一个不满足Redis就会采用adlist作为存储结构,adlist是一种双向链表结构不要求空间的连续性,而在redis 3.2之后,为了综合考虑时间效率与空间效率引入了quicklist。

quicklist的结构

quicklist是由list链表和ziplist结合而成的,或者可以看做是quicklist由若干个ziplist组成的双向链表结构,它在时间和空间的性能上得到了平衡,先来看看它的结构
在这里插入图片描述

下面是quicklist的源码

/* quicklist is a 32 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;
    //quicklist中的元素总数
    unsigned long count;        /* total count of all entries in all ziplists */
    //len : quicklist的节点数
    unsigned int 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;

解释:

  • head : 指向链表的头
  • tail : 指向链表的尾
  • count: quicklist中的元素总数
  • len : quicklist的节点数
  • compress: 节点压缩,16bit,通过修改参数list-compress-depth进行配置。
  • fill : 16bit,用来表示ziplist大小,如果为正数表示每个ziplist最多含有的数据项,如果该值为负数则:
数值 含义
-1 ziplist节点最大为4kb
-2 ziplist节点最大为8kb
-3 ziplist节点最大为16kb
-4 ziplist节点最大为32kb
-5 ziplist节点最大为64kb

我们可以通过Redis修改参数list-max-ziplist-size配置节点所占内存大小,quicklist结构图例:

quicklistNode

quicklistNode是quicklist 的节点类型,结构如下:

typedef struct quicklistNode {
   
   
    struct quicklistNode *prev; //上一个node节点
    struct quicklistNode *next; //下一个node
    unsigned char *zl;            //保存的数据 压缩前ziplist 压缩后压缩的数据
    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;
/* 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 
 注意:未压缩的长度存储在quicklistNode->sz中 , 
 压缩quicklistNode->zl时,node->zl指向quicklistLZF */

解释:

  • prev: 指向前一个节点的指针。
  • next: 指向后一个节点的指针。
  • zl: 数据指针,如果当前节点没有压缩那么它指向一个ziplist结构,否则它指向一个quicklistLZF结构。
  • sz: 表示整个ziplist的总大小,如果ziplist被压缩了这个sz的值保存的是压缩前的ziplist大小。
  • count: 表示ziplist里面包含的元素个数,16bit
  • encoding: 表示ziplist采用的编码方式,2表示使用了LZF压缩,1表示没有压缩。
  • container: 为quicklistNode节点zl指向的容器类型:1代表none,2代表使用ziplist存储数据
  • recompress: 1代表是压缩节点,在使用压缩节点时会先进行解压缩,使用后重新压缩
  • attempted_compress: 自动化测试时使用
  • -extra: 预留字段,目前Redis的实现里也没用上

quicklistLZF

quicklistLZF结构表示一个被压缩过的ziplist,结构如下

 //压缩结构
 typedef struct quicklistLZF {
   
   
    unsigned int sz; /* LZF size in bytes*/
    char compressed[];
} quicklistLZF;

解释:

  • sz:压缩后的quicklist大小
  • compressed[] : 存放压缩后的ziplist字节数组

总结

本章节介绍了Redis中的list存储结构的底层实现,在Redis3.2之前使用到了 双向链表(adlist)和压缩列表(ziplist) , 当存储的数据小且数据个数少时,为了节省内存空间,Redis选择ziplist压缩列表来存储。

ziplist是由一系列特殊编码的连续内存块组成的顺序型数据结构,它的每个节点存储了上一个节点的长度,从而可以实现如同链表一样的向前,或向后遍历。但是它的缺点就是修改了数据后,为了保证内存空间的连续性需要重新分配内存空间,非常耗时,所以如果当数据量多或者数据大的时候Redis选择使用adlist(linkedlist)双向链表结构来存储。

由于链表结构的内存是独立分配的,会加剧内存的碎片化,影响内存管理的效率,且链表的附加空间相对较高prev 和 next 就要占去 16 个字节。所以在Redis 3.2之后使用了quicklist代替 adlist(linkedlist)链表和ziplist压缩列表。

quicklist是 双向链表 和 压缩列表 的结合体,或者可以看做是quicklist由若干个ziplist组成的双向链表结构,quicklist综合考虑了时间效率与空间效。

文章结束,希望对你有所帮助

相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
相关文章
|
19天前
|
存储 消息中间件 NoSQL
Redis数据结构:List类型全面解析
Redis数据结构——List类型全面解析:存储多个有序的字符串,列表中每个字符串成为元素 Eelement,最多可以存储 2^32-1 个元素。可对列表两端插入(push)和弹出(pop)、获取指定范围的元素列表等,常见命令。 底层数据结构:3.2版本之前,底层采用**压缩链表ZipList**和**双向链表LinkedList**;3.2版本之后,底层数据结构为**快速链表QuickList** 列表是一种比较灵活的数据结构,可以充当栈、队列、阻塞队列,在实际开发中有很多应用场景。
|
24天前
|
存储 NoSQL 定位技术
Redis geo原理
Redis的GEO功能基于Earth Mapper(http://earth-api.org/)库,它允许存储地理位置信息并执行一些基于该信息的操作。
25 3
|
27天前
|
NoSQL 关系型数据库 MySQL
Redis 列表(List)
10月更文挑战第16天
17 2
|
30天前
|
消息中间件 存储 监控
redis 的List类型 实现 排行榜
【10月更文挑战第8天】
36 2
|
8天前
|
存储 NoSQL Redis
【赵渝强老师】Redis的存储结构
Redis 默认配置包含 16 个数据库,通过 `databases` 参数设置。每个数据库编号从 0 开始,默认连接 0 号数据库,可通过 `SELECT &lt;dbid&gt;` 切换。Redis 的核心存储结构包括 `dict`、`expires` 等字段,用于处理键值和过期行为。添加键时需指定数据库信息。视频讲解和代码示例详见内容。
|
1月前
|
存储 分布式计算 NoSQL
大数据-40 Redis 类型集合 string list set sorted hash 指令列表 执行结果 附截图
大数据-40 Redis 类型集合 string list set sorted hash 指令列表 执行结果 附截图
27 3
|
1月前
|
设计模式 NoSQL 网络协议
大数据-48 Redis 通信协议原理RESP 事件处理机制原理 文件事件 时间事件 Reactor多路复用
大数据-48 Redis 通信协议原理RESP 事件处理机制原理 文件事件 时间事件 Reactor多路复用
37 2
|
1月前
|
存储 缓存 NoSQL
大数据-46 Redis 持久化 RDB AOF 配置参数 混合模式 具体原理 触发方式 优点与缺点
大数据-46 Redis 持久化 RDB AOF 配置参数 混合模式 具体原理 触发方式 优点与缺点
56 1
|
1月前
|
消息中间件 NoSQL Kafka
大数据-116 - Flink DataStream Sink 原理、概念、常见Sink类型 配置与使用 附带案例1:消费Kafka写到Redis
大数据-116 - Flink DataStream Sink 原理、概念、常见Sink类型 配置与使用 附带案例1:消费Kafka写到Redis
130 0
|
5月前
|
安全 Java
java线程之List集合并发安全问题及解决方案
java线程之List集合并发安全问题及解决方案
902 1