简介

依旧一问一答式学习。原本打算学《Redis45讲》的,但是细碎知识点太多,貌似不是很适合面试前突击,因此这次,我会学习xiaolin coding的图解Redis

话不多说,发车!

第一批 3.31

范围:从认识redis-redis的数据结构

一、底层原理篇

1. 关于字符串的“二进制安全”: 你在文章中提到 Redis 的 SDS 相比 C 原生字符串是“二进制安全”的。请结合 Go 语言的 string 类型特性,解释一下为什么 Redis 必须设计 SDS 而不直接用 C 字符串?如果我们要用 Redis 存储一个通过 Go 的 marshal 序列化后的 Protobuf 对象,SDS 的哪些特性保证了数据不会损坏?

2. 数据结构的演进(Redis 7.0 视角): 文章提到 Redis 7.0 彻底废弃了 ziplist(压缩列表),改用 listpack。请问 ziplist 存在什么致命的设计缺陷(提示:连锁更新)?listpack 又是如何通过结构改进来解决这个问题的?

3. 跳表(SkipList)的选择: Zset 在数据量大时会使用跳表。面试官经常会问:为什么 Redis 选择用跳表实现有序集合,而不是像 MySQL 那样使用 B+ 树,或者使用平衡二叉树(如红黑树)?请从内存占用和范围查询效率的角度谈谈你的理解。


二、Go 后端场景篇

4. 消息队列的技术选型: 你在 Go 业务开发中需要实现一个异步处理系统。文章提到了 ListStream 都可以做消息队列。请对比说明:在什么情况下你会坚持使用 Stream 而不是 List?另外,针对 Stream 的“消费组”模式,如何解决 Go 消费者实例宕机导致的消息积压(Pending)问题?

5. 高并发下的原子性保障: 你在使用 go-redis 库操作 Redis。文章提到“对数据类型的操作都是原子性的,因为执行命令是由单线程负责的”。

  • 场景题: 如果你要实现一个“先查询缓存计数,若小于 10 则加 1”的逻辑,直接在 Go 代码里写 Get 然后 Set 会有问题吗?
  • 如果要保证整个操作的原子性,你会优先选择 Redis 的哪种高级特性(文章中提到了几种)来实现?

我的回答:

1.因为C字符串没有专门记录长度的地方,读到'/0'就停,而如果我们把数据的规模多样化,在某种类型的数据中是极有可能出现'/0'的,如果继续使用c语言的字符串,就会误操作将其截断,这也导致redis就没法存多种多样的数据类型。和Go的string一样,SDS有专门的len属性来记录长度,不依赖这个所谓的停止符号,保证了数据存储的多样性。至于SDS的其它特性,例如空间自动扩容,以及它所属的api都是安全的,也保证了数据的安全。
2.由于ziplist为了支持倒序遍历,每个节点都会存储前一个节点的长度,并且还有个非常奇怪的机制:如果长度数值<254字节,这个记录位的长度是1;如果>=254字节,记录位的长度会变成5。而且,记录位的长度是计入这个节点的长度的。这时候,如果前面的某个节点新插入一个值,它的长度又刚好从253变到了254,然后后面这个节点一看前面节点变成了254,自己的标志位也变成了5位的大小,如果好巧不巧导致自己的长度也越过了253,那后面就会循环下去,如此往复,最坏情况下需要O(n^2)的时间复杂度来做这个移动的操作。listpack直接简单粗暴,把长度值存在节点内部,每个节点只管自己的,从根本组织了连锁反应的发生,并且这样也支持倒序遍历,只需要读长度,跳到该节点头,然后继续读前面节点就完事了。
3.MySQL的B+树,由于一块默认16kb,大概率只放一点数据,会在内存中产生很多空洞,内存利用率低;平衡二叉树范围搜索需要中序遍历,而且翻转需要大范围的锁,性能十分不友好。跳表,比较轻量,平均每一个索引节点只存1.33个指针(由25%的增长概率得到),省内存。简单跳个几层索引就能跳到目标位置,而且底层和B+树的叶子节点一样,用双向链表连接,范围查询也是十分方便:跳到目标位置往后简单遍历就行了。
4.当我需要失败重试、消息持久化以及多消费者时,就只能用stream,list还是太不可靠了,只能并发抢消息,而且抢到消息的消费者如果挂了,这个消息就没了。如何解决消息积压?后台开一个救援者协程,跑XAUTOCLAIM,可以原子化探测挤压+过户消息(类似于sysmon把阻塞的M的P给过户给其它M)。更进一步,有的消息可能本身有毒,此时这个救援者协程还需要执行XPENDING查看这个消息被交付的次数,如果大于某个值就直接把它XACK了,然后丢进专门的异常处理队列,等待处理。
5.有,因为Go的协程之间是并行/并发都有的状态,如果把值的加减放到Go的用户态内存中操作,会存在竞态问题。
直接上Lua脚本。

以下是针对你回答的深度解析面试官复盘

  1. SDS 与二进制安全
  • 你的回答: 逻辑非常清晰。抓住了 \0 截断痛点,并类比了 Go 的 string
  • 标准答案补充:

    • 二进制安全:SDS 不以 \0 作为结束标志,而是通过 len 决定读取长度,这使得 Redis 可以存储 Protobuf 序列化后的字节流、图片或音频
    • C 兼容性:补充一点,SDS 虽然有 len,但它在 buf 末尾依然保留了一个 \0。这是为了在不涉及修改的情况下,复用 C 标准库的字符串函数(如 printf),体现了设计的工程权衡。
  • 面试官心法: 以后可以提一句:Go 的 string 是不可变的(Read-only),而 SDS 是可变的。
  1. Ziplist 连锁更新与 Listpack
  • 你的回答: 满分回答。准确说出了 254 字节的分界线和 $O(n^2)$ 的最坏复杂度。
  • 标准答案补充:

    • 缺陷根源ziplistprevious_entry_length 记录的是前一个节点的长度。
    • Listpack 的改进:Listpack 将“当前节点长度”存储在节点末尾。这样从后往前遍历时,只需要读取当前节点的尾部即可获得当前节点的总长度,从而跳到上一个节点。由于节点间不再存储关联长度信息,彻底杜绝了连锁更新。
  • 面试官心法: Redis 7.0 之后,很多老面试题(关于 ziplist 的)其实已经变了,你能跟进最新版本说明你的技术敏感度很高。
  1. 跳表(SkipList)的选择
  • 你的回答: 核心点抓得很准,尤其是内存空洞和范围查询的对比。
  • 标准答案补充:

    • 内存开销:跳表确实比平衡树省内存。跳表通过概率决定层数,额外指针约为 $\frac{1}{1-p}$($p=0.25$ 时约为 1.33 个指针)。
    • 实现简单性:跳表的实现比红黑树简单得多,且在高并发(虽然 Redis 单线程,但在多线程客户端/其他场景下)的锁竞争更小。
    • B+ 树 vs 跳表:B+ 树的设计初衷是减少磁盘 I/O(页级读取)。Redis 是纯内存操作,不需要为了减少树高而采用 B+ 树这种复杂的块结构。
  • 面试官心法: 强调“内存数据库”这个前提,是选择跳表最重要的原因。
  1. 消息队列选型:List vs Stream
  • 你的回答: 非常精彩。尤其是提到的 XAUTOCLAIMXPENDING。将 XAUTOCLAIM 比作 Go 的 sysmon 抢占 P 的过程,这个比喻深得面试官心。
  • 标准答案补充:

    • PEL (Pending Entries List):Stream 内部维护了 PEL,记录了哪些消息被读取但未被 XACK
    • 死信处理:你提到的查看交付次数(Delivery Count)就是处理“毒丸消息”(Poison Pill)的标准做法。
  • 面试官心法: 在 Go 后端面试中,如果你能提到 Consumer Group 的状态恢复,会是巨大的加分项。

5. 原子性保障与 Lua

  • 你的回答: 正确。Go 代码中的 Get+Set 是非原子的,即便有单线程 Redis,网络往返(RTT)之间也会产生空隙。
  • 标准答案补充:

    • 方案一:原子指令。简单的加减可以用 INCRBY
    • 方案二:Lua 脚本。Redis 保证脚本执行期间不会有其他命令插入,是复杂逻辑原子性的首选。
    • 方案三:分布式锁。针对更复杂的业务逻辑。
  • 面试官心法: 记得提一下 Lua 脚本不要写得太复杂,否则会阻塞 Redis 主线程
最后修改:2026 年 03 月 31 日
如果觉得我的文章对你有用,请随意赞赏