在上一篇文章的最后,我们走完了 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 | // core/breaker/googlebreaker.go |
理解这个算法需要先理清几个概念:
滑动窗口与 bucket
断路器内部使用了一个 40 个 bucket 的滑动窗口,每个 bucket 覆盖 250ms,总窗口为 10 秒。每个 bucket 记录四种计数:Sum(总请求数)、Success(成功)、Failure(失败)、Drop(被丢弃)。
每次有请求被接受、执行并返回结果后,调用方通过 Promise.Accept() 或 Promise.Reject(reason) 告诉断路器本次调用的结果,断路器据此在各 bucket 中增加相应的计数。
history():四个关键指标
1 | func (b *googleBreaker) history() windowResult { |
history() 遍历所有 40 个 bucket,计算四个值:
accepts:所有 bucket 中的成功总数。total:所有 bucket 中的请求总数(成功 + 失败 + 丢弃)。failingBuckets:从窗口尾部向前数,连续出现失败(且没有成功)的 bucket 数量。注意计数规则:遇到有成功的 bucket 时计数器归零,遇到有失败无成功的 bucket 时计数器加一。所以failingBuckets反映的是"距离最近一次成功有多久了"。workingBuckets:对称的概念——从窗口尾部向前数,连续出现成功(且没有失败)的 bucket 数量。
这四个值的组合用处很大。failingBuckets 和 workingBuckets 不是简单地在统计失败次数——它们统计的是连续趋势。10 秒前有过一次失败(几乎不影响当前健康度)和最近 1 秒内全是失败(系统出了大问题),breaker 的反应应该完全不同。failingBuckets 的连续计数机制天然区分了这两种情形:前者对应的 failingBuckets 几乎为 0,后者接近 40。
丢弃率的计算
回到 accept() 的执行过程:
第一步:计算权重因子。 w = k - (k - minK) * failingBuckets / buckets。当没有失败 bucket 时,w = k = 1.5;当所有 bucket 都在失败时,w = minK = 1.1。w 在 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:对外暴露的入口。包装了name和loggedThrottle,实现了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 | // core/breaker/breakers.go |
使用 double-check locking 模式保证并发安全。所有全局级别的 Do()、DoWithAcceptable() 等函数都是先拿到 named breaker,再调用它的方法。REST 中每个路由用 "GET://user/info" 作为 breaker 名,RPC 中用 "/package.Service/Method" 全限定方法名——同一个接口在同一个进程中共享一个断路器实例。
REST 与 RPC 中的集成
在 REST 中,BreakerHandler 按路由为单位创建断路器:
1 | // rest/handler/breakerhandler.go |
断路器根据 HTTP 状态码判定成功/失败——小于 500 算成功,大于等于 500 算失败。这和 gRPC 中的判断逻辑稍有不同。在 gRPC 服务端,断路器通过 serverSideAcceptable 判断错误是否可接受:
1 | // zrpc/internal/serverinterceptors/breakerinterceptor.go |
DeadlineExceeded 和 ErrServiceUnavailable 被明确标记为不可接受——前者说明下游超时了,后者说明被下游的断路器拒绝了,这两种情况都应该计入失败。
降载: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 | // core/load/adaptiveshedder.go |
maxFlight 的计算来自于 Little 定律 L = λW——系统的平均在途数等于到达率乘以平均驻留时间。在这里,λ 是 maxPass(每 bucket 的最大通过数),W 是 minRt(最小响应时间),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 | func (as *adaptiveShedder) Allow() (Promise, error) { |
第一重门:系统是否过载?是否还在"余热"中?
1 | func (as *adaptiveShedder) shouldDrop() bool { |
当 CPU 使用率 ≥ 阈值(默认 900 millicpu,即 90%)时,systemOverloaded() 返回 true。stillHot() 检查最近 1 秒内是否发生过丢弃——如果是,即便 CPU 暂时降低,它仍然返回 true。这个 1 秒的 coolOffDuration 起到了滞回作用,防止 CPU 短暂降到 89% 后又立即飙回 91% 带来的反复振荡。
如果 CPU 没有达到阈值且没有余热期,请求直接放行——不经过任何计算,不读任何计数器。这是一种精心设计的优化:正常状态下零开销——大多数时候系统 CPU 是正常的,不应该为"可能过载"而一直消耗额外的 CPU。
第二重门:当前吞吐量是否过高?
只有当第一重门满足时(系统过载或仍在余热),才会进入第二重门——highThru():
1 | func (as *adaptiveShedder) highThru() bool { |
maxFlight(历史的"最佳同时在途数")乘以 overloadFactor(当前 CPU 下的容量倍数)得到当前允许的在途数上限。同时检查两个值:EMA 平滑的平均在途数(avgFlying)和瞬时在途数(flying),两者都必须超过上限才算"高吞吐"。为什么要两个?光有瞬时高不代表持续高——可能只是一个突发尖刺,很快就自我修正了。光有平均高不代表现在还高——如果 in-flight 已经降下来但平均值因为 EMA 惯性仍然偏高,不该再拒绝请求。双重条件下,只有在"持续且当前都高"时才拒绝,策略更精确。
overloadFactor:CPU 越高,越"吝啬"
1 | func (as *adaptiveShedder) overloadFactor() float64 { |
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,那就是全盘拒绝——没有请求通过,也没有响应时间数据返回,minRt 和 maxPass 都冻结在窗口内,任何恢复都是不可能的。保持 10% 的通过率保证了系统始终有新鲜数据更新,从而能判断何时应该恢复正常。
avgFlying 的 EMA 不对称更新
1 | func (as *adaptiveShedder) addFlying(delta int64) { |
EMA 的更新只发生在 delta < 0 时——即请求完成时。这意味着:
- 当 in-flight 快速上升时(请求涌入),
avgFlying保持不变,滞后于实际值,允许更多请求被接受——系统乐观地认为"新请求的响应应该也会很快"。 - 当 in-flight 快速下降时(请求完成),
avgFlying也开始下降,但仍然滞后——此时降载器相对保守,“不要急着全放,看看情况再说”。
这是一个 pragmatically smart 的工程决策:进攻要快,撤退要慢。 系统应该尽快接纳请求(增加吞吐),但释放容量时要观察一阵(防止冲击)。
Promise:成功/失败的语义
与断路器的 Accept() vs Reject() 不同,降载器的 Promise 使用的是 Pass() vs Fail():
1 | func (p *promise) Pass() { |
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 | -- core/limit/periodscript.lua |
所有逻辑在 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 | -- core/limit/tokenscript.lua(核心逻辑节选) |
令牌桶的 Lua 脚本维护了两把 Redis key:tokenKey 存储当前令牌数,timestampKey 存储上次补充令牌的时间戳。每次请求时,先根据时间差补充令牌(不超过 bucket 容量 capacity),然后检查令牌是否充足,充足则扣减 1 个,不充足则返回 false。TTL 设为 2 * capacity / rate——足够两次从满到空的时长,保证 key 不会在空闲期泄漏。
但TokenLimiter 最值得关注的设计不是令牌桶算法本身,而是它的降级策略。
1 | // core/limit/tokenlimit.go |
当 Redis 连接失败(不是被拒绝,而是网络错误),TokenLimiter 并非束手无策——它立即启用本地的 xrate.Limiter(golang.org/x/time/rate 的标准实现)作为降级限流器。同时启动一个后台 goroutine 以 100ms 周期 ping Redis,一旦 Redis 恢复,切换回分布式限流。
为什么不在降级后继续使用本地限流器?因为 TokenLimiter 的核心使用场景是分布式环境——同一个接口的限流需要所有节点统一配额(比如全站每日短信发送数不超过 10000)。本地限流器只能限制当前进程,分布式额度被切成了 N 份,不再精确。所以一旦 Redis 恢复就立即切换回去。
本地限并发:syncx.Limit
严格来说,syncx.Limit 是一个并发数门控,不是速率限制——它不关心时间,只关心"当前有多少个请求在执行"。它的实现完全不需要 Redis:
1 | // core/syncx/limit.go |
用带缓冲的 channel 做信号量——TryBorrow() 非阻塞地向 channel 发送一个值,如果 channel 满了(有 N 个元素正在使用中),立即返回 false。这就是 REST 的 MaxConnsHandler 使用的机制:
1 | // rest/handler/maxconnshandler.go |
与降载不同,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 | // zrpc/server.go |
执行顺序(外到内):Trace → Recover → Stat → Prometheus → Breaker → Shedding → Timeout → [Auth] → Handler。
这与 REST 的顺序一致——Breaker 在 Shedding 外面,Shedding 在 Timeout 外面。值得注意的一点是:这里没有 MaxConns,这是因为 gRPC 自身通过 MaxConcurrentStreams 服务器选项提供了等价的连接并发控制。
三种保护的协同与边界
三者之间的关系不是层叠关系,而是三个独立的防护面:
1 | 请求流 |
- MaxConns 关心的是"同时来了多少个",不看内容不看结果,纯粹的门控。
- Breaker 关心的是"下游多少调用失败了",依赖历史数据做概率性决策。
- PeriodLimit / TokenLimiter 关心的是"某个 key 在窗口内的调用次数",依赖 Redis 做分布式配额。
- Shedding 关心的是"系统现在忙不忙",依赖 CPU 指标和响应时间统计。
按业务场景选择:
- 需要保护下游 → 用 Breaker
- 需要保护自身 CPU → 用 Shedding
- 需要限制调用方配额 → 用 PeriodLimit 或 TokenLimiter
- 需要限制连接并发 → 用 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 的可观测性体系。