Шаблон «Переборка»: проектирование отказоустойчивых микросервисов
В архитектуре микросервисов одно приложение разбивается на десятки или сотни независимых взаимодействующих сервисов. Хотя такая конструкция улучшает модульность и масштабируемость, она также создает серьезный риск: сбой в одной службе может каскадно привести к выходу из строя всей системы.
Если нижестоящая служба становится медленной или не отвечает на запросы, входящие запросы к вашим вышестоящим службам начнут накапливаться. Если все они используют одну и ту же память, процессор или пул потоков, медленная зависимость может быстро исчерпать все доступные ресурсы, что приведет к сбою всего вашего приложения.
Этот каскадный сбой известен как эффект домино. Чтобы предотвратить это, системные архитекторы используют Шаблон переборки.
В этом руководстве мы рассмотрим, что такое шаблон Bulkhead, как он работает и как его реализовать, используя простые аналогии, архитектурные концепции и примеры кода на Java (Resilience4j) и Go.
Реальная аналогия: водонепроницаемые корабельные переборки
Название этого узора пришло из судостроительной промышленности.
Переборка — это водонепроницаемая стена, построенная внутри корпуса корабля. Вместо единого огромного открытого пространства внутри корпуса корабля его внутренняя часть разделена на несколько независимых герметичных отсеков.
Если корабль столкнется с препятствием и его корпус будет пробит, в поврежденный отсек хлынет вода. Однако из-за водонепроницаемых переборок вода удерживается в этом единственном отсеке. Остальная часть корабля остается сухой и плавучей, что позволяет ему оставаться на плаву и достичь безопасного места.
Без переборок вода свободно текла бы по всему корпусу, в конечном итоге затопив корабль.
В программной инженерии:
- The Ship — это все ваше приложение или сервис.
- Отсеки представляют собой изолированные пулы ресурсов (потоки, соединения, ЦП).
- Нарушение корпуса — это сбой или замедление работы нижестоящего микросервиса.
- Наводнение — это истощение ресурсов.
Проблема: общие пулы ресурсов и исчерпание потоков
Чтобы понять, почему необходимы перегородки, давайте посмотрим, что происходит, когда ресурсы распределяются глобально.
Представьте себе шлюз API или веб-сервер, обрабатывающий запросы пользователей. Он имеет единый глобальный пул потоков из 100 потоков для обработки всех входящих вызовов. Сервер взаимодействует с тремя нижестоящими службами:
- Служба каталога (быстро, читает список товаров)
- Платежный сервис (быстро, обрабатывает оплату)
- Служба рекомендаций (медленно, рассчитывает персонализированные предметы)
Обычно все работает нормально. Но предположим, что служба рекомендаций страдает от взаимоблокировки базы данных и начинает требовать 30 секунд для ответа вместо 200 миллисекунд.
Вот что происходит:
- Пользователи продолжают посещать домашнюю страницу, вызывая запросы в Службу рекомендаций.
- Сервер назначает каждому запросу поток из глобального пула.
- Поскольку служба рекомендаций работает медленно, эти потоки ждут ответов.
- Через несколько секунд все 100 потоков в пуле ожидают службы рекомендаций.
- Когда новый пользователь пытается оформить заказ или просмотреть каталог, на сервере не остается потоков для обработки его запроса.
Несмотря на то, что службы каталога и оплаты полностью исправны, они теперь недоступны, поскольку медленная служба рекомендаций исчерпала общий пул потоков. Вся система отключилась.
Решение: шаблон «Переборка»
Шаблон Bulkhead решает эту проблему, разделяя пулы ресурсов так, чтобы сбой в одной области не влиял на другие.
Вместо единого глобального пула мы выделяем отдельные ограниченные пулы для каждой службы или нисходящей зависимости.
Если мы выделим 10 потоков специально для службы рекомендаций, то не более 10 потоков могут быть заблокированы в ожидании этого сервиса. Если служба рекомендаций замедлится, эти 10 потоков будут исчерпаны, а последующие запросы рекомендаций будут немедленно отклонены (быстро).
Однако оставшиеся 90 потоков по-прежнему зарезервированы для служб каталога и оплаты. Пользователи по-прежнему смогут просматривать товары и совершать покупки, даже если виджет рекомендаций временно недоступен.
Типы изоляции переборок
Существует два основных способа реализации переборок в программных системах:
1. Изоляция пула потоков
В этой модели каждой нисходящей зависимости назначается собственный выделенный пул потоков и очередь выполнения.
- Как это работает: Основной поток приложения передает задачу определенному пулу потоков. Если пул заполнен, запрос либо ставится в очередь, либо отклоняется.
- Плюсы: Обеспечивает полную изоляцию. Если служба становится медленной, это затрагивает только ее пул потоков. Потоки изолированы на уровне операционной системы/JVM.
- Минусы: приводит к дополнительной нагрузке на ЦП из-за планирования потоков, переключения контекста и управления очередями.
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 нам не обязательно нужна тяжелая структура, поскольку язык предоставляет встроенные примитивы параллелизма, такие как горутины и буферизованные каналы. Мы можем реализовать чистую переборку семафора, используя буферизованный канал:
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: разделение вычислительных ресурсов или очередей выполнения для премиальных и бесплатных клиентов. Пики ресурсов бесплатного уровня не приводят к истощению запросов ЦП или памяти премиум-уровня.
- Интеграция сторонних API: выделение отдельных пулов HTTP-клиентов для внешних платежных шлюзов, поставщиков услуг доставки или систем уведомлений. Если один сторонний сервис тормозит, другие внешние взаимодействия продолжаются без блокировок.
Почему Kafka/брокеры сообщений не могут заменить шаблон переборки
Распространенный вопрос: “Если у нас есть такие брокеры сообщений, как Apache Kafka, зачем нам нужен шаблон Bulkhead? Разве мы не можем просто использовать очереди для буферизации запросов?”
Хотя брокеры сообщений отделяют системы, они не могут заменить шаблон Bulkhead. Вот почему:
1. Синхронная и асинхронная связь
Kafka разработан для асинхронных, управляемых событиями архитектур. Производитель отправляет сообщение в тему, а потребитель в конечном итоге его обрабатывает. Однако приложениям, ориентированным на пользователя, часто требуется синхронная связь (запрос-ответ) (например, загрузка каталога продуктов или списание средств с кредитной карты через REST/gRPC API). Представление Kafka здесь требует сложных шаблонов запроса-ответа, что увеличивает задержку и накладные расходы. Переборки разработаны специально для защиты этих синхронных потоков выполнения в реальном времени.
2. Потоковое голодание внутри Kafka Consumers
Даже если ваша система полностью управляема событиями и использует Kafka, вам все равно нужны перегородки!
Предположим, что один потребительский микросервис прослушивает несколько тем Kafka (например, user-registrations и video-transcoding). Если потребитель выделит все свои внутренние рабочие потоки для обработки огромного пакета медленных заданий video-transcoding, он столкнется с нехваткой потоков. Потребитель не сможет обрабатывать облегченные сообщения user-registrations, даже если этот раздел исправен. Вам по-прежнему нужны внутренние перегородки (отдельные пулы потоков) внутри потребительской службы для изоляции работы.
3. Накладные расходы на стороне клиента и требования к отказоустойчивости
Когда нижестоящая служба не работает, перегородка позволяет вызывающей службе быстро отработать отказ и немедленно вернуть резервный ответ. Если вместо этого вы поместите все в очередь в Kafka, очередь может расти бесконечно, что приведет к устаревшим запросам, высокому потреблению памяти и задержке тайм-аутов при восстановлении системы.
Короче говоря, Kafka изолирует связь между системами по сети, а перегородки изолируют выполнение ресурсов внутри работающего экземпляра приложения. Они дополняют друг друга, а не исключают друг друга.
Лучшие практики при использовании переборок
- Всегда устанавливайте таймауты: перегородка ограничивает параллелизм, но не решает проблему медленного чтения сокетов. Объедините перегородки со строгими сетевыми таймаутами, чтобы освободить потоки как можно быстрее.
- Объединение с автоматическими выключателями: используйте перегородки рядом с автоматическими выключателями. Если переборка начинает постоянно отклонять запросы, автоматический выключатель должен сработать, чтобы полностью остановить трафик и дать возможность нижестоящему служебному помещению восстановиться.
- Отслеживание насыщения пула. Внедряйте оповещения о длине очереди и количестве активных потоков. Если переборка постоянно заполнена, вам может потребоваться масштабировать инфраструктуру или оптимизировать нижестоящие услуги.
- Настраивайте размеры индивидуально: не используйте универсальные ограничения. Измерьте задержку и частоту запросов каждой зависимости, чтобы определить правильные ограничения переборки.
Заключение
Шаблон переборки — это важный шаблон проектирования для создания отказоустойчивых облачных систем. Разделяя ресурсы, вы изолируете сбои, предотвращаете каскадные эффекты и гарантируете, что локализованная ошибка не перерастет в глобальный сбой.