Java

Netty EventLoop 被阻塞后,为什么 RPC 超时会扩散?

RPC timeout 成片扩散时,根因不一定在下游,也可能在调用端的 Netty EventLoop。只有先辨认清楚是下游执行慢、业务线程在等,还是 I/O 线程已经推进不动,后面的线程池、Tomcat 入口线程和隐藏等待分析才不会跑偏。

  • Netty
  • RPC
  • EventLoop
  • 接口超时
  • 性能排查
17 分钟阅读

很多 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 入口线程文章互相回抢,可以直接按下面这组边界理解。

十、关键误判:这类问题最容易在哪些地方走偏

误判 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 都交给下游了,而是按更接近现场的症状往下收:

十三、最后总结:EventLoop 阻塞最容易被误写成“下游慢了”

Netty EventLoop 被阻塞以后,麻烦不只是单个请求变慢,而是 I/O 推进、回调派发、心跳和重连会一起被拖住。

更实用的收敛顺序是:

先看清 timeout 是不是集中在少数调用端实例,再对齐调用方发起量和下游收到量,然后直接看 EventLoop 线程在做什么,最后再把重试、线程池和连接池纳入同一段超时扩散过程。

这一步一旦做实,很多看起来像“下游突然整体变慢”的 RPC 事故,最后都会落到调用端网络线程上的一个更具体、也更容易验证的堵点。