Redis线程模型详解:单线程执行、多线程IO与调优避坑 Redis 的“单线程”这四个字坑过的人比想象中多。我见过有团队因为一句“Redis 6.0 之后是多线程了”把一台 4 核机器上的io-threads直接调到 16结果 QPS 不升反降尾延迟抖得像心电图也见过有人在面试里斩钉截铁地说“Redis 是单线程的”却解释不清为什么INFO server里能看到io_threads_active为什么ps -T能看到好几个线程。这两个答案都只对了一半剩下的那一半恰恰是线上调优和排障时最值钱的部分。下面这些内容我按“一条命令从进入 Redis 到返回客户端”这条链路把线程角色一层层剥开哪些环节是单线程串行执行的为什么这么设计还能跑得飞快6.0 之后加进来的多线程 IO 到底动了哪一刀、什么场景开它才有收益后台那几根 bio 线程又在偷偷干什么以及把这套模型理解错了会在线上踩出什么样的坑。不管你是刚接触 Redis 的开发者还是已经在做容量规划、问题定位的运维或后端同学这篇都能当一份“线程视角”的排查手册来用。1. 把“单线程”这个词拆开它指的到底是哪一段1.1 一条命令的完整旅程中间经过谁的手先建立一张脑内地图。客户端发来一个GET user:1001数据包从网卡进内核TCP 连接上可读的事件被epoll_wait捕获主线程拿到一批就绪的文件描述符fd挨个读 socket、解析 RESP 协议、找到对应的命令函数、在内存里的哈希表上查到值、把结果序列化成 RESP 写回 socket 的发送缓冲区最后交给内核发出。这整条链路上真正被“单线程”锁死的只有中间那段“执行命令、读写内存数据结构”。很多人把“单线程”理解成“整个 Redis 进程只有一个线程”这是第一个认知偏差。真实的 Redis 进程里至少有这么几类线程在跑主线程也就是大家说的那个“单线程”、IO 线程6.0 引入可选启用、bio 后台线程负责文件关闭、AOF 刷盘、异步释放内存等、以及 fork 出来的子进程做 RDB 快照和 AOF 重写注意是进程不是线程。你用ps -T -p pid去看一个开了io-threads 4的实例看到七八个线程是正常的这不代表“命令执行变成多线程了”。所以更准确的表述是Redis 的命令执行是单线程串行的网络读写和部分后台任务可以是多线程的。这句话里的每个限定词都不是多余的后面几节我会挨个说明为什么。1.2 主线程串行执行换来的是“天然原子”为什么命令执行这一刀不切答案不只是“实现简单”而是一个非常有价值的副作用串行执行天然保证了单条命令的原子性。因为同一时刻只有一个命令在读改写内存所以INCR不需要加锁LPUSH不需要考虑并发交错MULTI/EXEC事务、Lua 脚本、WATCH这套机制才有意义可言。你可以做个思想实验如果命令执行是多线程并发的那么两个客户端同时对同一个 key 做INCR就必须引入行锁、分段锁或者 CAS 之类的机制。这些机制一旦引入锁竞争、缓存行伪共享、上下文切换的开销就全来了而 Redis 的核心数据结构哈希表、跳表、压缩列表本来是为单线程访问优化的根本没有考虑并发安全。换句话说Redis 是用“放弃命令级并行”换来了“零锁开销 简单正确”这笔交易在它主打的场景里非常划算。顺带说一个容易被忽略的点因为有这层串行保证我们才能心安理得地把一些复合逻辑塞进 Lua 脚本里当“迷你事务”用。如果哪天命令执行真的多线程化了这套用法第一时间就会出问题——这也是我认为短期内官方不会改动这一层的原因之一。2. 单线程凭什么跑得快而不是“应该很慢”2.1 纯内存 为场景定制的数据结构最直接的原因当然是数据全在内存里。一次内存随机访问是百纳秒级别而一次磁盘随机 IO 是毫秒级别差了四五个数量级。但这只能解释“快”解释不了“单线程还能扛住十万级 QPS”。真正拉开差距的是数据结构的设计。Redis 没有用一种通用结构打天下而是给每种类型配了在特定规模下最优的编码小哈希用小尺寸的紧凑数组结构元素多了才转成真正的哈希表小集合用整数集合全是整数时连指针都省了有序集合在元素少的时候用紧凑列表多了才上跳表和字典的组合。这些编码在内存占用和访问速度上都做过针对性取舍一次HGET的平均开销被压到了接近“算几个偏移量”的程度。还有一个细节值得单独提哈希表的扩容用的是渐进式 rehash。它不会在某一刻停下来把几百万个 key 一次性搬完那会造成一次明显的长阻塞而是每次操作顺带搬一小部分把成本摊薄到无数次操作里。这种“不让任何一次操作变得特别长”的思路贯穿了整个 Redis 的设计也解释了为什么它对尾延迟相对友好。2.2 事件循环 IO 多路复用一个人看很多口锅第二个关键点是网络模型。传统的一连接一线程模型一万个连接就是一万个线程光上下文切换就能把 CPU 吃干净。Redis 用的是单线程 IO 多路复用主线程注册一堆 fd 的读写事件用epollLinux或kqueueBSD 系这样的系统调用一次性问内核“这些 fd 里哪些有事儿”然后只处理有事儿的那些。打个比方前者像餐厅给每桌配一个服务员人一多服务员比客人还多全在过道上挤后者像一个服务员盯着所有桌子的呼叫灯哪桌亮灯去哪桌。在“连接多但每个连接不总是活跃”的场景下后者的效率高一个量级而 Redis 面对的恰恰就是这种场景。这里有个实践含义既然主线程是串行处理所有就绪事件的那么任何一个耗时长的操作都会把后面所有排队的事件一起堵住。这就是“单线程”模型在运维层面最重要的一条推论后面第 5 节会专门展开讲它带来的坑。2.3 没有锁、没有线程切换也就没有那些隐形开销单线程还有一个不写在文档里但实实在在的收益没有锁竞争没有线程间内存同步没有因为线程数超过核数导致的调度抖动。多线程程序里那些“看起来没干什么但 CPU 就是上不去”的现象在 Redis 主线程这里基本不存在。不过要客观一点说单线程也不是没有天花板。它最直接的瓶颈有三个单核 CPU 频率一个线程最多吃满一个核、网络带宽大 value 场景下 memcpy 和协议解析会吃掉大量 CPU、以及长耗时命令造成的阻塞。6.0 的多线程 IO 解决的正是第二个瓶颈的一部分而不是第一个和第三个。3. Redis 6.0 的多线程 IO动的是哪一刀3.1 只加速“读、解析、写回复”命令执行原地不动先把这个最容易混淆的点钉死。6.0 引入的多线程作用范围仅限于网络数据的读取、协议解析、以及把回复写回 socket。完整流程是这样的主线程通过epoll拿到一批就绪连接把待读的连接按轮询方式分给若干个 IO 线程各线程并行地从各自的 socket 读数据、解析出完整命令放进队列主线程等所有 IO 线程都干完再自己一个人串行地把队列里的命令一条条执行完执行产生的回复再按轮询分给 IO 线程并行写出去。也就是说命令执行那一层一根毛都没动。INCR依然是串行的Lua 脚本依然独占主线程。多线程 IO 提升的是“把命令搬进搬出”的效率尤其是当单个 value 比较大、协议解析占 CPU 明显的时候收益才看得出来。理解这一点之后很多现象就顺了为什么小 key 高并发的场景开多线程 IO 效果平平因为瓶颈根本不在搬数据上而在主线程执行命令那一段你把搬运工加到十个厨子还是只有一个。3.2io-threads到底该填几官方建议背后的算术配置项有三个都是老面孔了# 默认 1表示不启用多线程 IO全部由主线程处理 io-threads 4 # 默认 no开启后才让 IO 线程参与读和解析关闭时只让它们参与写 io-threads-do-reads yes关于取值官方给的参考是“4 核机器建议设 2 到 38 核机器建议设 6总之不要超过 8”。这个建议不是拍脑袋的背后的算术是IO 线程和主线程是抢同一批 CPU 核的。你设了 8 个 IO 线程加上主线程就是 9 个线程在 8 核上跑调度器得来回切更糟的是如果这台机器上还跑了别的服务实际的可用核数更少线程一多切换开销和缓存失效就会把并行带来的收益吃掉甚至倒挂。我的经验取值是io-threads一般不超过“可用核数的一半”且不超过 8。如果是 4 核独占设 2 或 38 核独占设 4 到 616 核以上8 基本到顶了再往上加意义很小。另外注意一个坑改完配置要重启实例才生效取决于版本稳妥做法是改配置文件重启后用CONFIG GET io-threads复核确认别以为CONFIG SET一下就完事了。3.3 怎么确认多线程真的活了以及开通前后怎么对比别猜直接看。INFO server里有个字段redis-cli -p 6379 info server | grep -E io_threads你会看到io_threads_active它表示当前时刻处于活跃状态的 IO 线程数量。注意这个词——“当前时刻”。在流量低谷或者连接数很少的时候它显示 0 是正常的因为主线程判断没必要唤醒 IO 线程。如果它在压测期间一直是 0那就要查了要么io-threads还是 1要么连接数太少并行不起来要么版本不支持。对比测试我自己的做法是固定的同一台机器、同一份数据集用redis-benchmark灌一批固定长度的 value、同一个并发度只改io-threads跑三轮取中位数。观察指标不止 QPS更重要的是redis-cli --latency的 p99 和 p999。很多时候 QPS 涨了 15%但 p999 反而变差了这种“吞吐换尾延迟”的交换值不值得看你的业务能不能忍。顺便提一句机器本身的干扰测之前先跑redis-cli --intrinsic-latency 100看看这台机器在空转时的系统延迟是多少。如果这个数字本身就很难看比如超过 1 毫秒那你的延迟测试结果基本没有参考价值先解决机器问题再说。3.4 什么情况下开了反而更慢这是最值得写进笔记的一条。以下场景多线程 IO 大概率是负收益场景特征为什么收益低甚至倒挂客户端连接数很少比如只有十几个待处理的连接不够分IO 线程大部分时间在空转唤醒和同步的固定开销反而成了负担value 都很小几十字节协议解析的 CPU 占比很低瓶颈在主线程执行命令加线程纯属添乱机器核数少且与别的服务混部线程间抢核上下文切换吃掉并行收益用了大量阻塞式慢命令主线程被堵住IO 线程再快也只能干等网络带宽已经打满这是物理瓶颈加线程不会让网卡变宽反过来说什么场景值得开多核机器、连接数上千、平均 value 在几百字节到几 KB、主线程 CPU 使用率里网络处理占比明显。判断“网络处理占比明显”有个土办法抓一份INFO commandstats看不出这个但你可以对比开与不开的 CPU 使用率如果开了之后 QPS 有明显提升而 CPU 没怎么涨说明之前确实卡在 IO 上了。4. 那些“Redis 是多线程”的证据其实各有各的来路4.1 bio 后台线程删大 key、刷 AOF、关文件如果你在 6.0 之前的版本上ps -T一个 Redis 进程看到 4 个线程1 主 3 后台别惊讶这不是 6.0 才有的。这几根后台线程内部叫 biobackground I/O负责几类不适合放在主线程做的活异步关闭文件主线程想关一个文件但close()在某些文件系统上可能阻塞于是把 fd 丢给后台线程去关。AOF 刷盘appendfsync everysec模式下真正的 fsync 由后台线程执行主线程不等它。异步释放内存这是最有用的一个。你用UNLINK替代DEL或者开启lazyfree-lazy-expire、lazyfree-lazy-eviction之类的开关后释放一个大对象的内存这件事会被丢到后台线程主线程立刻返回。这一条对线上的意义极大。一个几百万元素的哈希DEL掉可能在主线程上阻塞几十甚至上百毫秒期间所有客户端全部卡住换成UNLINK主线程只做“把这个 key 从字典里摘掉”的动作真正的逐元素释放交给后台。这个改动的成本几乎为零收益却是在最坏情况下保住整个实例属于我最推荐的无脑优化之一。4.2 多线程 IO 与主线程的配合细节回到 6.0 的 IO 线程有个实现细节值得知道IO 线程之间是不共享数据结构的。每个 IO 线程只处理分给它的那几个连接的缓冲区彼此之间没有锁只有“主线程等所有 IO 线程完成”这一个同步点。这也是它能低开销的原因——它做的是数据并行不是共享状态并行。这也带来一个隐性的行为分配是轮询的不是按负载均衡的。如果某一批连接里恰好有一个的超大请求那个 IO 线程就会成为整批的短板主线程必须等最慢的那个完成才能继续。所以在超大 value 混在小请求里的场景多线程 IO 的尾延迟表现可能不如预期。这个现象没有配置项能治只能从业务侧把大 key 拆掉。4.3 复制、集群、持久化进程别把这些算进“命令执行线程”再说几个容易造成误解的地方。主从复制里主节点接收从节点的同步请求、发送全量 RDB、传播增量命令这些动作主要是主线程在做部分版本有专门的复制缓冲和后台处理但不要指望它并行。集群模式下节点间的 gossip 消息处理也走主线程的事件循环。RDB 快照和 AOF 重写用的是fork出来的子进程靠操作系统的写时复制机制工作它不影响主线程执行命令但 fork 那一瞬间的阻塞和写时复制带来的内存翻倍是另一个维度的坑。所以如果有人在监控里看到“CPU 使用率超过 100%”别慌这在多核机器上是多线程各自吃核的正常表现但如果在top里看到主线程那个核长期接近 100% 而其他核很闲那说明瓶颈就在命令执行上加 IO 线程没用得从命令优化、分片、扩实例三个方向想办法。5. 把线程模型理解错了线上会踩出什么坑5.1 大 key 加阻塞命令一个人堵住整条高速路这是最经典也最致命的一类。因为命令执行串行任何一个耗时 100 毫秒的命令就等于让所有在线客户端集体等待 100 毫秒。常见的肇事者包括对大哈希或大集合执行HGETALL、SMEMBERS、KEYS *对大列表执行LRANGE 0 -1对大数据集跑SORT以及FLUSHALL、FLUSHDB。排查链路我是这么走的先看监控上延迟毛刺的时间点然后SLOWLOG GET 20看有没有记录前提是slowlog-log-slower-than设得合理默认 10 毫秒我一般调到 5000 微秒甚至更低宁可多记如果慢日志里没有但延迟确实抖那就要怀疑是阻塞在网络层或者内存分配上用redis-cli --latency-history持续观察同时看INFO memory里used_memory有没有阶梯式跳变可能触发了淘汰或大对象释放。找到大 key 之后处理方式要分场景能拆就按业务维度拆成多个小 key不能拆就用HSCAN、SSCAN、ZSCAN分批读删除一律换UNLINKKEYS一律换SCAN。这些是老生常谈但真正把它们写进 code review 检查项里的团队并不多。5.2 把重量级逻辑塞进 Lua 脚本Lua 脚本在 Redis 里是不可中断的整个脚本执行期间主线程完全被占用。我看到过有同学在脚本里做几十次循环查询再拼装结果单次执行几十毫秒平时流量小看不出来一到高峰期就是连环卡顿。合理的边界是脚本里只做“几条命令的原子组合”控制在微秒到百微秒级。写之前先问自己这段逻辑如果放在客户端做会怎样多数时候答案是“加上WATCH或者用SETNX之类的原子操作就够了”根本不需要脚本。确实需要脚本的用SCRIPT LOAD预加载避免每次传脚本正文并且给脚本设一个执行时长的心跳监控——INFO commandstats里脚本命令的平均耗时如果超过 1 毫秒就该警觉了。5.3 连接数爆炸和输出缓冲区也会把主线程拖死还有一个隐蔽的坑客户端连接数上去之后主线程在事件循环里挨个处理就绪 fd 的开销是线性增长的。几千连接还好上万连接且都活跃的话光是遍历和系统调用就吃掉大量 CPU。这时候io-threads能帮上忙但更根本的解法是在客户端侧加连接池、用 pipeline 合并小请求。另一个坑是输出缓冲区。某个客户端发了一堆命令但不读结果比如消费端卡住了回复会在服务端的输出缓冲区里堆积主线程要不断往这个缓冲区写数据。Redis 有client-output-buffer-limit来兜底正常客户端默认是不限制的这个默认值在极端情况下会导致内存暴涨。我一般会给普通客户端也设一个软限制和硬限制硬限制触发就断开连接宁可牺牲一个客户端也不能拖垮实例。6. 按线程模型做容量规划我的几条落地准绳理解了模型之后容量规划的思路会清晰很多。我的判断顺序是这样的第一步看主线程 CPU。如果主线程所在核的使用率持续超过 80%说明命令执行层已经饱和这时候加io-threads无效正确的动作是优化热点命令、拆分实例、或者引入本地缓存挡掉一部分读。分片的时候注意Redis Cluster 的槽位迁移和跨槽操作都有自己的成本别在高峰期做。第二步看网络。如果主线程 CPU 不高但 QPS 上不去观察网卡带宽和INFO stats里的instantaneous_input_kbps、instantaneous_output_kbps。带宽接近上限那就是物理瓶颈带宽还有余量但 CPU 里网络处理占比高这才轮到io-threads出场。第三步看延迟分布。只看平均值是被骗的开始一定要看 p99 和 p999。如果 p999 有明显毛刺但平均正常八成是慢命令或大 key 在作祟回头去查慢日志和LATENCY相关命令。一组我常用的观测命令压测和线上巡检都用得上# 系统层面的真实延迟基线 redis-cli -p 6379 --intrinsic-latency 100 # 持续观察客户端侧延迟分布 redis-cli -p 6379 --latency-history -i 5 # 慢命令记录 redis-cli -p 6379 slowlog get 20 # 主线程事件循环与命令统计 redis-cli -p 6379 info commandstats redis-cli -p 6379 info stats | grep -E instantaneous|rejected还有几个参数跟线程模型直接相关值得在配置里过一遍参数建议值为什么跟线程模型有关io-threads1默认或可用核数一半上限 8超过核数就是负收益别贪多io-threads-do-reads大 value 场景再开 yes只影响读和解析小 value 场景收益低lazyfree-lazy-user-delyes把DEL的释放成本挪到后台线程lazyfree-lazy-expireyes过期大 key 的释放不阻塞主线程slowlog-log-slower-than5000微秒单线程模型下慢命令就是全局阻塞必须抓得严maxmemory-policy按业务定淘汰时配合 lazyfree淘汰触发的释放同样可能阻塞最后补一个我自己的体会。刚接触 Redis 那会儿我也纠结过“单线程会不会不够用”后来才想明白单线程真正的风险不在“不够快”而在“不能被打断”。它像一个只有一位收银员的窗口队伍排得再长只要每单结算够快就没问题怕的是有人拿着一车货来慢慢扫码后面所有人都得等着。所以这些年我在 Redis 上做的优化八成不是想办法让收银员跑得更快而是想办法别让他遇到那一车货——拆大 key、禁KEYS、把重活挪出脚本、用UNLINK兜底。至于io-threads这类参数更多是锦上添花先把前面的地基打牢再考虑要不要开。