微服务架构下,一个 Ruby 应用通常要同时依赖十几个外部服务:数据库、缓存、第三方 HTTP 接口、消息队列等。一旦其中某个下游接口响应时间从 50 毫秒恶化到 5 秒,调用它的线程会被长时间占用,如果这些线程和调用健康下游的线程混在同一个池里,很快所有线程都会被拖住,整个进程陷入假死。舱壁模式的思路源自造船业:把船体分隔成多个水密舱室,一个舱室进水不会沉没整艘船。映射到服务调用上,就是给每个下游依赖分配独立的线程配额或信号量,彼此之间互不侵占。更进一步,这些配额不应该是一成不变的,而应该根据实时的负载情况动态伸缩,冷门依赖少占资源,热点依赖在健康时获得更多配额,故障时快速收缩。本文用 Ruby 实现一个具备动态调整能力的舱壁组件。

舱壁隔离的两种实现方式与选型
实现舱壁隔离主要有两种手段。第一种是信号量隔离:不创建额外线程,只是用一个计数器限制同一时刻对某个下游的并发调用数,请求线程自身执行调用逻辑,拿不到许可就快速失败或降级。它的优点是轻量、无上下文切换开销,缺点是无法实现异步超时——如果下游阻塞,调用线程依旧被占用。第二种是线程池隔离:每个下游依赖一个专属线程池,请求提交任务后等待 Future 结果。它的优势是可以在等待结果时设置超时、可以真正释放调用线程,同时线程池本身的队列长度、活跃线程数都是天然的监控指标,这恰恰是后续动态调整的数据基础。
在 Ruby 中,标准库提供了 Thread、Queue 和 ConditionVariable,加上 concurrent-ruby 这个成熟的并发库,可以很方便地构建这两种隔离方式。对于大多数 Rails 应用中的 HTTP 调用,如果使用的是同步客户端,信号量方式侵入性最小;如果调用链路已经是异步的(比如配合 fiber 或异步 IO),线程池方式能提供更强的控制力。本文选择线程池方案,因为动态调整并发度需要运行时指标支撑,线程池暴露的信息远比信号量丰富。下面的代码是静态版本的基础实现:
require 'concurrent-ruby'
class Bulkhead
def initialize(name, size:, max_queue: 100)
@name = name
@size = size
@queue = Queue.new
@pool = Array.new(size) { spawn_worker }
@queue_limit = max_queue
@metrics = { submitted: 0, rejected: 0, executed: 0 }
end
def spawn_worker
Thread.new do
loop do
task = @queue.pop
break if task == :shutdown
task.call
@metrics[:executed] += 1
end
end
end
# 提交任务,队列满则快速拒绝,这是舱壁的核心保护点
def execute(&block)
if @queue.size >= @queue_limit
@metrics[:rejected] += 1
raise BulkheadRejected, "依赖 #{@name} 舱壁已满"
end
@metrics[:submitted] += 1
@queue << block
end
end这段代码实现了最基本的三要素:专属线程组、有界队列、快速拒绝。任何下游的故障最多让它自己的队列塞满并触发拒绝,不会波及其他依赖。但要让它“动态”起来,还缺两个东西:一是运行时指标的采集,二是扩缩容的执行机制。
采集负载指标:动态调整的判断依据
动态调整不能凭感觉,必须有量化指标。对舱壁来说,最有价值的指标有三个。队列积压长度:等待执行的任务数,持续高于零说明吞吐不足,是扩容的最直接信号。任务执行耗时:如果单个任务耗时上升,可能是下游变慢,此时扩容反而危险,应该收缩并触发熔断。线程繁忙率:活跃线程数除以总线程数,长期接近 100% 说明配额不足。这三个指标要区分对待:积压高且耗时正常,才扩容;耗时恶化,则无论积压多高都应收缩。
指标采集建议放在独立的监控线程中,每隔固定周期(比如 5 秒)采样一次,同时保留最近 N 个采样点做滑动平均,避免单次抖动引发误判。实现如下:
class Bulkhead
METRIC_INTERVAL = 5 # 采样周期,单位秒
def start_metrics_collector
@samples = []
Thread.new do
loop do
sleep METRIC_INTERVAL
@samples << {
queued: @queue.size,
busy: busy_threads,
avg_latency: recent_avg_latency,
ts: Time.now.to_i
}
@samples.shift if @samples.size > 12 # 保留最近一分钟的数据
end
end
end
def busy_threads
@pool.count { |t| t.status == 'run' }
end
# 用最近一批任务的执行耗时估算平均延迟
def recent_avg_latency
return 0 if @latency_window.to_a.empty?
@latency_window.reduce(:+) / @latency_window.size
end
end采集到执行耗时的简单办法是包装用户提交的 block:在 execute 中记录入队时间,工作线程执行完任务后把 Process.clock_gettime(Process::CLOCK_MONOTONIC) 差值推入 @latency_window 数组并截断长度。注意一定要用单调时钟,系统时间被 NTP 校正时不会产生负数耗时。有了这些采样数据,扩缩容决策就有了客观依据,而不是靠拍脑袋设一个固定线程数。
动态调整核心:扩容、缩容与熔断的联动
拿到指标后,调整逻辑写成一个决策函数。核心规则可以归纳为:连续 M 个采样周期队列积压都超过阈值、且平均耗时没有恶化,则追加线程(扩容);连续 M 个周期耗时超过熔断阈值,则裁减线程并进入半开探测状态(收缩);处于熔断状态时,直接拒绝大部分请求,只放行极少量探测请求。下面的代码给出完整骨架:
class Bulkhead
SCALE_UP_QUEUE = 20 # 积压超过该值考虑扩容
SCALE_UP_STREAK = 3 # 连续命中次数
LATENCY_CEILING = 2.0 # 平均耗时超过 2 秒视为下游恶化
MAX_SIZE = 50 # 单个舱壁的线程上限
def start_autoscaler
streak = 0
Thread.new do
loop do
sleep METRIC_INTERVAL
s = @samples.last
next unless s
if s[:avg_latency] > LATENCY_CEILING
streak += 1
shrink if streak >= SCALE_UP_STREAK
elsif s[:queued] > SCALE_UP_QUEUE && s[:avg_latency] < LATENCY_CEILING / 2
streak += 1
scale_up if streak >= SCALE_UP_STREAK
else
streak = 0
end
end
end
end
def scale_up
new_threads = [(@size * 0.5).ceil, 5].min # 每次最多扩 50% 或 5 个
return if @size + new_threads > MAX_SIZE
new_threads.times { @pool << spawn_worker }
@size += new_threads
end
def shrink
# 缩容不能直接杀线程,标记配额让空闲线程自然退出
@shrink_target = [(@size / 2).to_i, 1].max
notify_idle_workers
end
end这里有一个容易被忽视的细节:缩容不能粗暴地 Thread.kill。Ruby 的 Thread#kill 会在任意位置抛出异常,可能让数据库连接、文件句柄处于不一致状态。正确做法是向队列投放若干个“退役哨兵”,空闲的工作线程取到哨兵后自行退出,正在执行任务的线程不受影响,实现优雅收缩。扩容则简单得多,直接追加新线程即可,因为所有工作线程消费的是同一个队列。
另外要注意全局资源的约束。动态调整的意义是在多个舱壁之间重新分配总预算,因此在工程实践中通常还会加一个全局的 ResourceManager,维护所有舱壁的线程总数上限。某个舱壁扩容前先向它申请配额,配额不足时可以从最空闲的舱壁“借调”。这样整个进程的总线程数始终可控,不会因为多个舱壁同时扩容把内存打爆。
落地时的注意事项与验证方法
第一个坑是 concurrent-ruby 与原生 Thread 的混用。如果直接使用其 FixedThreadPool,会发现它不支持动态调整大小,所以本文选择了自建线程组。若项目已经在用 concurrent-ruby,也可以在每个舱壁内部放一个可重建的池实例,调整大小时新旧池平滑过渡,旧池不再接收新任务并等待排空。第二个坑是 GIL(在 Ruby 3.x 中是 GVL)的存在:MRI 下的线程并非真正并行,但舱壁的价值并不因此打折,因为绝大多数下游调用是 IO 密集型的,GVL 会在 IO 等待时释放,多线程依旧能显著提升吞吐。如果用的是 JRuby 或 TruffleRuby,线程则真正并行,动态线程数的影响会更加直接。
验证动态舱壁是否生效,推荐一个简单的压力测试脚本:用 sinatra 起两个模拟下游,一个固定 10 毫秒响应,另一个可注入 3 秒延迟;然后用多个线程同时通过舱壁调用两者。观察日志可以发现:慢下游的舱壁在两个采样周期后触发收缩和熔断拒绝,快下游的舱壁因为拿到的配额增多,吞吐不降反升。这正是舱壁模式加动态调整的组合价值——故障被隔离在单个舱室内,资源自动流向健康的服务。上线后建议把每次扩缩容事件打到结构化日志里,配合 Grafana 之类的面板观察调整的频率,如果发现频繁的扩容缩容震荡,通常是阈值设置过灵敏,需要增大 SCALE_UP_STREAK 或放宽滞回区间。