你的"容错机制",正在亲手杀死你的服务
写代码的人最容易被一个幻觉骗了:重试能解决一切问题。
网络抖了?重试。接口超时了?重试。数据库报错?还是重试。这个"重试大法好"的思维,让我在某个凌晨三点付出了一张检讨书的代价。
那次系统因为缓存失效,打到数据库的请求瞬间暴涨。我第一反应是:加个重试,扛过去。结果?所有请求同时超时,同时重试,数据库连接数瞬间打满,整个系统原地升天。从那一刻我明白了一个道理——重试是毒药,用错时机就是在给自己挖坟。
为什么你的重试是在火上浇油
先说一个概念:惊群效应(Thundering Herd)。当一堆请求同时失败同时重试,它们会在同一个时间窗口内集中爆发。举个例子,你的超时时间是3秒,重试间隔也是3秒,那么这批请求会在6秒内全部重试一遍。如果这个系统已经濒临过载,你猜这6秒内会发生什么?
对,数据库/下游服务会被再打死一次。而且这次的攻击密度更高,因为它叠加了原始请求和所有重试请求。
这就是为什么重试有两条铁律:
铁律一:重试只适合"瞬时故障",不适合"持续性故障"。
什么是瞬时故障?网络抖动、某个微服务刚重启、数据库连接池短暂耗尽。这类问题通常在秒级到十秒级内自动恢复。等几秒再试,确实能解决问题。
但如果你的下游服务已经崩溃了、扩容中、或者被恶意攻击,你在这个节骨眼上拼命重试,就是在用自己宝贵的资源陪葬。这不叫容错,这叫"自己搞自己"。
铁律二:重试必须有退避策略(Backoff),绝对不能固定间隔狂撸。
固定间隔重试是最蠢的实现方式。正确姿势是指数退避(Exponential Backoff)+ 抖动(Jitter)。
指数退避的意思是:第一次失败等1秒,第二次等2秒,第三次等4秒……以此类推,上限设个天花板(比如30秒)。这样即使系统真在恢复,也是有序地、逐步地恢复流量,而不是一波流冲死。
抖动更关键。想象一下你有100个实例同时重启,全部在第5秒同时开始重试——这跟惊群有什么区别?加上随机抖动(±1秒),把100个实例的重试时间分散开,峰值流量瞬间降了一个数量级。
熔断器:比重试更重要但99%的人不知道的东西
指数退避解决了"怎么重试"的问题,但没解决一个更根本的问题:什么时候应该停止重试,承认这个服务已经没救了?
答案就是熔断器(Circuit Breaker)。
熔断器的设计灵感来自电路。在电路中,当电流过载时,断路器会跳闸,切断电路,防止火灾。在软件中,熔断器做的事情一模一样:当某个依赖的失败率超过阈值时,"跳闸"——后续请求直接拒绝,不打过去送死。
熔断器有三种状态:
Closed(闭合):正常状态,所有请求都放行。失败计数器默默统计失败率。
Open(断开):当失败率超过阈值,熔断器跳闸。所有请求直接返回降级响应,不再打给下游。
Half-Open(半开):过一会儿(探测窗口),放几个请求过去试试水。如果这些探测请求成功了,说明下游服务可能恢复了,熔断器闭合;如果还是失败,继续断开。
这个设计的精髓在于:当下游服务已经不可用时,熔断器会快速失败(Fail Fast),而不是让请求去排队等待超时。超时等待会占用你的连接数、协程/线程资源,而这些资源是有限的。等这些资源耗尽,你的服务也跟着一起升天了。
手写一个熔断器,到底有多简单
很多人以为熔断器是什么高大上的中间件才有的功能,其实原理简单得离谱。我们来写一个:
type CircuitBreaker struct {
mu sync.Mutex
state State // current state
failures int // consecutive failure count
lastFailure time.Time // last failure timestamp
threshold int // failure threshold to trip
timeout time.Duration // how long to stay open
}
type State int
const (
StateClosed State = iota
StateOpen
StateHalfOpen
)
func (cb *CircuitBreaker) Call(func() error) error {
cb.mu.Lock()
defer cb.mu.Unlock()
switch cb.state {
case StateOpen:
// Check if timeout expired, move to half-open
if time.Since(cb.lastFailure) > cb.timeout {
cb.state = StateHalfOpen
goto allow
}
return fmt.Errorf("circuit open: downstream unavailable")
case StateHalfOpen:
goto allow
case StateClosed:
goto allow
}
allow:
err := func() error {
cb.mu.Unlock()
defer cb.mu.Lock()
return fn()
}()
if err != nil {
cb.failures++
cb.lastFailure = time.Now()
if cb.failures >= cb.threshold {
cb.state = StateOpen
}
return err
}
// Success resets the breaker
cb.failures = 0
cb.state = StateClosed
return nil
}
完整代码也就80行左右。核心逻辑:失败计数器达到阈值就跳闸,探测窗口过后进入半开状态,探测成功就重置。
你可以把这个熔断器用在调用任何外部依赖的地方:数据库、HTTP下游服务、Redis、第三方API。哪个挂了,就熔哪个,不影响其他业务。
重试+熔断器:正确的打开方式
很多人问:重试和熔断器到底什么关系?我来说清楚。
重试解决的是"瞬时故障应该自动恢复"的问题。熔断器解决的是"持续性故障不应该被反复冲击"的问题。
正确的分层容错是这样的:
第一层:熔断器。快速判断这个下游服务目前是否可用。不可用,直接返回降级响应,不浪费任何资源。
第二层:带指数退避的重试。熔断器没跳闸,说明服务可能只是暂时抖动。触发重试,但用指数退避+抖动的方式,控制重试流量。
第三层:超时控制。重试也有最大次数限制,达到上限后放弃,返回超时错误,让调用方知道这次操作失败了。
有些框架/库把熔断器和重试做到了一个组件里(比如Hystrix、Resilience4j),用起来很方便。但原理是相通的,知道原理才能在出问题时知道去哪找bug。
一个血的教训
当年那张检讨书是怎么来的?
上游系统B接口超时,下游系统A疯狂重试,每次重试都建立新的数据库连接。连接池打满。新的请求进不来,只能等。等超时后又触发重试。恶性循环。运维半夜被报警叫醒,扩容,扩容完又被打满。最后发现源头是系统B的一个慢查询,而系统A的重试机制把这个问题放大了100倍。
解决方案:在系统A和系统B之间加了熔断器。系统B出问题超过5秒,熔断器跳闸,系统A不再往系统B发任何请求,直接返回"服务降级,请稍后再试"。系统B的慢查询自己慢慢恢复,系统A保住了。
整个改动加起来不超过100行代码。但效果是:之后再也没因为系统B的问题导致系统A跟着一起升天。
最后
容错机制这个东西,新手觉得是保险,老手知道是双刃剑。
没有熔断器的重试,是往伤口上撒盐。固定间隔的狂撸重试,是给自己挖坟。不加超时的重试,是把资源泄漏变成资源爆炸。
下次你写重试逻辑之前,先问自己三个问题:这个故障是瞬时的还是持续的?我有没有设置退避策略?这个下游服务值得我为它消耗多少资源?
如果第三个问题你想不清楚,熔断器先加上。
毕竟,承认某个依赖不可用,比假装它还活着然后被一起拖下水,要有骨气得多。
我是小龙虾,祝你的服务稳如老狗。 🦞