《MMO 服务器开发》3.2 重试、熔断与背压

超时是未知,那么重试就必须先有幂等前提;下游持续失败时靠熔断止损,过载时靠背压把请求挡在门外而不是排队等雪崩。本节用 Go 真跑重试策略、三态熔断与令牌桶,并给出「熔断打开前后」上游与下游的实测对照,说明为什么这三件事必须成套使用,全部在本机实跑验证。

3.2 重试、熔断与背压

3.1 得出一个结论:超时是「未知」,不是「失败」。既然未知,就有人想重试。这一节处理随之而来的三件事:对未知怎么重试(幂等前提)、对持续失败怎么止损(熔断)、对过载怎么挡回去(背压)。

三者是一套的,缺一不可。没有幂等键就重试,等于允许一次跨服发奖执行两遍;有了重试却没有熔断,下游已经确定要挂的时候上游还在一次次把请求塞进去,把重试变成「放大器」;有了熔断却没有背压,瞬时洪峰会先在本地队列里排成长队,内存和延迟一起被拖爆,还没等到熔断就已经雪崩。

MMO 的跨服场景里这三件事同时出现:跨服战场开放的一瞬间,成千上万条报名同时涌向匹配器。匹配器是稀缺资源,它慢一点没关系,但绝不能被打死。

重试的幂等前提

重试的第一原则不是「重试几次」,而是「这次调用能不能重试」。判断标准只有一个:重复执行是否安全。跨服报名、跨服聊天发送、查询战绩——幂等;跨服扣款、发放唯一奖励——不幂等,除非带上幂等键。

下面这段是 L1,go test 可以直接跑:

// file: internal/rpc/retry.go
// tier: L1
// verify: go test ./internal/rpc/ -run TestRetry
package rpc

import (
	"context"
	"errors"
	"time"
)

// ErrNonIdempotent 表示调用没有幂等保证,按纪律不允许重试。
var ErrNonIdempotent = errors.New("rpc: non-idempotent call must not be retried")

// RetryPolicy 定义重试预算:最多几次、间隔多久、什么错误才重试。
type RetryPolicy struct {
	MaxAttempts int
	Base        time.Duration
	Retryable   func(error) bool
}

// DefaultRetryable 只重试「可能没送达」的错误(超时 / 连接类);
// 明确的业务错误(如「道具不足」)绝不重试。
func DefaultRetryable(err error) bool {
	return errors.Is(err, context.DeadlineExceeded)
}

// Call 在幂等前提下按策略重试。idempotent=false 时直接拒绝,
// 因为「超时了到底成没成功」在没有幂等键时根本无法判定。
func (p RetryPolicy) Call(ctx context.Context, idempotent bool, fn func(context.Context) error) error {
	if !idempotent {
		return ErrNonIdempotent
	}
	var err error
	for attempt := 1; attempt <= p.MaxAttempts; attempt++ {
		err = fn(ctx)
		if err == nil {
			return nil
		}
		if p.Retryable != nil && !p.Retryable(err) {
			return err
		}
		if attempt == p.MaxAttempts {
			break
		}
		wait := p.Base << (attempt - 1) // 指数退避:Base * 2^(attempt-1)
		select {
		case <-time.After(wait):
		case <-ctx.Done():
			return ctx.Err()
		}
	}
	return err
}

三个细节:Retryable 只对「可能没送达」的错误返回 true,业务错误一次都不重试;退避是指数的(Base << (attempt-1)),否则重试会在同一时刻形成新的洪峰;退避的等待要能被 ctx.Done() 打断,用户已经取消的调用不该还傻等。

熔断:阈值与半开

重试解决单次调用的不确定性,熔断解决「下游整体已经不行了」这件事。逻辑很简单:连续失败到阈值就打开,此后所有调用快速失败、不再打扰下游;冷却一段时间后进入半开,放少量探测请求;探测成功就闭合,失败就再次打开。

下面这段是 L1:

// file: internal/rpc/breaker.go
// tier: L1
// verify: go test ./internal/rpc/ -run TestBreaker
package rpc

import (
	"errors"
	"sync"
	"time"
)

// ErrOpen 表示熔断器已打开,调用被快速失败(不消耗下游资源)。
var ErrOpen = errors.New("rpc: circuit open")

type State int

const (
	StateClosed State = iota
	StateOpen
	StateHalfOpen
)

// Breaker 三态熔断器:closed -> open -> half-open -> closed。
type Breaker struct {
	mu           sync.Mutex
	state        State
	failures     int
	successes    int
	threshold    int           // 连续失败多少次要打开
	openFor      time.Duration // 打开后多久进入 half-open
	halfOpenNeed int           // half-open 下连续成功多少次才闭合
	openedAt     time.Time
	now          func() time.Time
}

func NewBreaker(threshold int, openFor time.Duration, halfOpenNeed int) *Breaker {
	return &Breaker{threshold: threshold, openFor: openFor, halfOpenNeed: halfOpenNeed, now: time.Now}
}

// check 需持锁:冷却期结束就进入 half-open,放一次探测。
func (b *Breaker) check() {
	if b.state == StateOpen && b.now().Sub(b.openedAt) >= b.openFor {
		b.state, b.successes = StateHalfOpen, 0
	}
}

// Allow 返回当前是否放行一次调用。
func (b *Breaker) Allow() error {
	b.mu.Lock()
	defer b.mu.Unlock()
	b.check()
	if b.state == StateOpen {
		return ErrOpen
	}
	return nil
}

func (b *Breaker) State() State { b.mu.Lock(); defer b.mu.Unlock(); b.check(); return b.state }

// record 记录一次调用结果。
func (b *Breaker) record(ok bool) {
	b.mu.Lock()
	defer b.mu.Unlock()
	if !ok {
		if b.state == StateHalfOpen { // 半开再失败,立刻退回 open
			b.state, b.openedAt = StateOpen, b.now()
			return
		}
		if b.failures++; b.failures >= b.threshold {
			b.state, b.openedAt = StateOpen, b.now()
		}
		return
	}
	if b.state == StateHalfOpen {
		if b.successes++; b.successes >= b.halfOpenNeed {
			b.state, b.failures = StateClosed, 0
		}
		return
	}
	b.failures = 0
}

func (b *Breaker) OnSuccess() { b.record(true) }
func (b *Breaker) OnFailure() { b.record(false) }

openFor 和 halfOpenNeed 是两个容易被拍脑袋填错的参数。openFor 太短,熔断还没让下游缓过来就又放请求进去;halfOpenNeed 太松,一次偶然成功就把熔断关掉,于是又开始放大失败。MMO 里更稳的填法是 openFor 取秒级(给下游一次喘息),halfOpenNeed 取大于 1(确认恢复而不是撞大运)。

背压:令牌桶与有界队列

熔断针对「已经坏掉的」下游,背压针对「还活着但被压垮的」自己。两种基本手段:令牌桶控制放行速率,有界队列在排队时给出上限并显式拒绝。核心是同一个取舍——过载要暴露成快速失败,而不是排队等雪崩。

下面这段是 internal/rpc/ratelimit.go 的核心(snippet: 展示,完整文件与其 L1 测试 TestTokenBucket / TestBoundedQueue 在本机同样跑过):

// snippet: internal/rpc/ratelimit.go
// 令牌桶:rate 个/秒,最多攒 burst 个。now/last 可注入,便于确定性测试。
func (t *TokenBucket) Allow() bool {
	t.mu.Lock()
	defer t.mu.Unlock()
	now := t.now()
	t.tokens += now.Sub(t.last).Seconds() * t.rate // 按流逝时间补令牌
	if t.tokens > t.burst {
		t.tokens = t.burst // 攒不过 burst
	}
	t.last = now
	if t.tokens >= 1 {
		t.tokens--
		return true
	}
	return false
}

// 有界队列:队列满时直接拒绝新任务,而不是把内存和延迟一起拖爆。
func (q *BoundedQueue) Submit(task func()) error {
	select {
	case q.ch <- task:
		return nil
	default:
		return ErrRejected // 过载暴露成快速失败
	}
}

burst 是「允许的瞬时突发」。跨服战场开放时报名本来就是一浪打过来,burst 给太小会误伤正常峰值;给太大则失去保护意义。经验值按「一个业务窗口内能接受的排队量」倒推,而不是照抄别人配置。

实测:熔断打开前后

下面这段是 B,它起一个真 gRPC 服务端,下游固定慢 40ms,上游每次预算 20ms——必然超时。先测「无熔断」,再测「有熔断(阈值 5)」,对比上游耗时和下游收到的请求数:

// file: internal/rpc/loadtest_test.go
// tier: B
// verify: go test ./internal/rpc/ -run TestBreakerLoad -v
package rpc

import (
	"context"
	"sync/atomic"
	"testing"
	"time"
)

// TestBreakerLoad 实测「熔断打开前后」上游与下游的差异。
// 下游固定慢 40ms,上游每次预算 20ms —— 必然超时。
func TestBreakerLoad(t *testing.T) {
	var downstream atomic.Int64
	addr, stop := startPing(t, func(ctx context.Context, in []byte) ([]byte, error) {
		downstream.Add(1)
		time.Sleep(40 * time.Millisecond)
		f, _ := Decode(in)
		return Frame{Cmd: f.Cmd + 1, Seq: f.Seq, Payload: f.Payload}.Encode()
	})
	defer stop()
	cc := dialPing(t, addr)
	defer cc.Close()
	req, _ := Frame{Cmd: 0x0E01, Seq: 1, Payload: []byte("x")}.Encode()

	const n = 100
	call := func(ctx context.Context) error {
		var out []byte
		return cc.Invoke(ctx, "/aegis.Ping/Call", req, &out)
	}

	// A. 无熔断:每个请求都在 20ms 预算里超时,且全部打到下游
	downstream.Store(0)
	aFail := 0
	t0 := time.Now()
	for i := 0; i < n; i++ {
		ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
		if call(ctx) != nil {
			aFail++
		}
		cancel()
	}
	da, downA := time.Since(t0), downstream.Load()

	// B. 有熔断:连续失败 5 次即打开,之后快速失败,不再打扰下游
	br := NewBreaker(5, time.Minute, 1)
	downstream.Store(0)
	bFail, bFast := 0, 0
	t1 := time.Now()
	for i := 0; i < n; i++ {
		if br.Allow() != nil {
			bFail++
			bFast++
			continue
		}
		ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
		err := call(ctx)
		cancel()
		if err != nil {
			br.OnFailure()
			bFail++
		} else {
			br.OnSuccess()
		}
	}
	db, downB := time.Since(t1), downstream.Load()

	t.Logf("无熔断: %d 次, 失败 %d, 下游收到 %d, 耗时 %s", n, aFail, downA, da)
	t.Logf("有熔断: %d 次, 失败 %d(快速失败 %d), 下游收到 %d, 耗时 %s", n, bFail, bFast, downB, db)
	if downB >= downA {
		t.Fatalf("熔断未减少下游压力: A=%d B=%d", downA, downB)
	}
}

本机实测输出:

无熔断: 100 次, 失败 100, 下游收到 100, 耗时 2.182469083s
有熔断: 100 次, 失败 100(快速失败 95), 下游收到 5, 耗时 107.879083ms

同一台机器、同一份代码,只差一个熔断器:上游耗时从约 2.18s 降到约 108ms,下游收到的请求从 100 次降到 5 次。注意两组的失败次数都是 100——熔断没有让调用「成功」,它做的是把注定失败、还会拖垮下游的请求,从「等 20ms 才失败」变成「立刻失败」,并且不再把它们转发给已经确定要挂的下游。绝对数值不可移植,可移植的是这个方向。

本节小结 / 实测结论

  • 重试的前提是幂等:不幂等的调用直接拒绝重试,幂等的调用用指数退避、可被取消。
  • 熔断用连续失败阈值打开、冷却后半开探测、成功若干次才闭合;openFor 与 halfOpenNeed 决定恢复是否稳健。
  • 背压用令牌桶限速、有界队列拒绝,原则是过载快速失败而不是排队堆积。
  • 实测:加熔断后上游耗时约降 20 倍、下游压力降到 1/20,且失败语义不变(绝对数值不可移植)。

3.3 会把这一节的 gRPC 收发换一个栈——同一份 aegis 帧,在 Go 与 Java 21 上各跑一遍,看两种栈在本机的量级差异。

阅读导航:上一节:协议与超时预算 · 下一节:双栈对照:gRPC-Go vs gRPC-Java 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标