☰
Redis 进阶(一):单线程的 Redis 为什么这么快?epoll、事件循环与一次 10 万 QPS 压测
2026/10/10 6:22:28 网站建设 项目流程

我不会起名字322· 后端 / 算法 / 数据库

📘 技术栈 | 🧩 力扣 Hot100 | 🐹 Go 项目 | 🟥 Redis | 🐬 MySQL

文章目录

  • Redis 进阶(一):单线程的 Redis 为什么这么快?epoll、事件循环与一次 10 万 QPS 压测
    • 一、先把"单线程"说清楚
    • 二、问题的关键:一个线程怎么管住几万个连接
      • 2.1 IO 多路复用解决的就是这件事
    • 三、Redis 的事件循环:aeEventLoop 一轮干了什么
    • 四、单线程到底省掉了什么
    • 五、瓶颈到底在哪:内存、网卡,还是命令本身
    • 六、实测:用 redis-benchmark 看清楚什么在影响吞吐
    • 七、真正值得调的几个参数
    • 八、什么时候单线程会成为问题
    • 小结

Redis 进阶(一):单线程的 Redis 为什么这么快?epoll、事件循环与一次 10 万 QPS 压测

"Redis 是单线程的,所以很快。"这句话几乎每个后端都背过,但它其实经不起追问:单线程为什么反而快?几万个客户端连接同时打过来,一个线程怎么来得及处理?6.0 之后 Redis 又确实加了多线程,那它到底还算不算单线程?

这篇把这几个问题一次拆开:从阻塞 IO 讲到 epoll,从 epoll 讲到 Redis 自己的事件循环,最后用redis-benchmark跑一组数据,看清楚哪些参数真的会改变吞吐、哪些只是心理安慰。

一、先把"单线程"说清楚

严格的措辞是:Redis 的命令执行一直是单线程的,网络 IO 在 6.0 之后可以多线程。

版本命令执行网络读写后台任务
4.0 及以前单线程单线程有 bio 线程处理关闭文件、fsync 等
6.0 起单线程可配置 io-threads同上
7.x单线程可配置 io-threads同上

也就是说,你在redis.conf里配的io-threads 4,负责的是"把客户端的请求从 socket 里读出来、把响应写回 socket"这一段,以及解析协议的部分开销。真正去内存里SET/GET的那一步,永远只有一个线程在跑。

这个设计是有意为之的。Redis 的作者 antirez 反复解释过:多线程能带来的收益主要在网络 IO;而一旦命令执行也多线程化,就要为数据结构引入锁,复杂度会爆炸,收益却未必明显。

二、问题的关键:一个线程怎么管住几万个连接

要理解这一点,得先看清"慢"到底慢在哪。

一次GET key的完整耗时大致是:客户端把请求发到网卡 → 内核协议栈 → Redis 读到请求 → 查内存里的哈希表 → 把结果写回 socket。这里面真正属于 Redis 的"计算"部分,可能只有几百纳秒到几微秒;剩下的时间绝大部分耗在网络等待上—— 等客户端的数据到达、等内核缓冲区腾出空间。

如果采用最朴素的模型,一个线程处理一个连接,代码大概是这样:

// 阻塞式:线程会卡在 read 上,直到对端真的发数据intn=read(fd,buf,sizeof(buf));// 只有 read 返回之后,才轮到后面的逻辑process(buf,n);

这个模型的问题不是"线程开销大",而是当一个连接没数据可读时,整个线程就停在那里干等。一万个连接里可能只有几百个活跃,但你得开一万个线程陪着它们等,上下文切换的代价会把 CPU 吃光。

2.1 IO 多路复用解决的就是这件事

IO 多路复用的思路是:不再让每个线程盯一个连接,而是由一个线程同时盯住所有连接,谁有数据了就处理谁。

演进过程分三代:

机制做法复杂度问题
select每次把 fd 集合拷进内核,内核遍历一遍O(n)fd 数量有上限(通常 1024),每次都要拷贝
poll改成数组,去掉了数量上限O(n)仍然要整体遍历和拷贝
epoll内核维护红黑树 + 就绪链表,只返回就绪的 fdO(1) 级别只在 Linux 上

epoll 的关键改进有两个:一是fd 集合只注册一次,不用每次系统调用都重新拷一遍;二是返回的是"已经就绪的那几个",而不是让你自己把所有 fd 扫一遍。连接数越多、活跃比例越低,这个差异越夸张。

Redis 在 Linux 上默认就用 epoll(源码ae_epoll.c),在 macOS 上退回 kqueue,在 Solaris 上用 evport,最差情况退回 select。这套抽象叫ae(A simple Event-driven programming library),是 Redis 自己封装的事件库。

用 epoll 的伪代码看,主循环长这样:

while(1){// 阻塞等待,直到有 fd 就绪;n 是就绪的个数intn=epoll_wait(epfd,events,MAX_EVENTS,timeout_ms);for(inti=0;i<n;i++){if(events[i].data.fd==listen_fd){accept_new_client();// 新连接}elseif(events[i].events&EPOLLIN){read_and_process_request();// 读请求 + 执行命令 + 写回}elseif(events[i].events&EPOLLOUT){send_reply_to_client();// 把缓冲区里没发完的继续发}}// 顺便处理定时任务:过期 key、后台持久化触发等process_time_events();}

注意这个循环里没有阻塞式的 read。有数据才处理,没数据就在epoll_wait那里等着,CPU 是空闲的。这就是"一个线程扛几万连接"的全部秘密。

三、Redis 的事件循环:aeEventLoop 一轮干了什么

Redis 的服务端主体就是aeMain()里那个死循环,每一轮做三件事:

aeMain() → while (!eventLoop->stop) aeProcessEvents() aeProcessEvents() 一轮: 1. 计算最近的定时任务还有多久触发 → 作为 epoll_wait 的超时时间 2. epoll_wait 等文件事件(新连接 / 可读 / 可写) 3. 先处理文件事件,再处理时间事件

两个细节值得留意:

第一,超时时间不是随便写的。Redis 会先找出最近的定时任务(比如serverCron,默认每 100ms 跑一次),把epoll_wait的超时设成"距离它触发还剩多久"。这样既能保证在有请求时立刻响应,又能保证空闲时定时任务按时跑。

第二,文件事件永远优先于时间事件。也就是说,Redis 宁可让serverCron稍微晚一点执行,也不想让客户端请求排队。定时任务的执行时间会被记录,如果某次跑超时了,日志里会出现serverCron执行耗时相关的警告。

serverCron干的活儿包括:抽样淘汰过期 key、调整哈希表大小、触发后台 RDB/AOF 的fork、更新INFO里的统计信息、检查是否需要做主从重连等等。注意fork()是在这个主线程里发起的,虽然真正的持久化由子进程完成,但 fork 那一刻主线程是会被阻塞的,这也是"Redis 偶尔抖一下"的一个常见来源。

四、单线程到底省掉了什么

把多线程的代价列出来,答案就很清楚了:

  1. 没有锁。所有命令串行执行,数据结构不需要加锁,也不用设计无锁结构。Redis 的字典、跳表、压缩列表全是单线程假设下写的,代码能写得非常紧凑。
  2. 没有上下文切换。几万个连接对应的是几万个 fd,不是一个线程一个连接,切换开销几乎为零。
  3. 没有并发 bug。这也是 Redis 能保持极高稳定性的原因之一:你很难写出一个"因为并发而偶发"的 Redis bug。

代价同样明确:任何一个命令都不能慢。一个KEYS *在大实例上可能卡住几百毫秒,这段时间里所有其他客户端全都在等。这就是为什么线上禁用KEYS、要求用SCAN分批扫,为什么大 key(几十 MB 的 hash)会被反复强调,为什么DEL一个超大集合建议用UNLINK异步删。

一句话总结这一节:单线程把"并发复杂度"换成了"延迟敏感度"。Redis 能扛住高吞吐,前提是每个命令都足够快;一旦有慢命令,吞吐会瞬间塌掉。

五、瓶颈到底在哪:内存、网卡,还是命令本身

很多人默认"Redis 快是因为在内存里",但在真实生产环境里,最先撞到的往往不是内存速度。

可能的瓶颈典型表现怎么确认
网络往返(RTT)QPS 上不去,但 CPU 很闲redis-cli --latency看延迟;客户端和服务端是否跨机房
命令复杂度个别命令慢,INFO commandstats里某条命令usec_per_call很高SLOWLOG GET
单核 CPU 打满CPU 接近 100%,QPS 到顶不再涨单线程意味着只能用满一个核,这是硬上限
大 key / 热 key某个 key 的访问明显高于其他,延迟毛刺redis-cli --bigkeys、--hotkeys
fork 抖动延迟周期性出现毛刺,和备份时间吻合看latest_fork_usec

这里有个反直觉的结论:Redis 的吞吐上限本质上是"单核 CPU 能跑多少条命令",而延迟则主要取决于网络。所以优化方向上,降低 RTT(pipeline、连接池、就近部署)往往比升级 CPU 更有效。

六、实测:用 redis-benchmark 看清楚什么在影响吞吐

redis-benchmark是 Redis 自带的压测工具,不用装任何额外依赖。

# 最基础的跑法:50 并发、10 万次请求,只测 GET/SETredis-benchmark-h127.0.0.1-p6379-c50-n100000-tget,set-q# 带上 pipeline,一次往返塞 16 条命令redis-benchmark-h127.0.0.1-p6379-c50-n100000-tget,set-P16-q# 打满 CPU 看极限:并发拉到很高redis-benchmark-h127.0.0.1-p6379-c200-n500000-tget,set-q

要强调一句:压测机和 Redis 在同一台机器上跑时,压测本身也会吃 CPU,测出来的数字会偏低。redis-benchmark自己就占一部分核,与 Redis 抢同一个核的话结果会很失真。正式测试请把压测客户端放到另一台机器。

下面这组数据是在 4 核 8G 云主机、Redis 7.2、客户端与服务端同机(因此绝对值偏保守)条件下的典型量级,重点是看相对关系,不是照抄到你的环境:

场景并发pipeline大约 QPS说明
单条 GET50无8 万 ~ 12 万这是大家常说的"十万 QPS"的来源
单条 SET50无7 万 ~ 10 万比 GET 略低,因为要写 AOF、传播给从库
GET + pipeline 16501640 万 ~ 60 万网络往返被摊薄,吞吐翻几倍
GET + pipeline 32503260 万 ~ 90 万继续涨,但边际收益递减
开启 io-threads 450无提升 30% ~ 80%主要省在协议解析和 socket 读写

从这张表能直接读出三个结论:

  1. "十万 QPS"不是 Redis 的上限,是没有 pipeline 时的量级。加上 pipeline 之后能翻好几倍,这说明瓶颈在网络往返而不是命令执行。
  2. pipeline 不是越大越好。从 16 加到 32,收益明显变小;再往上加,单个批次的响应延迟会上升,反而影响业务体感。
  3. io-threads 的收益要看场景。请求体很小(比如短 GET)时,多线程本身的协调开销会吃掉一部分收益;大 value、高并发时收益才明显。默认它是关的,不要盲目打开。

七、真正值得调的几个参数

按"改了确实有用"排序:

# 1. 打开多线程 IO(Redis 6.0+)。一般设成核数的 3/4,不要等于核数 io-threads 4 io-threads-do-reads yes # 2. 最大连接数,默认 10000 通常够;要留余量给主从、哨兵、客户端重连 maxclients 20000 # 3. TCP 全连接队列,高并发短连接场景建议调大,否则内核直接丢连接 tcp-backlog 511 # 4. 客户端空闲多久踢掉,0 表示永不踢。设了可以回收僵尸连接 timeout 0 # 5. 客户端输出缓冲区限制,防慢客户端把内存吃干 client-output-buffer-limit normal 0 0 0 client-output-buffer-limit pubsub 32mb 8mb 60

有两件事比调参重要得多:

  • 用连接池,别每次请求新建连接。建连的三次握手 + 认证开销,在高 QPS 下非常可观,而且会瞬间抬高tcp-backlog的压力。
  • 批量命令优先用 pipeline 或 MGET/MSET。一次 RTT 换回几十条命令,是最便宜的性能优化。

八、什么时候单线程会成为问题

坦诚地说,有几个场景单线程是硬伤:

  1. 单个实例跑满一个核之后无法再扩展,只能靠加从库、分片或者上 Cluster 横向扩。
  2. 大 value 的读写会明显拉长单条命令耗时,比如一次性读写几 MB 的 value,吞吐会断崖式下降。
  3. fork 大内存实例时的抖动,几十 GB 的实例 fork 一次可能要几百毫秒。

这也是为什么系列后面要讲主从和 Cluster —— 它们解决的正是"单核不够用"和"单实例容量不够"这两个问题。

小结

  • Redis 的单线程指的是命令执行单线程,6.0 之后的io-threads只管网络 IO 和协议解析。
  • epoll 让一个线程能同时盯住几万个连接,核心是"只处理就绪的那个",避免了阻塞等待。
  • 事件循环aeProcessEvents一轮做三件事:算定时任务超时、等文件事件、先文件后时间。
  • 单线程换来的是无锁、无切换、无并发 bug,代价是对慢命令零容忍。
  • 吞吐瓶颈通常在网络 RTT 而不是内存速度,所以 pipeline 的收益往往大于调参。
  • 单条 GET 十万 QPS 是"无 pipeline"的典型量级,加上 pipeline 能翻好几倍。

下一篇讲主从复制与哨兵:为什么开了读写分离之后,业务就开始读到旧数据。


本文是《Redis 进阶》系列第 1 篇,同系列另有:主从复制与哨兵、Spring Boot 接口限流实战、Cluster 哈希槽与 MOVED 重定向。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询