一、先判断错误是否可重试
重试的第一步不是写退避循环,而是给错误分类。参数校验失败、权限不足、资源不存在重试多少次都不会成功,只会浪费时间并放大下游压力。只有瞬时故障才值得重试,例如连接被重置、请求超时、限流、明确标记为可重试的服务端错误。
| 错误类型 | 是否重试 | 原因 |
|---|---|---|
| 网络超时、连接被重置 | 可以 | 链路抖动,换一次连接可能恢复 |
| 429 限流、503 过载 | 可以,但要退避 | 立即重试会加重过载 |
| 400 参数错误、401 未授权 | 不可以 | 输入或凭证本身有问题 |
| 业务错误,如余额不足、背包已满 | 不可以 | 重试不会改变业务状态 |
| 幂等写操作的超时 | 谨慎重试 | 必须能判断第一次是否已经生效 |
判断标准要写进代码,而不是留在注释里。重试器应接收一个显式的 Retryable 判定函数,未配置就拒绝创建,避免“默认全部重试”这种危险行为。
二、指数退避与抖动
固定间隔重试会把失败请求以相同节奏反复打到下游。指数退避把等待时间按 2 的幂增长,并设置上限,让前几次快速重试、后几次给下游更长的恢复时间。例如基础延迟 100 毫秒、上限 5 秒,等待序列大致是 100 毫秒、200 毫秒、400 毫秒、800 毫秒,直到封顶。
只做指数退避还不够。如果一千个客户端同时失败,它们会在同一时刻以相同节奏重试,形成新的脉冲。抖动在每次延迟上乘一个随机因子,把重试时刻打散。常见做法是取 [delay × (1 - jitter), delay] 区间内的随机值,jitter 取 0.3 到 0.5 已经能显著削峰。
最大次数要同时受两个约束:业务能接受的总等待时间,以及下游的恢复速度。重试三次往往比重试十次更合理,因为超过一定时间后,调用方还不如快速失败并走回退路径。
三、重试放大与重试风暴
重试会放大流量。一次用户请求经过网关、房间服、匹配服三层,每层都重试三次,最坏情况下后端会收到 27 次调用。如果每层都用固定间隔、没有全局预算,故障时的流量可能达到正常值的数十倍,把本已过载的下游彻底压垮。
控制重试放大需要三层约束:只在一层做重试,通常是最接近故障的一层;给整条调用链设置截止时间,剩余预算不足时不再重试;对同一请求使用幂等键,避免重复扣费、重复发奖。重试次数、等待时长和最终失败都要进入指标,否则“重试了很多次”只会表现为延迟升高。
四、断路器状态机
断路器把“反复失败”变成“快速失败”。它有三个状态:关闭时正常放行并统计结果;失败率达到阈值后打开,直接拒绝请求;冷却时间到期后进入半开,只放行少量探测,根据结果决定关闭还是重新打开。
| 状态 | 是否放行 | 迁移条件 |
|---|---|---|
| 关闭 | 全部放行 | 样本足够且失败率达到阈值,转打开 |
| 打开 | 全部拒绝 | 冷却时间到期,转半开 |
| 半开 | 只放行少量探测 | 连续成功达到阈值转关闭;任一失败转打开 |
阈值应按失败率而不是绝对失败次数。低流量时,一两次失败就可能让绝对计数触发熔断,而实际失败率并不高;高流量时,固定次数又可能迟迟不触发。统计窗口要限定样本范围,避免几小时前的成功把当前故障“平均掉”。
半开探测数量也要限制。放行太多,等于在依赖还没恢复时重新压上全部流量;只放行一个,又可能因为单个探测的网络抖动错过恢复。常见起点是 1 到 3 个并发探测,并要求连续成功若干次才关闭。
五、回退策略
回退不是“吞掉错误”。默认值、缓存和功能降级是三类常见手段:读不到玩家配置时返回上一次缓存;排行榜不可用时展示本地缓存或隐藏入口;推荐服务超时时返回按热度排序的兜底列表。每种回退都要明确告诉调用方“这是降级结果”,而不是伪装成正常数据。
回退本身也要可观测。至少记录触发次数、回退类型、回退耗时和最终成功率。静默回退最危险:用户看到的是旧数据或缺失功能,监控上却一切正常,直到投诉出现才发现依赖已经坏了很久。
六、失败场景:所有错误都重试
某支付回调接口把数据库超时和“订单不存在”都当成可重试错误,固定每秒重试五次。数据库抖动时,回调请求被反复执行,重复入账;订单不存在的请求则永远重试,队列持续堆积。最终不是数据库恢复解决问题,而是人工下线消费者并清理重复流水。
| 时间 | 事件 | 结果 |
|---|---|---|
| T+0 | 数据库主从切换 | 部分写入超时 |
| T+1s | 所有超时按固定间隔重试 | 数据库连接池被打满 |
| T+5s | 重复回调被再次处理 | 出现重复入账 |
| T+30s | 队列积压继续增长 | 正常回调也被延迟 |
| T+10min | 人工停止消费者 | 开始对账与补偿 |
正确做法是:仅对可重试错误退避重试,写操作带幂等键,连续失败触发断路器,超过预算后进入可观测的回退路径。
七、可运行实现:断路器与退避重试
下面的实现把断路器与重试器放在同一个包。断路器用滑动窗口统计失败率,打开后经过冷却进入半开,并用探测名额限制并发;重试器要求显式提供可重试判定,延迟按指数增长并叠加抖动。
// file: ch14/breaker/breaker.go
// tier: L1
// verify: go test ./ch14/breaker/
package breaker
import (
"context"
"errors"
"math/rand"
"sync"
"time"
)
// ErrOpen 表示断路器已打开,请求被快速失败。
var ErrOpen = errors.New("breaker: circuit open")
// State 是断路器的三态。
type State uint8
const (
Closed State = iota // 正常放行并统计结果
Open // 拒绝请求,等待冷却
HalfOpen // 只放行少量探测
)
// Config 描述断路器参数:Closed 保留最近 WindowSize 次结果,
// 样本数达到 MinSamples 且失败率不低于 FailureRate 时打开;
// 冷却 OpenTimeout 后最多并发 HalfOpenProbes 个探测,连续成功
// HalfOpenSuccesses 次才恢复 Closed。
type Config struct {
WindowSize int
MinSamples int
FailureRate float64
OpenTimeout time.Duration
HalfOpenProbes int
HalfOpenSuccesses int
}
func (c Config) validate() error {
switch {
case c.WindowSize < 1 || c.MinSamples < 1 || c.MinSamples > c.WindowSize:
return errors.New("breaker: invalid window or samples")
case c.FailureRate <= 0 || c.FailureRate > 1:
return errors.New("breaker: failure rate must be in (0,1]")
case c.OpenTimeout <= 0:
return errors.New("breaker: open timeout must be positive")
case c.HalfOpenProbes < 1 || c.HalfOpenSuccesses < 1:
return errors.New("breaker: invalid half-open probes")
}
return nil
}
// Breaker 是并发安全的断路器。时间由 now 注入,测试可以推进
// 虚拟时钟;Allow 成功后必须调用且只调用一次 Done。
type Breaker struct {
mu sync.Mutex
cfg Config
state State
results []bool
next int
samples int
failures int
openedAt time.Time
probes int
halfSuccesses int
now func() time.Time
}
// New 校验配置并创建处于 Closed 状态的断路器。
func New(cfg Config) (*Breaker, error) {
if err := cfg.validate(); err != nil {
return nil, err
}
return &Breaker{cfg: cfg, state: Closed,
results: make([]bool, cfg.WindowSize), now: time.Now}, nil
}
// Allow 判断请求能否放行,并在 HalfOpen 占用一个探测名额。
func (b *Breaker) Allow() error {
b.mu.Lock()
defer b.mu.Unlock()
if b.state == Open {
if b.now().Sub(b.openedAt) < b.cfg.OpenTimeout {
return ErrOpen
}
b.state = HalfOpen
b.probes, b.halfSuccesses = 0, 0
}
if b.state == HalfOpen {
if b.probes >= b.cfg.HalfOpenProbes {
return ErrOpen
}
b.probes++
}
return nil
}
// Done 上报一次已放行请求的结果。Open 状态下的迟到结果被忽略,
// 它们属于上一轮探测,不能污染新一轮半开统计。
func (b *Breaker) Done(success bool) {
b.mu.Lock()
defer b.mu.Unlock()
switch b.state {
case Closed:
b.recordLocked(success)
case HalfOpen:
if b.probes > 0 {
b.probes--
}
if !success {
b.openLocked()
return
}
b.halfSuccesses++
if b.halfSuccesses >= b.cfg.HalfOpenSuccesses {
b.closeLocked()
}
}
}
// State 返回当前状态。
func (b *Breaker) State() State { b.mu.Lock(); defer b.mu.Unlock(); return b.state }
func (b *Breaker) recordLocked(success bool) {
if b.samples == len(b.results) {
if !b.results[b.next] {
b.failures--
}
} else {
b.samples++
}
b.results[b.next] = success
if !success {
b.failures++
}
b.next = (b.next + 1) % len(b.results)
rate := float64(b.failures) / float64(b.samples)
if b.samples >= b.cfg.MinSamples && rate >= b.cfg.FailureRate {
b.openLocked()
}
}
func (b *Breaker) openLocked() {
b.state = Open
b.openedAt = b.now()
b.probes, b.halfSuccesses = 0, 0
}
func (b *Breaker) closeLocked() {
b.state = Closed
b.next, b.samples, b.failures = 0, 0, 0
b.probes, b.halfSuccesses = 0, 0
for i := range b.results {
b.results[i] = false
}
}
// RetryConfig 描述指数退避重试参数。Retryable 必须显式给出,
// 防止把参数错误、权限不足等永久失败也拿去重试。
type RetryConfig struct {
MaxAttempts int
BaseDelay time.Duration
MaxDelay time.Duration
Jitter float64
Retryable func(error) bool
}
func (c RetryConfig) validate() error {
switch {
case c.MaxAttempts < 1:
return errors.New("breaker: max attempts must be at least 1")
case c.BaseDelay <= 0:
return errors.New("breaker: base delay must be positive")
case c.MaxDelay < c.BaseDelay:
return errors.New("breaker: max delay must not be smaller than base delay")
case c.Jitter < 0 || c.Jitter > 1:
return errors.New("breaker: jitter must be in [0,1]")
case c.Retryable == nil:
return errors.New("breaker: retryable predicate is required")
}
return nil
}
// Retrier 按配置执行带抖动的指数退避重试。
type Retrier struct {
cfg RetryConfig
rnd *rand.Rand
sleep func(context.Context, time.Duration) error
}
// NewRetrier 校验配置并创建一个重试器。
func NewRetrier(cfg RetryConfig) (*Retrier, error) {
if err := cfg.validate(); err != nil {
return nil, err
}
return &Retrier{cfg: cfg,
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
sleep: sleepContext}, nil
}
// Delay 返回第 retry 次重试前的等待时间,retry 从 1 开始。
// 基础延迟按 2 的幂增长并封顶,再乘以 [1-Jitter,1] 的随机
// 因子;抖动不改变重试次数,只把同时失败的客户端错开。
func (r *Retrier) Delay(retry int) time.Duration {
if retry < 1 {
retry = 1
}
delay := r.cfg.BaseDelay
for i := 1; i < retry; i++ {
if delay >= r.cfg.MaxDelay/2 {
delay = r.cfg.MaxDelay
break
}
delay *= 2
}
if delay > r.cfg.MaxDelay {
delay = r.cfg.MaxDelay
}
if r.cfg.Jitter == 0 {
return delay
}
return time.Duration(float64(delay) * (1 - r.cfg.Jitter*r.rnd.Float64()))
}
// Do 执行 fn,直到成功、遇到不可重试错误或次数耗尽。
func (r *Retrier) Do(ctx context.Context, fn func(context.Context) error) error {
var last error
for attempt := 1; attempt <= r.cfg.MaxAttempts; attempt++ {
if err := ctx.Err(); err != nil {
return err
}
last = fn(ctx)
if last == nil {
return nil
}
if !r.cfg.Retryable(last) {
return last
}
if attempt == r.cfg.MaxAttempts {
break
}
if err := r.sleep(ctx, r.Delay(attempt)); err != nil {
return err
}
}
return last
}
func sleepContext(ctx context.Context, d time.Duration) error {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
实现里有几个关键点。Allow 在打开状态检查冷却时间,到期后切换到半开并占用探测名额;Done 只处理当前状态的结果,打开状态下的迟到结果被忽略,避免旧探测污染新一轮统计。重试器把 Retryable 设为必填,抖动只改变等待时间,不改变最大次数。
测试覆盖:连续失败按失败率打开、低失败率不误触发、半开连续成功恢复关闭、半开失败立即重新打开、半开并发探测受限、迟到结果被忽略、不可重试错误只调用一次、重试次数耗尽、抖动产生足够分散的延迟、上下文取消及时退出。运行方式:
cd .local/checks/gs-practice-lab
gofmt -l ch14/breaker
go vet ./ch14/breaker/
go test ./ch14/breaker/ -count=1
八、和 14.1 的衔接
心跳与健康检查负责发现“节点还在不在”,重试与断路器负责在节点短暂异常时保护调用方。两者要共享同一套状态:被摘除的节点不应继续接收重试流量,恢复中的节点也不应被半开探测瞬间打满。回到 /game-server-in-action-14-1/ 可以看到检测器如何用连续心跳确认恢复。
小结
重试只适用于瞬时故障,指数退避负责给下游恢复时间,抖动负责打散同时重试的客户端,最大次数和调用链截止时间负责限制放大。断路器用失败率和统计窗口判断是否打开,半开阶段用有限探测验证恢复。回退必须显式、可观测,不能把失败伪装成正常结果。
常见错误与边界
- 所有错误都重试,包括参数错误和业务拒绝。
- 固定间隔重试且没有抖动,制造周期性流量脉冲。
- 每层都重试且没有全局预算,调用量按层数指数放大。
- 熔断阈值按绝对失败次数而不是失败率,低流量时误触发。
- 半开状态放行全部流量,依赖尚未恢复就再次被压垮。
- 回退静默失败,监控看不到降级,用户看到的是旧数据。
- 写操作重试没有幂等键,造成重复扣费或重复发奖。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。