最近在整理本地笔记时,翻到一篇标注着“2026.3.13”的旧文档,标题就写着“Redis的网络模型”。说来也怪,Redis这几年热度一直没下去,网上“redis下载”“redis安装”的教程一抓一大把,可真把网络模型讲透的很少。大多数人的认知停留在“单线程 + epoll,所以快”这个层面,但这八个字背后的设计逻辑、演进过程和生产中的调优手段,才是真正拉开差距的地方。
这篇文章就把我这些年看源码、做压测、排查线上连接问题攒下来的经验整理出来,从事件循环的底层原理,到Redis 6.0引入的多线程I/O,再到生产环境里那些要命的网络参数配置,一次讲清楚。不管你是刚接触Redis的新手,还是已经被线上连接问题折磨过几轮的运维/后端,我觉得都应该能从中找到点有用的东西。
1. 先说结论:Redis网络模型到底在解决什么问题
1.1 为什么人人都说Redis快
很多新手第一次接触Redis时,最大的疑问是:数据都存在内存里,性能快不是应该的吗?跟网络模型有什么关系?但这里有个很容易被忽略的事实——内存快只代表数据访问快,如果网络层处理不好,再快的内存也白搭。你可以把Redis想象成一家超跑租赁行,车都是好车(数据在内存),但如果门店只有一个接待员(单线程),一次只能接待一位客人(一个连接),高峰期门口排几百米长队,跑车再快也没用。Redis的网络层,就是那套接待流程的设计。
官方文档里有一句话信息量极大:Redis是单线程的,但它使用了I/O多路复用技术。单线程意味着没有锁竞争、没有线程切换开销;I/O多路复用意味着一个线程能同时照看成千上万个客户端连接。这两个特性叠加在一起,才让Redis在网络IO密集的场景下依然保持极高的吞吐。我见过不少把Redis配置调得乱七八糟、却依然能扛住几万QPS的例子,靠的就是这套底子够硬。
1.2 网络层是Redis性能的第一道关口
这里多说一句,很多人搜“redis数据类型”时常看到官方文档里列出的String、List、Hash、Set、ZSet五种基本类型,感觉网络模型和数据类型八竿子打不着。但实际生产里,网络层的问题往往就暴露在“某个key数据太大”上。比如一个Hash里塞了几十万个字段,一次HGETALL要把十几MB数据推给客户端,哪怕Redis处理命令只花了0.2毫秒,网络传输却可能要耗费几十毫秒,这期间后续所有连接都会被这个慢操作拖住。这也是我后来调优网络模型时反复踩到的一个坑。
所以真正理解Redis网络模型,不能只停留在“单线程 + epoll”这个结论上。它涉及连接建立、数据读取、命令解析、命令执行、结果写回这一整条链路,每一个环节都可能成为瓶颈,也都藏着对应的配置项去调节。
2. 核心设计:单线程事件循环凭什么扛高并发
2.1 从“一个连接一个线程”到“一个进程管所有连接”
早年写网络服务,最常见方案是来一个连接就分配一个线程,Apache早期的prefork模式就是这种思路。逻辑简单,坏处也明显——一旦连接数涨到几千上万,线程创建销毁的开销、上下文切换的代价就能把CPU吃光,更别说很多连接只是挂着没发数据,纯粹占着茅坑不拉屎。
Redis设计者antirez选了另一条路:用一个主线程跑一个事件循环,所有连接都注册到循环上,哪个连接有数据可读、可写,事件循环就处理哪个。这个模型就像班主任一个人盯全班自习,谁举手就点谁,没举手的学生安静坐着。虽然并发连接数可能上万,但真正时刻活跃的永远是少数,单线程完全忙得过来。
2.2 I/O多路复用:select / poll / epoll 的取舍
单线程要同时管理那么多连接,靠的是操作系统提供的I/O多路复用能力。Linux下主要有select、poll、epoll三兄弟,Redis在编译时会自动选择当前平台最高效的那个,通常就是epoll。
三者的差别用一张表就能看明白:
| 机制 | 最大连接数 | 效率 | 底层实现 |
|---|---|---|---|
| select | 通常1024 | fd集合全量拷贝,O(n)扫描 | 数组+遍历 |
| poll | 无上限(受内存约束) | 依然O(n)扫描 | 链表+遍历 |
| epoll | 受系统内存和fd限制 | 只返回就绪事件,接近O(1) | 红黑树+回调+mmap |
select最老,FD_SETSIZE限制了文件描述符数量,1024个基本到顶;poll把上限放开了,但每次调用还是要全量拷贝fd集合,连接一多开销就上来了。epoll则完全换了个思路,在内核里用红黑树维护监听集合,通过回调机制只通知真正就绪的事件,应用程序不用反复遍历所有连接。Redis在源码里有ae_select.c、ae_epoll.c、ae_kqueue.c这些封装,MacOS下会用kqueue,Windows下则用WSAPoll之类的,但核心逻辑一致。
2.3 事件循环的内部运转方式
理解了epoll还不够,还要看Redis怎么用它。Redis服务端启动后,核心是一个aeEventLoop结构体,里面维护了文件事件表和时间事件表。主循环是aeMain函数,里面是个while循环,反复调用aeProcessEvents。
aeProcessEvents的关键流程大致是这样:
- 计算最近一个时间事件还有多久要触发,得到一个阻塞等待的最大毫秒数。
- 调用aeApiPoll,也就是封装好的epoll_wait,等待就绪的文件事件。
- 有事件就绪后,按照优先级处理:先处理文件事件(客户端的读写事件),再处理时间事件(比如serverCron定时任务、过期key清理)。
- 处理完回到第一步,继续循环。
这个流程里要留意的是,文件事件负责处理三类东西:监听socket可读(表示有新连接到了)、客户端socket可读(有请求数据到达)、客户端socket可写(可以往客户端写回数据了)。整个网络IO读取阶段,主线程会把数据从内核缓冲区搬进Redis的输入缓冲区,接着按RESP协议解析命令,再去命令表里找到对应处理函数执行,最后把结果写到输出缓冲区,等待socket可写时发给客户端。
2.4 为什么单线程反而更有优势
很多人听到“单线程”就觉得是性能落后的代名词,这在Redis这里恰恰是反过来的,主要有四个原因:
第一,无锁竞争。多线程模型下,对共享数据结构的访问都要加锁,锁的粒度再细也有开销和串行化。单线程天然不需要锁,实现简单,也不会有死锁问题。
第二,无上下文切换。线程切换涉及寄存器保存、内核态用户态切换、缓存失效,这些成本在高频访问下非常可观。单线程把这些开销直接归零。
第三,CPU缓存友好。数据操作集中在一个线程里,热点数据的局部性更强,L1/L2缓存的命中率高,性能自然好看。
第四,执行模型简单,便于保证原子性。单线程意味着单个命令的执行天然是原子的,不需要额外引入事务锁。举个例子,Lua脚本在Redis里能实现复杂原子操作,正是托了单线程执行模型的福。
当然单线程也有天花板,后文会讲到它被突破的过程。
3. Redis 6.0之后的演进:从单线程事件循环到多线程I/O
3.1 单线程真正的瓶颈出现在网络读写上
Redis社区吵了好几年的“单线程是否够用”话题,最终在Redis 6.0给出了答案。antirez没有推翻单线程执行命令的核心设计,而是把矛头指向了另一个方向——网络I/O的数据读写。
注意区分两个阶段:读事件发生时,要把数据从内核socket缓冲区拷贝到用户态输入缓冲区;写事件发生时,要把输出缓冲区的数据拷贝回内核缓冲区。这两个拷贝过程是真正费时的系统调用和内存拷贝,在高并发下会产生大量用户态/内核态切换。
Redis 6.0的方案是多线程I/O:把网络数据的读写这个脏活、累活拆给一组I/O线程去并行干,但命令解析和命令执行仍然由主线程单线程完成。这就像餐厅里点单、上菜还是由主厨一个人负责,但洗菜、切菜、端盘这些杂活交给了帮厨团队。
3.2 io-threads机制的核心细节
Redis 6.0在redis.conf里增加了两个关键配置:
io-threads 4 io-threads-do-reads yes前者是所有I/O线程的总数(包括主线程),后者控制是否也让I/O线程参与读操作。默认情况下,多线程只会被用于写回结果,读操作仍然在主线程里做,主要是为了减少协议解析和命令执行的复杂度。如果需要开启读取多线程,才把io-threads-do-reads设为yes。
我把协作流程拆开看:
- 主线程继续负责accept新连接,把客户端socket交给事件循环。
- 当有读事件时,主线程按一定策略把需要读取数据的socket分配到各个I/O线程。
- I/O线程并行执行read、把数据拷贝到输入缓冲区。
- 主线程等待所有I/O线程完成读操作后,再依次解析命令、执行命令。
- 写回结果时,主线程同样把有数据要写的客户端分发给I/O线程,由它们并行write。
关键点在于:命令执行依旧串行在主线程上。这意味着Redis引以为傲的无锁数据结构和原子执行特性,一点都没被破坏。多线程I/O带来的收益,主要体现在网络吞吐上,而不是命令处理速度上。
3.3 什么时候才值得开多线程I/O
这不是开了就一定变快。说实话,我见过不少人在4核小机器上盲目把io-threads开到4,结果QPS没提升,延迟反而涨了,因为线程同步也有成本。
我的经验判断大概是这样:
- 4核及以下:不值得开。单线程加上epoll已经能把CPU吃得很满,多线程反而增加锁和同步开销。
- 8核以上、连接数和请求量极大:值得开,线程数建议设为核心数的一半,比如8核开4个I/O线程,16核开8个。
- 命令本身是性能瓶颈的情况,比如大量执行复杂操作或Lua脚本,开多线程几乎没用,调整的是数据模型或命令本身。
判断是否该开,不要靠猜,直接用redis-benchmark做一轮对比压测,开前开后各跑一次,看p99和QPS的变化再决定。这个习惯可以帮你省下很多不必要的折腾。
4. 网络参数调优:从默认配置到生产实操
4.1 redis.conf里那些值得手工改的网络参数
安装Redis本身很简单,不管是编译安装还是包管理器安装,跑起来都很容易,难的是装完之后的调优。很多人的Redis就是默认配置裸奔在公网上,存在一堆隐患。我挑几个网络模型相关的关键参数说。
首先是bind和port。默认bind 127.0.0.1 -::1,只允许本机访问,这在服务器上生产环境肯定要改,但改了之后必须用防火墙限制来源IP,别把6379直接暴露到公网。其次是tcp-backlog,默认511,这个值表示TCP的accept队列长度,它要和Linux内核参数net.core.somaxconn配合才能生效,否则填再大也没用。
然后是timeout,默认0表示连接空闲不休,如果要防呆连接占用资源,可以设成300(秒)。还有tcp-keepalive,默认300,是Redis主动对空闲连接发探测包的时间间隔,别跟操作系统的keepalive混淆。最后是maxclients,默认10000,这是最大连接数上限,超过后Redis会直接拒绝新连接,连接数接近上限时监控要报警。
还有一类容易被忽略的是输出缓冲区限制,都在client-output-buffer-limit配置里,分normal客户端、replica从库、pubsub订阅三类,默认值对普通请求来说够用,但要特别留意大key场景。我之前遇到过一次事故,一个业务逻辑把2MB的列表一次性推到客户端,结果把输出缓冲撑爆,Redis直接断开了那个连接,客户端还一脸懵。
4.2 内核参数配合调整
Redis网络模型部署在Linux上,只调Redis配置不管内核,就像换了赛车轮胎却不调悬挂,效果有限。建议重点看这几个:
net.core.somaxconn:翻到1024或更高,配合tcp-backlog。net.ipv4.tcp_max_syn_backlog:增大半连接队列,应对突发SYN泛洪和大量新建连接。net.ipv4.tcp_tw_reuse=1:让TIME_WAIT状态的socket快速复用,高并发短连接场景下能降低端口占用。net.ipv4.tcp_fin_timeout:适当调小,比如15,加速TIME_WAIT回收。fs.file-max和ulimit -n:连接数上来时,文件描述符不够会直接报“Can't accept a client connection: Too many open files”。
这些参数的修改一般写到/etc/sysctl.conf里,用sysctl -p生效。说实话,每次排查连接峰值问题,十有八九都是这几个内核参数顶到了上限。
4.3 用redis-benchmark把网络模型跑个透
调优不能靠感觉,压测数据说话。Redis自带的redis-benchmark是个好东西,基本用法是这样:
redis-benchmark -h 127.0.0.1 -p 6379 -t set,get -c 200 -n 1000000这里的-c是并发连接数,-n是总请求数。压测时最容易犯的错是让客户端和Redis共用同一台机器,或者客户端单线程成了瓶颈,导致压测结果反映不出Redis的真实能力。正确做法是客户端单独准备一台机器,或者用多实例、多线程压测工具,比如memtier_benchmark。压测过程中可以开一个窗口定时执行redis-cli info stats,看instantaneous_ops_per_sec、connected_clients、rejected_connections这些指标的变化。
压测的目的不是刷一个漂亮数字,而是找到当前瓶颈在CPU、网络、内核参数还是客户端,这一点比任何花哨的压测报告都重要。
5. 常见问题与排查技巧实录
5.1 连接数上去了,QPS却上不去
这是被问得最多的问题。表象是connected_clients涨到几千,但instantaneous_ops_per_sec始终上不去,应用侧RT却在飙升。
按我的排查顺序来:
- 先看是不是有慢操作阻塞了事件循环。执行
SLOWLOG GET 100,加上redis-cli --bigkeys扫一遍,看有没有大key,尤其是O(N)命令读取超大集合,这种命令会让后面所有请求排队等。 - 再看内核参数。检查
net.core.somaxconn、maxclients、ulimit -n,看日志里有没有“Too many open files”或者“accept: connection reset by peer”这类报错。 - 然后看客户端侧。客户端是不是没用连接池?每次请求新建连接?TIME_WAIT状态多不多?这类问题在Push推送服务里太常见了,一个客户端连一下断一下的骚操作,能把Redis的accept队列打到爆。
- 最后检查网络带宽。Redis数据量较大时,x每秒十几MB的吞吐,千兆网卡很容易先到瓶颈。这时候从Redis侧看
total_net_output_bytes增长速率,基本能判断。
5.2 延迟毛刺的处理
延迟毛刺最讨厌的地方在于,平均延迟很低,但p99偶尔飙到几百毫秒。我用redis-cli --latency -h 127.0.0.1 -p 6379这个命令长时间跟踪,能看到实时的延迟分布,如果出现明显的周期性毛刺,多数和下面几件事相关。
第一,RDB持久化触发的fork,fork过程中父进程的内存页表复制量很大,会造成毫秒级停顿,尤其在内存几十GB的实例上。解决思路是尽量用子进程做持久化,或者调整RDB保存策略,错开业务高峰。第二,swap。内存紧张时Redis的页可能被换到磁盘,这种延迟毛刺特别大,监控里看INFO memory的used_memory_swap字段是否为非零,不为零说明已经发生swap了。第三,慢日志里抓不到但延迟就是高的情况,多半是网络层面,比如网卡软中断被打满、TCP重传率上升,这时候要用netstat -s看重传统计和丢包情况,光盯Redis内部是找不到原因的。
5.3 开启io-threads后反而更慢
如果你在低配置机器上开了多线程I/O,发现性能不升反降,不要怀疑Redis不行,先检查配置方式。比较大的可能是线程数设置超过了CPU核数,或者把4个I/O线程开在了2核的VM上。我见过一个极端案例,有人在2核云服务器上把io-threads设成8,结果Redis命令执行还是一条线程,但线程间同步开销猛增,延迟直接翻倍。
多线程I/O只有在某些场景下收益明显:大量小命令、海量连接、多核心机器。如果你是一个几千QPS的中小业务,单线程默认配置已经绰绰有余,真的不用为了追新而开。
我个人的经验是:先跑一轮压测,对比开与不开的差异,数据有说服力就开,没有就关,别凭感觉。
最后说点实在的
网络模型这块内容,我在不同阶段反复读过好几遍源码,每次都有新的理解。最开始觉得“单线程+epoll”就是把连接丢给内核监听,懂了;后来自己写了一个小型的epoll服务端,才体会到事件循环里书写的次序、内存拷贝的时机原来处处都是设计;再后来线上排查慢client拖垮整个实例的问题,才真正明白输出缓冲区和网络IO的关系。Redis的网络模型不是一个孤立的主题,它和数据类型、持久化策略、内存规划都交织在一起,把这块吃透,再去理解其他中间件的网络设计,比如Nginx、Netty,都能触类旁通。
如果这篇文章能帮你少走一点弯路,那就很值得了。