本节把 TaskHub 推进到「过载不雪崩」:当上游突发流量、下游持续超时、某个依赖变慢时,系统要能优雅退化而不是连锁崩溃。限流、熔断、隔离是三道互补的防线,本节逐个落地并实测行为。
适用版本:Go 1.27(实测go1.27.0),golang.org/x/time/rate、golang.org/x/sync/semaphore。
9.2 限流、熔断与隔离
9.1 节让 TaskHub 能安全地并发,但并发本身会放大风险:突发流量可能压垮下游,一个慢依赖可能拖垮整个进程。分布式系统的经典教训是——局部故障会因为缺乏保护而蔓延成全局故障。本节给 TaskHub 装三道防线。
9.2.1 三道防线防的是不同的故障
先把三者分清,它们经常被混为一谈:
| 机制 | 防什么 | 触发条件 | 动作 |
|---|---|---|---|
| 限流(rate limit) | 流量超过容量 | 请求速率 > 阈值 | 拒绝/排队,保护自己 |
| 熔断(circuit breaker) | 依赖持续失败 | 失败率/连续失败 > 阈值 | 快速失败,保护调用方 |
| 隔离(bulkhead) | 一个依赖拖垮全局 | 某依赖并发/耗时异常 | 限制该依赖的资源预算 |
一句话:限流是「别进来太多」,熔断是「别再打它了」,隔离是「你坏了别连累我」。三者配合才能让系统在异常时保持大部分功能可用。
9.2.2 限流:令牌桶
golang.org/x/time/rate 实现的是令牌桶:桶按固定速率补充令牌,请求消耗令牌,桶空则等待或拒绝。它比「固定窗口计数」好,因为它允许一定程度的突发:
lim := rate.NewLimiter(rate.Limit(10), 5) // 10 令牌/秒,桶容量 5
if err := lim.Wait(ctx); err != nil { // 阻塞等待令牌
return err
}
rate.Limit(10) 是补充速率(每秒 10 个),第二个参数 5 是突发容量(桶最多攒 5 个令牌)。实测连续 20 个请求:
$ go run ./ch9/limit
第 5 个请求放行于 +0ms
第 10 个请求放行于 +501ms
第 20 个请求放行于 +1501ms
解读:前 5 个请求瞬间放行(吃掉桶里攒的 5 个令牌),第 6 个开始要等补充——每秒补 10 个,所以第 10 个在 501ms(补了约 5 个)、第 20 个在 1501ms(补了 15 个)。突发容量决定「能扛多大的瞬时尖峰」,速率决定「长期平均吞吐」,两个参数要分别设。
如果不想阻塞、只想「能过就过、不能过就拒」,用 Allow(非阻塞):
if !lim.Allow() {
return http.StatusTooManyRequests // 429
}
$ go run ./ch9/limit
TryAcquire: 2 令牌桶连续 5 次请求放行 2 次
桶容量 2、连续 5 次请求,只有 2 次放行。Allow 适合网关层——过载时快速返回 429,比让请求排队堆积更好。Wait 适合内部调用——排队等一等通常比失败更好。
9.2.3 限流的维度:全局还是每租户
TaskHub 是多租户的,限流维度直接决定公平性:
| 维度 | 作用 | 风险 |
|---|---|---|
| 全局 | 保护整体容量 | 一个租户能吃掉所有配额(吵邻居) |
| 每租户 | 保证公平,一个租户不影响别人 | 需要每租户一个 limiter,内存与管理成本 |
| 每接口 | 精细控制,热点接口单独限 | limiter 数量爆炸 |
推荐分层:全局 limiter 兜底保护,每租户 limiter 保证公平。每租户 limiter 用 map[tenantID]*rate.Limiter 缓存,配一个惰性清理避免 key 无限增长:
type TenantLimiter struct {
mu sync.Mutex
m map[int64]*rate.Limiter
r rate.Limit
b int
}
func (t *TenantLimiter) get(tenantID int64) *rate.Limiter {
t.mu.Lock()
defer t.mu.Unlock()
l, ok := t.m[tenantID]
if !ok {
l = rate.NewLimiter(t.r, t.b)
t.m[tenantID] = l
}
return l
}
注意这个 map 要配清理策略,否则活跃租户一多就是内存泄漏——和 7.1 节本地缓存的容量问题同源。
9.2.4 熔断:三态状态机
熔断器有三种状态,模拟电路保险丝:
- Closed(闭合):正常放行,统计失败。失败数超过阈值 → 跳到 Open。
- Open(断开):直接快速失败,不调用下游。冷却时间到 → 跳到 HalfOpen。
- HalfOpen(半开):放一个试探请求。成功 → 回到 Closed;失败 → 回到 Open。
用一个最小实现跑一遍完整状态迁移:
func (b *Breaker) Allow() bool {
switch b.state {
case Open:
if time.Since(b.openedAt) > b.openFor {
b.state = HalfOpen // 冷却结束,放试探
return true
}
return false // 快速失败
case HalfOpen:
return true
default: // Closed
return true
}
}
实测:连续 3 次失败后熔断,拦截后续请求;依赖恢复、冷却结束后试探成功,回到正常:
$ go run ./ch9/breaker
call 1 err=timeout state=Closed
call 2 err=timeout state=Closed
[状态] -> Open(failures=3)
call 3 err=timeout state=Open
call 4 被熔断拦截(快速失败)
call 5 被熔断拦截(快速失败)
--- 依赖恢复 ---
[状态] Open -> HalfOpen(冷却结束,放一个试探)
[状态] HalfOpen -> Closed(试探成功,恢复)
call 6 err=<nil> state=Closed
关键收益在 call 4、call 5:下游已经挂了,熔断器让请求瞬间失败,不再消耗连接和超时时间。如果没有熔断,每个请求都要等满超时,连接池被占满,本进程的资源被一个死掉的下游拖垮——这就是熔断要解决的问题。
9.2.5 熔断的参数怎么定
三个参数要权衡:
| 参数 | 含义 | 定法 |
|---|---|---|
| 失败阈值 | 连续失败几次(或失败率)触发 | 连续失败用 5~10 次;失败率用 50% + 最小样本数 |
| 冷却时间 | Open 停留多久后试探 | 略大于下游恢复时间,通常 5~30 秒 |
| 半开试探数 | HalfOpen 放几个请求 | 1 个最简单;要更平滑可放几个 |
用「失败率」而非「连续失败数」时,必须设最小样本数——否则请求量低时,2 个请求 1 个失败(50%)就熔断,误伤严重。成熟库(如 sony/gobreaker)都内置了这些参数,生产上优先用库而不是手写状态机。本节手写是为了讲清状态迁移,上面的熔断器是教学实现,未做并发压力下的正确性验证,生产请用成熟库。
9.2.6 隔离:把慢依赖关进独立预算
隔离舱(bulkhead,源自船体分舱)的思想是给每个下游分配独立的资源预算,一个下游耗尽自己的预算不影响别人。用 semaphore 限制对某依赖的并发,并在拿不到槽位时快速失败:
sem := semaphore.NewWeighted(3) // 该下游最多 3 个并发
actx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
if err := sem.Acquire(actx, 1); err != nil {
return ErrBulkheadFull // 池满,快速失败
}
defer sem.Release(1)
return callDownstream(actx)
实测 8 个请求同时打一个「最多 3 并发、每个 100ms」的慢依赖:
$ go run ./ch9/bulkhead
请求 4 被隔离舱拒绝(池满)
请求 1 被隔离舱拒绝(池满)
请求 2 被隔离舱拒绝(池满)
请求 3 被隔离舱拒绝(池满)
请求 6 被隔离舱拒绝(池满)
请求 5 完成
请求 8 完成
请求 7 完成
完成=3 拒绝=5
3 个请求进入、5 个在 50ms 内拿不到槽位被拒绝。代价是拒绝了 5 个请求,收益是进程没有被这个慢依赖拖垮——其余接口照常服务。这就是隔离的价值:用部分降级换取整体可用。
9.2.7 三者如何配合
TaskHub 的实际请求路径上,三道防线是叠加的:
入口 ──[全局限流]──[每租户限流]── 业务 ──[熔断器]──[隔离舱]── 下游依赖
- 请求进来先过限流,挡住超出容量的流量。
- 调下游前查熔断器,Open 状态直接快速失败,不打下游。
- 真正调用时过隔离舱,限制对单个下游的并发。
- 调用结果回写熔断器,累积失败计数。
配置上要分层降级:限流拒绝返回 429、熔断返回 503、隔离满返回 503 并带 Retry-After。让调用方知道「等一会儿再来」还是「这次别来了」。
三者还要统一观测。任何一个保护机制生效都意味着系统正在降级,必须暴露成指标(下面为示意,具体埋点在第 10 章展开):
// 示意:每个保护机制的触发都要计数或置位
counter("taskhub_rate_limited_total", tenant).Inc() // 被限流
gauge("taskhub_circuit_open", dep).Set(1) // 熔断器打开
counter("taskhub_bulkhead_rejected_total", dep).Inc() // 隔离舱拒绝
没有这些指标,你只会看到「错误率上升」,却不知道是限流、熔断还是隔离导致的——第 10 章的指标体系会把它们串起来。TaskHub 的约定是:保护机制触发必须可观测,且要有告警阈值,否则宁可先不加保护(至少故障是显式的),也不要加一个看不见的保护。
9.2.8 常见坑
- 限流只做全局:一个租户打满配额,其他租户全被限(吵邻居),要分维度。
- 桶容量设成 0:
rate.NewLimiter(r, 0)会拒绝一切请求,容量至少 1。 - 每租户 limiter 不清理:活跃租户越多内存越大,要配惰性淘汰。
- 熔断阈值只看失败率不看样本数:低流量下误熔断,加最小样本数。
- 冷却时间太短:下游还没恢复就频繁试探,反复 Open/HalfOpen 抖动。
- HalfOpen 放太多请求:试探变成一次小流量冲击,放 1 个最稳。
- 隔离舱拒绝不返回明确状态码:调用方不知道是过载还是 bug,应返回 503 +
Retry-After。 - 熔断器不用成熟库:并发下的状态机正确性很难手写对,生产用
sony/gobreaker等。 - 保护机制静默生效:限流/熔断/隔离都该打指标(第 10 章),否则你根本不知道系统正在降级。
小结
- 三道防线各司其职:限流防「进来太多」、熔断防「依赖持续失败」、隔离防「一个依赖拖垮全局」。
- 令牌桶限流由「速率 + 突发容量」两个参数定义,实测 10/s、容量 5 时前 5 个瞬时放行、第 20 个在 1.5 秒后。
- 限流要分维度:全局兜底 + 每租户公平,每租户 limiter 要配清理。
- 熔断三态(Closed/Open/HalfOpen)在依赖持续失败时快速失败,实测 3 次失败即熔断、冷却后试探恢复。
- 隔离舱用
semaphore给下游分配独立并发预算,拿不到槽位快速失败,用局部降级换整体可用。
并发有了保护,但并发还会泄漏资源。下一节用 goleak 把 goroutine 泄漏挡在测试里——让「忘了回收的 goroutine」在 CI 就暴露,而不是上线后慢慢吃光内存。
阅读导航:上一节:9.1 errgroup 与结构化并发 · 下一节:9.3 goroutine 泄漏与 goleak 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。