Redis 数据类型与底层实现:从 SDS、Quicklist 到 ZSet 跳表彻底讲透

Redis 数据类型与底层实现:从 SDS、Quicklist 到 ZSet 跳表彻底讲透 -- pd的后端笔记

文章目录

    • [Redis 数据类型与底层实现:从 SDS、Quicklist 到 ZSet 跳表彻底讲透 -- pd的后端笔记](#Redis 数据类型与底层实现:从 SDS、Quicklist 到 ZSet 跳表彻底讲透 -- pd的后端笔记)
  • [🎯 核心问题](#🎯 核心问题)
  • [🧠 一、先建立全局认知:Redis 常见数据类型有哪些?](#🧠 一、先建立全局认知:Redis 常见数据类型有哪些?)
    • [1.1 常见数据类型总览](#1.1 常见数据类型总览)
    • [1.2 这些类型到底怎么选?](#1.2 这些类型到底怎么选?)
  • [🚀 二、String 为什么看起来简单,底层却最值得深挖?](#🚀 二、String 为什么看起来简单,底层却最值得深挖?)
    • [2.1 Redis String 的一句话回答](#2.1 Redis String 的一句话回答)
    • [2.2 SDS 到底是什么?](#2.2 SDS 到底是什么?)
      • [为什么 Redis 不直接用 C 字符串?](#为什么 Redis 不直接用 C 字符串?)
    • [2.3 Redis String 对象的三种常见编码](#2.3 Redis String 对象的三种常见编码)
    • [2.4 String 最大能存多大?](#2.4 String 最大能存多大?)
  • [🔍 三、为什么 EMBSTR 的阈值是 44?它的历史为什么很多人讲不清?](#🔍 三、为什么 EMBSTR 的阈值是 44?它的历史为什么很多人讲不清?)
    • [3.1 先说结论](#3.1 先说结论)
    • [3.2 为什么早期是 39?](#3.2 为什么早期是 39?)
    • [3.3 为什么后来变成 44?](#3.3 为什么后来变成 44?)
    • [3.4 一个很有意思的历史细节](#3.4 一个很有意思的历史细节)
    • [3.5 面试怎么答更稳?](#3.5 面试怎么答更稳?)
  • [🧩 四、Hash 到底是什么?为什么它不是"Map 这么简单"?](#🧩 四、Hash 到底是什么?为什么它不是“Map 这么简单”?)
    • [4.1 Hash 的本质](#4.1 Hash 的本质)
    • [4.2 Hash 相比 String(JSON) 的核心优势](#4.2 Hash 相比 String(JSON) 的核心优势)
    • [4.3 Hash 的底层编码演进](#4.3 Hash 的底层编码演进)
    • [4.4 Hash 常见操作命令速查](#4.4 Hash 常见操作命令速查)
    • [4.5 Hash 的适用场景](#4.5 Hash 的适用场景)
  • [📦 五、List 的演进最能体现 Redis 的工程取舍](#📦 五、List 的演进最能体现 Redis 的工程取舍)
    • [5.1 List 是什么?](#5.1 List 是什么?)
    • [5.2 List 常见操作命令速查](#5.2 List 常见操作命令速查)
    • [5.3 为什么早期双向链表不够好?](#5.3 为什么早期双向链表不够好?)
    • [5.4 Ziplist 是什么?](#5.4 Ziplist 是什么?)
    • [5.5 Quicklist 又是什么?](#5.5 Quicklist 又是什么?)
    • [5.6 Quicklist 为什么更适合现代 Redis?](#5.6 Quicklist 为什么更适合现代 Redis?)
    • [5.7 用一张图看懂 Ziplist 和 Quicklist](#5.7 用一张图看懂 Ziplist 和 Quicklist)
    • [5.8 什么时候该用 List?](#5.8 什么时候该用 List?)
  • [🧱 六、ListPack 是什么?它和 Ziplist 有什么关系?](#🧱 六、ListPack 是什么?它和 Ziplist 有什么关系?)
    • [6.1 为什么 Redis 要从 Ziplist 走向 ListPack?](#6.1 为什么 Redis 要从 Ziplist 走向 ListPack?)
    • [6.2 ListPack 的核心特点](#6.2 ListPack 的核心特点)
    • [6.3 一句话理解三者关系](#6.3 一句话理解三者关系)
    • [6.4 一张表看懂它们的演进关系](#6.4 一张表看懂它们的演进关系)
  • [🏆 七、Zset 为什么是 Redis 面试里的超级高频点?](#🏆 七、Zset 为什么是 Redis 面试里的超级高频点?)
    • [7.1 Zset 是什么?](#7.1 Zset 是什么?)
    • [7.2 Zset 常见操作命令速查](#7.2 Zset 常见操作命令速查)
    • [7.3 Zset 的实现原理:为什么是 dict + skiplist?](#7.3 Zset 的实现原理:为什么是 dict + skiplist?)
      • [如果只用 dict](#如果只用 dict)
      • [如果只用 skiplist](#如果只用 skiplist)
    • [7.4 跳表的实现原理到底是什么?](#7.4 跳表的实现原理到底是什么?)
    • [7.5 Redis 跳表有哪些实现要点?](#7.5 Redis 跳表有哪些实现要点?)
      • 1)随机层高
      • [2)按 score 排序,score 相同时按 member 排序](#2)按 score 排序,score 相同时按 member 排序)
      • [3)支持 span(跨度)信息](#3)支持 span(跨度)信息)
    • [7.6 用一句话讲清 Zset](#7.6 用一句话讲清 Zset)
  • [⚖️ 八、为什么 Zset 用跳表,而不是红黑树?不是 B+ 树?](#⚖️ 八、为什么 Zset 用跳表,而不是红黑树?不是 B+ 树?)
    • [8.1 为什么不是红黑树?](#8.1 为什么不是红黑树?)
      • [原因 1:实现复杂度](#原因 1:实现复杂度)
      • [原因 2:范围查询与顺序遍历很自然](#原因 2:范围查询与顺序遍历很自然)
      • [原因 3:支持排名更方便](#原因 3:支持排名更方便)
      • [原因 4:概率平衡对 Redis 已经足够](#原因 4:概率平衡对 Redis 已经足够)
    • [8.2 为什么不是 B+ 树?](#8.2 为什么不是 B+ 树?)
    • [8.3 一张表看懂三者差异](#8.3 一张表看懂三者差异)
  • [💡 九、Redis 源码中有哪些巧妙设计?](#💡 九、Redis 源码中有哪些巧妙设计?)
    • [9.1 SDS:把"字符串"做成服务端友好的动态字节数组](#9.1 SDS:把“字符串”做成服务端友好的动态字节数组)
    • [9.2 `embstr`:对象头与内容一次分配](#9.2 embstr:对象头与内容一次分配)
    • [9.3 对象类型与底层编码分离](#9.3 对象类型与底层编码分离)
    • [9.4 渐进式 rehash](#9.4 渐进式 rehash)
    • [9.5 Quicklist:把链表和压缩结构揉成一个复合结构](#9.5 Quicklist:把链表和压缩结构揉成一个复合结构)
    • [9.6 惰性删除 + 定期删除的过期策略组合](#9.6 惰性删除 + 定期删除的过期策略组合)
  • [⚠️ 十、这些点最容易讲混](#⚠️ 十、这些点最容易讲混)
    • [10.1 `String` 类型 和 `SDS` 不是一个概念](#10.1 String 类型 和 SDS 不是一个概念)
    • [10.2 `embstr` 不是一种数据类型,而是一种编码方式](#10.2 embstr 不是一种数据类型,而是一种编码方式)
    • [10.3 `quicklist` 不是 `ziplist` 的简单别名](#10.3 quicklist 不是 ziplist 的简单别名)
    • [10.4 `listpack` 不是"另一个 list"](#10.4 listpack 不是“另一个 list”)
    • [10.5 Zset 不是"只有跳表"](#10.5 Zset 不是“只有跳表”)
  • [📊 十一、Redis 数据结构选型速查表](#📊 十一、Redis 数据结构选型速查表)
  • [✅ 十二、面试速答模板](#✅ 十二、面试速答模板)
    • [12.1 Redis 常见数据类型有哪些?](#12.1 Redis 常见数据类型有哪些?)
    • [12.2 Redis String 底层是什么?](#12.2 Redis String 底层是什么?)
    • [12.3 Redis String 最大多大?](#12.3 Redis String 最大多大?)
    • [12.4 `embstr` 为什么是 44?](#12.4 embstr 为什么是 44?)
    • [12.5 Zset 为什么用跳表?](#12.5 Zset 为什么用跳表?)
  • [🎯 总结](#🎯 总结)
  • [📚 参考资料](#📚 参考资料)

🎯 核心问题

很多同学学 Redis,第一阶段都停留在"会用命令"这一层。

但面试或者真实系统设计里,真正拉开差距的,往往不是你会不会写 SETHGETALLZADD,而是你能不能讲清楚下面这些问题:

  • 为什么 Redis String 明明叫"字符串",却既能存数字,也能高效追加?
  • 为什么一个小字符串会有 intembstrraw 三种不同实现?
  • 为什么 Redis 的 List 没直接用双向链表,而是后来演进成 quicklist
  • 为什么 Zset 不是红黑树,也不是 B+ 树,而是跳表?
  • Redis 源码里到底有哪些"一看就很巧"的设计?

这背后真正考察的,是你对 Redis 的对象模型、底层编码、内存布局、性能取舍有没有建立起系统认知。

这篇文章就不再带你刷具体命令操作了,而是把重点放在:

  • 常见数据类型的区别
  • 底层实现原理
  • 适用场景与选型思路
  • 一些高频面试点的"为什么"

你可以把这篇文章理解成:Redis 数据结构部分的进阶版总复习。


🧠 一、先建立全局认知:Redis 常见数据类型有哪些?

如果只停留在"Redis 是 key-value",那理解会很浅。

Redis 的厉害之处,不只是快,而是它把"值"设计成了多种高效的数据结构。不同结构,本质上是在解决不同业务问题。

1.1 常见数据类型总览

数据类型 典型业务场景 核心特点 常见底层编码 / 结构
String 缓存对象、计数器、分布式锁、Token 最通用,既能存文本也能存整数 int / embstr / raw,底层字符串是 SDS
Hash 用户资料、商品属性、对象字段集合 适合存结构化对象,支持 field 粒度修改 小对象时紧凑编码(早期 ziplist,新版本多为 listpack),大对象时哈希表
List 消息队列、时间线、任务队列 有序、可重复、支持两端操作 现代 Redis 主要是 quicklist
Set 标签、去重、共同好友、抽奖 无序、元素唯一 整数集合 intset 或哈希表
Zset 排行榜、延迟任务、带权重排序 有序 + 元素唯一 + score 可排序 dict + skiplist,小对象时紧凑编码
Bitmap 签到、活跃统计、布尔状态压缩 省内存位图统计 本质是 String 上按位操作
HyperLogLog UV 统计 极省内存的近似去重计数 概率统计结构
Stream 消息流、消费组 比 List 更像真正消息队列 Radix tree + listpack 等复合结构

1.2 这些类型到底怎么选?

很多人一上来就问:"我能不能统一用 String 存 JSON?"

当然能,但这通常不是最优解。

更推荐的思路是下面这张表:

需求 更适合的类型 原因
一个简单值、计数器、缓存串 String 通用性最强,读写开销小
一个对象有多个字段,且字段经常单独改 Hash 不用整对象反序列化后再整体覆盖
先进先出、两端推拉 List 天然支持头尾操作
去重、求交集、并集 Set 元素唯一,集合运算直接可用
既要去重又要排序 Zset score 排序非常适合排行榜、延迟执行

一句话总结:

Redis 不只是"存数据",而是在帮你用合适的数据结构建模业务。


🚀 二、String 为什么看起来简单,底层却最值得深挖?

Redis 里最常用的数据类型就是 String,但它绝不是一个朴素的 C 字符串。

2.1 Redis String 的一句话回答

Redis String 的底层不是普通 char[],而是 SDS(Simple Dynamic String) ;同时 Redis 会根据值的内容和长度,把对象编码成 intembstrraw

也就是说,String 这个"类型"背后,其实是两层:

  • 对象层:Redis 对外暴露的是 String 对象
  • 内容层:真正保存字节序列的是 SDS

2.2 SDS 到底是什么?

SDS 可以理解成:带长度信息的动态字符串

它的核心思想不是"我也是个字符串",而是"我比 C 字符串更适合做服务端高频修改"。

为什么 Redis 不直接用 C 字符串?

因为普通 C 字符串有几个天然问题:

  • 获取长度要扫一遍,时间复杂度是 O(n)
  • 拼接时容易发生缓冲区溢出
  • 二进制不安全,遇到 \0 就会提前终止
  • 频繁修改时,扩容策略不友好

而 SDS 解决方案很直接:

SDS 特性 解决的问题 工程后果
显式保存 len 不需要遍历求长度 strlen 类操作降为 O(1)
显式保存 alloc / 可用空间 知道还剩多少容量 追加更高效
惰性空间释放 缩短字符串时不急着回收 减少频繁重分配
空间预分配 扩容不每次只加 1 个字节 降低 realloc 次数
二进制安全 不依赖 \0 判定结束 可存图片片段、序列化字节流、协议数据

你可以把 SDS 想象成:

它不是"只会存文本的字符串",而是"带元信息、可高频修改、适合服务端场景的字节数组"。

2.3 Redis String 对象的三种常见编码

1)int

如果值本身能用整数表示,而且范围合适,Redis 会直接把它编码成整数。

适合场景:

  • 计数器
  • 点赞数
  • 库存数
  • 访问量

优点很明显:

  • 节省内存
  • 自增自减非常自然
  • 避免字符串解析开销

2)embstr

如果字符串比较短,Redis 会使用 embstr

embstr 的关键点不在于"短字符串",而在于:

Redis 对象头和 SDS 会一次性分配在一块连续内存里。

这带来两个直接好处:

  • 内存分配次数从 2 次变成 1 次
  • CPU 缓存局部性更好

所以它特别适合:

  • 短文本缓存
  • 小型 key 对应的小 value
  • 读多写少的小字符串对象

3)raw

如果字符串较长,Redis 会使用 raw

此时对象头和 SDS 通常是分开分配的,更适合可修改、可扩展的大字符串。

为什么长字符串不用 embstr

因为 embstr 追求的是紧凑与一次分配 ,但它不适合高频修改;一旦发生修改,很多场景下都会转成 raw

2.4 String 最大能存多大?

这是一个非常高频的面试题。

一句话答案

Redis 字符串类型单个 value 的最大大小是 512MB

这个限制意味着什么?

它不是说你"应该"把 500MB 数据塞进一个 String,而是说协议和实现允许到这个量级。

但在工程上,大 value 往往意味着风险:

风险点 为什么危险
网络传输慢 一次读写就可能拉高 RT
主线程阻塞时间变长 Redis 是单线程处理命令,超大 value 会拖慢其他请求
内存碎片更明显 大对象分配 / 回收更容易造成碎片
主从复制代价高 同步、重写、迁移成本都高

所以最佳实践往往不是"我能存多大",而是:

尽量别把大对象设计成单个 String。

更合理的思路通常是:拆分、分片、改模型。


🔍 三、为什么 EMBSTR 的阈值是 44?它的历史为什么很多人讲不清?

这道题的难点不在于背出一个数字,而在于你能不能讲出这个数字背后的内存布局。

3.1 先说结论

  • 在较老版本 Redis 中,embstr 阈值曾经是 39
  • 从后续版本演进后,这个阈值变成了 44
  • 核心原因不是业务语义变了,而是对象头 + SDS 头部的内存布局变化了

3.2 为什么早期是 39?

早期版本里:

  • redisObject 约占 16 字节
  • 老版 sdshdr 头部约占 8 字节
  • 字符串内容再加结尾 \0

Redis 当时希望:

最大的 embstr 对象,能尽量落在 jemalloc 的 64 字节分配档位里。

于是大致就是:

text 复制代码
64 - 16(robj) - 8(sdshdr) - 1(\0) = 39

所以旧版本代码里会看到经典的 39

3.3 为什么后来变成 44?

Redis 后来对 SDS 头部做了更细粒度的区分,比如 sdshdr8 / 16 / 32 / 64

对于短字符串,常用的是 sdshdr8,它的头部比老版 sdshdr 更紧凑。

这时大致可以理解为:

text 复制代码
64 - 16(robj) - 3(sdshdr8头) - 1(\0) = 44

所以阈值从 39 提升到了 44

换句话说,不是 Redis 突然"更喜欢"短字符串了,而是短字符串的头部更省空间了。

3.4 一个很有意思的历史细节

Redis 3.2 的源码里,宏已经是 44,但注释一度还保留着"current limit of 39"这样的老描述。

这正是源码阅读里很典型的现象:

  • 代码先改了
  • 注释没完全同步
  • 如果只背博客,不看源码,很容易把历史版本和当前版本讲混

所以这道题更稳妥的说法应该是:

embstr 阈值并不是一个纯理论常数,而是由对象头、SDS 头、分配器档位这些实现细节共同决定的工程值;早期常见是 39,后续因 SDS 头部布局变化演进到 44。

3.5 面试怎么答更稳?

可以这样说:

embstr 用于短字符串,它把 Redis 对象头和 SDS 一次性分配到一块连续内存里,减少分配次数并提升缓存局部性。这个阈值早期常见是 39,后来随着 SDS 头部结构优化变成 44,本质上是为了让整个对象尽量落在较小的内存分配档位中。


🧩 四、Hash 到底是什么?为什么它不是"Map 这么简单"?

很多同学会一句话回答:Hash 就是 field-value 结构。

这当然没错,但还不够。

4.1 Hash 的本质

Redis Hash 本质上是:

一个 key 对应一个小型字段字典。

例如:

  • key:user:1001
  • field:nameagecity
  • value:对应字段值

它特别适合建模"一个对象有多个属性"的场景,比如:

  • 用户资料
  • 商品信息
  • 订单摘要
  • 配置项集合

4.2 Hash 相比 String(JSON) 的核心优势

方案 优点 缺点 更适合的场景
String 存 JSON 结构直观,跨语言方便 改一个字段也要整串反序列化再回写 整体读写多、字段很少单改
Hash 字段级修改、字段级读取更自然 字段太多时也会膨胀 对象属性频繁单独更新

所以如果你的对象经常只改某几个字段,Hash 通常比 JSON String 更合理。

4.3 Hash 的底层编码演进

Hash 底层并不是永远都用哈希表。

它会根据"对象大小"和"字段数量"选择不同编码:

  • 小 Hash:用更紧凑的连续内存结构存,早期是 ziplist,新版本更多使用 listpack
  • 大 Hash:转成真正的哈希表

这背后的设计哲学非常 Redis:

小对象优先省内存,大对象优先保性能。

4.4 Hash 常见操作命令速查

你说了不用展开实操,那这里我们只做速查表。

操作目的 常见命令
设置字段 HSET
获取单个字段 HGET
获取多个字段 HMGET
获取所有字段和值 HGETALL
判断字段是否存在 HEXISTS
删除字段 HDEL
字段数统计 HLEN
数值字段自增 HINCRBY

4.5 Hash 的适用场景

更推荐的场景:

  • 用户信息缓存
  • 设备配置
  • 商品基础属性
  • 订单摘要信息

不太推荐的场景:

  • 单个 Hash 里塞几千上万字段
  • 经常 HGETALL 拉全量超大对象
  • 需要复杂嵌套查询

📦 五、List 的演进最能体现 Redis 的工程取舍

很多人对 List 的印象还停留在"一个双向链表"。

这其实是 Redis 早期实现阶段的理解,放到现代版本就不够了。

5.1 List 是什么?

Redis List 是一个有序、可重复、支持两端插入和弹出的线性结构。

典型场景:

  • 简单消息队列
  • 待处理任务队列
  • 最新动态时间线
  • 历史记录列表

5.2 List 常见操作命令速查

操作目的 常见命令
左侧入队 LPUSH
右侧入队 RPUSH
左侧出队 LPOP
右侧出队 RPOP
获取区间元素 LRANGE
获取长度 LLEN
按下标取元素 LINDEX
在阻塞模式下弹出 BLPOP / BRPOP

5.3 为什么早期双向链表不够好?

如果纯用双向链表,每个元素都要单独分配节点。

优点是:

  • 插入删除灵活
  • 两端操作方便

但缺点也很明显:

  • 每个节点都有额外指针开销
  • 内存碎片严重
  • CPU 缓存局部性差
  • 小元素特别浪费内存

如果你队列里全是几字节、十几字节的小消息,纯链表会显得非常"奢侈"。

5.4 Ziplist 是什么?

Ziplist 可以把它理解成:

一段连续内存里的紧凑列表。

它的优势:

  • 非常省内存
  • 小对象非常友好
  • 顺序存储,缓存局部性好

但它的问题也很典型:

  • 中间插入 / 删除可能触发整段内存搬迁
  • 连锁更新(cascade update)问题比较麻烦
  • 实现复杂,边界情况多

所以 Ziplist 很适合:

  • 小数据量
  • 元素短小
  • 节点变化不剧烈

但不适合:

  • 大量频繁修改
  • 大列表
  • 对稳定性能要求很高的场景

5.5 Quicklist 又是什么?

Quicklist 是 Redis 对 List 的一次非常经典的折中设计。

你可以把它理解成:

链表 + 紧凑连续存储块的组合体。

也就是说,它不是让每个元素都是一个节点,而是:

  • 外层是一个双向链表
  • 每个链表节点里,不再只放一个元素
  • 而是放一小段压缩列表 / 紧凑列表块

这就把两类结构的优点揉在了一起:

结构 优点 缺点
纯链表 插入删除灵活 指针开销大、碎片多
纯 Ziplist 内存紧凑 修改代价高,扩缩容成本高
Quicklist 在内存与修改成本之间做平衡 实现更复杂

5.6 Quicklist 为什么更适合现代 Redis?

因为大多数业务 List 都不是极端场景。

它们通常是:

  • 元素不算太大
  • 两端操作较多
  • 需要一定内存效率
  • 又不能接受纯连续内存结构修改代价太大

Quicklist 就很像一个工程上的"中间道路":

  • 不像链表那样每个元素一个节点那么浪费
  • 也不像一个超级大 Ziplist 那样修改一次就挪很多内存

5.7 用一张图看懂 Ziplist 和 Quicklist

传统双向链表
每个节点一个元素
Ziplist
所有元素放在一块连续内存
Quicklist
外层双向链表
节点1: 一小段紧凑存储
节点2: 一小段紧凑存储
节点3: 一小段紧凑存储

5.8 什么时候该用 List?

适合:

  • 简单消息队列
  • 消费者从两端取任务
  • 最新消息、最近访问记录

不适合:

  • 频繁按中间位置随机访问
  • 想做复杂排序
  • 需要去重

这时要考虑 Zset 或 Set。


🧱 六、ListPack 是什么?它和 Ziplist 有什么关系?

ListPack 可以理解成:

Ziplist 的后继者,是一种更现代、更简洁、更安全的紧凑存储格式。

6.1 为什么 Redis 要从 Ziplist 走向 ListPack?

Ziplist 最大的问题不是"不省内存",而是:

  • 编码和解析逻辑复杂
  • 元素长度变化时处理麻烦
  • 连锁更新问题让实现和性能都不够优雅

Redis 后续引入 ListPack,就是想保留"连续内存、紧凑存储"的优点,同时弱化 Ziplist 的历史包袱。

6.2 ListPack 的核心特点

特点 含义 工程收益
连续内存存放 元素紧凑排列 省内存、局部性好
编码更简化 相比 Ziplist 更易实现和维护 降低复杂度
更关注边界安全 减少历史结构的脆弱点 更稳
适合小对象紧凑编码 常作为 Hash / Zset / Quicklist 节点内部载体 兼顾空间和性能

6.3 一句话理解三者关系

  • ziplist:老一代紧凑连续结构
  • quicklist:List 的顶层组织结构
  • listpack:新一代紧凑连续结构,可作为一些容器内部载体

也就是说:

quicklistlistpack 不是替代关系,而常常是分层协作关系。

6.4 一张表看懂它们的演进关系

结构 定位 优点 痛点 典型使用阶段
ziplist 老版紧凑连续存储结构 省内存、局部性好 连锁更新、实现复杂、修改代价偏高 早期小 Hash / 小 Zset / List 节点
quicklist List 的顶层组织结构 兼顾链表灵活性和紧凑存储 结构更复合,理解门槛更高 现代 Redis List 主体实现
listpack 新版紧凑连续存储结构 比 ziplist 更简洁、更稳、更适合做内部载体 仍然不是大对象频繁中间修改的万能解 新版本小 Hash / 小 Zset / Quicklist 节点等

如果用一句更工程化的话来概括:

Redis 的演进路径不是"推翻重来",而是不断把老结构里最有价值的部分保留下来,再用新结构修掉旧问题。


🏆 七、Zset 为什么是 Redis 面试里的超级高频点?

因为它最能体现 Redis 的"复合结构设计"。

7.1 Zset 是什么?

Zset(Sorted Set)可以理解成:

带分数 score 的去重集合。

它同时满足三件事:

  • 元素唯一
  • 每个元素有一个 score
  • 元素按 score 有序

这使它特别适合:

  • 排行榜
  • 延迟任务
  • 优先级队列
  • 区间排名查询
  • TopN 场景

7.2 Zset 常见操作命令速查

操作目的 常见命令
添加元素及分数 ZADD
查看元素分数 ZSCORE
按分数范围查询 ZRANGEBYSCORE
按排名查询 ZRANGE
查看排名 ZRANK / ZREVRANK
删除元素 ZREM
统计元素数 ZCARD
分数自增 ZINCRBY

7.3 Zset 的实现原理:为什么是 dict + skiplist?

这是最关键的一句:

Redis Zset 大对象场景下,通常不是单靠一种结构实现,而是同时使用 dict + skiplist

它们分工非常明确:

结构 负责什么
dict 通过 member 快速找到对应 score
skiplist 按 score 维护有序关系,支持范围查询和排名

为什么不能只用其中一个?

如果只用 dict

  • 查某个 member 很快
  • 但没法天然有序
  • 做排名、范围查询会很痛苦

如果只用 skiplist

  • 有序没问题
  • 但按 member 精确查找不如哈希直接

所以 Redis 的做法非常聪明:

用哈希表解决"按成员找分数",用跳表解决"按分数排序与范围查询"。

这就是典型的"一个业务需求,两个结构协作完成"。

7.4 跳表的实现原理到底是什么?

跳表本质上可以理解成:

在有序链表上加多层索引。

最底层是完整链表,保存所有元素;上层则是"抽样出来的快捷通道"。

查找时,你不是一个节点一个节点地挪,而是:

  • 先在高层快速跳
  • 快到目标时再下沉
  • 最后在底层精确命中

像这样:
最高层索引
中间层索引
底层有序链表
member1
member2
member3
member4

7.5 Redis 跳表有哪些实现要点?

Redis 的跳表并不只是"多层链表"这么朴素,它还有几个很关键的设计:

1)随机层高

每个新节点插入时,会随机决定自己有几层。

这让跳表整体在概率意义上保持平衡,不需要像红黑树那样频繁旋转。

2)按 score 排序,score 相同时按 member 排序

这是为了保证顺序稳定。

否则相同 score 的元素,范围查询和排名顺序会混乱。

3)支持 span(跨度)信息

Redis 跳表节点里还维护了跨度,用于高效计算排名。

这点很重要,因为 Zset 不只是要"有序",还要支持:

  • 你排第几
  • 某个区间有多少个元素
  • 从第 N 名到第 M 名有哪些

如果没有 span,排名计算会慢很多。

7.6 用一句话讲清 Zset

Zset 本质上是一个"成员唯一、按 score 排序"的结构,底层大对象场景通常用 dict + skiplist 组合:dict 负责快速按 member 查找,skiplist 负责顺序、范围和排名。


⚖️ 八、为什么 Zset 用跳表,而不是红黑树?不是 B+ 树?

这是很多面试官喜欢追问的地方。

8.1 为什么不是红黑树?

红黑树当然也能做有序集合,但 Redis 还是更偏爱跳表,核心原因有下面几条。

原因 1:实现复杂度

红黑树需要:

  • 旋转
  • 染色
  • 多种平衡调整

跳表则简单得多:

  • 插入靠随机层高
  • 删除不需要复杂旋转
  • 代码更直观

而 Redis 很看重源码实现的简单性和可维护性。

原因 2:范围查询与顺序遍历很自然

跳表底层就是有序链表,所以做范围扫描很顺。

而 Zset 的典型操作恰恰就是:

  • 按 score 范围查
  • 按排名区间查
  • 顺序遍历前 N 名

这些都很贴合跳表的特性。

原因 3:支持排名更方便

Redis 跳表里维护 span 后,计算 rank 很自然。

红黑树也能做,但往往要引入额外字段与复杂维护逻辑。

原因 4:概率平衡对 Redis 已经足够

跳表是"概率平衡",不是"严格平衡"。

但 Redis 的目标不是做一个教科书式最优树结构,而是:

在足够快的前提下,让实现简单、稳定、可维护。

8.2 为什么不是 B+ 树?

B+ 树特别适合磁盘 / 页缓存场景,这是它最擅长的领域。

比如 MySQL InnoDB 索引为什么用 B+ 树?

因为:

  • 它面向磁盘页
  • 要减少随机 I/O 次数
  • 一个节点装很多 key,可以降低树高

但 Redis 是内存数据库,很多时候不需要优先围绕磁盘页去设计。

所以把 B+ 树直接拿来做 Redis Zset,收益并不大。

8.3 一张表看懂三者差异

结构 更适合的场景 优势 不足
跳表 内存中有序集合、范围查询、排名 实现简单,范围遍历自然,支持 rank 好做 概率平衡,不是严格平衡
红黑树 通用内存有序集合 理论成熟,严格平衡 旋转维护复杂,rank/范围实现不如跳表直观
B+ 树 磁盘 / 页存储索引 非常适合减少 I/O,树高低 更偏磁盘场景,内存里不一定划算

一句话总结:

Redis 选择跳表,不是因为跳表"绝对最强",而是因为它在 Redis 的使用模式下,做到了性能、实现复杂度、范围查询体验三者之间的很好平衡。


💡 九、Redis 源码中有哪些巧妙设计?

这一部分很适合在面试里体现"你不是只会背概念"。

下面列几个非常典型的例子。

9.1 SDS:把"字符串"做成服务端友好的动态字节数组

这是最经典的设计之一。

亮点在于:

  • O(1) 取长度
  • 二进制安全
  • 预分配与惰性释放
  • 比 C 字符串更适合服务端高频修改

9.2 embstr:对象头与内容一次分配

短字符串使用 embstr,把:

  • Redis 对象头
  • SDS 头
  • 实际字符内容

放在一块连续内存里。

这个设计看起来很小,但非常实用:

  • 少一次 malloc
  • 少一次 free
  • 局部性更好
  • 非常适合高频短字符串缓存

9.3 对象类型与底层编码分离

Redis 有一个很妙的思想:

逻辑类型和底层编码不是一回事。

比如同样是 String:

  • 可能编码成 int
  • 可能编码成 embstr
  • 可能编码成 raw

同样是 Hash / Zset:

  • 小对象可能是紧凑编码
  • 大对象才转成真正哈希表 / 跳表等结构

这意味着 Redis 能做到:

  • 对外接口统一
  • 对内实现按规模动态优化

这是非常漂亮的抽象层次设计。

9.4 渐进式 rehash

如果哈希表扩容一次性搬迁所有元素,主线程就会卡住。

Redis 的做法是渐进式 rehash

  • 不一次搬完
  • 后续增删改查顺手搬一点
  • 把一次大抖动摊平到多个请求中

这个设计非常符合 Redis 的运行模型,因为它本质上是在避免单次命令阻塞太久。

9.5 Quicklist:把链表和压缩结构揉成一个复合结构

Quicklist 是非常 Redis 风格的设计:

  • 不迷信纯理论最优
  • 而是针对实际业务负载折中

这种"组合式结构"正是 Redis 很多数据结构优化的共同思路。

9.6 惰性删除 + 定期删除的过期策略组合

Redis 过期 key 不会简单粗暴地"到点立刻全部扫描删除"。

它采用的是组合策略:

  • 访问到时顺手删(惰性删除)
  • 后台周期性抽样清理(定期删除)

这也是非常典型的工程思维:

不追求理论上最"干净",而是追求总体吞吐、实时性和成本之间的平衡。


⚠️ 十、这些点最容易讲混

10.1 String 类型 和 SDS 不是一个概念

  • String:Redis 的逻辑数据类型
  • SDS:Redis 底层实现字符串内容的结构

10.2 embstr 不是一种数据类型,而是一种编码方式

它不是和 String 平级的概念,而是 String 对象的一种底层编码。

10.3 quicklist 不是 ziplist 的简单别名

  • ziplist:一种紧凑连续存储结构
  • quicklist:一种更高层的复合 List 结构

10.4 listpack 不是"另一个 list"

它不是 Redis 的对外数据类型,而是一种内部紧凑存储格式。

10.5 Zset 不是"只有跳表"

大对象常见实现是 dict + skiplist,而不是只靠跳表单打独斗。


📊 十一、Redis 数据结构选型速查表

业务需求 推荐类型 关键原因 注意事项
缓存简单值 / Token / 计数器 String 最轻量、最通用 避免大 value
用户对象、商品属性 Hash 字段级更新方便 别把单个 Hash 做得过大
简单队列 / 时间线 List 两端操作高效 不适合随机访问
标签、去重 Set 唯一性强 无排序
排行榜、延迟队列 Zset 按 score 排序 + 范围查询 元素唯一,注意 score 设计

✅ 十二、面试速答模板

12.1 Redis 常见数据类型有哪些?

可以这样答:

常见有 String、Hash、List、Set、Zset,另外还有 Bitmap、HyperLogLog、Stream 等扩展结构。它们的差异不只是命令不同,而是底层建模能力不同,比如 Hash 适合对象字段集合,List 适合双端队列,Zset 适合带分值排序。

12.2 Redis String 底层是什么?

Redis String 底层内容用的是 SDS,而对象编码上会根据值的形态和长度使用 intembstrraw。SDS 相比 C 字符串支持 O(1) 取长度、二进制安全、预分配和惰性释放。

12.3 Redis String 最大多大?

单个 String value 最大 512MB,但工程上不建议设计大 value,因为会带来网络、阻塞、复制和内存碎片问题。

12.4 embstr 为什么是 44?

本质是为了让对象头、SDS 头和字符串内容整体尽量落在较小内存分配档位中。历史上早期阈值常见是 39,后续由于 SDS 头部结构优化,演进为 44。

12.5 Zset 为什么用跳表?

因为 Zset 需要有序、范围查询和排名能力,而 Redis 用 dict + skiplist 组合能同时满足按 member 快查和按 score 排序。跳表相比红黑树更容易实现,范围遍历更自然,也更符合 Redis 对工程复杂度的取舍。


🎯 总结

把 Redis 数据结构真正学明白,关键不是背命令,而是建立下面这条主线:

  • String 背后是 SDS + 多种对象编码
  • Hash / List / Zset 都不是"一个固定结构",而是会根据规模选择不同实现
  • Ziplist、Quicklist、ListPack 体现的是 Redis 在"省内存"和"稳性能"之间反复权衡
  • Zset 选择跳表,本质是工程上的平衡,而不是教科书式唯一最优解
  • Redis 很多源码设计都在说明一件事:对外统一接口,对内按数据规模和访问模式做动态优化

如果你把这条主线讲顺,Redis 数据结构这一块,面试基本就能从"会用"升级到"会讲原理"。


📚 参考资料

相关推荐
汀、人工智能5 小时前
[特殊字符] 第100课:任务调度器
数据结构·算法·数据库架构·贪心··任务调度器
XDHCOM5 小时前
Redis节点故障自动恢复机制详解,如何快速抢救故障节点,确保数据不丢失?
java·数据库·redis
会编程的土豆6 小时前
日常做题 vlog
数据结构·c++·算法
却话巴山夜雨时i7 小时前
互联网大厂Java面试场景:Spring Boot、微服务与Redis实战解析
spring boot·redis·微服务·kafka·prometheus·java面试·电商场景
麒麟ZHAO7 小时前
鸿蒙flutter第三方库适配 - 文件对比工具
数据库·redis·flutter·华为·harmonyos
香蕉鼠片7 小时前
Redis
数据库·redis·缓存
旖-旎7 小时前
哈希表(存在重复元素||)(4)
数据结构·c++·算法·leetcode·哈希算法·散列表
小臭希7 小时前
Redis(NoSQL数据库,Linux-Ubuntu环境下)
数据库·redis·缓存
被摘下的星星7 小时前
数据结构中逻辑结构和存储结构对应有哪些
数据结构