4. 网络超时异常回放:模拟超时场景,排查客户端重试与超时设置
老实说,网络超时是 Mock 回放里最容易被低估的坑。很多团队把超时当成一个「网络问题」,扔给运维就算完事。但你在做异常测试时会发现——超时引发的故障,往往比直接报错更难排查。为什么?因为超时意味着客户端和服务端都处在一种「悬而未决」的状态,日志不完整,重试逻辑可能被反复触发,最终拖垮整个系统。
我个人习惯把超时回放分成两类来对待:一类是连接超时,另一类是读取超时。两者模拟手段不同,排查思路也完全不一样。下面我们就一步步拆开来看。
4.1 超时回放的基本原理
先讲个我自己的经历。几年前排查过一个线上问题:用户反馈某个查询接口偶尔卡死,抓了堆栈发现线程全部阻塞在 HTTP 调用上。翻日志才知道,下游服务响应变慢,但我们的连接超时设了 30 秒,读取超时干脆没配——默认值是 0,意味着永不超时。那结果就是:下游慢,上游跟着一起死。
所以你得清楚:超时回放不是让 Mock 不响应,而是让 Mock 延迟响应或断开连接。这是排查客户端超时设置是否正确的唯一办法。
最常见的超时回放方案,我归纳下来有两种:
- 注入延迟 + 超时响应:Mock 先等待一段时间(比如 5 秒),然后返回 503 或直接断开 socket。用于验证客户端读取超时。
- 拒绝连接: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 客户端对这两种异常的处理策略差别很大,你需要针对你用到的客户端做针对性验证。
4.3 超时场景下的客户端行为
好,Mock 配完了。现在客户端收到超时后,会怎么反应?这就要看你的客户端配置了。我把它分成三类,你可以对照着检查一下你的项目:
| 客户端行为 | 触发条件 | 典型表现 |
|---|---|---|
| 静默重试 | connect timeout / read timeout | 日志只有一行"Retrying...", 对调用方完全透明 |
| 抛出异常 | 超过重试次数 | 调用方收到 TimeoutException, 业务链路中断 |
| 降级处理 | 配置了 fallback | 返回默认值或缓存数据, 日志标记为 degraded |
我见过最离谱的情况是:重试次数设了 5 次,每次重试之间的间隔是 0 毫秒。如果下游已经超时了,这种重试等于在伤口上撒盐——不仅救不回来,反而把连接池打爆了。所以排查超时要关注两个点:一是超时值本身,二是重试策略。
4.4 排查要点:客户端重试与超时设置
排查超时问题,我一般问自己三个问题:
- 超时值是否合理? 连接超时通常 2-5 秒就够了,读取超时可以给得宽松一些,但也不建议超过 30 秒。超过 30 秒的连接,多半是代码有问题而不是网络不行。
- 重试是否幂等? 写操作的重试一定要保证幂等。读操作也一样——如果接口不是纯查询,重试可能导致数据重复插入。
- 重试是否覆盖了超时异常? 很多项目的重试机制只捕获了
ConnectException,却忽略了SocketTimeoutException。结果就是:连接正常但响应慢——重试逻辑根本不会被触发。
你可以在 Mock 回放时分别触发两种超时,然后检查客户端的重试日志。如果重试次数和预期不一致,那就要去翻重试策略的源代码了。
4.5 我常用的排查工具和步骤
给你一张我自己的排查流程图,可以照着来:
排查的具体实操步骤,我按顺序列出来:
- 先确定超时类型:从客户端日志里找到异常栈。如果是
ConnectException,那就是连接超时;如果是SocketTimeoutException,那就是读取超时。 - 模拟对应场景回放:连接超时就用关闭端口模拟;读取超时就用延迟响应模拟。不要混。
- 观察重试行为:看客户端是否重试、重试了几次、每次间隔多长。重点关注重试是否触发了 新的 连接。
- 计算总耗时:重试次数 × 每次超时时间。确保这个数比调用方的超时时间小。
- 调整配置再跑一轮:改超时值、改重试次数、改退避策略,反复验证直到满足业务要求。
- 连接超时 ≠ 读取超时,两者的 Mock 模拟方式完全不同
- 重试总耗时必须小于业务超时(否则重试成了浪费)
- 写操作必须保证幂等,否则重试会引发脏数据
- 回放时一定要看客户端 DEBUG 日志,别只看应用日志
4.6 一个实际案例
最后分享一个我亲自排查的案例。有一个订单服务,下游库存接口偶尔超时。开发人员加了重试:重试 3 次,每次间隔 1 秒。结果发布后,下单成功率不但没提升,反而下降了。
我用 Mock 回放模拟了读取超时,发现了一个问题。客户端默认的连接超时是 5 秒,读取超时是 10 秒。每次重试都会重新建立连接,所以一次失败的实际耗时是:5 秒(连接超时)+ 10 秒(读取超时)= 15 秒。重试 3 次,总耗时达到了 45 秒。而业务方的全局超时只有 30 秒,结果是——三次重试还没跑完,调用方已经超时报错了。
解决方案其实很简单:把连接超时和读取超时分开配置,同时减少重试次数。改成连接超时 2 秒、读取超时 5 秒、重试 2 次。总耗时降到了 14 秒,问题解决。
所以你看,超时回放排查的不只是「超时」本身,而是整个链路的超时设计。好的 Mock 回放能让你在不上线的情况下就发现这些配置漏洞,这就是它的价值所在。