Redis 深度教程 · 02

单线程与 IO 模型

上一章拆了第一块基石——内存编码。这一章是第二块,也是全书最核心的一章:单线程串行执行。Redis 一半的「为什么」都从这里推出来。

本章你将建立的 schema

  • 「单线程」精确指的是命令执行单线程,不是进程只有一个线程
  • 一个线程靠 IO 多路复用(一个线程同时盯住大量连接的就绪事件)hold 住上万连接
  • 单线程如何同时带来「快」和「每条命令天然原子」这两件事
  • 大 key 为什么会拖垮整个实例,而不只是拖慢自己那一条命令
本章 = 基石二(全书的拱顶石)

起点提出 Redis 的两块基石:为每种数据形态精选内存编码、单线程串行执行。本章是第二块,也是整份教程的拱顶石(keystone)。读完它,目标不是「记住 Redis 是单线程的」这句结论,而是能自己从「单线程串行」这一个事实,推出「为什么快」「为什么命令天然原子」「为什么大 key 阻塞整个实例」三条结论。后面 03 的 fork 持久化、05 的分布式锁、06 的 LLM 限流,全都挂在这块基石上。

2.1单线程到底指什么

「单线程」精确指的是:命令的执行在单一线程里串行完成;但 Redis 进程并非只有这一个线程,后台另有子进程与多个辅助线程在干活。

为什么需要它

「Redis 是单线程的」是一句被反复传抄、又最容易理解错的话。把这个边界划错——以为整个进程从头到尾只有一根线程——后面所有推论都会跟着错:会以为 BGSAVE 必然卡住主流程、会以为 Redis 用不上多核的任何一点能力。先把精确边界立住,本章后面的链条才立得稳。

这句话的准确含义是:对客户端命令的「执行」这一段,由单一线程串行完成——同一时刻只有一条命令在改数据,下一条必须等当前这条返回。这根线程通常称为主线程(main thread),它跑的是事件循环(2.2 详述)。

但「命令执行单线程」不等于「进程只有一个线程」。一个真实的 Redis 进程里,至少还有下面这些不在主线程上的活:

  • bgsave 子进程:BGSAVE / AOF 重写时 fork(创建一个内存几乎一致的子进程)出独立进程做磁盘 dump,主线程继续服务请求。详见 03。
  • AOF 刷盘线程:appendfsync everysec 模式下,fsync(把内核缓冲真正落盘)交给一个后台线程(bio,background I/O),避免磁盘卡顿堵住主线程。
  • lazyfree 线程:lazyfree(惰性释放,把大对象的内存回收挪到后台)专门的线程异步释放大对象占的内存,避免一次 free 巨型结构卡住主线程。UNLINK 就靠它(2.5 详述)。
  • io-threads:Redis 6.0 起可选的 IO 线程,并行做 socket 读写与协议解析(2.6 详述)。
边界划清楚

串行的是命令执行这一段,不是整个进程。「单线程」是对执行模型的描述,不是对线程数的统计——一个开了 io-threads、正在 BGSAVE 的 Redis 进程,top 里能看到好几个线程和一个子进程。本章后面说「单线程」时,一律指命令执行这一段。

2.2IO 多路复用 + 事件循环

一根线程靠 IO 多路复用同时监听上万个 socket 的就绪事件,谁就绪就处理谁——这是「单线程还能扛高并发」的机制。

为什么需要它

直觉上「单线程」和「高并发」是矛盾的:一根线程一次只能干一件事,怎么同时服务上万个客户端?答案在于 IO 多路复用——它让一根线程不必为每个连接死等,而是一次性问内核「这上万个 socket 里,现在哪些可读可写」,只处理就绪的那些。理解了这一点,「单线程扛高并发」就不再是悖论。

Redis 自己实现了一个极薄的事件循环库,源码里叫 ae(A simple event-driven library),对应文件 ae.c。它在底层封装了操作系统提供的 IO 多路复用机制:Linux 上是 epoll、BSD / macOS 上是 kqueue,都没有时退回 select。这种「一个事件循环线程 + 多路复用 + 事件回调」的结构,就是经典的 reactor 模式(反应堆模式:事件到达后分发给对应处理器)。

关键在于 epoll 这类机制让一根线程同时盯住上万个 socket:线程把所有客户端连接注册进 epoll,然后阻塞在 epoll_wait 上;一旦其中任意若干个 socket 变为「可读」(客户端发来了命令)或「可写」(可以回数据了),内核就把这批就绪的 socket 一次性返回。线程于是只对就绪的连接做事,从不为某一个连接空等。一轮处理流程是:

  1. 读就绪:epoll 报告某 socket 可读,主线程从内核读出字节。
  2. 解析命令:按 RESP(Redis 序列化协议)把字节解析成一条命令。
  3. 执行:在主线程上串行执行这条命令,读写内存数据结构。
  4. 写回:把结果写进输出缓冲,socket 可写时发回客户端。
client A socket client B socket client C socket … 上万连接 epoll / kqueue 报告就绪集合 单个事件循环线程 ae · reactor 读 → 解析 → 执行 → 写回 命令逐条串行执行 c1 c2 c3 …
图 2.1上万连接经 epoll 汇成一个就绪集合,交给唯一的事件循环线程逐条执行。注意:并发发生在「同时监听」这一层,不在「执行」这一层——执行始终是一条接一条,没有第二条命令在并行改数据。
两个「并发」别混为一谈

「同时监听上万连接」和「同时执行多条命令」是两回事。前者靠 epoll 实现,是 IO 层面的并发;后者从不发生——命令执行永远串行。Redis 高并发的来源是「一根线程不为任何连接空等」,而非「多条命令并行跑」。把这两层分开,2.4 的「天然原子」就顺理成章。

2.3为什么单线程反而快

Redis 的瓶颈是内存与网络,不是 CPU;单线程省掉了多线程的全部协调成本——锁、上下文切换、缓存行竞争——反而比多线程更快。

为什么需要它

「单线程怎么会比多线程快」是面试常被追问、又最容易答歪的一题。答「因为内存快」只说对了一半——内存快是数据结构层的事,和单不单线程无关。真正的因果是:在一个 CPU 不是瓶颈的系统里,多线程带来的收益(并行算力)几乎用不上,而它的代价(同步开销)却全要付。把账算到这一层,才答到了点上。

把「快」归因到一句「内存快」是不够的。准确的因果链分三步:

  • 瓶颈不在 CPU:Redis 的绝大多数命令是内存里的 O(1) / O(logN) 操作,单条命令耗时常在微秒级。真正的限制是内存带宽和网络往返,而不是 CPU 算力。CPU 既然不是瓶颈,多线程能榨出的并行算力就基本用不上。
  • 多线程的代价却全要付:一旦多线程并发改同一份内存数据,就必须加锁保护,于是带来锁争用、线程上下文切换、以及 CPU 缓存行在核间来回失效(false sharing,伪共享)。这些协调成本在高频小操作下占比极高。
  • 单线程把这些成本全省了:没有锁、没有上下文切换、没有缓存行竞争。一根线程闷头跑,CPU 缓存命中率高、指令流水线不被打断。省下的协调成本,超过了「用不上的那点并行算力」。
一句话归因

Redis 快,不是因为「内存快」这一句(那是数据结构层的事),而是因为在一个 CPU 不是瓶颈的系统里,单线程省掉的多线程协调成本,大于它放弃的并行收益。这是一个工程权衡的结果,不是物理定律——换一个 CPU 密集的负载(比如大量 Lua 计算),单线程就会成为瓶颈,这也正是 2.6 多线程演进的动因之一。

2.4命令天然原子(核心推论)

单线程串行 → 每条命令执行时不可能被另一条命令打断 → 这正是 INCR、SETNX、分布式锁、限流令牌桶能成立的根基。

为什么需要它

这是本章要让读者「会自己推」的那一步,也是全书引用最多的一条结论。05 的分布式锁、06 的限流,本质上都在借用「一条 Redis 命令执行时不会被打断」这件事。如果这一步只是背下来而没真正想透,后面看锁和限流就只能记结论;想透了,它们就都成了这一条的直接推论。

推导只有一步,但要看清每个环节:命令执行是单线程串行的 → 同一时刻只有一条命令在改数据 → 一条命令一旦开始执行,在它返回之前,没有任何别的命令能插进来改同一份数据 → 因此每条 Redis 命令的执行天然是原子的,不需要任何锁。注意这里的原子性来自「串行执行」这个结构本身,是免费的——不是 Redis 额外加了锁机制实现的。

这条「天然原子」直接撑起了一批关键能力:

  • INCR / DECR / INCRBY:「读—加—写」三步在一条命令内完成,不可被打断,所以并发计数器永不丢更新。
  • SETNX 与 SET k v NX PX 30000:「不存在才设置」+「设过期」在一条命令里原子完成——这是分布式锁的根基(详见 05)。注意要用单条 SET ... NX PX,而非 SETNX 后再 EXPIRE 两条命令,否则中间会留下没有过期时间的窗口。
  • Lua 脚本 / EVAL:整段脚本作为一个整体在主线程上执行,期间不穿插其他命令。限流令牌桶、库存扣减这类「多步骤要么全做要么全不做」的逻辑,靠它一次原子完成(详见 06)。
想一想 · 会不会丢更新

两个客户端 A 和 B 在同一瞬间,都对同一个 key counter(当前值 100)执行 INCR。最终 counter 是 101 还是 102?为什么?多线程程序里同样的「读—加—写」会有什么风险?

展开思路与答案

最终是 102,绝不会丢更新。命令执行是单线程串行的:A 的 INCR 和 B 的 INCR 会被排成一前一后两条,谁先执行谁把 100 改成 101,另一条再把 101 改成 102,中间不存在交叉。

对照多线程:若两个线程各自做「读 100 → 算 101 → 写回 101」,两个读会同时读到旧值 100,结果都写回 101,丢掉一次自增——这正是经典的竞态(race condition)。多线程必须加锁才能避免,而 Redis 靠串行执行把这个问题从根上消除了,无需锁。

Redis · 单线程串行 A: INCR B: INCR 单一执行线程 100 → 101 → 102 无交叉 · 无锁 多线程 · 需要锁 线程 1 读 100 线程 2 读 100 锁 / mutex 不加锁→丢更新 都写回 101
图 2.2左侧两条 INCR 被排成一队、逐个把值推进,结果必为 102;右侧多线程不加锁则两次读都看到 100、各写回 101 丢掉一次自增。注意:Redis 的原子性是「串行结构」白送的,省掉了右侧那道锁。
原子的边界:单命令,不是多命令

天然原子的单位是一条命令(或一段 Lua 脚本、一个 MULTI/EXEC 事务块)。两条独立命令之间没有这层保证——GET 之后再 SET,中间完全可以插进别的客户端的写。所以「读旧值、判断、再写新值」这种跨命令逻辑,必须收进一条命令、一段 Lua、或一个事务里,才能继续享受原子性。这是 05 分布式锁要用 SET ... NX 单命令、而不是两条命令的根本原因。

2.5大 key 为什么阻塞整个实例

一条 O(N) 命令占住唯一的执行线程期间,其余所有客户端的请求只能在队列里干等——慢的不只是这一条命令,是整个实例。

为什么需要它

「大 key 危险」几乎人人会说,但多数人停在「它慢」这个层面。真正要建立的认知是:它的危害不是「自己慢」,而是「拖慢所有人」。这个差别,恰恰是从「单线程串行」推出来的——只有一根执行线程,它被一条命令占住,别的请求就全被挡在门外。理解了这条因果,才会真正敬畏大 key,也才看得懂 Redis 4.0 为什么要专门为「删除」造一个新命令。

把 2.3 的「快」反过来看就是这一节。单线程串行意味着只有一根线程在执行命令——这在命令都是微秒级时是优点,但一旦某条命令是 O(N) 且 N 很大,它就会长时间霸占这根唯一的线程,排在它后面的所有请求(哪怕只是一个 GET)都得等它执行完。典型的「大 key 操作」包括:

  • HGETALL 一个百万字段的大 hash——一次性遍历并序列化全部字段。
  • 一次性 DEL 一个百万元素的大 key——释放上百万个内存对象,全在主线程上同步完成。
  • 对大集合做 SMEMBERS、对大 ZSet 做无界 ZRANGE 0 -1、用 KEYS * 扫全库。

这里有个反直觉的点:DEL 一个大 key 也是 O(N) 的。删除不是「把指针一抹」那么简单——一个百万元素的集合,要逐个释放这百万个元素对象的内存,这个 free 的总量正比于元素个数,全程占着主线程。

Redis 4.0 为此引入了 UNLINK,把删除拆成两段:

  • 主线程只做 O(1) 解链:把这个 key 从键空间里摘掉,立刻返回。对客户端来说,key 瞬间就不可见了。
  • 后台 lazyfree 线程释放内存:真正耗时的「逐个 free 百万对象」挪到后台线程异步做,不占主线程。

同样的惰性释放也能让 DEL 自动享受到:把 lazyfree-lazy-user-del 配成 yes,普通 DEL 在内部就按 UNLINK 的方式走后台释放。相关的还有 lazyfree-lazy-expire(过期删除走惰性)、lazyfree-lazy-eviction(淘汰走惰性)、lazyfree-lazy-server-del(被覆盖的旧值走惰性),默认多为关闭,生产环境常建议打开。

重新认识 Redis 4.0 的「杀手特性」

面试里被问「Redis 4.0 带来了什么」,标准答案常是 UNLINK 和 lazyfree。把它讲透:这个所谓的杀手特性,本质上只是一次更聪明的删除操作——它没改变命令执行单线程这个事实,而是把「删除」里最耗时的内存释放从主线程搬到后台,让主线程只付 O(1) 的解链成本。它解决的正是本节开头那个问题:别让一次大 key 删除占住唯一的执行线程。

想一想 · 删 10MB 的 key 时别人在经历什么

一个 10MB、含上百万元素的大 key,此刻执行 DEL(且没开 lazyfree)。在它执行的这段时间里,另一个客户端发来的 GET some_small_key 会经历什么?如果把 DEL 换成 UNLINK,又会怎样?

展开思路与答案

用 DEL:那个 GET 会被阻塞排队,直到 DEL 把上百万个元素对象全部 free 完、主线程腾出来,才轮到它执行。即便 GET 本身是 O(1)、要读的 key 也很小,它的延迟也会被这次大删除整个拖高——这就是「一个大 key 拖垮整个实例」的实况,受害的是所有排在后面的无辜请求。

换 UNLINK:主线程只做 O(1) 解链就立刻返回,把百万对象的内存释放交给后台 lazyfree 线程。那个 GET 几乎不受影响,延迟正常。代价是内存不是「命令返回的那一刻」就全部回收,而是后台线程慢慢释放——用「内存延迟回收」换「主线程不被阻塞」。

2.6多线程的演进

io-threads(6.0)只并行 socket 读写与协议解析,命令执行仍单线程;Redis 8 / Valkey 8 让主线程与 IO 线程并发——「单线程」如今特指命令执行这一段。

为什么需要它

「Redis 不是早就多线程了吗,还说它单线程?」——这是 6.0 之后最常见的困惑。化解它,要把一次请求拆成两段看:socket 读写 / 协议解析是一段,命令执行是另一段。多线程化动的一直是前一段,后一段至今守住单线程。看清这条分界线,才能准确回答「Redis 到底是不是单线程的」,也才能理解后续版本在并发上各自动了哪一块。

当连接数和吞吐很高时,瓶颈会从「执行」转移到「在内核和用户态之间搬运网络字节、把字节解析成命令」——这部分是 CPU 密集且可以并行的。于是 Redis 把这一段逐步多线程化,但守住一条红线:命令执行始终单线程。三个阶段:

  • Redis 6.0 · io-threads:引入可配置的 IO 线程(io-threads 参数),把socket 读写和 RESP 协议解析分给多个 IO 线程并行做;但命令执行仍由主线程一条条串行完成。模型是「IO 多线程,执行单线程」。
  • Redis 8 / Valkey 8 · 并发 IO:进一步让主线程与 IO 线程并发工作(而非旧版那种「主线程派活后干等 IO 线程收工」的同步切换),把多核利用率再推高一截。Valkey 8 官方实测,单实例吞吐从约 360K RPS 提升到约 1.19M RPS(依硬件与负载而定)。
  • 不变的内核:无论哪个阶段,改内存数据结构的那一步永远在单一线程上串行。所以 2.4 的「命令天然原子」在所有版本上都成立——这也是它能被当作分布式锁、限流根基的前提。
哪部分多线程了,哪部分仍单线程
请求阶段Redis 5 及以前Redis 6 io-threadsRedis 8 / Valkey 8
socket 读写主线程IO 线程并行IO 线程并发
协议解析(RESP)主线程IO 线程并行IO 线程并发
命令执行(改数据)单线程单线程单线程
IO 线程区 · 可多线程 IO 线程 1 读 + 解析 IO 线程 2 读 + 解析 IO 线程 3 读 + 解析 IO 线程 4 读 + 解析 socket 读写 · RESP 协议解析 CPU 密集 · 可并行 执行边界 单一主线程 命令执行 · 改数据 三代不变 · 始终串行 汇入
图 2.3多线程化只发生在执行边界左侧(socket 读写 + 协议解析);越过边界,命令执行收敛回单一主线程。注意:Redis 6 → Redis 8 / Valkey 8 提升的是左侧的并行度与主线程-IO 线程并发度,右侧那根红线从未被打破。
「单线程」这句话的精确版本

今天再说「Redis 是单线程的」,准确的意思是「命令执行是单线程的」——表中那一行朱红高亮,三代都没变。socket 读写和协议解析早已多线程化。这正好呼应 2.1 立的边界:串行的从来只是命令执行这一段,而它恰恰是「天然原子」与「大 key 阻塞」两条推论共同的源头。

·自测

  1. 「Redis 是单线程的」这句话,精确说指的是哪一段单线程?一个正在 BGSAVE、且开了 io-threads 的进程,违背这句话吗?
  2. 单线程为什么反而比多线程快?把原因归结到「内存快」一句,错在哪?
  3. 从「单线程串行执行」出发,一步步推出「INCR 并发自增不会丢更新」。这条原子性是 Redis 额外加锁实现的吗?
  4. 设计判别:Redis 命令执行是单线程的,那么在一台 32 核的机器上,怎样才能把 CPU 大致压满、而不是只用到一个核?
展开全部答案

1. 指的是命令执行这一段单线程串行:同一时刻只有一条命令在改数据。它不违背——BGSAVE 是 fork 出的子进程在干活,io-threads 是 IO 线程在做 socket 读写与协议解析,两者都不在「执行命令」这一段。「单线程」描述的是执行模型,不是进程的线程总数。

2. 因为 Redis 的瓶颈是内存带宽与网络、不是 CPU;CPU 既然不是瓶颈,多线程能榨出的并行算力基本用不上,而它的代价——锁争用、上下文切换、缓存行竞争——却全要付。单线程把这些协调成本全省了。归结到「内存快」是错的:内存快是数据结构层的事,和单不单线程无关;真正的因果是「省掉的协调成本 > 放弃的并行收益」。

3. 推导:命令执行单线程串行 → 同一时刻只有一条命令在改数据 → 一条 INCR 开始执行后,在它返回前没有别的命令能插进来改同一个 key → 两个并发 INCR 被排成一前一后,100→101→102,不丢更新。这条原子性不是额外加锁实现的,而是「串行执行」这个结构本身白送的——没有锁参与。

4. 既然单实例的命令执行用不满多核,就横向铺开:① 在一台机器上跑多个 Redis 实例(多进程),靠操作系统把它们调度到不同核;② 上 Redis Cluster 做分片,把数据和请求分散到多个实例分别执行(详见 05);③ 打开 io-threads 让 socket 读写 / 协议解析吃到多核;④ 选用 Valkey 8 / Redis 8 的并发 IO 进一步提升多核利用率。核心思路:命令执行这一段压不满多核,就用「多实例 / 分片 + IO 多线程」从外面把核占满。

进阶挑战

p99 延迟突然飙高,从「单线程被占住」入手排查

线上一个 Redis 实例,监控显示 p99 延迟突然从 1ms 飙到几百 ms,QPS 没有明显上涨,CPU 也没打满。从本章「单线程被某条 O(N) 命令占住」这个角度,给出一条排查路径:用哪些工具、看哪些信号、最终怎样定位并避免那条慢命令?

提示(不给全解)

主线索是「某条慢命令占住了唯一的执行线程,把后面所有请求一起拖慢」。排查顺序可以是:① SLOWLOG GET 看慢日志——哪条命令执行超时、作用在哪个 key 上,这是最直接的证据;② redis-cli --bigkeys 扫出大 key,看慢命令作用的 key 是不是百万元素级;③ 复盘业务侧是否有 KEYS *、无界 ZRANGE 0 -1、对大 hash 的 HGETALL 这类 O(N) 全量操作;④ 对策:把 KEYS 换成 SCAN 游标遍历、范围查询加上界、大 key 删除改 UNLINK 或打开 lazyfree、必要时把大 key 拆分。想一想:为什么「CPU 没打满」反而和「单线程被占住」并不矛盾?(提示:占住的是一根线程的时间,不是全部核的算力。)