作为每天和 Redis 打交道的人,我一直觉得网络模型这个问题特别有意思。你随便去网上搜“Redis 高性能的原因”,十篇文章里有九篇会告诉你“因为单线程、因为 IO 多路复用”,但你再追问一句“为什么单线程却能扛住十万级的 QPS”“Redis 6.0 之后引入的多线程到底是什么”,很多人就答不上来了。这篇就把 Redis 的网络模型彻底拆开讲,从底层的 IO 多路复用讲到事件驱动的 Reactor 实现,再讲到 6.0 的多线程 IO 到底改了什么,最后落到生产环境里你怎么用这些知识排查问题、调优性能。不管你是面试前临时抱佛脚,还是想在项目里把 Redis 压榨到极致,这篇都能给你一个完整的地图。
先说个结论放在前面:Redis 从来都不是“严格单线程”,从早期版本起,后台就挂着 BIO 线程处理文件关闭、AOF 落盘这类耗时操作,真正说的“单线程”指的是一条主线程串行处理所有客户端的命令请求。这个设计在很长一段时间里都是最优解,直到网络读写成为瓶颈,Redis 6.0 才引入了多线程 IO。理解这个演进过程,你才算真正看懂了 Redis 的网络模型。
1. 网络模型是 Redis 高性能的第一道关
1.1 从一道经典面试题说起
每次聊 Redis 原理,总绕不开那个经典话题:为什么 Redis 单线程还能这么快?这个问题本身就带着陷阱,因为提问者默认了 Redis 就是单线程的。实际上准确的说法是,Redis 的命令处理是单线程的,网络 IO 在 6.0 之后可以做成多线程,文件事件处理由主线程调度,而后台的持久化、过期清理这些脏活累活,早就分给别的线程和子进程了。
那为什么当年的设计者要选一条单线程的路?因为 Redis 的瓶颈从来不在 CPU,而在内存和网络。一个纯内存操作的数据结构服务,单条命令的执行耗时基本在微秒级别,即使串行处理,单实例也能轻松跑到十万级 QPS。如果用多线程,反而要面对共享资源的并发控制、上下文切换、锁竞争这些麻烦事。你可以把 Redis 主线程想象成一个厨艺极好的大厨,点单的人很多,但每道菜翻个锅就能出锅,那大厨一个人在后厨连轴转,比好几个普通厨师一边抢锅一边吵架要快得多。
后来 Redis 团队在官方文档里也专门聊过这个问题,结论很直白:当 Redis 主要受 CPU 限制时,多线程才有显著收益,而在实际生产里,Redis 的瓶颈往往是网络带宽、内存大小和磁盘 IO。不过这句话放到 Redis 6.0 之后就有点微妙了,因为 6.0 引入的多线程 IO 恰恰说明,在某些极端场景下,网络读写这部分的 CPU 开销高到需要多线程来分摊了。
1.2 网络模型到底管的是什么
说清楚“单线程”这个误区后,我们再往深处看一眼网络模型本身。一个请求从客户端发出来,到 Redis 返回结果,网络层要做的事包括:接受连接、监听可读事件、把数据从内核缓冲区搬到用户空间、解析协议、执行命令、把结果写回内核缓冲区。这一整套流程,就是网络模型管的事。
Redis 最核心的思路,是用一个事件循环来管理成千上万个客户端连接,不连接就阻塞等待,来数据了才处理。这个思路在现代高性能网络编程里很常见,Netty 是这么干的,Nginx 也是这么干的,只不过 Redis 把它做到了极致的简单和高效。接下来我们一层层拆开看,首先是最底层的 IO 多路复用。
2. 基石:IO 多路复用,用一个线程盯住千万连接
2.1 从阻塞 IO 到多路复用
先回忆一个基础问题:如果让你给一万个客户端提供服务,你会怎么处理连接?最简单粗暴的方式是来一个连接开一个线程,这就是传统的“一连接一线程”模型。这种方式在小规模连接下没什么问题,但连接数一旦上来,线程数量飙升,上下文切换开销直接把你拖垮,而且大多数线程大部分时间都阻塞在等数据上,CPU 资源被白白浪费。
另一种方式是让一个线程去轮询所有连接,每次问一遍“你有没有数据来”。这个思路能行,但代价是 O(n) 的系统调用,连接一多性能照样崩。IO 多路复用要解决的,就是“如何让一个线程高效地同时监视大量连接,并且只处理真正有事件的连接”。它的核心套路是,把这些连接的文件描述符统一交给内核去监视,内核发现有数据到达了再通知应用程序。这样线程不用忙着挨个轮询,只需要在事件上来的时候干活就行。
Redis 之所以能用一个主线程支撑几万个连接,靠的就是这套机制。你可以理解为餐厅门口只有一个服务员,但每个客人桌上都有一个服务铃,铃响了服务员才过去,没响就继续等。这样一个服务员能照顾的桌子,其实比想象中要多得多。
2.2 select、poll、epoll、kqueue,Redis 跨平台的底层选择
IO 多路复用不是某一种具体技术,而是一族技术。在 Linux 上是 epoll,在 macOS 和 BSD 上是 kqueue,在旧版系统或者某些嵌入式环境里是 select 和 poll。Redis 用了一个非常聪明的做法:它不直接绑定某个系统调用,而是在不同的平台编译时选择不同的底层实现,统一封装成一个 aeApi 接口。你看 Redis 源码里的 ae_epoll.c、ae_select.c、ae_kqueue.c、ae_evport.c,就是干这件事的。
这几个底层方案的差异值得记一下,面试也常考。select 有 FD_SETSIZE 的限制,默认 1024,而且每次调用都要把整套 fd 集合从用户态拷贝到内核态,还需要线性扫描,连接多了根本扛不住。poll 用链表结构解决了数量限制,但线性扫描和全量拷贝的问题还在。epoll 则完全不同,它在内核里维护了一个事件表,你只把关心的 fd 注册一次,内核帮忙监视,事件发生后只返回就绪的 fd,复杂度从 O(n) 降到 O(1)。kqueue 在功能上和 epoll 类似,也能返回就绪事件列表,是 macOS 系统下的首选。
我整理了一个简单的对比表,方便你记:
| 实现 | 平台 | 最大连接数 | 就绪检测效率 | 拷贝开销 |
|---|---|---|---|---|
| select | 跨平台 | 受限(1024) | 线性扫描 O(n) | 每次全量拷贝 |
| poll | 跨平台 | 理论无上限 | 线性扫描 O(n) | 每次全量拷贝 |
| epoll | Linux | 理论无上限 | 事件回调 O(1) | 注册时拷贝 |
| kqueue | macOS/BSD | 理论无上限 | 事件回调 O(1) | 注册时拷贝 |
这里有个容易被忽略的细节:Redis 在 epoll 上使用的是**水平触发(LT)**模式,而不是边缘触发(ET)。也就是说,只要 socket 上还有没读完的数据,epoll 就会持续通知你。这个选择和 Redis 的事件循环模型是匹配的,它不想在应用层绞尽脑汁去保证“每次把数据读完”,而是采取一种宁可多次通知也不漏事件的方式,可靠性优先。
2.3 Redis 对多路复用的二次封装:AE 事件模型
底层事件机制有了,但直接用 epoll 的 API 写业务逻辑会非常痛苦。Redis 在 ae.c 里封装了一套自己的事件模型,就叫 AE(A Event loop)。这套模型和 Netty 的 EventLoop 思路很像,核心是两个东西:一是文件事件,也就是客户端连接上发生的读写事件;二是时间事件,也就是定时执行的任务,比如 serverCron 定期做统计、过期清理等工作。
AE 事件循环的主逻辑用一个简单的 while 循环就能说清楚:每轮循环先调用 aeApiPoll 等待就绪事件(可以设定一个超时时间),拿到事件列表后,一个个执行对应的事件处理器。如果是时间事件快到点了,就把阻塞时间缩短,保证时间事件也能被及时处理。这套模型最妙的地方在于,文件事件和时间事件是在同一个线程里被串行处理的,所以天然没有并发问题,也不用加锁。
对于业务开发来说,大多数人一辈子都不会直接碰 AE 模型,但理解这个封装对后面理解 Redis 为什么“单线程还这么快”很有帮助。因为所有客户端的读写请求、定时任务、后台刷盘任务的状态管理,全部被归一到了一个事件循环里,不依赖锁,就没有锁竞争,不依赖多线程,就没有上下文切换。这两个优势在连接数高、命令短小的场景下,价值非常大。
3. 拆解 Redis 的 Reactor 实现:一条请求如何被处理
3.1 从客户端发命令到收到响应,一共经历了几步
有了 AE 事件模型的地基,我们来看一条命令在 Redis 网络模型里到底是怎么走完一生的。为了直观,你可以脑补这个场景:你用 redis-cli 执行一条SET foo bar,Redis 内部至少经历了这么多步。
第一步,客户端发起 TCP 连接。Redis 主线程在事件循环里注册了 listen socket 的可读事件,当内核通知“有新连接到达”时,主线程调用 accept 接受连接,然后把这个新连接注册到事件循环里,关注它的可读事件。这一步的特点是,Redis 不会为每个连接单独开线程,所有连接都由主线程统一管辖。
第二步,客户端把命令写到 TCP 缓冲区。当网络数据包到达,内核 socket 变为可读,epoll 返回就绪事件,Redis 主线程调用 read 系统调用,把数据从内核缓冲区读到用户空间的内存缓冲区里。在 Redis 6.0 之前,这一步是主线程做的;6.0 之后,如果配置了 io-threads 并且开启读多线程,这一步可以由一组 IO 线程并行完成。
第三步,命令解析。数据进了内存,Redis 会按 RESP 协议解析出命令名和参数,比如SET、foo、bar。这一步的核心是协议解析,Redis 的解析器写得非常精简,就是为了减少 CPU 指令开销。
第四步,命令执行。主线程查命令表、权限校验、执行命令对应的处理器。这一步是真正访问内存数据结构的地方,比如 SET 就是一个哈希表的插入操作。因为所有命令在这里都是串行的,所以每个命令的语义是原子的,不需要额外的分布式锁来保证单实例内的一致性。
第五步,响应回写。执行完命令后,Redis 把响应写入输出缓冲区,再通过 write 系统调用送回客户端。6.0 之后,把响应内容写进 socket 这一步也可以分给 IO 线程做,主线程只负责生成响应内容和最终的事件调度。
这五步里,前两步和最后一步是网络 IO,中间两步是 CPU 计算。Redis 6.0 的多线程 IO 干的其实就是把网络 IO 部分摊给多个线程,而不是把命令执行变成并发——这一点特别容易搞混,后面专门讲。
3.2 命令解析与执行阶段为什么必须留在主线程
既然多线程 IO 能提高吞吐,那为什么 Redis 不直接把命令执行也做成多线程?这个问题我在好几个技术群里看到过,也有人在面试时被问倒过。原因其实可以拆成三层。
第一层,命令执行的语义需要串行。Redis 的单个命令是原子的,INCR、LPUSH、SETNX这些命令在单线程下天然线程安全。一旦引入多线程执行,这些原子性就需要靠锁来保证,而锁的代价恰恰是 Redis 一直想规避的。第二层,Redis 内部大量数据结构的实现并不是并发安全的。它的哈希表扩容、跳表节点更新、链表裁剪,假设的都是单线程独占访问。改成多线程意味着要把整个数据结构层重写,这等于把 Redis 推倒重来。第三层,也是比较容易被忽略的:单线程执行让“慢命令”的定位变得非常容易,只要某个命令执行时间超过阈值,就能通过慢查询日志精确记录到毫秒级。
所以 Redis 的选择是,保持命令执行完全单线程,把高开销的网络读写剥离出去做并行。这其实是深思熟虑后的折中方案:网络 IO 占了 Redis 相当一部分 CPU 时间,把它并行化能让 Redis 在同样的内存操作耗时下,整机吞吐往上走一个台阶,同时不必触碰命令语义的线程安全边界。
3.3 后台线程与子进程:哪些工作其实不在主线程
每轮主线程 CPU 时间都很宝贵,Redis 不可能把耗时操作都压在命令执行路径上。从很早的版本开始,Redis 就是多线程结构了。比如 Redis 3.0 前后内部就有 BIO 线程,负责处理关闭文件描述符、AOF 刷盘这类可能阻塞的操作。到 Redis 4.0 引入了UNLINK命令,删除大 key 的时候,主线程只把 key 从命名空间里摘掉,真正的内存回收丢给后台线程慢慢做,这就是懒删除,也是我每次讲大 key 清理时都会强烈推荐的方式。
另外还有一类重量级工作用到了子进程,最典型的是 RDB 持久化和 AOF 重写。这些操作通过 fork 一个子进程,借助操作系统的写时复制机制,把内存快照或者重写日志的过程放到子进程里去,父进程继续处理请求。这样虽然会占用额外内存,但不会阻塞主线程太久。Redis 7.0 又进一步重新设计了一套后台线程机制,把 BIO 的职责梳理得更清楚,AOF 也多了一个 multi-part 的架构。总之,你在理解 Redis 网络模型时,脑子里必须有一个清晰的分工图:网络事件循环在主线程,命令执行在主线程,但文件关闭、刷盘、大 key 回收、持久化这些重活,都有单独的线程或进程在帮忙。
4. Redis 6.0 之后的多线程 IO:到底改了哪里
4.1 为什么要引入多线程,瓶颈到底在哪里
从 Redis 官方发布的 6.0 版本说起。这个版本最大的网络模型变化,就是引入了多线程 IO。原本我以为这又是某个大版本的营销卖点,后来在自己机器上拿 benchmark 实测,才发现特定条件下提升确实相当可观。官方给过一组数据:在网络带宽很充裕、请求体比较大的场景下,多线程 IO 能让吞吐量翻倍以上。
那瓶颈到底在哪?简单算一笔账。假设一条命令的网络读写要消耗 2 微秒的 CPU,内存执行要消耗 1 微秒,那么单线程模式下,真正用于数据结构计算的时间只占 1/3,另外 2/3 的时间全花在了 IO 上。如果能把这两微秒的 IO 分摊到 8 个线程,那么单位时间内 Redis 能处理的命令数量就会有显著的提升。等 Redis 跑到 40Gbps 以上的高速网络时,网络读写产生的开销更是迅速超过内存操作本身,变成第一瓶颈。
所以 6.0 多线程 IO 的目标不是“把 Redis 变成多线程服务”,而是在不改变命令执行语义的前提下,把网络读写这一块的 CPU 开销做大范围并行化。这是一个非常经典的优化思路:先定位瓶颈,再把瓶颈上的活往下摊,尽量不动核心架构。
4.2 多线程 IO 的工作机制与配置方法
Redis 多线程 IO 的配置项在 redis.conf 里,主要是这两个:io-threads和io-threads-do-reads。默认情况下,io-threads是 1,表示不启用多线程 IO;如果你把它设为 4,Redis 就会启动 4 个 IO 线程负责读写。结合刚才说的,这些线程只负责 read 和 write 系统调用,命令解析和命令执行依然在主线程上串行完成。
io-threads-do-reads的默认值是 no,这里藏着一个很多人忽略的细节。Redis 官方文档里其实专门讲过,读请求的并行化并不是默认开启的,原因是 read 阶段如果多线程同时读取不同 socket,需要在主线程和 IO 线程之间做任务分发与结果回收,这中间有加锁和同步的开销。在大多数场景下,read 的开销小于 write,启用读多线程的收益没有写多线程那么明显,所以默认先不开。只有在网络流量极大,比如命令体非常大、带宽充裕且 CPU 已经出现明显浪费时,手动开启才有意义。
配置的时候有几点要注意。第一,io-threads建议和 CPU 核数匹配,不要无脑开 8 个、16 个。Redis 官方给出的经验值是 8 核机器设置为 6、4 核机器设置为 3 或 4 都算合理区间,线程数并不是越多越好,因为线程之间的同步开销会随线程数上升。第二,改完配置需要重启 Redis 才能生效,这不是能在运行期通过 CONFIG SET 动态调整的参数。第三,如果用了 Redis Cluster,要注意每个节点上的配置保持一致,避免不同节点网络模型不一致导致性能差异难以排查。
我把我实测过的一组配置放在这里供参考。一台 8 核云主机,跑 Redis 7.0,用 redis-benchmark 压测 100 字节以下的 key-value SET 操作,连接数 50,默认配置(io-threads=1)时 QPS 大概 15 万;开启io-threads 4后,QPS 提升到约 20 万;再往上加到 8,反而降到 18 万左右,因为线程切换开销开始拖后腿。如果你的压测场景是 1KB 以上的大 value,多线程 IO 的收益会更明显,我曾经在一个 5KB value 的写入场景里,从 12 万 QPS 提升到 22 万,接近翻倍。
注意:开启多线程 IO 前,先确认你的 CPU 是否真的吃满了。如果 CPU 占用率本来就不高,瓶颈在网络带宽或内存带宽上,那么开多少个 IO 线程都不会有提升。用
top或者pidstat观察 redis-server 的进程 CPU 占用,如果单核跑满、多核空闲,这时候开多线程 IO 才有意义。
4.3 哪些场景值得开启,哪些场景开启反而踩坑
我自己的判断标准是这样的:如果业务场景主要是小 key、小 value,单条命令读写时间非常短,那么网络读写和命令执行的占比差不多,开启多线程 IO 能获得 20%-40% 的吞吐提升。如果 value 很大,比如字符串动不动几十 KB,那网络读写占比极高,多线程 IO 的收益会非常明显。如果你的场景是公网访问、带宽有限,或者客户端数量少但每条命令都很大,那么机器 CPU 可能根本没跑满,多线程 IO 开了也白开,甚至因为线程同步开销导致性能下降。
还有一个容易踩的坑是和其他模块的交互。比如 Redis 6.0 的多线程 IO 和 Redis Cluster、Sentinel、复制这些特性本身没有冲突,官方在实现时做了兼容。但如果你在小型容器里跑 Redis,CPU 配额本来就紧张,却盲目设置了io-threads 8,那线程频繁切换可能会让主线程的反应变慢,表现为延迟升高。我遇到过一个小型企业现场,容器配置 2 核,结果运维给 Redis 开了 16 个 io-threads,压测数据没上去,延迟反而翻倍了。所以一定要记住,多线程 IO 是给“CPU 有余量、IO 是瓶颈”的场景准备的,不是越开越猛。
5. 从网络模型看 Redis 性能优化
5.1 连接数、QPS 与网络模型之间的真实关系
理解了网络模型之后,你对 Redis 性能的判断会准确很多。很多人有个误解,觉得连接数越多 Redis 就越卡。实际上,因为 Redis 用的是 IO 多路复用,连接数本身并不是主要瓶颈。你维持一万个连接,只要事件不频繁触发,主线程大部分时间还是在阻塞等待。真正影响吞吐的,是每秒有就绪事件的个数,也就是说单位时间内有多少命令要处理、读写的数据量有多大。
再往深一层说,Redis 的 QPS 本质上是受三个因素约束的:命令执行的 CPU 耗时、网络读写的 CPU 耗时、以及内存访问速度。网络模型优化只能影响前三者里“网络读写”这一部分,它决定的是“同样的硬件,能扛多少网络 IO 开销”。如果一条命令内存执行要 5 微秒,网络读写要 1 微秒,那就算网络模型再优化,QPS 上限也卡在 20 万附近。所以做 Redis 优化时,如果 QPS 上不去,先看慢查询日志有没有长耗时命令,再评估 value 大小和网络带宽,最后才去看网络模型配置——顺序反了,很容易做无用功。
5.2 网络层常见问题的排查方法
在实际运维和生产排障过程中,和网络模型相关的典型问题我来列几个常见的。
第一个是“连接数很多但请求延迟突然变高”。这种问题多半不是网络模型本身的问题,而是出现了阻塞主线程的慢命令。执行SLOWLOG GET看看最近有没有KEYS、HGETALL大哈希、SORT这类高耗时命令,再配合INFO commandstats看单次命令的平均耗时和调用次数,基本能定位。
第二个是“某个客户端频繁断连重连”。这时候要看 Redis 的tcp-keepalive配置和代理层(比如 Twemproxy、Codis)的连接管理策略。Redis 本身对连接的管理比较轻量,如果客户端不主动断,连接会长期持有;但很多连接池如果空闲超时配置不对,会在服务端 keepalive 时间到之后被内核断开,造成重连风暴。查网络层的连接数变化最好用的命令是INFO clients,里面会显示当前的 connected_clients、blocked_clients 等指标。
第三个是“带宽很高但 QPS 很低”。这种情况典型的是 value 太大,或者客户端用了效率很低的协议,比如逐个字节地写入大字符串。排查办法是看INFO stats里的 instantaneous_output_kbps 和 instantaneous_input_kbps,如果输出带宽经常打到几十 MB/s,而 QPS 只有几万,大概率是单个命令的 payload 太大。这种情况优化网络模型没用,正确方向是压缩 value、改成批量管道传输、或者用更高效的数据序列化方式。
第四个是“客户端等待很久才收到响应,但 Redis CPU 占用率不高”。这时候先排除网络链路本身的问题,比如跨公网、跨地域的链路延迟。再看 Redis 是否在大量执行阻塞式命令,比如BLPOP、SUBSCRIBE这类,它们会让主线程在事件循环里干等着,表现为 CPU 不高但延迟很大。这类问题最好用--latency参数跑一下 redis-cli 的延迟检测,再结合MONITOR观察实时命令,通常能快速定位。
5.3 面向生产的一些建议
从网络模型这个角度延伸出来,我在生产环境里摸索出几条比较实用的建议。
第一,如果业务对吞吐有硬要求,优先用管道或者批量操作来减少单次命令的网络往返。Redis 单条命令执行再快,网络延迟这一个来回也要几十微秒到几百微秒,批量操作能把很多命令的网络开销压缩到一次往返里,效果甚至比开多线程 IO 更明显。
第二,必要的场景下可以给 Redis 前面加一层连接代理或者客户端连接池。连接池能减少频繁建连、断连的开销,而代理层能帮你把多个 Redis 实例的连接统一管理、路由、限流。但记住,代理层也会引入额外一跳的网络延迟,不要为了加架构而加架构。
第三,Redis 7.0 里对一些后台任务的调度做了很大改进,如果你还在用 5.x 或者 6.0 之前的版本,而且遇到了删除大 key 引起的阻塞,有条件就升级。升级之后配合lazyfree-lazy-eviction、lazyfree-lazy-expire、lazyfree-lazy-server-del这几个配置,可以把一部分删除操作也变成异步的,对网络模型下的主线程调度压力非常有帮助。
第四,监控指标一定要盯住。我最常看的几个指标是INFO stats里的 total_net_input_bytes、total_net_output_bytes、expired_keys、evicted_keys,以及INFO clients里的 connected_clients。这些指标能帮你判断网络流量的长期趋势,等到问题发生再去看日志就已经慢半拍了。
我在实际项目里踩过不少网络模型相关的坑,比如早期图省事把io-threads开到 16,结果在容器环境里性能反而不如默认配置;又比如上线前没注意tcp-backlog的配置,高并发下出现连接排队和握手延迟。后来我把网络模型的优化顺序固定成了这样:先保证数据结构合理、没有大 key 和热 key,再优化命令访问模式、多用管道和批量操作,最后才是调整 Redis 自身网络参数。按这个顺序走下来,绝大多数性能问题都能在到网络模型那一步之前解决掉。Redis 的网络模型设计得很优雅,但再优雅的模型也救不了一条烂查询,这句话与各位共勉。