Netty EventLoop 被阻塞后,为什么 RPC 超时会扩散?
RPC timeout 成片扩散时,根因不一定在下游,也可能在调用端的 Netty EventLoop。只有先辨认清楚是下游执行慢、业务线程在等,还是 I/O 线程已经推进不动,后面的线程池、Tomcat 入口线程和隐藏等待分析才不会跑偏。
很多 RPC 超时事故,第一眼看上去都很像“下游慢了”。
现场通常是这样的:
- 调用方开始大量报
timeout - 网关、接口 RT、错误率一起变差
- 下游服务却说自己 RT 还行,线程池和数据库也没明显炸掉
- 同一个调用方实例上,很多不同接口一起超时
这时候团队最容易走进一个误区:
- 既然是 RPC timeout,那肯定先去看下游
- 既然超时成片扩散,那一定是网络或者数据库出了大问题
但线上还有一类很典型的根因,经常被低估:调用端自己的 Netty EventLoop 被阻塞了。
一旦这件事发生,麻烦不在于“某一个请求慢了”,而在于:
- 同一个 EventLoop 上挂着的多个 Channel 都会被影响
- 响应读取、请求发送、心跳、重连、回调派发会一起延后
- 上游看到的是很多 RPC 同时 timeout
- 继续重试后,超时还会沿着线程池、连接池和下游继续放大
所以现场更值得先问的不是“下游是不是慢了”,而是:
这次 RPC timeout,究竟是下游执行慢了,还是调用端的 EventLoop 已经没空处理 I/O 了?
如果眼前还只是“接口慢、超时多、资源图不高”,但还没确认扩散发生在 RPC / Netty 这一层,先用 接口很慢,但 CPU、GC、数据库都正常,隐藏等待点可能在哪? 把等待层级切开。本文只处理已经怀疑超时沿调用端 I/O 线程扩散的现场。
一、判断现场是不是 EventLoop 阻塞扩散
不要一看到 RPC timeout 就把结论写成 EventLoop。下面这张表只是帮你核对,你面对的到底是不是 I/O 线程阻塞扩散。
| 你现在看到的现象 | 更像什么问题 | 下一步更适合看什么 |
|---|---|---|
| 同一调用端实例多个 RPC 一起 timeout,下游却不明显慢 | 典型 EventLoop 阻塞扩散 | 沿本文继续收窄 |
| 业务 worker active 打满、queue 持续增长、reject 出现 | 更像普通线程池拥塞 | 线程池打满以后,应该先查队列、拒绝策略还是慢任务? |
| queue 不长但业务线程或任务仍慢 | 更像伪正常 / 执行层等待 | 线程池队列不长但任务还是慢,常见瓶颈在哪里? |
| Tomcat busy 高,入口请求线程和内部任务互相等待 | 更像入口线程 vs 业务线程分诊 | Tomcat busy threads 很高,和业务线程池 backlog 是一回事吗? |
| backlog 主要出现在异步任务、补偿和 MQ 消费 | 更像异步 backlog 主要链路 | 异步任务越堆越多,问题常常不在异步本身 |
| 还没确认是应用、网络还是依赖,只知道接口慢而资源图不高 | 更像隐藏等待总判断 | 接口很慢,但 CPU、GC、数据库都正常,隐藏等待点可能在哪? |
如果第一行最贴近你的现场,就沿本文继续收窄;如果后面几行更贴近,不要把所有 timeout 都提前写成 EventLoop 阻塞。
二、先把 EventLoop 的职责说清楚:它不是普通业务线程
很多人知道 Netty 有 EventLoop,但排障时还是会下意识把它当成“另一个线程池”。
这会带来很多误判。
EventLoop 更像一个串行 I/O 调度器,它常见会负责这些事:
- 处理 Channel 上的读写事件
- 分发 decode / encode 相关逻辑
- 派发 Promise / Future 回调
- 执行定时任务、重连任务、心跳任务
- 推进 write flush、读响应、连接状态变化
关键点在于:它本来就不适合承载重计算、长阻塞、同步下游调用。
因为同一个 EventLoop 往往服务的不只是一个请求,也不只是一个连接,而是一组连接和它们对应的事件。
所以一旦 EventLoop 被卡住,受影响的不会只是一条 RPC,而是这条 loop 上的一串请求。
三、为什么 EventLoop 只卡了一下,RPC 超时却会成片扩散
这类事故最难理解的地方就在于:
- 你明明没有看到下游服务整体变慢
- 但调用方却一批一批 timeout
原因是 EventLoop 卡住以后,慢的不是某一段业务逻辑,而是 I/O 推进本身。
1. 响应已经回来了,但调用端没及时读到
下游可能早就把响应发回来了。
但如果调用端的 EventLoop 正在做不该做的事,比如:
- 在回调里跑同步逻辑
- 在 handler 里做重序列化或大对象转换
- 在 loop 线程里写大量日志
- 在 listener 里调另一个阻塞调用
那响应包即使到了 socket,也可能不能及时被读出来、分发到 promise、唤醒业务线程。
调用方看到的就会是 timeout。
2. 请求发不出去,或者 flush 被整体延后
如果 EventLoop 卡住,请求也可能不是“等返回太久”,而是“根本没及时发出去”。
这时现场很容易出现两种相互矛盾的说法:
- 调用方说:我发了请求,结果超时了
- 下游说:我这边根本没收到这么多请求
其实两边都没说错。
因为请求在调用端可能还停留在:
- 编码未完成
- flush 未执行
- 写事件未真正推进
3. 心跳、重连、超时任务一起被拖后
EventLoop 不只处理正常请求,还会处理:
- 心跳
- 空闲检测
- 定时超时
- 重连任务
所以一旦 loop 阻塞,心跳延迟和连接抖动也会跟着出现。现场会变得更像“网络不稳定”或者“下游节点有问题”。
4. 重试会把局部阻塞放大成系统性超时
最麻烦的是,当 EventLoop 阻塞已经让调用方开始 timeout,上游往往还会继续重试。
于是链路会演化成:
EventLoop 卡住 -> 一批请求发不准 / 收不准 -> timeout 增多 -> 上游重试 -> 更多请求压到同一批连接和线程 -> 超时继续扩散
所以这类事故经常看起来像“下游突然整体不行了”,其实第一现场可能一直在调用端本机。
四、哪些现场更接近 EventLoop 阻塞,而不是下游真的慢
如果你怀疑是 Netty EventLoop 问题,我更建议优先找下面这些信号。
1. 同一个调用端实例最明显,问题不均匀
这类事故经常不是所有实例一起坏,而是:
- 某几台实例 timeout 特别多
- 同样调用同一批下游,只有部分实例异常
- 重启异常实例后短期恢复
这更像本地线程、连接、EventLoop 或运行态问题,而不完全像下游整体退化。
2. 下游监控看起来正常,但调用方 timeout 很多
常见表现是:
- 下游 RT 没明显抬高
- 下游 access log 收到的请求量低于调用方统计的“发起量”
- 下游线程池、数据库、GC 没对应恶化
这时就要警惕:不是下游处理慢,而是请求没及时发到,或者响应没及时读出。
3. connect timeout、read timeout、心跳异常混在一起出现
如果同时出现这些现象:
- 连接建立慢
- 读超时增多
- 心跳超时或 idle 断连增多
- 重连任务变多
就不要只盯某个业务接口。很多时候这说明 loop 本身已经没有稳定推进事件。
4. EventLoop 线程栈里出现了不该在 loop 上跑的逻辑
这是最直接的证据之一。
如果你抓线程栈,看到 Netty 的 loop 线程卡在这些位置,就要高度警惕:
- JSON / protobuf 大对象序列化
- 同步日志落盘或异步日志阻塞回压
- 同步 HTTP / RPC 调用
- 本地锁等待
- 大对象压缩、解压
- DNS 解析、证书校验、文件 I/O
正常情况下,EventLoop 不该长时间停在这些业务动作上。
五、最常见的几个触发场景
线上真正把 EventLoop 卡住的,往往不是“明显写错一个 while true”,而是一些看起来没那么夸张的动作。
1. 在 handler 或 callback 里做同步阻塞调用
这是最典型的一类。
比如:
- 收到响应后,在 listener 里同步查库
- 在 channelRead 里直接调另一个远程接口
- 在连接建立回调里做阻塞鉴权
这些动作一旦跑在 EventLoop 线程上,影响的就不是当前请求,而是整个 loop 上的后续事件。
2. 编解码或序列化太重
常见于:
- 超大报文
- 嵌套对象过深
- 压缩、解压开销过大
- 反序列化后立即做复杂对象转换
这类问题经常让人误判成“CPU 没打满,为什么 I/O 还变慢”。
因为真正慢的是 EventLoop 的单线程推进速度,不一定体现在总体 CPU 饱和上。
3. 日志、监控、埋点回调放错了位置
有些项目会在网络 handler 里直接做:
- 大量结构化日志拼装
- trace tag 组装
- 同步 MDC 处理
- 大量字符串拼接
这些动作单次看不大,但高并发下非常容易把 loop 拖慢。
4. DNS、TLS、连接管理相关逻辑卡在 loop 上
比如:
- 连接池失效后大量新建连接
- DNS 解析偶发变慢
- TLS 握手异常
- 代理或 mesh 侧重连抖动
这类问题会让现场更像“网络突然抖了”,但实际瓶颈可能是调用端 loop 在忙于处理连接管理。
六、怎么把它和“下游真的慢”区分开
这一步最关键。
更像下游真的慢时,通常会看到
- 下游 RT、线程池、数据库、锁等待同步恶化
- 下游 access log 能对上大部分慢请求
- 调用方的 timeout 与下游处理耗时高度一致
- 下游不同调用方都一起受影响
更像调用端 EventLoop 阻塞时,通常会看到
- 下游收到的请求量对不上调用方发起量
- 下游 RT 没那么差,但调用方 timeout 很高
- 部分调用端实例特别明显
- 抓到 EventLoop 线程在执行不该在 loop 上执行的阻塞逻辑
一句话概括就是:
下游真的慢,是请求到了但处理慢;EventLoop 阻塞,是请求和响应在调用端就已经推进不动了。
七、现场可以这样收敛
如果线上已经出现“RPC timeout 成片扩散”,我更建议按下面顺序把范围缩小。
第 1 步:先看问题是不是集中在少数调用端实例
重点看:
- 哪些实例 timeout 最多
- 是否重启单实例会短期恢复
- 同一实例上是不是多个下游一起受影响
第 2 步:对齐调用方发起量和下游收到量
这一步很值钱。
如果调用方说发了很多,但下游明显没收到对应量,就别先把锅都给下游处理逻辑。
第 3 步:抓 EventLoop 线程栈
重点看 loop 线程此时在做什么:
- 是在正常 select / epoll wait
- 还是在跑业务代码、日志、序列化、锁等待
第 4 步:再看 loop 上的连带症状
例如:
- 心跳延迟
- flush 延迟
- 重连增多
- pending task 变多
- write buffer 异常堆积
第 5 步:最后再把重试、线程池、连接池放回全链路
因为 EventLoop 被拖住以后,超时很容易继续放大到:
- 上游重试
- 业务线程等待 future
- 数据库连接池紧张
- 整体接口 RT 抬高
八、一个典型案例:为什么下游服务明明没慢,订单接口却开始大面积 RPC timeout
假设订单服务通过 Netty 客户端调营销服务。
现场现象
- 订单服务里多个依赖调用一起报 timeout
- 营销服务自己 RT 基本正常
- 同一个订单服务实例最严重
- 该实例重启后恢复
继续看线程栈
发现异常实例的某个 NioEventLoop 线程长期停在:
- 响应回调里的大对象 JSON 转换
- 拼接审计日志
- 同步写本地文件
于是这条 loop 上的请求开始出现:
- 响应到达但无法及时分发
- flush 延迟
- 心跳抖动
- 上游 future 等超时
最后的真实链路
EventLoop 被回调阻塞 -> 一批 RPC 响应不能及时处理 -> 上游业务线程等待 future 超时 -> 上游继续重试 -> timeout 从单机扩散成链路问题
这个案例很典型地说明:RPC timeout 的第一现场,不一定在被调用方,也可能在调用方自己的网络线程。
九、这篇文章和隐藏等待 / 线程池 / Tomcat 分诊链的边界
为了避免它和普通线程池、Tomcat 入口线程文章互相回抢,可以直接按下面这组边界理解。
- 本文只处理调用端 Netty EventLoop、Channel I/O、回调派发这一路的阻塞扩散;如果你已经明确是普通业务 worker 线程拥塞,说明堵点更靠近业务 worker,回到 线程池打满以后,应该先查队列、拒绝策略还是慢任务?。
- 本文不会替代“queue 不长但 worker 仍慢”的伪正常场景页;如果重点是业务线程执行阶段慢,而不是 I/O 线程推进不动,更适合去 线程池队列不长但任务还是慢,常见瓶颈在哪里?。
- 如果入口请求线程和业务线程互相等待,重点已经变成 Tomcat 入口层和执行层的分诊,不应继续停在 EventLoop 文章里;这时改看 Tomcat busy threads 很高,和业务线程池 backlog 是一回事吗?。
- 如果 backlog 主要落在异步任务、补偿任务和 MQ 消费,本文只够提醒你“timeout 也可能沿 I/O 线程扩散”,真正主线还是 异步 backlog 文章。
- 如果你还没确认到底是应用、网络、下游还是 I/O 线程,只知道接口慢和超时增多,先用 隐藏等待总判断页 把层级切开。
- 如果真正慢的是数据库连接获取、长事务、锁等待,本文不替代数据库等待链;那条链该由连接池边界和数据库文章继续承接。
十、关键误判:这类问题最容易在哪些地方走偏
误判 1:只要报 RPC timeout,就默认下游慢
不对。
调用端的 EventLoop、连接管理、编码和回调链同样可能是第一现场。
误判 2:CPU 不高,就可以排除 EventLoop 阻塞
也不对。
EventLoop 是单线程推进模型,总 CPU 不高并不代表 loop 没被一个阻塞动作拖住。
误判 3:下游没报错,就说明不是 RPC 框架问题
也不对。
如果请求没及时发出去,或者响应没及时读出来,下游当然可能“看起来没问题”。
误判 4:只盯业务线程池,不看网络线程
很多现场业务线程只是“等 future 结果超时”,真正的堵点在网络线程上。
十一、FAQ:EventLoop 阻塞最常见的疑问
1. EventLoop 被阻塞后,为什么会影响多个 RPC,而不是一个请求?
因为一个 EventLoop 通常服务多个 Channel 和多个事件源。它一旦被占住,读写、回调、心跳、超时任务都会一起延后。
2. 下游监控正常,能不能基本确定是调用端问题?
不能直接下结论,但优先级会明显提高。尤其是当下游收到量和调用方发起量对不上的时候,更该优先查调用端 loop、连接和中间层。
3. 抓到 EventLoop 在跑序列化或日志,就一定是根因吗?
要看停留时间和并发量。如果只是瞬时经过不一定有问题;如果在高峰期长期卡在这些逻辑上,而且和 timeout 时间窗口对齐,就很可疑。
4. 最直接的治理动作是什么?
核心思路是把不该放在 loop 上的工作挪出去,例如:
- 重逻辑切到业务线程池
- 回调里不做同步阻塞动作
- 压缩、序列化、日志做限量或异步化
- 对大包、重试、连接重建做隔离和限流
5. EventLoop 阻塞和普通线程池打满,最大的区别是什么?
普通线程池打满更多是业务 worker 不释放,现象常见是 active、queue、reject;EventLoop 阻塞更像 I/O 推进和回调派发停住了,请求可能没及时发出、响应没及时读回、心跳和重连也一起延后。两者都可能导致 timeout,但堵点层级完全不同。
6. 下游监控正常时,为什么调用方还会 timeout?
因为 timeout 不一定代表“下游执行慢”,也可能代表“调用端没把请求及时发出去”或“响应已经回来了但调用端没及时处理”。这正是本文和下游慢链最大的边界。
7. 什么时候该转去看 Tomcat busy 或普通线程池,而不是继续盯 EventLoop?
当 timeout 已经主要表现为入口请求线程等待内部任务,或者普通业务 worker active / queue 指标更先恶化时,就要切回 Tomcat 分诊页或线程池现象页。EventLoop 文章只适合解释 I/O 线程自身的阻塞扩散,不替代所有超时现场。
十二、如果现场更接近这些症状,就往对应方向看
如果你读到这里,下一步就别再把所有 timeout 都交给下游了,而是按更接近现场的症状往下收:
- 如果你已经确认是普通业务 worker active / queue 先恶化,继续看 线程池打满以后,应该先查队列、拒绝策略还是慢任务?
- 如果 worker queue 不长,但任务执行阶段仍慢,继续看 线程池队列不长但任务还是慢,常见瓶颈在哪里?
- 如果入口请求线程和内部任务互相等待,继续看 Tomcat busy threads 很高,和业务线程池 backlog 是一回事吗?
- 如果 backlog 主要出现在异步任务、补偿、MQ 消费,继续看 异步任务越堆越多,问题常常不在异步本身
- 如果你还没确认是应用、网络、依赖还是 I/O 线程,只知道接口慢和超时增多,先回到 接口很慢,但 CPU、GC、数据库都正常,隐藏等待点可能在哪?
- 如果 timeout 已经伴随重试风暴和调用链扩散,再接着看重试放大和接口超时分诊相关文章
十三、最后总结:EventLoop 阻塞最容易被误写成“下游慢了”
Netty EventLoop 被阻塞以后,麻烦不只是单个请求变慢,而是 I/O 推进、回调派发、心跳和重连会一起被拖住。
更实用的收敛顺序是:
先看清 timeout 是不是集中在少数调用端实例,再对齐调用方发起量和下游收到量,然后直接看 EventLoop 线程在做什么,最后再把重试、线程池和连接池纳入同一段超时扩散过程。
这一步一旦做实,很多看起来像“下游突然整体变慢”的 RPC 事故,最后都会落到调用端网络线程上的一个更具体、也更容易验证的堵点。