《后端架构设计·高性能与高可用》5.2 舱壁隔离与资源配额

在 arch-lab 订单服务上给库存、支付、通知三个下游分别实现信号量舱壁与令牌桶配额,用真实测试证明支付被拖慢时库存与通知仍在 18ms 内完成,并给出按 Little 定律计算并发配额、OVERLOADED/RATE_LIMITED 错误码与舱壁过小的退出条件。

共享池是故障传播的捷径

5.1 的有界工作池给整个进程一个队列和一组 worker。它解决的是“总并发有上限”,没有解决“哪个下游配得上多少并发”。arch-lab 的下单链路同时调用库存、支付、通知:支付网关抖动到 200ms 时,如果三者共用一个 16 个 worker 的池,16 个 worker 会全部卡在支付上,库存和通知即使只要 2ms 也拿不到执行机会。这就是共享池把单下游故障放大成全链路故障的过程。

舱壁(bulkhead)把资源按下游切开:每个下游一个信号量、一个有界等待队列、一份速率配额。任何一个舱壁被占满,只拒绝该下游的调用,其他下游继续工作。本站 高可用模式 讨论过故障隔离的总体原则,本节只把它落成可运行代码,并量化“隔离前健康调用被饿死、隔离后仍能完成”的差别。

三种限制保护三件不同的事

Bulkhead 同时维护三个上限,它们在故障中的表现完全不同:

限制保护对象满了以后适用信号
MaxConcurrent下游连接数、线程数排队或拒绝下游 P99、连接池占用
MaxQueue本进程内存与等待时间立即拒绝排队长度、排队时长
QPS/Burst下游每秒请求预算返回配额错误下游配额、合同上限

并发限制回答“同时最多几个”,速率限制回答“每秒最多几个”。只做并发限制,短时间的高 QPS 仍会在队列里堆出很长等待;只做速率限制,慢下游会让在途请求越积越多。两者必须同时存在,且拒绝原因要分开记录,否则无法判断该扩容还是该降速。

Do 的顺序是配额 → 信号量 → 队列 → 执行。配额在准入时扣减,即使随后排队超时也不退还;这是有意的:配额统计的是“下游被请求了多少次”,不是“成功了多少次”,否则重试会在配额之外放大流量。

// file: internal/platform/bulkhead/bulkhead.go
// tier: L1
// verify: go test ./internal/platform/bulkhead/
package bulkhead

import (
	"context"
	"errors"
	"sync"
	"sync/atomic"
	"time"
)

var (
	ErrOverloaded = errors.New("bulkhead: queue full or wait timeout")
	ErrQuota      = errors.New("bulkhead: quota exceeded")
)

type Config struct {
	Name          string
	MaxConcurrent int
	MaxQueue      int
	WaitTimeout   time.Duration
	QPS           int // tokens per second; 0 means unlimited
	Burst         int
}

type Stats struct {
	InFlight, Queued, Completed, Rejected, QuotaRejected, MaxInFlight int64
}

// Bulkhead is one downstream's admission boundary: a semaphore caps concurrent
// calls, a bounded queue caps waiters, and an optional token bucket caps rate.
// The three limits protect different things and must not be collapsed into one.
type Bulkhead struct {
	cfg         Config
	sem         chan struct{}
	mu          sync.Mutex
	tokens      float64
	last        time.Time
	queued      atomic.Int64
	inFlight    atomic.Int64
	maxInFlight atomic.Int64
	completed   atomic.Int64
	rejected    atomic.Int64
	quotaDenied atomic.Int64
}

func New(cfg Config) *Bulkhead {
	if cfg.MaxConcurrent < 1 {
		cfg.MaxConcurrent = 1
	}
	if cfg.Burst < 1 {
		cfg.Burst = cfg.MaxConcurrent
	}
	return &Bulkhead{
		cfg: cfg, sem: make(chan struct{}, cfg.MaxConcurrent),
		tokens: float64(cfg.Burst), last: time.Now(),
	}
}

// Do admits one call. Quota is charged at admission; concurrency and queue
// bounds are enforced after it, so a rejected call has already spent its token.
func (b *Bulkhead) Do(ctx context.Context, fn func(context.Context) error) error {
	if !b.allowQuota() {
		b.quotaDenied.Add(1)
		return ErrQuota
	}
	select {
	case b.sem <- struct{}{}:
	default:
		if q := b.queued.Add(1); q > int64(b.cfg.MaxQueue) {
			b.queued.Add(-1)
			b.rejected.Add(1)
			return ErrOverloaded
		}
		timer := time.NewTimer(b.cfg.WaitTimeout)
		defer timer.Stop()
		select {
		case b.sem <- struct{}{}:
			b.queued.Add(-1)
		case <-timer.C:
			b.queued.Add(-1)
			b.rejected.Add(1)
			return ErrOverloaded
		case <-ctx.Done():
			b.queued.Add(-1)
			b.rejected.Add(1)
			return ctx.Err()
		}
	}
	bump(&b.maxInFlight, b.inFlight.Add(1))
	defer func() {
		b.inFlight.Add(-1)
		<-b.sem
		b.completed.Add(1)
	}()
	return fn(ctx)
}

func (b *Bulkhead) allowQuota() bool {
	if b.cfg.QPS <= 0 {
		return true
	}
	b.mu.Lock()
	defer b.mu.Unlock()
	now := time.Now()
	b.tokens += now.Sub(b.last).Seconds() * float64(b.cfg.QPS)
	if max := float64(b.cfg.Burst); b.tokens > max {
		b.tokens = max
	}
	b.last = now
	if b.tokens < 1 {
		return false
	}
	b.tokens--
	return true
}

func (b *Bulkhead) Stats() Stats {
	return Stats{
		InFlight: b.inFlight.Load(), Queued: b.queued.Load(),
		Completed: b.completed.Load(), Rejected: b.rejected.Load(),
		QuotaRejected: b.quotaDenied.Load(), MaxInFlight: b.maxInFlight.Load(),
	}
}

func bump(dst *atomic.Int64, v int64) {
	for cur := dst.Load(); v > cur; cur = dst.Load() {
		if dst.CompareAndSwap(cur, v) {
			return
		}
	}
}

用测试证明隔离,而不是画一张架构图

第一组测试让 60 个支付调用(每个 25ms、并发 2)先跑起来,5ms 后再发 60 个库存调用(2ms、并发 8)和 60 个通知调用(1ms、并发 8)。三个下游各有自己的舱壁,因此库存与通知不必等支付;测试同时检查支付在健康调用完成时仍未结束,排除“支付已经跑完所以看起来很快”的假象。

对照组把一个只有 2 个许可的共享舱壁交给 10 个慢调用,再并发发 10 个 1ms 的健康调用,等待窗口 50ms。健康调用全部被慢调用挡住并拒绝。

// file: internal/platform/bulkhead/bulkhead_test.go
// tier: L1
// verify: go test ./internal/platform/bulkhead/ -count=1 -v
package bulkhead

import (
	"context"
	"errors"
	"sync"
	"sync/atomic"
	"testing"
	"time"
)

func call(ctx context.Context, b *Bulkhead, work time.Duration) error {
	return b.Do(ctx, func(context.Context) error {
		time.Sleep(work)
		return nil
	})
}

func TestIsolationKeepsHealthyDownstreamsMoving(t *testing.T) {
	ctx := context.Background()
	payment := New(Config{Name: "payment", MaxConcurrent: 2, MaxQueue: 1000, WaitTimeout: 2 * time.Second})
	inventory := New(Config{Name: "inventory", MaxConcurrent: 8, MaxQueue: 1000, WaitTimeout: 2 * time.Second})
	notify := New(Config{Name: "notify", MaxConcurrent: 8, MaxQueue: 1000, WaitTimeout: 2 * time.Second})

	var payWG, fastWG sync.WaitGroup
	payBegin := time.Now()
	for i := 0; i < 60; i++ {
		payWG.Add(1)
		go func() { defer payWG.Done(); _ = call(ctx, payment, 25*time.Millisecond) }()
	}
	time.Sleep(5 * time.Millisecond)

	invBegin := time.Now()
	for i := 0; i < 60; i++ {
		fastWG.Add(1)
		go func() { defer fastWG.Done(); _ = call(ctx, inventory, 2*time.Millisecond) }()
	}
	for i := 0; i < 60; i++ {
		fastWG.Add(1)
		go func() { defer fastWG.Done(); _ = call(ctx, notify, time.Millisecond) }()
	}
	fastWG.Wait()
	fastElapsed := time.Since(invBegin)
	paymentStillRunning := time.Since(payBegin) < 500*time.Millisecond
	payWG.Wait()

	if !paymentStillRunning {
		t.Fatalf("payment finished too early; test did not overlap")
	}
	if fastElapsed > 300*time.Millisecond {
		t.Fatalf("healthy downstreams took %s", fastElapsed)
	}
	t.Logf("payment_latency=25ms payment_calls=60 fast_calls=120 fast_elapsed=%s payment_still_running=%v",
		fastElapsed.Round(time.Millisecond), paymentStillRunning)

	shared := New(Config{Name: "shared", MaxConcurrent: 2, MaxQueue: 1000, WaitTimeout: 50 * time.Millisecond})
	var slowWG sync.WaitGroup
	for i := 0; i < 10; i++ {
		slowWG.Add(1)
		go func() { defer slowWG.Done(); _ = call(ctx, shared, 100*time.Millisecond) }()
	}
	time.Sleep(5 * time.Millisecond)
	var rejected atomic.Int64
	var healthy sync.WaitGroup
	for i := 0; i < 10; i++ {
		healthy.Add(1)
		go func() {
			defer healthy.Done()
			if err := call(ctx, shared, time.Millisecond); errors.Is(err, ErrOverloaded) {
				rejected.Add(1)
			}
		}()
	}
	healthy.Wait()
	slowWG.Wait()
	if rejected.Load() == 0 {
		t.Fatalf("shared bulkhead did not starve healthy calls")
	}
	t.Logf("shared_bulkhead_healthy_rejected=%d", rejected.Load())
}

func TestQueueBoundAndWaitTimeout(t *testing.T) {
	ctx := context.Background()
	b := New(Config{MaxConcurrent: 1, MaxQueue: 1, WaitTimeout: 20 * time.Millisecond})
	started := make(chan struct{})
	go func() {
		_ = b.Do(ctx, func(context.Context) error { close(started); time.Sleep(100 * time.Millisecond); return nil })
	}()
	<-started
	var wg sync.WaitGroup
	for i := 0; i < 2; i++ {
		wg.Add(1)
		go func() { defer wg.Done(); _ = call(ctx, b, time.Millisecond) }()
	}
	wg.Wait()
	if s := b.Stats(); s.Rejected != 2 {
		t.Fatalf("rejected = %d, want 2 (one queue-full, one wait-timeout)", s.Rejected)
	}
	t.Logf("queue_full_or_timeout_rejected=2")
}

func TestQuota(t *testing.T) {
	ctx := context.Background()
	b := New(Config{MaxConcurrent: 4, MaxQueue: 100, QPS: 10, Burst: 10})
	ok, denied := 0, 0
	for i := 0; i < 50; i++ {
		if err := call(ctx, b, 0); errors.Is(err, ErrQuota) {
			denied++
		} else {
			ok++
		}
	}
	if ok != 10 || denied != 40 {
		t.Fatalf("ok=%d denied=%d, want 10/40", ok, denied)
	}
	t.Logf("quota_qps=10 burst=10 allowed=%d quota_rejected=%d", ok, denied)
}

func TestConcurrencySweep(t *testing.T) {
	const calls = 40
	const latency = 15 * time.Millisecond
	for _, limit := range []int{1, 2, 4, 8} {
		b := New(Config{MaxConcurrent: limit, MaxQueue: calls, WaitTimeout: 2 * time.Second})
		var wg sync.WaitGroup
		begin := time.Now()
		for i := 0; i < calls; i++ {
			wg.Add(1)
			go func() { defer wg.Done(); _ = call(context.Background(), b, latency) }()
		}
		wg.Wait()
		elapsed := time.Since(begin)
		s := b.Stats()
		t.Logf("max_concurrent=%d calls=%d elapsed=%s throughput=%.0f/s max_in_flight=%d rejected=%d",
			limit, calls, elapsed.Round(time.Millisecond), float64(calls)/elapsed.Seconds(),
			s.MaxInFlight, s.Rejected)
		if s.Completed != calls || s.Rejected != 0 {
			t.Fatalf("completed=%d rejected=%d", s.Completed, s.Rejected)
		}
	}
}

真实运行在 macOS darwin/arm64、Go 1.26.0 上完成,单次运行,原始输出在 .local/reports/be-arch/be-arch-perf-05-2-bulkhead-tests.txt:

$ go test ./internal/platform/bulkhead/ -count=1 -v
payment_latency=25ms payment_calls=60 fast_calls=120 fast_elapsed=18ms payment_still_running=true
shared_bulkhead_healthy_rejected=10
queue_full_or_timeout_rejected=2
quota_qps=10 burst=10 allowed=10 quota_rejected=40
max_concurrent=1 calls=40 elapsed=658ms throughput=61/s  max_in_flight=1 rejected=0
max_concurrent=2 calls=40 elapsed=326ms throughput=123/s max_in_flight=2 rejected=0
max_concurrent=4 calls=40 elapsed=167ms throughput=240/s max_in_flight=4 rejected=0
max_concurrent=8 calls=40 elapsed=82ms  throughput=490/s max_in_flight=8 rejected=0

隔离组里,支付还要 700ms 左右才结束,库存与通知的 120 个调用在 18ms 内全部完成;共享组里,10 个健康调用一个都没完成。这不是“舱壁让调用变快”,而是舱壁阻止了健康调用替慢下游排队。

配额怎么分:从 Little 定律倒推

并发配额不是拍脑袋的“给 50 个”。用 L = λ × W:λ 是目标 QPS,W 是该下游的 P99 延迟,L 是在途请求数,再乘 1.5 的安全系数覆盖抖动。arch-lab 目标 300 QPS:

下游P99λ×W安全系数 1.5速率配额依据
库存40ms1218400/s预留接口,容量约为下单 QPS 的 1.3 倍
支付120ms3654150/s受支付通道合同限制,低于下单 QPS
通知10ms35200/s异步发送,允许积压后补

支付配额 150/s 小于下单 300/s,意味着高峰期必须有一部分订单走异步支付或排队,而不是把 300 QPS 全压给通道。配额是业务承诺的体现,不是技术参数:它应该由支付合同、下游容量和降级策略共同决定。MaxQueue 用等待时间上限反推:队列长度 ≈ 目标 QPS × 可接受排队时间。300 QPS 下允许排队 50ms,队列约 15;再大只会让请求在超时前多等一会儿,最终仍被上游取消。WaitTimeout 应小于上游剩余预算,否则请求会在舱壁里待到上游已经放弃。

拒绝时的错误码与用户体验

两种拒绝必须用不同错误码,客户端才能采取不同动作:

场景错误码HTTP客户端动作
舱壁队列满/等待超时OVERLOADED503 + Retry-After: 1指数退避重试,保持幂等键
速率配额超限RATE_LIMITED429 + Retry-After降低发送速率,不要立即重试
上游预算耗尽TIMEOUT504查询订单状态,不要盲目重下

对用户而言,OVERLOADED 不是“下单失败”,而是“暂时没排上”。arch-lab 的处理是:订单保持 CREATED,返回可重试提示与建议等待秒数;客户端用同一个幂等键重试,服务端不会创建第二笔订单。绝不能把舱壁拒绝包装成 PAYMENT_FAILED,否则用户会以为卡被扣了。

舱壁太小:吞吐下降的退出条件

并发上限低于 λ × W 时,吞吐由舱壁而不是下游决定。上面的对照里,40 个 15ms 调用在并发 1/2/4/8 下吞吐为 61/123/240/490 每秒,基本随并发线性上升;按 L = λ × W,490/s × 15ms ≈ 7.35,8 个许可刚好覆盖,仍未饱和。如果把支付舱壁设成 4,吞吐会停在 240/s 左右,只有目标的 80%。

出现以下任一情况,说明舱壁太小,应上调或改为异步:

  • 正常峰值下 Rejected / Completed > 1%,且下游 P99 没有恶化;
  • 排队时间 P99 超过下游 P99 的 50%,说明大部分时间花在等许可;
  • 把 MaxConcurrent 提高一档后吞吐上升超过 20%,说明之前被舱壁卡住;
  • 下游连接池使用率低于 60%,而舱壁拒绝率高于 1%,说明本进程限额比下游容量更紧。

反过来,如果提高并发后下游 P99 明显上升、错误率上升,说明舱壁已经不小,瓶颈在下游,应停止扩容并考虑降级。

常见错误与边界

第一,三个下游共用一个信号量。这正是舱壁要解决的问题;共享信号量会把慢下游的排队传染给所有调用。

第二,只限并发不限队列。信号量满了以后调用方无限等待,故障时表现为大量 goroutine 堆积,而不是明确的拒绝。

第三,把配额当限流。舱壁配额是“这个下游允许多少”,全局限流是“整个服务允许多少”;两者作用域不同,不能互相替代。

第四,拒绝码混用。OVERLOADED、RATE_LIMITED、TIMEOUT 对应不同的客户端动作,混成一个 500 会让客户端重试放大故障。

第五,舱壁只按实例配置,不按下游容量配置。10 个实例各给支付 54 个并发,合起来 540 个并发,按 120ms 折算约 4500/s,远超通道能承受的 150/s;配额必须按集群总量分摊到实例。

小结

本节把隔离从架构图落成三个独立舱壁:库存、支付、通知各有自己的信号量、等待队列和令牌桶。真实测试里,支付被拖慢时 120 个健康调用仍在 18ms 内完成,而共享舱壁下 10 个健康调用全部被拒绝;配额按 Little 定律从 300 QPS 和各自 P99 倒推,支付 54 并发、150/s,库存 18 并发、400/s。拒绝用 OVERLOADED 与 RATE_LIMITED 区分,并给出舱壁过小的四条退出条件。下一节讨论慢调用如何从单个下游扩散到整个调用链,以及 deadline 与取消如何截断这条链路。

阅读导航:上一节:5.1 连接池、线程池与协程池 · 下一节:5.3 慢调用与故障扩散 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查