Redis 网络模型:从"排队等餐"到"智能管家"的进化史

Redis 为什么这么快?除了内存存储,还有一个关键因素:网络模型。今天我们就用生活化的例子,来理解 Redis 网络模型的演进过程,看看它是如何从"排队等餐"进化到"智能管家"的。

核心问题:为什么 Redis 选择单线程模型?为什么新版本引入多线程,但命令执行仍然是单线程?这背后到底发生了什么?


IO 模型演进概览

在深入了解之前,让我们先看看 IO 模型的演进路径:

阻塞 I/O (BIO)

最原始模式:一线程一连接

痛点:线程资源昂贵,高并发下内存爆炸,线程切换开销大。
非阻塞 I/O (NIO)

忙轮询:虽然不阻塞,但 CPU 空转

痛点:用户态不断发起系统调用询问内核,导致 CPU 占用率极高,效率无本质提升。
I/O 多路复用

Redis 的选择:一个线程监控多个 Socket

核心:利用 epoll (Linux) / kqueue (BSD) 实现事件驱动。 Redis 借此实现了单线程处理数万并发,简单且高效。
信号驱动 I/O

半异步:内核通知,应用处理

尴尬:虽然避免了轮询,但信号处理流程复杂,在 TCP 网络编程中极少被作为主方案。
异步 I/O (AIO)

终极形态:内核全包圆

现状:最理想的模式(发起调用后直接去干别的,数据拷贝完再回调)。 但 Linux 早期 AIO 支持不完善(仅支持文件 IO),网络 AIO (io_uring) 较新,Redis 暂未使用。

开篇:为什么 Redis 这么快?

想象一下,你开了一家餐厅:

传统餐厅(多线程模型)

  • 每个顾客配一个服务员
  • 100 个顾客需要 100 个服务员
  • 成本高、管理复杂,服务员之间还要协调工作

Redis 餐厅(单线程模型)

  • 只有一个超级服务员
  • 但这个服务员有"超能力"——可以同时服务成千上万的顾客
  • 成本低、管理简单,不会出现"多个服务员抢着服务一桌"的情况

这个"超能力"就是 IO 多路复用(epoll),它让 Redis 用一个线程就能高效处理大量网络连接。

为什么 Redis 6.0 引入多线程,但核心还是单线程?

就像餐厅增加了"传菜员":

  • 主厨(单线程):还是一个人,负责"做菜"(执行 Redis 命令)
  • 传菜员(多线程):多人负责"传菜"(网络 IO、解析命令)

为什么这样设计?我们后面会详细解释。


基础概念:先理解"厨房"和"餐厅"

在深入 IO 模型之前,我们需要先理解两个基础概念。

内核空间 vs 用户空间:餐厅的"厨房"和"大厅"

想象一下餐厅的布局:

内核空间(厨房)

  • 只有厨师能进,普通顾客不能进
  • 这里处理"危险操作":切菜、用火、操作设备
  • 如果顾客乱动,整个餐厅都可能出问题

用户空间(大厅)

  • 顾客可以自由活动,点餐、用餐、聊天
  • 这里是应用程序运行的地方
  • 相对安全,出错不会影响整个系统

为什么分开?

就像餐厅不允许顾客进厨房一样,操作系统不允许应用程序直接操作硬件。这就像一道"安全门",防止程序出错导致整个系统崩溃。

缓冲区机制:传菜窗口

数据在"厨房"和"大厅"之间传递,需要一个"传菜窗口"(缓冲区):

  • 写数据:把数据从用户空间(大厅)拷贝到内核空间(厨房),然后写入设备
  • 读数据:从设备读取数据到内核空间(厨房),然后拷贝到用户空间(大厅)

这个过程涉及两次拷贝,这也是为什么 IO 操作比较慢的原因之一。

文件描述符(FD):餐厅的"订单号"

生活类比

当你去餐厅点餐时,服务员会给你一个"订单号"。通过这个订单号,你可以:

  • 追踪你的餐点状态
  • 知道什么时候可以取餐
  • 避免搞混不同的订单

技术解释

在 Linux 系统中,每个打开的文件、每个网络连接,都有一个文件描述符(File Descriptor,简称 FD)。它是一个从 0 开始递增的整数,就像订单号一样。

  • 打开一个文件,系统返回一个 FD(比如 3)
  • 建立一个网络连接,系统返回一个 FD(比如 4)
  • Redis 需要同时处理很多连接,每个连接都有一个 FD

为什么重要?

Redis 要处理大量客户端连接,就需要管理大量的 FD。IO 多路复用就是用一个线程来"监听"这些 FD,看哪个有数据可以处理。


五大 IO 模型:从"排队等餐"到"智能管家"

IO 模型经历了五次进化,让我们用生活场景来理解每一种。

阻塞 IO:最原始的"排队等餐"

生活场景:银行排队办业务

你取号后,必须一直站在队伍里等:

  • 不能离开
  • 不能做其他事
  • 不能玩手机(因为要盯着队伍)
  • 轮到你才能办业务

这就是阻塞 IO:应用程序发起请求后,必须一直等待,直到数据准备好并复制完成,才能继续执行。

技术原理

应用程序发起 read 请求
内核准备数据(阻塞等待)
数据复制到用户空间(阻塞等待)
应用程序继续执行

整个过程,应用程序的线程都被"阻塞"了,就像你一直站在队伍里。

问题

如果有很多客户,需要很多窗口(线程):

  • 1000 个连接需要 1000 个线程
  • 每个线程都要占用内存(每个线程约 1-2MB)
  • 线程切换的开销也很大
  • 成本高,效率低

在高并发场景下,这种方式完全不可行。


非阻塞 IO:可以"四处逛逛"的等待

生活场景:快餐店的"呼叫器"

你点餐后,拿到一个呼叫器:

  • 可以四处逛逛,不用一直站着等
  • 可以看手机、聊天
  • 但你需要不停地看呼叫器,看餐好了没
  • 餐好了,你还要自己走过去取(这个阶段还是要等)

这就是非阻塞 IO:发起请求后立即返回(不阻塞),但需要不断轮询(问:好了没?好了没?)。

技术原理

应用程序发起 read 请求
立即返回(不阻塞)
应用程序不断轮询:"数据好了没?"
数据准备好了
数据复制到用户空间(阻塞等待)← 这里还是要等!
应用程序继续执行

问题

  1. CPU 空转:就像你不停地看呼叫器,很累但效率不高
  2. 数据复制阶段仍阻塞:数据准备好后,复制数据到用户空间时还是要等待
  3. 浪费资源:大部分时间都在"空等",没有实际工作

这种方式虽然不阻塞,但效率并没有提高多少。

非阻塞IO模型


IO 多路复用:一个"超级服务员"同时服务多桌

生活场景:高级餐厅的"智能服务员"

想象一个高级餐厅,有一个超级服务员:

  • 他可以同时照看 10 桌、100 桌甚至 1000 桌客人
  • 他有一个"智能平板"(epoll),显示每桌的状态
  • 哪桌需要服务,平板会主动提醒(事件通知)
  • 服务员不需要挨个问"需要什么吗?"

这就是IO 多路复用:用一个线程监听多个 FD,当某个 FD 有数据时,主动通知应用程序。

技术原理

应用程序注册要监听的 FD
调用 select/poll/epoll(阻塞等待,但只阻塞一次)
内核监控所有 FD
某个 FD 有数据了(事件通知)
应用程序处理这个 FD 的数据(数据复制时阻塞)
继续监听下一个事件

关键优势:只阻塞一次,就能知道多个 FD 中哪些有数据。

三种实现方式:

select:最原始的"平板"

生活类比

  • 一个只能显示 1024 桌的平板
  • 每次都要把所有桌子的信息都看一遍
  • 每次都要把信息从"厨房"传到"大厅"(用户空间和内核空间拷贝)

技术问题

  1. FD 数量限制:最多只能监听 1024 个 FD
  2. 需要遍历所有 FD:即使只有 1 个 FD 有数据,也要检查所有 FD
  3. 需要拷贝 fd_set:每次调用都要把 fd_set 从用户空间拷贝到内核空间,返回时再拷贝回来

在高并发场景下,这些开销是巨大的。

poll:改进的"平板"

生活类比

  • 平板可以显示无限桌(没有 1024 的限制)
  • 但还是每次都要看所有桌子(遍历所有 FD)

改进

  • 解决了 FD 数量限制的问题(使用链表存储,理论上无上限)

问题

  • 还是要遍历所有 FD,效率不高
  • 仍然需要拷贝 pollfd 数组

虽然比 select 好一点,但在高并发场景下性能仍然不够理想。

epoll:最智能的"平板"(Redis 的选择)

生活类比

  • 智能平板,只显示"需要服务的桌子"
  • 不需要遍历所有桌子
  • 桌子状态变化时,平板主动提醒

工作原理

epoll 在内核中维护两个数据结构:

  1. 红黑树:记录所有要监听的 FD

    • 查找、添加、删除的效率都是 O(log n)
    • 就像智能平板的"桌子列表"
  2. 就绪链表:只记录"需要服务的桌子"

    • 当某个 FD 有数据时,内核把它添加到这个链表
    • 就像平板只显示"需要服务的桌子"

工作流程

1. epoll_create:创建 epoll 实例(创建智能平板)
2. epoll_ctl:添加要监听的 FD(把桌子加入平板)
3. epoll_wait:等待事件(平板主动提醒哪些桌子需要服务)
4. 处理就绪的 FD(服务员去服务这些桌子)

核心优势

  1. 不需要遍历所有 FD:只返回就绪的 FD
  2. 不需要重复拷贝:FD 只需添加一次到内核空间
  3. 性能不受 FD 数量影响:即使监听 10 万个 FD,性能也不会明显下降
  4. 事件驱动:内核主动通知,不需要轮询

这就是为什么 Redis 选择 epoll 的原因!

epoll模型


信号驱动 IO:餐厅的"广播通知"

生活场景:电影院散场

你买票后,可以自由活动:

  • 可以逛街、吃饭、玩手机(不阻塞)
  • 电影结束后,广播通知所有观众(信号通知)
  • 你听到广播后,自己走过去(数据复制阶段仍阻塞)

这就是信号驱动 IO:注册信号处理函数,内核数据准备好时发送信号通知,用户进程收到信号后自己去读取数据。

技术原理

应用程序注册信号处理函数(SIGIO)
发起 read 请求,立即返回(不阻塞)
应用程序可以做其他事情
内核数据准备好了,发送 SIGIO 信号
应用程序收到信号,去读取数据(数据复制时阻塞)← 这里还是要等!

优缺点

  • 优点:等待阶段不阻塞,可以做其他事情
  • 缺点
    • 信号处理复杂
    • 信号可能丢失(就像广播可能听不清)
    • 数据复制阶段还是要阻塞等待
    • 不适合高并发场景

为什么 Redis 不用?

信号处理太复杂,而且 epoll 已经足够好了。就像餐厅有智能平板就够用,不需要广播系统。


异步 IO:最完美的"代购服务"

生活场景:代购服务

你下单后,完全不用管

  • 可以工作、睡觉、做任何事(完全不阻塞)
  • 代购员会:
    1. 去采购(数据准备)
    2. 送到你家(数据复制)
    3. 打电话通知你(回调通知)
  • 整个过程你完全不用等待

这就是异步 IO:发起请求后立即返回,内核完成数据准备和数据复制,然后通过回调通知应用程序。

技术原理

应用程序发起 aio_read 请求
立即返回(不阻塞)
应用程序可以做任何事(完全不阻塞)
内核完成:
  1. 数据准备
  2. 数据复制到用户空间
内核通过回调函数通知应用程序
应用程序处理数据

与信号驱动 IO 的区别

  • 信号驱动 IO:数据准备好后通知你,但你还要自己去取(复制阶段阻塞)
  • 异步 IO:数据准备好并送到你手上后,才通知你(完全非阻塞)

这是唯一一个完全非阻塞的 IO 模型。

Linux 异步 IO 的现状

Linux 原生 AIO(libaio)有很多限制:

  • 只支持直接 IO(O_DIRECT),不支持缓冲 IO
  • 只支持磁盘 IO,对网络 IO 支持不好
  • 性能一般,不如 epoll

所以很多系统用 epoll + 线程池来模拟异步 IO 的效果:

  • Node.js:用 libuv(基于 epoll)实现异步 IO
  • Nginx:用 epoll + 事件驱动实现高并发

为什么 Redis 不用异步 IO?

  1. Linux AIO 不完善:对网络 IO 支持不好
  2. epoll 已经足够好:一个线程可以处理数万个连接
  3. 复杂度 vs 收益:异步 IO 带来的提升有限,但复杂度大大增加
  4. 跨平台兼容性:Windows 和 Linux 的 AIO 实现不同

就像餐厅有智能平板就够用了,不需要完美的代购服务(而且这个服务还不完善)。

异步IO模型


五种模型对比:一张表格看懂所有

让我们用一个表格来总结五种 IO 模型的特点:

模型 生活类比 等待阶段 数据复制阶段 性能 复杂度 实际应用
阻塞 IO 银行排队 阻塞等待 阻塞等待 简单 简单场景
非阻塞 IO 快餐店呼叫器 不阻塞(但忙等) 阻塞等待 中等 很少用
IO 多路复用 智能服务员 不阻塞(事件通知) 阻塞等待 中等 Redis、Nginx
信号驱动 IO 电影院广播 不阻塞(信号通知) 阻塞等待 复杂 很少用
异步 IO 代购服务 不阻塞 不阻塞 复杂 Windows IOCP、Node.js(模拟)

关键理解

  1. 前四种模型,数据复制阶段都要阻塞:只有异步 IO 是完全非阻塞的
  2. 但 Linux 的异步 IO 不完善:所以 Redis 选择 epoll
  3. epoll 是性价比最高的选择:性能好、复杂度适中、跨平台

Redis 的选择:为什么是 epoll?

生活场景:Redis 就像一家"极致效率"的餐厅

Redis 的设计哲学是:简单、高效、可靠。epoll 完美契合这个理念。

为什么选择 epoll?

1️⃣ 性能卓越 (O(1))

epoll 是事件驱动的,只处理’活跃’连接

关键点:与 select/poll 不同,epoll 的性能不会随着连接总数的增加而线性下降,只与“活跃连接数”有关。
2️⃣ 实现简单且高效

无需复杂的异步回调地狱,逻辑线性清晰

相比于复杂的 AIO(异步 IO),I/O 多路复用配合非阻塞 IO,编程模型更符合人类直觉,易于排查问题。
3️⃣ 灵活的事件库 (ae.c)

Redis 封装了简单的事件驱动库 ae.c

屏蔽差异:底层自动选择最优方案:Linux 用 epoll,macOS/BSD 用 kqueue,老旧系统降级用 select
4️⃣ 天作之合:单线程模型

避免了多线程的上下文切换和锁竞争

0 锁消耗:因为是单线程,不需要考虑各种锁(Mutex/Spinlock),CPU 利用率极高。

为什么不选异步 IO?

虽然异步 IO 理论上是最完美的,但:

  1. Linux AIO 不完善:对网络 IO 支持不好,只支持磁盘 IO
  2. epoll 已经足够好:一个线程处理数万连接,性能已经足够
  3. 复杂度 vs 收益:异步 IO 带来的提升有限,但复杂度大大增加
  4. 单线程模型的优势:Redis 的单线程模型已经避免了锁的复杂性,异步 IO 的复杂度会破坏这个优势

就像餐厅有智能平板就够用了,不需要完美的代购服务(而且这个服务还不完善)。


Redis 6.0+ 多线程:增加"传菜员"

生活场景:餐厅增加了"传菜员"

Redis 6.0 之前,一切都是单线程:

  • 一个服务员(主线程)负责所有工作:接收订单、传菜、做菜、上菜

Redis 6.0 之后,引入了多线程 IO:

  • 主厨(主线程):还是一个人,负责"做菜"(执行 Redis 命令)
  • 传菜员(IO 线程):多人负责"传菜"(网络 IO、解析命令)

为什么这样设计?

做菜很快(命令执行)

  • Redis 命令执行是内存操作,非常快(微秒级别)
  • 如果多线程执行命令,需要加锁,反而变慢
  • 就像多个厨师做一道菜,反而会互相干扰

传菜很慢(网络 IO)

  • 网络 IO 和解析命令比较慢(毫秒级别)
  • 多线程可以并发处理,提升效率
  • 就像多个传菜员可以同时传多道菜

多线程处理什么?

Redis 6.0+ 的多线程主要处理:

  1. 网络读取:从网络读取客户端发送的数据
  2. 命令解析:解析 Redis 命令协议
  3. 命令执行仍然是单线程(主线程执行)

工作流程

1️⃣ 客户端发起请求

Socket 数据包进入内核缓冲区

2️⃣ I/O 线程:并发读取

主线程分配 Socket 任务,多线程并行从网卡读取数据

突破点:打破了单线程在处理大包或海量小包时的网络 I/O 吞吐瓶颈。
3️⃣ I/O 线程:协议解析

将原始二进制流解析为 Redis 指令 (RESP 协议)

机制:主线程此时处于“忙等待”状态,确保所有指令解析完成后进入下一步。
4️⃣ 主线程:逻辑执行

核心步骤:单线程串行执行,确保绝对原子性

精髓Redis 依然是单线程执行命令! 无需引入复杂的分布式锁或竞态处理,维持了极简且高性能的内存操作。
5️⃣ I/O 线程:并发回写

IO 线程将计算结果并行写回 Socket 缓冲区

卸载开销:将耗时的系统调用(write/send)从主线程剥离,主线程可以立即处理下一批请求。
6️⃣ 响应完成

客户端接收结果,整个请求周期闭环

性能提升

在高并发场景下,IO 不再是瓶颈:

  • 原来:主线程要等待网络 IO 完成
  • 现在:IO 线程并发处理,主线程可以更快地执行命令
  • 就像餐厅有了传菜员,主厨可以专心做菜,效率更高

但要注意

  • 命令执行仍然是单线程,保证了 Redis 的简单性和可靠性
  • 多线程只在 IO 层面,避免了锁的复杂性

总结:从"排队等餐"到"智能管家"

IO 模型演进历程

1️⃣ 阻塞 I/O (BIO)

像传统的银行柜台:一个窗口只能服务一个客户

痛点:高并发下需要开启大量线程,内存开销巨大,系统调度压力极高,基本不可用。
2️⃣ 非阻塞 I/O (NIO)

不用死等,但要反复询问:‘好了吗?好了吗?’

痛点:应用层需要不断进行系统调用(忙轮询),极度消耗 CPU 资源,且数据拷贝过程依然阻塞。
3️⃣ I/O 多路复用 (epoll)

Redis 的核心:一个’总管’监听所有连接

优势:事件驱动机制。只有当 Socket 真正有数据可读写时才激活。这是 Redis 单线程也能支撑 10W+ QPS 的物理基石。
4️⃣ 信号驱动 I/O

内核通知,但处理逻辑过于碎片化

现状:由于信号处理函数的复杂性和在 TCP 协议栈下的局限性,在主流高性能数据库中极少被采用。
5️⃣ 异步 I/O (AIO)

理想的’全托管’模式,但 Linux 生态尚在追赶

现实:最完美的理论模型(数据拷完再通知)。但由于 Linux 原生网络 AIO (io_uring) 普及较晚,目前大多数方案仍首选成熟的 epoll。

Redis 的选择

  • 网络模型:epoll(IO 多路复用)
  • 命令执行:单线程(简单、可靠、性能好)
  • IO 处理:多线程(Redis 6.0+,提升 IO 性能)

核心优势

  1. 简单:单线程执行命令,避免了锁的复杂性
  2. 高效:epoll + 单线程,性能达到极致
  3. 可靠:单线程模型,避免了多线程的并发问题

未来展望

Redis 可能会继续优化网络模型,但核心设计理念不会变:

  • 单线程执行命令:保证简单性和可靠性
  • epoll 网络模型:保证高性能
  • 多线程 IO:在需要时提升 IO 性能

参考资料


希望通过这篇文章,你能理解 Redis 网络模型的演进过程,以及为什么 Redis 选择 epoll 作为网络模型。记住:简单、高效、可靠,这就是 Redis 的设计哲学。