前阵子帮一个朋友排查 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 重写是另一类“假并发”。每次执行BGSAVE,Redis 都会 fork 一个子进程,子进程把内存里的数据写进临时文件,主进程继续服务。内存页面通过写时复制(COW)共享,子进程读到的是一份一致性的内存快照。
这个模型有两个容易被忽略的坑。第一个是fork 瞬间的卡顿。fork 需要拷贝主进程的页表,如果实例内存很大,比如 30 GB,fork 本身可能消耗几十毫秒到上百毫秒,期间主线程是阻塞的。别以为后台持久化就完全无感。第二个坑是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 都在主线程完成。只有把它调成大于 1,IO 线程池才会真正启用。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 1 | 10.5 万 | 0.9 ms |
| io-threads 2 | 12.8 万 | 0.8 ms |
| io-threads 4 | 14.1 万 | 0.8 ms |
| io-threads 8 | 13.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 | 几乎不消耗 CPU |
| bio_aof_fsync | AOF 刷盘 | 磁盘压力大时会占 CPU |
| bio_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这些参数,你就知道每个旋钮拧下去到底是在调整哪一条线的节奏了。