前阵子有一个同事跑来找我,说他们负责的某个内部系统的Redis实例突然大面积超时,CPU和内存看上去都正常,连接数也不高,但所有读写就是卡住。最后查下来,是有人图省事,在线上直接执行了一条KEYS *。这个命令在那个实例上跑了十几秒,而几乎所有请求都要排队等它执行完。这个问题的根源不是什么复杂的网络故障,就是Redis的单线程模型。
“Redis是单线程的”这句话做开发的多少都听过。但很多人对它的理解只停留在“所以Redis快”这一个结论上,稍微往深问一点,比如单线程到底指的是哪条链路、为什么不能用KEYS、Redis 6.0引入多线程IO后又变了什么,就答不上来了。
这篇博客我打算把“Redis单线程模型”这件事从头到尾讲透:先搞清楚它说的是什么,再解释为什么单线程反而很快,接着展开IO多路复用和事件循环的核心机制,最后结合线上排障的实战经验,把单线程模型的“红利”和“坑”一起说清楚。不管你是刚接触Redis的新手,还是已经被线上问题折磨过的老手,这篇内容应该都能帮你把脑子里那团模糊的概念理顺。
1. 先搞清楚:单线程到底是指哪一段
1.1 “Redis是单线程的”这句话并不完全准确
很多文章和面试回答喜欢直接说“Redis就是单线程的”,这句话用来应付初级问题可以,但严格来说是不准确的。
准确的说法是:Redis有一个主线程,负责接收客户端请求、解析命令、执行命令、返回结果,这条用户请求处理链路在Redis 6.0之前完全由一个线程完成。日常我们说“单线程模型”,指的就是这条命令执行链路只有一个线程,所有命令按顺序一个一个执行,不会有两个命令同时被执行。
但一个Redis进程里并不是只有这一个线程。比如RDB持久化和AOF重写会通过fork子进程来做,过期键需要异步清理,UNLINK删除大key时也有后台线程在帮忙释放内存。也就是说,单线程模型是针对命令执行的主链路而言的,并不是说整个Redis进程从头到脚都只有一个线程在干活。
理解了这个前提,后面很多问题就好解释了:既然命令是串行执行的,那么只要某一条命令在关键时刻掉链子,后面所有的请求都会排在它后面,形成“假死”一样的效果。
1.2 一个请求在主线程上到底经历了什么
为了更直观,我们把一次完整的Redis请求处理流程拆开看,假设你现在用客户端执行了一条SET name "hello",这条命令在服务端主线程里会经过这么几步:
- 主线程调用
epoll_wait等事件通知,拿到当前有数据可读的连接的socket信息。 - 从socket缓冲区中读取数据,拿到一串符合RESP协议的命令文本或者二进制内容。
- 解析这个命令,识别出这是
SET,并且把参数name和"hello"提取出来。 - 执行命令,也就是在内存里的哈希表中写入
name这个key和对应的value。 - 把执行结果(也就是
OK)编码成RESP协议格式,写回客户端的socket。
注意,在Redis 6.0之前,这五步全部由主线程完成。就算有100万个连接同时在请求,最终每一步都是这一个线程在处理。
这个流程也解释了为什么“单线程模型”会对性能那么敏感:整条链路从头到尾是串起来的。前面四步如果都很快,那一切没问题;但只要某一步出现了一个耗时操作,比如读取了特别大的数据、解析了超长的参数、执行了一个O(N)的命令、写回了巨大的响应,后面的命令就全都要等着。这就是单线程模型最核心的特征,也是一切性能问题的总根源。
2. 为什么一条线程反而能跑这么快
2.1 Redis的瓶颈从来不是CPU,而是网络和内存带宽
很多人第一次听到“Redis用单线程”时,第一反应都是:单线程怎么可能快?多线程不是应该更快吗?但Redis的设计者很早就说过,Redis的性能瓶颈通常不在CPU,而在网络IO和内存带宽。
为什么?因为Redis是一个基于内存的数据库。数据全都躺在内存里,读写一个key就相当于对内存中的哈希表做一次插入、查询或者更新。内存的随机访问延迟大概是几十纳秒到一百纳秒,而一次网络请求的传输延迟都是以微秒甚至毫秒来计算的。两者根本不在一个数量级上。
也就是说,Redis执行一条命令本身消耗的CPU时间非常少,大部分时间其实都花在了网络读写上。只要网络IO能跟上,单线程处理几十万QPS是非常轻松的事。相反,如果强行引入多线程,线程切换、锁竞争、内存屏障这些开销反而会拖慢整体速度,让原本简单的问题变复杂。
2.2 多线程和锁,在Redis这种场景里是负担
我们平时说“多线程能提升性能”,这句话成立的前提是任务可以被并行化,而且并行带来的收益能覆盖线程调度的开销。
比如一个CPU密集型的计算任务,为了充分利用多核CPU,确实需要多线程。再比如一个任务里有大量的磁盘IO或网络IO等待,线程可以在等待时切换出去干别的,这时候多线程也很有价值。
但Redis的命令执行不是这样。命令本身都是内存操作,执行得极快,几乎没有等待。如果给Redis加多线程,就要考虑多个线程同时操作内存里的同一个key怎么办,必须加锁或者引入同步机制。而锁竞争一出现,性能往往比你想象的还要惨。
有个类比我觉得很贴切:一个厨师做一道快手菜只需要几十秒,你给他配十个帮厨,菜也不会变快多少,反而要花大量时间沟通“谁拿锅谁拿勺”,最后锅都抢坏了。Redis选择单线程,本质上是选择了一种容易保证正确性、又足够高效的实现方式。
2.3 底层数据结构的高效也是隐藏加速器
除了单线程本身,Redis快还有一个容易被忽略的原因:它内部的数据结构设计得极其讲究。
比如字符串不是直接套用C语言的字符串,而是自己设计了简单动态字符串,避免了很多不必要的内存拷贝和长度计算。哈希表用了渐进式rehash,rehash过程不会一次性卡住主线程,而是分多次慢慢完成。列表和哈希在元素少的时候用紧凑的压缩列表存储,节省内存又提高缓存命中率;元素多了才升级成链表或者哈希表。有序集合还用了跳表这种实现起来简单、查询效率也不错的索引结构。
这些优化让命令的执行时间非常短。没有这些数据结构做支撑,单线程模型是跑不出高QPS的。数据结构的高效和单线程模型是相辅相成的:一个让单条命令极快,一个让命令之间无缝衔接。
3. 支撑单线程高并发的关键:IO多路复用与事件驱动
3.1 没有IO多路复用,单线程根本扛不住高并发
如果Redis单线程只是简单地在循环里阻塞地等待每个连接,那别说高并发,就是几十个连接都伺候不过来。Redis真正厉害的地方在于,它用一套事件驱动的机制,让一个线程能够同时监听成千上万个连接。
这套机制在Linux上就是epoll。你可以把epoll想象成一套排号叫号系统:Redis主线程注册好自己感兴趣的socket事件,然后调用epoll_wait去等待。内核会在有socket可读或者可写的时候主动通知Redis,而不是让Redis挨个去问每个socket“你好了没有”。
如果没有IO多路复用,通常的做法是给每个连接创建一个线程去处理,这叫“一个连接一个线程”模型。这种模型在连接数少的时候可行,但连接数一旦上万,光线程上下文切换的开销就能把CPU吃满。而IO多路复用让Redis用极少的资源管理超大数量的连接,这也是它能支撑那种动不动几万几十万并发连接的技术底座。
3.2 事件循环里藏着文件事件和时间事件两种任务
Redis内部自己封装了一个事件库,在Linux平台用的是基于epoll的实现。整套机制的核心是一个事件循环,主要处理两类事件:
一类是文件事件,也就是socket的可读、可写事件。这是最常被触发的,比如客户端发来了一条命令,可读事件触发,主线程就去读取数据并执行命令。
另一类是时间事件,Redis会周期性地执行一些后台维护任务,比如更新统计信息、清理过期key、检查是否需要触发持久化等等。比较典型的是serverCron,它每隔100毫秒左右会运行一次。
这两类事件在同一个循环里协同工作。Redis会先调用epoll_wait获取当前触发的文件事件,处理完那些有数据的socket之后,再去检查时间事件有没有到点。这种设计保证了命令处理的实时性,同时也能把一些维护工作顺便做了。
3.3 为什么epoll比select和poll强这么多
在epoll出现之前,select和poll是Linux下使用比较广泛的IO多路复用方案。它们有两个比较明显的短板:一是每次调用都要把全部监听的文件描述符从用户态拷贝到内核态,二是内核需要线性扫描所有描述符才能知道哪些就绪。连接数量上去之后,效率和扩展性都很差。
epoll改善了这个过程。它会维护一张内核事件表,用户只需要把要监听的fd注册一次,之后内核直接告诉进程“谁就绪了”,不需要反复拷贝全部fd,也不需要线性扫描。在大量空闲连接存在的情况下,epoll的优势尤其明显。
Redis在实现ae_epoll时用的也是比较稳妥的水平触发模式。简单理解就是:只要socket上还有数据没读完,内核就会反复通知你。这样即使某一次处理到一半被打断了,也不会存在漏读事件的问题,逻辑上简单很多。这套机制保证了Redis在连接数极高时依然能够稳定地收包、处理、回包。
4. 单线程模型的两面性:原子性的红利,和阻塞的代价
4.1 红利:命令天然具备原子性
单线程模型最直接的好处之一,就是所有命令的执行都是串行的,不存在两个命令同时修改同一个key的情况。因此,单个Redis命令天然是原子的。
这一点在开发里太好用了。比如你想做一个计数器,让每次请求进来INCR一下,你不需要考虑并发下“读出来是1,还没写回去,另一个线程又读出来还是1”这种问题。因为Redis主线程在执行这条INCR时,不会允许别的命令插进来。
再比如分布式锁,用SET key value NX PX 10000现成搞定,核心就是靠单线程下“不存在并发写”这个特性,保证同一时刻只有一个客户端能设置成功。
那很多人会问,如果我想把多个操作组合在一起保持原子性怎么办?Redis也给了方案:可以在服务端用Lua脚本。因为脚本也是在主线程里执行的,只要脚本还没执行完,其他客户端命令就不会被执行,所以Lua脚本相当于把一串命令整体变成了一个原子操作。
4.2 代价:一条慢命令能让所有请求陪葬
原子性听上去很美好,但硬币的另一面是:串行处理也意味着“一颗老鼠屎坏了一锅汤”。
最典型的就是KEYS命令。KEYS *会遍历整个键空间,如果实例里的key有几百万个,这个命令执行期间,其他所有命令都得排队。线上真实情况比这还糟,因为KEYS在返回所有key的同时还要构建一个大列表作为结果回包,如果匹配结果特别多,光是序列化并写回响应都会卡很久。
类似的高危操作还有:对一个包含百万元素的大集合执行SMEMBERS、对大列表做SORT、在Lua脚本里写死循环、直接删除一个特别大的key导致内存释放阻塞等等。
如果你在线上因为某些需求需要遍历key,请记住用SCAN而不是KEYS。SCAN是基于游标分批迭代的,每次返回少量数据,对主线程的影响小得多。这个经验不是理论,是很多线上事故用血换来的。
4.3 不光是命令行,fork和内存分配也能卡住主线程
很多人以为只有“慢命令”才会阻塞Redis主线程,其实还有一种很容易被忽略的阻塞来源:持久化时的fork。
Redis做RDB快照或者AOF重写时,通常会先调用fork创建一个子进程来做持久化。fork这个操作本身必须由主线程调用,而且它需要复制父进程的页表等元数据。实例内存越大,fork耗时就越长。一个几十GB内存的实例,fork一下卡个几百毫秒甚至更久是真实存在的事情。
另外,删除一个大key之所以危险,表面上是DEL命令执行时间太长,但更底层的耗时往往来自内存释放。当这个key占用了几百MB内存时,释放内存并做内存管理器的合并回收,同样是一个耗时操作。所以Redis后来提供了UNLINK,让真正的内存回收放到后台线程去做,主线程只负责标记一下。
理解了这些,你就能明白为什么Redis官方文档总是强调:不要让实例内存过大,不要在高峰期做全量持久化,不要用DEL删除大key。因为这些操作表面上和“命令执行”无关,实际上都会把单线程主循环卡住。
5. 用单线程模型重新审视:什么场景适合Redis
5.1 典型适合场景:缓存、计数、排行榜、限流、分布式锁
理解了单线程模型的能力边界,你对Redis能干什么、不能干什么会更有把握。
缓存当然是最经典的使用场景。读多写少的业务数据,放进Redis里,能抗住极高的读压力。因为读操作本身基本都是O(1)的内存查询,单线程处理几十万QPS也没问题。
计数器类需求也很适合。比如文章阅读量、点赞数、用户在线数量,直接用INCR/DECR,天然原子,不需要额外锁。限流场景同样如此,可以用INCR加上EXPIRE实现一个简单的固定窗口计数器。
排行榜用有序集合,得分存在ZSET里,维护和查询Top N都非常自然。分布式锁用SET key value NX PX,配合单线程原子性,简单可靠。
简单队列或者发布订阅也可以做,像LPUSH/BRPOP组合实现任务队列,PUBLISH/SUBSCRIBE实现消息广播。用Redis做这些事很适合,因为模型简单、命令快,不太会触发单线程阻塞问题。
5.2 不适合或者要非常小心的场景
单线程模型决定了Redis不适合干“重活”。
首先,复杂聚合和批量扫描类需求要小心。比如你要同时对几个大集合做交集、并集,或者全量遍历一份很大的数据,这类操作会让主线程陷入长时间的算力消耗。不是不能做,而是必须在低峰期、在副本上、或者接受它可以会阻塞其他请求的事实。
其次,大Value场景要避免。一个字符串value动辄几MB,写入时要分配内存、网络读写时要拷贝传输,对单线程来说都是不小的压力。最好把大对象拆散,或者在客户端压缩后再放进去。
第三,强持久化和强一致的存储需求不适合直接用Redis。Redis虽然支持RDB和AOF两种持久化,但默认配置下主线程不会等待强同步,发生宕机时仍然存在数据丢失窗口(比如AOF刷盘频率不是每写必刷的情况下)。如果业务对数据丢失零容忍,建议把它当缓存而不是事实源,或者配合其他存储方案使用。
5.3 扩展时的思路也要基于单线程模型
单实例Redis的吞吐量再高,也总有达到上限的一天。当一条主线程已经跑到了极限,靠给Redis加多线程并不能彻底解决问题。此时更常见的扩缩容手段是搭建Redis Cluster,把数据按槽位分散到多个实例上,让请求分摊到多个进程、多台机器。
从单线程模型的视角看,集群相当于把“一个主线程干活”变成了“多个主线程并行干活”,每个分片内部的命令还是串行执行的,但分片之间天然隔离了阻塞风险。这也是为什么Redis Cluster的设计如此重要。
6. Redis 6.0引入多线程IO后,模型发生了什么变化
6.1 多线程IO优化的是网络读写,而不是命令执行
很多人在今天还会听到“Redis不是多线程了吗”的说法,这是对Redis 6.0变化的误解。
Redis 6.0确实引入了多线程IO,但它优化的是网络数据的读写,而不是命令的执行。简单来说,当一个客户端发来大量请求时,主线程可以借助多个IO线程并发地从各自连接的socket中读取数据、或者并发地把响应数据写回客户端。这个阶段有点像把“接待接电话”的工作分给多人同时做。
但命令本身依然集中在主线程里执行。所以命令执行的原子性依然保留,也不会出现两个命令真正并行操作同一个key的情况。多线程IO解决的是高网络吞吐场景下,单个线程处理socket读写成为瓶颈的问题。
也就是说,Redis 6.0之后更准确的描述是:“网络IO可以多线程,命令执行依然是单线程”。这里面的界限要分清。
6.2 配置细节和实测中的注意点
Redis 6.0的多线程IO默认是关闭的,需要在配置文件里设置io-threads,比如设置为4。官方文档也提醒,这个参数建议根据机器核数来调整,不要盲目设置成很大,通常不要超过CPU核心数,在4核机器上设置2到3个线程已经比较常见。
另外一个很关键的口子:在6.0的默认行为里,多线程IO只用于处理响应数据的写回,也就是“往客户端写”这个环节。要让IO线程也参与读取客户端发来的命令,还需要额外把io-threads-do-reads打开,默认是no。
开启多线程IO后,建议先做压测验证,因为并不是所有负载都能感受到明显提升。如果你的实例本身命令执行量不大,网络也没有触顶,多线程IO带来的收益可能非常有限,甚至因为多线程内部同步机制反而增加了一点开销。
还要注意,多线程IO并不会让一条慢命令变得更快,也不会消除命令执行的排队。任何会阻塞主线程的操作,比如一个超长的KEYS,在多线程IO开启后依然会导致整体卡顿。所以前面讲的那些避坑原则,在6.0、7.0版本里依然成立。
7. 实战中如何排查和避免单线程阻塞
7.1 先开慢日志,这是定位阻塞的第一现场
排查Redis性能问题,建议第一个打开的就是慢日志。Redis默认的慢指令阈值是10毫秒,也就是执行超过10毫秒的命令会被记录到慢日志里。
你可以用SLOWLOG GET查看最近记录,用SLOWLOG RESET清空后重新观察。如果线上出现卡顿,登录实例先看一遍慢日志,很多时候问题原因就写在里面:是哪个命令、执行了多久、是谁发出来的,一目了然。
需要注意,慢日志记录的是“命令执行耗时”,但网络读写和socket写入慢不一定能体现在这里面。比如你请求一个超大key,如果慢在网络传输而不是命令执行,慢日志里可能看不到明显记录。这种情况要结合下一节的监控工具来看。
7.2 通过INFO、latency命令和bigkeys扫描找证据
Redis提供了几个非常适合排查问题的命令。
INFO commandstats会统计每条命令累计被调用的次数以及消耗的时间,可以按平均耗时排序,找出那些对整体影响最大的命令。如果发现某个命令的调用次数不多,但总时长很高,说明它在拖慢整体。
redis-cli --latency可以测量到你本地到Redis实例的实时网络延迟,如果延迟数值一直在跳动,可能是网络问题,也可能是Redis本身在间歇性卡顿。
至于大key,可以用redis-cli --bigkeys来扫描。它会分析每个类型中最大的若干key,但不建议在业务高峰期跑,因为扫描本身也会产生一定压力。找到大key之后,再根据业务情况决定是拆分、压缩还是用UNLINK删除。
7.3 线上常见的几个阻塞现象及对应处理
下面列几个我实际见过的问题,以及相应的排查和处理思路:
| 现象 | 可能原因 | 常用处理手段 |
|---|---|---|
| 所有命令间歇性超时 | 大key删除、RDB fork、AOF fsync | 用lastsave看持久化时间;改用UNLINK;错峰执行持久化 |
| 某个时段延迟明显上升 | 大量key同时过期 | 给过期时间加随机偏移;避免同一时间触发大批量惰性删除 |
| QPS不高但耗时很高 | 存在KEYS、SMEMBERS这类O(N)命令 | 查看慢日志;用SCAN替代;对于集合读取使用分批遍历 |
| 内存不高但主线程卡死 | 单个特大字符串或哈希 | 用--bigkeys扫描;压缩或拆分大value |
| 某客户端持续占用连接 | 客户端在循环MONITOR | 建议关闭MONITOR,换用慢日志或指标监控 |
7.4 平时开发中的一些避坑习惯
最后分享一些我一直在用的技术习惯,虽然都很基础,但确实能挡掉很多事故。
第一,线上禁用高危命令。如果用的是云厂商的Redis或者自建环境,建议通过配置文件直接禁用KEYS、FLUSHALL、FLUSHDB、DEBUG等命令;或者至少设定为只能在本机执行。
第二,控制单条value的大小。开发时就要约定好,单个字符串value上限不要超过几百KB。如果发现有超过1MB的value,就要考虑换存储方案或者做内容拆分。
第三,写代码时避免无脑调用。比如在循环里对Redis发一大批请求,完全可以用pipeline合并,一次网络RTT搞定,既减轻网络压力,也减轻主线程的处理次数。
第四,给key的过期时间加抖动。比如“7天过期”不要全部写死,而是随机在7天到8天之间选定,避免某一天出现大规模的key同时过期,给主线程造成集中清理压力。
第五,使用低峰期做重操作。比如大集合的排序、全局扫描、全量取数,尽量放到流量低的时候,或者直接连到只读副本上执行。
写在最后
我在实际排查Redis问题过程中最深的体会是:单线程模型不是一个“弱点”,而是一种设计取舍。它用较低的实现复杂度换来了命令执行的简单性和可预期性,代价是要求使用者必须清楚哪些操作会让主线程付出过长的时间。很多线上事故并不是Redis本身的问题,而是使用方对模型理解不到位,把一个不适合单线程场景的操作压进了主线程。理解了单线程模型真正的边界,你在设计缓存、计数、锁和队列这些方案时,会自然避开那些高危操作,排查问题时思路也清楚很多。