Redis 为什么这么快?单线程只是答案的一部分

Redis 的高性能不是“单线程”三个字就能解释。本文从内存访问、紧凑数据结构、串行命令执行和 I/O 多路复用四层机制出发,讲清每一层解决的问题,并补充 Redis 6 与 Redis 8 的 I/O 线程演进、慢命令风险和一段可直接复述的面试回答。

Redis 为什么这么快?单线程只是答案的一部分

面试官问:“Redis 为什么这么快?”

很多人的第一反应是:“因为 Redis 是单线程,没有线程切换和锁竞争。”

这句话抓住了一个特点,却没有解释完整。单线程本身并不会自动带来高性能:如果一次命令要执行很久,或者线程一直阻塞在某个网络连接上,后面的请求照样会排队。

更完整的答案是:Redis 把主要数据访问放在内存中,用经过优化的编码和数据结构缩短单次命令的执行时间,以串行的核心命令执行路径降低并发控制成本,再借助非阻塞 I/O 与 I/O 多路复用高效管理大量连接。四层机制共同作用,才形成了我们感受到的“快”。

本文讨论 Redis Open Source 的典型工作路径。Redis 的线程与数据结构实现会随版本演进;具体部署的吞吐和延迟还会受到命令类型、数据规模、网络、持久化配置、硬件和客户端使用方式影响。

先给面试官一个简版答案

Redis 快,首先因为热路径主要访问内存,避开了按请求随机读取磁盘的高成本;其次,它会为不同数据类型选择合适的内部编码和数据结构,让常见操作尽量短小;再次,核心命令通常串行执行,减少了共享数据上的锁竞争和线程切换,也让执行语义更容易保持确定;最后,Redis 使用非阻塞 I/O 和 I/O 多路复用,让一个事件循环可以管理大量连接,只处理已经就绪的事件。

需要补充的是,Redis 并非所有工作都只使用一个线程。后台持久化、异步释放以及网络 I/O 等工作可能使用其他进程或线程。Redis 6 引入了可选的 threaded I/O,Redis 8 又重做了这套实现;但客户端命令准备完成后,仍会进入主线程执行。因此更严谨的说法是:Redis 采用 mostly single-threaded 的核心命令执行模型,而不是“整个 Redis 只有一个线程”。

四层机制分别解决什么问题

第一层:以内存为主,缩短数据访问路径

Redis 的主要数据集保存在内存中。对常见读写命令来说,访问目标通常不需要像磁盘型数据库那样,先把不在缓存中的数据页从持久化设备读入内存。内存访问延迟显著低于随机磁盘 I/O,这是 Redis 高性能最基础的一层。

但“Redis 在内存里”不能被理解成“Redis 完全不访问磁盘”。Redis 可以通过 RDB 快照、AOF(Append Only File)或二者组合实现持久化;后台生成快照、AOF 写入与重写、操作系统换页都可能影响延迟。官方延迟文档也特别提醒:一旦 Redis 的内存页被换出到 swap,再次访问就会触发慢得多的磁盘 I/O。

所以更准确的表达是:Redis 把服务请求的主要数据访问路径放在内存中,同时把持久化作为另一条需要单独权衡的路径。

第二层:数据结构与编码,尽量让单次操作短小

Redis 不只是“在内存里放一个大哈希表”。它对 String、List、Hash、Set、Sorted Set 等类型设计了不同的内部表示,并会结合元素数量、元素大小和配置选择更紧凑或更适合操作的数据编码。

这里最容易出现版本过时的说法。

  • 当前 Redis 的 List 主要由 quicklist 实现。源码把 quicklist 描述为“由 listpack 组成的双向链表”:外层便于从两端和节点间移动,节点内部用紧凑的连续编码存放多个元素。它不是简单地“小时用连续数组,大时切换成普通链表”。
  • Sorted Set 在规模较小时可以使用紧凑的 listpack 编码;达到相关阈值后,典型实现会使用哈希表与 skiplist(跳表)。哈希表适合按成员查分值,跳表适合按分值有序遍历与范围查询。
  • Hash 等聚合类型也有紧凑编码与哈希表等不同表示。具体转换阈值是配置项和版本细节,面试时没有必要死背一个可能已经变化的数字。

这些设计不只是为了“理论复杂度漂亮”。紧凑编码可以减少指针和对象头等额外内存,也有机会改善 CPU cache locality;而适合操作语义的数据结构,则让查询、插入、删除和范围访问保持合理复杂度。

List 与 Sorted Set 的典型内部结构

第三层:串行执行核心命令,降低协调成本

Redis 官方把自己的设计称为 mostly single threaded:核心客户端请求通常按顺序执行,同一时刻不会让多个工作线程同时修改同一份键空间数据。

这带来三个直接收益:

  1. 核心数据操作不必普遍围绕共享状态加锁,减少锁竞争与同步开销。
  2. 不需要在多个命令执行线程之间频繁切换,调度路径更直接。
  3. 命令按顺序执行,原子性语义和内部状态管理更容易保持清晰。

但要把因果关系说对:单线程的价值是降低协调复杂度,不是“线程越少天然越快”。 如果执行了时间复杂度高、数据量很大或包含阻塞行为的命令,主线程会被占住,排在后面的请求都会增加延迟。

这也是为什么生产环境要关注 SLOWLOG、命令复杂度、大 Key,以及 KEYS、超大范围查询、大集合删除等可能占用主线程较久的操作。对于大对象删除,Redis 还提供 UNLINK 等方式,把内存回收工作放到后台线程处理。

第四层:I/O 多路复用,让一个事件循环管理大量连接

如果 Redis 每接入一个客户端就让主线程阻塞等待数据,那么单线程当然无法服务大量连接。它真正采用的是:非阻塞 socket + I/O 多路复用 + 事件循环。

连接建立后,socket 被设置为非阻塞状态,Redis 注册可读、可写等文件事件。事件循环调用操作系统提供的多路复用机制等待;在 Linux 上通常是 epoll,其他平台还可能使用 kqueueevent portsselect。只有连接真正就绪时,事件循环才调用对应处理函数。

可以把它理解为:Redis 不会挨个问一万个连接“你有数据吗”,也不会停在某个没有数据的连接上等待;操作系统先返回当前已经就绪的连接集合,Redis 再处理这些事件。

I/O 多路复用的事件循环

一条请求的典型主线可以简化为:

  1. 多路复用 API 返回就绪的 socket。
  2. Redis 读取数据并解析协议。
  3. 准备好的命令进入核心命令执行路径。
  4. 结果写入客户端输出缓冲区,并在 socket 可写时发送。
  5. 事件循环继续处理下一批就绪事件。

I/O 多路复用解决的是如何高效管理大量连接,单线程串行执行解决的是如何低成本地操作共享数据。两者不能混为一谈,也缺一不可。

Redis 6 之后,还是“单线程”吗?

最稳妥的回答是:“要看你说的是哪一部分。”

Redis 很早就会使用后台线程或子进程处理部分慢任务。Redis 6 又加入可选 I/O 线程,帮助处理客户端 socket 的读写;当前配置说明还包括读取、协议解析和写出等工作。Redis 8 的发布说明显示,其 I/O threading 实现再次重构,并且开启 io-threads 后,读写都会使用 I/O 线程。

不过在 Redis 8.0 源码中,I/O 线程解析出完整命令后,会把带有待执行命令的客户端放回主线程队列,命令最终由主线程调用 processCommand()。因此,“Redis 6 之后网络 I/O 可以多线程化,而核心命令仍由主线程执行”仍可作为面试中的主线,但最好再补一句:线程实现会演进,Redis 不是一个从头到尾都只有单线程的程序。

Redis 请求处理中主线程与 I/O 线程的边界

三个常见误区

误区一:Redis 快,主要因为单线程

单线程只是减少协调开销。没有内存访问、短小命令和 I/O 多路复用,单线程无法自然变快。

误区二:单线程就没有上下文切换

Redis 核心命令执行路径减少了多个命令线程之间的切换,但操作系统仍会调度进程,Redis 也可能运行后台线程或子进程。说“完全没有上下文切换”过于绝对。

误区三:Redis 的瓶颈永远不是 CPU

这同样过于绝对。网络、内存带宽、单核 CPU、慢命令、持久化和客户端使用方式都可能成为瓶颈。判断瓶颈要看实际指标,而不是背固定结论。

最后,给出一段可直接复述的回答

Redis 快不是单一原因,而是四层机制共同作用。

第一,主要数据访问在内存中,避免了按请求随机读取磁盘的高延迟;第二,Redis 针对不同数据类型使用紧凑编码、哈希表、quicklist、跳表等结构,让常见命令尽量短小;第三,核心命令通常串行执行,减少了共享数据上的锁竞争和多执行线程切换,也让执行语义更简单;第四,Redis 使用非阻塞 I/O 和 I/O 多路复用,一个事件循环可以管理大量连接,只处理已经就绪的 socket。

所以,真正的关键不是“单线程很快”,而是单次操作足够短,核心执行协调成本低,网络等待又不会阻塞事件循环。此外,Redis 并非所有工作都单线程:Redis 6 开始支持可选的 I/O 线程,Redis 8 又重做了 I/O threading,但核心命令执行仍以主线程串行为主。

参考资料

上一篇:一条 UPDATE 语句背后发生了什么?从 Buffer Pool 到两阶段提交 下一篇:什么是哈希算法?从文件校验、密码存储到 Git 与区块链

评论

评论(0)

暂无评论