《游戏服务端编程实践》14.2 重试、断路器与回退机制

说明哪些错误值得重试、指数退避与抖动如何避免重试风暴,拆解断路器从关闭到打开再到半开的状态机与失败率阈值,讨论默认值、缓存和功能降级三类回退及其可观测性,最后给出带半开探测与抖动退避的 Go 实现和测试,并结合登录、支付回调与匹配队列的故障场景说明参数取舍。

一、先判断错误是否可重试

重试的第一步不是写退避循环,而是给错误分类。参数校验失败、权限不足、资源不存在重试多少次都不会成功,只会浪费时间并放大下游压力。只有瞬时故障才值得重试,例如连接被重置、请求超时、限流、明确标记为可重试的服务端错误。

错误类型是否重试原因
网络超时、连接被重置可以链路抖动,换一次连接可能恢复
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/ 可以看到检测器如何用连续心跳确认恢复。

小结

重试只适用于瞬时故障,指数退避负责给下游恢复时间,抖动负责打散同时重试的客户端,最大次数和调用链截止时间负责限制放大。断路器用失败率和统计窗口判断是否打开,半开阶段用有限探测验证恢复。回退必须显式、可观测,不能把失败伪装成正常结果。

常见错误与边界

  • 所有错误都重试,包括参数错误和业务拒绝。
  • 固定间隔重试且没有抖动,制造周期性流量脉冲。
  • 每层都重试且没有全局预算,调用量按层数指数放大。
  • 熔断阈值按绝对失败次数而不是失败率,低流量时误触发。
  • 半开状态放行全部流量,依赖尚未恢复就再次被压垮。
  • 回退静默失败,监控看不到降级,用户看到的是旧数据。
  • 写操作重试没有幂等键,造成重复扣费或重复发奖。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist