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 连接建立超时
一点经验: 我建议 SDK 内部统一包装成一个自定义的 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 之后,不是直接扔回去就完事了。你至少得做三件事:

  1. 记录上下文: 包括请求 ID、耗时、已重试次数、超时阈值。这些信息排查问题时救命用的。
  2. 更新熔断器状态: 连续超时应该触发熔断器打开,给下游喘息机会。
  3. 指标上报: 记录超时次数、耗时分布。方便做容量规划和阈值调整。

我曾在某次线上事故里发现,调用方已经把 TimeoutException 吞掉了,只打了一行 warn 日志。结果问题持续发酵了两个小时,直到全链路打爆。嗯,那之后我把日志级别强制提成了 error,并且接入了实时告警。

4.5 避坑指南:常见的 TimeoutException 陷阱

我曾经踩过的坑: Future.get() 的超时参数传了 0。你以为传 0 代表不限时?错了,传 0 代表立即超时。这个坑我踩了一次,再也没忘。
  • 陷阱一:线程池拒绝策略和超时混用。 线程池满了抛 RejectedExecutionException,你误当成超时去重试——结果请求越积越多。
  • 陷阱二:全局超时和重试超时叠加计算。 重试 3 次,每次 200ms,外部超时设了 500ms。结果前两次重试的耗时就把预算用完了,第三次必超时。
  • 陷阱三:异步回调中的 TimeoutException 没传播。 CompletableFuture 里的超时异常可能被 exceptionally 接到了,但你没往上抛,调用方以为成功了。
  • 陷阱四:代码实现中异常类不匹配。 你 catch 的是 TimeoutException,但底层抛出的是 RuntimeException 包装过的。catch 了个寂寞。

4.6 归纳总结:一个完整的超时异常处理流程

下面这张图展示了我做 SDK 时的标准处理流程。你可以把它当成一个 checklist 来用。

超时异常处理流程 发起调用 等待响应 / 超时监控 已超时? 正常返回 捕获 TimeoutException 可重试?是 → 重试 | 不可重试 → 降级兜底 + 记录告警 关键:每次超时都更新熔断器状态,避免雪崩

这张图其实就讲了一件事:超时不是终点,它是一个决策点。你需要在捕获的瞬间判断——能不能重试?该不该降级?要不要告警?

我个人习惯是在 SDK 内部把这个流程封装成一个 TimeoutHandler。每次调用前注册好处理策略,超时发生时自动走完这个流程。调用方只需要关心业务逻辑,异常处理交给 SDK 统一管理。

最后一条经验: 超时阈值别写死。我见过太多 SDK 把 500ms 硬编码进去,生产环境一扩容就扛不住。把阈值设计成可配置的,并且支持按接口维度独立设置。这是 SDK 成熟度的分水岭。