Redis 数据类型与底层结构
Redis 常用数据类型、对象编码、ZSet 跳表、渐进式 rehash、ziplist、listpack 与 SDS 高频面试题。
Redis 有哪些常用数据类型,各适合什么场景?
回答重点
Redis 最常用的五种数据类型是:
| 类型 | 主要特征 | 典型场景 |
|---|---|---|
| String | 字节序列,也支持整数增减、位操作 | 对象缓存、计数器、分布式锁令牌、Session |
| Hash | 一个 key 下保存多个 field-value | 对象属性、购物车、局部字段更新 |
| List | 有序、可重复的双端序列 | 简单队列、栈、时间线、定长列表 |
| Set | 无序、元素唯一,支持集合运算 | 去重、标签、点赞关系、共同关注、抽奖 |
| ZSet | 成员唯一,每个成员关联一个可重复的 score | 排行榜、权重排序、分数范围查询、延迟任务 |
Redis 后续还提供了几种常用能力:
- Bitmap:Redis 2.2 引入的位操作,本质上基于 String,适合签到、在线状态等二值统计。
- HyperLogLog:Redis 2.8 引入的近似基数统计,适合在允许误差时统计大规模 UV。
- GEO:Redis 3.2 引入的地理位置命令,适合附近位置和距离计算,其底层使用 ZSet。
- Stream:Redis 5.0 引入的消息流,提供消息 ID、消费组、确认和待处理消息列表。
List 可以实现简单队列,但不原生提供消费组、消息确认和失败重投。需要可靠消费语义时,通常优先考虑 Stream 或专用消息队列。
Redis 数据类型和底层编码是什么关系?
回答重点
数据类型定义对外的命令和语义,底层编码是 Redis 根据元素数量、元素大小和版本选择的内部实现。同一种类型可以在运行中转换编码,不能把二者当成一回事。
| 数据类型 | Redis 7.x 常见编码 | 说明 |
|---|---|---|
| String | int、embstr、raw | 可表示为整数时使用 int;短字符串可能使用连续分配的 embstr;其他字符串使用 raw |
| List | quicklist | quicklist 是双向链表,Redis 7.0 起节点内使用 listpack |
| Hash | listpack、hashtable | 小对象使用紧凑结构,超过配置阈值后转为哈希表 |
| Set | intset、listpack、hashtable | 纯整数小集合可用 intset;Redis 7.2 起小型非整数集合可用 listpack |
| ZSet | listpack、skiplist | 小集合使用 listpack;较大集合使用哈希表与跳表的组合 |
关键版本差异:
- Redis 3.2 到 6.2 的 List 已使用 quicklist,但其节点内是 ziplist;Redis 7.0 起改为 listpack。
- Redis 6.2 及以前,小型 Hash 和 ZSet 使用 ziplist;Redis 7.0 起改为 listpack。
- Redis 7.0 的 Set 仍主要是 intset 或 hashtable;Redis 7.2 才增加小型 Set 的 listpack 编码。
可以用下面的命令观察某个 key 当前采用的编码:
redis-cli OBJECT ENCODING my-key具体阈值和短字符串边界属于配置或版本实现细节,面试时应先说明版本,避免把某个版本的默认值说成永久规则。
Set 和 ZSet 有什么区别,ZSet 常见于哪些业务?
回答重点
- Set 只保证成员唯一,不维护顺序,核心能力是成员判断和交、并、差集。
- ZSet 同样保证成员唯一,但为每个成员保存一个
double类型的 score,并按 score 排序;score 可以重复。 - ZSet 支持按排名、score 范围和字典序查询,也可以原子地增加 score。
排行榜可以把文章 ID 作为 member、点赞数作为 score:
redis-cli ZADD article:ranking 200 article:1 40 article:2 100 article:3
redis-cli ZINCRBY article:ranking 1 article:2
redis-cli ZRANGE article:ranking 0 2 REV WITHSCORES
redis-cli ZSCORE article:ranking article:2Redis 6.2 起可以用 ZRANGE ... REV 统一升序和降序查询;旧代码中常见的 ZREVRANGE 仍可使用,但已被标记为弃用。
延迟任务也可以把执行时间戳作为 score,再查询不晚于当前时间的成员。不过“查询到期任务”和“删除任务”必须用 Lua 等方式组合成一个不可插入的操作,并补充确认、重试和死信机制;仅靠 ZRANGEBYSCORE 加 ZREM 不是可靠消息队列。
ZSet 的底层结构是怎样的?
回答重点
ZSet 有两类编码:
- 紧凑编码:元素较少且 member 较小时使用 listpack,按
member、score紧凑存放,节省指针和哈希表开销。 - 普通编码:数据较大时使用
dict + skiplist。dict 建立member -> score映射,便于按成员查找;skiplist 按score + member排序,支持排名和范围操作。
因此,“ZSet 底层就是跳表”不完整。普通编码同时维护哈希表和跳表,两者共享成员字符串,写操作需要同步更新两份索引。
默认情况下,Redis 7.x 的 zset-max-listpack-entries 为 128,zset-max-listpack-value 为 64 字节;超过任一限制时会使用普通编码。这些值可以配置,不是 ZSet 的固定语义。Redis 6.2 及以前对应的是 zset-max-ziplist-entries 和 zset-max-ziplist-value,紧凑结构也还是 ziplist。
面试官追问
为什么普通编码还要维护 dict?
跳表适合有序遍历和范围查询,但仅按 member 查找并不高效。dict 可以近似 O(1) 判断成员是否存在并取得 score,跳表则提供平均 O(log N) 的插入、删除和排名能力,二者是以额外内存换取不同查询路径的效率。
Redis 跳表如何实现,为什么不使用 B+ 树?
回答重点
跳表是在有序链表上增加多级稀疏索引:
- 第 0 层包含全部节点,层数越高,节点越稀疏。
- 查找从最高层开始,向前越过仍小于目标的节点,不能继续时下降一层。
- 节点保存 member、score、后向指针,以及各层的前向指针和跨度。
- 前向指针用于查找和遍历,后向指针支持倒序遍历,跨度用于计算排名。
- score 相同时再按 member 的字典序排序,保证顺序确定。
Redis 以概率方式生成节点层高:每增加一层的概率是 0.25。Redis 6.2 和 7.x 的最大层数是 32;Redis 5.0 的实现曾使用 64,因此不能脱离版本死记该数字。跳表查找、插入和删除的平均复杂度是 O(log N),最坏情况仍是 O(N)。
Redis 选择跳表而不是 B+ 树,主要是场景取舍:
- B+ 树通过高扇出和页布局减少磁盘 I/O,更适合磁盘或页式存储。
- Redis 的 ZSet 主要在内存中操作,不需要围绕磁盘页组织节点。
- 跳表插入和删除只需调整局部指针,不需要节点分裂、合并或树旋转,实现与维护成本较低。
- 跳表天然支持顺序、范围和反向遍历,符合 ZSet 的主要访问模式。
这不表示跳表在所有场景都优于 B+ 树;结论只针对 Redis 的内存有序集合及其工程目标。
ziplist 和 listpack 有什么区别?
回答重点
ziplist 和 listpack 都用一段连续内存紧凑保存多个整数或字符串,优势是减少小元素的指针和分配器开销,代价是插入、删除和查找中间元素通常需要 O(N) 扫描或移动内存。
ziplist 的表头记录总字节数、尾节点偏移和元素数量。每个 entry 主要包含:
prevlen:前一个 entry 的长度,用于反向遍历。encoding:当前数据的类型和长度。data:整数或字符串数据。
prevlen 会根据前一个 entry 的大小使用不同字节数。某个 entry 变大后,可能迫使下一个 entry 扩大 prevlen,并继续影响后续节点,这就是连锁更新。
listpack 保留连续内存布局,但 entry 记录的是自身编码、数据和自身长度 backlen,不再记录前一个 entry 的长度。反向遍历时可从当前 entry 尾部的 backlen 定位其开头,因此前一个 entry 变大不会连锁改变后续 entry 的长度字段。
版本演进需要分清:
- Redis 3.2 引入 quicklist,List 由多个 ziplist 节点组成,以限制单个连续块的大小。
- Redis 5.0 引入 listpack,最初用于 Stream 等结构。
- Redis 7.0 将 List 的 quicklist 节点以及小型 Hash、ZSet 的 ziplist 替换为 listpack。
listpack 解决的是 ziplist 的连锁更新,不会把中间插入和连续内存扩容变成 O(1)。
Redis 哈希表如何渐进式 rehash?
回答重点
Redis 的字典为 rehash 准备两张哈希表,并记录当前迁移位置:
- 正常情况下只使用第一张表。
- 需要扩容或收缩时,为第二张表分配合适容量,并开始 rehash。
- Redis 分批把第一张表中若干桶的全部键值迁移到第二张表,而不是一次搬完。
- 第一张表迁空后释放旧空间,将第二张表变成新的主表。
目标容量按实现选择能容纳数据的哈希表大小,并不等于任何情况下都机械地扩大两倍。
渐进式 rehash 期间:
- 新增键只写入第二张表,避免第一张表继续增长。
- 查找先查第一张表,未命中再查第二张表。
- 更新或删除会定位键实际所在的表并执行操作。
- 普通字典操作会顺带迁移桶,周期任务也会推进迁移,避免低请求量时长期无法完成。
这种设计把一次大规模迁移拆到多次事件循环中,降低单次停顿,但 rehash 期间需要同时保留两张表,会暂时增加内存占用。BGSAVE 或 AOF 重写期间还要关注写时复制带来的额外内存成本。
面试官追问
扩容过程中为什么还能正确读取?
因为旧数据只会从第一张表向第二张表迁移,查询会依次检查两张表;新数据又只进入第二张表,所以键不会落到查询范围之外。命令执行仍由主线程串行协调,也不会出现两个线程同时搬迁同一个桶的问题。
SDS 为什么比 C 字符串更适合 Redis?
回答重点
Redis 使用 SDS(Simple Dynamic String)保存字符串对象和许多内部文本。可以把它理解为“字节数组 + 长度与容量元数据”,具体头部会根据字符串大小使用 sdshdr5、sdshdr8、sdshdr16、sdshdr32 或 sdshdr64 等布局。
SDS 相比以 \0 结尾的普通 C 字符串有几个关键优势:
- 获取长度是
O(1):直接读取已保存的长度,不需要像strlen一样扫描。 - 二进制安全:读写以长度为准,数据中可以包含
\0,因此既能存文本也能存二进制内容。 - 降低缓冲区溢出风险:修改前根据已分配容量判断空间,不足时由 SDS API 扩容。
- 减少重复分配:有剩余容量时,追加数据可以复用现有缓冲区。
- 兼容部分 C API:有效数据末尾仍保留终止符,但终止符不参与长度计算。
String 对象不一定直接表现为独立 SDS:可表示为整数时可能使用 int 编码,短字符串可能使用对象头与 SDS 连续分配的 embstr,较长或被修改后的字符串通常使用 raw。这些编码不改变 String 对外的命令语义。
评论