舱壁模式:设计容错微服务
在微服务架构中,单个应用程序被分解为数十或数百个独立的协作服务。虽然这种设计提高了模块化性和可扩展性,但它也带来了一个重大风险:一项服务的故障可能会级联并导致整个系统瘫痪。
如果下游服务变得缓慢或无响应,则对上游服务的传入请求将开始堆积。如果它们都共享相同的内存、CPU 或线程池,则缓慢的依赖关系可能会快速耗尽所有可用资源,导致整个应用程序崩溃。
这种级联失败被称为多米诺骨牌效应。为了防止这种情况,系统架构师使用 Bulkhead 模式。
在本指南中,我们将探讨 Bulkhead 模式是什么、它是如何工作的,以及如何使用简单的类比、架构概念以及 Java (Resilience4j) 和 Go 中的代码示例来实现它。
现实世界的类比:水密船舶舱壁
这种图案的名字来源于造船业。
舱壁是建在船体内部的水密墙。船体内部不是一个单一的、巨大的开放空间,而是被分成几个独立的密封隔间。
如果船与障碍物相撞并且船体破裂,水就会涌入受损的舱室。然而,由于水密舱壁的存在,水被限制在单个隔间中。船的其余部分保持干燥和浮力,使其能够保持漂浮并到达安全地带。
如果没有舱壁,水会在整个船体中自由流动,最终导致船舶沉没。
在软件工程中:
- The Ship 是您的整个应用程序或服务。
- 隔间是隔离的资源池(线程、连接、CPU)。
- 船体破裂是下游微服务的故障或速度减慢。
- 洪水是资源耗尽。
问题:共享资源池和线程耗尽
为了理解为什么需要隔板,让我们看看当资源在全球范围内共享时会发生什么。
想象一下 API 网关或处理用户请求的 Web 服务器。它有一个包含 100 个线程的全局线程池来处理所有传入呼叫。服务器与三个下游服务交互:
- 目录服务(快速,读取产品列表)
- 支付服务(快速、流程结帐)
- 推荐服务(慢,计算个性化物品)
正常情况下,一切正常。但假设推荐服务遇到数据库死锁,并且开始需要 30 秒而不是 200 毫秒来响应。
发生的情况如下:
- 用户持续访问首页,触发推荐服务请求。
- 服务器从全局池中为每个请求分配一个线程。
- 由于推荐服务速度较慢,因此这些线程会等待响应。
- 几秒钟内,池中的所有 100 个线程都在等待推荐服务。
- 当新用户尝试结账或查看目录时,服务器没有剩余线程来处理他们的请求。
尽管目录和支付服务完全健康,但它们现在无法访问,因为缓慢的推荐服务耗尽了共享线程池。整个系统已经离线。
解决方案:舱壁模式
Bulkhead 模式通过对资源池进行分区来解决这一问题,以便一个区域的故障不会影响其他区域。
我们为每个服务或下游依赖项分配单独的有界池,而不是单个全局池。
如果我们专门为推荐服务分配 10 个线程,那么最多可以有 10 个线程被阻塞等待。如果推荐服务速度变慢,这 10 个线程将被耗尽,后续的推荐请求将被立即拒绝(快速失败)。
然而,剩余的 90 个线程仍然保留用于目录和支付服务。即使推荐小部件暂时不可用,用户仍然可以浏览产品并进行购买。
舱壁隔离的类型
在软件系统中实现舱壁有两种主要方法:
1.线程池隔离
在此模型中,每个下游依赖项都分配有自己的专用线程池和执行队列。
- 工作原理:主应用程序线程将任务交给特定的线程池。如果池已满,则请求将被排队或被拒绝。
- 优点:提供完全隔离。如果服务变慢,则只有其线程池受到影响。线程在操作系统/JVM 级别是隔离的。
- 缺点:由于线程调度、上下文切换和队列管理而引入额外的 CPU 开销。
2.信号量隔离
信号量隔离不是创建新的线程池,而是使用计数器(信号量)来限制特定服务允许的并发调用数量。
- 工作原理:当请求开始时,它会尝试从信号量获取许可。如果许可证可用,它将在调用线程上执行请求并在完成后释放许可证。如果没有可用的许可证,该请求将立即被拒绝。
- 优点:非常轻量级,几乎为零开销,因为不涉及线程上下文切换。
- 缺点:没有线程分离。如果网络套接字上的调用在没有适当超时的情况下被阻塞,它仍然可以阻塞调用线程。
实现示例
让我们看看如何用两种流行的后端语言实现舱壁。
1.Java(Resilience4j 和 Spring Boot)
Resilience4j 是一个专为 Java 设计的轻量级、易于使用的容错库。以下是如何在 Spring Boot 应用程序中为下游支付服务配置隔板。
配置(application.yml)
resilience4j.bulkhead:
instances:
paymentService:
maxConcurrentCalls: 10
maxWaitDuration: 10ms
resilience4j.threadpoolbulkhead:
instances:
paymentService:
maxThreadPoolSize: 10
coreThreadPoolSize: 5
queueCapacity: 20
代码实现
import io.github.resilience4j.bulkhead.annotation.Bulkhead;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
@Service
public class OrderService {
private final RestTemplate restTemplate;
public OrderService(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
// Apply semaphore bulkhead
@Bulkhead(name = "paymentService", fallbackMethod = "paymentFallback")
public String processPayment(OrderDetails details) {
return restTemplate.postForObject("http://payment-service/charge", details, String.class);
}
// Fallback method executed when the bulkhead is full
public String paymentFallback(OrderDetails details, Throwable throwable) {
return "Payment service is currently busy. Please try again later.";
}
}
2.Go(Go语言)
在 Go 中,我们不一定需要重型框架,因为该语言提供了原生并发原语,例如 Goroutines 和缓冲通道。我们可以使用缓冲通道实现干净的信号量隔板:
package main
import (
"errors"
"fmt"
"net/http"
"time"
)
// Bulkhead represents a concurrency limiter
type Bulkhead struct {
semaphore chan struct{}
}
// NewBulkhead initializes a bulkhead with a max concurrency limit
func NewBulkhead(maxConcurrency int) *Bulkhead {
return &Bulkhead{
semaphore: make(chan struct{}, maxConcurrency),
}
}
// Execute runs the task if resource permit is available, otherwise returns error
func (b *Bulkhead) Execute(task func() error) error {
select {
case b.semaphore <- struct{}{}:
// Acquired permit
defer func() { <-b.semaphore }() // Release permit
return task()
default:
// Bulkhead is full, reject immediately
return errors.New("bulkhead is full: request rejected")
}
}
func main() {
// Allow maximum of 3 concurrent calls
paymentBulkhead := NewBulkhead(3)
mockTask := func() error {
fmt.Println("Processing payment...")
time.Sleep(2 * time.Second) // Simulate network delay
return nil
}
// Simulate 5 rapid requests
for i := 1; i <= 5; i++ {
go func(reqID int) {
err := paymentBulkhead.Execute(mockTask)
if err != nil {
fmt.Printf("Request %d failed: %v\n", reqID, err)
} else {
fmt.Printf("Request %d completed successfully\n", reqID)
}
}(i)
}
// Keep main alive to watch output
time.Sleep(3 * time.Second)
}
隔板模式的常见用例
以下是一些典型场景,其中实施舱壁模式至关重要:
- API网关路由:隔离不同后端服务的路由。如果推荐服务出现故障,网关上的订单服务路由仍保持完全运行。
- 数据库连接池:按服务或租户划分数据库连接池。来自一个租户的大量分析查询不会耗尽所有可用的连接句柄,从而节省了其他租户的事务查询。
- 多租户 SaaS 应用程序:将高级租户与免费租户的计算资源或执行队列分开。免费层资源高峰不会导致高级层的 CPU 或内存请求不足。
- 第三方 API 集成:为外部支付网关、运输提供商或通知引擎专用单独的 HTTP 客户端池。如果一项第三方服务速度变慢,其他外部交互将继续进行而不会出现阻塞。
为什么 Kafka/Message Brokers 无法取代 Bulkhead 模式
一个常见的问题是:“如果我们有像 Apache Kafka 这样的消息代理,为什么我们需要 Bulkhead 模式?我们不能只使用队列来缓冲请求吗?”
虽然消息代理使系统解耦,但它们无法取代 Bulkhead 模式。原因如下:
1. 同步与异步通信
Kafka 专为异步、事件驱动架构而设计。生产者将消息推送到主题,最终由消费者处理。 然而,面向用户的应用程序通常需要**同步(请求-响应)**通信(例如,加载产品目录或通过 REST/gRPC API 向信用卡收费)。在这里引入 Kafka 需要复杂的请求-回复模式,增加了高延迟和开销。 Bulkheads 专门设计用于实时保护这些同步执行线程。
2. Kafka 消费者内部的线程饥饿
即使您的系统完全是事件驱动的并使用 Kafka,您仍然需要隔板!
假设单个消费者微服务监听多个 Kafka 主题(例如 user-registrations 和 video-transcoding)。如果消费者分配其所有内部工作线程来处理大量缓慢的 video-transcoding 作业,它将经历线程饥饿。即使该分区运行正常,消费者也无法处理轻量级 user-registrations 消息。您仍然需要消费者服务内部的内部隔板(单独的线程池)来隔离工作。
3. 客户端开销和快速失败要求
当下游服务关闭时,隔板允许调用服务快速失败并立即返回回退响应。如果您将所有内容都放入 Kafka 中进行排队,则队列可能会无限增长,从而导致请求过时、内存消耗高以及系统恢复时延迟超时。
简而言之,Kafka 通过网络解耦系统之间的通信,而Bulkheads 隔离正在运行的应用程序实例中的资源执行。它们是互补的,而不是相互排斥的。
使用隔板时的最佳实践
- 始终设置超时:隔板限制了并发性,但它不能解决套接字读取速度慢的问题。将隔板与严格的网络超时相结合,以尽快释放线程。
- 与断路器组合:在断路器旁边使用隔板。如果舱壁开始持续拒绝请求,则断路器应该跳闸以完全停止流量并为下游服务提供恢复空间。
- 监控池饱和度:对隔板队列长度和活动线程计数实施警报。如果舱壁持续充满,您可能需要扩展基础设施或优化下游服务。
- 单独调整大小:不要使用一刀切的限制。测量每个依赖项的延迟和请求率以确定正确的舱壁限制。
## 结论
Bulkhead 模式是构建弹性云规模系统的基本设计模式。通过对资源进行分区,您可以隔离故障,防止级联效应,并确保局部错误不会演变成全局中断。