资讯详情

资讯详情

Redis线程模型全解析:单线程执行与多线程IO调优

上周有个同学拿着压测报告来问我明明把io-threads调到了 8为什么 Redis 的 QPS 一点没涨P99 反而抖得更厉害了。这个问题几乎每隔一段时间就会有人踩一次根源不在参数本身而在于大家对Redis 线程模型的理解停在了一句流传太广的话上——“Redis 是单线程的”。这句话在 2019 年之前基本成立放到今天就不够用了。Redis 多线程 IO 在 6.0 版本正式落地之后确实有了一批新的工作线程但被多线程化的只有网络读写和协议解析真正的命令执行依旧老老实实待在主线程里排队。很多人误以为开了多线程就等于“执行也并行了”于是拿一个 CPU 密集型的大命令去压测结果当然纹丝不动。这篇东西想把这层窗户纸捅破。我会从 Redis 线程模型的分层结构讲起说清单线程为什么快、代价在哪再把 6.0 多线程 IO 的完整执行链路、参数计算方法、实测对比逐一拆开最后给一份可以直接抄的配置模板和一张排查速查表。不管你是在写业务代码的中间件使用者还是负责缓存集群稳定性的运维同学或者只是准备面试想把这题答明白看完应该都能拿到自己想要的东西。文中涉及的数据来自一台 8 核 16G 的测试机只能看趋势别当成绝对值照搬。1. 把 Redis 线程模型拆成四层别再用“单线程”一句话概括1.1 主线程唯一有资格执行命令的那条路主线程是 Redis 的绝对核心它跑的是一个经典的事件循环event loop底层在 Linux 上就是 epoll。这个循环每转一圈做的事情大致是固定的先处理到期的定时任务然后从事件队列里取出就绪的 socket读请求、解析命令、执行命令、把结果塞进客户端的输出缓冲区最后处理写事件回包。整个过程是串行的一条命令执行完才会轮到下一条这也是“单线程”这个说法的来源。关键在于命令执行的串行化是 Redis 的刻意设计不是历史遗留的妥协。你写INCR、LPUSH、HSET这些操作天然不需要加锁因为它们根本不可能并发执行。原子性、隔离性在这个层面是免费的你不需要考虑两个客户端同时改一个 key 会撕裂数据。事务也好、Lua 脚本也好之所以能把一堆命令打包成一个不可分割的单元靠的正是这条串行执行的流水线。所以我一直跟人强调多线程 IO 上线之后主线程的职责一点没变反而更重要了。它是整个链路的收敛点所有请求最终都要在这里排队。主线程一旦被阻塞前面 IO 线程再怎么并发搬运都是白搭。1.2 IO 线程只负责搬运和解析不碰数据6.0 引入的 IO 线程io-threads干的活非常明确把 socket 上的字节读出来、按 RESP 协议解析成命令对象、以及把主线程准备好的回复写回 socket。它不执行命令不加锁也不碰任何键空间数据。你可以把它理解成快递站的分拣员——包裹字节流由他们拆包、登记、装车但真正签字确认收货执行命令的永远是那个唯一的柜员。这个边界划得很干净好处是显而易见的多线程部分不需要考虑数据竞争因为它压根没访问共享数据结构。解析出来的命令对象被放进一个队列主线程处理的时候按客户端顺序一个一个取出来执行。这也是为什么开了多线程 IO 之后你的MULTI/EXEC事务、Lua 脚本、WATCH语义全都不受影响——执行阶段还是单线程原子性一点没丢。需要提醒的是IO 线程的分工是按客户端连接划分的不是按命令划分。一个客户端的请求在解析阶段就落在某一个 IO 线程手里解析完交给主线程。所以它解决的是“同时有很多连接在收发数据”这个场景下的瓶颈而不是“单条命令算得慢”的问题。1.3 后台线程那些悄悄干脏活的 bio除了主线程和 IO 线程Redis 内部还有一批常驻的后台线程统一由biobackground I/O模块管理。这批线程的存在感很低但一旦出问题就是大事。它们主要负责三类工作关闭文件描述符比如 RDB 落盘完成、AOF 重写完成后需要关掉旧文件、AOF 的 fsync 刷盘、以及大对象的异步释放。异步释放这块值得单独说。你执行UNLINK删一个大 hash主线程只是把 key 从字典里摘掉真正的内存回收交给后台线程慢慢做。FLUSHALL ASYNC、FLUSHDB ASYNC、配置了lazyfree-lazy-expire之后的过期键删除走的都是这条路。这套机制是 4.0 引入的目的就是缓解“删大 key 卡住主线程”这个经典问题。我见过一个案例一个存了几十万成员的 zset 用DEL删除主线程卡了将近 1.2 秒整个实例的请求全部超时换成UNLINK之后主线程耗时降到了微秒级。所以严格来说Redis 从来就不是“完全单线程”它只是“命令执行单线程”。这个区分在排查问题时特别重要因为后台线程出问题比如磁盘慢导致 fsync 堆积表现出来的现象是主线程延迟抖动你不了解这层结构就会往错误的方向查。1.4 版本演进对照表把各版本的变化拉成一张表心里会更有数版本线程模型的关键变化对使用者的实际影响4.0 之前主线程 极少量后台辅助删大 key、持久化阻塞是主要痛点4.0引入UNLINK、lazy free 与 bio 线程大对象释放可以异步化5.0数据结构与流类型增强线程结构未变无直接影响6.0引入多线程 IOio-threads高并发小包场景吞吐提升明显6.2 / 7.0多线程 IO 细节优化、参数行为调整配置前建议先CONFIG GET确认这张表我最想让人记住的是最后一行参数在不同小版本上行为可能不一样尤其是io-threads-do-reads这种控制“读是否也走多线程”的开关。同样是 6.x有的版本默认只多线程写回复读还是要显式打开。上线前用CONFIG GET io-threads*看一眼实际生效的值比翻文档靠谱。2. 单线程为什么能跑这么快代价又在哪里2.1 三个硬条件纯内存、IO 多路复用、无锁经常有人问单线程凭什么能扛十万级 QPS答案不是“单线程更快”而是“单线程在这个特定场景下省掉了大量开销”。拆开看是三个条件同时成立。第一数据全在内存里。一次GET的时间开销主要是哈希查找加网络往返内存访问是纳秒级网络往返是几十微秒级。CPU 计算根本不是瓶颈给再多核也用不上。第二IO 多路复用把等待时间挤干了。传统的一请求一线程模型大量线程阻塞在read上上下文切换的成本比实际干活还高。epoll 让一个线程同时盯着几万个 fd谁有数据就处理谁没有数据的时候线程在epoll_wait上睡着一点 CPU 都不浪费。这是单线程能撑住高并发的技术前提没有它单线程模型根本立不住。第三没有锁竞争和上下文切换。多线程版本里每个共享数据结构的读写都要加锁锁的粒度、死锁风险、缓存行伪共享全是要命的问题。单线程把这一整类问题直接删掉了代码简单行为可预测缓存局部性还好——同一个线程反复访问同一批数据CPU 的 L1/L2 命中率高得多。我的经验是很多“单线程为什么快”的讨论之所以吵不出结果是因为大家把这三个条件拆开单独比。真正的原因是它们叠加内存快 不等待 无协调成本三者相乘才是 Redis 的实际表现。2.2 单线程的真实成本一次阻塞等于全线排队反过来单线程模型的代价也非常直接任何一个阻塞点都会让所有客户端一起等。这不像多线程系统某个线程卡住只是部分请求变慢Redis 这里是全局性的。常见的阻塞源有好几类。最典型的是时间复杂度高的命令比如KEYS *在千万级 key 的库上跑一次主线程可能卡几秒SORT、ZUNIONSTORE、SMEMBERS作用在大集合上也是同样的问题。第二类是单个大 value 的读写比如一个 100MB 的 string序列化和网络传输本身就慢。第三类是 Lua 脚本里写了耗时逻辑脚本执行期间整个实例是停摆的。第四类是持久化相关的操作RDB fork 的时候虽然用的是写时复制但 fork 本身的耗时和内存页表的复制在超大实例上不可忽视。我踩过最深的一个坑是HGETALL打在一个几十万字段的 hash 上。业务方觉得“我只要拿这一条数据”实际上每次调用都在主线程上做一次全量遍历加网络传输几百个并发一起来直接把 P99 拉到秒级。后来改成用HSCAN分批拉或者干脆拆成多个小 key问题立刻消失。注意SLOWLOG的默认阈值是 10 毫秒很多生产事故的元凶就藏在这个阈值之上。上线就把slowlog-log-slower-than调到 5000 微秒甚至更低先观察一周再放宽这个习惯能帮你提前发现大部分隐患。2.3 哪些命令是隐藏的“堵点”怎么换掉它们给一份对照表遇到直接替换别犹豫高危操作阻塞原因推荐替代方案KEYS pattern全库遍历SCAN游标分批或维护独立的索引集合DEL大 key同步释放内存UNLINK 开启 lazy freeFLUSHALL同步清空全库FLUSHALL ASYNCHGETALL大 hash全量返回HSCAN分批或拆分 keySMEMBERS大集合全量返回SSCAN分批SORT大列表内存排序写入时维护有序结构或放到离线算长 Lua 脚本执行期间独占主线程拆分脚本、控制循环次数这张表里的每一条我都在生产环境见过对应的故障。最想强调的还是大 key 这一条因为它最隐蔽——代码里看起来只是一次普通的DEL但 key 的实际大小只有到线上才知道。养成定期扫描大 key 的习惯用--bigkeys或者自己写SCAN采样比事后救火划算得多。3. Redis 6.0 的多线程 IO 具体怎么跑起来的3.1 一轮请求在多线程 IO 下的完整走位把一轮请求拆开看多线程 IO 的流程是这样的主线程先确认所有 IO 线程都处于就绪等待状态然后通过 epoll 拿到当前就绪的客户端连接按轮询的方式把这些连接分配给各个 IO 线程。IO 线程并发地去读 socket 数据、按 RESP 协议解析成命令解析完挂在那里等。等所有 IO 线程都报“我这边解析完了”主线程开始干活——按顺序、单线程地执行这一批命令把结果写进各自客户端的输出缓冲区。执行完再把这批客户端按同样方式分配给 IO 线程由它们并发地把回复写回 socket。写完之后主线程回收状态进入下一轮。这个流程里有两个细节特别值得注意。第一命令执行的顺序和请求到达的顺序是一致的多线程只在“前后两端”并行中间那段铁打的串行所以语义完全没变。第二每一轮都要等最慢的那个 IO 线程这是个典型的木桶效应。如果某个连接上有超大 value 在传输它会拖慢整轮其他线程干完活只能等着。这也解释了一个现象开了多线程之后平均延迟可能下降但 P99 反而更容易抖因为同步点变多了。3.2 io-threads 到底该设多少给出计算过程参数不是越大越好这里给一个可以照着算的流程。先看机器核数。io-threads的官方建议是不超过 8且设置为 CPU 核数的 3/4 左右。原因是主线程自己也要占一个核同时操作系统和其他进程也要留余量。假设你的实例跑在 8 核机器上专供 Redis 使用8 × 3/4 6取 6 比较合适如果是 4 核4 × 3/4 3取 3如果是 2 核取 1 就行开了基本是负收益因为线程调度开销会吃掉那点并发收益超过 8 核的机器建议先封顶在 8再往上加收益递减非常明显还有一个隐含前提io-threads只对网络 IO 密集的场景有效。如果你的瓶颈是命令执行慢大 key、复杂命令加线程数一点用没有反而会因为线程同步点多消耗 CPU。判断方法很简单看INFO commandstats里各命令的平均耗时如果单条命令耗时正常但 QPS 上不去那是 IO 瓶颈如果单条命令耗时本身就高先优化命令。配置上还有个开关要注意读方向的多线程在 6.x 上通常需要额外打开# 开启多线程 IO设置为 6 个 IO 线程 io-threads 6 # 让读请求的读取与解析也走 IO 线程不同版本默认值有差异先确认 io-threads-do-reads yes改完用CONFIG GET io-threads*核对实际生效的值别只看配置文件写没写。另外这个参数支持CONFIG SET动态调整但改完最好观察一段时间再决定是否固化到配置文件。3.3 压测对比与三个实测结论在 8 核 16G 的测试机上用redis-benchmark做了一组对照。客户端 200 连接、value 大小 128 字节、SET/GET混合# 单线程 IO 基线 redis-benchmark -h 127.0.0.1 -p 6379 -c 200 -n 1000000 -t set,get -q # 客户端侧同样命令服务端改 io-threads 6 io-threads-do-reads yes 后重跑结果趋势大致是io-threads1时 QPS 在 12 万上下调到 6 之后涨到接近 20 万再调到 10 基本没变化P99 甚至略微回升。换成 value 大小 4KB 的场景多线程带来的提升更明显因为网络传输的占比更高了。而把命令换成SORT或者大 hash 的HGETALL多线程带来的收益接近于零。结论可以归纳成三条。第一多线程 IO 解决的是“搬运成本”不是“计算成本”网络越重、value 越大、连接数越多收益越明显。第二超过 8 个 IO 线程基本没有意义收益曲线早就平了多出来的线程只是增加了同步开销。第三P99 会比单线程更容易抖因为每轮都有同步点如果你的 SLA 对尾延迟很敏感建议保守一点或者干脆只开到 4。还有个容易被忽略的点多线程 IO 和部分特性组合时是有兼容性限制的比如 TLS 传输、某些低版本的连接类型。我这边的做法是先在预发环境开全量对比跑一轮确认功能正常、延迟符合预期再推生产别直接拿配置文件改了就上线。4. 后台线程与定时任务单线程模型里的“另一批人”4.1 bio 的三个队列close、fsync、lazy free后台 I/O 线程池的结构是一组固定数量的线程加上三个任务队列分别对应关闭文件、AOF fsync、异步释放内存。任务来的时候往对应队列里一塞线程醒来就干活干完继续睡。队列是加锁的但锁的争用非常低频对主线程的影响基本可以忽略。这三个队列里我最关心的是 fsync 那个。appendfsync everysec模式下fsync 由后台线程每秒做一次主线程不参与但有个坑如果 fsync 还没完成下一次写又来了主线程会短暂等待这就是aof_delayed_fsync这个指标存在的原因。你看到这个值一直往上涨说明磁盘跟不上写入节奏了要么换盘要么把 AOF 改成别的刷盘策略别硬扛。lazy free 队列也值得留意。异步释放虽然不阻塞主线程但内存是真的在慢慢还回去运维面板上看到的used_memory下降会有延迟。有一次做压测测试同学以为内存泄漏了其实是大批 key 过期之后异步释放还没跑完等了几十秒就正常了。所以看内存指标要结合lazyfree_pending_objects一起看别单看一个数。4.2 serverCron 与惰性删除别把周期任务当成免费主线程里还有一个常驻的定时任务serverCron默认每秒执行 10 次hz参数控制。它要做的事情不少更新统计信息、检查客户端超时、执行过期键的主动删除、触发持久化条件检查、处理集群状态等等。单次执行时间不长但它是跑在主线程里的如果你把hz调得很高比如为了更及时的过期删除调到 100那主线程被切碎的时间片就不可忽视了。过期键删除有两套机制并行。一套是惰性删除访问到这个 key 时发现过期了就顺手删掉另一套是主动删除serverCron每轮会采样一批带过期时间的 key如果过期比例超过一个阈值就继续下一轮采样同时受一个时间上限约束超时了就退出把 CPU 还给正常请求。这个设计保证了删除工作不会无限期占用主线程。实践中的建议是只给必要的 key 设置过期时间别把整个库都设成统一的 TTL。我见过一个设计所有缓存 key 都是 1 小时过期结果每到整点前后大批 key 同时失效主动删除的采样压力骤增主线程延迟出现明显的周期性尖刺。后来给 TTL 加了随机抖动在 3600 秒基础上浮动 ±300 秒这个尖刺就消失了。这个技巧成本极低效果立竿见影。4.3 Redis 7 之后的演进方向7.0 的多线程策略没有出现颠覆性变化命令执行仍然是单线程多线程的边界还是“网络层”。变化主要体现在一些细节上IO 线程的实现更成熟参数行为的默认值经过调整某些版本对读多线程的处理方式更合理。对比一下走 shared-nothing 路线的内存数据库——它们的做法是数据分片到多个线程每个线程管一片完全无锁并行。这条路吞吐上限确实更高但代价是跨分片操作、事务、Lua 脚本全部复杂化运维和一致性模型都要重新设计。Redis 选择了保守路线我认为这个取舍很清醒绝大多数业务场景的瓶颈根本不在命令执行而在网络和命令选型。把用户赶到多线程执行模型上反而会把简单问题变复杂。所以看到“Redis 支持多线程了”这种标题别激动认真读一下它到底多线程了哪一块。绝大多数情况下你需要优化的是 key 设计、命令选择和网络拓扑而不是把线程数调大。5. 生产落地配置清单、监控口径与排查实录5.1 一份可直接抄的配置模板下面这份配置针对的是一台 8 核、只跑 Redis 的机器主要承载读多写少的缓存场景。参数都带注释按自己环境微调# 网络 IO 多线程8 核取 6 io-threads 6 io-threads-do-reads yes # 慢查询阈值压到 5 毫秒先摸清家底 slowlog-log-slower-than 5000 slowlog-max-len 512 # 大对象异步释放全开 lazyfree-lazy-eviction yes lazyfree-lazy-expire yes lazyfree-lazy-server-del yes lazyfree-lazy-user-del yes lazyfree-lazy-user-flush yes # 客户端输出缓冲区上限防止大 value 打爆内存 client-output-buffer-limit normal 0 0 0 client-output-buffer-limit replica 512mb 128mb 60 client-output-buffer-limit pubsub 64mb 16mb 60 # 定时任务频率保持默认别盲目调高 hz 10几点说明。client-output-buffer-limit的 normal 类我保留 0 0 0不限制是因为业务侧已经做了严格的返回大小校验如果你的业务方不可控建议改成256mb 64mb 60这种硬限制。lazyfree-lazy-user-del yes打开之后DEL会自动走异步等于所有删除都变成UNLINK的行为这个对存量代码非常友好但要注意内存回收的延迟感知。5.2 常见问题速查表现象最可能的原因排查手段处理方式调大io-threads后 QPS 无变化瓶颈在命令执行不在网络INFO commandstats看单命令耗时优化命令、拆分大 key多线程开启后 P99 变差每轮同步点增多木桶效应对比开启前后延迟分布降低线程数到 4 或关闭读多线程延迟周期性尖刺大批 key 同时过期INFO stats看过期键数量TTL 加随机抖动删除操作引发超时同步释放大对象SLOWLOG GET定位命令用UNLINK开 lazy free主线程卡顿但 CPU 不高后台 fsync 或 fork 阻塞看aof_delayed_fsync、latest_fork_usec换盘、控制实例内存大小内存下降滞后异步释放未完成看lazyfree_pending_objects属正常现象等待即可5.3 三个真实场景的排查过程第一个场景某业务的缓存集群在每天上午十点出现规律性延迟升高持续两三分钟自行恢复。看监控 CPU 不高、网络正常、慢查询日志里有一批GET出现在 50 毫秒以上看起来莫名其妙。后来比对时间点发现十点整有一次全量配置下发业务侧会把几千个 key 批量写一遍其中有几个是几百 KB 的大 value。主线程在传输这些大 value 时被占住其余请求全排队。解决办法是把大 value 拆成多个小 key或者干脆换个地方存延迟尖刺立刻没了。第二个场景有同学反馈开了io-threads 8之后某些时段延迟反而更高。我让他把io-threads-do-reads关掉再测延迟立刻平稳了。原因是那批请求里有不少小包高频读写读多线程带来的同步开销超过了并行收益。这个案例说明一件事参数的效果和业务流量特征强相关别人的最佳实践到你这里可能是负优化一定要自己做对照实验。第三个场景比较隐蔽。一个实例的内存一直缓慢上涨重启后恢复过几天又涨。排查发现是代码里有一批 key 设置了过期时间但同时又在另一个地方对同名 key 做了PERSIST导致过期失效。更麻烦的是这些 key 里有大 hash本来靠过期删除回收内存现在全指着惰性删除只有被访问到才删。这类问题的教训是过期策略和业务逻辑要放在一起 review别让两拨人各写一半。5.4 我在实际使用中的几点体会第一别迷信“把参数调到最优”。Redis 的默认值是一群非常懂行的人反复权衡出来的绝大多数场景下默认值就是最优解改之前先想清楚你解决的是哪个具体瓶颈。第二监控比调参重要十倍SLOWLOG、INFO commandstats、单命令延迟分布这些数据攒起来比任何经验帖都管用。第三遇到延迟问题先问三个问题是不是大 key、是不是慢命令、是不是过期集中能覆盖八成以上的情况。如果你手上还有余力可以做一件事把线上真实的命令流量采一份样本在预发环境用不同的io-threads配置各跑一轮画出 QPS 和 P99 的对照曲线。这条曲线比任何人的结论都更贴合你自己的业务做一次能用很久。至于再往后Redis 的多线程边界会不会继续往外扩我觉得短期内还是会守着“网络层多线程、执行层单线程”这条线真正的性能优化空间在客户端连接管理、命令合并、以及在业务侧把数据模型设计得更贴合访问模式上——这些活靠调参数是调不出来的。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →