我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

Redis 7线程模型拆解:主线程、IO线程与io-threads调优

Redis 7线程模型拆解:主线程、IO线程与io-threads调优 前阵子帮一个朋友排查 Redis 7 升级后的延迟抖动技术群里又吵了起来有人说“Redis 7 早就是多线程架构io-threads 一开就能起飞”也有人坚持“Redis 永远是单线程网上那些讲多线程的全是在带节奏”。两边其实都只对了一半。这篇就是想拨开这团迷雾把 Redis 7 的线程模型拆开讲清楚主线程到底负责什么、IO 线程帮了什么忙、bio 线程和 fork 子进程又各自承担什么角色最后再聊一聊 io-threads 这个参数到底该不该开、怎么开以及线上排查时怎么判断瓶颈卡在哪一环。如果你正在上手 Redis 7或者准备做压测调优这篇应该能帮你在脑子里建立一张比较完整的图。1. 先拆散传得最广的那团雾单线程与多线程并存才是一幅完整图景1.1 “Redis 是单线程”这句话为什么能坑到不少人这句话在很多博客、面试题里都出现过本身不算错但它偷懒了。准确的说法应该是Redis 的命令执行环节是单线程的也就是所有客户端命令真正对数据结构做读写的部分最终都串行在主事件循环线程上。可这不等于整个 Redis 进程只有一个线程。从早期版本开始Redis 就有后台线程和子进程。比如持久化 RDB 快照时会 fork 出一个子进程AOF 重写也是类似做法。再比如关闭文件描述符、AOF 的 fsync 刷盘、以及后面版本引入的 lazy free 惰性删除都有独立的后台线程在处理。所以“Redis 从来都是纯单线程”这句话从来就不成立。只是这些后台线程在过去很长一段时间里太“透明”了大家平时感知不到于是“Redis 单线程”这个简化说法越传越广等到 Redis 6 引入了 IO 线程市面上又开始说“Redis 多线程了”同样也是过度简化。Redis 7 延续了这个设计思路能用多线程加速的地方比如网络读写、后台释放内存就用但真正碰数据状态的核心路径依然保持单线程。1.2 7.x 里到底有哪些线程在跑你在 Linux 上随便找一个运行中的 Redis 7 进程用ps -T看一眼线程列表通常能看到这么几类线程/进程标识主要职责是否常驻典型触发时机主事件循环线程epoll 事件处理、命令执行、定时任务常驻始终存在IO 线程形如 io_thd_1辅助网络套接字的读和写配置后常驻io-threads大于 1 时启用bio_close_file后台关闭文件描述符常驻关闭连接、关闭持久化文件等bio_aof_fsync后台执行 AOF 刷盘常驻开启 AOF 且达到刷盘时机bio_lazy_free后台释放淘汰/删除的大对象内存常驻执行 UNLINK、FLUSHALL ASYNC、key 逐出fork 子进程RDB 快照、AOF 重写按需产生BGSAVE、BGREWRITEAOF内存碎片整理线程搬移内存块整理碎片按需运行activedefrag开启后这里面最容易让人误解的就是 IO 线程。它听起来像“多线程执行命令”实际上它主要是在帮主线程搬运数据把网络上的请求字节流读进来把要返回的响应字节流写出去。命令怎么执行、数据结构怎么改仍然是主线程一个人说了算。所以看 Redis 7 的线程模型我习惯用一句话概括主线程是唯一的大脑IO 线程是手脚bio 线程和 fork 子进程是干脏活的后勤。后面几节就按照这个分工逻辑往下拆。2. IO 线程池的真实工作流读、执行、写回是怎么协作的2.1 从 epoll 事件到响应写回完整走一遍先说不开 IO 线程的情况。主线程跑一个事件循环用epoll等客户端 socket 的可读事件一旦有请求进来就自己调用read()把数据读进输入缓冲区解析出命令执行把结果写到输出缓冲区再调用write()刷给客户端。整个过程一气呵成没有任何并发实现简单也不容易出错。瓶颈也出在这里。当连接数很大、每个请求又都是几个字节的小命令时主线程大量的 CPU 时间其实消耗在read()和write()系统调用上而不是真正的命令执行。那部分工作是可以并列出去做的。于是 Redis 6 开始引入了 IO 线程池Redis 7 继续沿用并做了完善。打开 IO 线程后一轮事件循环大概是这样的主线程通过epoll拿到一批有事件发生的客户端 socket。如果开启了io-threads-do-reads yes主线程会把这批客户端分成 N 份交给 N 个 IO 线程让它们并行read()并做初步解析。所有 IO 线程读完这一批后主线程开始逐个执行命令这个阶段仍然只有一个线程在真正碰数据结构。执行完主线程把需要写回响应的客户端再次分给 IO 线程IO 线程并行write()刷缓冲区。主线程等待写回完成进入下一轮循环。整个过程可以类比成一家餐厅主厨永远只有一个人菜必须由他一个人炒但点单、传菜、上菜可以交给服务员去跑。客人再多炒菜顺序也不会乱只是前端接待能力被撑大了。2.2 io-threads-do-reads 为什么不默认开启这里有一个非常容易踩的细节。光把io-threads从 1 调成 4默认情况下 IO 线程只参与写回响应请求的读取和解析仍然在主线程完成。想要 IO 线程也帮忙读必须在配置里再单独开io-threads-do-reads yes。官方把读路径单独做成一个开关是有原因的。读请求不只是把字节搬进内存那么简单它还涉及命令解析、协议状态维护甚至要修改客户端的一些属性和统计信息。一旦多个线程同时做这件事就得引入更精细的同步机制处理不好还会带来请求乱序问题——同一个连接发出的多条命令如果被不同线程并行读了执行顺序就可能被打乱这对 Redis 来说是不能接受的。所以 Redis 的实现里同一个客户端 socket 在每一轮只会被分配给一个 IO 线程并且主线程要等所有 IO 线程都结束读取后才开始执行命令。本质上 IO 线程做的是受限并发只并行化系统调用层面的事情不触碰逻辑状态。我在实际配置时有一条经验大多数业务场景网络小包多、连接数大的时候读路径和写路径都值得并行但如果只是偶尔几个大户连接做 pipeline 压测IO 线程几乎帮不上忙。因为 pipeline 模式下主线程本来就在高效执行命令网络读写并不是瓶颈。这时候调参不但没有收益反而可能因为线程调度开销让延迟更难看了。3. 为什么命令执行死都不肯多线程原子性红利与并发代价3.1 单线程执行命令给业务带来的三样礼物很多人只盯着“单线程是不是性能瓶颈”却没意识到命令执行单线程给业务带来了非常值钱的语义保证。最直接的就是原子性。一个命令挨着一个命令执行天然不会有两个线程同时修改同一个 key。比如INCR、DECR、LPUSH这类操作在 Redis 里不需要任何锁就是原子的。业务拿它做计数器、做库存扣减、做限流根本不用考虑“我这步是不是 read-modify-write会不会被别的线程打断”。这是单线程模型给的最朴实也最值钱的礼物。第二样礼物是简单可靠的事务和脚本机制。MULTI/EXEC把一批命令打包执行中间不会插入别的客户端的命令Lua 脚本更夸张整个脚本的执行就像一段原子操作。如果命令执行变成多线程这些语义都得重新设计复杂度会成倍上升。第三样礼物藏在性能里没有锁竞争、没有线程切换数据结构不需要加锁保护。对纯内存操作来说单线程执行反而能跑出非常稳定的低延迟。这也解释了为什么很多团队到现在依然把 Redis 7 当分布式锁和后端缓存的底座——它给出的延迟曲线是高度可预期的。3.2 强行多线程执行命令会付出什么代价不妨想象一下如果 Redis 真的把命令执行改成多线程会发生什么。多个线程同时对同一个 key 执行INCR那就必须给 key 加锁不同 key 的操作虽然可以并行但热 key 会变成争抢点事务和 Lua 脚本需要跨线程协调底层数据结构的共享也必须重新设计。为了换来本就有限的内存操作提升却引入了无数复杂的边界条件这是典型的得不偿失。Redis 团队的真实思路不是要把命令执行改成多线程而是把会阻塞主线程的重活尽量挪到后台。最典型的就是UNLINK和FLUSHALL ASYNC。删除一个几百 MB 的 key如果用DEL主线程要逐块释放内存可能卡顿好几毫秒甚至几十毫秒换成UNLINK主线程只把对象从中摘掉真正等内存的工作丢给 bio 的 lazy free 线程慢慢做。理解了这一点就能明白 Redis 7 线程模型的内核逻辑核心路径保持单线程是设计选择不是技术落后。它用后台线程和子进程来解决外围的阻塞问题而不是牺牲核心语义去换多核吞吐。这也是每一个调优 Redis 的人都应该先建立的认知框架。4. 藏在后台的“隐形线程军团”bio、fork 与内存管理谁在干活4.1 bio 线程关文件、刷盘、释放内存各司其职说 bio 线程是隐形军团是因为它们在正常工作负载下几乎不占 CPU但一旦出现问题又往往是最容易被忽略的嫌疑对象。Redis 至少会拉起几条常驻后台线程分别处理三类任务关闭文件描述符、AOF 刷盘、惰性释放对象内存。先看关闭文件描述符。一个客户端断开连接、或者关闭持久化文件底层都要调用close()。大多数时候这个调用很快但某些场景下 fd 释放也可能涉及内核层面的等待与其让主线程卡在这一步不如扔给bio_close_file线程。再看 AOF 刷盘。这里的 fsync 是一个典型的可能阻塞主线程的操作。即使 AOF 策略是everysec真正执行 fsync 的地方也在 b每秒一次的循环里但如果磁盘响应很慢主线程可能在操作日志写入时被拖住。Redis 7 里 AOF 的刷盘动作由bio_aof_fsync线程承担可以避免这类卡顿。最后是 lazy free。这是最贴近业务的一个。我删除一个很大的 hash 或 list 时如果直接DEL主线程会去free一块巨大的内存期间整个 Redis 都无法响应其他命令改成UNLINK之后内存释放任务进入bio_lazy_free队列由后台线程去一块块释放。这不是玄学更不是“惰性删除会导致内存泄漏”——释放总归会完成只是不在主线程的时间线上完成。4.2 fork 子进程与 COW持久化其实也是一场并发RDB 快照和 AOF 重写是另一类“假并发”。每次执行BGSAVERedis 都会 fork 一个子进程子进程把内存里的数据写进临时文件主进程继续服务。内存页面通过写时复制COW共享子进程读到的是一份一致性的内存快照。这个模型有两个容易被忽略的坑。第一个是fork 瞬间的卡顿。fork 需要拷贝主进程的页表如果实例内存很大比如 30 GBfork 本身可能消耗几十毫秒到上百毫秒期间主线程是阻塞的。别以为后台持久化就完全无感。第二个坑是COW 导致的内存上涨。主进程在子进程写快照期间发生写操作会触发页复制内存可能肉眼可见地涨一波。针对这两点实际操作中我的习惯是实例内存控制在合理范围内给 COW 预留一些余量同时检查服务器是否开启了 THP 大页如果开启建议关闭——THP 会让 COW 复制的粒度从 4KB 变成 2MB内存放大效应非常夸张。另外提一下activedefrag。Redis 支持在后台做内存碎片整理也是独立线程在干。它会在内存碎片率超过阈值时启动把零散的小块内存搬移到更紧凑的位置。它对主线程的影响不是阻塞性的但会占 CPU所以一般不需要主动调高默认阈值其实已经比较保守了。5. io-threads 配置的收益与代价一次实测观察和三个容易踩的坑5.1 配置长什么样什么时候才该动它先看配置在redis.conf里通常是这样io-threads 4 io-threads-do-reads yesio-threads默认是 1也就是所有网络 IO 都在主线程完成。只有把它调成大于 1IO 线程池才会真正启用。io-threads-do-reads默认是 no光调io-threads只会让 IO 线程帮着写回。什么时候才值得动它我总结了一个比较稳的判断标准连接数大、单个请求非常小、主线程 CPU 使用率已经很高但吞吐上不去并且能确认网络读写占了大量系统调用时间。典型的场景是大量在线业务连接每个连接持续发小GET/SET请求压力主要消耗在read和write系统调用上。反过来如果你的流量集中在少数几条 pipeline 连接上或者 key/value 都很大、CPU 主要在复制和序列化数据这时候开 IO 线程基本没有收益。5.2 实测观察不同线程数下的吞吐与延迟为了直观我在一台 8 核物理机上做过一轮简单的压测环境是 Redis 7、纯GET/SET小 key、200 并发连接、数据量几千万级别。结果大概如下仅供围观不代表所有环境配置QPS约P99 延迟约io-threads 110.5 万0.9 msio-threads 212.8 万0.8 msio-threads 414.1 万0.8 msio-threads 813.9 万1.3 ms从 1 调到 4吞吐确实有明显提升这个提升的来源就是网络读写被并行化了。但继续加到 8吞吐不但没有再涨P99 反而出现抖动。这里有两方面原因一方面机器只有 8 核命令执行的主线程本身要占一核IO 线程加上系统其他线程已经接近物理上限另一方面线程数过多时任务分发和同步的代价开始超过并行读写的收益。这个规律放到不同机器上可能数字不同但趋势是一致的io-threads 不是越大越好超过 CPU 核心数之后大概率是负优化。官方建议也是不要超过核心数我个人一般选核心数的一半到四分之三比较稳。5.3 三个容易踩的坑第一个坑就是前面说的只调io-threads而没开io-threads-do-reads。有人压测完发现性能没变化以为多线程没用其实读请求根本没走 IO 线程。第二个坑是在低负载或少量连接场景下强行开 IO 线程。线程创建了每轮事件循环还要做任务分发、同步、等待这些额外开销有时候比读几个字节的收益还大。我见过一个小业务实例开了 8 个 IO 线程结果延迟反而从 0.4ms 涨到 0.9ms关掉恢复默认立马好了。第三个坑是忘了看机器真实的 CPU 配额。容器场景下面经常出现一个假象机器物理核心很多容器限制只有 2 核你在配置里写 io-threads 8线程是起来了但调度器让它们互相打架系统上下文切换暴涨。调参之前先确认你真正能用的核数再决定线程数否则只是自我感动。6. 线上排查视角怎么观测线程状态判断瓶颈卡在哪个环节6.1 用 ps 和 top 看 Redis 进程里的线程遇到 Redis 性能问题第一步永远是先看线程状态而不是急着改参数。拿ps -T看线程列表ps -T -p $(pgrep -x redis-server)输出里一般能看到这些名字线程名说明健康状态下应该什么样redis-server 主线程事件循环和命令执行CPU 占用可能高但线程状态以 R/S 为主io_thd_1、io_thd_2 ...IO 线程空闲时基本睡眠压测时 CPU 上升bio_close_file关闭 fd几乎不消耗 CPUbio_aof_fsyncAOF 刷盘磁盘压力大时会占 CPUbio_lazy_free后台释放内存大 key 删除时短期活动jemalloc_bg_thd内存分配器后台整理大多时候安静用top -Hp PID也能实时看到线程级 CPU。如果某个bio_lazy_free线程长期处于运行态大概率是有大 key 在持续被删除或者大量 key 在过期淘汰如果多个io_thd_*线程 CPU 飙升但你 QPS 很低可能是有客户端在疯狂读写但并不走命令执行这时候要回头查网络层。6.2 判断瓶颈三步法面对一个“Redis 很慢”的问题我现在基本按三步走。第一步看主线程的 CPU 到底高不高。如果主线程 CPU 几乎跑满说明命令执行路径确实累这时候先不要折腾 IO 线程应该查是不是有大 key、慢命令、或者某些 key 在频繁触发阻塞操作。可以用SLOWLOG GET看慢日志也可以开latency monitor看事件类型。第二步看网络层有没有积压。如果主线程 CPU 不高但延迟依然很大试着redis-cli --latency -h 127.0.0.1 -p 6379观察本机延迟再远程压测对比。如果本机很快、跨网络很慢问题可能根本不在 Redis如果本机也不稳定那就要看输出缓冲区、连接数、以及 IO 线程是否入队等待。第三步做一个干净的开关对比实验。把io-threads从默认 1 临时调成 4压测一轮再调回来看 QPS 和 P99 有没有变化。注意用CONFIG SET io-threads动态调整时要在低峰期操作线程池的重建和任务重排队会带来一些瞬态抖动不要在压测刚启动时边改边看数据。这套方法看起来简单但能避免你在错误的方向上折腾。很多团队一遇到性能瓶颈第一个念头就是“多线程没开够”实际上先搞清楚是谁的 CPU 在忙、哪个环节在等比盲目调参重要得多。回到开头的争论我的观点一直很明确Redis 7 的线程模型没有秘密它就是把单线程的确定性、原子性和后台多线程的吞吐能力做了一个很务实的分层。主线程管逻辑IO 线程管搬运bio 和 fork 管脏活各司其职。理解了这层分工再去碰io-threads、activedefrag、UNLINK这些参数你就知道每个旋钮拧下去到底是在调整哪一条线的节奏了。
返回列表