一、为什么需要舱壁模式
想象一个典型的服务调用场景:你的服务同时调用三个下游服务——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)。造船时,船体内部被分隔成多个水密舱室。如果船体某个位置被撞出破洞,海水涌入的只是那个舱室——关上水密门,船仍然可以浮在水面上继续航行。没有隔舱壁的话,一个破洞进来的水会蔓延到整个船体,船就沉了。
“舱壁”这个名字直接来自造船工程。船舶设计师在几百年的时间里验证了一个道理:隔离是控制损害最有效的手段。你不一定能阻止事故发生,但你可以阻止事故的扩散。软件系统也是一样——你无法保证下游服务永远不宕机,但你可以保证一个下游的宕机不会拖垮整个系统。
三、核心思想
舱壁模式的核心就是资源隔离:给每个下游依赖分配独立的资源池,一个池子耗尽不影响其他池子。
图中,服务 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 满了,select 的 default 分支立刻走拒绝逻辑——这就是舱壁的核心行为。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 Bulkhead | SemaphoreBulkhead 类 | Java 生态标准舱壁实现。同时支持信号量隔离和固定线程池隔离,提供 maxConcurrentCalls 和 maxWaitDuration 配置。Spring Boot 生态广泛使用 |
| Netflix Hystrix | HystrixThreadPool 接口及实现 | 经典的线程池隔离实现。每个 HystrixCommand 拥有独立的 ThreadPoolExecutor,通过 coreSize、maxQueueSize 控制线程数和等待队列。Netflix 全部微服务架构使用,虽然已停止维护但设计思想仍被广泛参考 |
| Tomcat 线程池 | ThreadPoolExecutor | Tomcat 的请求处理线程池。每个 Connector 拥有独立的线程池,maxThreads 默认 200。不同 Connector 之间天然隔离——一个 Connector 的慢请求不会影响其他 Connector。这本质上就是舱壁模式在 Web 容器层面的应用 |
七、小结
何时使用:
- 服务有多个下游依赖——防止一个慢依赖耗尽所有线程,拖垮其他正常依赖。这是舱壁模式最核心的使用场景:多依赖场景下的资源隔离
- 多租户系统——给每个租户分配独立的资源池,防止一个租户的异常流量影响其他租户。SaaS 平台的基本要求
- 任何需要隔离的共享资源——数据库连接池、HTTP 连接池、文件句柄,只要是多个消费者共享的资源,都应该考虑隔离
何时不用:
- 单依赖服务——只有一个下游依赖时,隔离没有意义,因为不存在”故障传染”的问题。此时用熔断器就够了
- 线程池开销不可接受——线程池隔离需要额外的线程,每个线程约占 1MB 栈空间。如果依赖很多,线程数会膨胀。这种情况下用信号量隔离替代
- 调用量极低的内部服务——并发数几乎不会超过个位数,舱壁没有发挥空间。简单的超时配置就足够了
八、参考资料
- Resilience4j Bulkhead - Java 生态最活跃的舱壁实现,同时支持信号量和线程池两种隔离策略
- Netflix Hystrix Wiki - Netflix 对线程池隔离机制的官方文档,含舱壁与熔断器配合使用的说明
- Microsoft Bulkhead Pattern - Azure 架构中心对舱壁模式的系统描述,含上下文图
- Release It! (2nd Edition) - Michael Nygard 著,生产环境稳定性模式经典书籍,舱壁模式的命名和推广者
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






