Redis 不是死脑筋,它是一套“会进化”的存储系统

0 阅读9分钟

很多人背熟了 Redis 的八股文,却从没想过:为什么它要设计这么多底层结构?
答案只有四个字:因地制宜。
数据少的时候,它拼命“省内存”;数据多的时候,它拼命“保速度”。
理解了这个逻辑,你就能把 Redis 所有的核心设计串成一条线。

第一站:基础数据类型,Redis 的“五官”

Redis 为什么快?因为它不把数据当字节看,而是当数据结构看。这五种基础类型,就像你的五官,各司其职:

类型生活类比一句话场景
String(字符串)便签纸存验证码、做计数器
List(列表)排队通道消息队列、最新动态
Hash(哈希)微型档案柜存用户信息、购物车
Set(集合)去重背包抽奖去重、共同好友
ZSET(有序集合)竞技排行榜积分排名、延迟队列

主线伏笔:  这五种类型里,ZSET 是最复杂的,因为它既要去重(像 Set),又要排序(像 List)。为了同时满足这两点,Redis 在底层搞出了“两套班子”,也是我们今天的重头戏。

第二站:ZSET 的进化——“人少坐板凳,人多建高架”

如果你来实现 ZSET,你面临一个矛盾:用数组排序快(但插入慢),用链表插入快(但查找慢)。

Redis 的解决方案极其聪明:根据人数多少,动态切换战术。

deepseek_mermaid_20260906_09dee4.png

1. 人少的时候(元素 < 128 且文本较短):启用“大通铺”模式(listpack)

大家紧挨着坐在一条长板凳上(连续内存),按分数从小到大排好。虽然新增一个人需要挪动后面的人(线性查找),但反正人少,挪动的代价极低。关键是这张“长板凳”极度节省内存,没有任何指针开销。

物理结构示意:

+--------+--------+--------+--------+--------+
| 总分數 | 元素数 | "小明" |  "小红" |  END  |
|        |        |  +95  |  +92    |       |
+--------+--------+--------+--------+--------+
     每个小块只记录自己的长度,互不影响

追问:为什么现在的 Redis 用 listpack,而不用以前的 ziplist?
因为 ziplist 像“多米诺骨牌”,中间插一个人,后面的骨牌全得动(级联更新)。listpack 改成了 “各自独立” ,改自己不影响邻居,彻底消除了这个隐患。

2. 人多的时候(超过阈值):立刻升级为“立交桥 + GPS”模式(跳表 + 字典)

一旦变成大几千人的排行榜,再用“大通铺”就会卡死。Redis 立刻拆成两套系统协作:

deepseek_mermaid_20260906_5fe3f2.png

  • GPS 导航(字典 dict) :专门用来查“张三多少分?”—— O(1) 瞬间定位
  • 立体高架桥(跳表 skiplist) :专门用来查“分数前 10 名是谁?”—— 多层索引极速遍历

第三站:跳表的秘密——抽奖抽出来的“高速电梯”

跳表是 ZSET 在大数据量下的灵魂。它到底是什么?

你可以把跳表想象成一条普通的地铁线,但随机给一部分站点加装了“跨站直达电梯”

text

L3 (特快)  Head ----------------------------------------> [100] ----------------------------------------> NULL
L2 (快线)  Head ----------------> [50] -----------------> [100] ----------------------------------------> NULL
L1 (普快)  Head -------> [20] --> [50] --------> [80] --> [100] ----------------------------------------> NULL
L0 (底站)  Head -> [5] -> [20] -> [35] -> [50] -> [65] -> [80] -> [90] -> [100] ---------------------> NULL
  • 底层:所有站点(元素)串成一条线。
  • 上层:随机抽 50% 的站点,建一条快线。
  • 更上层:再在快线里抽 50%,建一条特快线。

查找“80”的过程:L3 直达 100(过头了)→ 下到 L2 到 50 → 右到 100(过头)→ 下到 L1 到 80,命中。只跳了 3 步!

这种“随机抽奖”的方式,让查找效率直接飙升到 O(logN) (百万级数据只需几次跳跃)。

为什么不用红黑树(平衡二叉树)?
因为红黑树虽然查找快,但做范围查询(比如取前 10 名)很麻烦,需要中序遍历。而跳表的底层是链表,取出一段数据就像切豆腐一样顺滑,而且代码实现比红黑树简单十倍,不容易出 Bug。

第四站:哈希表扩容——不砸墙的“蚂蚁搬家”

Redis 的全局大仓库(字典)如果塞满了,需要扩容换个大房子。

粗暴的思维:把旧房子东西全搬到新房子,再开门营业。

结果:Redis 卡死几秒,线上报警。
Redis 的思维渐进式 Rehash(蚂蚁搬家)

deepseek_mermaid_20260906_e92a61.png

  1. 先把新房子(扩容后的空间)准备好。
  2. 不着急搬。每次你来找数据(执行 get/set 命令)时,Redis 顺手搬一个箱子过去。
  3. 半夜没人访问时,定时任务再偷偷主动搬几个箱子。

搬家期间的规则(面试加分项):

  • 查数据:先翻旧房子,翻不到再去翻新房子。
  • 写数据:只写新房子(保证旧房子只减少不增加,最终变成空房被回收)。

这样搬一整个仓库,用户完全感知不到卡顿,把耗时的“大手术”切碎成毫秒级的“小动作”

第五站:线程模型——彻底讲透“几个线程”和“谁干活”

网上关于 Redis 线程的讨论经常吵成一锅粥:有人说单线程,有人说多线程。都对,也都不全对。  要回答清楚,得把 Redis 的线程分成三支队伍来看。

先破一个最常见的误解

很多人说“Redis 6.0 变成了多线程”,这句话对了一半,错了一半

✅ 对的部分:Redis 6.0 确实引入了多线程,用来处理网络 IO。

❌ 错的部分:Redis 执行命令的核心逻辑,永远是单线程,从来没变过。

一句话定论:多线程只干“搬砖”的活(收数据、发数据),核心的“脑力活”(操作内存数据)依然是老板一个人干。

三支线程队伍,各司其职

deepseek_mermaid_20260906_224243.png

第一队:主线程(1个) ——Redis 的“大脑”,唯一有资格操作内存数据的线程。所有命令(GET/SET/ZADD 等)都在这里顺序执行,永远不加锁,所以极快

第二队:后台 BIO 线程(3个,固定) ——专门处理“虽然耗时但不能阻塞主线程”的脏活累活:

  • BIO 线程1:关闭文件和回收 AOF/RDB 产生的文件描述符。
  • BIO 线程2:AOF 持久化的 fsync 刷盘操作(磁盘 IO 很慢,必须交给后台)。
  • BIO 线程3:Redis 4.0 引入,负责 UNLINK 异步删除大 Key(如果是 DEL 删一个几十 MB 的 Hash,主线程会卡住,UNLINK 把删除动作扔给后台慢慢干)。

第三队:IO 线程(数量由配置决定) ——Redis 6.0 引入,专门负责网络数据的收发,绝不触碰 Redis 内存数据

  • 只负责 read() 把请求从 Socket 读进来,和 write() 把结果写回 Socket。
  • 数量由 io-threads 配置项决定。设置 io-threads N,实际会创建 N-1 个 IO 线程(主线程自己也会参与 IO 工作)。
  • 默认值io-threads 4,所以默认创建 3 个 IO 线程。

Redis 默认到底有几个线程?(精确答案)

以最常见的 Redis 6.0+ 默认配置(io-threads 4)为例:

线程类型数量说明
主线程1 个固定不变,执行所有命令
后台 BIO 线程3 个固定不变(Redis 4.0+)
IO 线程io-threads - 1 = 3 个默认配置下
线程总数7 个1 + 3 + 3 = 7

注意:如果把 io-threads 设为 1,则 IO 线程数为 0,总线程数回到 4 个(1 主 + 3 BIO),等价于 Redis 6.0 之前的纯单线程模式。

IO 线程数怎么设置才合理?

io-threads 并非越大越好,设置不当反而因上下文切换拖慢性能:

  • 官方铁律:线程数必须小于 CPU 核心数。

  • 推荐值

    • 4 核 CPU:设为 2 或 3
    • 8 核 CPU:设为 6
    • 不建议超过 8,再多反而因线程切换开销得不偿失。
  • 默认只负责写回:默认 IO 线程只负责 write() 写回响应。如果想让 IO 线程也负责 read() 读取请求,需额外开启 io-threads-do-reads yes(通常不建议开,读取阶段涉及解析协议,交给多线程容易出问题)。

一个请求的完整生命周期(厘清分工)

deepseek_mermaid_20260906_63e89f.png

注意:上图为 Redis 6.0+ 默认配置下的线程分工。三个阶段分工明确:

  • 阶段一(读) :主线程单线程执行 read(),IO 线程不参与
  • 阶段二(执行命令)只有主线程,串行操作内存,不加锁。
  • 阶段三(写) :IO 线程并行执行 write() 写回响应(这是默认开启的多线程行为)。

如果设置 io-threads-do-reads yes,阶段一也会变为 IO 线程并行读取,但官方不推荐且极少在生产环境开启。

三个阶段的角色分工

  • 阶段一(读) :IO 线程并行把请求读进来,主线程等着。
  • 阶段二(执行)只有主线程在干活,串行执行所有命令,不加锁。
  • 阶段三(写) :IO 线程并行把结果写回去,主线程等着。

面试时怎么回答“Redis 线程模型”?

递进式回答模板(背下来直接说):

“Redis 的线程模型要分三层看:

第一层(核心) :执行 Redis 命令的主线程永远是单线程的,所有读写内存的操作都在这一个线程里串行执行,所以 Redis 的所有命令都是天然原子性的,不需要加锁,这也是 Redis 为什么能做到微秒级延迟的根本原因。

第二层(后台) :Redis 有 3 个固定的后台 BIO 线程,专门干耗时但不紧急的脏活——关闭文件、AOF 刷盘、异步删除大 Key。

第三层(网络 IO) :Redis 6.0 引入了可配置的 IO 线程(默认 3 个),只负责 Socket 的读写,不碰内存数据。这样就把网络收发的耗时代价摊到了多个 CPU 核心上,解决了高并发下网络吞吐的瓶颈。

按默认配置,一个 Redis 实例总共有 7 个线程(1 主 + 3 BIO + 3 IO)。”

终章:整条主线的逻辑闭环

回顾一下这条主线,你会发现 Redis 的所有设计都遵循同一个法则:

场景战术目标
数据少时紧凑存储(listpack)省钱(省内存)
数据多时跳表 + 字典保快(O(logN) 查询)
扩容时蚂蚁搬家(渐进式 Rehash)求稳(不阻塞服务)
处理请求时核心单线程 + IO 多线程分清主次(不加锁 + 榨干带宽)

面试官问“Redis 为什么快”?
不要再只背“基于内存”了。正确的答案是:因为它是一套“活的”系统,面对不同的数据量和场景,它会动态选择最合适的底层数据结构,永远在内存占用和访问速度之间寻找当前最优解。线程模型上,核心命令执行永不妥协——永远是单线程不加锁;网络 IO 则因地制宜,用多线程榨干多核 CPU 的带宽。