《Go 语言编程实战》9.2 限流、熔断与隔离

给 TaskHub 装三道防线:用 golang.org/x/time/rate 的令牌桶做限流并实测突发与稳态速率、用三态熔断器在下游持续失败时快速失败、用 semaphore 做隔离舱把慢依赖关进独立预算,讲清三者各自防的是什么故障以及请求路径上的配合方式。

本节把 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 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练