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 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。