ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Redis单线程为何能支撑十万QPS?epoll事件循环与原子性解析

Redis单线程为何能支撑十万QPS?epoll事件循环与原子性解析 1. 一个反直觉的事实单线程Redis为何能跑赢多线程服务先扔一个结论我最早接触Redis时也以为单线程低性能是常识。毕竟Java、Go这边张口就是线程池、并发模型Nginx也在搞多进程为什么偏偏Redis坚持单线程还能在普通物理机上轻松压出万级到十几万级QPS先看一组我自己压测过的数据。用redis-benchmark默认100个并发连接、50000个请求GET/SET这种简单命令在2.4GHz的Xeon机器上本机回环测试操作类型并发连接数QPS约平均延迟SET10011万约0.9msGET10012万约0.8msINCR10010万约1.0ms多key Pipeline10条10040万吞吐约2.4ms这是非常夸张的数字。要知道一个普通的HTTP接口服务能到两三千QPS已经很不错了数据库单机能上万QPS也得靠连接池和各种优化。而Redis用一个线程把读写、删除、哈希计算、内存分配全包了仍然能做到十万级。问题来了它靠的是什么先说结论Redis单线程之所以快不是因为单线程本身有魔法而是因为它的工作负载里CPU从来没有成为真正的瓶颈真正消耗时间的等待环节又被一种极其高效的方式绕开了。单线程只是在这个前提下把复杂度做到最低、把浪费降到零的一种最优解。这不是一个别看它是单线程其实很厉害的故事而是一个为什么在这个场景里单线程就是比多线程更合理的计算模型问题。理解了这一点你才会真正看懂Redis的架构设计而不是背一堆面试八股。2. 破局点绝大多数连接都在等而等待不消耗CPU要解释Redis为什么快先得搞清楚一个基础事实一个客户端发出一条命令到Redis返回结果这个过程中真正让CPU干活的时间有多少你可以把Redis处理一次请求分为四段从TCP缓冲区读取客户端发来的命令字节流解析协议拿到命令名、参数、key执行命令——比如查哈希表、改动数据、计算过期时间把结果编码成RESP协议格式写回TCP发送缓冲区。这四段里真正属于执行命令的第三段通常只有几十微秒甚至几微秒。而第一段和第四段涉及网络读写如果客户端不在本机那么光网络往返RTT就要几十毫秒——我说的是最差的情况。即便是本机压测网络栈的内核缓冲、拷贝也可能比命令执行本身耗时要长。传统多线程服务器处理这种场景是怎么做的每个连接分配一个线程线程阻塞在read()上等待数据到达。注意这里的关键词阻塞。read()没读到数据这个线程就被挂起了操作系统要把它从运行队列移到等待队列等数据到了再唤醒它又从等待队列移回运行队列。一次阻塞唤醒涉及内核态和用户态的切换还牵扯到线程的上下文切换——保存寄存器、刷新TLB、切换栈。如果连接数一多线程切换本身就能把CPU吃干净。1000个连接配1000个线程光调度开销就够呛。Redis的方案是完全另一条路它不等待任何一个连接而是同时盯着成千上万个连接哪个连接的数据准备好了它就去处理那个连接。操作系统提供的这个盯着能力就是IO多路复用核心接口在Linux上是epoll。打个比方传统多进程阻塞模型相当于你雇了一千个服务员每人盯一桌客人客人没喊人服务员也站在那儿干等——工资照发时间白耗。Redis的单线程事件循环相当于只有一个非常高效的大堂经理手里拿着一份名单不停查看一千桌里谁开始招手了就过去处理那一桌。这个经理虽然只有一个人但他永远在处理有事情发生的桌子从不浪费时间干等。这就是效率的来源。这里有个容易混淆的点得说清楚。很多人以为Redis快是因为不用IO多路复用其实恰恰相反——Redis是IO多路复用的教科书级应用。单线程只是顺水推舟的选择真正让它扛住十万QPS的引擎是事件循环 epoll。两者缺一不可。3. 事件循环的运转细节从accept到read到write一个循环搞定理解了等待不耗CPU之后接下来把Redis主线程具体怎么跑这件事拆开。你不需要现在就去看源码但理解了这段再看源码会非常轻松。Redis服务端的主循环本质是一个事件处理器。它维护两类事件文件事件file event也就是socket上发生的可读/可写和时间事件time event比如定时任务、过期键清理。3.1 主循环一次迭代做了什么简化后的伪代码思路大致是while (loop未停止) { 1. 调用 epoll_wait 等待事件就绪超时时间根据最近的时间事件计算 2. 如果 epoll_wait 返回了可读/可写事件按顺序处理 3. 处理所有到期的时间事件 expire、serverCron 等 4. 如果需要把处理完的响应写回客户端 }关键点在于epoll_wait这个系统调用。传入的是一个epoll实例里面挂了一堆socket的文件描述符包括监听socket和所有已建立的客户端连接socket。内核会告诉你这两个fd可读了那三个fd可写了。Redis不需要逐个轮询一千个连接它一次就能拿到当前有事件的所有连接。从可读事件来看流程是这样监听socket可读说明有新连接进来调accept()接受连接把这个新fd加入epoll客户端socket可读说明客户端发了命令或者管道数据调用read()把数据读入输入缓冲区从输入缓冲区里按RESP协议解析出一条或多条命令逐一执行命令把结果写入输出缓冲区如果输出缓冲区有数据且socket可写就往内核发送缓冲区里写。注意一个细节Redis不是收到一条命令就马上把结果返回的。它会攒一攒在循环迭代里批量处理完本次所有就绪事件之后再统一把结果写回。这是写事件的巧妙之处如果某个客户端的send buffer满了Redis就把这个fd标记为需要监听可写事件等内核说可以写了再真正flush。这种延迟写机制让它在高并发下不会因为个别慢客户端拖慢整体。3.2 epoll为什么比select/poll强聊到这里得提一嘴为什么Redis选的是epoll而不是select或poll。select最早的问题是两个一是fd数量有上限默认1024虽然可调二是每次调用都要从用户态把完整的fd集合拷贝到内核态内核还要线性扫描一遍所有的fd连接数一多O(N)的开销直接吃掉性能。poll解决了上限问题但复杂度依然是O(N)——每次都要全量扫描。epoll直接改变了游戏规则它有两个杀手锏epoll_ctl注册fd时同时挂上一个回调当fd有事件时内核直接把就绪fd加入一个就绪链表不需要每次扫描全量fdepoll_wait返回时只给你发生了事件的那一小撮fd用户态只需要处理这少数几个复杂度从O(N)降为O(K)——K是实际就绪的fd数量。在Redis这种一万个连接但每秒只有两千个在活跃的场景里这两个特性意味着你只需要为真正干活的两千个连接付成本剩余八千个闲杂连接几乎零开销。实际观察真线上环境Redis的QPS达到10万时并发连接数经常只有几百到几千。每个连接的处理时间极短单线程完全忙得过来。如果你本地压测发现Redis性能突然掉到两三千QPS先查一下是不是用了Windows版本或者内核里TCP Nagle之类的参数没关而不是急着责怪Redis单线程。3.3 时间事件与文件事件如何协同除了处理网络读写Redis还得干这些杂活定期清理过期keyactive expire cycle、触发持久化、处理客户端超时、更新统计信息。这些都在serverCron这个时间事件里推进默认频率是100ms一次hz配置默认10即每秒执行10次。时间事件和文件事件在一个循环里处理意味着Redis在执行长耗时命令期间会延迟这些周期任务的执行。这就是为什么你在线上执行一个KEYS *全量扫描时会发现过期key清理、定期持久化都像卡住了一样。理解这个协同关系是排查Redis为什么突然延迟变高的基础。4. 单线程的真正红利无锁、无竞争、零上下文切换到这里你可能还会问就算epoll解决了等待问题那处理命令本身为什么不用多线程那样不是还能再快一截吗这就得聊聊单线程带来的几个隐藏收益。很多谈Redis性能的人只讲epoll不讲这个但在我看来无竞争带来的确定性比单纯的吞吐量更重要。4.1 没有锁意味着没有锁的开销多线程环境下任何共享数据结构的读写都需要加锁。Redis是一个全局共享状态机一个key的字典、所有过期时间、所有客户端状态全都放在全局内存里。如果拆成多线程每次操作都要先拿锁。锁竞争激烈时线程大部分时间都在自旋或者阻塞等待吞吐反而下降。单线程最大的好处就是整个数据结构的生命周期里天然没有并发访问。你不需要为每一个读操作考虑此刻会不会有另一个线程正在改这个哈希表不需要担心脏读、死锁、ABA问题。所有命令变成了一系列严格串行的原子操作。这也带来一个很有价值的副作用很多命令天然就是原子性的。比如INCR它不需要一个读取-加一-写回的加锁协议因为同一个时刻只有一个人在跑。LPUSH、SETNX、HSET这些命令也一样。分布式锁、计数器、限流器之所以能用单个Redis命令实现根本原因就是单线程下的天然原子性。你看Redis官方文档里说每个命令都是原子执行的背后的地基就是单线程。4.2 零上下文切换的账线程切换不是免费的。每次切换操作系统要保存当前线程的全部寄存器状态、程序计数器、栈指针然后加载新线程的上下文。这个成本在Linux上通常是微秒级别看着不高但当线程数上升到成百上千切换频率急剧上升时CPU很多时间都花在换人而不是干活上。Redis单线程不存在这个问题它从头到尾只有一个人干所有活CPU从启动到停机始终在处理Redis自身的逻辑。要知道Redis的命令执行本身往往只有几十微秒如果中间穿插两三次线程切换那可比命令执行本身还贵。这是多线程反而更慢的真正原因——不是多线程这个思想错了而是在极短任务 高连接并发的组合下调度开销占比太大了。4.3 缓存友好性和内存布局单线程还有一个容易被忽略的优势对CPU缓存的利用率高。同一个线程持续处理各种命令时它访问的内存往往落在比较固定的几个热区域命令解析用的临时缓冲区、全局哈希表、最近请求过的key对象。这些区域在L1/L2缓存里的命中率很高。如果换成多线程每个线程抢占不同的核冷数据进Cache、热数据被逐出Cache Miss率会显著上升内存访问延迟增加。4.4 单线程的代价到底是啥有一种观点认为Redis单线程所以快是伪命题因为它快是快在简单任务代价是一遇到重任务就崩。这种批评有道理但其实更适合表述为单线程把性能的上限和下限绑定在了一起。它保证了你不会因为锁竞争而突然拖垮但同时也让CPU密集型操作成为致命的短板。Redis能扛住的高QPS是有前提的——命令复杂度必须低。有个数据可以参考一个GET在Redis里的纯执行时间大概在几十纳秒到一两微秒之间。而一个KEYS *扫描一个百万key的库可能耗时一秒以上。同一个线程做前一种任务能到十万QPS做后一种任务一秒只能处理一次。所以Redis单线程为什么快这个问题真正的答案还包括你要让它跑的都是快命令。5. 红线与翻车现场什么情况下单线程模型会崩聊完了为什么快必须讲什么情况下会慢不然这篇文章对实际工作的参考价值就少了一半。我见过很多人把Redis部署上线以后遇到几个典型问题根本原因都是单线程模型下的长命令或大对象。5.1 慢查询类型一时间复杂度高的命令KEYS *是最典型的。它在字典全量扫描O(N)。另一个是SMEMBERS key——取一个包含几十万元素的集合的全部成员O(N)。还有HGETALL、LRANGE 0 -1数据量大了一样慢。ZRANGE配合大skiplist也要小心。这些命令一旦执行整个Redis事件循环就被卡住了后面的所有客户端请求全部排队表现就是命令超时、连接堆积、客户端疯狂报错。我见过一个真实事故某个业务上线了一个用KEYS *做模糊匹配的功能访问量一上来Redis CPU瞬间打到100%所有请求的平均延迟从1ms暴涨到5秒。最后只能紧急重启Redis然后让开发改成SCAN。SCAN和KEYS的区别是面试高频本质差异是SCAN是分批、增量式迭代每次返回一小部分虽然不保证完整一致性但不会阻塞事件循环太久。5.2 慢查询类型二大key与大value单个value超过几十MB或者list/set里塞了上百万元素都属于大key。DEL这类命令删除一个包含百万元素的key时需要逐个释放内存这个过程可能阻塞几百毫秒到几秒。Redis 4.0以后引入了UNLINK把释放内存的操作交给后台线程异步执行主线程只做标记算是对这个痛点的一个针对性补丁。大key的问题还在于网络传输一个10MB的value序列化写入发送缓冲区客户端接收整个链路串行期间其他请求只能等着。就算数据本身是热数据大value也会拖慢一切。这提醒我们Redis的高性能是需要维护的数据模型设计不好再好的引擎也被糟蹋。5.3 慢查询类型三CPU密集型命令Redis没有把耗CPU的计算型命令作为主要场景但确实有一些SORT、BITOP、PFCOUNTHyperLogLog合并多个key、复杂的Lua脚本。EVAL执行Lua脚本时整个脚本在事件循环里同步执行如果一个脚本里有死循环或者高复杂度逻辑后果和KEYS *一样。Lua脚本的原子性本身是特性但也是一把双刃剑——脚本执行期间其他命令完全被阻塞。我建议团队里约定Lua脚本执行时间务必控制在10ms以内超过就得拆分逻辑。5.4 持久化带来的假阻塞还有一类阻塞不来自命令而来自持久化。RDB保存和AOF rewrite都涉及fork子进程fork瞬间主进程要拷贝页表内存越大fork越慢如果用了appendfsync always每个写命令后都刷盘磁盘慢会导致写延迟陡然上升。很多人以为这些和单线程无关实际上fork后的内存页复制是阻塞主线程的而AOF文件写入期间的IO等待也会拖慢命令响应。线上压测时一定要把持久化配置考虑进去不能只看无持久化裸跑的数字。6. Redis 6.0的伪多线程IO线程组到底改了什么最后必须聊一个最近几年技术圈反复讨论的话题Redis 6.0引入了多线程IO。很多人看到这个新闻第一反应是Redis终于抛弃单线程了其实完全不是。准确说法是Redis 6.0依然保持命令执行单线程只是把网络IO的读写放到了多个线程上。6.1 为什么要拆IO事件循环模型下单线程要同时负责读客户端数据、解析命令、执行、写回结果。压测场景里当QPS冲到极限你会发现在事件循环中IO读写和解析的时间占比会越来越高。尤其是跨机器网络环境一个数据包从网卡到应用缓冲区涉及多次内存拷贝。这部分工作完全是机械性的不涉及共享数据非常适合并行。Redis官方的做法是主线程继续负责epoll监听、事件分发和命令执行但把从socket读取数据到输入缓冲区和从输出缓冲区把数据写入socket这两件事外包给若干IO线程。典型的配置io-threads 4即启用4条IO线程辅助。6.2 数据安全如何保证既然有多个IO线程在同时读写不同的客户端连接会不会产生竞争Redis的处理方式是每个线程负责一部分fd的读写fd与线程是绑定的不会有两条线程同时操作同一个连接。命令解析和执行仍交回主线程IO线程只做纯粹的字节搬运。这个设计很精巧——网络数据拷贝是天然可以分片的命令执行则不行。实测下来Redis 6.0开启IO多线程后纯网络负载场景的吞吐能提升约一倍特别是GET这种极短命令瓶颈几乎全在IO收益最明显。命令执行复杂时IO线程的收益就没那么大了因为瓶颈回到了主线程。6.3 最佳实践什么场景该开我的建议是默认不开。普通业务量下单线程Redis已经足够没必要为了调参而调参。只有当redis-cli --stat里显示主线程CPU接近100%、命令延迟在上升、且瓶颈确认在网络读取时再考虑开IO多线程。具体配置io-threads 4记住如果io-threads设置为1就等价于传统的单线程模式。设置大于1时建议不要超过物理核数否则线程上下文切换的成本又开始回来了。还有Redis官方默认只允许为读写启用IO线程但如果你配置了io-threads-do-reads yes会把读事件的IO也并行化收益更大但要留意稳定性不是所有版本都表现一致。6.4 多线程IO之外的全局演进Redis 7.0之后其实已经不只是在IO上做文章了。异步删除、异步AOF刷盘、CONFIG SET动态调整都在尽量把慢操作从主线程摘出去。这进一步印证了一个方向单线程模型并不妨碍水平扩展和异步化关键是找出真正阻塞核心的那一两个点。7. 压测方法论怎么验证Redis极限不被数字骗了很多人拿redis-benchmark压出来的QPS到处炫这里我要泼点冷水。redis-benchmark是一个很好的基准工具但它有几个坑不清楚这些你压出来的数字和线上真实情况可能相差一个数量级。第一本机压测和跨机器压测完全是两个数据。本机回环走的是内核内部的虚拟网卡延迟极低测出来的是Redis自己的纯处理能力。跨机器时网络往返、网卡中断处理、TCP协议栈全部成为真实开销。生产环境是跨机器的所以压测一定要用另一台机器做客户端。第二默认参数下单条命令单次往返会产生大量的协议解析和小包传输。想测出应用场景下的真实吞吐应该用Pipeline——把多条命令打包在一次网络往返中发送。同样一批命令非Pipeline可能只有两万QPSPipeline一开直接干到十几万。这个差距不是你性能提升了而是网络等待被吃掉了。第三压测命令的选择。SET、GET这些简单命令能压出最高QPS但要模拟真实业务应该混合SET/GET/EXPIRE/HSET还要带点ZADD。建议用redis-benchmark -t set,get,incr,lpush,rpush,hset,zadd -q这种混合命令集。第四关注延迟分布而不是平均值。redis-benchmark --latency能看P99延迟甚至--latency-dist能看全景。平均值会掩盖毛刺比如平均1ms实际上P99到了30ms这在线上一旦遇到热点就出问题。我还习惯在压测时用INFO命令实时观察instantaneous_ops_per_sec同时看used_memory的增长曲线避免内存暴涨导致OOM被kernel杀掉那会误导你以为是QPS的锅。8. 一套可直接复现的压测命令清单如果读者想自己验证Redis单线程到底能跑多高我给一套完整步骤。环境是Linux Redis 6.2以上客户端机器和服务端机器分开最佳。服务端启动注意关闭透明大页降低fork开销redis-server --port 6379 --appendonly no --save --io-threads 4这里的--save 是关闭RDB定时保存--appendonly no关闭AOF让基准测试纯粹测内存能力。客户端执行标准基准redis-benchmark -h 192.168.1.10 -p 6379 -c 100 -n 100000 -t get,set,incr -q关注输出里的requests per second。接着测Pipelineredis-benchmark -h 192.168.1.10 -p 6379 -c 50 -P 10 -n 500000 -t get,set -q-P是每次调用流水线携带的命令数你会看到QPS数字明显上涨但它反映的是去掉网络往返后Redis真正的吞吐上限。然后测延迟分布redis-benchmark -h 192.168.1.10 -p 6379 -c 100 -n 200000 -t set --latency看p50、p99那几行。最后一步在你自己的业务代码里模拟真实负载。如果用的Java客户端用Jedis或Lettuce起一个多线程连接池打一个分钟级压测跟踪Redis服务端的instantaneous_ops_per_sec。这一套做完你对Redis在你这套环境里到底能扛多少QPS会有一个非常实在的底数。9. 再往深走一步彻底搞懂Redis快慢的关键观测指标压测只是手段真正重要的是线上监控。很多人对Redis性能缺乏感知等到用户投诉才去排查。我建议至少监控这几个指标instantaneous_ops_per_sec实时QPS低于平时水平且持续下降先怀疑慢查询latencyRedis自带的延迟监控SLOWLOG GET能看到最近慢命令明细used_memory和mem_fragmentation_ratio内存碎片率超过1.5就要注意了可能是频繁反复修改大value导致的blocked_clients这个参数一旦大于0说明有客户端在等锁比如BLPOP虽然不影响整体QPS但反映业务模型total_net_input_bytes、total_net_output_bytes如果出口流量远大于入口说明存在大key或多客户端的广播式消费。其中SLOWLOG最直接配置config set slowlog-log-slower-than 10000 config set slowlog-max-len 128设置超过10ms的命令都记录下来然后定期SLOWLOG GET 50看结果。很多线上问题第一步排查就能从这里发现真凶。10. 关于为什么是Redis单线程的最后一点个人体会做技术的人容易陷入一个误区看到单线程想到弱看到多线程想到强。真实世界的性能工程不是这么线性的。Redis用单线程跑出十万级QPS最核心的启示是先想清楚你的瓶颈到底在哪再决定用什么样的并发模型。如果瓶颈是网络等待和调度开销那么多线程并不能解决甚至会加重问题如果瓶颈是纯计算单线程再快也有天花板。我踩过的坑足够说明这一点。很久之前我把一个日志处理逻辑放到Redis Lua脚本里跑脚本里有DB大小里的字符串正则匹配结果线上一个请求触发脚本后面的流量全堵了几秒。后来我把这种逻辑挪到了应用层用消息队列慢慢算Redis只管缓存和计数整个系统反而稳得一批。所以Redis单线程的为什么快答案不是一句epoll或者内存快就能打发的。它是一整套关于等待、调度、锁、缓存、数据模型的综合工程。希望这篇文章能帮你把那个反直觉的问题变成一个本来就该这么设计的常识。
返回列表