mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
3445 字
9 分钟
舱壁模式(Bulkhead)
2026-06-13

一、为什么需要舱壁模式#

想象一个典型的服务调用场景:你的服务同时调用三个下游服务——A 很快,平均 10ms;B 很慢,平均 500ms;C 也很快,平均 20ms。正常情况下一切正常,每个请求分配一个线程,很快处理完就归还线程池。

某天 B 开始出问题了。响应时间从 500ms 恶化到 5 秒,然后开始超时。调用 B 的请求都卡在那里等超时,线程一个接一个被占用,久久不归还。10 秒后,线程池里大部分线程都在等 B。20 秒后,线程池满了——所有线程都卡在 B 的调用上。

这时候 A 和 C 明明是好的,但新进来的请求连线程都拿不到了。请求 A 和 C 也开始失败,不是因为 A 或 C 挂了,而是因为根本没有线程可用。这就是资源耗尽导致的故障传染:一个慢依赖吃光了共享资源,把无辜的其他依赖一起拖下水。

这不是纸上谈兵。2013 年,Netflix 的 API 网关因为一个慢的 Hystrix command 耗尽了所有 Tomcat 线程,导致整个 API 不可用。问题出在一个下游服务的延迟飙升,调用它的线程越积越多,最终其他所有请求都拿不到线程。一个无关紧要的慢接口,搞垮了整个系统。

根本原因在于:所有下游调用共享同一个线程池,没有任何隔离。一个依赖出问题,就像一锅汤里掉进一只老鼠——整锅都废了。

二、现实类比#

船舱的隔舱壁(Bulkhead)。造船时,船体内部被分隔成多个水密舱室。如果船体某个位置被撞出破洞,海水涌入的只是那个舱室——关上水密门,船仍然可以浮在水面上继续航行。没有隔舱壁的话,一个破洞进来的水会蔓延到整个船体,船就沉了。

“舱壁”这个名字直接来自造船工程。船舶设计师在几百年的时间里验证了一个道理:隔离是控制损害最有效的手段。你不一定能阻止事故发生,但你可以阻止事故的扩散。软件系统也是一样——你无法保证下游服务永远不宕机,但你可以保证一个下游的宕机不会拖垮整个系统。

三、核心思想#

舱壁模式的核心就是资源隔离:给每个下游依赖分配独立的资源池,一个池子耗尽不影响其他池子。

flowchart TD Client[调用方] --> PoolA[线程池 A\n容量: 20] Client --> PoolB[线程池 B\n容量: 10] Client --> PoolC[线程池 C\n容量: 20] PoolA --> SvcA[服务 A] PoolB --> SvcB[服务 B] PoolC --> SvcC[服务 C] style PoolB fill:#ffcccc,stroke:#cc0000 style SvcB fill:#ffcccc,stroke:#cc0000

图中,服务 B 的线程池只有 10 个线程——即使全部被 B 的慢调用占满,最多也只消耗 10 个线程,服务 A 和服务 C 的 20 个线程完全不受影响。故障被限制在了 B 的舱室里,不会扩散。

两种常见的隔离策略:

  • 线程池隔离:每个下游依赖分配独立的线程池。对该依赖的调用必须通过线程池执行。池子满了,请求直接被拒绝,不会占用更多线程
  • 信号量隔离:每个下游依赖分配一个信号量(计数许可)。不创建独立的线程池,调用在调用方线程上执行,但并发数受信号量限制

关键数据结构:

属性说明
最大并发数整数线程池大小或信号量许可数
当前占用数整数已被占用的线程或许可数
等待队列队列等待可用资源的请求(可选)
超时时间毫秒等待资源的最大时间(可选)

操作复杂度:

操作复杂度说明
获取许可O(1)计数器比较 + 原子递增
释放许可O(1)原子递减
拒绝请求O(1)计数器比较,直接返回

3.1 线程池隔离 vs 信号量隔离#

两种策略各有适用场景,核心区别在于:调用是在独立线程上执行,还是在调用方线程上执行。

线程池隔离的优势在于更强的隔离性。因为调用在独立线程上执行,你可以设置超时并中断——如果下游 5 秒没响应,直接中断那个线程,资源立刻释放。即使下游永远不返回,线程池也能通过超时机制回收线程。代价是线程切换的开销:每次调用都要从调用方线程切换到线程池线程,响应时间里多了线程调度的成本。对于本身很快的调用(几毫秒),这个开销占比就不容忽视。

信号量隔离的优势在于低开销。调用直接在调用方线程上执行,没有线程切换,也没有额外的线程池管理成本。但它的致命弱点是无法中断一个阻塞的调用——因为调用运行在调用方线程上,你不能中断自己。如果下游卡死了,调用方线程也跟着卡死,信号量许可永远不会被释放。

简单判断标准:

场景推荐策略原因
调用慢/可能阻塞的下游服务线程池隔离可以超时中断,防止线程泄漏
调用快的内存操作信号量隔离开销低,不太可能阻塞
对延迟极度敏感信号量隔离没有线程切换开销
下游不可预测线程池隔离最坏情况下能通过超时兜底

3.2 舱壁 vs 熔断器#

舱壁和熔断器经常被放在一起讨论,但它们解决的问题不同,工作的时机也不同。

舱壁是预防性的——在故障发生之前就限制每个依赖能使用的资源上限。即使依赖 B 开始变慢,它最多也只能占用分配给它的 10 个线程,不会继续蔓延。舱壁不关心下游是快是慢、是成功还是失败,它只关心一件事:你最多用多少资源。

熔断器是反应性的——在检测到下游持续失败后,主动停止发送请求。熔断器关注的是下游的健康状况:错误率太高就跳闸,冷却后放一个探测请求,成功就恢复。

两者是互补的,不是替代关系。舱壁限制了故障的爆炸半径,熔断器减少了向故障下游发送的流量。在 Netflix Hystrix 中,每个 command 同时配置了线程池隔离(舱壁)和熔断器——舱壁保证资源不被耗尽,熔断器保证不再向已宕机的下游发请求。实际生产中,两者应该配合使用。

四、变体与对比#

模式触发条件行为保护方向适用场景
舱壁(线程池)并发数超过池大小拒绝多余请求保护调用方资源慢/不可预测的下游调用
舱壁(信号量)并发数超过许可数拒绝多余请求限制并发,保护调用方快速/内存操作
熔断器错误率/延迟超过阈值跳闸,快速失败保护调用方不浪费资源下游持续不可用
限流器请求速率超过限额拒绝/排队多余请求保护被调用方流量控制

组合使用的建议:

  • 舱壁 + 熔断器:最常见的组合。舱壁限制每个依赖的资源消耗,熔断器在依赖故障时停止发送请求。两者协同工作:熔断器跳闸后,舱壁的并发数自然下降;舱壁满载时,熔断器的错误率会上升,加速跳闸
  • 舱壁 + 限流器:舱壁保护调用方,限流器保护被调用方。舱壁决定”我最多给你发多少并发请求”,限流器决定”你最多能处理多少请求”,从两端分别设防
  • 熔断器 + 限流器:熔断器处理故障场景,限流器处理正常流量的峰值。两者保护的对象不同——熔断器保护调用方,限流器保护被调用方

五、多语言实现#

5.1 Go 实现#

Go 没有原生的线程池概念,但可以用带缓冲的 channel 模拟信号量——这是 Go 里实现舱壁最地道的写法。

package bulkhead
import (
"context"
"fmt"
)
// Bulkhead 基于信号量的舱壁隔离
type Bulkhead struct {
sem chan struct{} // 带缓冲 channel 充当信号量
}
// New 创建一个最大并发数为 maxConcurrent 的舱壁
func New(maxConcurrent int) *Bulkhead {
return &Bulkhead{
sem: make(chan struct{}, maxConcurrent),
}
}
// Execute 在舱壁保护下执行函数
// 如果并发数已满,返回错误而非阻塞
func (b *Bulkhead) Execute(ctx context.Context, fn func() error) error {
select {
case b.sem <- struct{}{}:
// 成功获取许可
defer func() { <-b.sem }()
return fn()
default:
// 并发数已满,直接拒绝
return fmt.Errorf("bulkhead: 并发数已满,请求被拒绝")
}
}
// ExecuteWithWait 等待可用许可或超时
func (b *Bulkhead) ExecuteWithWait(ctx context.Context, fn func() error) error {
select {
case b.sem <- struct{}{}:
defer func() { <-b.sem }()
return fn()
case <-ctx.Done():
return fmt.Errorf("bulkhead: 等待超时,%w", ctx.Err())
}
}

Go 的 buffered channel 天然就是计数信号量:往里发一个值就是获取许可,从里取一个值就是释放许可。channel 满了,selectdefault 分支立刻走拒绝逻辑——这就是舱壁的核心行为。Execute 提供非阻塞的即时拒绝,ExecuteWithWait 则允许请求等待一段时间,用 context.Context 控制超时。

实际使用时,包装 HTTP 调用:

package main
import (
"context"
"fmt"
"net/http"
"time"
"bulkhead"
)
func main() {
// 给下游服务 A 分配 20 个并发
bhA := bulkhead.New(20)
// 给下游服务 B 分配 10 个并发(B 历史表现差,少分配点)
bhB := bulkhead.New(10)
http.HandleFunc("/api", func(w http.ResponseWriter, r *http.Request) {
// 调用服务 A,受舱壁保护
err := bhA.Execute(r.Context(), func() error {
resp, err := http.Get("http://service-a/endpoint")
if err != nil {
return err
}
defer resp.Body.Close()
// 处理响应...
return nil
})
if err != nil {
// 舱壁拒绝,返回降级响应
w.WriteHeader(http.StatusServiceUnavailable)
fmt.Fprint(w, "服务繁忙,请稍后重试")
return
}
// 调用服务 B,带超时等待
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
err = bhB.ExecuteWithWait(ctx, func() error {
resp, err := http.Get("http://service-b/endpoint")
if err != nil {
return err
}
defer resp.Body.Close()
// 处理响应...
return nil
})
if err != nil {
// 降级处理
w.WriteHeader(http.StatusServiceUnavailable)
fmt.Fprint(w, "服务 B 繁忙,已降级")
return
}
w.WriteHeader(http.StatusOK)
})
}

注意这里给服务 A 和 B 分配了不同的并发上限。服务 B 历史表现差,只给它 10 个并发——即使 B 彻底卡死,最多也只占 10 个位置。这就是舱壁模式”按风险分配资源”的思路:风险越高的依赖,给的资源越少。

5.2 TypeScript 实现#

TypeScript 里没有 channel,但可以用 Promise + 计数器实现一个并发限制器:

class SemaphoreBulkhead {
private active = 0;
constructor(private maxConcurrent: number) {}
async execute<T>(fn: () => Promise<T>): Promise<T> {
// 并发数已满,直接拒绝
if (this.active >= this.maxConcurrent) {
throw new Error("bulkhead: 并发数已满,请求被拒绝");
}
this.active++;
try {
return await fn();
} finally {
this.active--;
}
}
}

上面的实现有个问题:if 检查和 active++ 之间存在竞态窗口。JavaScript 虽然是单线程的,但 await fn() 会挂起当前执行,其他调用可能在这个间隙里通过检查。不过这其实是可接受的行为——信号量隔离本身就允许短暂的突发超过限制,关键是在持续高并发时能阻止资源耗尽。如果需要严格限制,可以用排队机制:

class QueueBulkhead {
private active = 0;
private queue: Array<() => void> = [];
constructor(
private maxConcurrent: number,
private maxWait: number = 5000, // 最大等待时间(毫秒)
) {}
async execute<T>(fn: () => Promise<T>): Promise<T> {
// 等待可用许可
await this.acquire();
try {
return await fn();
} finally {
this.release();
}
}
private acquire(): Promise<void> {
if (this.active < this.maxConcurrent) {
this.active++;
return Promise.resolve();
}
// 超过最大等待数,直接拒绝
if (this.queue.length >= this.maxConcurrent) {
return Promise.reject(new Error("bulkhead: 等待队列已满"));
}
// 排队等待,带超时
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
const idx = this.queue.indexOf(waiter);
if (idx !== -1) this.queue.splice(idx, 1);
reject(new Error("bulkhead: 等待超时"));
}, this.maxWait);
const waiter = () => {
clearTimeout(timer);
resolve();
};
this.queue.push(waiter);
});
}
private release(): void {
this.active--;
// 唤醒下一个等待的请求
if (this.queue.length > 0) {
this.active++;
const next = this.queue.shift()!;
next();
}
}
}

QueueBulkhead 在并发数满时把请求放进等待队列,而不是直接拒绝。队列本身也有容量限制,防止等待请求无限堆积。每个等待请求都有超时——超过 maxWait 还没轮到,直接拒绝。这种”有限等待 + 超时”的策略比直接拒绝更温和,适合对用户体验要求较高的场景。

包装 fetch 调用的例子:

// 给不同下游分配不同的舱壁
const userBulkhead = new QueueBulkhead(20);
const orderBulkhead = new QueueBulkhead(10);
async function fetchUser(id: string) {
return userBulkhead.execute(async () => {
const resp = await fetch(`/api/users/${id}`);
if (!resp.ok) throw new Error(`用户服务返回 ${resp.status}`);
return resp.json();
});
}
async function fetchOrder(id: string) {
return orderBulkhead.execute(async () => {
const resp = await fetch(`/api/orders/${id}`);
if (!resp.ok) throw new Error(`订单服务返回 ${resp.status}`);
return resp.json();
});
}

六、生产验证#

项目源码位置用途
Resilience4j BulkheadSemaphoreBulkheadJava 生态标准舱壁实现。同时支持信号量隔离和固定线程池隔离,提供 maxConcurrentCallsmaxWaitDuration 配置。Spring Boot 生态广泛使用
Netflix HystrixHystrixThreadPool 接口及实现经典的线程池隔离实现。每个 HystrixCommand 拥有独立的 ThreadPoolExecutor,通过 coreSizemaxQueueSize 控制线程数和等待队列。Netflix 全部微服务架构使用,虽然已停止维护但设计思想仍被广泛参考
Tomcat 线程池ThreadPoolExecutorTomcat 的请求处理线程池。每个 Connector 拥有独立的线程池,maxThreads 默认 200。不同 Connector 之间天然隔离——一个 Connector 的慢请求不会影响其他 Connector。这本质上就是舱壁模式在 Web 容器层面的应用

七、小结#

何时使用:

  • 服务有多个下游依赖——防止一个慢依赖耗尽所有线程,拖垮其他正常依赖。这是舱壁模式最核心的使用场景:多依赖场景下的资源隔离
  • 多租户系统——给每个租户分配独立的资源池,防止一个租户的异常流量影响其他租户。SaaS 平台的基本要求
  • 任何需要隔离的共享资源——数据库连接池、HTTP 连接池、文件句柄,只要是多个消费者共享的资源,都应该考虑隔离

何时不用:

  • 单依赖服务——只有一个下游依赖时,隔离没有意义,因为不存在”故障传染”的问题。此时用熔断器就够了
  • 线程池开销不可接受——线程池隔离需要额外的线程,每个线程约占 1MB 栈空间。如果依赖很多,线程数会膨胀。这种情况下用信号量隔离替代
  • 调用量极低的内部服务——并发数几乎不会超过个位数,舱壁没有发挥空间。简单的超时配置就足够了

八、参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

舱壁模式(Bulkhead)
https://blog.souloss.com/posts/programming/system-patterns/system-patterns-bulkhead/
作者
Tsukimi
发布于
2026-06-13
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时