Redis 为什么快?7个原因拆解:从内存到IO,从数据结构到设计哲学

作者:没有逆称
关键词:Redis 高性能、IO 多路复用、epoll、SDS、跳表、单线程模型


目录


一、面试被问住了

上一场面试聊到项目缓存选型,面试官冒了一句:

“Redis 号称 QPS 能到 10 万+,你说说它为什么快?”

我脱口而出:“因为它是内存数据库……”

面试官继续盯着我,我硬挤了几个词——"单线程、IO多路复用……"就到头了。

事后复盘发现,Redis 快的理由远不止两三点,面试官期待的是系统性回答,从内存到IO、从数据结构到设计哲学,一层层拆开讲。

下回再被问到,我可以按下面这个思路答了——


先给面试官一个总览:

Redis 高性能 = 
    内存操作 + 单线程 + IO多路复用 + 高效数据结构 + 内存优化 + 渐进式rehash + 事件驱动

   ┌─────────────────────────────────────┐
   │         各因素影响权重                │
   ├─────────────────────────────────────┤
   │ 纯内存操作           ★★★★★ 最重要    │
   │ 高效数据结构         ★★★★☆          │
   │ IO多路复用           ★★★★☆          │
   │ 单线程模型           ★★★☆☆ 有争议    │
   │ 渐进式rehash         ★★★☆☆          │
   │ 对象共享与编码优化    ★★★☆☆          │
   │ 事件驱动架构         ★★☆☆☆          │
   └─────────────────────────────────────┘

下面逐条展开。


二、原因一:纯内存操作

这是最根本的原因,没有之一。

2.1 内存 vs 磁盘 速度对比

一个简单的数量级对比:

存储层级 典型延迟 相比内存的倍数
CPU 寄存器 ~1 ns
内存(RAM) ~100 ns 1x(基准)
SSD(随机读) ~100 μs 1000x
磁盘(机械寻道) ~10 ms 100,000x
网络请求(同机房) ~500 μs 5000x

Redis 所有的数据读写都在内存中完成,不需要磁盘 IO。

2.2 对比 MySQL

MySQL 查询路径:
    查询 → 解析SQL → 执行计划 → 存储引擎
        ↓
    检查 Buffer Pool(内存中是否有缓存页)
        ↓
    没有 → 磁盘 IO 加载数据页(慢!)
        ↓
    返回结果

Redis 查询路径:
    命令 → 解析 → 直接在内存哈希表查
        ↓
    返回结果  (快!)

🔑 一句话:内存访问速度比磁盘快 3~5 个数量级,这是 Redis 快的先天优势。


三、原因二:单线程模型(核心争议点)

3.1 Redis 的核心处理确实是单线程

Redis 6.0 之前——所有网络 I/O 和命令执行全部单线程。
Redis 6.0 之后——网络 I/O 引入多线程命令执行依然是单线程

很多人疑惑:单线程不是更慢吗?怎么反而成了高性能的原因?

3.2 单线程为什么快?

原因 1:避免了上下文切换的开销

多线程环境下,CPU 需要在不同线程之间切换,每次切换都要保存和恢复寄存器、程序计数器等,带来几十到几百纳秒的额外开销。

多线程模型(一般应用):
    
    线程A ———→ 切换 → 线程B ———→ 切换 → 线程A
                ↑                           ↑
             保存/恢复                    保存/恢复
             上下文                       上下文
             
单线程模型(Redis):
    
    线程A —→ 一直跑 —→ 完事
    没有切换,没有开销

原因 2:避免了锁竞争

多线程共享数据需要加锁,加锁本身有开销,锁冲突更会导致线程阻塞等待。

Redis 单线程执行命令,天然不需要锁:

你们同时读 Key A?排好队,一个一个来,不存在谁等谁的情况。

3.3 单线程的瓶颈在哪?怎么解决?

瓶颈点 后果 解决方案
单个 key 存储超大数据(big key) 阻塞其他命令 拆分大 key、用 scan 分批
CPU 密集型操作(如 hgetall 千万级哈希) 长时间独占 CPU 优化数据结构、使用 unlink 异步删除
单核性能上限 单个实例吞吐瓶颈 Redis Cluster 分片扩容

3.4 那为什么 6.0 又引入了多线程?

6.0 的多线程只用于网络 I/O 读写,不用于命令执行:

Redis 6.0 多线程模型:

    主线程(单线程)——执行命令
        ↕
    I/O 线程池(多线程)——读写 socket 数据

改进的原因是:网络 I/O 读写在大流量下占用 CPU 时间可观,用多线程分摊后,主线程可以更多地处理命令。


四、原因三:IO 多路复用(epoll)

4.1 什么是 IO 多路复用?

先看一个场景:

你是 Redis 服务器,有 10000 个客户端连接着你。每个客户端随时可能发命令过来,你该怎么知道谁发了消息?

方案一:BIO(阻塞 IO)——每个连接一个线程

// 伪代码:每个客户端一个线程
while (true) {
    Socket clientSocket = serverSocket.accept();
    new Thread(() -> {
        while (true) {
            byte[] data = clientSocket.read(); // 阻塞!没有数据就等
            // 处理命令
        }
    }).start();
}

→ 1 万连接 = 1 万个线程 = 内存爆炸 + 上下文切换灾难

方案二:NIO + epoll 多路复用(Redis 的做法)

// 伪代码:一个线程管理所有连接
int epollFd = epoll_create(10000);

while (true) {
    int n = epoll_wait(epollFd, events, 10000, -1); // 谁有数据就返回谁
    for (int i = 0; i < n; i++) {
        // 只处理有数据可读的 socket,效率极高
        processCommand(events[i].fd);
    }
}

4.2 epoll 为什么比 select/poll 快?

机制 时间复杂度 最大连接数 工作方式
select O(n) 1024 轮询所有 fd
poll O(n) 无限制 轮询所有 fd
epoll O(1) 无限制 回调通知,只返回有事件的 fd

epoll 的核心优势——它不轮询,只通知。

你问 1 万个学生:“谁有问题?”(select/poll:遍历一遍)
vs
有问题的人举手,你只看到举手的人(epoll:回调通知)

4.3 Redis 中的实现

┌─────────────────────────────────────────┐
│            Redis 事件循环                 │
│                                           │
│  ┌─────────────┐    ┌───────────────┐    │
│  │ 文件事件     │    │  时间事件      │    │
│  │(Socket可读)│    │(定时任务)    │    │
│  └──────┬──────┘    └──────┬────────┘    │
│         │                  │              │
│         ▼                  ▼              │
│  ┌──────────────────────────────┐        │
│  │      aeApiPoll(epoll_wait)   │        │
│  └──────────────┬───────────────┘        │
│                 │                         │
│                 ▼                         │
│     处理并返回结果给客户端                 │
└─────────────────────────────────────────┘

Redis 内部封装了 ae 事件驱动库,在 Linux 上使用 epoll,在 macOS 上用 kqueue,用统一接口隐藏差异。


五、原因四:高效的数据结构

Redis 不只存字符串,它的五种基本类型(String / Hash / List / Set / ZSet)底层都用了一套精心设计的数据结构,针对不同场景做了不同的编码优化。

5.1 底层数据结构一览

      ┌──────────┐
      │   SDS    │ ← 代替 C 字符串的"智能字符串"
      ├──────────┤
      │  双向链表 │ ← List 的底层之一
      ├──────────┤
      │  压缩列表 │ ← 少量元素时的"紧凑存储"
      ├──────────┤
      │  哈希表   │ ← Hash / Set 的底层
      ├──────────┤
      │  跳表     │ ← ZSet 的核心结构
      ├──────────┤
      │ 整数集合   │ ← 纯整数时的 Set 优化
      ├──────────┤
      │  快速列表  │ ← 压缩列表 + 双向链表的混合体
      └──────────┘

5.2 SDS(Simple Dynamic String)

C 语言的字符串用 char[],问题很多。Redis 自己实现了 SDS:

// Redis 3.2+ 的 SDS 结构(sdshdr8)
struct sdshdr8 {
    uint8_t len;      // 已用长度
    uint8_t alloc;    // 总分配长度
    unsigned char flags; // 类型标记
    char buf[];       // 数据
};

SDS 比 C 字符串好在哪?

对比项 C 字符串 SDS
获取长度 O(n),遍历直到 \0 O(1),直接读 len
缓冲区溢出 容易,不检查边界 自动扩容,预分配空间
二进制安全 \0 会截断数据 ✅ 以 len 为准,存二进制
修改时重新分配 每次都要 空间预分配 + 惰性释放,减少 realloc

5.3 跳表(Skip List)

ZSet 的有序数据结构使用的是跳表(skip list),不是平衡树。

跳表的原理:

Level 3:  1 ─────────────────────────→ 9 ─────────→ null
Level 2:  1 ─────────→ 5 ─────────→ 9 ─────────→ null
Level 1:  1 ─→ 3 ─→ 5 ─→ 7 ─→ 9 ─→ 11 ─→ 13 ─→ null
           ↑
         查找 7:从顶层开始
                1 < 7 → 向右
                9 > 7 → 降一层
                5 < 7 → 向右
                9 > 7 → 降一层
                7 == 7 ✓ (O(logN))

为什么用跳表不用平衡树?

对比项 红黑树/平衡树 跳表
实现复杂度 复杂(旋转、染色) 简单(随意增删层数)
范围查询 中序遍历较复杂 简单,底层链表直接遍历
内存占用 每个节点 2 个指针 平均每个节点 1.33 个指针(更低)
插入/删除 O(logN) O(logN)

5.4 压缩列表(ziplist)

当 Hash / List / ZSet 的元素较少时,Redis 用压缩列表代替哈希表/跳表,连指针都不存,整个结构就是一块连续内存:

压缩列表内存布局:

[zlbytes] [zltail] [zllen] [entry1] [entry2] ... [entryN] [zlend]
  4字节    4字节    2字节     变长      变长              1字节

每个 entry 用变长编码存储前一个 entry 的长度和自身数据,不存指针,纯挨着放

🔑 面试重点:你能说出压缩列表比哈希表好在哪?—— 节省内存,适合小数据量。


六、原因五:对象共享与内存优化

6.1 整数对象共享池

Redis 启动时会预创建 0~9999 这 1 万个整数对象,后续所有用到这些整数值的地方,直接复用:

127.0.0.1:6379> SET k1 100
OK
127.0.0.1:6379> SET k2 100
OK
# k1 和 k2 的值指向同一个共享整数对象,不重复分配内存!

6.2 不同编码自动转换

Redis 会根据元素的数量和大小,自动选择最省内存的编码方式

Hash/List/ZSet 编码自动升级路径:

    少量元素 ────→ 压缩列表(ziplist)
        │                    │
        │  元素增多或        │
        │  元素变大          │
        ▼                    ▼
    大量元素 ────→ 哈希表 / 跳表 / 快速列表

      Set 编码自动升级:

    纯整数 ──→ 整数集合(intset)
        │
        │  添加非整数
        ▼
    任意值 ──→ 哈希表

示例:你往一个 ZSet 里只塞了 10 个分数,Redis 不会直接用跳表,而是用压缩列表省内存。


七、原因六:渐进式 rehash

7.1 问题:哈希表要扩容怎么办?

哈希表元素越来越多,负载因子提高,必须扩容

通常的做法:

旧表(4个桶)  →  新表(8个桶)

1. 分配新表
2. 把旧表所有元素 rehash 到新表  ← 这一步如果元素很多,会卡住!
3. 释放旧表

如果 Redis 在 rehash 时停下所有请求,几百万 key 全部重新计算哈希位置——那快就不成立了。

7.2 Redis 的做法:渐进式

不一次性搬完,每次搬一点点:

┌─────────────────────────────────────────┐
│         渐进式 rehash 示意图               │
│                                           │
│  rehashidx = 0                            │
│                                           │
│  旧表[0]  ──→  新表[0]  (本次搬)        │
│  旧表[1]  ──→  新表[1]  (下次搬)        │
│  旧表[2]          ...                     │
│  旧表[3]          ...                     │
│  旧表[4]          ...                     │
│  旧表[5]          ...                     │
│  旧表[6]          ...                     │
│  旧表[7]          ...                     │
│                                           │
│  每次增删改查操作,顺带搬一个桶            │
│  搬完一个 rehashidx++                     │
│  搬完所有 → rehashidx = -1(完成)        │
└─────────────────────────────────────────┘

关键点:

  • 不是专门起个线程搬,而是每次处理客户端命令时顺带搬
  • 搬的同时不影响读写——查一个 key,先查新表,查不到再查旧表
  • 新 key 只往新表加,旧表的 key 只会越来越少

🔑 一句话:渐进式 rehash 把"一次性阻塞"变成了"平滑过渡",保证了 Redis 在高负载下的响应速度。


八、原因七:合理的事件驱动架构

8.1 Redis 的完整执行流程

┌─────────┐      ┌───────────────┐      ┌──────────┐
│ 客户端 1 │ ──→ │               │      │          │
└─────────┘      │  IO 多路复用   │      │ 命令执行  │
┌─────────┐      │  (epoll_wait)  │ ──→ │ (单线程)  │
│ 客户端 2 │ ──→ │               │      │          │
└─────────┘      └───────────────┘      └──────────┘
┌─────────┐                                      │
│ 客户端 N │                                      │
└─────────┘                                      ▼
                                          ┌──────────────┐
                                          │  三种结果类型  │
                                          │              │
                                          │ 简单字符串  → │
                                          │ 数组        → │
                                          │ 整数        → │
                                          └──────────────┘

8.2 Redis 的处理流程有多轻量

对比 Spring Boot Web 请求处理:

Spring Boot 处理一次请求:
    创建线程(或从线程池取)→ 解析HTTP → 调用Controller
    → Service → DAO → 数据库查询 → 序列化JSON → 写回
    (整个链路层层抽象,框架开销大)

Redis 处理一次命令:
    epoll_wait 返回 → 读取命令 → 查找 key → 执行操作 → 写回结果
    (几乎没有框架抽象层,纯 C 手写,极其轻量)

九、完整总结

9.1 一图流

            ┌──────────────────────────────┐
            │      Redis 为什么快           │
            └──────────────────────────────┘
                         │
         ┌───────────────┼───────────────────┐
         │               │                    │
    ┌────┴────┐    ┌────┴────┐        ┌──────┴──────┐
    │ 内存层  │    │ IO 层   │        │  数据结构层  │
    └────┬────┘    └────┬────┘        └──────┬──────┘
         │              │                    │
   ┌─────┴─────┐  ┌────┴─────┐       ┌──────┴──────┐
   │ 纯内存操作 │  │ IO多路   │       │ SDS / 跳表   │
   │           │  │ 复用epoll│       │ 压缩列表     │
   └───────────┘  └──────────┘       └─────────────┘

    ┌───────┐    ┌──────────────┐    ┌─────────────┐
    │ 模型层 │    │  优化层      │    │   架构层     │
    └───┬───┘    └──────┬───────┘    └──────┬──────┘
        │               │                    │
   ┌────┴─────┐   ┌────┴──────┐      ┌──────┴──────┐
   │ 单线程    │   │ 整数共享   │      │ 渐进式     │
   │ 无锁设计  │   │ 编码切换   │      │ rehash     │
   └──────────┘   └───────────┘      └─────────────┘

9.2 速记表

原因 一句话总结 面试优先级
纯内存操作 比磁盘快 3~5 个数量级 ⭐⭐⭐⭐⭐
单线程模型 无上下文切换、无锁竞争 ⭐⭐⭐⭐
IO 多路复用 epoll O(1) 事件通知,不轮询 ⭐⭐⭐⭐⭐
SDS / 跳表 / 压缩列表 数据结构为性能极致优化 ⭐⭐⭐⭐
整数共享 + 编码切换 减少内存分配、自动选择最优编码 ⭐⭐⭐
渐进式 rehash 扩容不阻塞,平滑过渡 ⭐⭐⭐⭐
轻量事件驱动 C 语言实现,几乎没有框架抽象开销 ⭐⭐

十、常见面试题

Q1:Redis 单线程的优缺点?6.0 为什么引入多线程?

答:

优点:

  • 避免了线程上下文切换的开销
  • 避免了锁竞争
  • 实现简单,不容易出错

缺点:

  • CPU 密集型操作会阻塞所有请求
  • 单核能力有上限,只能靠集群扩容

6.0 多线程: 仅在网络 I/O 读写阶段使用多线程,命令执行依然是单线程。原因是大流量下网络 I/O 本身就是瓶颈,用多线程分摊后主线程 CPU 更宽裕。


Q2:Redis 的 IO 多路复用具体是怎么实现的?

答: Redis 封装了 ae 事件驱动库,底层在 Linux 上用 epoll,macOS 上用 kqueue。工作机制是注册 socket 到 epoll,然后 epoll_wait 阻塞等待事件,有事件到达时只处理有数据可读的 socket,时间复杂度 O(1)。


Q3:Redis 的哈希表是怎么扩容的?会不会阻塞?

答: Redis 采用渐进式 rehash。当负载因子超过阈值时,分配新哈希表,但不一次性搬完。每次处理客户端命令时顺带搬一个桶,直到全搬完。rehash 期间查 key 先查新表再查旧表,新增 key 只加新表,不影响读写。


Q4:SDS 比 C 字符串好在哪里?

答:

  1. O(1) 获取长度——C 字符串需要遍历到 \0
  2. 二进制安全——以 len 为长度,存二进制数据没问题
  3. 自动扩容——不需要手动管理内存
  4. 空间预分配——减少内存 realloc 次数

Q5:ZSet 为什么用跳表不用红黑树?

答:

  1. 实现简单——跳表代码比红黑树简单得多,不容易出 bug
  2. 范围查询方便——底层是链表,直接遍历即可;红黑树中序遍历较复杂
  3. 内存更低——跳表平均每个节点约 1.33 个指针,红黑树需要 2 个(左右子节点)+ 1 个父节点 = 3 个
  4. O(logN) 性能不输红黑树

Q6:Redis 处理 10 万 QPS,瓶颈到底在哪里?

答: 瓶颈通常不在 CPU,而是:

  1. 网络带宽——尤其大 key 传输时
  2. 内存带宽——huge key 操作频繁时
  3. big key 阻塞——单个 key 几十 MB,一次操作阻塞其他请求
  4. fork 耗时——RDB 持久化时的 fork 操作在内存大时会阻塞

Q7:如果 Redis 是单线程,那持久化(RDB/AOF)不会阻塞吗?

答: 核心命令执行是单线程,但 Redis 用子进程(fork)处理持久化:

  • RDB——fork 出子进程写快照,主线程继续服务
  • AOF 重写——也是 fork 子进程处理
  • fork 本身会阻塞主线程(复制页表),大内存(10G+)时 fork 耗时明显

Q8:和 Memcached 比,为什么 Redis 有时更快?

答:

  • Redis 有更丰富的数据结构(跳表、压缩列表等),在某些场景下比 Memcached 的纯 KV 更高效
  • Redis 单线程无锁,Memcached 多线程需要锁竞争
  • Redis 渐进式 rehash 保证响应平稳,Memcached 扩容可能抖动
  • 但纯 KV 简单场景(比如只缓存 HTML 片段),Memcached 多线程可能通过并行获得更高吞吐

如果这篇文章对你有帮助,点个赞吧!


参考资料:

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐