
开门见山。去年做一次大促压测Redis 服务本身的 CPU 并没有到 90%但 P99 延迟偏偏到了 15ms 以上。perf top 里看__do_page_fault、_raw_spin_unlock_irqrestore全冒出来了进一步跟下去才发现负载全打在网络读写上——读包、拆包、回包这三段链路在单线程事件循环里把 CPU 时间片全吃掉了。那次之后我专门把 Redis 的网络 IO 架构从源码到内核参数捋了一遍结合 8.4 版本的改动沉淀出这篇拆解。如果你正在维护微服务架构里的 Redis 集群或者单纯好奇分布式架构里的核心中间件如何支撑高并发这篇文章值得读完。我会从事件循环、IO 多线程、缓冲区管理、TCP 调优、监控排障这条线逐一展开全部基于可复现的配置与实测经验。1. 从单线程到多阶段并行Redis 网络 IO 架构演进的方向要理解 8.4 的网络 IO 设计先要清楚一个基本矛盾Redis 执行命令快但网络读写慢。命令如果是一条SET key value在内存里就是一个哈希表写入动作微秒级就能完成但 TCP 收包、解析协议、写回应要经历系统调用、数据拷贝、内核buffer 到用户态的传递哪怕每秒百万次 opCPU 也扛不住。1.1 aeEventLoop 的经典模型Redis 源码里有一个核心抽象叫aeEventLoop它把文件事件FD 可读可写、时间事件定时任务统一放进一个循环里。早年版本的结构可以简化为创建 epoll或 kqueue/event ports取决于平台注册监听 socket 的可读事件当可读事件触发时调用acceptTcpHandler接收新连接对客户端 FD 注册可读事件触发后调用readQueryFromClient读取请求数据解析并执行命令后把响应写入输出缓冲区注册可写事件触发后调用sendReplyToClient发送这段循环在单线程里跑宏观上很简洁。问题是当连接数从几百涨到几万或者网络包变得很碎小包多epoll_wait返回的事件集合会非常大每个事件对应的 read/write 系统调用需要把数据从内核拷贝到用户态或反向拷贝这部分开销是纯 CPU 计算之外的额外成本。1.2 瓶颈到底在哪一步我用strace -p跟过线上某个实例发现一次 GET 请求产生的系统调用至少有这几个epoll_wait返回事件后read读取字节流解析出命令后执行完write发送回复再叠加 TCP 协议栈的 ACK 处理、PUSH 标志等网络收发路径上的 CPU 消耗经常占整个实例 CPU 的 50% 以上。Redis 的瓶颈从来不是命令执行本身而是网络 IO 这条看不见的路径。8.4 的架构调整本质上就是把这条路径上的操作拆开让多核 CPU 分担工作。1.3 8.4 阶段的架构变化Redis 6.0 引入了多线程 IO把 read/write 操作从主线程剥离。经过 7.x 的迭代到 8.4 时这套机制已经比较成熟核心变化可以概括为三点多线程只负责网络读写的“搬运”过程命令执行仍然由主线程串行完成所以不会破坏原有的命令原子性多线程读io-threads-do-reads在 8.4 中支持按连接粒度拆分主线程不再逐条调用readQueryFromClient而是把 FD 分发给 IO 线程批量读入缓冲区再回到主线程解析执行写回阶段同样由 IO 线程分担主线程只负责把输出链表里的数据引用交给线程池这意味着从宏观视角看Redis 的网络层变成了“分发-并行读-串行执行-并行写”四段流水而不是老版本里“读-解析-执行-写”的单行道。2. 连接建立与协议解析数据进入 Redis 的第一道关卡网络 IO 架构的深度拆解不能只停留在宏观。连接从 accept 到进入事件循环每一步都涉及数据结构和状态流转。2.1 连接建立的完整链路Redis 8.4 里客户端连接进来后anetTcpAccept返回 fd接着封装成connection对象再创建client结构体。client非常关键它绑定了查询缓冲区、输出缓冲区、连接状态、多线程读写的标志位等。注意连接建立时的几个细节每个新连接默认在CLIENT_NETWORK状态但不会立即注册读事件而是等到事件循环下一次迭代避免 accept 风暴瞬间压爆单次循环开启io-threads后新连接的 fd 会按轮询方式分配到各 IO 线程对应的本地连接列表里减少线程间争抢如果负载均衡器LVS/Nginx做透明代理所有连接可能来自同一个 IP此时需要注意maxclients与实际来源 IP 的关系避免误判2.2 RESP3 协议解析的处理Redis 协议从 RESP2 升级到 RESP3 后数据帧类型变多包括简单字符串、错误、整数、批量字符串、数组、Map、Set、Push 等类型。8.4 的解析器不再是简单按行切割而是引入了一个multi-bulk解析状态机。状态机的流转大概是这样的读取首字节判断类型标识符 - : $ * % ~ 等如果是批量字符串$N\r\n需要精确读取 N 字节再读取结尾的 CRLF数组*M表示后面有 M 个元素解析器需要维护一个嵌套深度计数器避免恶意超大数组导致递归过深解析过程如果发现数据不完整比如只到了半帧会返回C_ERR并标记解析阶段为等待更多数据下次继续这里有一个容易忽略的点协议解析是主线程完成的IO 线程只负责把字节流搬到输入缓冲区。所以即使开了 IO 多线程如果请求格式复杂或很碎主线程的解析开销仍然会成为瓶颈。实测中同样吞吐下用Redis Pipeline打包大量命令比逐条发小包能显著降低主线程解析压力。2.3 输入缓冲区的动态分配每个 client 的输入缓冲区是动态增长的但在 8.4 里有一个隐藏上限检查。如果客户端持续发送数据却不读取响应输入缓冲区可能占用大量内存。对应的保护机制是client-query-buffer-limit。这个概念很多人没注意默认值通常是 1GB但对生产环境来说太大了。如果一个客户端发送了超大请求比如几十 MB 的 value内存就会瞬间被吃光。建议把这个值调低到 256MB 或者更低配合CLIENT KILL命令做保护。3. IO 多线程的参数配置与场景取舍到底该不该开很多文章把io-threads讲成“无脑开到 8”就能顶住高并发这是误解。我踩过这个坑在一台 16 核机器上把io-threads从 4 调到 16结果吞吐不仅没提升锁竞争反而把 P99 拉高了。所以这一节值得展开讲。3.1 参数开关与默认行为redis.conf 里有四个相关配置io-threads N开启 N 个 IO 线程处理读写默认 1即关闭io-threads-do-reads yes/no默认情况下 IO 线程只处理写回复读仍然由主线程处理开启后读也交给 IO 线程tcp-backlogTCP accept 队列长度maxclients最大连接数官方建议是 4 核机器设置 2 或 38 核以上设置 4 到 8而且io-threads不应该超过机器核数-1空余一个核跑主线程和调度。如果机器没有多核或者实例的核心热点不在网络而在内存分配开多线程基本是负优化。3.2 多线程读写的内部同步IO 线程池在 8.4 里使用一个环形缓冲区分发 FD主线程和 IO 线程之间通过pthread_mutex与条件变量同步。每轮循环的大致流程是主线程遍历 poll 事件把需要读写的 fd 标记并挂到当前轮次的列表主线程忙等或睡眠直到所有 IO 线程完成当前批次主线程开始解析输入、执行命令、生成输出响应写入阶段再次交给 IO 线程然后主线程继续下一轮事件循环这个设计里有一个很精妙的点读写操作被拆分到不同轮次避免了同一时刻多线程操作同一个 fd 导致的并发写冲突。但代价是主线程等待 IO 线程完成时会有微小停顿。如果机器核数少、网络小包极高停顿反而超过直接单线程读写的损耗。3.3 不同场景下的实测对比我总结过一组粗略对比基于同一实例、同样 10 万连接、value 大小 100 字节的压测场景io-threadsio-threads-do-readsQPSP99ms纯 GET4 核1no15w0.8纯 GET4 核2no18w1.1纯 GET4 核2yes17w1.6纯 GET16 核4no30w1.2纯 GET16 核8yes22w2.8Pipeline 批量16 核4no52w1.5可见并非线程越多越好。开启io-threads-do-reads时要考虑多线程同时读 FD 会造成更多的上下文切换。对 Redis 这种内存数据库来说真正划算的场景是网络包很大或客户连接非常多读操作本身开销占比高的时候。如果只是小命令高频请求主线程的解析成本可能更关键。4. 输出缓冲区的隐性陷阱当 Redis 的网络层被内存判了死刑命令执行完成不等于请求结束响应还躺在输出缓冲区里等待写回客户端。这一步是网络 IO 架构里最容易被忽视的内存与流量管理问题。4.1 三种 client 输出缓冲区的差异按连接类型8.4 内部维护了三类输出缓冲区配置normal普通客户端replica主从复制时从节点连接pubsub发布/订阅连接三类缓冲的硬性限制参数分别对应client-output-buffer-limit normal/replica/pubsub。格式是hard_limit soft_limit soft_seconds。当缓冲区数据超过 hard或超过 soft 且持续 soft_seconds 秒连接会被直接关闭。默认配置通常类似normal 0 0 0不限制replica 256mb 64mb 60pubsub 32mb 8mb 60。4.2 慢消费者与大 Key 引发的级联故障有次线上故障一个PUBSUB频道客户端消费跟不上但发布者持续推消息pubsub 输出缓冲区迅速涨到 300MB最终触发了硬限制断开连接。断开后发布客户端还在持续写入其他连接的缓冲区也被拖累整个 Redis 实例内存飙升触发 OOM。复盘时的关键点在于输出缓冲区不是无限扩容的它受物理内存限制而且触发硬限制断开连接时响应可能已经堆积了大量数据断连会导致内存瞬间释放对实例造成波动。8.4 在这方面增加了更平滑的降级机制当某类连接的 buffer 接近限制时会优先通过内存碎片整理减轻压力而不是立即 kill。建议把普通客户端的缓冲区也设一个合理上限比如client-output-buffer-limit normal 512mb 128mb 60。虽然通常会因为某些批量操作大返回而报错但总比把整个实例拖垮好。4.3 内存分配策略与网络性能的耦合Redis 8.4 使用 jemalloc 或 glibc malloc编译时决定内存分配行为与网络吞吐有很强的相关性。每次write系统调用之前Redis 需要把输出链表中的数据拼接成连续的字节流吗不一定它支持writev聚合发送减少内存拷贝。但client输出缓冲区如果积累了太多小块数据writev的 iovec 数组会耗尽退化成多次write性能下降。这里有个实操经验如果你发现INFO stats里的mem_fragmentation_ratio很高并且出现大量write系统调用可以试着开启activedefrag yes同时调整active-defrag-max-scan-files。碎片化降低后网络层的发送效率也会改善因为小内存块拼接成大缓冲区更容易。5. 内核态与用户态的协作TCP 层网络参数的调优实践Redis 的网络 IO 不只发生在 Redis 进程里内核 TCP 协议栈的参数同样决定了连接的建立、回收和吞吐上限。这节讲几个最容易落地的参数。5.1 listen backlog 与 accept 队列Redis 的tcp-backlog参数对应内核队列长度。这个值如果太小高并发连接建立时握手完成队列溢出客户端会看到 connect 超时或 reset。net.core.somaxconn是内核层的上限Redis 的tcp-backlog超过它会被内核强制截断。推荐配置sysctl -w net.core.somaxconn2048tcp-backlog 1024sysctl -w net.ipv4.tcp_max_syn_backlog2048这里的逻辑是连接请求先进入 SYN 队列完成三次握手后进入 accept 队列Redis 事件循环从 accept 队列里取连接。如果accept速度跟不上队列打满新的连接请求会被直接丢弃。5.2 TIME_WAIT 连接快速回收与重用在短连接场景下比如每个请求新建 Redis 连接大量 TIME_WAIT 状态连接会让新连接无端口可用。有两个内核参数可以缓解net.ipv4.tcp_tw_reuse1允许在 TIME_WAIT 阶段重用端口发起新连接注意是客户端场景net.ipv4.tcp_fin_timeout15缩短 TIME_WAIT 时长对 Redis 服务端本身来说几百毫秒关闭的连接主要影响的是 fd 数量端口耗尽通常发生在高并发主动发连接的一侧比如 Sidecar 或业务SDK。所以如果你的业务框架每次都新建 Redis 连接一定要在客户端侧启用方案或改用连接池。5.3 Unix Socket 与 TLS 连接的额外开销同一台机器上的业务进程访问 Redis用 Unix Socket 代替 TCP 可以减少协议栈开销。实测中Unix Socket 方式在短连接场景下比 TCP 能提升 10%-20% 的吞吐。代价是排障不方便ss和strace看到的不是端口而是 socket 文件。8.4 对 TLS 连接的处理有专门优化TLS 握手和加解密本身非常耗 CPU因此开启 TLS 后IO 多线程的收益会显著提升。因为加解密操作可以分散到 IO 线程并行处理。但要特别注意如果同时使用io-threads和 TLS需要确认它们对connRead/connWrite的封层处理是否线程安全。实际上的 8.4 实现里TLS 读写线程用了独立锁压力大的场景仍有可能造成上下文切换开销。6. 监控指标与真实排障案例让网络 IO 瓶颈不再隐形再好的架构也要靠监控验证。分享一套我常用的指标组合以及一次排障过程。6.1 必须盯住的 INFO 指标connected_clients当前连接数连接数与网络 IO 压力不是线性关系instantaneous_ops_per_sec当前命令吞吐total_net_input_bytes/total_net_output_bytes累计网络流量可计算环比判断流量突增rejected_connections拒绝连接次数往往意味着maxclients或 backlog 不足mem_clients_normal、mem_clients_replicas、mem_clients_pubsub各类型连接占用内存快速定位缓冲区异常stat_io_threads_reads/stat_io_threads_writes这里指内部统计项不同版本字段可能有差异IO 线程工作次数redis-cli info stats一次能拿到大部分数据。如果要追溯历史建议搭配 Prometheus 的redis_exporter把net_input_bytes、connected_clients、io_threads_*指标做成趋势图。6.2 一次高连接数低吞吐的定位过程现象某实例connected_clients稳定在 2.5w但instantaneous_ops_per_sec只有 8wCPU 还有大量空闲P99 却高达 10ms。排查链路redis-cli info clients看maxclients与连接状态没有异常ss -s发现 TCP 连接中约 40% 处于 Last-ack/Close-wait大概率是客户端异常关闭资源perf top看到rwsem_spin_on_owner和conn_read相关的热点锁等待严重进一步确认 IO 线程数量为 4但实际 2.5w 连接里存在大量的短请求读写线程频繁切换调整方案加大tcp-backlog、开启io-threads-do-reads no保持默认写多线程、在业务侧启用长连接池把连接数压到 3000调整后 P99 从 10ms 掉到 1.2ms吞吐升到 22w。这个案例说明问题往往不在 Redis 模型本身而在于连接模式与 IO 线程的匹配度。6.3 针对 8.4 新特性的观测建议8.4 的源码里对网络层增加了一些统计字段比如 IO 线程单次处理 FD 的数量分布。如果你做源码剖析与架构实战建议重点看networking.c中handleClientsWithPendingReadsUsingThreads和writeToClient两个函数它们的主线逻辑直接决定多线程 IO 的表现。有条件的团队可以做一次“线程模型压测矩阵”固定连接数变化 io-threads 数量观察 QPS 与 P99 的曲线找出每个实例类型的最优配置。这类测试对业务稳定很有价值尤其是微服务环境中 Redis 连接被各服务共享的场景。6.4 最后的几条实操经验调整io-threads后需要重启实例不要相信热加载开启io-threads-do-reads时优先配合 Pipeline 使用否则读半包的概率会增大监控指标里不要只看平均值要看 P99 和 Max生产环境建议打开慢查询日志和latency monitor检测命令执行与事件循环的延迟异常总的来说Redis 8.4 的网络 IO 架构并不是一个孤立的黑盒它和连接模型、缓冲区策略、内核状态、IO 线程配置层层相关。把这一条链路真正吃透至少能在关键时刻少熬几个夜。