ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

Redis 进阶(一):单线程的 Redis 为什么这么快?epoll、事件循环与一次 10 万 QPS 压测 我不会起名字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 上直到对端真的发数据intnread(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。这套抽象叫aeA simple Event-driven programming library是 Redis 自己封装的事件库。用 epoll 的伪代码看主循环长这样while(1){// 阻塞等待直到有 fd 就绪n 是就绪的个数intnepoll_wait(epfd,events,MAX_EVENTS,timeout_ms);for(inti0;in;i){if(events[i].data.fdlisten_fd){accept_new_client();// 新连接}elseif(events[i].eventsEPOLLIN){read_and_process_request();// 读请求 执行命令 写回}elseif(events[i].eventsEPOLLOUT){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 偶尔抖一下的一个常见来源。四、单线程到底省掉了什么把多线程的代价列出来答案就很清楚了没有锁。所有命令串行执行数据结构不需要加锁也不用设计无锁结构。Redis 的字典、跳表、压缩列表全是单线程假设下写的代码能写得非常紧凑。没有上下文切换。几万个连接对应的是几万个 fd不是一个线程一个连接切换开销几乎为零。没有并发 bug。这也是 Redis 能保持极高稳定性的原因之一你很难写出一个因为并发而偶发的 Redis bug。代价同样明确任何一个命令都不能慢。一个KEYS *在大实例上可能卡住几百毫秒这段时间里所有其他客户端全都在等。这就是为什么线上禁用KEYS、要求用SCAN分批扫为什么大 key几十 MB 的 hash会被反复强调为什么DEL一个超大集合建议用UNLINK异步删。一句话总结这一节单线程把并发复杂度换成了延迟敏感度。Redis 能扛住高吞吐前提是每个命令都足够快一旦有慢命令吞吐会瞬间塌掉。五、瓶颈到底在哪内存、网卡还是命令本身很多人默认Redis 快是因为在内存里但在真实生产环境里最先撞到的往往不是内存速度。可能的瓶颈典型表现怎么确认网络往返RTTQPS 上不去但 CPU 很闲redis-cli --latency看延迟客户端和服务端是否跨机房命令复杂度个别命令慢INFO commandstats里某条命令usec_per_call很高SLOWLOG GET单核 CPU 打满CPU 接近 100%QPS 到顶不再涨单线程意味着只能用满一个核这是硬上限大 key / 热 key某个 key 的访问明显高于其他延迟毛刺redis-cli --bigkeys、--hotkeysfork 抖动延迟周期性出现毛刺和备份时间吻合看latest_fork_usec这里有个反直觉的结论Redis 的吞吐上限本质上是单核 CPU 能跑多少条命令而延迟则主要取决于网络。所以优化方向上降低 RTTpipeline、连接池、就近部署往往比升级 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 读写从这张表能直接读出三个结论十万 QPS不是 Redis 的上限是没有 pipeline 时的量级。加上 pipeline 之后能翻好几倍这说明瓶颈在网络往返而不是命令执行。pipeline 不是越大越好。从 16 加到 32收益明显变小再往上加单个批次的响应延迟会上升反而影响业务体感。io-threads 的收益要看场景。请求体很小比如短 GET时多线程本身的协调开销会吃掉一部分收益大 value、高并发时收益才明显。默认它是关的不要盲目打开。七、真正值得调的几个参数按改了确实有用排序# 1. 打开多线程 IORedis 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 换回几十条命令是最便宜的性能优化。八、什么时候单线程会成为问题坦诚地说有几个场景单线程是硬伤单个实例跑满一个核之后无法再扩展只能靠加从库、分片或者上 Cluster 横向扩。大 value 的读写会明显拉长单条命令耗时比如一次性读写几 MB 的 value吞吐会断崖式下降。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 重定向。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进