ARTICLE DETAIL

资讯详情

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

Redis线程模型深度剖析:单线程为何快,多线程改了什么?

Redis线程模型深度剖析:单线程为何快,多线程改了什么? 1. 从快说起为什么Redis值得研究线程模型凡是接触过后端开发的人几乎都听说过Redis单线程却性能极高的说法。我最早是在一次缓存选型讨论里听到这个论调的当时团队里有人坚持单线程肯定撑不住双十一级别的流量也有老哥直接反问那为什么Redis用单线程还能跑到10万QPS。争论到最后大家其实都是在凭印象说话谁也没真正把线程模型这层窗户纸捅破。这篇文章想做的事情很简单把Redis的线程模型拆开揉碎讲清楚它到底为什么快、单线程的真实含义是什么、6.0之后引入的多线程到底改了什么以及我们在实际项目中应该怎么看待这些特性。如果你正准备应对Redis面试题或者正在纠结生产环境要不要升级到6.x/7.x又或者只是好奇单线程和高性能这两个词为什么能同时出现在一个系统身上这篇内容应该能给你一个相对完整的答案。我默认你至少用过Redis知道SET、GET大概是什么意思。如果你连Redis都没装过也不影响阅读我会把线程模型相关的背景知识一并交代清楚。但如果你已经是个熟练工我建议你重点看两个部分一是IO线程切换的源码级逻辑二是多线程版本在实际压测中暴露出来的问题——这两块是网上讲得最少、也最容易踩坑的地方。先给一个反直觉的结论Redis单线程快不是因为单线程本身有多优秀而是因为它的设计者们把单线程这个约束变成了极致优化的起点。换句话说单线程是果不是因。真正的原因藏在数据结构、IO多路复用、对象共享、内存布局这些细节里。2. 每次网络请求背后的单线程真相很多人理解Redis的单线程以为就是从头到尾只有一个线程在干活。这话对了一半。Redis主流程确实由一个主线程串行执行但严格来说一个Redis进程启动后不只有一个线程。你可以自己登录服务器看一眼ps -L -p $(pgrep redis-server) -o pid,tid,comm,psr实测会看到类似这样的输出PID TID COMM PSR 1234 1234 redis-server 2 1234 1235 bio_close_file 0 1234 1236 bio_aof_fsync 1 1234 1237 bio_lazy_free 1这几个额外线程是后台线程一个负责关闭文件描述符一个负责把AOF缓冲刷到磁盘一个负责惰性删除大key。它们不参与命令执行只是帮主线程干一些可能会卡住的杂活。主线程则负责三件事读取客户端请求、解析命令、执行命令并写回响应。当我们说Redis是单线程指的其实是命令执行这一步是串行的——同一时刻只有一条命令在跑。换句话说Redis不是只有一个线程而是处理命令的线程只有一个。那为什么非要把命令执行做成单线程这里有个非常实际的考量如果像传统关系型数据库那样搞多线程并发执行命令就需要引入锁来保护共享数据结构而锁一旦出现线程切换和争用的开销就会吞掉大量性能。Redis的所有数据结构字符串、列表、哈希、跳表等在单线程模型下天然无竞争不需要加锁自然就没有死锁、没有上下文切换开销、没有缓存失效的问题。这就像只有一个人用的厨房锅碗瓢盆随便放不用考虑别人会不会拿错。而多线程的厨房哪怕只有两个人也得约定刀放左边、锅放右边这种约定本身就是成本。2.1 Redis不是纯单线程的证据后台线程与异步删除你可能见过UNLINK命令——它可以在O(1)时间内删除一个超大key。如果是几十万元素的列表直接DEL会让主线程卡几百毫秒而UNLINK只是把key从主字典里摘下来真正释放内存的动作交给后台线程去做。这个设计就是单线程主线 辅助线程的典型例子。主线程仍然串行执行命令但把耗时的资源回收工作分流出去。类似的还有AOF刷盘默认everysec策略下主线程只负责把写命令追加到内存缓冲后台线程每秒刷一次盘避免主线程在fsync上阻塞。所以第一个要澄清的点是即使在没有多线程IO的版本里Redis也从来不是纯单线程。线程模型从来都是一个组合只是主线路上确实只有一个执行者。2.2 单线程带来的两个隐含优势除了不用加锁单线程还有两个容易被忽略的好处。第一个是可预测性。因为是串行执行每条命令的平均耗时就是最终耗时的天花板不存在多个线程同时抢CPU导致某个请求特别慢的尾延迟问题。这在广告投放、实时竞价这类对延迟抖动极度敏感的场景里非常关键。第二个是实现简单带来的低维护成本。Redis的源码只有十几万行核心代码尤其紧凑这跟单线程假设有直接关系。没有复杂的锁策略、没有并发bug维护者可以把精力集中在数据结构和命令实现上。对一个基础组件来说简单本身就是一种强大的可靠性。3. 性能引擎IO多路复用与事件循环的配合聊完单线程的基础接下来要回答一个核心问题单线程凭什么能扛住几十万QPS答案藏在IO多路复用机制里。Redis在Linux上默认使用epoll在macOS上使用kqueue。这两个机制的共同点是内核替你盯着成千上万个socket只要有事件发生新连接、可读、可写就告诉Redis这个fd有事了。Redis主线程拿到一批就绪事件逐个处理处理完再回到epoll_wait等着。整个过程没有阻塞等待没有为每个连接开一个线程资源开销极小。这也是单线程高性能的核心原因大部分时间Redis不是在干活而是在等待内核通知。真正的CPU消耗集中在命令执行上而命令执行本身又是微秒级的操作。3.1 事件循环的完整路径从read到write为了让你对一次GET请求经历了什么有画面感我拆一下事件循环的完整路径客户端连上Redis内核把可读事件塞给epoll。Redis在aeMain循环里调用aeApiPoll拿到就绪事件列表。对每个事件Redis调用读事件处理器——如果是客户端连接就创建client结构体如果是已有连接的数据到达就把数据读入输入缓冲。输入缓冲里的完整命令被解析出来放入argv数组交给命令查找表匹配到对应的处理函数。处理函数执行命令把结果写入输出缓冲。主线程回到epoll_wait等下一次事件。输出缓冲的内容会由后续的写事件处理器异步刷给客户端。注意到没有整个流程里Redis主线程没有主动去轮询客户端而是被动等待内核通知。这就是epoll高效的本质——事件驱动的等比查快得多。3.2 应对慢客户端为什么读和写也会成为瓶颈单线程事件的隐患在于如果某个客户端读数据特别慢或者输出缓冲特别大主线程写入响应时可能会被阻塞。比如一个客户端10秒才读一次数据而Redis已经产生了大量写响应堆积在输出缓冲里主线程在write时可能被卡住。Redis的老版本对这种情况的处理是把慢客户端的socket设为非阻塞写不进去就先把数据留在缓冲里等下次可写事件再继续写。但这样做有个副作用内存中堆积的输出数据越来越多极端情况下会触发client-output-buffer-limit限制直接断开这个客户端。所以单线程快是有前提的——客户端要么处理得快要么Redis敢于断开慢速客户端。在生产环境里如果某个业务的客户端处理能力跟不上你会看到大量连接被Redis主动断开日志里出现Client closed connection之类的记录。这其实是Redis保护自己的一种方式。4. Redis 6.0多线程到底多线程了什么2020年Redis 6.0发布最大的变化就是默认开启了IO线程。很多人以为Redis变成了多线程数据库这是个误解。6.0的多线程只作用于IO读写阶段命令执行仍然是单线程的。准确地说Redis 6.0把从socket读取请求和把响应写回socket这两个IO密集的环节从主线程拆分给了多个IO线程。命令的解析、执行、数据结构操作仍然由主线程串行完成。打个比方从前是一个厨师既要点菜读请求又做菜执行命令又端菜写响应。现在改成多个服务员帮忙点菜、端菜厨师只负责站在灶台前做菜。菜还是同一个厨师一盘一盘炒但点菜和上菜的效率提升了一截。4.1 IO线程的工作流程主线程如何分发任务具体流程可以分这么几步主线程在epoll_wait拿到可读事件后不直接读数据而是把这些socket分发给多个IO线程。IO线程读取socket数据放入各自对应的client缓冲区然后通知主线程读好了。主线程收集完所有IO线程的读结果开始串行解析命令并执行。执行完成后主线程把响应放入输出缓冲再把写任务分发给IO线程。IO线程把响应数据写回各自的socket写完后通知主线程写完了。这里面有个值得注意的细节Redis并不是把一个请求的所有阶段分发出去而是分阶段分发——读阶段是多线程的写阶段是多线程的但两个阶段之间主线程要等所有IO线程干完才能进入下一轮。也就是说CPU密集的命令执行阶段依然严格串行没有跑成多线程并发执行命令。4.2 线程数的配置建议与边界条件配置文件里有两个参数io-threads 4 io-threads-do-reads yesio-threads默认是4但官方文档明确说建议在4核及以上的机器上设置为4或8不要盲目调大。io-threads-do-reads默认是no——也就是默认只开启多线程写响应不开启多线程读请求。原因在于读阶段涉及的事情更复杂比如解析命令、处理半包数据没凑齐一条完整命令等多线程读容易引入边界条件所以官方默认关掉。实测下来在我的测试机上开启4个IO线程后纯SET操作吞吐量提升了约20%~30%但延迟的p99反而略有上升。原因很简单IO线程之间的任务分配需要同步主线程要等所有IO线程完成才能继续这个等在某些场景下会抵消一部分收益。所以在决定要不要开启多线程之前先问自己三个问题当前机器的瓶颈是不是确实在IO读写上你的读写比例是多少写多读少还是读多写少你是否能接受p99延迟小幅波动如果业务请求本身很小比如一次只读写几十字节IO线程的收益会非常有限因为数据拷贝的开销占比太小。相反如果业务常有大批量读写比如MGET一个几百个key的列表多线程IO的收益就会明显体现出来。4.3 Redis 7.0的进一步变化到了Redis 7.0主线程仍然单线程执行命令但引入了多线程的AOF刷盘和更精细的IO线程调度。此外7.0还优化了IO线程在任务分发时的负载均衡策略避免了某些线程吃不到任务的不均衡现象。如果你正在选型我建议直接上7.0或7.2不管用不用多线程7.x在内存效率、稳定性上都有明显改进。5. 为什么快五层因素拆解讲完线程模型再回到最初的问题Redis到底为什么快我把它拆成五层每一层都值得单独品味。5.1 数据结构层面的优化Redis快不只是因为内存数据库更因为它为每种使用场景设计了专门的数据结构。字符串用的是SDS简单动态字符串它在获取长度、追加内容时都是O(1)不会像C字符串那样反复遍历。列表在元素少时用压缩列表后来改成listpack元素多才转成双端链表。哈希和跳表都做了相应的空间换时间设计。这意味着Redis处理命令时很多操作都是指针级别的移动而不是数据遍历。举个典型的例子如果你要往一个列表头部插入数据Redis的LPUSH是O(1)的直接改头指针就行不需要移动后续元素。而如果你在MySQL里往一个B树索引的头部插入数据可能要涉及页分裂那就是另外一回事了。5.2 内存分配的极致优化Redis自己实现了一个内存分配器jeMalloc它在分配小块内存时的开销比glibc默认的ptmalloc低不少。此外Redis对短字符串做了对象共享0~9999的整数所有key都共享同一个字符串对象不会重复创建。这些细节单独看都不起眼但叠加在一起稳定的内存分配效率就让Redis在长时间运行后依然保持低延迟。很多线上问题其实不是CPU不够而是内存碎片化和分配器退化导致的Redis在这方面做得相当好。5.3 网络模型的本质优势这层就是前面说的epoll事件驱动。你知道传统阻塞IO模型里一个连接往往需要分配一个线程线程多了CPU就忙着切换上下文。而Redis用事件循环把连接和线程解耦几万个连接都挂在epoll上线程只需要处理有事件的那些fd。这是Redis单线程还能撑起大连接数的根本原因。5.4 命令执行的轻量级Redis的命令执行路径非常短一次GET请求从解析到返回通常不到1微秒。对比数据库查询动辄几毫秒的执行时间Redis快在不需要做太多事。它没有查询优化器没有事务日志没有锁等待只是纯内存操作。这提醒我们一件事比较性能时要确认比较的对象是不是真的做了一样复杂的事。拿Redis和MySQL比QPS本来就不公平。5.5 持久化策略不影响主流程Redis的持久化分RDB快照和AOF日志但这两者都不在主线程里执行。RDB fork子进程去dumpAOF刷盘有后台线程主线程继续处理请求。即使开启AOF写日志也只是追加内存缓冲每秒才刷一次盘。这样持久化带来的性能损耗被控制在了很小的范围内。5.6 单线程与多线程的性能对比实录我在一台4核8G的云主机上做过一轮粗浅的压测工具是redis-benchmark结果供参考场景QPS约p99延迟约无多线程IO纯GET14.5万0.8ms无多线程IO纯SET13.2万0.9ms开启io-threads4纯SET16.8万1.1ms开启io-threads4MGET 100个key8.9万1.3ms注意压测环境和真实业务差异很大这组数据只能说明趋势多线程IO在纯SET这种高并发写场景有一定提升但在MGET这种本身就需要拼装大量数据的场景里因为主线程仍要花大量时间解析和执行命令IO线程的收益被稀释了。而且p99延迟确实会变差一点点因为多线程同步引入了额外的等待。如果你想要稳定可预期的低延迟不要盲目开多线程。先把Redis绑定到独立CPU核心taskset再看网络中断是否均衡最后才考虑io-threads配置。6. 避坑指南多线程时代的几个典型问题多线程IO虽然提升了吞吐但也不是没有代价。我在实际使用和围观社区反馈中总结出下面几个坑希望你别踩。6.1 开启多线程后延迟抖动反而更明显前面提到过主线程要等所有IO线程完成读写任务才能进入下一轮。如果某个IO线程被系统调度延迟了整个事件循环都会被拖慢。这在低负载时尤其明显——本来没啥任务却因为线程间同步带来额外开销。我的建议是如果Redis的QPS在5万以下io-threads收益很小保持默认的1即关闭多线程IO反而更稳。如果QPS冲到20万以上再考虑多线程。6.2 多线程读请求要谨慎开启io-threads-do-reads yes这个选项官方默认关着是有道理的。读请求涉及半包处理和命令解析这部分逻辑其实是CPU密集的。IO线程帮你把数据从内核拷贝到用户态但解析命令还是主线程做。多线程读真正省下的只是从socket读入内存这一小段但引入了数据竞争的风险。我几乎不在生产环境开这个选项除非压测证明读阶段确实成了瓶颈。6.3 每线程的任务分配不均导致CPU浪费Redis的IO线程任务是按连接维度分配的。如果某个连接上的请求量特别大比如某个业务用了一个长连接狂发命令那这个连接对应的IO线程就会很忙其他IO线程闲着。极端情况下io-threads4的效果还不如不开启。解决办法是让客户端使用连接池把请求分散到多个连接上。Redis官方其实也建议客户端至少保持几十个连接的连接池而不是一个长连接打天下。6.4 千万别把多线程和集群混为一谈很多面试或实操里会有人把Redis多线程和Redis Cluster搞混。多线程解决的是单实例内的IO瓶颈Cluster解决的是数据容量和单点问题。它们是两个维度的事——前者是CPU利用率的优化后者是分布式横向扩展。如果你觉得有了多线程就不用搭集群那是方向性错误。6.5 多线程和事务/脚本的交互Redis的MULTI/EXEC事务和Lua脚本在执行时主线程会进入不可中断的独占状态。此时IO线程虽然还在但主线程不会去处理和它们相关的读写请求。如果某个Lua脚本执行了较长时间整个实例的请求会被卡住IO线程也救不了你。所以不要因为Redis支持多线程就放心地写长Lua脚本。长脚本仍然是单实例的软死锁风险点。7. 生产环境选型决策你到底需不需要多线程聊了这么多理论落地时最实际的问题是我该不该升级到支持多线程的Redis版本该不该开启IO线程我的建议分三种情况如果你的瓶颈在CPU也就是说Redis实例的CPU使用率长期超过80%且你确认热点都集中在命令执行上那么多线程IO帮不了你。这时你应该考虑的是集群扩容、拆分大key、优化命令比如用pipeline代替逐个命令。如果你的瓶颈在IO比如大量并发连接导致网卡中断处理不过来、或者单条命令返回的数据量很大那么多线程IO是有效的优化手段。建议先开启io-threads写线程压测验证收益后再考虑是否开启io-threads-do-reads。如果你的瓶颈在内存或磁盘多线程IO完全无关。你需要关注的是RDB/AOF配置、内存淘汰策略、以及是否需要换更大的内存机器。升级到6.x或7.x本身是安全的兼容性也很好。但生产环境还是要先在预发环境压测一下观察p99延迟和内存碎片率有没有异常波动。我见过有团队一升到6.2就打开io-threads8结果P99从1ms涨到3ms最后乖乖改回默认配置。多线程不是越多越好而是刚好够用最好。7.1 一个现实的抄作业配置如果你确实想在生产环境使用多线程IO我给出一个相对保守的起步配置io-threads 4 io-threads-do-reads no前提是机器至少4核且Redis独占CPU不要和数据库、消息队列混部。客户端使用连接池至少有20~50个连接。压测确认吞吐量有提升且p99延迟变化在可接受范围内。开启后持续观察一周关注slowlog和内存监控。如果一周内没有异常再考虑把io-threads-do-reads打开但务必在压测环境充分验证后再上。7.2 Redis 7.x的额外选择Redis 7.x在IO线程调度上明显更成熟还引入了新的多线程刷盘机制。如果你已经准备升级我建议越过6.x直接上7.2不仅IO线程更稳内存效率也有提升。但依然要遵循先压测后上线的原则。8. 从线程模型反推使用姿势理解了Redis的线程模型会发现很多使用上的潜规则都有了解释。比如为什么Redis的key要尽量短因为key越长内存消耗越大SDS存储和比较的开销也越大。为什么推荐用pipeline批量操作因为一次网络往返的开销远比命令执行本身高pipeline把多条命令打包一次性减少往返次数。为什么不要用KEYS命令因为KEYS会遍历整个字典命令执行阶段是单线程的遍历期间其他请求全部排队。为什么big key要避免因为删除这类大key时即使后台线程负责释放内存主线程在摘除key时也要消耗不少CPU可能造成短暂阻塞。这些经验不是凭空总结的每一条背后都能对应到线程模型的某个特征。当你下次听到Redis为什么快不只要说单线程和epoll还要能补一句但正因为它快所以它更怕慢命令和跨网络的高延迟。而当你面对线上Redis性能问题时排查思路也会清晰很多先看慢日志如果慢日志里清一色是KEYS、SORT、大key删除这类命令说明是命令本身太重如果慢日志很少但整体延迟高就要看是不是客户端网络抖动、或者客户端连接数太多导致IO线程频繁被唤醒。线程模型决定了Redis的确定性也决定了它的弱点所在。我个人在实际排查中总结出一个经验先用redis-cli --latency看整体延迟再用SLOWLOG GET看命令级延迟最后才去看CPU和内存。这三步走下来90%的性能问题都能定位。线程模型的知识更多是帮你理解为什么这个命令会慢而不是直接告诉你怎么改。搞清楚机制你才能在变化万千的业务场景里做出合理的判断。
返回列表