ARTICLE DETAIL

资讯详情

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

Redis线程模型进化史:从单线程事件循环到多线程IO与后台异步化

Redis线程模型进化史:从单线程事件循环到多线程IO与后台异步化 如果你平时在追 Redis 源码或者正在准备后端面试你会发现有个话题怎么都绕不开Redis 线程模型与 IO 模型的进化史。从早期的单线程事件循环到 6.0 引入多线程 IO再到 7.x 对后台任务和持久化链路的进一步优化这套模型每一步变化背后都对应着真实的线上性能痛点。我见过不少同学一听到“Redis 是单线程”就直接下结论说它水平扩展不行也见过有人把io-threads调到 16 之后反而把实例性能拖垮。这篇文章我会按时间线把模型演化的来龙去脉讲清楚结合源码思路和生产环境里的实际调参经验给正在做 Redis 运维、后端研发或者准备面试的同学一份能直接落地的参考。1. 为什么 Redis 的线程模型值得单独写一章很多人把 Redis 的“线程模型”简单理解为“单线程”但这个理解太粗糙了。Redis 从早期到现在的架构演变其实是一部典型的“单线程应用如何在高并发场景下做最优取舍”的教科书。搞懂它你不仅能解释清楚为什么 Redis 快还能在线上出现延迟尖峰时快速判断问题到底出在网络层、命令执行层还是后台持久化层。1.1 单线程 Redis 凭什么能扛住百万级 QPS先回答一个被问烂了的问题单线程的 Redis 为什么还能跑出几十万甚至百万级别的 QPS很多人只记住了“因为 Redis 是内存数据库”但这只是答案的一部分。真正核心的是三件事叠加在一起。第一纯内存访问。数据都放在内存里每次操作省去了磁盘寻址和机械 IO 的开销。内存随机读写的延迟大概在几十纳秒到微秒级别而磁盘随机 IO 的延迟通常是毫秒级别这里差了两个数量级。第二IO 多路复用 事件驱动。Redis 的网络处理不是“来一个连接就阻塞等数据”而是用epoll、kqueue这类多路复用 API 统一监听大量 socket 的事件然后把事件分发给对应的处理器。用一个生活类比来说普通阻塞模型就像只有一个服务员一桌客人点完菜他就站着等这桌吃完才去服务下一桌而 Redis 的处理方式是这个服务员同时盯着所有桌的情况哪一桌有人举手示意他就跑过去记菜单处理完马上回来继续盯着所有客人。第三单线程规避了锁竞争和上下文切换。如果使用多线程处理命令每次操作共享数据结构都要加锁读多写少时要上读写锁锁的争抢和线程间上下文切换会带来大量额外开销。Redis 把命令执行放在单线程里数据结构的访问天然是原子的不需要加锁CPU 缓存命中率也更高。这三件事缺一不可。纯内存存储如果没有事件驱动单线程就只能在阻塞 IO 里空等网络QPS 依旧上不去事件驱动如果没配合无锁数据结构性能也会被锁竞争拖累。所以 Redis 单线程模型不是“赌对了单一因素”而是整套设计互相咬合得很紧密。1.2 “Redis 是单线程”这句话其实不严谨如果严格抠字眼从 Redis 4.0 开始“Redis 是单线程”这句话就已经不成立了。准确说法是Redis 的命令执行主逻辑由主线程串行处理但网络 IO 读写、释放大对象、AOF fsync 等任务可以由独立线程或子进程完成。简单梳理一下 Redis 进程内部到底有哪些“干活的角色”角色数量主要职责主线程1 个事件循环、命令读取解析、命令执行、数据读写、慢日志统计IO 线程池从 6.0 起默认关闭处理 socket 的读和写搬运网络数据到输入缓冲区/从输出缓冲区写回bio 后台线程多个lazy free 异步释放大对象、AOF fsync、关闭文件描述符子进程fork 产生RDB 持久化快照、AOF 重写利用 COW 机制与主线程并行比如你在 Redis 4.0 之后用UNLINK删除一个大 key主线程不会真的立刻去释放那块内存而是把这个释放任务丢给bio_lazy_free线程。再比如 RDB 持久化主线程fork一个子进程子进程负责把快照写入磁盘主线程继续处理命令。所以更严谨地说Redis 的模型是“命令线程单线程化 周边任务多线程化”。这个认识在面试和实际排障中都特别重要。如果你只盯着“单线程”看遇到一个 Redis 实例 CPU 多核使用率不同就会误以为程序出 bug 了实际上这恰恰说明主线程和 IO/后台线程分工明确。2. 前传从阻塞 IO 到多路复用的事件驱动内核要讲进化史得先看 Redis 之前是什么状态。我最早接触 Redis 时也觉得直接基于最朴素的 socket 编程不也能提供服务吗确实可以但那种“朴素”实现很快会在高并发连接下崩溃。Redis 早期版本在设计上就一步到位选择了基于事件驱动的 IO 多路复用模型这一步奠定了后续单线程执行的基础。2.1 最原始的阻塞模型为什么不行想象一个最简单的 TCP 服务端先accept()等一个客户端连接然后read()等客户端发数据处理完再write()发回去最后关闭连接。这种模型有两个致命问题。第一个问题是进程会被阻塞在慢客户端的读写上。只要有一个客户端连接了很久不发数据或者网速很慢服务端进程就一直卡在read()调用上后面排队的客户端全部没法被accept()服务瞬间“假死”。第二个问题是一个线程只能处理一个连接要支撑大量连接只能用多线程或 IO 多路复用。早期很多 Web 服务器就是“一个连接一个线程”的思路连接数一涨线程数跟着涨上下文切换成本暴涨最终性能瓶颈很快出现。Redis 面对的场景又是高并发短命令客户端可能同时保持几万甚至几十万个连接但绝大多数连接在任意时刻都是空闲的。如果每个连接都占用一个线程内存和 CPU 都会浪费在等待上。所以 Redis 需要一种“单进程能同时监听海量连接并在其中某个连接真正有数据可读/可写时才去处理它”的机制这就是 IO 多路复用。2.2 文件事件处理器Redis 事件驱动内核的骨架Redis 自己封装了一套跨平台的事件驱动库名字是aeAsync Event。它内部会按操作系统自动选择最优的多路复用 APILinux 上优先使用epollmacOS/BSD 上使用kqueueSolaris 上使用evport实在不行才回退到select。这套事件驱动库的核心是“文件事件”机制。Redis 把每一个 socket 都抽象成一个文件事件主要分为三类可读事件客户端有请求数据到达或者有新的连接到达。可写事件输出缓冲区里有数据可以写回客户端。时间事件不是基于 socket 的而是基于时间触发的定时任务比如serverCron这个周期执行的后台任务会负责过期键检查、AOF 重写调度、统计信息刷新等。整个事件循环的骨架可以简化理解为// Redis ae 事件循环的简化逻辑 while (!eventLoop-stop) { // 阻塞等待多路复用事件最多等待 timeval 指定的时间 numevents aeApiPoll(eventLoop, timeval); // 依次处理每个 socket 文件事件 for (j 0; j numevents; j) { // 如果可读调用读处理器 if (mask AE_READABLE) { fe-rfileProc(eventLoop, fd, fe-clientData, mask); } // 如果可写调用写处理器 if (mask AE_WRITABLE) { fe-wfileProc(eventLoop, fd, fe-clientData, mask); } } // 处理时间事件如 serverCron processTimeEvents(eventLoop); }这里背后最重要的一点是epoll相对select的优势select每次调用都要把所有 fd 从用户态拷贝到内核态再线性扫描一遍复杂度是 O(N)一旦 fd 数量上千就明显变慢。而epoll由内核维护 eventpoll 对象调用epoll_ctl注册 fd 之后内核只把真正发生了事件的 fd 回调给用户态程序复杂度是 O(事件数)。所以 Redis 才能做到几万个连接依然低延迟。3. 单线程模型优势、瓶颈与暗礁到了 3.x 时代Redis 的网络层和命令执行层全部走主线程单线程事件循环。绝大多数人熟悉的“Redis 单线程性能剖析”都是针对这个阶段。它有很明显的优势也为后面 6.0 的多线程 IO 埋下了伏笔。3.1 单线程省下的三笔关键开销为什么 Redis 作者 Antirez 当时宁愿放弃多核利用率也要坚持单线程一个重要原因是在内存操作极快的场景下多线程带来的同步成本可能远大于性能收益。单线程省下的开销主要有三笔。第一线程上下文切换。多线程程序在 CPU 核之间切换时需要保存和恢复线程上下文频繁切换甚至可能让 CPU 大量时间花在切换本身而不是干正事上。单线程程序不存在这个问题。第二锁竞争。多线程操作共享内存数据结构最稳妥的办法是加锁。Redis 的数据结构包括哈希表、跳表、压缩列表、双向链表等如果多线程并发读写这些结构每个操作都要上锁释放锁锁的竞争会让性能雪崩。单线程直接把“并发正确性”问题从根源消解掉了。第三CPU 缓存命中率。程序访问内存时CPU 会缓存最近用到的数据到 L1/L2 Cache。单线程连续操作同一个数据结构热点数据大概率在缓存里命中率很高。多线程频繁切换操作不同的数据结构缓存命中率反而下降。这三点加在一起让单线程 Redis 在“命令都很快、并发请求密集、数据都能放内存”的场景下跑的比很多自以为能多核并行的服务还快。3.2 瓶颈不在 CPU而在网络 IO 与阻塞命令单线程模型的脆弱点也很明显只要有一条命令执行很慢后面所有客户端请求都得排队等它。我举一个自己踩过的坑。当时公司有个 Redis 实例存了一个 5MB 大小的 string key平时没人碰它但每十分钟有个定时任务会去更新这个 key。更新本身问题不大问题在于大 key 传输和拷贝消耗的时间很长主线程执行一次 SET 或者 GET 大 key 的耗时能到几十毫秒。这几十毫秒里所有读写请求全部阻塞整个业务链路出现剧烈延迟抖动。单线程模型下的慢操作通常来自几个方面大 key 操作一个几 MB 甚至几十 MB 的 string 或 hashGET、HGETALL、SET都可能让主线程卡顿几十毫秒。复杂命令比如KEYS *、SMEMBERS一个包含百万成员的 set时间复杂度 O(N) 甚至 O(N^2)。阻塞命令BLPOP、BRPOP这类命令如果 key 不存在Redis 会把客户端挂起主线程本身不阻塞但如果多个阻塞客户端同时被唤醒恢复处理也可能带来尖峰。网络读写系统调用集中6.0 之前主线程要负责把每个客户端的请求数据从内核缓冲区读到用户态再把计算结果write()回客户端。如果请求或响应很大这里的主线程read/write系统调用本身就是隐性开销。这里要特别强调一个容易被误解的点慢操作不是由“单线程”自己造成的而是“慢命令”被单线程串行放大造成的。就算换成多线程命令执行一个大 key 的操作也会占用大量 CPU 和内存带宽只是后续请求可能不用排队等它。Redis 的解法不是无脑上多线程而是想尽办法把这些“脏活”从命令执行线上剥离出去这就是第 4 节要讲的重点。4. Redis 6.0 多线程 IO迟来的升级与配置实操Redis 6.0 是线程模型进化史上最关键的一个分水岭。它终于引入了多线程但又有意控制在线程使用的边界上让主线程的命令执行逻辑保持单线程不变。很多人听到“6.0 有多线程了”后兴奋地去调参结果并不理想所以这里我把原理和配置实操都讲清楚。4.1 为什么 6.0 才引入多线程而且只做 IO先明确一点6.0 的多线程不是把命令执行变成多线程而是把“从客户端 socket 上读取数据”和“把结果写回客户端 socket”这两个环节从主线程剥离出去由一组 IO 线程池负责。命令的解析、执行、数据结构的访问仍然全部由主线程串行完成。这套设计很像餐厅里“厨师 配菜员 传菜员”的分工主线程相当于厨师只负责炒菜也就是执行指令、操作数据。IO 线程相当于配菜员和传菜员把食材网络请求洗好切好放到备餐台输入缓冲区再把炒好的菜响应结果端给客人客户端。为什么只做 IO因为从瓶颈分析来看单线程环境下最痛的一项开销往往不是 CPU 计算而是网络数据的搬运。请求到达时主线程要调read()把数据从内核复制到用户空间响应离开时又要调write()把数据从用户空间复制给内核。在大流量、多连接场景下这部分系统调用和内存拷贝会占用大量主线程时间间接拉高命令执行的排队延迟。把这些任务分给 IO 线程主线程就能从“抢着搬砖”的状态中解放出来专心处理命令执行。同时保持命令执行单线程还有一层大家容易忽视的考量Redis 很多高级特性依赖命令原子的串行执行。比如 Lua 脚本的原子性、事务的队列执行、分布式锁依赖的SETNX语义一旦命令执行也变成多线程这些特性的并发控制就会变得极其复杂甚至彻底破坏语义。所以 6.0 选择了最稳妥的“半多线程”方案。4.2 io-threads 参数怎么设置最合理Redis 6.0 提供了两个关键配置项io-threads 4 io-threads-do-reads yesio-threads表示 IO 线程总数包括主线程默认值是 1也就是不启用多线程。如果设为 4代表 1 个主线程加 3 个 IO 线程。官方文档的建议是如果机器有 4 核可以设为 2 或 3如果机器有 8 核可以设为 4 或 6。核心思想是IO 线程数不需要等于 CPU 核数更不是越大越好。IO 线程的工作只是搬运数据本身不是重计算线程太多反而会增加线程同步、缓存失效和调度开销。io-threads-do-reads默认是no意味着即便开了多线程默认也只会把“写回客户端”这一半交给多线程读请求还是由主线程自己做。官方这个默认值背后是有实测依据的客户端请求通常是小数据包读的耗时本来就低而响应数据往往更大写回操作更值得多线程分担。除非你确认实例的 read 系统调用已经明显拖累主线程否则建议先保持默认不要盲目开启读多线程。我自己的调优经验是分三步走第一步观察当前瓶颈。使用redis-cli --latency和SLOWLOG GET看延迟和慢命令。如果延迟高但慢日志里命令执行时间很短说明问题可能出在网络读写。第二步逐步调大io-threads。先改成 2观察延迟和 CPU 使用率再改成 4每跑一天看指标。不要一次跳太多。第三步验证是否真的生效。在 Redis 6.0 以上版本执行redis-cli info stats在输出里找io_threads_active这个字段如果值是 1说明 IO 多线程已经激活。同时用top -H -p redis_pid观察进程内线程状态可以看到io_thd_1、io_thd_2这类线程名确认它们是否占用了不同的 CPU 核。配置项默认值建议范围说明io-threads12 ~ 4填的是总线程数包含主线程io-threads-do-readsno通常保持 no开启后读请求也由 IO 线程处理io-threads-active动态状态0/1通过 INFO stats 查看我还见过一个反面案例有人把io-threads配成 16等于 1 个主线程加 15 个 IO 线程结果延迟反而升高了。原因在于 IO 线程在处理完数据后要和主线程做同步这个同步成本和线程间 contention 已经超过了 IO 并行带来的收益。所以多线程 IO 不是越多越爽它对场景很敏感。5. 后台线程与 fork看不见的并发机器如果说 6.0 的 IO 多线程是 Redis 从“纯单线程”走向“混合多线程”的公开一步那么后台线程和 fork 子进程就是 Redis 早就在用的“隐形并发”。很多人排查线上问题时会忽略它们但它们恰恰是很多诡异延迟和内存突增的根源。5.1 bio 线程卸载主线程的脏活Redis 在 4.0 引入了一个bio后台线程机制bio的全称是 Background I/O它专门处理主线程不想碰的三种任务关闭文件描述符。AOF 文件fsync刷盘。lazy free也就是异步释放对象内存。举个最典型的场景早期版本执行DEL删除一个包含百万成员的 hash key主线程会同步遍历并释放每个节点这期间其他命令全部阻塞。Redis 4.0 之后推出了UNLINK命令它在主线程里只是把 key 从字典中摘除然后把这个需要释放的对象交给bio_lazy_free线程去慢慢回收主线程立刻继续服务其他请求。UNLINK和DEL的区别我在生产环境里反复验证过大 key 用DEL删除时客户端耗时几十毫秒到几百毫秒都有改成UNLINK后主线程耗时会降到亚毫秒级。类似地FLUSHDB ASYNC和FLUSHALL ASYNC就是利用 lazy free 的别名。但要注意bio线程只是“异步推迟了释放动作”不代表内存立刻被回收。异步释放时Redis 实际内存可能是在命令执行完之后的一段时间才逐渐下降。如果你用INFO memory观察used_memory会发现UNLINK之后内存不是瞬时掉的而是等后台线程慢慢回收。这个特性对容量监控很有影响不能只看瞬时值要多观察几分钟。5.2 fork 与 Copy-On-Write持久化背后的另一半引擎Redis 做 RDB 快照和 AOF 重写时会通过fork()生成一个子进程。子进程不是“复制一份主线程”而是直接继承父进程的内存页表。关键在于 Linux 的Copy-On-WriteCOW机制子进程刚创建时和父进程共享所有物理内存页谁先写了某个内存页谁就要复制一份再改。这套机制让 Redis 在做持久化时主线程不需要停下来等磁盘写入可以继续处理命令。代价是在快照/重写期间如果主线程收到了大量写命令父进程会不断触发 COW 复制导致物理内存占用大幅上升。我遇到过一次内存翻倍的案例实例平时占用 4GB开启 RDB 定时快照后正好赶上业务大促写流量高峰内存涨到 8GB差点触发 OOM。所以生产环境里给 Redis 设置maxmemory时一定要考虑持久化期间的 COW 开销不能让 maxmemory 顶到物理内存上限。另外通过INFO persistence里的rdb_last_cow_size和aof_last_cow_size字段可以查看最近一次 RDB/AOF 重写期间 COW 消耗了多少内存这能直接帮你估算该给持久化留多少余量。另一个容易被忽略的点是fork()本身也不是零成本的。进程内存越大fork复制页表项的开销越大这一瞬间会阻塞主线程几十毫秒甚至更久。所以大内存 Redis 实例在做定时持久化时会出现周期性的微小延迟尖峰这也是很多监控图上“每隔一小时抖一下”的经典来源。6. 线上问题排查实录模型问题怎么定位聊了这么多原理最终还是要回到“线上出了问题怎么办”。基于线程模型的知识你可以把 Red
返回列表