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 不是“只有跳表”)
- [10.1 `String` 类型 和 `SDS` 不是一个概念](#10.1
- [📊 十一、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,第一阶段都停留在"会用命令"这一层。
但面试或者真实系统设计里,真正拉开差距的,往往不是你会不会写 SET、HGETALL、ZADD,而是你能不能讲清楚下面这些问题:
- 为什么 Redis String 明明叫"字符串",却既能存数字,也能高效追加?
- 为什么一个小字符串会有
int、embstr、raw三种不同实现? - 为什么 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 会根据值的内容和长度,把对象编码成 int、embstr 或 raw。
也就是说,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:
name、age、city - 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:新一代紧凑连续结构,可作为一些容器内部载体
也就是说:
quicklist和listpack不是替代关系,而常常是分层协作关系。
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,而对象编码上会根据值的形态和长度使用
int、embstr、raw。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 数据结构这一块,面试基本就能从"会用"升级到"会讲原理"。
📚 参考资料
- Redis 源码(3.0)
object.c中早期embstr阈值39:https://github.com/redis/redis/blob/3.0/src/object.c - Redis 源码(3.2)
object.c中embstr阈值44:https://github.com/redis/redis/blob/3.2/src/object.c - Redis 源码(3.0)旧版
sds.h:https://github.com/redis/redis/blob/3.0/src/sds.h - Redis 源码(3.2)新版
sds.h:https://github.com/redis/redis/blob/3.2/src/sds.h - Redis 官方文档:数据类型概览:https://redis.io/docs/latest/develop/data-types/
- Redis 官方文档:内存优化相关说明:https://redis.io/docs/latest/operate/oss_and_stack/management/optimization/memory-optimization/