共享池是故障传播的捷径
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 | 速率配额 | 依据 |
|---|---|---|---|---|---|
| 库存 | 40ms | 12 | 18 | 400/s | 预留接口,容量约为下单 QPS 的 1.3 倍 |
| 支付 | 120ms | 36 | 54 | 150/s | 受支付通道合同限制,低于下单 QPS |
| 通知 | 10ms | 3 | 5 | 200/s | 异步发送,允许积压后补 |
支付配额 150/s 小于下单 300/s,意味着高峰期必须有一部分订单走异步支付或排队,而不是把 300 QPS 全压给通道。配额是业务承诺的体现,不是技术参数:它应该由支付合同、下游容量和降级策略共同决定。MaxQueue 用等待时间上限反推:队列长度 ≈ 目标 QPS × 可接受排队时间。300 QPS 下允许排队 50ms,队列约 15;再大只会让请求在超时前多等一会儿,最终仍被上游取消。WaitTimeout 应小于上游剩余预算,否则请求会在舱壁里待到上游已经放弃。
拒绝时的错误码与用户体验
两种拒绝必须用不同错误码,客户端才能采取不同动作:
| 场景 | 错误码 | HTTP | 客户端动作 |
|---|---|---|---|
| 舱壁队列满/等待超时 | OVERLOADED | 503 + Retry-After: 1 | 指数退避重试,保持幂等键 |
| 速率配额超限 | RATE_LIMITED | 429 + Retry-After | 降低发送速率,不要立即重试 |
| 上游预算耗尽 | TIMEOUT | 504 | 查询订单状态,不要盲目重下 |
对用户而言,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 慢调用与故障扩散 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。