3、超时与重试机制:超时设置策略、指数退避算法、重试风暴防范、幂等性设计

聊到分布式系统的容错,超时和重试是绕不开的一对「孪生兄弟」。我见过太多系统,要么超时设得太短导致频繁误判,要么重试逻辑写得像「自杀式袭击」——直接把下游打挂。说白了,这玩意儿就像走钢丝,既要快,又要稳。

3.1 超时设置策略:别拍脑袋定数字

超时时间怎么定?我个人的习惯是:先测量,再设定,最后动态调整。千万别拍脑袋写个 500ms,那跟算命没区别。

核心原则:超时时间 = 正常响应时间的 P99 分位值 × 冗余系数(通常 1.5~2 倍)

举个例子。我在项目中遇到过这样一个场景:一个订单查询接口,平时 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 分钟内,整个机房的带宽被打满,所有服务都挂了。

怎么防?我总结了三个「必杀技」:

  1. 限制重试层级:重试最多只能做一层。A 调 B 失败,A 可以重试 B;但 B 调 C 失败,B 不能重试 C 的同时 A 还在重试 B。否则就是「套娃式重试」,必死无疑。
  2. 熔断 + 重试联动:一旦熔断器打开(比如错误率达到 50%),立即停止所有重试。等熔断器半开或关闭后,再恢复重试逻辑。
  3. 全局重试限流:在网关层或基础框架层,统计当前正在进行的重试请求数量。如果超过阈值(比如总请求量的 10%),直接拒绝新的重试。
警告:千万不要在重试逻辑里再嵌套重试逻辑。我曾经见过一个代码,重试失败后进入 catch 块,catch 块里又发起重试——这简直是「递归式自杀」。记住:重试最多两层,最好只做一层。

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 小时,用定时任务清理过期数据。

避坑指南:我曾经犯过一个错误——在去重表里只存了 requestId,没存业务结果。结果重试请求来了,虽然识别出是重复的,但不知道上次返回了什么,只能返回「处理中」。这其实是不对的。正确的做法是:去重表里不仅要存请求标识,还要存上一次的处理结果,这样重试时才能原样返回。

3.5 知识体系总览

下面这张图,是我对超时与重试机制的整体理解。你可以把它当作一个「决策流程图」:

超时与重试机制知识体系 超时设置策略 • 基于P99分位值 • 滑动窗口动态调整 • 读写分离策略 • 冗余系数1.5~2倍 指数退避算法 • 基础退避公式 • 随机抖动防惊群 • 最大延迟限制 • 最大重试次数 重试风暴防范 • 限制重试层级 • 熔断+重试联动 • 全局重试限流 • 禁止嵌套重试 幂等性设计 • 唯一键约束 • 乐观锁机制 • 去重表方案 • Token机制 核心目标:高可用 + 数据一致性

你看,这四个部分其实是环环相扣的。超时策略决定了「什么时候该重试」,指数退避决定了「怎么重试」,重试风暴防范决定了「能不能重试」,而幂等性设计决定了「重试后出问题怎么办」。缺一个,系统都不稳。

最后说一句:重试不是银弹。如果下游已经挂了,你重试一万次也没用。该熔断就熔断,该降级就降级。有时候,「不重试」才是最好的重试策略。


公众号:蓝海资料掘金营,微信deep3321