Java 面试宝典:Redis 中的 List 是如何实现的?

大家好,我是大明哥,一个专注「死磕 Java」系列创作的硬核程序员。

本文已收录到我的技术网站:www.skjava.com。有全网最优质的系列文章、Java 全栈技术文档以及大厂完整面经


回答

Redis 中的 List 数据结构是一个双向链表,用于存储一个序列的数据,它类似于 Java 中的数组或列表,其底层实现分为两个版本:

  • 3.2 版本以前使用 linkedlist + ziplist

    • 当列表中元素的⻓度较⼩或者数量较少时,通常采⽤zipList来存储。原因是因为 zipList 是一个紧凑的数据结构,能够有效地减少内存占用。但是,在列表中元素较多或者元素较大时,zipList 的性能会下降,因为在列表的头部或尾部添加或删除元素时,可能需要重新分配并复制整个 ziplist。所以,zipList 非常适合少量的小数据存储。同时 zipList 还有一个"连锁更新"的问题,也会严重影响 ziplist 的性能。
    • 当列表元素大于 512 或者元素的长度大于 64 字节时,List 底层由 zipList 转换为linkedlistlinkedlist 是一个双向链表,能够快速支持列表的删除和插入操作,但是内存利用率较低,因为它需要额外的内存空间来维护链表结构。
  • 3.2 版本以后使用 quicklist

    • 从 3.2 版本开始后,List 的底层实现采用 quicklistquicklist 是将 zipList 作为节点嵌入 linkedList 的节点中,它结合了两者的优点,也具备两者的优点。具体来说,quicklist 是由多个 ziplist 节点组成的 linkedList 链表,每个 ziplist 节点可以存储多个列表元素。

扩展

List 介绍

List 的 Redis 中的 5 种主要数据结构之一,它是一种序列集合,可以存储一个有序的字符串列表,顺序是插入的顺序。我们可以使用相关命令添加一个字符串元素到 List 的头部(左边)或者尾部。

**List 的最大长度为 2^31 - 1,即每个 List 支持超过 40 亿个元素。**主要特点如下:

  • 有序性:List 中的元素按照插入顺序排序,可以在列表的头部或尾部添加元素。
  • 双向链表:List 内部通过双向链表实现,使得在列表的两端操作(如插入和删除)都非常快,时间复杂度为 O(1)。
  • 元素重复性:List 允许重复的元素。
  • 元素访问:List 支持通过索引访问元素(如 LINDEX 命令),但这是通过遍历实现的,因此其时间复杂度为 O(N)。若 List 元素较多,则访问效率较低

List 常用的命令如下:

  • LPUSH key value1 [value2]:将一个或多个元素插入到列表头部。如果 key 不存在,一个空列表会被创建并执行 LPUSH 操作。返回值是操作后列表的长度。

  • RPUSH key value1 [value2]:将一个或多个值插入到列表尾部。类似于 LPUSH

  • LPOP key:移除并返回列表的第一个元素。如果列表为空,返回 nil。

  • RPOP key:移除并返回列表的最后一个元素。如果列表为空,返回 nil。

  • LLEN key:返回列表的长度,如果 key 不存在 返回 0。

  • LINDEX key index:获取列表在 index 位置的元素。index 是基于 0 的,也可以是负数,-1 表示最后一个元素,-2 表示倒数第二个元素等。

  • LSET key index value:将列表的 index 位置的值设置为 value。如果 index 超出范围,操作会返回一个错误。

  • LRANGE key start stop:获取列表指定范围内的元素。start 和 stop 都是基于 0 的索引,可以使用负数索引指定位置。

  • LREM key count value:根据参数 count 的值,移除与参数 value 相等的元素。count 的值可以是以下几种:

    • count > 0:从头到尾,移除最多 count 个 value 相等的元素。
    • count < 0:从尾到头,移除最多 -count 个 value 相等的元素。
    • count = 0:移除所有 value 相等的元素。
  • LTRIM key start stop:对一个列表进行修剪(trim),就是说,让列表只保留指定区间内的元素,不在指定区间之内的元素都将被删除。

下面是 List 常用命令的演示:

需要注意的是,List 设置过期时间,只能给整个 List 设置过期时间,不能单独给某一个元素设置。

List 的底层实现

List 的底层实现有三种:zipListlinkedListquickList。他们的使用情况如下:

  • 当 List 存储的元素较少且每个元素的大小也较小时,Redis 会选择使用 zipList 来存储数据,以节省内存空间。
  • 当列表元素大于 512 或者元素的长度大于 64 字节时,Redis 则转换为使用 linkedList 来存储数据,以优化操作的性能。

zipList

当 List 中的元素比较少或者每个元素的大小也小时,Redis 选择 zipList 来存储数据。zipList 通过紧凑的内存布局存储一系列的 entry,每个 entry 可以代表一个字符串或者整数。

zipList 的内部结构主要分为三个部分:表头(header)、条目(entries)和表尾(end),如下图:

  • 表头(header)

    • zlbytes:4 个字节,记录整个 ziplist 占用的总字节数。
    • zltail:4 个字节,记录到 ziplist 最后一个元素的偏移量,这样就可以快速定位到最后一个元素,提高从列表尾部添加或者访问元素的效率。
    • zllen:2 个字节,记录 ziplist 中元素的个数。
  • 条目(entries)

    • 列表元素 entry。每个entry可以存储字符串或者整数。entry 的结构取决于它所存储的数据类型和大小。
  • 表尾(end)

    • 一个字节的特殊值0xFF,用于标记压缩列表的结束。

entry 的结构与其存储的数据类型相关,如下:

存储字符串时,有三个部分,而整数只有两个部分,主要是因为整数是以最紧凑的格式存储,没有使用任何额外的标记或填充字节,所以在 zipList 中,整数值的编码同时包含了类型信息和实际的整数值。

  • prevlen

记录前一个 entry 占用字节数,zipList 能实现逆序遍历就是靠这个字段确定往前移动多少字节拿到上一个 entry 首地址。

若前一个 entry 长度小于 254 个字节时,则 prevlen 占用 1 个字节。若前一个 entry 长度大于等于 254 个字节,则 prevlen 占用 5 个字节,第一个字节设为0xFE,接下来的4字节用于存储实际的长度值。

  • encoding

用于表示当前 entry 的类型和长度,当前 entry 的长度和值是根据保存的是整数还是字符串以及数据的长度共同来决定。

前两位用于表示类型,当前两位值为 "11" 则表示 entry 存放的是整数类型数据,其他表示存储的是字符串。

  • entry-data

实际存储数据的位置。如果 entry 存储整数类型,则没有这个 entry-data,它会合并到 encoding 中。

zipList 适合较少元素的存储,如果元素过多的话,则查询效率会大打折扣,时间复杂度为 O(N)。除了查询效率较低外,zipList 还有一个问题:"连锁更新问题 "。那什么是连锁更新问题呢?

zipList是一个紧凑的序列数据结构,它每个 entry 都有一个 prevlen 字段来记录前一个 entry 的大小,当我们插入一个较大元素或者修改一个元素较大时,会引发后续 entry 的 prevlen 占用空间发生变化,从而导致该 entry 的存储空间大小超过 254 bytes,进一步引发后续 entry 的存储空间,导致一连串的 entry 存储空间发生变化,从而引发"连锁更新"问题。

"连锁更新"通常发生在以下情况:

  • zipList 中的一个 entry 被更新(或者插入一个新的 entry),导致该 entry 的存储空间增加,并且这个存储空间增加会导致后续 entry 的存储空间发生变化。
  • 如果前一个 entry 的存储空间需要使用更多的字节来表示(例如,从1字节增加到5字节),那么这种存储空间的增加可能会影响到下一个 entry ,因为下一个 entry需要更新它存储的前一个 entry 的存储空间 prevlen 字段,它由1 字节变成 5 字节。
  • 如果这个更新又导致下一个 entry的存储空间表示也不足,那么这个过程就会继续传播,形成一个连锁反应,直到找到一个 entry,其长度表示不需要改变为止。

举一个例子:假如我们有一个 zipList ,它有多个 entry 大小在 250~253字节之间,他们的 prevlen 都是 1 个字节:

现在我们插入一个新的 entry,长度为 260 bytes,则是 e1prevlen 就会由 1 bytes 增加到 5 bytes,导致 e1 存储空间由 252 bytes 增加到 256 bytes:

e1 由 252 bytes 增加到 256 bytes,那么 e2prevlen 就会由 1 bytes 增加到 5 bytes,从而导致 e2 的存储空间由 252 bytes 增加到 256 bytes:

e2 的存储空间增加会导致 e3 的增加,e3 又会导致 e4,e4 又会延伸到 e5,但是 e5 的存储空间由 100bytes 增加到 104 bytes,小于 254 bytes,所以不会导致 e6 的 prevlen 值增大,至此 "连锁更新" 结束:

"连锁更新"会对 zipList 的性能有影响,因为它会导致大量的内存重新分配和数据复制。而且在极端情况下,即使只是修改了一个很小的 entry,也可能会导致整个 zipList 被复制和更新,严重影响 zipList 的性能。

总结:当列表中元素的⻓度较⼩或者数量较少时,通常采⽤zipList来存储。原因是因为 zipList 是一个紧凑的数据结构,能够有效地减少内存占用。但是,在列表中元素较多或者元素较大时,zipList 的性能会下降,因为在列表的头部或尾部添加或删除元素时,可能需要重新分配并复制整个 ziplist。所以,zipList 非常适合少量的小数据存储。同时 zipList 还有一个"连锁更新"的问题,也会严重影响 ziplist 的性能。

linkedList

linkedList 是一个由一个个节点组成的双向链表,数据结构定义如下:

c 复制代码
typedef struct list {
    // 头指针
    listNode *head;
    // 尾指针
    listNode *tail;
    // 节点值的复制函数
    void *(*dup)(void *ptr);
    // 节点值释放函数
    void (*free)(void *ptr);
    // 节点值比对是否相等
    int (*match)(void *ptr, void *key);
    // 链表的节点数量
    unsigned long z;
} list;

list 结构体代表整个链表,包含指向链表头节点、尾节点的指针,以及链表的长度等信息。listNode 代表双向链表的一个节点,定义如下:

arduino 复制代码
typedef struct listNode {
    // 前驱节点指针
    struct listNode *prev;
    // 后驱节点指针
    struct listNode *next;
    // 指向节点的值
    void *value;
} listNode;

整体结构如下:

linkedList 是一个双向链表,所以在执行两端操作时(如 LPUSHRPUSHLPOPRPOP),时间复杂度是 O(1),效率非常快。但是如果它要执行索引类操作(如 LINDEXLSET )时,则需要遍历列表,所以效率会低些。同时,linkedListzipList 相比,它的内存使用率较高,每个节点除了存储值本身外,还需要额外空间存储前向和后向的指针。但是,对于大型列表,这种额外的内存开销是合理的,因为它提供了更佳的操作性能。所以 linkedList 比较适合列表元素数量较多,或者列表中包含大型元素的场景。

quicklist

quicklist 出现之前,List 使用 zipListlinkedList 来存储数据。

  • zipList:是一个紧凑的数据结构,它能够有效地减少内存占用,但是当列表中元素较多或者元素本身较大时,zipList 的性能会下降,因为在列表的头部或尾部添加或删除元素时,可能需要重新分配并复制整个 ziplist。所以,zipList 非常适合少量的小数据存储。
  • linkedList:一个双向链表,能够快速支持插入和删除操作,但是内存利用率较低,因为它需要额外的内存空间来维护链表结构。

zipListlinkedList都存在这样或那样的缺陷,所以 Redis 在 3.2 版本采用 quicklist 来取代zipListlinkedList

quicklist 是将 zipList 作为节点嵌入 linkedList 的节点中,它结合了两者的优点。具体来说,quicklist 是由多个 ziplist 节点组成的 linkedList 链表,每个 ziplist 节点可以存储多个列表元素。

  • 内存利用率 :通过使用 ziplistquicklist 可以像使用 ziplist 那样高效地存储小数据元素,提供内存利用率。
  • 操作性能 :由于 quicklist 是基于双向链表的,它可以快速地在两端添加或删除元素,而不需要像单一 ziplist 那样可能会发生重新分配和复制整个数据结构的情况。对于列表中间的插入和删除操作,Redis 会先找到对应的 ziplist 节点,然后在这个 ziplist 中进行操作,这样可以保持较高的操作效率。

quicklist 表头结构:

arduino 复制代码
typedef struct quicklist {
    // 链表头部节点指针
    quicklistNode *head;
    
    // 链表头部节点指针
    quicklistNode *tail;
    
    // 所有 ziplist 的总 entry 个数
    unsigned long count;
    
    // quicklistNode 个数
    unsigned long len;          /* number of quicklistNodes */
    signed int fill : QL_FILL_BITS;       /* fill factor for individual nodes */
    unsigned int compress : QL_COMP_BITS; /* depth of end nodes not to compress;0=off */
    unsigned int bookmark_count: QL_BM_BITS;
    quicklistBookmark bookmarks[];
} quicklist;

quicklist节点结构:

arduino 复制代码
typedef struct quicklistNode {
    // 前驱节点指针
    struct quicklistNode *prev;
    // 后继节点指针
    struct quicklistNode *next;
    // 指向 ziplist 的指针
    unsigned char *zl;
    // ziplist 字节大小
    unsigned int sz;
    // ziplst 元素个数
    unsigned int count : 16;
    // 编码格式,1 = RAW 代表未压缩原生ziplist,2=LZF 压缩存储
    unsigned int encoding : 2;
    // 节点持有的数据类型,默认值 = 2 表示是 ziplist
    unsigned int container : 2;
    // 节点持有的 ziplist 是否经过解压, 1 表示已经解压过,下一次操作需要重新压缩。
    unsigned int recompress : 1;
    // ziplist 数据是否可压缩,太小数据不需要压缩
    unsigned int attempted_compress : 1;
    // 预留字段
    unsigned int extra : 10;
} quicklistNode;

结合 quicklistquicklistNodequicklist 的结构如下图:

使用 quicklist 关键点就在于我们如何平衡好每个 ziplist 的大小或者元素个数,平衡内存得使用和操作性能。

  • quicklistNodeziplist 的越小,内存使用率就会越低,极端情况下,每个 ziplist 只有一个元素,这样quicklist 退化为了 linkedList
  • quicklistNodeziplist 的越大,内存使用率就越高,但这样就越不利于操作性能了,极端情况下,所有元素都几种在 ziplist 中,quicklist 就退化为了 ziplist

所以,我们需要通过配置来平衡每个 ziplist 的大小或者元素个数。Redis 提供了参数 list-max-ziplist-sizelist-compress-depth 来配置 quicklist

list-max-ziplist-size

控制每个 quicklist 节点内部的 ziplist 可以包含的最大元素数量或字节大小。

  • 为负数时表示 ziplist 节点的字节大小上限。
  • 为正数时表示ziplist 节点中元素的数量上限。

当一个 ziplist 中的元素达到配置的阈值时,如果有新元素添加到列表中时,Redis 会创建一个新的 ziplist 节点来存储这个元素。该值较大(绝对值)时,单个 ziplist 存储的元素就越多,内存利用率就越高,但是会牺牲列表的操作性能,如果较小,则有利于列表的操作性能,但牺牲了内存的利用率。所以,在实际生产情况下我们需要根据实际情况配置一个适中的值,来平衡列表操作的内存效率和性能。

Redis 默认 list-max-ziplist-size-2,限制 ziplist 节点大小为 2KB。

list-compress-depth

用于配置 quicklist 中节点的压缩。list-compress-depth 决定了在 quicklist 中,距离首尾元素多远的中间节点应该被压缩存储,该参数影响着内存使用和访问这些被压缩节点数据时的性能。注意,为了 push/pop 操作的高效性,quicklist 的头和尾节点永远都不会被压缩。

  • 0:表示不对 quicklist 中的任何节点进行压缩。访问性能最佳,但是内存利用率最高。
  • 1:表示对 quicklist 的首尾节点不之外的节点进行压缩。这样可以最大限度地提供内存的利用率,但是不利于这些元素的访问,因为需要进行解压操作。
  • > 1:指定了列表首尾节点各有多少个节点不被压缩。如,list-compress-depth 2 表明 quicklist 的首尾各两个节点不会被压缩,其余中间的节点都会被压缩。随着这个值的增加,被压缩的节点数量减少,内存使用会增加,但访问这些较靠近列表首尾的元素的速度会更快。
相关推荐
心在飞扬9 分钟前
CentOS + Node.js 全套部署命令
后端
Zella折耳根16 分钟前
复习篇-继承和接口
java·开发语言·python
程序员二叉23 分钟前
【JVM】OOM详解+JVM参数+FullGC排查+CPU飙高+死锁+内存泄漏+命令大全
java·开发语言·jvm·面试
云烟成雨TD24 分钟前
Spring AI 1.x 系列【47】 MCP Annotations 模块
java·人工智能·spring
mqcode24 分钟前
若依框架如何配置多数据源?同时连接 MySQL、SQL Server、Firebird 三种数据库
后端
三十..41 分钟前
Redis 核心原理与高可用架构实践
运维·数据库·redis
不知名的老吴42 分钟前
线程的生命周期之线程同步
java·开发语言·jvm
协享科技44 分钟前
Spring Boot 与 Go 双服务架构实践:从单体拆分到通信设计
java·人工智能·spring boot·后端·架构·golang·ai编程
柒和远方1 小时前
后端认证、鉴权、高并发:从 Session 到 JWT 再到 Redis
前端·后端·面试
dearxue1 小时前
这一次,我们一起把AI的复杂一口吃掉
人工智能·后端