跳到主要内容
约 12 分钟8 张卡

Redis 数据结构

理解 Redis 的值,要分清两个层次:数据类型决定你能用哪些命令、命令有什么语义,比如 List 能 LPUSH、ZSet 能按分数排序;内部编码决定这个值在内存里实际怎么存,比如同样是 Hash,少量字段和大量字段可以使用不同编码。选型时看数据类型,分析内存和性能时看编码。本文以 Redis 6、7 为基线,重点是按访问需求选类型、理解紧凑编码的取舍,以及 ZSet 为什么同时用字典和跳表。

1. 面试核心​

先记住这几条,再读正文
  • String 保存的是字节串,键空间字典负责从键找到值对象,两者是不同层次的东西。卡 · 字符串
  • 按访问方式选类型:Hash 按字段读写,Set 去重,ZSet 按分数和成员排序。卡 · 数据类型与场景
  • INCR 在一条命令里原子完成递增;客户端先 GET 再 SET 会丢失并发更新。卡 · 字符串
  • Redis 6 的 List 用 QuickList,即双向链表连接多个 ZipList 块,减少小元素的内存开销;Redis 7.0 起块内结构改为 Listpack。卡 · 列表
  • ZSet 的 skiplist 编码用字典按成员查分数,用跳表维护分数顺序。卡 · 有序集合

2. 数据类型与场景​

选类型时先问自己要对数据做什么操作,再找支持这种操作的类型:

类型常用操作面试中的选择依据
StringGET、SET、INCR独立值、计数器、序列化缓存
HashHGET、HSET、HINCRBY同一对象的多个字段,可局部读写
ListLPUSH、RPOP、LMOVE有序序列、双端队列;弹出后不会自动确认
SetSADD、SISMEMBER、SINTER去重、成员判断、交并差
ZSetZADD、ZSCORE、ZRANGE排名、分数区间、延迟任务的时间索引
StreamXADD、XREADGROUP、XACK日志式消息、消费组、待确认消息

命令和类型是绑定的:对一个 List 执行 GET,会得到 WRONGTYPE 错误。TYPE key 查看的是逻辑类型,OBJECT ENCODING key 查看的是内部编码。应用代码只按类型使用命令,编码用来诊断实现细节和内存成本。

3. 字符串​

String 可以保存文本、序列化后的对象,也可以保存任意二进制字节。在 Redis 6、7 里,它有三种内部编码:

  • int:值能表示成整数时,直接按整数保存。
  • embstr:短字符串,对象头和字符串内容一次分配、连续存放。
  • raw:较长的字符串,对象头和字符串内容分开分配。

字符串内容用 SDS(Simple Dynamic String)保存,它是 Redis 自己的字符串结构。SDS 显式记录了长度,不像 C 字符串那样靠结尾的零字节判断结束,所以内容里可以有零字节,这就是常说的二进制安全。它还记录了已分配的容量,连续追加时可以少做几次重新分配,但需要扩容时,仍可能重新分配内存并复制内容。

String 可以用来保存计数器:

SET interview:counter 41
INCR interview:counter
GET interview:counter

最后 GET 得到 42。INCR 在一条命令里完成了取值、加一和写回,这三步之间不会插入别的客户端的命令。它要求值能表示为有符号 64 位整数,值不是整数或者加完会溢出时,命令报错。

如果不用 INCR,而是客户端先 GET、算出新值、再 SET,就会出问题:两个客户端都 GET 到 41,各自 SET 成 42,明明加了两次,结果只加了一次。所以计数要直接用 INCR。

4. 列表​

List 是按插入顺序保存元素、允许重复的列表。从一端进、另一端出就是先进先出队列,比如 LPUSH 配合 RPOP;同一端进出就是后进先出的栈。在头尾操作单个元素是 O(1),按索引访问要沿着列表找过去,位置越靠中间成本越高。

内部编码这里有版本差异。Redis 6 的 List 用 QuickList:一个双向链表,每个节点是一个 ZipList 块。ZipList 把多个元素紧挨着存在一段连续内存里,省掉了每个元素单独的指针。代价是往块里插入元素要移动后面的数据,而且 ZipList 的每个条目记录了前一个条目的长度,前一个条目变长时,这个记录本身可能也要变长,一个接一个影响下去,这叫连锁更新。

Redis 7.0 起,QuickList 的块改用 Listpack。Listpack 把反向遍历需要的长度信息存在当前条目自己的尾部,不再依赖前一个条目的长度,所以不会出现连锁更新。

用 List 做队列时,要注意弹出就是删除:

LPUSH interview:queue first second
RPOP interview:queue

LPUSH 依次从左边插入 first 和 second,列表变成 second、first,RPOP 从右边弹出 first,列表剩下 second。普通弹出会直接删掉元素,消费者如果在处理过程中崩溃,这个任务就没了。

Redis 6.2 起可以用 BLMOVE 把任务原子地从待处理列表移到「处理中」列表。处理成功后再从处理中列表删除;超时没完成的任务,可以交给其他消费者重试。重试意味着同一任务可能被处理两次,业务上要保证幂等。

5. 有序集合​

ZSet 是按分数排序的集合,每个成员(member)唯一,并关联一个分数(score);不同成员的分数可以相同。排序先按 score,score 相同时按 member 的字节字典序。更新一个已有 member 的 score,它在排序中的位置也会跟着调整。

score 是双精度浮点数,[-2^53,2^53] 范围内的整数都能精确表示,超出后就不再保证。拿金额做 score 时,可以先换成最小货币单位(比如分),再确认不会超出这个范围。

编码同样有版本差异:Redis 6 的小 ZSet 可以用 ZipList,Redis 7.0 起改用 Listpack。元素变多后使用常规的 skiplist 编码,它同时维护两个结构:一个字典,按 member 查 score;一个跳表,按顺序访问。跳表是在有序链表上加了多层「快速通道」的结构,每个节点在每一层记录指向下一个节点的指针和跨度(跨过了几个成员),节点还有一个后退指针,用于反向遍历。

高层: [2] ----------> [6]
低层:[1]->[2]->[3]->[4]->[5]->[6]

查找 5 时,先在高层前进到 2;高层的下一跳是 6,已经超过目标,于是下降一层,从 2 继续往后走到 5。查找过程中把经过的跨度加起来,就得到了 5 的排名。

跳表查找的期望成本是 O(log N)。区间查询要返回 M 个成员时,还要再加上 O(M),返回的结果越多越慢。

ZADD interview:rank 100 alice 100 bob 80 carol
ZRANGE interview:rank 0 -1 WITHSCORES
ZADD interview:rank 120 alice

第一次查询的顺序是 carol/80、alice/100、bob/100,alice 和 bob 同分,按字典序 alice 在前。再次 ZADD alice 时,因为 member 唯一,集合里仍然只有三个成员,alice 的分数变成 120,排到了最后。

只有字典,可以快速查分数,但没法按顺序遍历;只有跳表,按 member 查分数就得扫描。两个结构一起用,才能同时满足 ZSCORE 和 ZRANGE 这两类操作。

6. Hash 与 Set​

Hash 保存字段到值的映射,每个字段的值都是字节串。HSET 可以只更新一个字段,不用把整个对象读出来、改完再整体写回,传输量也小。Redis 6 的 Hash 用 ZipList 或 hashtable 编码,Redis 7.0 起 ZipList 由 Listpack 替代。在 Redis 6 和 7.0 里,TTL 只能设在整个 key 上,不能给单个字段设过期时间。

Set 保存不重复的成员,不保证排列顺序。Redis 6 和 7.0 用 intset 或 hashtable 编码:成员都是整数且数量不多时用 intset,否则用 hashtable。具体用哪种,由元素内容、大小和数量共同决定。Redis 7.2 又增加了 listpack 编码。

同一种类型,不同命令的成本可以差很多。SISMEMBER 只判断指定成员是否存在,求交集或全量读取则要处理更多元素。具体成本还取决于内部编码,所以估算复杂度时,既要看命令,也要看数据如何存储。

7. 字典与渐进式 rehash​

Redis 的键空间,以及 hashtable 编码的 Hash 和 Set,底层都是字典。Redis 6、7 的字典用桶数组加链表处理哈希冲突。

元素多了要扩容,元素少了要缩容。如果一次性把所有元素搬到新表,大字典会让主线程停顿很久。渐进式 rehash 把这次搬迁拆成很多小步:

  • 字典准备好第二张哈希表,然后按桶把旧表的元素逐步迁移过去。
  • 迁移期间,查找按需检查两张表,新插入的元素只写入新表,这样旧表只减不增。
  • 全部迁移完成后,释放旧表。

推动迁移的有两股力量:普通的字典查询或更新会顺带推动迁移;启用 activerehashing 时,定时任务也会推进键空间的迁移,这样没人访问的字典也能迁完。整体耗时就被分摊开了,但分配新的桶数组、处理很长的冲突链,仍然要花时间。

注意渐进式 rehash 是字典这一层的机制。String 的编码、ZipList、Listpack 和跳表各有各的结构,跟它无关。

8. 紧凑编码与可观察性​

前面几种类型都有一个紧凑编码(ZipList、Listpack、intset),这是同一个取舍:把多个元素连续存放,省掉指针和多次内存分配,小对象因此省很多内存;代价是某些查找需要顺序扫描,插入可能要移动部分数据。对象一大,这个代价就超过了收益,所以元素数量或大小超过条件时,Redis 会把它转换成适合大对象的编码。

转换对应用透明,类型和命令语义都不变。转换条件取决于版本、配置和实际数据,与其背阈值,不如在练习用的 key 上直接看:

TYPE interview:rank
OBJECT ENCODING interview:rank
MEMORY USAGE interview:rank

三条命令分别查看类型、编码和内存占用估计。删除元素之后会不会转回紧凑编码,要看具体实现。

9. SCAN 游标遍历​

SCAN 用游标分批遍历键空间,每次只做一小部分工作,不会像 KEYS 那样一次扫完整个键空间、长时间占住主线程。第一次调用传游标 0,之后每次传上一次返回的游标,直到返回的游标再次为 0,才表示这一轮遍历完成。

SCAN 0 MATCH interview:* COUNT 100

COUNT 只是每次大概做多少工作的提示,返回的条数可以比它多,也可以比它少。MATCH 是在取出元素之后再过滤,所以可能某一批全被过滤掉,返回空数组,但游标还不是 0,遍历并没有结束。

SCAN 的保证是:从遍历开始到结束一直存在的元素,一定会被返回,但同一个元素可能返回多次;遍历期间新增或删除的元素,返回与否没有保证。所以基于 SCAN 的批处理要么去重,要么让处理动作本身幂等。它适合渐进遍历,不提供数据快照。

10. Stream 与专用操作​

Stream 是按消息 ID 追加记录的日志结构。消费组用 PEL(Pending Entries List,待确认列表)记录已经投递给消费者、但还没确认的消息。XACK 只是把消息从 PEL 中移除,消息本身还在 Stream 里,要靠删除或裁剪清理。消费者故障后,其他消费者可以用 XCLAIM 认领它没确认的消息;Redis 6.2 起还可以用 XAUTOCLAIM 自动扫描并认领。

任务执行成功了、确认却丢了,消息可能被再次投递,所以业务要用幂等标识处理重复。裁剪 Stream 时,也要保留还在等待恢复的消息内容。

还有几种建立在已有类型上的专用结构:

  • Bitmap 是 String 上的位操作,适合编号稠密的场景,比如按用户 ID 记录签到。编号稀疏、偏移量很大时,中间空着的位也要占空间。
  • HyperLogLog 估计去重后的数量,标准误差约为 0.81%。它只保存估计用的状态,不保存成员,需要精确判断某个成员在不在时用 Set。
  • Geo 用于地理位置和按距离范围检索,适合筛选候选,路线规划还需要专门的道路模型。

单条命令的原子性和多条命令之间的协调是两回事,区别见 原子命令与事务。

11 · 面试题卡片​

本页题目与参考回答

11.1 Redis 的 String、Hash 与键空间字典分别是什么?​

考察重点

  • 逻辑类型与内部编码。
  • SDS 与二进制安全。

相关内容:字符串、Hash 与 Set。

参考回答

String 和 Hash 是值的数据类型,String 保存字节串,Hash 保存字段到值的映射;键空间字典是 Redis 按键找到值对象的结构。

String 的字符串编码用 SDS 保存内容,SDS 显式记录长度,所以内容中可以包含零字节,不会被当成字符串结尾。int、embstr、raw 是 String 的内部编码,Hash 在元素少时用紧凑编码、元素多时用 hashtable。按键定位值和值本身怎么存,分属两个层次。

11.2 缓存对象、去重、排行榜和可靠任务分别怎样选类型?​

考察重点

  • 操作需求与数据结构。
  • 队列确认和幂等。

相关内容:数据类型与场景、Stream 与专用操作。

参考回答

整体读写的缓存对象用 String,需要按字段读写用 Hash;精确去重用 Set,按分数排名用 ZSet;可靠任务用「处理中」List 或 Stream 消费组。

选择依据是要对数据做什么操作。可靠任务的关键是取出后不能立刻丢失:List 方案用 BLMOVE 把任务移到处理中列表,Stream 用 PEL 记录未确认消息。两者都需要超时恢复,也都可能重复投递,所以业务要保证幂等。

11.3 Redis 为什么给同一数据类型提供多种编码?​

考察重点

  • 空间与操作成本。
  • 版本和转换条件。

相关内容:列表、紧凑编码与可观察性。

参考回答

小对象用紧凑编码节省指针和内存分配开销,大对象换成分块或哈希结构,降低查找和插入成本。

紧凑编码把元素连续存放,部分操作需要扫描或移动数据;对象较小时,省下的内存开销更有价值。比如 Redis 6 的 QuickList 用双向链表连接多个 ZipList 块,Redis 7.0 起这些块换成 Listpack,避免了 ZipList 的连锁更新。转换条件取决于版本、配置和数据,实际结构用 OBJECT ENCODING 查看。

11.4 ZSet 为什么同时使用字典与跳表,范围查询的成本是多少?​

考察重点

  • 按成员查找与有序访问。
  • 分数、排名与结果数量。

相关内容:有序集合。

参考回答

字典负责按 member 快速查分数,跳表负责按分数和成员的顺序访问,两类操作各用一个结构。

跳表节点记录的跨度还能用来计算排名。跳表导航的期望成本是 O(log N),区间查询返回 M 条结果时再加 O(M)。这是常规 skiplist 编码的情况:Redis 6 的小集合可以用 ZipList,Redis 7.0 起改用 Listpack,访问方式是顺序扫描。

11.5 INCR 与 GET 后 SET 有什么并发差异?​

考察重点

  • 单命令原子性。
  • 多命令与跨键边界。

相关内容:字符串、Stream 与专用操作。

参考回答

INCR 在一条原子命令里完成递增,GET 后 SET 是两条命令,中间有并发覆盖的窗口。

例如两个客户端都读到 1,各自算出 2 再写回,就丢掉了一次增加。更复杂的条件更新,要用对应的原子命令、Lua 脚本,或者 WATCH 加重试。

11.6 List 消费者取出任务后崩溃,怎样减少任务丢失?​

考察重点

  • 弹出与确认。
  • 重投递和业务幂等。

相关内容:列表、Stream 与专用操作。

参考回答

用 BLMOVE 把任务原子地移入「处理中」列表,任务在处理完成前一直有记录可以恢复。

处理成功后再从处理中列表删除,超时未完成的任务重新投递。Stream 消费组用 PEL 和 XCLAIM、XAUTOCLAIM 认领机制管理同样的过程。任务完成但删除或确认丢失时,任务仍会被重复处理,所以业务要保持幂等。

11.7 SCAN 返回空数组时能结束吗,它会重复或漏掉元素吗?​

考察重点:游标终止、COUNT 与并发变更。

相关内容:SCAN 游标遍历。

参考回答

不能,要以返回的游标为 0 判断遍历完成,空数组只说明这一批没有匹配项。

COUNT 是工作量提示,每批返回多少条没有保证。一次完整遍历会返回全程都存在的元素,但可能重复;遍历期间新增或删除的元素是否出现没有保证。

11.8 Redis 字典怎样扩容,渐进式 rehash 期间如何查找和插入?​

考察重点:双表迁移、操作分摊与延迟边界。

相关内容:字典与渐进式 rehash。

参考回答

字典建立第二张表,按桶把旧表元素分多次迁移过去,而不是一次搬完。

迁移期间查找按需检查两张表,新插入的元素只写入新表。普通字典操作会推进迁移,启用 activerehashing 后,定时任务也会推进键空间的迁移,把耗时分摊开。分配新表和处理长冲突链仍有成本。

12. 参考资料​

来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明