Redis 有哪些常见数据结构?它们的底层实现和使用场景是什么?

本文以 Redis 7.0+ 的常见面试答案为主,同时标注旧版本实现和当前源码中的变化

首先我们需要理解 Redis 的底层数据结构,其中主要会考的内容其实就是压缩链表ziplist和跳表 skiplist

面试回答建议:Redis 的常见数据结构有哪些?

Redis 最常见的五种基本数据类型是 String、List、Hash、Set 和 ZSet

类型 数据特点 常见底层实现 典型场景
String 字符串、整数或二进制数据 int / embstr / raw;字符串核心结构是 SDS 缓存对象、计数器、分布式锁、Session、限流
List 有序、允许重复 小列表可使用 listpack;较大列表使用 quicklist,quicklist 节点内部保存 listpack 栈、队列、时间线、简单消息队列
Hash field-value 映射 listpack 或 hashtable;旧版本小对象使用 ziplist 对象缓存、购物车、用户信息
Set 无序、元素唯一 经典答案是 intset 或 hashtable;较新的源码还可能使用 listpack 点赞、标签、共同关注、抽奖、集合运算
ZSet 元素唯一,并按 score 有序 listpack 或 skiplist + dict;旧版本小对象使用 ziplist 排行榜、延时任务、范围查询、带权排序

回答基本的分类+底层数据结构+大概用法

  • 类型:五类主要的------String、List、Hash、Set、ZSet
  • 常见的用法:最常见的是 String 存的是单个的字符,当我们需要存对象流信息的时候一般都是使用 Hash 来进行创建的,Set 主要是用来去重的(黑马点评里面有使用来做关注相关的内容),ZSet 由于具有排序的效果因此往往都是跟排序有关的内容(比如消息队列的模拟、熔断限流策略的制作等等),List 仅仅在早期的时候用来做过消息队列没那么重要
  • String 的原理是简单动态字符串(也就是动态数组)List双向链表或压缩列表(ziplist)Hash 的是压缩列表或哈希表实现的
  • 而对于 Set ,如果元素全部是整数且数量较少,使用 intset ;如果出现非整数元素或数量超过阈值:转换为 hashtable
  • ZSet(Sorted Set) ,当元素少、member 较短时:使用 listpack ;如果数据量增大后:使用 skiplist + dict ,但是在某些旧版本里面可能是用ziplist实现的

具体底层数据结构的讲述

  1. SDS:Redis 自己实现的动态字符串内部记录字符串长度和已分配空间,因此获取长度是 O(1),扩容时也不需要每次都重新分配内存,同时支持存储二进制数据。
  2. Listpack:把多个元素紧凑地存放在一块连续内存中,每个元素主要由 encoding + data + backlen 组成。它指针开销小、内存利用率高,适合保存数量较少、内容较短的数据;但中间插入或删除可能需要移动后面的数据
  3. Quicklist:本质是一个双向链表,但每个链表节点中不是只存一个元素,而是存放一个 listpack**。这样既保留了链表头尾插入删除方便的特点,又减少了每个元素都单独使用指针造成的内存浪费。Redis 的 List 主要使用这种结构。
  4. **Hashtable:**由数组和哈希桶组成,通过哈希函数计算元素所在的位置;发生哈希冲突时,Redis 使用链式结构保存同一个桶中的多个元素。其查询、插入和删除的平均时间复杂度为 O(1),这个和 Java 的 HashTable 没有任何区别
  5. **Dict:**是 Redis 对哈希表的进一步封装。可以简单理解为,hashtable 是具体的存储结构,dict 是管理哈希表的完整字典结构。dict 内部维护两张哈希表,扩容时逐步把旧表中的数据迁移到新表,这就是渐进式 rehash,可以避免一次迁移大量数据而长时间阻塞。
  6. Intset:专门存储整数的有序连续数组。当整数范围变大时,底层编码会从较小的整数类型升级为更大的整数类型。它节省内存且支持二分查找 ,但插入中间位置时需要移动后面的元素,因此数据大了就会切换到 Listpack
  7. **Skiplist:**在普通有序链表上增加多层稀疏索引。**查询时从最高层开始向后跳跃,再逐层下降,因此查询、插入和删除的平均复杂度为 O(log n)。**节点中的 span 还能帮助 Redis 快速计算元素排名。
  8. **Ziplist:**Redis 早期使用的连续内存结构。每个节点记录前一个节点的长度,节点长度变化时可能导致后续节点连续修改,产生连锁更新,因此 Redis 7.0 后主要由 listpack 替代

五种基本类型

1. String

1.1 使用场景

  • 缓存对象:将 JSON、序列化对象或页面结果直接缓存。
  • 计数器INCRDECR,例如访问量、点赞数和库存。
  • 分布式锁 :通常使用 SET key value NX PX timeout,释放锁时还要校验锁的唯一值。
  • 共享 Session:多实例服务共享登录状态。
  • 限流与状态位:配合过期时间或位操作实现。

1.2 底层实现

不能只说"String 的底层一定是 SDS"。更严谨的说法是:

  • 可以表示为 intembstrraw 编码。
  • 可直接表示为整数的短值,可能使用整数编码。
  • 普通字符串内容主要由 SDS(Simple Dynamic String) 保存。

1.3 SDS 原理

SDS 在字符数组外记录了长度、容量和类型信息,核心优势包括:

  1. O(1) 获取长度 :直接读取 len,不用像 C 字符串一样遍历到 \0
  2. 二进制安全 :数据可以包含 \0,长度不依赖结束符判断。
  3. 减少内存重分配:可以保留空闲空间,追加内容时不一定每次重新申请内存。
  4. 避免缓冲区溢出:修改前会检查容量,不够时先扩容。
  5. 兼容部分 C 字符串函数 :末尾仍然保留 \0

面试总结: String 的字符串内容核心使用 SDS,它用额外的元数据换取 O(1) 长度、二进制安全和更安全的动态扩容。


2. List

2.1 使用场景

  • 栈:LPUSH + LPOP
  • 队列:LPUSH + RPOP 或阻塞命令 BLPOP/BRPOP
  • 最新消息列表、文章时间线。
  • 简单消息队列。

List 可以做简单消息队列,但存在明显限制:

  1. 消息 ID 一般需要业务自行生成。
  2. 缺少完善的消费组、确认、重试和消息追踪机制。
  3. 多消费者竞争弹出元素,难以实现 Stream 那样的消费进度管理。

需要消费组和消息确认时,通常优先使用 Stream

2.2 版本演进

Redis 版本/语境 List 常见实现
较早版本 ziplist 或双向链表
Redis 3.2 之后 quicklist,节点内部原先保存 ziplist
Redis 7.0+ quicklist 节点内部改用 listpack
当前源码变化 很小的 List 还可能直接以 listpack 编码,超过阈值再转换为 quicklist

因此,面试中推荐回答:

Redis 7.0+ 的 List 主要由 quicklist 实现,quicklist 本质上是一个双向链表,每个链表节点内部保存一段 listpack。

2.3 双向链表原理

每个节点记录前驱和后继指针:

  • 头尾插入、删除可以做到 O(1)。
  • 能够从两个方向遍历。
  • 每个元素都单独分配节点,会产生较多指针开销和内存碎片。
  • 节点分散在内存中,缓存局部性较差。

它是理解 quicklist 的基础,但不能直接把它当成新版 Redis List 的完整答案。

2.4 QuickList 原理

QuickList 将两种结构组合起来:

  • 外层是双向链表,便于头尾操作和分段管理。
  • 每个节点内部不是只存一个元素,而是存一块 listpack。
  • 一块连续内存保存多个元素,减少了链表节点和指针数量。
  • 单个节点不会无限增长,避免一次移动过多数据。

它在两种极端之间取得平衡:

  • 纯链表:操作灵活,但内存开销大。
  • 单块连续数组:内存紧凑,但中间插入和扩容需要移动大量数据。

面试总结: quicklist 是"链表 + 紧凑列表"的折中方案,兼顾头尾操作效率、内存利用率和缓存局部性。


3. Hash

3.1 使用场景

  • 缓存对象:以对象 ID 为 key,以字段和值组成 Hash。
  • 购物车:商品 ID 作为 field,数量作为 value。
  • 用户资料、配置项、商品属性。
  • 对对象的单个字段进行更新,避免整体序列化和反序列化。

3.2 底层实现

  • 元素少且 field/value 较短时:使用 listpack
  • 超过元素数量或元素长度阈值后:转换为 hashtable
  • Redis 7.0 以前的经典答案通常是"ziplist 或 hashtable"。

3.3 哈希表原理

哈希表通过哈希函数将 key 映射到桶:

  1. 根据 key 计算哈希值和桶下标。
  2. 不同 key 可能落入同一个桶,产生哈希冲突。
  3. Redis 字典使用链式结构处理冲突。
  4. 查找、插入和删除的平均复杂度为 O(1),极端冲突情况下会退化。

渐进式 Rehash

哈希表需要扩容或收缩时,如果一次迁移全部数据,可能造成明显阻塞。Redis 会维护新旧两张表,在后续操作中逐步迁移桶:

  • 每次执行部分迁移工作。
  • 查询时可能需要检查两张表。
  • 完成迁移后释放旧表。

面试总结: Redis 使用哈希表提供平均 O(1) 的字段查询,并通过渐进式 rehash 将扩容成本分散到多次操作中,避免单次长时间阻塞。


4. Set

4.1 使用场景

  • 点赞用户集合、抽奖参与用户集合。
  • 标签系统、黑名单、已访问用户集合。
  • 共同关注:交集。
  • 合并多个用户群:并集。
  • 推荐排除已关注内容:差集。
  • 去重与成员判断。

4.2 底层实现

经典面试答案是:

  • 元素全部是整数且数量较少:使用 intset
  • 出现非整数元素或数量超过阈值:转换为 hashtable

需要知道的版本补充:较新的 Redis 源码还支持使用 listpack 保存小型 Set,因此更完整的回答是 intset / listpack / hashtable,但多数面试题仍以 intset / hashtable 为标准答案。

4.3 IntSet 原理

IntSet 是专门存储整数的小型有序集合:

  • 底层是一块连续且有序的整数数组。
  • 支持二分查找,查找复杂度为 O(log n)。
  • 插入和删除需要移动后续元素,复杂度为 O(n)。
  • 根据最大数值范围选择 int16int32int64 编码。
  • 加入更大的整数后会整体升级编码,一般不会再降级。

它节省内存的原因是:

  • 不需要为每个整数创建独立对象。
  • 不需要哈希桶、链表指针等额外结构。
  • 所有整数使用统一宽度连续存放。

面试总结: IntSet 适合元素全部是整数且数量较少的 Set;它用有序连续数组节省内存,但中间插入可能移动数据。


5. ZSet(Sorted Set)

5.1 使用场景

  • 排行榜:score 表示分数,member 表示用户。
  • 延时任务:score 表示执行时间戳。
  • 热度榜、优先级队列。
  • 按时间或权重进行范围查询。
  • 相同 score 下按 member 字典序排序。

5.2 底层实现

  • 元素少、member 较短时:使用 listpack
  • 数据量增大后:使用 skiplist + dict
  • 旧版本小对象使用 ziplist。

为什么大对象需要两个结构?

  • dict:根据 member 快速找到 score,平均 O(1)。
  • skiplist:按照 score 排序,支持范围遍历、排名和有序更新。

5.3 跳表原理

跳表是在完整有序链表上增加多层稀疏索引:

  • Level 0 保存全部节点。
  • 越高层节点越少,用于快速跳过大段数据。
  • 查找从最高层开始:能向右走就向右,不能继续时下降一层。
  • 插入新节点时随机生成层数,不需要像平衡树一样进行旋转。
  • 平均查找、插入和删除复杂度为 O(log n)。

Redis 跳表节点主要包含:

  • score:排序分数。
  • member:成员值。
  • backward:Level 0 的后退指针,方便反向遍历。
  • level[]:每层的前进指针和跨度 span
  • span:记录跨过的节点数量,用于快速计算排名。

5.4 为什么 Redis 选择跳表,而不是红黑树?

  1. 实现和维护相对简单:随机层高代替复杂的旋转和再平衡。
  2. 范围查询自然:定位起点后,沿 Level 0 顺序遍历即可。
  3. 排名计算方便 :通过 span 可以在查找过程中累计排名。
  4. 更新局部化:主要修改搜索路径上的指针,不需要树旋转。
  5. 平均复杂度足够稳定:查找、插入和删除平均为 O(log n)。

不建议把"并发友好"作为主要原因,因为 Redis 命令执行主路径本身通常是串行的,这并不是其选型的核心依据。

紧凑型结构:ZipList 与 ListPack

ZipList(压缩列表,旧实现)

ZipList 是 Redis 早期用于小型 List、Hash 和 ZSet 的紧凑顺序结构。所有元素存放在一块连续内存中,避免了大量指针开销。

整体结构可以简化为:

text 复制代码
| zlbytes | zltail | zllen | entry1 | entry2 | ... | zlend |

字段含义:

  • zlbytes:整个压缩列表占用的总字节数。
  • zltail:尾节点相对于起始位置的偏移量。
  • zllen:元素数量。
  • zlend:结束标记 0xFF

单个 entry 可以简化为:

text 复制代码
| prevlen | encoding | data |
  • prevlen:前一个 entry 的长度,用于反向遍历。
  • encoding:当前数据类型和长度编码。
  • data:实际数据。

ZipList 的优点

  • 连续内存,内存利用率高。
  • 无需为每个元素保存完整的前后指针。
  • 缓存局部性较好。

ZipList 的连锁更新问题

prevlen 的长度取决于前一个 entry 的长度。当某个 entry 变大后,后一个 entry 的 prevlen 可能从 1 字节扩展为 5 字节;后一个 entry 因此也变大,又可能继续影响下一个 entry,形成连锁更新。

极端情况下,需要连续扩展很多节点并反复移动内存,代价可能很高。这是 Redis 使用 ListPack 替代 ZipList 的重要原因之一。

ListPack

ListPack 同样将多个元素紧凑地放在连续内存中,但它不再让当前节点记录"前一个节点的长度"。

整体结构可以简化为:

text 复制代码
| total-bytes | num-elements | entry1 | entry2 | ... | end |

单个 entry 可以简化为:

text 复制代码
| encoding | data | backlen |

backlen 反向编码的是当前 entry 自己的长度。反向遍历时,可以从 entry 尾部读取 backlen,计算当前 entry 的起始位置。

为什么可以避免 ZipList 的连锁更新?

  • ZipList:后一个节点的 prevlen 依赖前一个节点的长度。前一个变大,后一个也可能被迫扩展。
  • ListPack:每个节点只记录自己的 backlen。前一个节点变大,不会修改后一个节点的元数据。

因此,ListPack 消除了 ZipList 因 prevlen 级联扩展产生的连锁更新问题。

面试总结: ListPack 保留了连续内存、节省指针开销的优势,并通过记录当前 entry 自身长度,解决 ZipList 的连锁更新问题。

相关推荐
郝学胜-神的一滴2 小时前
力扣 692:巧用小顶堆高效求解前K个高频单词
java·数据结构·python·程序人生·算法·leetcode·职场和发展
zzzll11112 小时前
SpringBoot 入门与实践指南
java·spring boot·后端
newerp3 小时前
Go :结构体嵌入、sort.Interface 与 io.Reader/Writer
后端
SamDeepThinking3 小时前
如何维持缓存的一致性?
后端·面试·程序员
泡海椒3 小时前
还在手动把 Postman 和浏览器的 curl 转成 Java 代码?这个框架让你一键粘贴直接用
后端
用户8181870627463 小时前
第13章 死锁诊断实战
后端
枫叶V3 小时前
Prompt Injection 防不住怎么办?从 Source-Sink 模型设计 Agent 安全边界
后端·agent
用户8181870627463 小时前
第14章 线程池 RejectedExecutionException 与拒绝策略源码
后端
男孩李3 小时前
浅谈Linux的last命令
java·linux·服务器