Redis - 单线程的 Redis 凭什么这么快:高性能 IO 模型解读
文章目录


单线程的 Redis 凭什么这么快:高性能 IO 模型解读
“Redis 是单线程的”——这句话几乎成了面试经典题。但真要追问下去,问题就立刻深了一层:单线程是怎么做到几十万 QPS 的?多线程不是天然更快吗?为什么 Redis 偏偏选了一条看起来反直觉的路?

把这些问题串起来看,会发现答案藏在两个层面:一个是为什么不用多线程,一个是单线程靠什么撑住高并发。前者是设计取舍,后者是工程实现,两个加在一起,才是 Redis 性能模型的完整图景。
先澄清:Redis 真的只有一个线程吗
严格说,Redis 不是纯粹的单线程。
更准确的说法是:Redis 处理网络 IO(6.x之前的版本) 和键值对读写的主流程由一个线程完成。 这条线程负责接收连接、解析命令、读写数据、返回结果,是对外提供服务的核心通路。除此之外,Redis 还有若干后台线程在干活:
- 持久化(AOF 重写、RDB 快照)由 fork 出的子进程负责
- 异步删除大 key(4.0 引入的 lazy-free)跑在专门的后台线程里
- 集群间数据同步、过期 key 的辅助清理也在独立的线程
之所以大家习惯说"单线程 Redis",强调的是用户请求处理这条主路径上只有一个线程在干活。这是 Redis 性能讨论的核心战场,其他后台线程不在这条主路径上。
为什么不用多线程
多线程理论上能提升吞吐——这一点没有问题。但实际效果跟资源调度、锁设计、上下文切换等一系列细节强相关。线程数从 1 加到 4 通常会带来明显加速,但继续加到 16、32 时,吞吐曲线常常会变平甚至往下掉。
瓶颈在哪儿?共享资源的并发访问控制。

举个 Redis 自己的例子。假设 Redis 改成多线程,线程 A 对一个 List 做 LPUSH 并把长度加 1,同时线程 B 对同一个 List 做 LPOP 并把长度减 1。这两个操作必须串行执行,否则长度计数就乱了。这就需要加锁。一旦引入锁,多线程就退化成"轮流上场",并行变串行,加线程反而拖慢系统。
再退一步看,多线程编程本身就难。共享数据的并发控制要用互斥锁、读写锁、原子操作;调试线程死锁、竞态条件、内存可见性问题往往要花掉好几倍于业务代码的时间。Redis 作者 Antirez 多次表达过同一个意思:让代码保持简单可控,比追求极限并发更重要。
更关键的是,Redis 的瓶颈本来就不在 CPU。它的核心操作是内存读写,CPU 几乎从来不是限制因素——网络 IO 才是。既然 CPU 还有空闲,就没必要用多线程把简单问题搞复杂。
单线程为什么能跑这么快
通常来说单线程的处理能力会比多线程差很多,但 Redis 单线程能扛十万级别的 QPS,这是几方面因素叠加出来的结果:
- 操作主要在内存完成:内存随机访问百纳秒级,比磁盘快五个数量级
- 数据结构高效:哈希表 O(1)、跳表 O(log n),单次操作的耗时本身极短
- 避免了多线程开销:没有锁、没有上下文切换、没有缓存一致性同步
- 基于多路复用的 IO 模型:让单线程也能"同时"处理大量连接
前三条好理解,关键在最后一条——多路复用才是单线程能并发服务大量客户端的核心机制。
基本 IO 模型与潜在阻塞点
要理解多路复用,得先看不用它会怎么样。
一个最朴素的网络服务,处理一个 GET 请求需要做这些事:监听端口(bind/listen)、建立连接(accept)、读取请求(recv)、解析命令(parse)、读取键值(get)、返回结果(send)。其中 bind/listen、accept、recv、parse、send 属于网络 IO,get 属于键值数据操作。

如果在一个线程里把这些步骤串起来跑,会有两个潜在阻塞点:
- accept():如果监听端口上一直没有连接进来,线程就会卡在 accept 上,其他客户端都连不上
- recv():和某个客户端建立连接后,如果客户端迟迟不发数据,线程就会卡在 recv 上,其他已建立连接的客户端的请求也没法处理
只要有一个客户端连接慢、发数据慢,整个 Redis 就被拖住。这显然不能接受。幸好 Socket 网络模型本身支持非阻塞模式。
非阻塞 Socket:不阻塞但也不知道何时有数据

Socket 编程里有几个关键函数返回不同类型的套接字:socket() 返回主动套接字,listen() 把它转换成监听套接字,accept() 接收连接后返回已连接套接字。这三种套接字都可以设置成非阻塞模式:
- 监听套接字非阻塞:调用 accept 时如果没有连接到达,立刻返回,线程不会卡住
- 已连接套接字非阻塞:调用 recv 时如果数据没到达,立刻返回,线程不会卡住
非阻塞模式让线程不会被某个具体的 IO 操作卡住,但带来了新问题:怎么知道什么时候数据真的到了?
总不能让线程不停地空轮询每个套接字。如果有 1 万个客户端连接,挨个问"你有数据吗",CPU 全耗在轮询上了,啥业务也干不成。
这时候就需要一种机制:让操作系统帮我们盯着所有套接字,一旦哪个上有事件发生,就主动告诉我们。这就是 IO 多路复用。
IO 多路复用:让一个线程盯住一万个连接
Linux 上的 IO 多路复用是 select、poll、epoll 这三套机制(大体一脉相承,后者是前者的优化)。它们的核心思想是:把一组文件描述符交给内核,由内核统一监听,等任何一个上有事件发生时再通知应用程序。

在 Redis 的场景下,多路复用允许内核里同时存在多个监听套接字和已连接套接字。Redis 网络框架通过 epoll 系统调用,把这些套接字交给内核监听。Redis 主线程不会卡在某个具体的 accept 或 recv 上,而是去处理已经就绪的事件。
这就把问题从"逐个等待"变成了"批量监听"。一个线程可以同时和成千上万个客户端保持连接,只要内核监听到哪个套接字上有数据可读、可写,就把对应的事件交给 Redis 处理。Redis 处理完,再去处理下一个就绪的事件。
事件队列与回调机制
epoll 不是简单地告诉 Redis"现在有事件了"就完事了,它还提供了基于事件的回调机制。
select/epoll 监测到 FD 上有请求到达时,会触发对应的事件,事件被放进一个事件队列。Redis 单线程不停地从这个队列里取事件出来处理。处理时根据事件类型调用对应的回调函数:
- 连接请求触发 Accept 事件,Redis 调用 accept 函数处理新连接
- 读请求触发 Read 事件,Redis 调用对应的命令处理函数读取数据并执行命令
- 写就绪触发 Write 事件,Redis 把响应数据写回给客户端
这种"事件循环 + 回调"的模式有个名字叫 Reactor 模式。它的好处是 Redis 不需要主动轮询任何套接字,所有动作都由事件触发,CPU 不会被空转浪费,响应延迟也很低。
打个生活化的比方:医院的分诊台和医生分工。如果让一个医生既要做分诊、测体温、登记,又要做实际诊断,效率会被拖得很低。所以医院设了分诊台,医生只接收已经分诊完的病人。Linux 内核就是分诊台,Redis 主线程就是医生——它只处理已经就绪的事件,不浪费一秒钟在等待上。
不同操作系统的多路复用实现
epoll 是 Linux 上的实现,但多路复用并不是 Linux 独有的。不同操作系统提供了不同的实现:
- Linux:select、poll、epoll
- FreeBSD、macOS:kqueue
- Solaris:evport
- Windows:IOCP
select 和 poll 性能相对差,主要原因是它们要求每次调用时把全部 FD 集合从用户态拷贝到内核态,而且返回时还要遍历所有 FD 找出就绪的那些。文件描述符数量上去之后,开销线性增长。
epoll 用红黑树管理 FD 集合,添加新 FD 通过 epoll_ctl 系统调用一次性注册到内核,就绪的 FD 通过链表传回,应用程序拿到的是已经就绪的事件列表。规模再大也不会出现遍历所有 FD 的开销。
Redis 的网络框架对这些机制做了封装,运行时根据系统选择最合适的实现。在 Linux 上自然是 epoll。
这个模型还有哪些潜在瓶颈
多路复用解决了网络 IO 的并发问题,但单线程模型本身还有一些值得注意的瓶颈点。
任意一个慢请求都会拖累后续请求。 主线程是顺序处理事件的,前一个事件没处理完,后面的事件就得等。常见的慢请求来源包括:
- 操作 bigkey:写入或删除一个上百 MB 的大 key 时,内存分配和释放都耗时
- 高复杂度命令:SORT、SUNION、ZUNIONSTORE 这种 O(n) 甚至更高的命令
- 大量 key 集中过期:过期 key 的清理也在主线程做
- AOF always 模式刷盘:每次写都要等磁盘返回
- 主从全量同步时 fork 子进程:fork 那一瞬间会阻塞主线程
网络 IO 的读写本身也是单线程瓶颈。 在并发量极高的场景下,主线程处理客户端 socket 的读写就开始成为瓶颈。这也是 Redis 6.0 引入"多线程 IO"的原因——把网络数据的读写拆出去用多线程并行做,但具体命令的执行依然由单线程负责。这样既利用了多核,又避免了并发控制问题。
Redis 6.0 的多线程不是颠覆而是补强
Redis 6.0 的多线程模型经常被误解。它不是把命令执行改成了多线程,而是只把网络读写这一步拆给了 IO 线程:
- 主线程接收连接、把客户端分配给 IO 线程
- IO 线程并行地读取所有客户端的请求数据
- 主线程统一执行命令(依然是单线程)
- IO 线程并行地把响应写回客户端
也就是说,"命令执行"始终是单线程,只是"网络 IO"变成了多线程。这样既保留了单线程模型的简单性,又利用上了多核 CPU 的网络处理能力。默认情况下这个特性是关掉的,需要在 redis.conf 里手动开启 io-threads 才能生效。
小结
回到最初的问题。

Redis 真的只有单线程吗? 严格说不是,但服务请求的主路径上只有一个线程。
为什么用单线程? 内存操作快到 CPU 不是瓶颈,多线程引入的并发控制反而得不偿失。
单线程为什么这么快? 内存操作 + 高效数据结构 + 多路复用 + 事件驱动 Reactor 模型,四个因素叠加。
把这些点串起来,能得到一个对 Redis 性能模型更立体的认知。当你看到生产环境某个 Redis 突然变慢时,可以从这张图上找线索:是有 bigkey 卡住了主线程?是 fork 阻塞了?是网络读写打满了?知道架构,就知道问题可能藏在哪些地方。
性能调优的本质从来不是套用所谓的"最佳实践",而是理解机制、定位瓶颈、对症下药。多路复用和单线程的故事,正是这种思路的一个范本。

更多推荐




所有评论(0)