Todos os artigos

// Knowledge.log — 技術記事

Bulkhead com Resilience4j no Boot 4.1: isolando a dependência lenta da saudável

Configure Bulkhead do Resilience4j por dependência no Spring Boot 4.1 e prove com WireMock que um gateway de pagamento lento não consome as chamadas do estoque.

O gateway de pagamento começa a responder em dois segundos em vez de duzentos milissegundos. Ninguém mexeu no serviço de estoque, mas as chamadas de consulta de estoque também começam a falhar. É o sintoma clássico de pool compartilhado: as threads (ou o crédito de concorrência) que deveriam estar livres para o estoque estão todas presas esperando o pagamento responder.

Bulkhead resolve exatamente isso: cada dependência de saída recebe seu próprio limite de chamadas simultâneas, de forma que estourar o limite de uma não afeta o crédito das outras. Neste artigo a gente configura dois bulkheads independentes com Resilience4j no Spring Boot 4.1, sobe uma dependência lenta com WireMock em processo (sem Docker) e prova com um teste de concorrência real que o isolamento funciona — sem depender de Thread.sleep como critério de aprovação.

Isso é diferente do que vimos no artigo sobre rate limiting com Resilience4j no Boot 4.1 sem Redis. RateLimiter controla admissão de entrada: quantas requisições por segundo a sua API aceita processar. Bulkhead controla isolamento de saída: quantas chamadas simultâneas cada dependência downstream pode receber do seu serviço. São problemas opostos e a confusão entre os dois é comum o bastante para render sua própria seção de armadilhas mais adiante.

Pré-requisitos e versões usadas

Tudo abaixo foi executado neste host, sem inventar número:

  • Java 25.0.4 Temurin.
  • Spring Boot 4.1.1 (a documentação oficial de requisitos do sistema confirma compatibilidade até o Java 26; a versão 4.2.0-M1 é milestone e fica de fora).
  • io.github.resilience4j:resilience4j-spring-boot4:2.4.0 — o starter para Boot 4, não confundir com resilience4j-spring-boot3.
  • AOP via org.springframework.boot:spring-boot-starter-aspectj:4.1.1. O guia de getting started do Resilience4j ainda cita spring-boot-starter-aop, mas essa coordenada retorna 404 para Boot 4.1.1 — troque por starter-aspectj e siga em frente.
  • Teste com org.wiremock:wiremock-standalone:3.13.2. A variante enxuta org.wiremock:wiremock:3.13.2 falhou neste host porque o Jetty 11 que ela espera não está no classpath de teste do Boot 4.1; o standalone empacota o próprio Jetty e resolve isso.

O pom.xml completo do projeto de demonstração:

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>4.1.1</version>
</parent>
<properties>
  <java.version>25</java.version>
  <resilience4j.version>2.4.0</resilience4j.version>
</properties>
<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aspectj</artifactId>
  </dependency>
  <dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-spring-boot4</artifactId>
    <version>${resilience4j.version}</version>
  </dependency>
  <dependency>
    <groupId>org.wiremock</groupId>
    <artifactId>wiremock-standalone</artifactId>
    <version>3.13.2</version>
    <scope>test</scope>
  </dependency>
</dependencies>

ThreadPoolBulkhead vs SemaphoreBulkhead

O Resilience4j oferece dois tipos reais de bulkhead, e eles não são intercambiáveis:

SemaphoreBulkhead (io.github.resilience4j.bulkhead.Bulkhead, BulkheadRegistry) usa um java.util.concurrent.Semaphore executado na própria thread que fez a chamada. Não cria threads novas, não tem fila própria — só limita quantas execuções concorrentes passam pelo portão. Quando o semáforo está saturado e o maxWaitDuration configurado já se esgotou, a chamada recebe BulkheadFullException na hora. É o tipo padrão da anotação @Bulkhead (Type.SEMAPHORE) e o que faz sentido para um método bloqueante feito com RestClient: o limite protege a dependência sem exigir um pool de threads dedicado, e o estouro falha rápido em vez de empilhar chamadas esperando.

ThreadPoolBulkhead (io.github.resilience4j.bulkhead.ThreadPoolBulkhead, ThreadPoolBulkheadRegistry) é dono de um ThreadPoolExecutor próprio com fila limitada. Você submete a chamada com executeSupplier(...) e recebe um CompletionStage/CompletableFuture; o pool e a fila cheios geram RejectedExecutionException, que o Resilience4j embrulha em BulkheadFullException. Faz sentido quando o método já retorna CompletableFuture e você quer que a dependência lenta tenha threads reservadas e isoladas do restante da aplicação — mas se o método for bloqueante e simplesmente fizer .join() no resultado, você voltou a prender a thread do servlet, só que com um passo a mais no meio.

Um jeito rápido de decidir: chamada bloqueante com RestClient, sem CompletableFuture no meio? SEMAPHORE. Já retorna CompletableFuture e você quer um pool dedicado? THREADPOOL. Chamada reativa com WebClient/Mono? Também SEMAPHORE — o próprio Resilience4j documenta que o suporte reativo usa apenas o tipo semáforo, já que criar um pool de threads extra dentro de um pipeline reativo derrota o propósito de não bloquear.

Vale registrar que isso é diferente do padrão do Spring Cloud CircuitBreaker, que por padrão usa FixedThreadPoolBulkhead para chamadas não reativas — o oposto do padrão @Bulkhead. Este artigo fica no resilience4j-spring-boot4 puro; não é sobre a camada de abstração do Spring Cloud, então a configuração YAML dela fica de fora.

Configuração por dependência no Boot 4.1

O ponto central de isolamento não é usar Bulkhead — é usar dois nomes de instância diferentes, um por dependência. Um único bulkhead compartilhado entre pagamento e estoque simplesmente recria o problema original com um nome mais bonito.

resilience4j:
  bulkhead:
    instances:
      payment:
        maxConcurrentCalls: 2
        maxWaitDuration: 0
      inventory:
        maxConcurrentCalls: 8
        maxWaitDuration: 0

management:
  endpoints:
    web:
      exposure:
        include: health,metrics

Repare em maxWaitDuration: 0 nos dois: com espera zero, a chamada que não conseguir permissão recebe BulkheadFullException de imediato, em vez de ficar bloqueada na fila do semáforo esperando uma vaga abrir. É a configuração certa para provar isolamento com um teste determinístico; um maxWaitDuration maior que zero ainda usa a thread de chamada, só que ela fica parada esperando — o que muda o comportamento sob carga mas não seria mais fácil de testar.

E note que management.endpoints.web.exposure.include lista só health,metrics. Nunca inclua env nesse include: ele expõe variáveis de ambiente e propriedades de configuração — incluindo, dependendo do que estiver no classpath, segredos — pelo endpoint do Actuator.

Cada dependência ganha seu próprio método anotado:

@Component
public class DownstreamClients {

    private final RestClient paymentRestClient;
    private final RestClient inventoryRestClient;

    public DownstreamClients(
            @Qualifier("paymentRestClient") RestClient paymentRestClient,
            @Qualifier("inventoryRestClient") RestClient inventoryRestClient) {
        this.paymentRestClient = paymentRestClient;
        this.inventoryRestClient = inventoryRestClient;
    }

    @Bulkhead(name = "payment")
    public String pay() {
        return paymentRestClient.get().uri("/pay").retrieve().body(String.class);
    }

    @Bulkhead(name = "inventory")
    public String stock() {
        return inventoryRestClient.get().uri("/stock").retrieve().body(String.class);
    }
}

Sem type explícito, os dois usam SEMAPHORE — coerente com o que discutimos acima para chamadas bloqueantes via RestClient. O name em cada anotação é o que amarra o método à instância correspondente no YAML; é essa amarração, repetida duas vezes com nomes diferentes, que constitui o isolamento — não uma propriedade mágica do Bulkhead em si.

WireMock em processo, sem Docker

Para simular a dependência lenta sem subir contêiner nenhum, o wiremock-standalone roda como uma dependência de teste comum, embutido na JVM do teste:

static final WireMockServer WM = new WireMockServer(options().dynamicPort());

static {
    WM.start();
    WM.stubFor(get(urlEqualTo("/pay"))
            .willReturn(aResponse().withStatus(200).withBody("paid").withFixedDelay(2000)));
    WM.stubFor(get(urlEqualTo("/stock"))
            .willReturn(aResponse().withStatus(200).withBody("ok")));
}

/pay responde com withFixedDelay(2000) — dois segundos de atraso fixo, simulando o gateway de pagamento lento. /stock responde imediatamente, sem atraso nenhum. dynamicPort() evita conflito de porta entre execuções, e o RestClient de cada dependência aponta para a porta que o WireMock escolheu, via @DynamicPropertySource apontando downstream.payment e downstream.inventory para http://127.0.0.1:{porta}.

Isso cobre o comportamento que o teste precisa exercitar sem tocar em rede externa, sem Testcontainers e sem qualquer dependência de infraestrutura.

O teste de concorrência que prova o isolamento

A prova de isolamento não pode ser "funcionou aqui, parece que sim". Ela precisa contar aceitos, rejeitados e o pico de concorrência real, com o atraso vindo do WireMock — não de um Thread.sleep artificial que só finge que existe contenção.

O teste sobe 6 workers de pagamento e 8 workers de estoque, todos liberados ao mesmo tempo por um CountDownLatch, e cada um chama seu respectivo método anotado:

int paymentWorkers = 6;
int inventoryWorkers = 8;
ExecutorService pool = Executors.newFixedThreadPool(paymentWorkers + inventoryWorkers);
CountDownLatch start = new CountDownLatch(1);

Cada worker de pagamento cai em um destes três buckets: paymentAccepted se clients.pay() retornou "paid", paymentRejected se estourou BulkheadFullException (direto ou como causa de outra exceção), paymentOther para qualquer outro caso. O mesmo padrão vale para o estoque. Um listener em paymentBh.getEventPublisher().onCallPermitted(...) / onCallFinished(...) também acompanha o pico real de chamadas concorrentes permitidas.

Resultado desta execução, com mvn -q -Dtest=BulkheadIsolationTest test:

RESULT paymentAccepted=2 paymentRejected=4 paymentOther=0 paymentMaxPermitted=2 inventoryAccepted=8 inventoryRejected=0 inventoryOther=0 paymentAvailable=2 inventoryAvailable=8

Dos 6 workers de pagamento, exatamente 2 passaram (o maxConcurrentCalls configurado para payment), os outros 4 estouraram BulkheadFullException — nenhum caiu em "outro" tipo de falha, e o pico observado de chamadas permitidas simultâneas nunca passou de 2. Dos 8 workers de estoque, todos os 8 foram aceitos, zero rejeitado — mesmo com o pagamento travado por dois segundos inteiros no meio da janela de teste. Depois que o burst termina, getAvailableConcurrentCalls() volta a 2 para payment e a 8 para inventory: os créditos usados durante o teste foram devolvidos corretamente, não vazaram.

Isso é a demonstração central do artigo: o atraso de dois segundos em /pay nunca reduziu a capacidade disponível para /stock. Os dois bulkheads são contadores independentes.

Métricas de saturação em produção

Em produção você não vai ficar lendo contagem de exceção em log para saber se o bulkhead de pagamento está saturado. O Resilience4j expõe via Micrometer, por instância (tag name):

  • resilience4j.bulkhead.available.concurrent.calls — quantas chamadas ainda cabem agora.
  • resilience4j.bulkhead.max.allowed.concurrent.calls — o teto configurado, útil como referência para calcular percentual de uso.

Se available cai a zero enquanto max continua em 2, a instância payment está saturada naquele instante — é o sinal de alerta que você quer no painel, não uma média de latência que esconde o problema.

Um ponto que vale declarar explicitamente porque é fácil supor o contrário: não existe um contador Micrometer documentado para rejeições de bulkhead. Não há resilience4j.bulkhead.rejected.calls nem equivalente oficial. Para contar rejeições, use o EventPublisher do bulkhead (onCallRejected) alimentando um contador próprio da aplicação, ou consuma BulkheadFullException no ponto de chamada e incremente uma métrica sua. Inventar um nome de meter que não existe na biblioteca só vai gerar um painel vazio em produção.

E uma distinção que separa debugging de dez minutos de debugging de uma hora: rejeição não é timeout. Uma chamada rejeitada pelo bulkhead nunca chegou a ocupar um permit — ela falhou no portão, antes de tocar a rede. Um timeout acontece depois que a chamada já conseguiu entrar (permit concedido) e a dependência simplesmente demorou demais para responder, sendo cortada pelo TimeLimiter ou pelo timeout de leitura do cliente HTTP. Se seu painel mistura os dois em uma métrica só de "erro", você vai investigar rede quando o problema é concorrência, ou vice-versa.

Para expor essas métricas com segurança: management.endpoints.web.exposure.include: health,metrics (mais um endpoint do Prometheus, se você já tiver esse registry configurado). Não coloque env nessa lista: o endpoint devolve propriedades de configuração e, dependendo do classpath, segredos.

Bulkhead não é RateLimiter

Vale amarrar a distinção que abriu o artigo, porque a confusão entre os dois mecanismos é o tipo de coisa que sobrevive a revisão de código. RateLimiter, como vimos no artigo sobre rate limiting com Resilience4j no Boot 4.1 sem Redis, decide quantas requisições por segundo a sua própria API aceita processar — protege você de quem te chama. Bulkhead decide quantas chamadas simultâneas cada dependência de saída pode receber de você — protege as suas dependências (e o resto da sua aplicação) de uma delas ficando lenta. Um serviço pode perfeitamente ter os dois ao mesmo tempo, cobrindo direções diferentes do mesmo tráfego, e um não substitui o outro.

Armadilhas comuns

  • Um bulkhead só para duas dependências. Se payment e inventory compartilham o mesmo name, o estoque volta a competir pelo crédito do pagamento lento — isolamento zero, apesar do código "ter Bulkhead". Confira o name da anotação contra o name do YAML sempre que dependências novas entrarem no serviço.
  • maxWaitDuration maior que zero mudando o comportamento sob saturação. Com espera configurada, a chamada que não conseguiu permit fica parada até o timeout de espera, ainda ocupando a thread de chamada — diferente de falhar na hora. Isso é uma decisão válida de produção (fila curta absorvendo picos), mas não é a mesma coisa que zero, e um teste escrito para zero não vai bater com essa configuração.
  • THREADPOOL em método que faz .join(). Trocar o tipo para THREADPOOL sem também trocar a assinatura do método para retornar CompletableFuture mantém a thread do servlet presa esperando o resultado — você ganhou um pool extra sem tirar a contenção original de lugar nenhum.
  • Seguir a página de getting started ao pé da letra na dependência de AOP. Ela ainda cita spring-boot-starter-aop; em Boot 4.1.1 essa coordenada não existe mais no Maven Central. Use spring-boot-starter-aspectj.
  • Achar que Semaphore "cria threads". Ele não cria nenhuma — só limita quantas execuções concorrentes atravessam o portão na thread de quem chamou. O dimensionamento do pool do Tomcat continua sendo um problema separado; manter maxConcurrentCalls de payment bem abaixo do total de threads do Tomcat é o que garante que o próprio limite do bulkhead nunca vira o novo gargalo.

Recomendação

Se a sua aplicação chama duas ou mais dependências externas com perfis de latência e criticidade diferentes através do mesmo processo, vale configurar Bulkhead por dependência desde o início — o custo é uma seção de YAML e uma anotação por método, e o retorno é a garantia de que o serviço C não cai porque o serviço A decidiu ter um dia ruim. Use SEMAPHORE como padrão para chamadas bloqueantes com RestClient; reserve THREADPOOL para os casos em que o método já é assíncrono e você realmente quer um pool de threads dedicado e isolado para aquela dependência específica.

Recue dessa abordagem se você só tem uma dependência externa relevante (não há o que isolar de quê), ou se a aplicação já usa Spring Cloud CircuitBreaker como camada de abstração — nesse caso a configuração e os padrões default são outros, e misturar os dois namespaces de propriedades é mais fonte de bug do que de proteção. Fora esses casos, o teste de concorrência com WireMock deste artigo é reaproveitável quase palavra por palavra para validar qualquer novo par de dependências que você isolar.

Referências

javaspring-bootresilience4jmicroservices

// Continue.training — 次のステップ

Conhecimento só conta quando vira prática.

Volte ao artigo, execute os exemplos e compartilhe o que aprendeu.

Explorar mais artigos