重试机制设计的核心要点与实战优化策略
先说结论:我一般选封装函数 + AbortController
重试机制这玩意儿,说简单也简单,说复杂也能折腾你一整天。我在好几个项目里都碰过类似需求——接口偶尔抖一下,用户点个按钮没反应,刷新页面又好了。这种问题不解决,客服投诉能塞满邮箱。
我试过不少方案:从最原始的手动 setTimeout 递归,到用第三方库 like axios-retry,再到自己封装带退避策略的 fetch 重试函数。最后发现,最灵活、最可控的,还是自己写一层逻辑,配合 AbortController 控制超时和中断。
当然,如果你项目里已经用了 axios,那 axios-retry 确实省事,但我踩过坑:它对并发请求的控制不够细,加上 CancelToken 已经 deprecated,改起来还挺烦。所以我现在基本不用它了。
—
三种我用过的方案长啥样
下面这三个是我实战中真正上过线的方案,不是理论派那种“可以这么做”。我都跑过压测,也都在线上被用户疯狂点击验证过。
1. 最土但最直观:setTimeout + 计数器
function retryFetch(url, options = {}, maxRetries = 3, delay = 1000) {
let attempt = 0
const makeRequest = () => {
return fetch(url, options)
.then(res => {
if (!res.ok) throw new Error(HTTP ${res.status})
return res.json()
})
.catch(err => {
attempt++
if (attempt >= maxRetries) {
console.error(达到最大重试次数,放弃:, err)
return Promise.reject(err)
}
console.log(请求失败,第 ${attempt} 次重试...)
setTimeout(makeRequest, delay)
})
}
return makeRequest()
}
这个写法看着挺顺,但有个大问题:不能中断!如果用户快速点了五次,每个都会开启独立的定时器,最后可能同时发起十几条请求。而且 delay 固定,网络差的时候反而更雪崩。
我之前在一个表单提交场景用了这招,结果用户手滑连点,后台直接被打爆。后来加了个 loading 锁才缓解,但治标不治本。
2. Axios + axios-retry(曾经的真爱)
import axios from 'axios'
import axiosRetry from 'axios-retry'
axiosRetry(axios, {
retries: 3,
retryDelay: (retryCount) => {
return retryCount * 1000 // 指数退避?自己算吧
},
retryCondition: (error) => {
// 只对网络错误和 5xx 重试
return !error.response || (error.response.status >= 500 && error.response.status < 600)
}
})
// 使用
axios.get('https://jztheme.com/api/data')
.then(...)
.catch(...)
一开始真香。配置一次,全局生效,省心。但问题是:太粗暴了。你想针对某个特定接口设置不同的重试次数?难。想在重试时更新 token?得 hook 进去,代码变得很绕。
还有个致命伤:CancelToken 废弃后,AbortController 接入不顺畅。我折腾了半天才发现 axios-retry 内部还没完全适配,某些情况下 abort 不生效,内存泄漏警告都出来了。
所以现在除非是内部小工具项目,否则我不推荐用这组合。
3. 我现在主力用的:自封装 fetch + AbortController + 退避算法
async function fetchWithRetry(
url,
options = {},
{ maxRetries = 3, baseDelay = 1000, shouldRetry = null } = {}
) {
let lastError
for (let i = 0; i <= maxRetries; i++) {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 8000) // 超时 8s
// 动态退避:第 n 次重试等待 baseDelay * 2^(n-1)
if (i > 0) {
const delay = baseDelay * Math.pow(2, i - 1)
await new Promise(resolve => setTimeout(resolve, delay))
console.log(第 ${i} 次重试,延迟 ${delay}ms)
}
try {
const res = await fetch(url, {
...options,
signal: controller.signal
})
clearTimeout(timeoutId)
if (!res.ok) {
lastError = new Error(HTTP ${res.status}: ${res.statusText})
if (shouldRetry && !shouldRetry(res)) {
break
}
continue
}
return await res.json()
} catch (err) {
if (err.name === 'AbortError') {
lastError = new Error(请求超时或被取消)
} else {
lastError = err
}
// 网络异常一律重试(除非超过次数)
if (i < maxRetries) continue
}
}
return Promise.reject(lastError)
}
// 使用示例
fetchWithRetry('https://jztheme.com/api/data', {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
}, {
maxRetries: 2,
baseDelay: 1000,
shouldRetry: (res) => res.status === 503 // 只对 503 重试
})
.then(data => console.log(data))
.catch(err => console.error(最终失败:, err.message))
这个版本我现在所有新项目都在用。虽然多写了十几行,但自由度高到飞起:
- 可配置退避策略(指数/固定/随机)
- 支持超时中断
- 可定制重试条件
- Promise 链清晰,调试容易
最重要的是,不会像 axios-retry 那样暗地里搞事情。每一行都是我自己写的,出问题我也知道去哪改。
—
谁更灵活?谁更省事?
看场景。
如果你只是想给整个项目的 API 请求统一加个“最多重两次”的兜底,且不怕全局副作用,那 axios-retry 确实快。但你要做好后期难以拆分的心理准备。
如果你想精细控制每个请求的行为,比如“登录失败不重试”“轮询接口要指数退避”,那就别图省事了,老老实实封装一个函数。
至于那个最原始的 setTimeout 版本?我已经把它当反面教材收藏了。除了 demo 演示,生产环境真不适合用。
还有一个隐藏成本:维护性。团队新人看到 axios-retry 的配置,不一定知道背后发生了什么;但看到一个叫 fetchWithRetry 的函数,大概率能猜出来它是干啥的。
—
我的选型逻辑
我现在是这么定规矩的:
- 新项目一律用原生 fetch + 自封装重试函数
- 老 axios 项目,如果改动成本大,就继续用 axios-retry,但加个 wrapper 控制范围
- 需要取消请求的场景,必须用 AbortController,其他都不考虑
我还把上面那个 fetchWithRetry 抽成了 utils/request.js,根据不同环境调整 baseDelay 和 maxRetries。甚至可以根据 NODE_ENV 开关重试功能,方便本地调试。
这里注意我踩过好几次坑:退避时间别设太短。有一次我把 baseDelay 设成 500ms,结果服务端刚好也在扩容,连续三次 504,客户端疯狂重试,形成雪崩。后来改成最小 1s 起步,指数增长,才稳住。
—
以上是我的对比总结,有不同看法欢迎评论区交流
重试机制看着小,但真要做得靠谱,细节一堆。我说的这套也不是银弹,比如还没加上 jitter(随机抖动)来防重试风暴,后续可能会补。
也有同事用 RxJS 的 retryWhen 操作符实现过类似功能,写法更函数式,但我个人觉得学习成本偏高,中小型项目有点杀鸡用牛刀。
总之,别迷信第三方库。很多时候,十几行代码自己封装一个,比引入一个包更安全、更可控。尤其是涉及网络请求这种高频操作,越透明越好。
以上是我踩坑后的总结,希望对你有帮助。

暂无评论