指数退避超时 - 防止无限重试循环
服务器返回错误。你的代码立即重试。
再次失败。再次重试。
继续失败。继续重试。
这看起来像是坚持,实际上是在制造灾难。
立即重试的陷阱
API 调用失败了。也许是网络抖动,也许是服务器过载。
最直观的处理方式:立即重试。失败了就再来一次,不行就再来一次。
async function fetchData() {
let retries = 0
while (retries < 10) {
try {
return await api.call()
} catch (error) {
retries++
// 立即重试
}
}
throw new Error('Failed after 10 retries')
}
这代码有逻辑,有限制。但它缺少一个关键的东西:时间。
1. 雪崩效应
- 服务器过载导致请求失败
- 所有客户端立即重试
- 服务器压力瞬间翻倍
- 更多请求失败,更多重试
- 系统完全崩溃
2. 资源浪费
- CPU 在无意义的循环中消耗
- 网络带宽被失败请求占用
- 连接池被阻塞
- 其他正常请求无法执行
3. 无法自愈
- 服务器需要时间恢复
- 但重试不给它这个时间
- 就像一个摔倒的人还没站稳,又被推倒
- 系统永远无法恢复正常
失败后立即重试,是人的本能反应。但本能不总是对的。
时间的价值
有些失败,不是代码的问题,而是时机的问题。
服务器过载,需要几秒钟来处理积压的请求。数据库连接池满了,需要等待其他连接释放。
这些失败,不是错误,而是系统在说:「等一下,我需要时间。」
如果你不给它时间,只是一遍遍地敲门,门永远不会开。
指数退避的核心思想很简单:每次失败后,等待的时间翻倍。
async function fetchDataWithBackoff() {
let retries = 0
let delay = 100 // 初始延迟 100ms
while (retries < 10) {
try {
return await api.call()
} catch (error) {
retries++
// 等待,然后延迟时间翻倍
await sleep(delay)
delay *= 2
// 100ms, 200ms, 400ms, 800ms, 1.6s, 3.2s...
}
}
throw new Error('Failed after 10 retries')
}
💡 Click the maximize icon to view in fullscreen
第一次失败,等 100 毫秒。也许只是网络抖了一下。
第二次失败,等 200 毫秒。问题可能稍微严重一点。
第三次失败,等 400 毫秒。给系统更多时间。
每次失败,延迟翻倍。这不是在放弃,而是在给系统喘息的空间。
不是软弱,是智慧
有人会觉得,增加延迟是在「认输」。用户等待时间变长了,这不是体验变差了吗?
但反过来想:如果不增加延迟,系统会崩溃。所有用户的所有请求都会失败。
指数退避是在灾难和等待之间做选择。它选择让少数用户多等一会儿,而不是让所有用户的请求全部失败。
这是一种妥协,但也是唯一可行的妥协。
为什么需要随机性?
如果一千个客户端在同一时刻遇到失败,它们会在同一时刻开始重试。第二次重试也会在同一时刻发生。
这叫「惊群效应」(Thundering Herd)。
解决方法:在延迟时间上加一点随机性。
// 不是精确的 200ms,而是 150-250ms 之间的随机值
const jitter = delay * (0.5 + Math.random() * 0.5)
await sleep(jitter)
这样,重试请求会分散开来,而不是集中爆发。
完整的实现:
async function fetchWithExponentialBackoff(maxRetries = 5) {
let retries = 0
let baseDelay = 100
while (retries < maxRetries) {
try {
return await api.call()
} catch (error) {
retries++
if (retries >= maxRetries) {
throw new Error(`Failed after ${maxRetries} retries`)
}
// 指数退避 + 随机抖动
const exponentialDelay = baseDelay * Math.pow(2, retries - 1)
const jitter = exponentialDelay * (0.5 + Math.random() * 0.5)
const maxDelay = 30000 // 最长等待 30 秒
const delay = Math.min(jitter, maxDelay)
await sleep(delay)
}
}
}
关键点:
- 指数增长:
Math.pow(2, retries - 1)- 延迟时间指数增长 - 随机抖动:
0.5 + Math.random() * 0.5- 在 50%-150% 范围内随机 - 最大限制:
Math.min(jitter, maxDelay)- 防止延迟无限增长 - 重试上限:
maxRetries- 避免永远重试下去
知道何时停止
指数退避不是万能的。它只是给系统时间恢复,但如果系统根本无法恢复呢?
你需要一个上限。重试五次、十次,还是不行,那就真的不行了。
继续重试不会改变结果,只会浪费资源。
知道何时停止,和知道如何重试一样重要。
1. 用户操作(如搜索、提交表单)
- 重试次数:3-5 次
- 初始延迟:100-200ms
- 最大延迟:2-3秒
- 原因:用户在等待,不能让他们等太久
2. 后台任务(如数据同步、报表生成)
- 重试次数:10-20 次
- 初始延迟:1-5秒
- 最大延迟:5-10分钟
- 原因:没有用户直接等待,可以多给系统时间
3. 消息队列处理
- 重试次数:根据业务决定(可能是无限)
- 初始延迟:100ms
- 最大延迟:1小时
- 原因:消息不能丢失,但也不能一直占用队列
4. 关键业务操作(如支付、订单)
- 重试次数:较多(10-15次)
- 初始延迟:500ms
- 最大延迟:30秒
- 原因:容错性要求高,但也要避免重复操作
5. 健康检查(Health Check)
- 重试次数:3 次
- 初始延迟:1秒
- 最大延迟:5秒
- 原因:快速判断服务是否可用,不需要长时间重试
对失败的尊重
指数退避的本质,不是技术问题,而是态度问题。
它是在说:失败不是敌人,失败是信号。它在告诉你,现在不是正确的时机。
立即重试,是对失败的无视。指数退避,是对失败的尊重。
你承认系统需要时间,承认有些事情不能强求,承认等待也是一种策略。
这不只适用于代码,也适用于生活中的很多事情。
最后
失败后的第一反应是再试一次。这是本能。
但有时候,最好的策略不是立即行动,而是等一等,给系统一点时间,给自己一点空间。
指数退避不是在放弃,而是在创造成功的条件。
延迟在增长,但这个增长是有意义的。它反映了你对系统压力的理解,对失败的尊重。
相关阅读:
博客内链
- 队列、可靠性与系统边界 - 队列系统中的重试策略
- 错误隔离 - 防止重试造成的级联失败
- 幂等性检查 - 重试时保证操作不重复
Related Posts
Articles you might also find interesting
错误隔离
失败是必然的。真正的问题不是失败本身,而是失败如何蔓延。错误隔离不是为了消除失败,而是为了控制失败的范围。
单例模式管理 Redis 连接
连接不是技术细节,而是系统与外部世界的第一次握手,是可靠性的起点
监控观察期法
部署不是结束,而是验证的开始。修复代码只是假设,监控数据才是证明。48小时观察期:让错误主动暴露,让数据证明修复。
监听 Redis 连接事件 - 让不可见的脆弱变得可见
连接看起来应该是透明的。但当它断开时,你才意识到透明不等于可靠。监听不是多余,而是对脆弱性的承认。
幂等性检查
在不确定的系统中,幂等性检查是对重复的容忍,是对稳定性的追求,也是对失败的预期与接纳
管理后台需要两次设计
第一次设计回答"发生了什么",第二次设计回答"我能做什么"。在第一次就试图解决所有问题,结果是功能很多但都不够深入。
告警分级与响应时间
不是所有问题都需要立即响应。RPC失败会在凌晨3点叫醒人。安全事件每15分钟检查一次。支付成功只记录,不告警。系统的响应时间应该匹配问题的紧急程度。
文档标准是成本计算的前提
API文档不只是写给开发者看的。它定义了系统的边界、成本结构和可维护性。统一的文档标准让隐性成本变得可见。
BullMQ 队列
队列不是技术选型,而是对时间的承认,对顺序的尊重,对不确定性的应对
BullMQ Worker
Worker 的本质是对时间的重新分配,是对主线的解放,也是对专注的追求
配置不会自动同步
视频生成任务永远pending,代码完美部署,队列正确配置。问题不在代码,在于配置的独立性被低估。静默失败比错误更危险。
CRUD 操作
四个字母背后,是数据的生命周期,是权限的边界,也是系统设计的基础逻辑
数据库参数国际化:从 13 个迁移学到的设计原则
数据不该懂语言。当数据库参数嵌入中文标签时,系统的边界就被语言限制了。这篇文章从 13 个参数对齐迁移中提炼出设计原则——国际化不是功能,是系统设计的底层约束。
Stripe Webhook中的防御性编程
三个Bug揭示的真相:假设是代码中最危险的东西。API返回类型、环境配置、变量作用域——每个看似合理的假设都可能导致客户损失。
双重验证:Stripe生产模式的防御性切换
从测试到生产不是更换API keys,而是建立一套双重验证系统。每一步都在两个环境中验证,确保真实支付不会因假设而失败。
在运行的系统上生长新功能
扩展不是推倒重来,而是理解边界,找到生长点。管理层作为观察者和调节器,附着在核心系统上,监测它,影响它,但不改变它的运行逻辑。
实现幂等性处理,忽略已处理的任务
在代码层面识别和忽略已处理的任务,不是简单的布尔检查,而是对时序、并发和状态的深刻理解
缺失值的级联效应
一个NULL值如何在调用链中传播,最终导致错误的错误消息。理解防御层的设计,在失败传播前拦截。
Props Drilling
数据在组件树中层层传递,每一层都不需要它,却必须经手。这不是技术债,是架构对真实需求的误解。
队列生产者实例的工厂函数
工厂函数不是设计模式的炫技,而是对重复的拒绝,对集中管理的追求,对变化的准备
资源不会消失,只会泄露
在积分系统中,用户的钱不会凭空消失,但会因为两个时间窗口而泄露:并发请求之间的竞争,和回调永不到达的沉默。
RPC函数的原子化处理
当一个远程函数做太多事情,失败就变得难以理解
RPC函数
关于远程过程调用的本质思考:当你试图让远方看起来像眼前
使用Secret Token验证回调请求的合法性
在开放的网络中,信任不能被假设。Secret Token 是对身份的确认,对伪装的识别,也是对安全边界的坚守
第三方回调的状态映射完整性
KIE.AI 视频生成的三个修复揭示了一个本质问题:回调不只是接收结果,是建立状态映射。没有 vendor_task_id,系统就失去了追溯能力。