跳到主要内容

Redis 线程模型

· 阅读需 9 分钟

Redis 以高性能著称,其核心原因之一就是其独特的 线程模型设计。很多人听说 Redis 是“单线程”,但实际上 Redis 的线程模型在不同版本中已经发生了演进。理解 Redis 的线程模型,对于理解其高性能原理、以及在高并发场景中的使用方式非常重要。

之所以想把这个话题单独写一篇,是因为“Redis 是单线程”这句话在面试和日常讨论中出现的频率太高,但大部分人对它的理解停留在字面上:要么以为整个 Redis 进程只有一个线程,要么以为 Redis 6 之后命令执行也变成多线程了。这两种理解都不准确。而这个模型直接影响我们怎么用 Redis——哪些命令不能随便执行、大 Key 为什么危险、Pipeline 为什么有效,根源都在线程模型上。

本文将从 Redis 单线程设计、事件驱动模型、IO 多路复用以及 Redis 6 之后的多线程改进几个方面进行介绍。

Redis 为什么选择单线程

Redis 早期版本(Redis 6 之前)的核心执行模型是 单线程处理命令

也就是说:

  • 所有客户端请求
  • 所有命令执行
  • 数据读写

都由 一个主线程完成

这个设计带来一个非常重要的性质:任意时刻只有一条命令在执行。所以 Redis 的单条命令天然是原子的,不需要加锁,也不存在两条命令交错修改同一个 Key 的问题。像 INCRSETNX 这类命令能直接当作并发原语来用,就是这个模型的副产品。

但需要注意的是:

Redis 的单线程 只指命令执行单线程,并不是整个 Redis 进程只有一个线程。例如:

  • RDB 持久化
  • AOF rewrite
  • 异步删除
  • BIO 线程

这些其实都是后台线程。

以异步删除为例:UNLINK 命令在主线程里只做一件事——把 Key 从字典中摘除,真正释放内存的工作交给后台线程慢慢做。这样即使删除一个很大的对象,主线程也不会被卡住。RDB 持久化则更进一步,直接 fork 出子进程去写快照,利用操作系统的写时复制(copy-on-write)避免阻塞主线程。

Redis 的核心线程只负责:

  • 网络 IO
  • 命令解析
  • 命令执行
  • 返回结果

单线程为什么依然很快

很多人会疑惑:单线程为什么还能支撑几十万 QPS?

Redis 的高性能主要来自以下几个原因。

内存数据库:Redis 所有数据都在内存中,避免了磁盘 IO。内存速度和磁盘速度不是一个数量级的。对于一条普通的 GET 命令,真正的执行部分只是一次哈希表查找,耗时是微秒甚至纳秒级——瓶颈根本不在 CPU 计算上。

避免线程切换开销:单线程执行命令,避免了加锁,竞争,复杂并发控制。多线程模型里,线程上下文切换、锁的争抢、缓存失效这些开销并不便宜;当每个请求本身只需要极短的处理时间时,这些并发控制的成本反而可能超过收益。Redis 选择单线程,本质上是判断:命令执行不是瓶颈,网络 IO 才是,那就把并发问题在 IO 层解决,让执行层保持最简单的形态。

此外,Redis 内部的数据结构也是为这个模型服务的:SDS 字符串、跳表、压缩列表等都针对内存访问做了优化,单个操作的路径非常短。

IO 多路复用

Redis 并不是一次只处理一个连接,而是使用 IO 多路复用模型

所谓 IO 多路复用,是指用一个线程同时监视多个 socket,由操作系统告诉你“哪些连接现在有数据可读/可写”,线程只处理就绪的那部分。相比“一个连接一个线程”的传统模型,它不需要为每个连接付出线程的内存和调度成本,单线程就能撑起海量连接。

Redis 使用的 IO 模型包括:

  • epoll(Linux)
  • kqueue(MacOS / BSD)
  • select
  • evport

Redis 在编译时会根据平台自动选择最优实现,Linux 上就是 epoll。这层封装在源码里叫 ae(A simple Event driven programming library),对上层暴露统一的事件接口。

Redis 会通过事件循环监听所有客户端连接:

┌───────────────┐
Client 1 ───▶│ │
Client 2 ───▶│ │
Client 3 ───▶│ IO Multiplex │
Client N ───▶│ │
└───────┬───────┘


Redis EventLoop


Command Execute

处理流程:

  1. 监听多个客户端 socket
  2. 哪个 socket 有数据就绪
  3. 读取请求
  4. 执行命令
  5. 返回结果

事件循环每一轮都会从多路复用接口取出就绪的事件,逐个处理:读事件就解析命令并执行,写事件就把结果发回客户端。因为每个事件的处理都很快,循环转得足够快,客户端感知不到自己在“排队”。

因此 Redis 可以用 单线程同时处理大量连接

Redis 6 的多线程改进

随着硬件发展,CPU 核数越来越多,Redis 的单线程模型在某些场景下会遇到瓶颈,瓶颈主要在 网络 IO

例如:

  • 大量客户端连接
  • 网络数据收发量巨大

命令执行本身是纯内存操作,快得很;但把请求从内核缓冲区读进来、把响应写回去,这些 read/write 系统调用是有实际开销的。当流量大到一定程度,主线程的时间大量花在收发数据上,真正执行命令的时间反而占比很小——这就是单线程模型的天花板。

因此 Redis 6 引入了 IO 多线程

执行流程变为:

Client Request


IO Threads (read)


Main Thread Execute Command


IO Threads (write)

思路很清晰:把读取解析和写回响应这两段“搬运工”的活分给多个 IO 线程并行做,命令执行仍然收敛回主线程串行处理。这样既利用了多核提升网络吞吐,又完整保留了单线程执行的原子性语义——上层业务代码不需要做任何改变。

因此:

  • 命令执行仍然是单线程
  • IO 操作可以并行

需要说明的是,IO 多线程默认是关闭的,通过配置开启:

# redis.conf
# IO 线程数,建议小于 CPU 核数
io-threads 4
# 默认只有写回用多线程,读取解析也想并行需再打开这个
io-threads-do-reads yes

只有在网络流量确实成为瓶颈的实例上开启才有意义,小流量场景开了反而多一层线程协调的开销。

Redis 单线程的使用建议

由于 Redis 命令执行是单线程,因此需要注意以下几点。一条慢命令会阻塞 所有 客户端的请求,这是单线程模型最需要敬畏的地方。

避免慢命令

例如:

# 遍历整个键空间,O(N),期间阻塞所有请求
KEYS *

这类命令会阻塞 Redis。

应该使用:

# 基于游标分批迭代,每次只处理一小部分,不会长时间阻塞
SCAN

类似的还有对大集合执行 SMEMBERSHGETALLLRANGE 0 -1 等全量读取命令,本质上都是把 O(N) 的工作塞进了单线程里。

控制大 Key

大 Key 会导致:

  • 网络阻塞
  • CPU 执行时间变长

而且大 Key 的删除和过期同样发生在主线程(除非用 UNLINK 或开启惰性删除),一个几百 MB 的 Key 过期时可能造成明显的请求毛刺。

建议:

  • 拆分数据
  • 使用 hash / set

使用 Pipeline

Pipeline 可以减少网络往返:

client -> redis -> client

不用 Pipeline 时,每条命令都要等上一条的响应回来才发下一条,吞吐被网络 RTT 限死;Pipeline 把一批命令攒起来一次发出,一次收回全部响应,把 N 次往返压缩成一次。

提升整体吞吐量。

踩坑与注意

结合线程模型,有几个实践中容易忽略的点:

  1. Lua 脚本同样阻塞主线程。脚本执行期间 Redis 不处理任何其他命令,脚本里写循环处理大量数据,效果等同于一条超级慢命令。

  2. 别把 Redis 6 的 IO 多线程理解成“并发执行命令”。事务、Lua、INCR 的原子性语义完全没变,不需要因此加锁;同样,也别指望开 IO 线程能解决慢命令问题——慢在执行层的,多线程 IO 帮不上忙。

  3. 排查阻塞先看慢日志SLOWLOG GET 记录的是命令执行耗时(不含网络),是定位“Redis 偶尔卡一下”的第一入口;配合 latency 相关命令能进一步区分是命令慢、fork 慢还是磁盘慢。

  4. 单实例吃不满多核是正常的。命令执行只用一个核,如果想利用整台多核机器,常见做法是单机多实例或上 Redis Cluster,而不是等 Redis 变成多线程执行。

总结

Redis 通过 内存操作、IO 多路复用、事件驱动(Event Loop)以及单线程命令执行 的架构设计,在避免锁竞争和线程切换开销的同时,实现了极高的并发处理能力。Redis 6 的 IO 多线程只是把网络收发并行化,命令执行仍然串行——这个核心不变,围绕它的使用原则(避免慢命令、控制大 Key、善用 Pipeline)也就始终成立。理解了这一点,很多 Redis 的最佳实践就不再是背出来的条目,而是模型推导出的必然结论。

评论 / COMMENTS