O padrão Bulkhead: projetando microsserviços tolerantes a falhas

O padrão Bulkhead: projetando microsserviços tolerantes a falhas

Em uma arquitetura de microsserviços, um único aplicativo é dividido em dezenas ou centenas de serviços independentes e colaborativos. Embora esse design melhore a modularidade e a escalabilidade, ele também apresenta um grande risco: uma falha em um serviço pode se espalhar e derrubar todo o sistema.

Se um serviço downstream ficar lento ou não responder, as solicitações recebidas para seus serviços upstream começarão a se acumular. Se todos compartilharem a mesma memória, CPU ou pool de threads, uma dependência lenta pode esgotar rapidamente todos os recursos disponíveis, causando falha em todo o aplicativo.

Essa falha em cascata é conhecida como efeito dominó. Para evitar isso, os arquitetos de sistema usam o Padrão Bulkhead.

Neste guia, exploraremos o que é o padrão Bulkhead, como ele funciona e como implementá-lo usando analogias simples, conceitos de arquitetura e exemplos de código em Java (Resilience4j) e Go.


A analogia do mundo real: anteparas estanques de navios

O nome desse padrão vem da indústria de construção naval.

Uma antepara é uma parede estanque construída dentro do casco de um navio. Em vez de ter um único e enorme espaço aberto dentro do casco do navio, o interior é dividido em vários compartimentos independentes e selados.

Diagrama de arquitetura de padrão Bulkhead mostrando pools de threads compartilhados e isolados

Se o navio colidir com um obstáculo e seu casco for rompido, a água inundará o compartimento danificado. No entanto, devido às anteparas estanques, a água fica contida naquele único compartimento. O resto do navio permanece seco e flutuante, permitindo que ele permaneça à tona e alcance segurança.

Sem anteparas, a água fluiria livremente por todo o casco, acabando por afundar o navio.

Em engenharia de software:

  • The Ship é todo o seu aplicativo ou serviço.
  • Os Compartimentos são pools de recursos isolados (threads, conexões, CPU).
  • A violação do casco é uma falha ou lentidão em um microsserviço downstream.
  • A Inundação é o esgotamento dos recursos.

O problema: pools de recursos compartilhados e esgotamento de threads

Para entender por que os anteparos são necessários, vejamos o que acontece quando os recursos são compartilhados globalmente.

Imagine um API Gateway ou um servidor web lidando com solicitações de usuários. Possui um único pool de threads global de 100 threads para processar todas as chamadas recebidas. O servidor interage com três serviços downstream:

  1. Serviço de Catálogo (rápido, lê lista de produtos)
  2. Serviço de pagamento (rápido, processa a finalização da compra)
  3. Serviço de recomendação (lento, calcula itens personalizados)

Normalmente, tudo funciona bem. Mas suponha que o serviço de recomendação sofra um impasse no banco de dados e comece a levar 30 segundos para responder em vez de 200 milissegundos.

Aqui está o que acontece:

  1. Os usuários continuam visitando a página inicial, acionando solicitações ao Serviço de Recomendação.
  2. O servidor atribui um thread do pool global para cada solicitação.
  3. Como o serviço de recomendação é lento, esses threads ficam aguardando respostas.
  4. Em segundos, todos os 100 threads do pool estarão aguardando no Serviço de Recomendação.
  5. Quando um novo usuário tenta fazer check-out ou visualizar o catálogo, o servidor não tem mais threads para processar sua solicitação.

Embora os serviços de Catálogo e Pagamento estejam completamente íntegros, eles agora estão inacessíveis porque o lento Serviço de Recomendação esgotou o conjunto de encadeamentos compartilhados. Todo o sistema ficou offline.


A solução: o padrão Bulkhead

O Padrão Bulkhead resolve esse problema particionando pools de recursos para que uma falha em uma área não afete as outras.

Em vez de um único pool global, alocamos pools separados e limitados para cada serviço ou dependência downstream.

Se alocarmos 10 threads especificamente para o Serviço de Recomendação, então no máximo 10 threads poderão ser bloqueados aguardando nele. Se o Serviço de Recomendação ficar mais lento, esses 10 threads serão esgotados e as solicitações de recomendação subsequentes serão rejeitadas imediatamente (falha rápida).

No entanto, os 90 threads restantes ainda estão reservados para os serviços de Catálogo e Pagamento. Os usuários ainda poderão navegar pelos produtos e fazer compras, mesmo que o widget de recomendação esteja temporariamente indisponível.


Tipos de isolamento de anteparo

Existem duas maneiras principais de implementar anteparos em sistemas de software:

1. Isolamento do pool de threads

Neste modelo, cada dependência downstream recebe seu próprio pool de threads dedicado e fila de execução.

Diagrama de isolamento do pool de threads mostrando solicitações de entrada enfileiradas para threads de trabalho
  • Como funciona: O thread principal do aplicativo transfere a tarefa para um pool de threads específico. Se o pool estiver cheio, a solicitação será colocada na fila ou rejeitada.
  • Prós: Fornece isolamento completo. Se um serviço ficar lento, apenas seu pool de threads será afetado. Threads são isolados no nível do sistema operacional/JVM.
  • Contras: Introduz sobrecarga extra de CPU devido ao agendamento de threads, alternância de contexto e gerenciamento de filas.

2. Isolamento de semáforo

Em vez de criar novos conjuntos de threads, o isolamento de semáforo usa um contador (um semáforo) para limitar o número de chamadas simultâneas permitidas para um serviço específico.

Diagrama de isolamento de semáforo mostrando threads de solicitação executando tarefas após adquirir uma licença
  • Como funciona: Quando uma solicitação é iniciada, ela tenta adquirir uma permissão do semáforo. Se uma licença estiver disponível, ele executa a solicitação no thread de chamada e libera a licença quando terminar. Se não houver licenças disponíveis, o pedido será imediatamente rejeitado.
  • Prós: Muito leve, com sobrecarga praticamente zero, já que nenhuma alternância de contexto de thread está envolvida.
  • Contras: Sem separação de threads. Se uma chamada for bloqueada em um soquete de rede sem um tempo limite adequado, ela ainda poderá bloquear o thread de chamada.

Exemplos de implementação

Vejamos como implementar anteparos em duas linguagens de back-end populares.

1. Java (Resilience4j e Spring Boot)

Resilience4j é uma biblioteca de tolerância a falhas leve e fácil de usar, projetada para Java. Abaixo está como você configura um anteparo para um serviço de pagamento downstream em um aplicativo Spring Boot.

Configuração (application.yml)

resilience4j.bulkhead:
  instances:
    paymentService:
      maxConcurrentCalls: 10
      maxWaitDuration: 10ms

resilience4j.threadpoolbulkhead:
  instances:
    paymentService:
      maxThreadPoolSize: 10
      coreThreadPoolSize: 5
      queueCapacity: 20

Implementação de código

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. Vá (Golang)

No Go, não precisamos necessariamente de uma estrutura pesada porque a linguagem fornece primitivas de simultaneidade nativas, como Goroutines e canais em buffer. Podemos implementar um anteparo de semáforo limpo usando um canal em buffer:

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)
}

Casos de uso comuns para o padrão Bulkhead

Aqui estão alguns cenários típicos em que a implementação de um padrão de anteparo é crítica:

  • Roteamento de gateway de API: isolamento de rotas para diferentes serviços de back-end. Se o Serviço de Recomendação falhar, as rotas do Serviço de Pedido no Gateway permanecerão totalmente operacionais.
  • Pools de conexões de banco de dados: divisão de pools de conexões de banco de dados por serviço ou locatário. Uma onda de consultas analíticas pesadas de um locatário não esgotará todos os identificadores de conexão disponíveis, salvando consultas transacionais para outros locatários.
  • Aplicativos SaaS multilocatários: separação de recursos de computação ou filas de execução para locatários premium e gratuitos. Os picos de recursos do nível gratuito não privarão as solicitações de CPU ou memória do nível premium.
  • Integrações de API de terceiros: Dedicação de pools de clientes HTTP separados para gateways de pagamento externos, provedores de remessa ou mecanismos de notificação. Se um serviço de terceiros ficar lento, outras interações externas continuarão sem bloqueios.

Por que Kafka/Message Brokers não podem substituir o padrão Bulkhead

Uma pergunta comum é: “Se temos corretores de mensagens como o Apache Kafka, por que precisamos do padrão Bulkhead? Não podemos simplesmente usar filas para armazenar solicitações em buffer?”

Embora os corretores de mensagens dissociem os sistemas, eles não podem substituir o padrão Bulkhead. Aqui está o porquê:

1. Comunicação síncrona vs. comunicação assíncrona

Kafka foi projetado para arquiteturas assíncronas e orientadas a eventos. O produtor envia uma mensagem para um tópico e o consumidor eventualmente a processa. No entanto, os aplicativos voltados para o usuário geralmente exigem comunicação síncrona (solicitação-resposta) (por exemplo, carregar um catálogo de produtos ou cobrar um cartão de crédito por meio de uma API REST/gRPC). A introdução do Kafka aqui requer padrões complexos de solicitação-resposta, adicionando alta latência e sobrecarga. Bulkheads são projetados especificamente para proteger esses threads de execução síncrona em tempo real.

2. Thread Starvation dentro dos consumidores Kafka

Mesmo que o seu sistema seja totalmente orientado a eventos e use Kafka, você ainda precisará de anteparos! Suponha que um único microsserviço consumidor ouça vários tópicos Kafka (por exemplo, user-registrations e video-transcoding). Se o consumidor alocar todos os seus threads de trabalho internos para processar um lote enorme de tarefas video-transcoding lentas, ele sofrerá privação de thread. O consumidor não será capaz de processar mensagens user-registrations leves, mesmo que essa partição esteja íntegra. Você ainda precisa de anteparos internos (conjuntos de threads separados) dentro do serviço do consumidor para isolar o trabalho.

3. Sobrecarga do lado do cliente e requisitos de falha rápida

Quando um serviço downstream está inativo, um anteparo permite que o serviço de chamada falhe rapidamente e retorne uma resposta de fallback imediatamente. Se você colocar tudo na fila no Kafka, a fila poderá crescer infinitamente, levando a solicitações obsoletas, alto consumo de memória e atrasos no tempo limite quando o sistema se recuperar.

Resumindo, Kafka desacopla a comunicação entre sistemas na rede, enquanto Bulkheads isolam a execução de recursos dentro de uma instância de aplicativo em execução. Eles são complementares e não mutuamente exclusivos.


Melhores práticas ao usar anteparos

  • Sempre definir tempos limite: um anteparo limita a simultaneidade, mas não resolve leituras lentas de soquete. Combine anteparos com tempos limite de rede rígidos para liberar threads o mais rápido possível.
  • Combinar com disjuntores: Use anteparas ao lado dos disjuntores. Se um anteparo começar a rejeitar solicitações de forma consistente, o disjuntor deverá desarmar para interromper completamente o tráfego e dar espaço para o serviço downstream se recuperar.
  • Monitore a saturação do pool: implemente alertas sobre o comprimento da fila do bulkhead e contagens de threads ativos. Se um anteparo estiver constantemente cheio, talvez seja necessário dimensionar sua infraestrutura ou otimizar o serviço downstream.
  • Ajuste os tamanhos individualmente: não use um limite único. Meça a latência e a taxa de solicitação de cada dependência para determinar os limites corretos do anteparo.

Conclusão

O Bulkhead Pattern é um padrão de design essencial para a construção de sistemas resilientes em escala de nuvem. Ao particionar seus recursos, você isola falhas, evita efeitos em cascata e garante que um bug localizado não se transforme em uma interrupção global.