4. 网络超时异常回放:模拟超时场景,排查客户端重试与超时设置

老实说,网络超时是 Mock 回放里最容易被低估的坑。很多团队把超时当成一个「网络问题」,扔给运维就算完事。但你在做异常测试时会发现——超时引发的故障,往往比直接报错更难排查。为什么?因为超时意味着客户端和服务端都处在一种「悬而未决」的状态,日志不完整,重试逻辑可能被反复触发,最终拖垮整个系统。

我个人习惯把超时回放分成两类来对待:一类是连接超时,另一类是读取超时。两者模拟手段不同,排查思路也完全不一样。下面我们就一步步拆开来看。

本章核心:学会用 Mock 工具模拟 connect timeout 和 read timeout,验证客户端的连接池配置、重试次数、退避策略。重点是「回放不出错,但回放返回了超时」,这才是真正的异常测试。

4.1 超时回放的基本原理

先讲个我自己的经历。几年前排查过一个线上问题:用户反馈某个查询接口偶尔卡死,抓了堆栈发现线程全部阻塞在 HTTP 调用上。翻日志才知道,下游服务响应变慢,但我们的连接超时设了 30 秒,读取超时干脆没配——默认值是 0,意味着永不超时。那结果就是:下游慢,上游跟着一起死。

所以你得清楚:超时回放不是让 Mock 不响应,而是让 Mock 延迟响应或断开连接。这是排查客户端超时设置是否正确的唯一办法。

最常见的超时回放方案,我归纳下来有两种:

  1. 注入延迟 + 超时响应:Mock 先等待一段时间(比如 5 秒),然后返回 503 或直接断开 socket。用于验证客户端读取超时。
  2. 拒绝连接:Mock 服务不启动指定端口,或者启动后立刻关闭。用于验证连接超时。

两种方案的关键区别在于:延迟响应触发的是 read timeout,拒绝连接触发的是 connect timeout。很多客户端框架的默认配置对这两种超时的处理方式完全不一样,这一点你一定要注意。

我个人推荐的做法是:先用 reject 模式验证连接池,再用延迟模式验证重试逻辑。顺序别搞反,不然排查的时候容易一头雾水。

4.2 如何用 Mock 模拟超时

实际用工具怎么操作呢?我拿 MockServer 举个例子。你可以在 Mock 的 expectation 里直接指定一个 delay,或者给它配一个 dropConnection 行为。

// 模拟读取超时:延迟 10 秒返回,触发客户端 read timeout
mockClient.when(
  request().withPath("/api/order").withMethod("POST")
).respond(
  response()
    .withDelay(TimeUnit.SECONDS, 10)
    .withStatusCode(503)
    .withBody("{\"error\": \"timeout\"}")
);

// 模拟连接超时:不给任何响应,直接断开连接
mockClient.when(
  request().withPath("/api/order").withMethod("POST")
).respond(
  response()
    .withConnectionOptions(
      connectionOptions().withCloseSocket(true)
    )
);

注意第二段代码里的 withCloseSocket(true)。这个配置的底层原理是让 Mock 服务端直接关闭 TCP 连接,客户端这边就会抛出 SocketException: Connection reset 或者 NoHttpResponseException。不同的 HTTP 客户端对这两种异常的处理策略差别很大,你需要针对你用到的客户端做针对性验证。

一个小技巧:回放超时场景时,记得把客户端的日志级别调到 DEBUG。很多客户端在超时后不会主动打印完整的异常栈,调低日志级别能帮你第一时间看到连接池状态和重试次数。

4.3 超时场景下的客户端行为

好,Mock 配完了。现在客户端收到超时后,会怎么反应?这就要看你的客户端配置了。我把它分成三类,你可以对照着检查一下你的项目:

客户端行为 触发条件 典型表现
静默重试 connect timeout / read timeout 日志只有一行"Retrying...", 对调用方完全透明
抛出异常 超过重试次数 调用方收到 TimeoutException, 业务链路中断
降级处理 配置了 fallback 返回默认值或缓存数据, 日志标记为 degraded

我见过最离谱的情况是:重试次数设了 5 次,每次重试之间的间隔是 0 毫秒。如果下游已经超时了,这种重试等于在伤口上撒盐——不仅救不回来,反而把连接池打爆了。所以排查超时要关注两个点:一是超时值本身,二是重试策略。

4.4 排查要点:客户端重试与超时设置

排查超时问题,我一般问自己三个问题:

  1. 超时值是否合理? 连接超时通常 2-5 秒就够了,读取超时可以给得宽松一些,但也不建议超过 30 秒。超过 30 秒的连接,多半是代码有问题而不是网络不行。
  2. 重试是否幂等? 写操作的重试一定要保证幂等。读操作也一样——如果接口不是纯查询,重试可能导致数据重复插入。
  3. 重试是否覆盖了超时异常? 很多项目的重试机制只捕获了 ConnectException,却忽略了 SocketTimeoutException。结果就是:连接正常但响应慢——重试逻辑根本不会被触发。

你可以在 Mock 回放时分别触发两种超时,然后检查客户端的重试日志。如果重试次数和预期不一致,那就要去翻重试策略的源代码了。

避坑指南:我曾经遇到过一个问题——回放时 Mock 给的超时是 5 秒,客户端日志显示重试了 3 次,每次耗时都是 5 秒。一算总耗时:15 秒。调用方那边已经超时报错了,客户端这边还在傻傻地重试。这就是 重试超时 > 业务超时 的典型陷阱。你一定要确保重试的总耗时不超过调用方的等待上限。

4.5 我常用的排查工具和步骤

给你一张我自己的排查流程图,可以照着来:

网络超时回放故障排查流程 1. 确定超时类型 连接超时? 还是读取超时? 拒绝连接 (端口关闭) 延迟响应 (超10秒返回) 2. 检查客户端日志 (是否有重试? 异常类型?) 3. 验证总耗时 (重试总耗时 < 业务超时?) 4. 调整配置,重新回放

排查的具体实操步骤,我按顺序列出来:

  1. 先确定超时类型:从客户端日志里找到异常栈。如果是 ConnectException,那就是连接超时;如果是 SocketTimeoutException,那就是读取超时。
  2. 模拟对应场景回放:连接超时就用关闭端口模拟;读取超时就用延迟响应模拟。不要混。
  3. 观察重试行为:看客户端是否重试、重试了几次、每次间隔多长。重点关注重试是否触发了 新的 连接。
  4. 计算总耗时:重试次数 × 每次超时时间。确保这个数比调用方的超时时间小。
  5. 调整配置再跑一轮:改超时值、改重试次数、改退避策略,反复验证直到满足业务要求。
关键检查点:
  • 连接超时 ≠ 读取超时,两者的 Mock 模拟方式完全不同
  • 重试总耗时必须小于业务超时(否则重试成了浪费)
  • 写操作必须保证幂等,否则重试会引发脏数据
  • 回放时一定要看客户端 DEBUG 日志,别只看应用日志

4.6 一个实际案例

最后分享一个我亲自排查的案例。有一个订单服务,下游库存接口偶尔超时。开发人员加了重试:重试 3 次,每次间隔 1 秒。结果发布后,下单成功率不但没提升,反而下降了。

我用 Mock 回放模拟了读取超时,发现了一个问题。客户端默认的连接超时是 5 秒,读取超时是 10 秒。每次重试都会重新建立连接,所以一次失败的实际耗时是:5 秒(连接超时)+ 10 秒(读取超时)= 15 秒。重试 3 次,总耗时达到了 45 秒。而业务方的全局超时只有 30 秒,结果是——三次重试还没跑完,调用方已经超时报错了。

解决方案其实很简单:把连接超时和读取超时分开配置,同时减少重试次数。改成连接超时 2 秒、读取超时 5 秒、重试 2 次。总耗时降到了 14 秒,问题解决。

所以你看,超时回放排查的不只是「超时」本身,而是整个链路的超时设计。好的 Mock 回放能让你在不上线的情况下就发现这些配置漏洞,这就是它的价值所在。