Redis 面试题:有一种数据类型要存两次,为什么?
写在前面:这道题到底在问什么
Redis 面试里有一种题目,初看很怪:“有一种数据类型,Redis 要存两次,为什么?”很多候选人听到“存两次”,第一反应是内存浪费,接着就开始猜主从复制、RDB 加 AOF、应用层双写缓存,结果越答越偏。
这道题的真正考点,其实藏在一个非常具体的底层设计上:Redis 的有序集合 ZSet(Sorted Set),在常见编码下,同时用了“跳表(skiplist)”和“字典(dict)”两套结构来维护同一份数据。同一个成员和分数的映射关系,在两套结构里各自登记了一次,因此从“索引结构”的角度看,它确实被“存了两次”。
面试官真正想考察的,不是“有没有存两次”这个字面答案,而是你能不能顺藤摸瓜讲清楚下面这一整条链路:
- Redis 的对象系统和编码机制是怎么工作的;
- 为什么 ZSet 只用跳表不够,只用字典也不够;
- 这两套结构各自解决了什么问题,又各自放弃了什么;
- “存两次”会不会导致内存真的翻倍,Redis 做了哪些优化;
- 为什么跳表能胜任排序,而字典能胜任常数时间点查;
- 以及这些设计最终如何落到排行榜、延迟队列等真实业务里。
如果你能把这套逻辑完整讲出来,这道题就从“名词背诵”变成了“工程权衡”的加分题。下面我们用一篇文章的篇幅,把它彻底拆开。
1. 先把结论说清楚:ZSet 的“双结构”设计
Redis 的每个键值对都会用一个redisObject来描述“值”这个对象。对象本身带有类型(type)和编码(encoding)信息。对有序集合来说,类型是OBJ_ZSET,而编码主要有两种:
- 紧凑编码阶段:元素数量很小、单个元素很短时,使用
ziplist(压缩列表)或 Redis 7.0 之后的listpack(紧凑列表)连续存储,按成员、分数交替排列,靠线性扫描完成操作。 - 常规编码阶段:数据变多、元素变长之后,升级为
OBJ_ENCODING_SKIPLIST,此时底层由一个zset结构同时持有两个内部结构——dict字典和zskiplist跳表。
所谓“存两次”,指的就是常规编码下这个zset:
typedef struct zset { dict *dict; zskiplist *zsl; } zset;同一个成员member和它的分数score,既作为“成员到分数”的键值对放在了字典里,又作为“按分数排序的节点”放在了跳表里。这就是两套索引并存的原因。
用一句话概括:字典负责“按成员查分数”,跳表负责“按分数排序、取范围、查排名”。单靠任何一套结构,都无法同时把这两类操作都做到足够快。
2. 先回到问题原点:ZSet 到底要支持哪些操作
理解“为什么要两套结构”之前,必须先看有序集合在业务中承担的角色。ZSet 和普通 Set 最大的区别是:每个成员除了“是否存在”,还带有一个score分数,Redis 会按分数对成员排序。
围绕这个特性,ZSet 至少需要高效支持以下几类操作:
2.1 按成员做点查和点改
ZSCORE key member:查某个成员的分数。ZADD key score member:新增或更新某个成员。ZREM key member:删除某个成员。
这些操作都“只关心某一个具体成员”,最自然的期望是像哈希表一样 O(1) 定位。这类操作由字典来保证。
2.2 按分数做范围和排序
ZRANGE key start stop:按下标(排名)取一段成员。ZRANGEBYSCORE key min max:按分数区间取一段成员。ZREVRANGE key start stop:逆序按下标取成员。ZRANK key member:查某个成员的正序排名。ZREVRANK key member:查某个成员的逆序排名。
这些操作都关心“顺序”和“范围”。字典本身是无序的,无法高效给出“排第 N 名是谁”或“分数在 80 到 90 之间的成员有哪些”。这类操作由跳表来保证。
2.3 两类操作的时间复杂度要求
| 操作 | 字典能办到吗 | 跳表能办到吗 | 期望复杂度 |
|---|---|---|---|
| ZSCORE 按成员查分数 | O(1) | 需要遍历,差 | O(1) |
| ZADD 插入或更新 | O(1) | O(logN) | 整体 O(logN) |
| ZRANK 查排名 | 不支持 | O(logN) | O(logN) |
| ZRANGE 按排名取范围 | 不支持 | O(logN+M) | O(logN+M) |
| ZRANGEBYSCORE 按分数取范围 | 不支持 | O(logN+M) | O(logN+M) |
可以看到:没有任何一种单一常用结构,能既 O(1) 按成员查分数,又 O(logN) 按分数排序取范围。这正是 Redis 选择“两套结构互补”的根本原因。
3. 先看第一套结构:字典 dict 负责“人找人”
ZSet 里的字典,保存的是从成员字符串到分数的映射。概念上等价于一个普通的哈希表:
member1 -> score1 member2 -> score2 member3 -> score3 ...当客户端执行ZSCORE leaderboard Alice时,Redis 只需要拿Alice去字典里做一次哈希查找,就能立刻返回她的分数,不需要关心其他成员、不需要排序,时间复杂度 O(1)。
如果只有字典、没有跳表,那么:
- 查分数很快;
- 但要回答“排行榜第 10 名是谁”,字典完全无能为力,必须把全部键值对取出来排序,复杂度退化到 O(NlogN),在大集合上不可接受。
所以字典解决的是“给定名字,迅速找到他的分数”这件事,单独使用不完整。
4. 再看第二套结构:跳表 skiplist 负责“按序排队”
跳表解决的是另一个问题:如何以较小的代价,维护一个有序序列,并支持根据排名或分数快速定位。
最朴素的有序结构是“双向链表”:插入、删除只要改指针,但查找第 N 个元素要沿着链表一个个数,O(N),太慢。为了提速,跳表在链表的基础上增加了多层“快速通道”:越高的层跨过的节点越多,查找时从最高层出发,先大步跳跃,再逐步下沉到细节。这样,查找、插入、删除、按排名定位都能做到平均 O(logN)。
ZSet 里的跳表节点,按score升序排列;当两个成员分数相同时,再按成员字符串的字典序排序,保证全局有序且唯一。
5. “存两次”的第一层真相:两套结构各司其职
现在可以把核心结论讲清楚了。ZSet 常规编码下,成员和分数的数据被两套结构同时索引:
- 字典:
member -> score,服务ZSCORE、ZADD更新判定、ZREM等“按成员”的操作。 - 跳表:
(score, member) 有序节点链,服务ZRANGE、ZRANGEBYSCORE、ZRANK等“按顺序和范围”的操作。
以一次ZADD leaderboard 99 Alice为例,Redis 要做两件事:
- 在字典里登记
Alice -> 99; - 在跳表里按分数 99(成员字典序)把
Alice插入到正确的位置。
以后再执行ZSCORE leaderboard Alice时走字典,执行ZRANK leaderboard Alice时走跳表。一次写入、两处索引,这就是“存两次”的工程含义。
6. 面试追问一:为什么不“只用跳表”去查成员分数
有人会问:跳表既然已经存了成员,为什么不顺便查分数?答案是:可以查,但不够快。
跳表的节点是按score排序组织的,它的“索引键”本质是分数和成员的比较函数,而不是哈希值。如果想在跳表里按成员名查它的分数,你需要一个“从成员到节点的映射”,但裸跳表并不提供这种哈希索引。你只能从表头开始按顺序关系查找,虽然跳表本身是分层跳跃的,但它支持高效的前提是“按排序键搜索”;跨节点跳过的依据是比较score和member的大小。对于完全未知的任意成员,跳表做不到 O(1)。
换句话说,跳表的快速查找是“沿着有序关系”快速,不是“根据任意键”常数时间。因此单独用跳表,ZSCORE会退化为按成员比较逐步逼近,复杂度和随机访问近似 O(N),在需要高频点查排行榜成员分数的场景里是灾难。
7. 面试追问二:为什么不“只用字典”去做范围查询
反向的问题同样常见:既然字典点查快,为什么不直接给所有 key 排个序?
因为哈希表的物理布局和插入顺序、分数顺序都没有关系。哈希表为了 O(1) 定位,把 key 分散到不同的桶里;同一个桶内的元素之间也没有分数的大小顺序。要做ZRANGE或ZRANGEBYSCORE,你只能把全部成员和分数取出来,再按分数做一次性排序:
- 每次范围查询都是 O(NlogN) 或至少 O(N);
- 插入时如果要维持有序,又会破坏哈希表的 O(1) 写入。
这与 Redis 对 ZSet 操作“接近对数级、范围连续读取”的预期完全不符。
总结下来就是一张经典的权衡表:
| 结构 | 按成员查分数 | 按分数排序/范围 | 插入删除 |
|---|---|---|---|
| 只用字典 | O(1),擅长 | O(NlogN),不擅长 | O(1) |
| 只用跳表 | O(N),不擅长 | O(logN+M),擅长 | O(logN) |
| 字典 + 跳表 | O(1) | O(logN+M) | O(logN) |
两套结构组合后,各自的长处都保留了下来,代价则是额外的指针和维护成本,这就是 Redis 的取舍。
8. 第二层真相:是不是真的把“同一份数据”复制了两遍
讲到这里,很多候选人会顺嘴说“所以内存占用翻倍了”。这个说法不准确,也是面试官喜欢继续追问的地方。Redis 的“存两次”,存的是两套索引关系,而不是把字符串内容和分数值物理复制两份。
8.1 成员字符串是共享的
在跳表节点和字典条目中,成员对应的字符串对象并不是两个独立的拷贝。Redis 使用动态字符串 SDS,并通过引用计数管理生命周期。字典的 key 和跳表节点的ele字段指向的是同一个 SDS 字符串,只是两套结构各自记录了指向它的指针。
typedef struct zskiplistNode { sds ele; /* 成员字符串,与字典 key 共享 */ double score; struct zskiplistNode *backward; struct zskiplistLevel { struct zskiplistNode *forward; unsigned long span; } level[]; } zskiplistNode;所以成员字符串本身只有一份实际内存。所谓“存两次”,是关系的两套登记:
- 字典里有一条:
“Alice” -> 指向分数; - 跳表里有一个节点:
分数 99 + “Alice” 指针 + 若干层指针。
8.2 分数值是共享的
更巧妙的细节在“分数”上。字典条目的 value 并不保存一个孤立的double副本,而是保存了指向跳表节点score字段的指针。在添加元素时,Redis 先创建或定位跳表节点,再把字典条目的 value 指向这个节点的score字段:
znode = zslInsert(zs->zsl, score, ele); dictAdd(zs->dict, ele, &znode->score);这样,字典和跳表看到的始终是同一份分数。更新分数时,只要修改这一处double,两套结构就自动一致;同时也避免了“两处分数不同步”的数据一致性隐患。
8.3 真正的额外开销是什么
“存两次”真正消耗的内存,主要是:
- 字典的哈希桶、dictEntry、指针和 rehash 开销;
- 每个跳表节点的层数组
level[],以及每层中的前向指针forward和跨度span(后者通常是 8 字节级别的无符号整数); - 跳表头节点、尾指针、维护
length和最大层数等元数据。
节点的层数是随机的,平均层数约为 1/(1-p),Redis 中p = 0.25,所以平均约 1.33 层。也就是说,平均每个节点比单层链表多付出约三分之一的层指针,再叠加几字节的span。这才是双结构的真实代价,而非“内容翻倍”。
9. 第三层真相:小集合阶段其实可以“不存两次”
前面一直在说“ZSet 存两次”,但实际上 Redis 只有在集合比较大、元素比较长时,才升级为跳表加字典的双结构。当集合很小的时候,Redis 用的是完全不同的紧凑编码,这时候反而只有一份连续内存。
9.1 ziplist / listpack 编码
早期版本使用ziplist,Redis 7.0 起用listpack取代。它们的共同思路是:把成员和分数交替地紧凑排列在一块连续内存里:
[member1][score1][member2][score2][member3][score3] ...在这种编码下,ZSet 并不维护跳表和字典。成员和分数只在这一份连续内存里保存一次,查询、插入、删除都靠线性遍历完成。虽然时间复杂度是 O(N),但当元素只有十几个、几十个且单个元素很小时,线性扫描反而比维护两套复杂索引更快、更省内存。
9.2 什么时候从紧凑编码升级为双结构
Redis 不会让紧凑编码无限膨胀下去。它设置了两条转换阈值:
- 元素数量:超过配置的最大元素个数;
- 单元素长度:某个成员字符串超过配置的最大长度。
在早期版本中,这两个参数对应zset-max-ziplist-entries和zset-max-ziplist-value,默认值分别是 128 和 64 字节;Redis 7.0 之后,相关的配置名调整为zset-max-listpack-entries与zset-max-listpack-value,语义基本一致。
一旦触发任一条件,Redis 会把它从紧凑编码整体转换为OBJ_ENCODING_SKIPLIST,此后才真正进入我们前面讲的“字典 + 跳表”双结构阶段。
9.3 Redis 7.0 为什么用 listpack 替换 ziplist
ziplist 虽然省内存,但有一个著名问题:连锁更新。ziplist 的每个条目里会记录“前一个条目的长度”,如果前一个条目长度发生变化,后面的条目可能因为保存长度的字段宽度不够,被迫跟着变更,最坏情况下一次修改要更新一整串节点。
listpack 调整了元数据编码方式,不再保存“前一项长度”,而是用更紧凑的格式保存当前元素的长度信息,从设计上避免了连锁更新。因此 Redis 7.0 用 listpack 承接了原先 ziplist 的角色,同时也应用在 List、Hash 的小集合阶段。
10. 面试怎么答,才能从“会背”变成“真懂”
前面的内容已经覆盖了这道题纵深方向的全部细节。真正面试时,不建议一开口把这些全部倒出来,而是先亮结论,再分层展开,最后补一个小集合特例,这样既显得结构清楚,又不容易被追问到盲区。
10.1 第一句先给结论
我理解这里说的“存两次”,指的是 Redis 有序集合 ZSet 在常规编码下,同时用字典和跳表两套结构维护同一份数据:字典负责按成员查分数,跳表负责按分数排序和范围查询。
一句话把“存两次”的对象、原因和两套结构的分工都锚定下来,避免面试官误以为你不知道在问什么。
10.2 第二层解释为什么单一结构不够
如果只用字典,按成员查分数是 O(1),但做ZRANGE、ZRANK这类排序和范围查询就很不方便;如果只用跳表,按分数查询和排名很快,但ZSCORE无法 O(1) 定位成员分数。两者组合后,点查和范围查询都能拿到理想的复杂度。
10.3 第三层补充内存细节,证明自己没有理解偏
这里可以主动补一句:虽然看起来是“存两次”,但成员字符串通过 SDS 共享,分数也由字典条目直接指向跳表节点的score字段,所以并非把同一份业务数据物理复制两遍,额外付出主要是两套索引的指针和元数据开销。
10.4 最后补小集合特例,展示视野完整
如果时间允许,再补一句:当集合很小、元素很短时,Redis 会先用 ziplist 或 listpack 紧凑编码,此时只有一份连续内存,不会维护双结构;超过阈值后才升级为字典加跳表。这个细节会让回答的完整度明显上一个台阶。
11. 落到真实业务:排行榜和延迟队列
面试讲清原理后,如果能顺手带上两个真实场景,会更有说服力。ZSet 最常见的应用就是排行榜和延迟队列。
11.1 排行榜
把用户 ID 作为 member、分数作为 score,使用ZADD写入;需要展示前十名时,直接取倒数排名:
ZADD rank 1000 player_a ZADD rank 980 player_b ZINCRBY rank 30 player_a ZREVRANGE rank 0 9 WITHSCORES这里ZREVRANGE能高效取 TopN,依赖的正是跳表的有序性;查某个用户分数时走ZSCORE,依赖的正是字典 O(1) 点查。两个典型操作恰好对应两套底层的分工。
11.2 延迟队列
把任务执行时间戳作为 score、任务 ID 作为 member:
ZADD delay_queue 1700000000 task_1 ZRANGEBYSCORE delay_queue 0 当前时间戳 LIMIT 0 10 ZREM delay_queue task_1消费方不断用ZRANGEBYSCORE找出当前时刻之前应执行的任务,处理完再用ZREM移除。这个模式天然利用了 ZSet“按分数排序取范围”的能力,也解释了为什么它比 List 更适合做可排序、可延迟的任务队列。
12. 如果面试官继续挖:三个高频延伸点
12.1 编码转换阈值可以调整吗
可以。通过配置zset-max-listpack-entries(Redis 7.0 之前对应 ziplist 版本)和zset-max-listpack-value可以调整升级阈值。把阈值调大,小集合会在紧凑编码下停留更久、更省内存,但单次操作会维持线性扫描;调太小则会更快进入跳表编码。参数调优需要在内存占用和操作复杂度之间取舍。
12.2 为什么 ZSet 的常规编码用跳表,不用红黑树或 B+ 树
核心原因有三点:
- 跳表实现更简单,节点层级随机生成,插入删除不需要像红黑树那样做复杂旋转;
- 范围查询友好,找到起点后可以顺着 level 0 顺序遍历,天然适合
ZRANGE、ZRANGEBYSCORE; - 内存层级灵活,平均层数不高,实际开销可控。
Redis 作者也表达过,跳表在实现效率和范围查找上的综合表现,很适合 Redis 这种追求简单和性能的系统。
12.3 一次 ZADD 在双结构里的大致流程
可以把ZADD key score member理解为:先查字典判断 member 是否已存在;如果不存在,就创建跳表节点、插入跳表,并把字典 value 指向跳表节点的 score 字段;如果已存在,则更新 score,并在跳表中调整节点位置以维持有序性。整个过程保持了字典和跳表的一致,也体现了“一次写入、两处索引”这条主线。
13. 总结:把这道题答成“工程权衡”而不是“名词复读”
这道题从“有一种数据类型要存两次”出发,真正考的是三件事:
- 知不知道对象编码:ZSet 存在 ziplist/listpack 紧凑编码与 skiplist 编码两个阶段;
- 懂不懂双结构的必要性:字典负责按成员点查,跳表负责按分数排序取范围,单一结构无法全包;
- 能不能识别内存误区:“存两次”是两套索引而额外开销主要是索引本身,不是业务数据的物理复制。
把它理解清楚后,再遇到“Redis 为什么要这么设计”“跳表和红黑树比怎么样”“延迟队列怎么实现”这类问题,都能从同一套底层逻辑自然延伸。
面试时建议记住一条主线:先给结论,再说为什么单结构不行,然后补内存共享细节,最后提小集合特例。这样回答既有层次,也能覆盖大多数追问。