把重试机制做稳:退避、抖动和幂等
摘要:重试是工程系统里最常见的容错手段,但也是最容易被滥用的手段。只会“再试一次”通常不够,真正稳定的重试需要区分错误类型、控制节奏、避免重复副作用。本文从退避、抖动和幂等三个角度,整理一套更可靠的重试思路。
为什么重试不能只写一个 while
很多系统在遇到瞬时故障时,第一反应都是重试。数据库短暂抖动、上游超时、网络丢包、冷启动阶段的连接失败,确实都可能通过重试恢复。
问题在于,简单粗暴的重试会把故障放大:
- 所有请求同时重试,会把上游打得更满
- 没有等待时间,会在短时间内疯狂重放
- 没有终止条件,会把永久错误当成临时错误
- 没有幂等保护,会重复扣款、重复发信、重复写库
所以,重试不是“失败后的默认动作”,而是一个需要设计的策略。
先分清能不能重试
不是所有错误都适合重试。判断之前,先把错误分成两类:
适合重试的临时错误
- 连接超时
- 读写超时
- 503、502 之类的短暂服务不可用
- DNS 抖动或短暂网络中断
- 下游限流,但系统允许稍后再试
不适合重试的永久错误
- 参数校验失败
- 鉴权失败
- 资源不存在
- 逻辑错误
- 已经确定不会成功的业务请求
如果把永久错误也纳入重试,只会浪费时间,还会掩盖真正的问题。
一个实用原则是:只对“稍后有可能成功”的失败重试。
退避比立即重试更重要
最朴素的重试是失败后立刻再来一次。这个做法在低并发环境下看似有效,但一旦大面积故障发生,就会把所有客户端的压力瞬间推到同一个时间点。
更稳的方式是指数退避:
第一次失败后等 200ms
第二次失败后等 400ms
第三次失败后等 800ms
第四次失败后等 1600ms
它的作用不是“等久一点”,而是主动给下游恢复时间。
常见做法是:
const delay = base * 2 ** attempt
但纯指数退避还有一个问题:所有客户端还是可能在同一时刻醒来。于是就需要抖动。
抖动能避免同步风暴
如果一批请求在同一时刻失败,它们在相同的退避策略下也会在相同时间点重试。这会形成新的尖峰。
抖动的作用,就是给每次等待时间加一点随机扰动,让重试分散开。
常见方式有三种:
- full jitter:每次随机等待
0 ~ backoff - equal jitter:在
backoff / 2 ~ backoff之间随机 - decorrelated jitter:下一次等待时间参考上一次,但保留随机性
对于大多数业务系统,full jitter 已经足够实用:
const wait = Math.random() * backoff
它简单,效果也足够明显。
幂等性是重试的底座
重试真正危险的地方,不是“多试了一次”,而是“多执行了一次副作用”。
比如:
- 下单接口重试后创建了两笔订单
- 支付接口重试后扣了两次钱
- 发送消息接口重试后发了两条
要避免这类问题,接口设计必须考虑幂等性。
常见做法有几种:
- 让客户端带上幂等键
- 服务端把幂等键和结果缓存起来
- 数据库层增加唯一约束
- 写入流程先查后写,或者直接用幂等写法
如果一个操作天然不能幂等,那它就不适合简单重试。要么改协议,要么拆分流程,把“触发动作”和“最终落库”分开。
一个可复用的重试实现
下面是一版比较克制的 TypeScript 重试封装。它做了三件事:
- 限制最大尝试次数
- 使用指数退避加抖动
- 只重试指定错误
type RetryOptions = {
retries: number
baseDelayMs?: number
shouldRetry?: (error: unknown) => boolean
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
export async function retry<T>(
task: () => Promise<T>,
options: RetryOptions,
): Promise<T> {
const {
retries,
baseDelayMs = 200,
shouldRetry = () => true,
} = options
let lastError: unknown
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await task()
} catch (error) {
lastError = error
if (attempt === retries || !shouldRetry(error)) {
throw error
}
const backoff = baseDelayMs * 2 ** attempt
const wait = Math.random() * backoff
await sleep(wait)
}
}
throw lastError
}
这段代码的重点不是语法,而是边界:
- 重试次数有上限
- 失败条件可定制
- 等待时间不是固定值
如果你在 Node.js 里处理 HTTP 请求,还可以把 AbortSignal 接进来,让调用方在超时或取消时直接终止重试。
别把重试当成补锅
重试应该处理的是短暂波动,不应该掩盖架构问题。
如果一个接口经常需要三四次才能成功,通常说明下面至少有一个问题:
- 超时阈值设得太激进
- 下游稳定性不够
- 调用链太长
- 缓存没有命中
- 并发控制缺失
这时候继续加重试,只是在延迟问题暴露的时间。
更好的做法是把重试和观测一起看:
- 记录每次重试的原因
- 统计最终成功率和失败率
- 关注重试后的尾延迟
- 对高频失败的错误单独报警
重试本身不是目标,稳定才是目标。
一个实用检查表
上线前可以用这几条快速检查重试策略:
- 只重试临时性错误
- 有最大重试次数
- 有退避
- 有抖动
- 有幂等保护
- 有超时上限
- 有日志和指标
如果这七项里少了两三项,重试大概率只是“看起来更稳”,不是“真的更稳”。
总结
重试不是把失败再做一遍,而是用受控的方式给系统一次恢复机会。
真正可靠的重试,至少要同时考虑三件事:错误是不是值得重试、等待节奏会不会放大故障、重复执行会不会产生副作用。
把退避、抖动和幂等一起设计进去,重试才会从“碰碰运气”变成“可预测的容错策略”。