凌晨两点收到告警,一个服务在十分钟内被重启了四十多次,每次重启后存活不到三十秒又挂掉。监控曲线像锯齿一样反复起伏,故障恢复时间反而比不做自愈更长。这种场景就是典型的自愈循环:系统检测到故障,自动执行恢复动作,恢复动作本身没有解决问题,于是再次检测到故障,再次执行恢复,无限往复。要打破这个循环,核心在于两点:一是让重试之间的间隔不断拉长,也就是退避策略;二是给重试次数设定一个硬上限,超过之后停止盲目重试并转人工介入。

自愈循环是怎么形成的
自愈循环的本质,是恢复动作失败后系统仍然选择用同样的方式继续恢复。常见的触发路径有三种。第一种是健康检查过于敏感,比如把探测超时设置为一秒,服务启动预热需要五秒,编排系统便会在服务还没就绪时判定其死亡,不断杀掉重启。第二种是重试没有间隔,检测到失败立即重启,服务在冷启动阶段就被判定失败,形成毫秒级的死亡螺旋。第三种是重试没有上限,某个依赖的外部数据库彻底宕机后,服务本身无论重启多少次都无法恢复,系统却还在做无意义的循环。
值得强调的是,快速失败与立即重试是两回事。快速失败指的是错误发生时迅速返回错误码,避免资源被长时间占用;而重试的节奏必须由策略控制。把两者混为一谈,就会写出检测到异常就立刻重启的逻辑,这正是自愈循环最常见的成因。另外,同步重试风暴还会放大故障:一百个实例同时失败、同时重试,下游服务刚缓过一口气又被集中打挂,这种现象有时被称为重试风暴,破坏力甚至超过原始故障。
退避策略:让重试间隔逐步拉长
退避策略的核心思想很简单:如果第一次恢复失败了,说明问题不是瞬时抖动,短时间内再次尝试大概率还是失败,所以下一次尝试的等待时间应该变长。最常用的是指数退避,即第 n 次重试前等待基础间隔乘以二的 n 减一次方。假设基础间隔为一秒,第一次失败后等一秒,第二次等两秒,第三次等四秒,以此类推。这样既能保证瞬时故障被快速恢复,又能让持续性故障下的重试频率呈指数级下降。
下面是一段 Go 实现的指数退避工具函数,加入了等待时间上限,防止间隔无限增长:
package backoff
import (
"context"
"time"
)
// ExponentialBackoff 计算第 attempt 次重试前应等待的时间
// base 为基础间隔,max 为单次等待上限,capAttempts 为最大重试次数
func WaitTime(attempt int, base, max time.Duration) time.Duration {
d := base
for i := 1; i < attempt; i++ {
d *= 2
if d >= max {
return max
}
}
return d
}
// Retry 执行带退避的重试,fn 返回 nil 表示成功
func Retry(ctx context.Context, capAttempts int, base, max time.Duration, fn func() error) error {
var lastErr error
for i := 1; i <= capAttempts; i++ {
if err := fn(); err == nil {
return nil
} else {
lastErr = err
}
if i == capAttempts {
break
}
wait := WaitTime(i, base, max)
select {
case <-time.After(wait):
case <-ctx.Done():
return ctx.Err()
}
}
return lastErr
}仅有指数退避还不够。如果多个客户端在同一时刻失败,它们退避后依然会在同一时刻集中重试,形成周期性的请求洪峰。解决办法是加入抖动,也就是在每次计算出的等待时间上叠加一个随机量,把重试时机打散。常见做法是取零到等待时长之间的随机值,或者取等待时长的一半再加上零到一半之间的随机值。抖动配合指数退避,是各主流云厂商客户端的标配做法,例如 AWS SDK 默认就启用了带抖动的指数退避。
除了指数退避,还有固定间隔和线性递增两种退避方式。固定间隔实现最简单,适合失败原因明确且恢复时间稳定的场景,比如等待缓存过期;线性递增介于两者之间,间隔增长平缓,适合对恢复速度敏感但又担心重试压力的场景。工程上如果没有特殊理由,优先选择带抖动的指数退避,它是综合表现最均衡的方案。
最大重试次数:给自愈机制装上刹车
退避策略解决的是重试太快的问题,但即使间隔拉得很长,重试次数不设上限依然是危险的。一方面,持续性的故障例如磁盘损坏、依赖服务彻底下线,重试一万次也不会成功,白白消耗资源;另一方面,无限重试会让日志和监控被刷爆,掩盖真正的根因。最大重试次数的意义在于:当系统判断自愈已经失效时,主动放弃自动恢复,转入异常兜底流程。
兜底流程的设计取决于业务形态。对于无状态服务,可以是把实例标记为不可用并从负载均衡摘除,同时发出高级别告警通知人工处理;对于批处理任务,可以记录断点后进入死信队列,等待问题修复后重新投递;对于关键链路,还可以触发降级预案,用只读模式或缓存数据维持基本可用。关键原则是:达到上限之后,系统的状态必须是明确的失败态,而不是悬在半空的未知态。
在实践中,最大次数的设定需要结合平均故障恢复时间来估算。一般建议把自动重试的总时长控制在与业务可容忍的恢复时间相当的水平,例如业务要求五分钟内恢复,基础间隔一秒、上限三十秒的指数退避下,十次左右的重试已经覆盖了几分钟的时间窗口,超过这个次数仍失败,说明大概率不是瞬时故障。此外还要区分错误类型:参数校验失败、权限不足这类确定性错误不应该重试,直接快速失败;只有网络超时、服务端临时过载这类瞬时错误才值得消耗重试配额。
# Kubernetes 中通过探针参数避免自愈循环
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
spec:
replicas: 3
selector:
matchLabels:
app: my-app
template:
metadata:
labels:
app: my-app
spec:
containers:
- name: main
image: registry.ippipp.com/my-app:v1.2.3
readinessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 20 # 预留启动预热时间,避免误杀
periodSeconds: 5 # 探测间隔
failureThreshold: 3 # 连续失败3次才判定未就绪
livenessProbe:
httpGet:
path: /livez
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 5 # 提高阈值,减少重启风暴
terminationGracePeriodSeconds: 60上面的配置体现了同样的思路在容器编排层面的落地:initialDelaySeconds 给服务预留预热时间,failureThreshold 相当于判定故障前的小重试,避免单次抖动就触发重启。如果确实出现了重启循环,Kubernetes 本身还有退避机制,每次重启间隔会从十秒开始翻倍,上限五分钟,达到上限后容器进入 CrashLoopBackOff 状态,这正是退避加最大次数思想的标准实现,值得在自研系统中借鉴。
落地时的检查清单
把上面的内容收敛成一份可以直接执行的检查清单。第一,确认所有重试路径都存在退避逻辑,搜索代码中检测到异常后立即重试的分支,尤其是定时任务里无间隔触发的自愈脚本。第二,确认重试间隔带抖动,多实例场景下没有抖动等于没有退避。第三,为每个自愈动作设定最大次数,并且定义达到上限后的兜底行为,兜底行为必须有对应的告警规则。第四,区分可重试错误与不可重试错误,避免把确定性失败纳入重试。第五,为重试过程埋点监控,统计重试次数分布和最终成功率,一旦重试成功率持续下降,说明退避参数或服务本身需要调整。
最后需要注意熔断与重试的配合。当某个依赖的失败率持续高于阈值时,熔断器应当直接拒绝请求,让重试暂停一段时间后再放行探测请求。熔断负责在宏观上止血,退避和最大次数负责在微观上控制恢复节奏,三者组合起来,才能构成一个既积极自愈又不会失控的稳定系统。自愈不是让故障消失,而是让故障在可控的代价内被消化,理解了这一点,退避策略和最大重试次数的设计就有了清晰的判断标准。