4. 超时异常处理:TimeoutException 及其捕获归纳总结
超时异常,说白了你得先接受一个事实:在分布式系统里,调用一定会超时。不是可不可能的问题,而是什么时候发生的问题。
我个人习惯在写 SDK 的第一天就把 TimeoutException 当成一等公民来对待。它不是偶然异常,它是系统设计的一部分。你想想看,如果每次超时都让调用方自己拿 try-catch 兜着,那 SDK 就太不负责任了。
4.1 TimeoutException 的本质
TimeoutException 在 Java 里是 java.util.concurrent 包下的一个 checked exception。但很多框架会自己包装一层。我见过好几种变体:
| 异常类型 | 来源 | 典型场景 |
|---|---|---|
| java.util.concurrent.TimeoutException | JUC 原生 | Future.get(timeout, unit) 等待超时 |
| com.google.common.util.concurrent.UncheckedTimeoutException | Guava | FluentFuture.withTimeout() 超时 |
| org.springframework.dao.QueryTimeoutException | Spring | 数据库查询超时 |
| io.netty.handler.timeout.ReadTimeoutException | Netty | Channel 读空闲超时 |
| org.apache.http.conn.ConnectTimeoutException | HttpClient | TCP 连接建立超时 |
SdkTimeoutException。这样调用方只需要 catch 一个异常,不用记住五六个不同的类名。降低心智负担,比什么都重要。
4.2 超时异常的发生场景
你可能会觉得,超时不就是请求发出去等太久吗?其实没那么简单。我在微服务项目里遇到过几次全链路雪崩,根源就是 TimeoutException 没处理对。
典型的超时触发点:
- 连接超时: 三次握手没完成。服务器挂了、防火墙丢包、路由不可达。
- 读取超时: 连接建立了,但数据迟迟不来。比如数据库死锁、下游 GC 停顿。
- 写入超时: 发送缓冲区满了,写不进去。背压场景常见。
- 总耗时超时: 整个调用链从开始到结束超过预设阈值。SDK 级别的兜底。
为什么在代码里必须考虑如何处理这个异常呢?因为不同场景下的处理策略完全不一样。
4.3 捕获策略:粒度要细
我看过一些代码,直接在 Service 层最外面包一层 try-catch,里面全盘接住 Exception。这种做法在超时场景下非常危险。
我的建议是:按照超时类型分层捕获。
// 层级 1:单次调用超时
try {
Future<Response> future = executor.submit(callable);
Response resp = future.get(200, TimeUnit.MILLISECONDS);
return resp;
} catch (TimeoutException e) {
// 记录失败次数,判断是否重试
retryCoordinator.recordFailure(request);
if (retryCoordinator.shouldRetry(request)) {
return asyncRetry(request);
}
throw new SdkTimeoutException("单次调用超时", e);
}
// 层级 2:重试循环超时
try {
return retryTemplate.execute(retryCtx -> {
// 这里面的单次超时已经在上面捕获了
return doCall(request);
});
} catch (SdkTimeoutException e) {
// 重试耗尽仍然超时,走降级
return fallbackStrategy.apply(request);
}
4.4 捕获后的标准动作
捕获 TimeoutException 之后,不是直接扔回去就完事了。你至少得做三件事:
- 记录上下文: 包括请求 ID、耗时、已重试次数、超时阈值。这些信息排查问题时救命用的。
- 更新熔断器状态: 连续超时应该触发熔断器打开,给下游喘息机会。
- 指标上报: 记录超时次数、耗时分布。方便做容量规划和阈值调整。
我曾在某次线上事故里发现,调用方已经把 TimeoutException 吞掉了,只打了一行 warn 日志。结果问题持续发酵了两个小时,直到全链路打爆。嗯,那之后我把日志级别强制提成了 error,并且接入了实时告警。
4.5 避坑指南:常见的 TimeoutException 陷阱
- 陷阱一:线程池拒绝策略和超时混用。 线程池满了抛 RejectedExecutionException,你误当成超时去重试——结果请求越积越多。
- 陷阱二:全局超时和重试超时叠加计算。 重试 3 次,每次 200ms,外部超时设了 500ms。结果前两次重试的耗时就把预算用完了,第三次必超时。
- 陷阱三:异步回调中的 TimeoutException 没传播。 CompletableFuture 里的超时异常可能被 exceptionally 接到了,但你没往上抛,调用方以为成功了。
- 陷阱四:代码实现中异常类不匹配。 你 catch 的是 TimeoutException,但底层抛出的是 RuntimeException 包装过的。catch 了个寂寞。
4.6 归纳总结:一个完整的超时异常处理流程
下面这张图展示了我做 SDK 时的标准处理流程。你可以把它当成一个 checklist 来用。
这张图其实就讲了一件事:超时不是终点,它是一个决策点。你需要在捕获的瞬间判断——能不能重试?该不该降级?要不要告警?
我个人习惯是在 SDK 内部把这个流程封装成一个 TimeoutHandler。每次调用前注册好处理策略,超时发生时自动走完这个流程。调用方只需要关心业务逻辑,异常处理交给 SDK 统一管理。