Redis 有哪些常见数据结构?它们的底层实现和使用场景是什么? 本文以 Redis 7.0 的常见面试答案为主同时标注旧版本实现和当前源码中的变化首先我们需要理解 Redis 的底层数据结构其中主要会考的内容其实就是压缩链表ziplist和跳表 skiplist面试回答建议Redis 的常见数据结构有哪些Redis 最常见的五种基本数据类型是String、List、Hash、Set 和 ZSet类型数据特点常见底层实现典型场景String字符串、整数或二进制数据int/embstr/raw字符串核心结构是 SDS缓存对象、计数器、分布式锁、Session、限流List有序、允许重复小列表可使用 listpack较大列表使用 quicklistquicklist 节点内部保存 listpack栈、队列、时间线、简单消息队列Hashfield-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如果出现非整数元素或数量超过阈值转换为hashtableZSetSorted Set当元素少、member 较短时使用listpack如果数据量增大后使用skiplist dict但是在某些旧版本里面可能是用ziplist实现的具体底层数据结构的讲述SDSRedis 自己实现的动态字符串内部记录字符串长度和已分配空间因此获取长度是 O(1)扩容时也不需要每次都重新分配内存同时支持存储二进制数据。Listpack把多个元素紧凑地存放在一块连续内存中每个元素主要由 encoding data backlen 组成。它指针开销小、内存利用率高适合保存数量较少、内容较短的数据但中间插入或删除可能需要移动后面的数据。Quicklist本质是一个双向链表但每个链表节点中不是只存一个元素而是存放一个 listpack**。这样既保留了链表头尾插入删除方便的特点又减少了每个元素都单独使用指针造成的内存浪费。Redis 的 List 主要使用这种结构。**Hashtable**由数组和哈希桶组成通过哈希函数计算元素所在的位置发生哈希冲突时Redis 使用链式结构保存同一个桶中的多个元素。其查询、插入和删除的平均时间复杂度为 O(1)这个和 Java 的 HashTable 没有任何区别**Dict**是 Redis 对哈希表的进一步封装。可以简单理解为hashtable 是具体的存储结构dict 是管理哈希表的完整字典结构。dict 内部维护两张哈希表扩容时逐步把旧表中的数据迁移到新表这就是渐进式 rehash可以避免一次迁移大量数据而长时间阻塞。Intset专门存储整数的有序连续数组。当整数范围变大时底层编码会从较小的整数类型升级为更大的整数类型。它节省内存且支持二分查找但插入中间位置时需要移动后面的元素因此数据大了就会切换到 Listpack**Skiplist**在普通有序链表上增加多层稀疏索引。**查询时从最高层开始向后跳跃再逐层下降因此查询、插入和删除的平均复杂度为 O(log n)。**节点中的 span 还能帮助 Redis 快速计算元素排名。**Ziplist**Redis 早期使用的连续内存结构。每个节点记录前一个节点的长度节点长度变化时可能导致后续节点连续修改产生连锁更新因此 Redis 7.0 后主要由 listpack 替代五种基本类型1. String1.1 使用场景缓存对象将 JSON、序列化对象或页面结果直接缓存。计数器INCR、DECR例如访问量、点赞数和库存。分布式锁通常使用SET key value NX PX timeout释放锁时还要校验锁的唯一值。共享 Session多实例服务共享登录状态。限流与状态位配合过期时间或位操作实现。1.2 底层实现不能只说“String 的底层一定是 SDS”。更严谨的说法是可以表示为int、embstr或raw编码。可直接表示为整数的短值可能使用整数编码。普通字符串内容主要由SDSSimple Dynamic String保存。1.3 SDS 原理SDS 在字符数组外记录了长度、容量和类型信息核心优势包括O(1) 获取长度直接读取len不用像 C 字符串一样遍历到\0。二进制安全数据可以包含\0长度不依赖结束符判断。减少内存重分配可以保留空闲空间追加内容时不一定每次重新申请内存。避免缓冲区溢出修改前会检查容量不够时先扩容。兼容部分 C 字符串函数末尾仍然保留\0。面试总结String 的字符串内容核心使用 SDS它用额外的元数据换取 O(1) 长度、二进制安全和更安全的动态扩容。2. List2.1 使用场景栈LPUSH LPOP。队列LPUSH RPOP或阻塞命令BLPOP/BRPOP。最新消息列表、文章时间线。简单消息队列。List 可以做简单消息队列但存在明显限制消息 ID 一般需要业务自行生成。缺少完善的消费组、确认、重试和消息追踪机制。多消费者竞争弹出元素难以实现 Stream 那样的消费进度管理。需要消费组和消息确认时通常优先使用Stream。2.2 版本演进Redis 版本/语境List 常见实现较早版本ziplist 或双向链表Redis 3.2 之后quicklist节点内部原先保存 ziplistRedis 7.0quicklist 节点内部改用 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. Hash3.1 使用场景缓存对象以对象 ID 为 key以字段和值组成 Hash。购物车商品 ID 作为 field数量作为 value。用户资料、配置项、商品属性。对对象的单个字段进行更新避免整体序列化和反序列化。3.2 底层实现元素少且 field/value 较短时使用listpack。超过元素数量或元素长度阈值后转换为hashtable。Redis 7.0 以前的经典答案通常是“ziplist 或 hashtable”。3.3 哈希表原理哈希表通过哈希函数将 key 映射到桶根据 key 计算哈希值和桶下标。不同 key 可能落入同一个桶产生哈希冲突。Redis 字典使用链式结构处理冲突。查找、插入和删除的平均复杂度为 O(1)极端冲突情况下会退化。渐进式 Rehash哈希表需要扩容或收缩时如果一次迁移全部数据可能造成明显阻塞。Redis 会维护新旧两张表在后续操作中逐步迁移桶每次执行部分迁移工作。查询时可能需要检查两张表。完成迁移后释放旧表。面试总结Redis 使用哈希表提供平均 O(1) 的字段查询并通过渐进式 rehash 将扩容成本分散到多次操作中避免单次长时间阻塞。4. Set4.1 使用场景点赞用户集合、抽奖参与用户集合。标签系统、黑名单、已访问用户集合。共同关注交集。合并多个用户群并集。推荐排除已关注内容差集。去重与成员判断。4.2 底层实现经典面试答案是元素全部是整数且数量较少使用intset。出现非整数元素或数量超过阈值转换为hashtable。需要知道的版本补充较新的 Redis 源码还支持使用listpack保存小型 Set因此更完整的回答是intset / listpack / hashtable但多数面试题仍以intset / hashtable为标准答案。4.3 IntSet 原理IntSet 是专门存储整数的小型有序集合底层是一块连续且有序的整数数组。支持二分查找查找复杂度为 O(log n)。插入和删除需要移动后续元素复杂度为 O(n)。根据最大数值范围选择int16、int32或int64编码。加入更大的整数后会整体升级编码一般不会再降级。它节省内存的原因是不需要为每个整数创建独立对象。不需要哈希桶、链表指针等额外结构。所有整数使用统一宽度连续存放。面试总结IntSet 适合元素全部是整数且数量较少的 Set它用有序连续数组节省内存但中间插入可能移动数据。5. ZSetSorted Set5.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成员值。backwardLevel 0 的后退指针方便反向遍历。level[]每层的前进指针和跨度span。span记录跨过的节点数量用于快速计算排名。5.4 为什么 Redis 选择跳表而不是红黑树实现和维护相对简单随机层高代替复杂的旋转和再平衡。范围查询自然定位起点后沿 Level 0 顺序遍历即可。排名计算方便通过span可以在查找过程中累计排名。更新局部化主要修改搜索路径上的指针不需要树旋转。平均复杂度足够稳定查找、插入和删除平均为 O(log n)。不建议把“并发友好”作为主要原因因为 Redis 命令执行主路径本身通常是串行的这并不是其选型的核心依据。紧凑型结构ZipList 与 ListPackZipList压缩列表旧实现ZipList 是 Redis 早期用于小型 List、Hash 和 ZSet 的紧凑顺序结构。所有元素存放在一块连续内存中避免了大量指针开销。整体结构可以简化为| zlbytes | zltail | zllen | entry1 | entry2 | ... | zlend |字段含义zlbytes整个压缩列表占用的总字节数。zltail尾节点相对于起始位置的偏移量。zllen元素数量。zlend结束标记0xFF。单个 entry 可以简化为| prevlen | encoding | data |prevlen前一个 entry 的长度用于反向遍历。encoding当前数据类型和长度编码。data实际数据。ZipList 的优点连续内存内存利用率高。无需为每个元素保存完整的前后指针。缓存局部性较好。ZipList 的连锁更新问题prevlen的长度取决于前一个 entry 的长度。当某个 entry 变大后后一个 entry 的prevlen可能从 1 字节扩展为 5 字节后一个 entry 因此也变大又可能继续影响下一个 entry形成连锁更新。极端情况下需要连续扩展很多节点并反复移动内存代价可能很高。这是 Redis 使用 ListPack 替代 ZipList 的重要原因之一。ListPackListPack 同样将多个元素紧凑地放在连续内存中但它不再让当前节点记录“前一个节点的长度”。整体结构可以简化为| total-bytes | num-elements | entry1 | entry2 | ... | end |单个 entry 可以简化为| encoding | data | backlen |backlen反向编码的是当前 entry 自己的长度。反向遍历时可以从 entry 尾部读取backlen计算当前 entry 的起始位置。为什么可以避免 ZipList 的连锁更新ZipList后一个节点的prevlen依赖前一个节点的长度。前一个变大后一个也可能被迫扩展。ListPack每个节点只记录自己的backlen。前一个节点变大不会修改后一个节点的元数据。因此ListPack 消除了 ZipList 因prevlen级联扩展产生的连锁更新问题。面试总结ListPack 保留了连续内存、节省指针开销的优势并通过记录当前 entry 自身长度解决 ZipList 的连锁更新问题。