
作为每天和 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、kqueueRedis 跨平台的底层选择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)每次全量拷贝epollLinux理论无上限事件回调 O(1)注册时拷贝kqueuemacOS/BSD理论无上限事件回调 O(1)注册时拷贝这里有个容易被忽略的细节Redis 在 epoll 上使用的是**水平触发LT**模式而不是边缘触发ET。也就是说只要 socket 上还有没读完的数据epoll 就会持续通知你。这个选择和 Redis 的事件循环模型是匹配的它不想在应用层绞尽脑汁去保证“每次把数据读完”而是采取一种宁可多次通知也不漏事件的方式可靠性优先。2.3 Redis 对多路复用的二次封装AE 事件模型底层事件机制有了但直接用 epoll 的 API 写业务逻辑会非常痛苦。Redis 在 ae.c 里封装了一套自己的事件模型就叫 AEA Event loop。这套模型和 Netty 的 EventLoop 思路很像核心是两个东西一是文件事件也就是客户端连接上发生的读写事件二是时间事件也就是定时执行的任务比如 serverCron 定期做统计、过期清理等工作。AE 事件循环的主逻辑用一个简单的 while 循环就能说清楚每轮循环先调用 aeApiPoll 等待就绪事件可以设定一个超时时间拿到事件列表后一个个执行对应的事件处理器。如果是时间事件快到点了就把阻塞时间缩短保证时间事件也能被及时处理。这套模型最妙的地方在于文件事件和时间事件是在同一个线程里被串行处理的所以天然没有并发问题也不用加锁。对于业务开发来说大多数人一辈子都不会直接碰 AE 模型但理解这个封装对后面理解 Redis 为什么“单线程还这么快”很有帮助。因为所有客户端的读写请求、定时任务、后台刷盘任务的状态管理全部被归一到了一个事件循环里不依赖锁就没有锁竞争不依赖多线程就没有上下文切换。这两个优势在连接数高、命令短小的场景下价值非常大。3. 拆解 Redis 的 Reactor 实现一条请求如何被处理3.1 从客户端发命令到收到响应一共经历了几步有了 AE 事件模型的地基我们来看一条命令在 Redis 网络模型里到底是怎么走完一生的。为了直观你可以脑补这个场景你用 redis-cli 执行一条SET foo barRedis 内部至少经历了这么多步。第一步客户端发起 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如果你把它设为 4Redis 就会启动 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-threads1时 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、复制这些特性本身没有冲突官方在实现时做了兼容。但如果你在小型容器里跑 RedisCPU 配额本来就紧张却盲目设置了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 的网络模型设计得很优雅但再优雅的模型也救不了一条烂查询这句话与各位共勉。