retry:构建健壮系统的容错基石
在分布式系统与网络应用中,失败是常态。网络抖动、服务重启、数据库连接超时等问题无法完全避免。面对失败,简单的“错误提示”已不足以保障用户体验。
retry(重试机制)是一种优雅的容错策略:在可恢复的错误发生时,自动尝试重新执行操作,而非立即失败。
但盲目重试可能适得其反——“重试风暴”会加剧系统负载,导致雪崩。因此,一个生产级的 retry 函数必须结合指数退避(Exponential Backoff)与随机抖动(Jitter),并智能判断重试条件。
为何需要指数退避?避免雪崩效应
问题:固定间隔重试的危险
假设 1000 个客户端同时请求一个短暂故障的服务,且都配置了 retry(fn, { retries: 3, delay: 1000 }):
- T+0s:全部失败,进入重试队列。
- T+1s:1000 个请求同时重发。
- T+2s:若仍失败,再次 1000 个请求并发。
结果:服务在恢复前被瞬间压垮,形成“重试风暴”,延长故障时间。
解法:指数退避(Exponential Backoff)
核心思想:每次重试的等待时间呈指数增长。
公式:delay = base * (2 ^ retryCount)
例如,base = 1000ms:
| 重试次数 | 延迟时间 |
|---|---|
| 1 | 1s |
| 2 | 2s |
| 3 | 4s |
| 4 | 8s |
指数增长确保:
- 初期快速重试(短延迟)。
- 后期给予服务充足恢复时间(长延迟)。
- 避免请求堆积,防止雪崩。
为何需要随机抖动?分散请求压力
即使使用指数退避,若所有客户端的退避时间完全一致,仍可能在某一时刻同步重试。
问题:同步重试
1000 个客户端在 T+1s、T+3s、T+7s 同时发起重试,形成“脉冲式”流量,冲击服务。
解法:随机抖动(Jitter)
在计算出的退避时间上,加入一个随机偏移:
const jitteredDelay = delay * (0.5 + Math.random() * 0.5) // 50%~100% 的 delay或全随机模式:
const jitteredDelay = Math.random() * delay效果:原本在 T+4s 集中重试的 1000 个请求,现在分散在 T+2s 到 T+4s 之间随机发起。
这有效“抹平”了请求曲线,避免瞬时高峰,显著提升系统整体稳定性。
类比:就像人群过桥,如果所有人同时迈步,桥会剧烈晃动;而自然行走的随机步调,则让负载平稳分布。
实现策略:从简单到生产就绪
策略 1:固定间隔重试
最简单实现,适合内部稳定服务:
const retryWithFixedDelay = async <T>(
fn: () => Promise<T>,
retries: number,
delay: number
): Promise<T> => {
try {
return await fn()
} catch (error) {
if (retries <= 0) throw error
await sleep(delay)
return retryWithFixedDelay(fn, retries - 1, delay)
}
}策略 2:线性退避
延迟线性增长:delay = base * retryCount
const linearBackoff = (base: number, count: number) => base * count比固定延迟稍好,但退避力度不足。
策略 3:指数退避 + 抖动(推荐)
生产环境的黄金标准:
const exponentialBackoffWithJitter = (base: number, count: number): number => {
const delay = base * Math.pow(2, count)
const jitter = delay * 0.5 * Math.random() // 0~50% 抖动
return delay + jitter
}完整 retry 实现:
type RetryOptions = {
retries?: number
baseDelay?: number
shouldRetry?: (error: unknown) => boolean
}
const retry = <T>(
fn: () => Promise<T>,
options: RetryOptions = {}
): Promise<T> => {
const {
retries = 3,
baseDelay = 1000,
shouldRetry = () => true
} = options
const attempt = async (remaining: number): Promise<T> => {
try {
return await fn()
} catch (error) {
if (remaining <= 0 || !shouldRetry(error)) {
throw error
}
const delay = exponentialBackoffWithJitter(baseDelay, retries - remaining)
await sleep(delay)
return attempt(remaining - 1)
}
}
return attempt(retries)
}错误分类:哪些错误值得重试?
并非所有错误都应重试。盲目重试 404 或 401 错误只会浪费资源。
应重试的错误(可恢复)
- 网络层:超时、连接中断、DNS 失败。
- 服务端:HTTP 5xx(500, 502, 503)、数据库死锁、临时限流。
- 语义:表示“服务暂时不可用”,可能在稍后恢复。
不应重试的错误(不可恢复)
- 客户端错误:HTTP 4xx(400, 401, 403, 404)。
- 语义:表示“请求本身有问题”,重试无意义。
实现 shouldRetry 判断
const shouldRetry = (error: unknown): boolean => {
if (error instanceof NetworkError) return true
if (error instanceof TimeoutError) return true
if (error instanceof HttpError) {
// 5xx 服务端错误可重试
return error.status >= 500
// 4xx 客户端错误不重试
}
return false
}
// 使用
retry(fetchData, { shouldRetry })类型安全:保持原函数的 Promise 类型
retry 的返回值必须与原函数 fn 的 Promise 类型完全一致,包括泛型。
核心:ReturnType<T> 的传递
const retry = <T extends (...args: any[]) => Promise<any>>(
fn: T,
options?: RetryOptions
): ((...args: Parameters<T>) => ReturnType<T>) => {
return (...args: Parameters<T>): ReturnType<T> => {
// 调用 fn 并应用重试逻辑
return attempt(() => fn(...args), options) as ReturnType<T>
}
}类型推导示例
const fetchUser = (id: string): Promise<User> => {
return axios.get(`/api/users/${id}`).then(res => res.data)
}
const retryFetchUser = retry(fetchUser, { retries: 3 })
// retryFetchUser 的类型被推导为:
// (id: string) => Promise<User>
// 与原函数完全一致IDE 能正确提示参数和返回类型,实现无缝开发体验。
结语:重试是优雅的“韧性”设计
retry 不是简单的“多试几次”,而是一种系统韧性(Resilience)设计。
- 指数退避:给予系统恢复的“呼吸空间”。
- 随机抖动:避免群体行为导致的“共振崩溃”。
- 错误分类:智能决策,避免无效操作。
- 类型安全:无缝集成,不破坏类型契约。
当你为 API 调用封装 retry 策略时,你不仅提升了应用的健壮性,更在代码中注入了一种哲学:允许失败,但永不轻易放弃。
这正是构建高可用系统的核心精神。