当Ruby服务调用外部HTTP接口时,网络抖动、GC停顿或下游过载都可能让一次简单的请求从毫秒级拖到秒级甚至超时。如果每次调用都直接占用当前请求处理线程,那么大量慢调用会迅速占满Web服务器的线程池,导致整个服务对外表现为不可用。舱壁模式(Bulkhead)的核心思想是把不同依赖的调用隔离到独立的线程池或信号量中,避免一个依赖的故障耗尽全局资源。而拒绝策略则决定了当舱壁内部资源耗尽时,新的调用请求该如何处理。在Hystrix等Java库中常见的拒绝策略包括Abort(抛异常)、Discard(静默丢弃)和CallerRuns(调用者运行)。其中CallerRuns策略因为能提供背压、不丢失请求且实现简单,在Ruby生态中尤其值得借鉴。

本文将聚焦CallerRuns策略,说明它的工作原理,并基于Ruby的concurrent-ruby库实现一个可用的舱壁隔离器。我们会从舱壁与拒绝策略的基本概念讲起,然后逐步构建一个线程池隔离的演示代码,最后讨论测试方式和生产环境中的注意事项。读者不需要预先熟悉Hystrix,但最好了解Ruby线程模型和基本的并发概念。
舱壁模式与拒绝策略概述
舱壁这个名称来自船舶设计:船体被分隔成多个独立密封舱,一个舱进水不会导致整船沉没。在软件系统中,舱壁模式把对不同外部依赖的调用隔离到各自的资源池中。例如你有一个Ruby服务同时依赖订单API和物流API,可以为订单API分配一个最多10个线程的池子,为物流API分配另一个池子。当订单API变慢时,最多会有10个线程被占满,但物流API的线程丝毫不受影响,请求处理线程也不会被订单API的慢调用拖垮。
实现舱壁有两种常见方式:线程池隔离和信号量隔离。线程池隔离为每个依赖创建独立的线程池,调用时提交任务到线程池并等待结果,从而在物理上隔开执行线程。信号量隔离则限制同时进入某个代码块的线程数量,但执行仍在调用者线程上,适合那些不涉及阻塞IO的快速操作。无论哪种方式,当并发数达到上限时,新的调用请求就会触发拒绝策略。Ruby中的concurrent-ruby库提供了线程池和信号量实现,可以很方便地组合出舱壁效果。
常见的拒绝策略有四种:Abort策略直接抛出异常,调用方需要处理失败;Discard策略静默丢弃新请求,风险是可能丢失关键调用;CallerRuns策略让调用者线程自己执行被拒绝的任务,也就是不再提交到线程池,而是同步运行;还有自定义策略,例如队列等待或降级返回缓存。CallerRuns策略最有趣的地方在于它把压力传导给了调用者,形成天然的背压机制。调用者线程本来就在等待这个依赖的结果,如果它自己去执行,虽然会阻塞更久,但避免了请求丢失,同时也会让上游感受到延迟,从而间接降低新请求的进入速率。
在Ruby服务中,由于MRI(CRuby)存在全局解释器锁(GIL),真正的并行执行有限,但线程池依然有价值:对于IO密集型的外部调用,GIL会在IO等待时释放,线程池可以同时处理多个阻塞IO。CallerRuns策略在Ruby中实现时需要注意:调用者线程往往是Web服务器的工作线程,例如Puma或Unicorn的worker线程。如果这些线程频繁执行CallerRuns任务,可能会加剧服务器自身的线程耗尽。因此该策略更适合用于有界线程池且调用频率可控的场景。
基于concurrent-ruby实现基本舱壁隔离
Ruby标准库没有内置线程池,但concurrent-ruby是目前最成熟的并发工具库,提供了线程池、Future、Promise、定时器等多种抽象。我们先用它实现一个简单的线程池舱壁,不包含拒绝策略,只演示隔离效果。假设有一个外部依赖调用方法slow_remote_call,模拟耗时操作。
require 'concurrent'
# 模拟外部依赖调用:随机耗时0.2~1.5秒
def slow_remote_call(name)
sleep(rand(0.2..1.5))
"response from #{name}"
end
# 为订单依赖创建固定大小线程池
order_pool = Concurrent::FixedThreadPool.new(5)
# 提交10个调用任务
futures = 10.times.map do |i|
Concurrent::Future.execute(executor: order_pool) do
slow_remote_call("order-#{i}")
end
end
# 等待全部完成并收集结果
results = futures.map(&:value)
puts results.inspect
order_pool.shutdown
order_pool.wait_for_termination(5)
上面的代码创建了一个最大5个线程的线程池,然后提交了10个任务。由于池中只有5个线程,另外5个任务会进入队列等待。默认情况下,Concurrent::FixedThreadPool使用无界队列,因此不会触发拒绝策略,所有任务最终都会执行,但排队时间可能很长。这正是无界队列的隐患:如果下游持续变慢,队列会无限增长,最终导致内存耗尽。
为了实现有界队列和拒绝策略,我们需要使用Concurrent::ThreadPoolExecutor类,它允许设置max_queue和fallback_policy。不过concurrent-ruby内置的拒绝策略只支持Abort、Discard和CallerRuns三种,其中CallerRuns的实现是直接在提交线程上执行任务。我们可以在创建线程池时指定fallback_policy: :caller_runs来启用CallerRuns策略。下面是一个改进版本,将队列长度限制为2,总共可容纳5个执行中任务加上2个排队任务,当第8个任务提交时就会触发CallerRuns。
require 'concurrent'
def slow_remote_call(name)
sleep(rand(0.2..1.5))
"response from #{name}"
end
order_pool = Concurrent::ThreadPoolExecutor.new(
min_threads: 5,
max_threads: 5,
max_queue: 2,
fallback_policy: :caller_runs
)
futures = 10.times.map do |i|
Concurrent::Future.execute(executor: order_pool) do
slow_remote_call("order-#{i}")
end
end
results = futures.map(&:value)
puts results.inspect
order_pool.shutdown
order_pool.wait_for_termination(5)
当提交第8个任务时,线程池已经达到最大线程数且队列已满,CallerRuns策略会直接在当前线程(即循环提交的线程)执行这个任务,直到它完成。因此整个提交循环会变慢,但不会抛出异常。这里有一个细节:Concurrent::Future.execute在提交时并不会立即阻塞,而是返回Future对象;但如果任务被CallerRuns策略执行,实际上会在提交线程同步运行。由于我们使用map循环连续提交,第8个任务执行时循环会暂停,完成后继续提交后续任务。这正是背压的表现。
这种使用concurrent-ruby内置策略的方式简单有效,但理解其内部行为对于自定义实现很有帮助。核心逻辑是:当线程池无法接受新任务时,不排队也不抛异常,而是直接调用任务的执行代码块。在concurrent-ruby源码中,CallerRunsPolicy的handle_overflow方法就是直接task.call。我们自己实现时也可以参考这个逻辑。
手动实现一个轻量级CallerRuns舱壁
有时候我们不想引入concurrent-ruby的全部依赖,或者需要更精细地控制超时和异常处理。这时可以基于Ruby标准库的Queue和Thread手动实现一个最小化的线程池舱壁,并内置CallerRuns拒绝策略。下面是一个简化的实现,包含固定数量的工作线程和一个有界任务队列,当队列满时执行CallerRuns。
require 'thread'
class BulkheadExecutor
def initialize(thread_count, queue_size)
@queue = Queue.new
@max_queue = queue_size
@workers = Array.new(thread_count) do
Thread.new do
loop do
task = @queue.pop
break if task == :shutdown
task.call
end
end
end
@mutex = Mutex.new
@queue_length = 0
end
# 提交任务,如果队列已满则CallerRuns直接执行
def submit(&block)
if queue_full?
# CallerRuns策略:当前线程直接执行
block.call
else
@mutex.synchronize { @queue_length += 1 }
@queue << block
end
end
def shutdown
@workers.size.times { @queue << :shutdown }
@workers.each(&:join)
end
private
def queue_full?
@mutex.synchronize { @queue_length >= @max_queue }
end
# 队列长度需要在任务被消费后递减,这里略去了消费端递减逻辑
# 完整实现需要包装task,在任务执行前递减队列计数
end
上面的代码是一个示意,实际上队列长度的维护需要更细致:在任务执行完成后递减计数,否则queue_full?会一直返回true。我们可以将任务包装成闭包,在执行前后调整计数。以下是修正后的完整版本,确保队列计数准确,并且工作线程从队列取出任务后立即递减计数,这样提交端就能判断是否还有空位。
require 'thread'
class BulkheadExecutor
def initialize(thread_count, queue_size)
@queue = Queue.new
@max_queue = queue_size
@queue_length = 0
@mutex = Mutex.new
@workers = Array.new(thread_count) do
Thread.new do
loop do
task = @queue.pop
break if task == :shutdown
@mutex.synchronize { @queue_length -= 1 }
task.call
end
end
end
end
def submit(&block)
if queue_full?
# CallerRuns策略:当前线程直接执行,不进入队列
block.call
else
@mutex.synchronize { @queue_length += 1 }
@queue << block
end
end
def shutdown
@workers.size.times { @queue << :shutdown }
@workers.each(&:join)
end
private
def queue_full?
@mutex.synchronize { @queue_length >= @max_queue }
end
end
# 使用示例
executor = BulkheadExecutor.new(5, 2)
10.times do |i|
executor.submit do
puts "Task #{i} started on #{Thread.current.object_id}"
sleep(rand(0.1..0.5))
puts "Task #{i} finished"
end
end
executor.shutdown
这个实现中,工作线程在取出任务后先递减队列长度,然后才执行任务,这样即使任务执行时间很长,队列长度也能及时反映真实占用的队列槽数。当queue_full?返回true时,提交线程直接执行任务,形成CallerRuns效果。需要注意的是,如果任务执行时间很长,提交线程会被阻塞,这相当于一种同步背压。在Web服务器环境中,如果一个请求处理线程触发了CallerRuns,那么该请求的响应时间会增加,但不会失败,同时也会让服务器线程池的压力传导到客户端。
这个手动实现没有处理任务异常和超时,生产使用时应加上异常捕获和日志记录。另外,Ruby的Queue#pop是阻塞的,当队列为空时工作线程会等待,符合预期。当调用shutdown时,我们向队列推入与工作线程数量相等的:shutdown标记,每个线程取出一个后退出循环。这个模式简单可靠。
CallerRuns策略的测试与生产考量
验证CallerRuns策略是否生效,可以通过记录任务执行所在的线程来判断。如果任务被线程池执行,其Thread.current.object_id应该属于工作线程;如果被CallerRuns执行,则与提交线程的object_id相同。我们可以编写一个简单的测试脚本,模拟队列满的情况,并输出每个任务的执行线程信息。使用上面手动实现的BulkheadExecutor,提交线程为主线程,工作线程是内部创建的线程。代码如下:
executor = BulkheadExecutor.new(2, 1) # 2个工作线程,队列容量1
submitter_id = Thread.current.object_id
puts "Submitter thread id: #{submitter_id}"
6.times do |i|
executor.submit do
thread_id = Thread.current.object_id
if thread_id == submitter_id
puts "Task #{i} executed on submitter thread (CallerRuns)"
else
puts "Task #{i} executed on worker thread #{thread_id}"
end
sleep(0.2)
end
end
executor.shutdown
运行这个脚本,你会看到前三个任务(2个在工作线程,1个在队列)由工作线程执行,从第4个任务开始由于队列已满且工作线程忙碌,提交线程会直接执行任务,输出显示为submitter thread。这个行为清楚地展示了CallerRuns策略的触发时机。
在生产环境中使用CallerRuns策略有几个关键点需要权衡。首先,它相当于把依赖调用的执行压力转移到了调用者线程,如果调用者线程是Web服务器的有限线程资源,那么大量CallerRuns会降低服务器的整体吞吐量,但同时也防止了请求被直接丢弃,用户可能只是感受到变慢而不是错误。其次,需要确保任务本身是线程安全的,因为CallerRuns会让任务在非工作线程上执行,可能打破某些依赖ThreadLocal的假设。在Ruby中ThreadLocal使用较少,但仍要注意。最后,CallerRuns策略不适合非常耗时的任务,因为调用者线程会长时间阻塞,最好配合超时控制,例如在任务内部使用Timeout.timeout或依赖库自身的超时设置。
另一种思路是将CallerRuns与队列容量调优结合。队列容量不应该设为零,因为零队列会导致几乎每个任务都在线程池满时直接执行,退化为同步调用。适度的队列可以平滑突发流量,同时配合CallerRuns提供有界等待。比如设置队列容量为线程数的50%到100%,既不会积压过多请求,也能在高峰期给出一定的缓冲。具体的容量需要根据依赖的响应时间分布和调用频率来压测决定。
总结
CallerRuns策略是舱壁模式中一种优雅的拒绝处理方式,它在不丢失请求的前提下向调用方传导背压。Ruby开发者可以利用concurrent-ruby的内置策略快速实现,也可以手动编写一个轻量级线程池舱壁来加深理解并控制细节。无论采用哪种方式,核心都在于为每个外部依赖建立独立的资源边界,并明确边界被触碰时的行为。随着服务依赖增多,这种隔离思想会变得越来越重要。
在实际落地时,不要忽视监控和日志。你需要知道在什么时间点触发了CallerRuns,频率如何,这样才能判断是依赖容量不足还是偶发抖动。可以将触发事件记录到指标系统,并设置告警阈值。同时,结合超时、重试和熔断等其他稳定性手段,才能构建出真正健壮的Ruby服务。
Ruby舱壁模式CallerRuns策略修改时间:2026-09-20 23:13:35