3、超时与重试机制:超时设置策略、指数退避算法、重试风暴防范、幂等性设计
聊到分布式系统的容错,超时和重试是绕不开的一对「孪生兄弟」。我见过太多系统,要么超时设得太短导致频繁误判,要么重试逻辑写得像「自杀式袭击」——直接把下游打挂。说白了,这玩意儿就像走钢丝,既要快,又要稳。
3.1 超时设置策略:别拍脑袋定数字
超时时间怎么定?我个人的习惯是:先测量,再设定,最后动态调整。千万别拍脑袋写个 500ms,那跟算命没区别。
举个例子。我在项目中遇到过这样一个场景:一个订单查询接口,平时 95% 的请求在 200ms 内返回,但偶尔会飙到 800ms。如果我把超时设在 300ms,那 5% 的正常请求会被误杀。所以我一般看 P99——也就是 99% 的请求在多少毫秒内完成。假设 P99 是 600ms,那我设 1.2s 就相对安全。
| 分位值 | 含义 | 推荐用途 |
|---|---|---|
| P50 | 一半请求在此时间内完成 | 日常监控,不用于超时设定 |
| P95 | 95% 的请求在此时间内完成 | 宽松场景的参考值 |
| P99 | 99% 的请求在此时间内完成 | 超时设定的首选参考 |
| P999 | 99.9% 的请求在此时间内完成 | 核心链路、资金类场景 |
另外,超时不是一成不变的。网络抖动、业务高峰期,响应时间都会变。我建议用滑动窗口 + 动态超时:每 10 秒计算一次最近窗口内的 P99,然后自动调整超时阈值。嗯,这里要注意——调整幅度别太大,每次 ±10% 就差不多了,否则系统会「抖」起来。
3.2 指数退避算法:别一窝蜂往上冲
重试最忌讳什么?一失败就立刻重试,而且每次间隔都一样。你想想看,如果下游服务已经过载了,你每秒重试 10 次,那不是雪上加霜吗?
指数退避算法就是来解决这个问题的。它的核心思想很简单:每次重试的等待时间,按指数增长。
// 基础指数退避
wait_time = base_delay × (2 ^ retry_count)
// 实际项目中常用的是「带抖动的指数退避」
wait_time = min(
max_delay,
random(base_delay × (2 ^ retry_count), base_delay × (2 ^ (retry_count + 1)))
)
为什么要加随机抖动?我记得有一次线上事故,几百个客户端同时检测到连接超时,然后按照相同的指数退避策略重试——结果你猜怎么着?它们在同一时刻又发起了请求,下游直接被冲垮。这就是「惊群效应」。加上随机抖动,相当于给每个请求的等待时间加了一点「噪音」,让它们错峰重试。
- 基础延迟(base_delay):100ms ~ 500ms
- 最大延迟(max_delay):10s ~ 30s
- 最大重试次数:3 ~ 5 次
- 抖动系数:±25% ~ ±50%
3.3 重试风暴防范:别让「好心」办坏事
重试风暴,说白了就是重试请求像滚雪球一样越滚越大,最终把整个系统压垮。我经历过一次刻骨铭心的教训:某个核心服务挂了,上游 20 个服务同时开始重试,每个服务又调了 5 个下游,结果 5 分钟内,整个机房的带宽被打满,所有服务都挂了。
怎么防?我总结了三个「必杀技」:
- 限制重试层级:重试最多只能做一层。A 调 B 失败,A 可以重试 B;但 B 调 C 失败,B 不能重试 C 的同时 A 还在重试 B。否则就是「套娃式重试」,必死无疑。
- 熔断 + 重试联动:一旦熔断器打开(比如错误率达到 50%),立即停止所有重试。等熔断器半开或关闭后,再恢复重试逻辑。
- 全局重试限流:在网关层或基础框架层,统计当前正在进行的重试请求数量。如果超过阈值(比如总请求量的 10%),直接拒绝新的重试。
3.4 幂等性设计:重试的「安全气囊」
重试最怕什么?怕同一个请求被执行了两次。比如支付接口,如果因为超时重试导致用户被扣了两次钱,那麻烦就大了。
幂等性,就是用来解决这个问题的。它的定义很简单:同一个请求,无论执行多少次,结果都一样。
我常用的幂等方案有这几种:
| 方案 | 实现方式 | 适用场景 |
|---|---|---|
| 唯一键约束 | 用业务ID(如订单号)做数据库唯一索引 | 创建类接口(下单、注册) |
| 乐观锁 | 用版本号或状态机控制更新 | 更新类接口(改状态、改金额) |
| 去重表 | 用请求ID + 业务ID 做去重 | 通用场景,适合所有写接口 |
| Token 机制 | 客户端先获取 token,提交时携带 | 防止重复提交(前端 + 后端配合) |
我个人最推荐的是「去重表」方案。为什么?因为它通用性强,而且实现简单。具体做法是:
// 伪代码:幂等性检查
boolean isIdempotent(String requestId, String bizId) {
// 1. 用 requestId + bizId 作为唯一键,插入去重表
// 2. 如果插入成功,说明是第一次请求,继续执行
// 3. 如果插入失败(唯一键冲突),说明是重试请求,直接返回上一次的结果
try {
dedupTable.insert(requestId, bizId);
return true; // 第一次请求
} catch (DuplicateKeyException e) {
return false; // 重复请求,直接返回缓存结果
}
}
嗯,这里要注意:去重表的数据不能一直保留,否则表会越来越大。我一般设置 TTL 为 24 小时,用定时任务清理过期数据。
3.5 知识体系总览
下面这张图,是我对超时与重试机制的整体理解。你可以把它当作一个「决策流程图」:
你看,这四个部分其实是环环相扣的。超时策略决定了「什么时候该重试」,指数退避决定了「怎么重试」,重试风暴防范决定了「能不能重试」,而幂等性设计决定了「重试后出问题怎么办」。缺一个,系统都不稳。
最后说一句:重试不是银弹。如果下游已经挂了,你重试一万次也没用。该熔断就熔断,该降级就降级。有时候,「不重试」才是最好的重试策略。
公众号:蓝海资料掘金营,微信deep3321