0%

go-zero 源码分析 10:熔断、降载与限流

在上一篇文章的最后,我们走完了 go-zero 的服务发现与负载均衡链路——客户端通过 etcd Watch 实时获取在线节点列表,再通过 P2C 算法选择延迟最低、在途请求最少的那个节点发出请求。这一切运转良好的前提是:下游是健康的,流量是可控的,CPU 是空闲的,但生产环境从来不缺少意外。

  • 下游故障:某个 RPC 服务的数据库连接池耗尽,每次调用都超时 2 秒。客户端的 P2C 算法虽然能感知延迟变化,但调用仍然在发生——几百个并发的调用者同时在等 2 秒超时,客户端的 goroutine 堆积、内存上升,最终把调用方自己也拖垮。这本质上是一个级联故障——一个组件的问题沿着调用链扩散,最终瘫痪整个系统。
  • 流量突发:缓存大面积失效,流量全部穿透到数据库。CPU 瞬间飙到 99%,所有的请求都在排队等待,响应时间从 50ms 涨到 30 秒。系统还没挂,但等价于挂了——因为没有人能在一个可接受的时间内拿到结果。这本质上是一个过载问题——系统容量上限被突破时,如果不主动拒绝一部分请求,最终所有请求都会被拖死。
  • 资源滥用:某个调用方代码有 bug,在循环中不断调用你的接口,每秒数千次。其他正常的调用方被挤占,甚至因为连接数占满而连不上。这本质上是一个资源竞争问题——缺少按调用方做配额控制的机制。

go-zero 提供了三层独立的保护机制来应对这三种场景,它们从保护对象、触发条件到决策算法都不同:

机制 保护对象 触发条件 核心问题
熔断(Breaker) 调用方 下游错误率 下游挂了,要不要继续调?
降载(Shedding) 当前服务 系统 CPU 过载 CPU 满了,要不要拒绝新请求?
限流(Limiting) 系统容量边界 请求超过预设速率 某个调用方是否超过了分配额度?

三者不是互斥关系,而是各自的战场不同。理解了这一点,再看它们的具体实现,就能明白为什么有些设计决策要那样做而不是这样做。

熔断:Google SRE 的概率式断路器

传统的"全开全关"有什么问题

提到熔断器,很多人首先想到的是 Netflix Hystrix 的三态机闭合 → 开放 → 半开 → 闭合。当失败率超过阈值(比如 50%),开关从闭合变为开放,所有请求直接失败;一段时间后变为半开,允许一个探测请求通过;探测成功则闭合,失败则继续开放。

这个模型清晰且直观,但它有一个根本性缺陷:开关是二元的——要么全放,要么全拒。 假设失败率从 5% 涨到 55%,瞬时超过 50% 阈值,此时断路器从"放行所有请求"直接跳变为"拒绝所有请求"。这 45% 的成功请求本可以正常处理,却被一并拒绝了。

在生产环境中,这种情况尤其危险:当故障开始恢复时(比如失败率从 100% 逐步回落到 40%),半开状态只放行一个探测请求——如果这个探测请求恰好失败(巧合),整个恢复过程就被延后了一个探测周期。这导致恢复过于缓慢,而故障期间系统吞吐量又被打了一个不必要的零。

概率式拒绝:让拒绝与失败程度成正比

go-zero 没有使用传统的三态机,而是实现了一个概率式断路器——默认名为 googleBreaker。它的核心思路来自 Google SRE 手册中"处理过载"一章的客户端节流算法:不是要么全放要么全拒,而是计算一个"丢弃概率",失败越多,丢弃概率越高,但总有一部分请求能通过。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// core/breaker/googlebreaker.go
func (b *googleBreaker) accept() error {
var w float64
history := b.history()
w = b.k - (b.k-minK)*float64(history.failingBuckets)/buckets
weightedAccepts := mathx.AtLeast(w, minK) * float64(history.accepts)
dropRatio := (float64(history.total-protection) - weightedAccepts) / float64(history.total+1)
if dropRatio <= 0 {
return nil
}

// 强制放行:至少每秒有一个请求能通过
lastPass := b.lastPass.Load()
if lastPass > 0 && timex.Since(lastPass) > forcePassDuration {
b.lastPass.Set(timex.Now())
return nil
}

dropRatio *= float64(buckets-history.workingBuckets) / buckets

if b.proba.TrueOnProba(dropRatio) {
return ErrServiceUnavailable
}

b.lastPass.Set(timex.Now())
return nil
}

理解这个算法需要先理清几个概念:

滑动窗口与 bucket

断路器内部使用了一个 40 个 bucket 的滑动窗口,每个 bucket 覆盖 250ms,总窗口为 10 秒。每个 bucket 记录四种计数:Sum(总请求数)、Success(成功)、Failure(失败)、Drop(被丢弃)。

每次有请求被接受、执行并返回结果后,调用方通过 Promise.Accept()Promise.Reject(reason) 告诉断路器本次调用的结果,断路器据此在各 bucket 中增加相应的计数。

history():四个关键指标

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func (b *googleBreaker) history() windowResult {
var result windowResult
b.stat.Reduce(func(b *bucket) {
result.accepts += b.Success
result.total += b.Sum
if b.Failure > 0 {
result.workingBuckets = 0
} else if b.Success > 0 {
result.workingBuckets++
}
if b.Success > 0 {
result.failingBuckets = 0
} else if b.Failure > 0 {
result.failingBuckets++
}
})
return result
}

history() 遍历所有 40 个 bucket,计算四个值:

  • accepts:所有 bucket 中的成功总数。
  • total:所有 bucket 中的请求总数(成功 + 失败 + 丢弃)。
  • failingBuckets:从窗口尾部向前数,连续出现失败(且没有成功)的 bucket 数量。注意计数规则:遇到有成功的 bucket 时计数器归零,遇到有失败无成功的 bucket 时计数器加一。所以 failingBuckets 反映的是"距离最近一次成功有多久了"。
  • workingBuckets:对称的概念——从窗口尾部向前数,连续出现成功(且没有失败)的 bucket 数量。

这四个值的组合用处很大。failingBucketsworkingBuckets 不是简单地在统计失败次数——它们统计的是连续趋势。10 秒前有过一次失败(几乎不影响当前健康度)和最近 1 秒内全是失败(系统出了大问题),breaker 的反应应该完全不同。failingBuckets 的连续计数机制天然区分了这两种情形:前者对应的 failingBuckets 几乎为 0,后者接近 40。

丢弃率的计算

回到 accept() 的执行过程:

第一步:计算权重因子。 w = k - (k - minK) * failingBuckets / buckets。当没有失败 bucket 时,w = k = 1.5;当所有 bucket 都在失败时,w = minK = 1.1w 在 1.5 到 1.1 之间根据 failingBuckets 线性插值——失败越多,w 越小,允许的请求越少。

第二步:计算加权允许数。 weightedAccepts = max(w, minK) * accepts。这是根据当前健康状态,系统"本应"能接受的请求数量——如果最近的失败多,w 就小,等量的 accepts 乘以更小的 w,意味着更多请求应该被拒绝。

第三步:计算丢弃率。 dropRatio = (total - protection - weightedAccepts) / (total + 1)。分子是"超出合理范围的请求数",分母是总请求数。当 total(实际请求总量)远超 weightedAccepts(合理请求量)时,dropRatio 接近 1——几乎所有请求都应该被丢弃。

第四步:强制放行(force pass)。 如果距离上一次实际放行请求已经超过了 1 秒,无论 dropRatio 多高,当前请求都会被放行。这个机制保证了断路器不会永远"锁死"——即使下游故障持续了很长时间,至少每秒有一个探测请求能穿透,用于检测下游是否已经恢复。

第五步:工作 bucket 修正。 dropRatio *= (buckets - workingBuckets) / buckets。正常工作的 bucket 越多,丢弃率越低。当全部 40 个 bucket 都在正常工作时,dropRatio 乘以 0——再怎么算出来的丢弃率都不作数,全部放行。

第六步:概率判断。 proba.TrueOnProba(dropRatio) 根据 dropRatio 做概率性拒绝,概率越高被拒绝的可能性越大。注意这里的行为:dropRatio 为 0.3 时,大约 30% 的请求会被拒绝,70% 仍然能通过。这正是在本文开头强调的概率式拒绝:不是全开全关,而是按比例拒绝。

这种概率式断路器的巧妙之处在于:它把健康与故障之间的边界从 开关 变成了 渐变。12 个 failure bucket 对应约 30% 的丢弃率,30 个对应约 75%——丢弃率随着失败持续时间的增加自然增长,无需人工设定阈值。

三层的分层设计

断路器模块的内部架构是分层的:

1
circuitBreaker (Breaker 接口) → loggedThrottle → googleBreaker (internalThrottle)
  • circuitBreaker:对外暴露的入口。包装了 nameloggedThrottle,实现了 Breaker 接口的全部方法。所有的 Do*DoWith* 变体都在这里有定义。
  • loggedThrottle:在 googleBreaker 的外面包了一层日志和错误记录。它持有一个 errorWindow——一个固定容量为 5 的环形缓冲区,记录最近 5 次被判定为"失败"调用的错误原因和发生时间。当断路器跳开后拒绝请求(返回 ErrServiceUnavailable),logError 方法会打印一条带有最近错误原因的日志,运维排查不再需要猜"它是为什么开的"。
  • googleBreaker:核心算法层,实现 internalThrottle 接口的两个方法——allow()doReq()

Promise 也遵循了这个分层:promiseWithReason 包装了 internalPromise,在 Reject(reason string) 时先把错误原因推入 errorWindow,再调用内层 promise.Reject()。这就是为什么你看到 Reject(reason string)Accept() 分别是两个 Promise 接口——前者多了一个记录原因的责任。

全局 Breaker 注册表

断路器按名称管理——name 就是它的 identity。框架提供了一个全局注册表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// core/breaker/breakers.go
func GetBreaker(name string) Breaker {
lock.RLock()
b, ok := breakers[name]
lock.RUnlock()
if ok {
return b
}

lock.Lock()
b, ok = breakers[name]
if !ok {
b = NewBreaker(WithName(name))
breakers[name] = b
}
lock.Unlock()

return b
}

使用 double-check locking 模式保证并发安全。所有全局级别的 Do()DoWithAcceptable() 等函数都是先拿到 named breaker,再调用它的方法。REST 中每个路由用 "GET://user/info" 作为 breaker 名,RPC 中用 "/package.Service/Method" 全限定方法名——同一个接口在同一个进程中共享一个断路器实例。

REST 与 RPC 中的集成

在 REST 中,BreakerHandler 按路由为单位创建断路器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// rest/handler/breakerhandler.go
func BreakerHandler(method, path string, metrics *stat.Metrics) func(http.Handler) http.Handler {
brk := breaker.NewBreaker(breaker.WithName(strings.Join([]string{method, path}, breakerSeparator)))
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
promise, err := brk.Allow()
if err != nil {
metrics.AddDrop()
w.WriteHeader(http.StatusServiceUnavailable)
return
}
cw := response.NewWithCodeResponseWriter(w)
defer func() {
if cw.Code < http.StatusInternalServerError {
promise.Accept()
} else {
promise.Reject(fmt.Sprintf("%d %s", cw.Code, http.StatusText(cw.Code)))
}
}()
next.ServeHTTP(cw, r)
})
}
}

断路器根据 HTTP 状态码判定成功/失败——小于 500 算成功,大于等于 500 算失败。这和 gRPC 中的判断逻辑稍有不同。在 gRPC 服务端,断路器通过 serverSideAcceptable 判断错误是否可接受:

1
2
3
4
5
6
7
// zrpc/internal/serverinterceptors/breakerinterceptor.go
func serverSideAcceptable(err error) bool {
if errorx.In(err, context.DeadlineExceeded, breaker.ErrServiceUnavailable) {
return false
}
return codes.Acceptable(err)
}

DeadlineExceededErrServiceUnavailable 被明确标记为不可接受——前者说明下游超时了,后者说明被下游的断路器拒绝了,这两种情况都应该计入失败。

降载:BBR 思路的自适应过载保护

问题:CPU 是比 QPS 更好的过载信号

断路器保护的是 被调用方出错 的场景。但如果下游一切正常——响应很快,没有错误——只是流量太大了呢?

一个直观的方案是限制最大 QPS——比如限制为 10000 QPS,超过就拒绝。但 QPS 不是一个好的过载信号。10000 次 /health 和 10000 次复杂查询请求,CPU 的开销完全不同。同一个接口在不同数据量级下的 CPU 开销也可能差数倍。用 QPS 做限流,要么过于宽松(设置太高导致 CPU 满了),要么过于保守(设置太低浪费了资源)。

go-zero 的降载模块直接监视 CPU 使用率。不是 QPS,不是因为 QPS 控制不了突发多变的流量模式,而是因为 CPU 直接反映"系统当前有多忙"——不管是什么请求、什么数据量、什么代码路径,CPU 只要满了,系统就该减压了。

算法总览:不是重新发明,而是借鉴 TCP BBR

TCP BBR(Bottleneck Bandwidth and Round-trip propagation time)是 Google 提出的一种拥塞控制算法。不同于传统算法依赖丢包信号,BBR 通过持续测量瓶颈带宽最小延迟来决策发送速率——它不等到网络出现拥塞信号才降速,而是主动寻找最优发送速率。

go-zero 的 adaptiveShedder 借鉴了 BBR 的测量-决策模式,但问题域从"网络带宽"变成了"CPU 容量":

TCP BBR 概念 go-zero 降载对应
瓶颈带宽(bottleneck bandwidth) 最大通过率 maxPass——最近 5 秒内的峰值吞吐
最小 RTT(round-trip time) 最小响应时间 minRt——最近 5 秒内的最快响应
BDP(带宽×延迟积) maxFlight——系统在最优状态下能同时处理的请求数
拥塞窗口调整 overloadFactor——根据 CPU 使用率线性缩减允许的负载
1
2
3
4
5
// core/load/adaptiveshedder.go
func (as *adaptiveShedder) maxFlight() float64 {
maxFlight := float64(as.maxPass()) * as.minRt() * as.windowScale
return mathx.AtLeast(maxFlight, 1)
}

maxFlight 的计算来自于 Little 定律 L = λW——系统的平均在途数等于到达率乘以平均驻留时间。在这里,λmaxPass(每 bucket 的最大通过数),WminRt(最小响应时间),windowScale 是单位转换因子(把 per-bucket 转为 per-millisecond)。计算结果 maxFlight 就是系统的"BDP"——在低延迟、无过载状态下,最多可以同时在途处理的请求数。

sliding window 中的 maxPass 与 minRt

降载器使用了两个独立的滑动窗口,每个窗口为 5 秒,包含 50 个 bucket(每 100ms 一个):

  • passCounter:记录每个 bucket 中通过的请求数(Pass 次数)。
  • rtCounter:记录每个 bucket 中响应时间的总和和次数Sum 是总毫秒数,Count 是请求数)。

注意这两个窗口在创建时使用了 collection.IgnoreCurrentBucket()——当前的 bucket 还在填充中,数据不完整,在 Reduce 时被排除在外。这是一个细致的考量:如果不忽略当前 bucket,50ms 前创建的 bucket 只收集了 50ms 的数据,其计数会和完整 100ms 的 bucket 混合在一起产生误差。

maxPass() 在 50 个完整 bucket 中寻找单个 bucket 的最大通过数——它代表系统在极限状态下能处理的吞吐量峰值。minRt() 寻找单个 bucket 的最小平均响应时间——它代表系统在最理想状态下的延迟水平。两个极端值组合在一起,给出系统"最佳表现"的数学描述。

决策链:三层判断,两重门

请求到达 Allow() 时,降载器执行的是三层判断链:

1
2
3
4
5
6
7
8
func (as *adaptiveShedder) Allow() (Promise, error) {
if as.shouldDrop() {
as.droppedRecently.Set(true)
return nil, ErrServiceOverloaded
}
as.addFlying(1)
return &promise{start: timex.Now(), shedder: as}, nil
}

第一重门:系统是否过载?是否还在"余热"中?

1
2
3
4
5
6
7
8
9
func (as *adaptiveShedder) shouldDrop() bool {
if as.systemOverloaded() || as.stillHot() {
if as.highThru() {
// ... 记录日志和上报指标 ...
return true
}
}
return false
}

当 CPU 使用率 ≥ 阈值(默认 900 millicpu,即 90%)时,systemOverloaded() 返回 true。stillHot() 检查最近 1 秒内是否发生过丢弃——如果是,即便 CPU 暂时降低,它仍然返回 true。这个 1 秒的 coolOffDuration 起到了滞回作用,防止 CPU 短暂降到 89% 后又立即飙回 91% 带来的反复振荡。

如果 CPU 没有达到阈值且没有余热期,请求直接放行——不经过任何计算,不读任何计数器。这是一种精心设计的优化:正常状态下零开销——大多数时候系统 CPU 是正常的,不应该为"可能过载"而一直消耗额外的 CPU。

第二重门:当前吞吐量是否过高?

只有当第一重门满足时(系统过载或仍在余热),才会进入第二重门——highThru()

1
2
3
4
5
func (as *adaptiveShedder) highThru() bool {
avgFlying := as.avgFlying
maxFlight := as.maxFlight() * as.overloadFactor()
return avgFlying > maxFlight && float64(as.flying) > maxFlight
}

maxFlight(历史的"最佳同时在途数")乘以 overloadFactor(当前 CPU 下的容量倍数)得到当前允许的在途数上限。同时检查两个值:EMA 平滑的平均在途数(avgFlying)和瞬时在途数(flying),两者都必须超过上限才算"高吞吐"。为什么要两个?光有瞬时高不代表持续高——可能只是一个突发尖刺,很快就自我修正了。光有平均高不代表现在还高——如果 in-flight 已经降下来但平均值因为 EMA 惯性仍然偏高,不该再拒绝请求。双重条件下,只有在"持续且当前都高"时才拒绝,策略更精确。

overloadFactor:CPU 越高,越"吝啬"

1
2
3
4
func (as *adaptiveShedder) overloadFactor() float64 {
factor := (cpuMax - float64(stat.CpuUsage())) / (cpuMax - float64(as.cpuThreshold))
return mathx.Between(factor, overloadFactorLowerBound, 1)
}

overloadFactor 实现了降载中最关键的非线性控制。它根据当前 CPU 使用率,线性缩放允许的负载上限:

  • CPU = 90%(刚好到阈值):factor = (1000 - 900) / (1000 - 900) = 1.0——允许满载。
  • CPU = 95%:factor = 50 / 100 = 0.5——只允许一半。
  • CPU = 100%:factor = 0 / 100 = 0——会被钳制到 overloadFactorLowerBound = 0.1,至少保留 10% 的通过率。

这里有一个重要的设计:最低保留 10%。 如果 CPU = 100% 时允许的负载降到 0,那就是全盘拒绝——没有请求通过,也没有响应时间数据返回,minRtmaxPass 都冻结在窗口内,任何恢复都是不可能的。保持 10% 的通过率保证了系统始终有新鲜数据更新,从而能判断何时应该恢复正常。

avgFlying 的 EMA 不对称更新

1
2
3
4
5
6
7
8
func (as *adaptiveShedder) addFlying(delta int64) {
flying := atomic.AddInt64(&as.flying, delta)
if delta < 0 {
as.avgFlyingLock.Lock()
as.avgFlying = as.avgFlying*flyingBeta + float64(flying)*(1-flyingBeta)
as.avgFlyingLock.Unlock()
}
}

EMA 的更新只发生在 delta < 0 时——即请求完成时。这意味着:

  • 当 in-flight 快速上升时(请求涌入),avgFlying 保持不变,滞后于实际值,允许更多请求被接受——系统乐观地认为"新请求的响应应该也会很快"。
  • 当 in-flight 快速下降时(请求完成),avgFlying 也开始下降,但仍然滞后——此时降载器相对保守,“不要急着全放,看看情况再说”。

这是一个 pragmatically smart 的工程决策:进攻要快,撤退要慢。 系统应该尽快接纳请求(增加吞吐),但释放容量时要观察一阵(防止冲击)。

Promise:成功/失败的语义

与断路器的 Accept() vs Reject() 不同,降载器的 Promise 使用的是 Pass() vs Fail()

1
2
3
4
5
6
7
8
9
10
func (p *promise) Pass() {
rt := float64(timex.Since(p.start)) / float64(time.Millisecond)
p.shedder.addFlying(-1)
p.shedder.rtCounter.Add(int64(math.Ceil(rt)))
p.shedder.passCounter.Add(1)
}

func (p *promise) Fail() {
p.shedder.addFlying(-1)
}

Pass() 做了三件事:计算本次请求的响应时间并加入 rtCounter,在 passCounter 中记录一次通过,降低 in-flight 计数。Fail() 只做降低 in-flight——失败的请求不计入吞吐量和响应时间的统计。区分是重要的:如果你把超时的请求时间也纳入 minRt,那 minRt 就不再是"系统在最佳状态下的表现",降载的判断就失真了。

限流:Redis 分布式与本地并发的双重防线

问题:凭什么"不限"还不够

熔断保护下游,降载保护 CPU,两者处理的都是系统级别的健康问题。但有一种场景两者都照顾不到:某个特定的调用方发送了远超过其合理份额的请求,但每个请求都返回 200 OK,CPU 也没有到 90%。 另一些调用方因此被挤占——它们的请求在队列中排队,响应时间上升,尽管"整体"看起来还正常。

这就是限流的适用场景——按调用方、按时间窗口、按配额,在进入 CPU 压力之前就挡住过量请求。go-zero 提供了三种限流方式:Redis 固定窗口、Redis 令牌桶和本地限并发。

Redis 固定窗口:PeriodLimit

固定窗口是最简单的限流策略:在一个固定长度的时间段内(比如 1 秒),最多允许 N 个请求。实现也非常直接——18 行 Lua 脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- core/limit/periodscript.lua
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = redis.call("INCRBY", KEYS[1], 1)
if current == 1 then
redis.call("expire", KEYS[1], window)
end
if current < limit then
return 1
elseif current == limit then
return 2
else
return 0
end

所有逻辑在 Redis 中通过一个 INCRBY + 条件 EXPIRE 实现原子性。返回值有三种:

  • 1(Allowed):还没到配额上限。
  • 2(HitQuota):刚好等于配额上限——这被单独区分出来是有原因的,调用方可以据此判断"这次是第 N 次,可能是该提醒用户稍后再尝试了"。
  • 0(OverQuota):已超过配额。

PeriodLimit 还提供了一个可选的 Align() 选项,用于将时间窗口对齐到整点边界。如果设置为 24 小时的窗口并对齐,“每天 5 次"的语义体现为"从零点到 23:59:59 最多 5 次”,而不是"从第一次请求开始的 86400 秒内最多 5 次"。对于手机验证码这种需求,对齐到自然日是合理的设计。

Redis 令牌桶:TokenLimiter

固定窗口的缺陷是"临界突发"——在窗口的最后 1ms 和下一个窗口的第 1ms 发出了两倍配额的请求(两者分别属于不同的窗口)。令牌桶通过稳态的速度限制(rate)和短期的突发容量(burst)来解决这个问题。

1
2
3
4
5
6
7
8
9
10
11
-- core/limit/tokenscript.lua(核心逻辑节选)
local delta = math.max(0, now - last_refreshed)
local filled_tokens = math.min(capacity, last_tokens + (delta * rate))
local allowed = filled_tokens >= requested
local new_tokens = filled_tokens
if allowed then
new_tokens = filled_tokens - requested
end
redis.call("setex", KEYS[1], ttl, new_tokens)
redis.call("setex", KEYS[2], ttl, now)
return allowed

令牌桶的 Lua 脚本维护了两把 Redis key:tokenKey 存储当前令牌数,timestampKey 存储上次补充令牌的时间戳。每次请求时,先根据时间差补充令牌(不超过 bucket 容量 capacity),然后检查令牌是否充足,充足则扣减 1 个,不充足则返回 false。TTL 设为 2 * capacity / rate——足够两次从满到空的时长,保证 key 不会在空闲期泄漏。

TokenLimiter 最值得关注的设计不是令牌桶算法本身,而是它的降级策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// core/limit/tokenlimit.go
func (lim *TokenLimiter) reserveN(ctx context.Context, now time.Time, n int) bool {
if atomic.LoadUint32(&lim.redisAlive) == 0 {
return lim.rescueLimiter.AllowN(now, n)
}

resp, err := lim.store.ScriptRunCtx(ctx, tokenScript, ...)
if errors.Is(err, redis.Nil) {
return false
}
if err != nil {
lim.startMonitor()
return lim.rescueLimiter.AllowN(now, n)
}
// ...
}

当 Redis 连接失败(不是被拒绝,而是网络错误),TokenLimiter 并非束手无策——它立即启用本地的 xrate.Limitergolang.org/x/time/rate 的标准实现)作为降级限流器。同时启动一个后台 goroutine 以 100ms 周期 ping Redis,一旦 Redis 恢复,切换回分布式限流。

为什么不在降级后继续使用本地限流器?因为 TokenLimiter 的核心使用场景是分布式环境——同一个接口的限流需要所有节点统一配额(比如全站每日短信发送数不超过 10000)。本地限流器只能限制当前进程,分布式额度被切成了 N 份,不再精确。所以一旦 Redis 恢复就立即切换回去。

本地限并发:syncx.Limit

严格来说,syncx.Limit 是一个并发数门控,不是速率限制——它不关心时间,只关心"当前有多少个请求在执行"。它的实现完全不需要 Redis:

1
2
3
4
// core/syncx/limit.go
type Limit struct {
pool chan lang.PlaceholderType
}

用带缓冲的 channel 做信号量——TryBorrow() 非阻塞地向 channel 发送一个值,如果 channel 满了(有 N 个元素正在使用中),立即返回 false。这就是 REST 的 MaxConnsHandler 使用的机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// rest/handler/maxconnshandler.go
func MaxConnsHandler(n int) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
latch := syncx.NewLimit(n)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if latch.TryBorrow() {
defer func() {
if err := latch.Return(); err != nil {
logx.WithContext(r.Context()).Error(err)
}
}()
next.ServeHTTP(w, r)
} else {
w.WriteHeader(http.StatusServiceUnavailable)
}
})
}
}

与降载不同,MaxConns 不判断 CPU,只判断"同时打开的 slot 是否够用"。它更适合防御的场景是:服务配置了 1000 的超时,但在 T=999ms 时所有请求都还没完成,第 1001 个连接请求到达时 TryBorrow 返回 false,快速返回 503。

三层保护在请求链中的位置与分工

到现在,我们理解了三种保护的独立原理。但它们不是孤立的——它们在一个请求的处理链中按特定的顺序组合在一起。

REST 中间件链的顺序(由外到内)

回到第 07 篇讨论过的中间件链,这里聚焦三个保护相关中间件的位置:

1
Trace → Log → Prometheus → MaxConns → Breaker → Shedding → Timeout → ...

注意:MaxConns 在最外,Breaker 在中间,Shedding 在内层。 这不仅仅是一个感觉上的排序,关键是:

  • MaxConns 放最外:它的判断零依赖且成本极低——只需看一眼 channel 是否满。让它在最外层直接拦住新连接,保护后面所有的 CPU 开销(包括 Trace 打点、Log 写入、Prometheus 统计)。
  • Breaker 在 Shedding 外面:当下游已明确故障(连续返回 500)时,breaker 概率性拒绝,被拒绝的请求不再进入 Shedding 检测。这避免了这样一种情况——下游挂了,对 CPU 还没到 90%,Shedding 不触发,所有请求都去调用下游然后等超时。
  • Shedding 在 Breaker 里面:当 CPU 过载时,Shedding 直接拒绝请求——哪怕下游完全正常。这时候 breaker 还处于关闭状态(没有错误),但因为系统 CPU 撑不住了,必须优先保护当前服务。同样重要的是,Shedding 在 Timeout 外面——过载时拒绝请求只需微秒级,但等待超时需要秒级,把 Shedding 放在 Timeout 外面意味着"不等超时就知道该拒绝"。

RPC 服务端拦截器链

在 zRPC 的服务端,保护机制以拦截器的方式存在。核心函数 setupUnaryInterceptors 展示了它们的加载顺序(由于 gRPC 的拦截器是 prepend 机制,先添加的在最外层):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// zrpc/server.go
func setupUnaryInterceptors(svr internal.Server, c RpcServerConf, metrics *stat.Metrics) {
// ... Trace, Recover, Stat, Prometheus ...
if c.Middlewares.Breaker {
svr.AddUnaryInterceptors(serverinterceptors.UnaryBreakerInterceptor)
}
if c.CpuThreshold > 0 {
shedder := load.NewAdaptiveShedder(load.WithCpuThreshold(c.CpuThreshold))
svr.AddUnaryInterceptors(serverinterceptors.UnarySheddingInterceptor(shedder, metrics))
}
if c.Timeout > 0 {
svr.AddUnaryInterceptors(serverinterceptors.UnaryTimeoutInterceptor(...))
}
}

执行顺序(外到内):Trace → Recover → Stat → Prometheus → Breaker → Shedding → Timeout → [Auth] → Handler

这与 REST 的顺序一致——Breaker 在 Shedding 外面,Shedding 在 Timeout 外面。值得注意的一点是:这里没有 MaxConns,这是因为 gRPC 自身通过 MaxConcurrentStreams 服务器选项提供了等价的连接并发控制。

三种保护的协同与边界

三者之间的关系不是层叠关系,而是三个独立的防护面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
                   请求流

┌──────────────┼──────────────┐
▼ ▼ ▼
MaxConns Breaker PeriodLimit
(连接并发) (错误率) (调用方配额)
│ │ │
└──────────────┼──────────────┘

Shedding
(CPU压力)


Timeout


Handler
  • MaxConns 关心的是"同时来了多少个",不看内容不看结果,纯粹的门控。
  • Breaker 关心的是"下游多少调用失败了",依赖历史数据做概率性决策。
  • PeriodLimit / TokenLimiter 关心的是"某个 key 在窗口内的调用次数",依赖 Redis 做分布式配额。
  • Shedding 关心的是"系统现在忙不忙",依赖 CPU 指标和响应时间统计。

按业务场景选择:

  • 需要保护下游 → 用 Breaker
  • 需要保护自身 CPU → 用 Shedding
  • 需要限制调用方配额 → 用 PeriodLimitTokenLimiter
  • 需要限制连接并发 → 用 MaxConns / syncx.Limit

总结

本文从三种弹性保护的"分工"出发,逐一分析了它们的实现原理和协同关系。

熔断器的精髓在于"概率式拒绝"。 不是全开全关,而是根据 10 秒滑动窗口的历史数据动态计算丢弃率。dropRatio 的公式融合了失败 bucket 的权重衰减、成功 bucket 的恢复修正和每秒一次的强制放行——让系统在故障时有节奏地试探恢复,而不是盲目地"等一等再说"。

降载器的思路从 TCP BBR 借来了"测量-决策"模式。 它持续测量系统的最大通过率和最小响应时间,通过 Little 定律推算出系统的最优同时在途数。然后根据当前 CPU 使用率,线性缩减这个上限——CPU 越高,越紧缩。EMA 的不对称更新(只在请求完成时更新)让它在进攻时乐观、撤退时保守。

限流器覆盖了剩下的配额管理。 Redis 固定窗口简单可靠,令牌桶平滑了临界突发,本地 syncx.Limit 作为轻量级的并发门控随时可用。TokenLimiter 的 Redis 故障自动降级为本地限流,展示了设计中对"尽量不停止服务"的坚定立场的坚持。

三种保护在 REST/RPC 请求链中从外到内依次排布——MaxConns 最外(零开销门控),Breaker 中(依赖错误信号),Shedding 在 Breaker 里面但在 Timeout 外面(不等超时就拒绝过载请求)。三者不是"任选其一",而是三个独立的防护面——彼此互不知晓,各负责各的守护边界,但组合在一起构成了 go-zero 请求链上最坚固的一道防线。

在下一篇中,我们将从"怎么在代码中保护自己"转到"怎么看清楚系统的每个角落"——追踪一次请求如何在日志、链路和指标中留下能相互定位的证据,学习 go-zero 的可观测性体系。