4. 超时与重试机制:连接超时/读取超时设置,指数退避重试策略,幂等性保证与重试风暴防护
聊到系统稳定性,超时和重试是绕不开的一对「孪生兄弟」。
我见过太多系统,要么超时设得太大,把整个线程池拖垮;要么重试不加控制,直接把下游打挂。说白了,这两个参数设不好,就是给自己埋雷。
4.1 连接超时 vs 读取超时
先搞清楚这两个概念。很多人混为一谈,其实它们管的是不同阶段。
- 连接超时:从发起 TCP 握手到建立连接的时间。网络不通、IP 不对、端口没开,都会卡在这一步。
- 读取超时:连接建立后,等待对端返回数据的时间。服务端处理慢、死锁、GC 停顿,都会导致读取超时。
我个人习惯,连接超时设得短一些,比如 500ms 到 1s。为什么呢?连接失败通常是网络或配置问题,没必要等太久。读取超时可以稍微长一点,比如 3s 到 5s,毕竟业务处理需要时间。
经验之谈:我在项目中遇到过,某次把连接超时设成了 10s,结果上游服务挂了,所有请求都卡在连接阶段,线程池瞬间被打满。后来改成 1s,问题立刻缓解。
代码示例(Java 中使用 HttpClient):
RequestConfig config = RequestConfig.custom()
.setConnectTimeout(1000) // 连接超时 1 秒
.setSocketTimeout(3000) // 读取超时 3 秒
.setConnectionRequestTimeout(500) // 从连接池获取连接的超时
.build();
小提示:连接超时和读取超时不要设成一样的值。连接超时应该比读取超时短,这是常识,但很多人会搞反。
4.2 指数退避重试策略
重试不是简单的「失败了再来一次」。你想想看,如果服务端已经扛不住了,你立刻重试,那不是雪上加霜吗?
指数退避的核心思想很简单:每次重试的间隔时间逐渐增加。比如第一次等 1 秒,第二次等 2 秒,第三次等 4 秒,第四次等 8 秒……
公式:delay = baseDelay * (2 ^ attempt),再加上一个随机抖动,防止所有客户端同时重试。
我曾经在一个支付系统中,看到重试间隔固定为 1 秒。结果下游数据库抖动,所有请求都在 1 秒后同时重试,直接把数据库打崩了。加上指数退避和随机抖动后,再也没出现过类似问题。
public void retryWithExponentialBackoff(Runnable task, int maxRetries) {
int baseDelay = 1000; // 基础延迟 1 秒
for (int i = 0; i < maxRetries; i++) {
try {
task.run();
return; // 成功则退出
} catch (Exception e) {
if (i == maxRetries - 1) throw e; // 最后一次失败,抛出异常
long delay = (long) (baseDelay * Math.pow(2, i) * (1 + Math.random()));
Thread.sleep(delay);
}
}
}
注意:指数退避不是万能的。如果重试次数太多,总等待时间会很长。建议最大重试次数不超过 3-5 次,总超时控制在 30 秒以内。
4.3 幂等性保证
重试的前提是什么?是操作必须幂等。说白了,就是同一个请求执行多次和执行一次,结果是一样的。
为什么重要?因为重试时,你无法判断上一次请求到底有没有成功。可能服务端已经处理成功了,只是返回结果时网络断了。这时候你再重试,如果操作不幂等,就会产生重复数据。
常见的幂等实现方式:
- 唯一请求 ID:每次请求带上一个全局唯一的 ID,服务端根据 ID 去重。
- 数据库唯一索引:比如订单号、流水号,重复插入会报错,但不会产生脏数据。
- 版本号/乐观锁:更新操作带上版本号,防止覆盖。
我个人习惯,在微服务调用中,强制要求每个请求都带上 requestId。服务端收到请求后,先查缓存里有没有这个 ID 的处理结果,有就直接返回,没有才处理。
// 幂等校验伪代码
public Response handleRequest(Request req) {
String requestId = req.getRequestId();
// 先查幂等缓存
Response cached = idempotentCache.get(requestId);
if (cached != null) {
return cached; // 直接返回上次结果
}
// 处理业务逻辑
Response result = doProcess(req);
// 存入缓存,设置过期时间
idempotentCache.put(requestId, result, 24, TimeUnit.HOURS);
return result;
}
避坑指南:幂等缓存的过期时间要合理。太短的话,重试还没结束缓存就过期了;太长的话,占用内存。我一般设 24 小时,配合定时清理。
4.4 重试风暴防护
重试风暴,说白了就是「雪崩的导火索」。一个服务挂了,所有调用方都在疯狂重试,流量瞬间放大几倍甚至几十倍,直接把下游打垮。
我经历过一次惨痛的教训。某个核心服务因为数据库慢查询,响应时间从 10ms 涨到 5s。调用方设置了 3 次重试,结果流量变成了原来的 4 倍。更可怕的是,这些重试请求又触发了其他服务的重试,最终整个链路都挂了。
防护手段主要有这么几种:
| 防护手段 | 说明 | 推荐做法 |
|---|---|---|
| 限制重试次数 | 最多重试 2-3 次 | 超过次数直接降级 |
| 限制重试比例 | 只对一定比例的请求重试 | 比如只重试 10% 的失败请求 |
| 熔断器 | 错误率达到阈值,直接熔断 | 配合 Hystrix 或 Sentinel |
| 请求缓存 | 相同请求短时间内只处理一次 | 结合幂等 ID 实现 |
核心原则:重试是手段,不是目的。如果下游已经扛不住了,重试只会加速死亡。这时候应该做的是降级、熔断,而不是死磕。
嗯,这里要注意一点:重试风暴往往不是单个服务的问题,而是整个调用链的连锁反应。所以,我建议在架构层面统一管理重试策略,而不是让每个开发人员自己拍脑袋设参数。
最后总结一下我的个人经验:
- 连接超时设短,读取超时设长,别搞反了
- 重试用指数退避,加随机抖动
- 不幂等的操作,绝对不要重试
- 重试一定要有上限,配合熔断器使用
做到这几点,你的系统在面对故障时,至少不会因为重试把自己玩死。