Arquitetura de software

Sistemas reativos: backpressure, Reactive Streams e Project Reactor

Avançado9 min de leituraAtualizado

Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês

A programação reativa resolve um problema específico: atender muitas conexões simultâneas sem uma thread por conexão, permitindo que um consumidor lento peça a um produtor rápido que diminua o ritmo. Este guia separa as características arquiteturais do modelo de programação, explica com precisão a backpressure e o contrato Reactive Streams, percorre código Reactor e é direto sobre quando o modelo não vale o custo.

O que você vai aprender

  • Explicar as quatro características de um sistema reativo e como elas diferem da programação reativa
  • Descrever o que bloqueia uma thread e por que o I/O não bloqueante muda a curva de escalabilidade
  • Aplicar o contrato Reactive Streams e raciocinar sobre estratégias de backpressure
  • Compor pipelines assíncronos com Flux e Mono do Reactor, incluindo o tratamento de erros
  • Decidir entre uma stack reativa e virtual threads para uma determinada carga de trabalho

"Reativo" nomeia duas coisas diferentes, e misturá-las causa a maior parte da confusão em torno do termo. Um sistema reativo é uma propriedade arquitetural: responde bem sob carga e diante de falhas, o que se consegue isolando componentes e conectando-os por mensagens assíncronas. Programação reativa é um estilo de código para expressar fluxos de dados assíncronos como pipelines de operadores. Dá para construir um sistema totalmente reativo com filas simples e consumidores bloqueantes, e dá para escrever código reativo que não entrega nenhuma das propriedades arquiteturais. Vale deixar claro sobre qual das duas se está falando.

O problema de engenharia por trás disso é estreito e real. Um servidor que dedica uma thread do sistema operacional a cada requisição em andamento consegue manter alguns milhares delas antes que os custos de memória e de agendamento dominem, e a maioria dessas threads passa o tempo bloqueada esperando um banco de dados ou uma API downstream, sem calcular nada. Bibliotecas reativas permitem que um número pequeno de threads carregue muitas requisições em andamento, e acrescentam um mecanismo para que um consumidor lento limite um produtor rápido. Todo o resto é consequência.

As quatro características

O manifesto reativo descreve um sistema como reativo quando ele apresenta quatro propriedades relacionadas.

Responsivo: responde em um tempo previsível e, quando não consegue, avisa rapidamente em vez de deixar quem chamou esperando. Esse é o objetivo; as outras três são meios.

Resiliente: a falha de um componente fica contida e não vira a falha do todo. Isso se obtém com isolamento, replicação e delegação da recuperação a um supervisor, em vez de esperar que o componente com falha se conserte sozinho.

Elástico: o throughput escala com os recursos nas duas direções, o que exige não haver gargalo central nem estado mutável compartilhado que precise ser coordenado.

Orientado a mensagens: os componentes se comunicam por troca de mensagens assíncronas, que é o que cria as fronteiras que tornam o isolamento e a elasticidade possíveis, e o que permite expressar explicitamente o gerenciamento de carga e a backpressure.

Lido dessa forma, "sistema reativo" descreve objetivos que qualquer plataforma de microsserviços orientada a eventos bem construída já persegue. A contribuição do manifesto é dar nome à relação entre fronteiras assíncronas e resiliência, não inventar uma nova arquitetura.

O custo de bloquear

Para entender por que o não bloqueante importa, acompanhe uma thread ao longo de uma requisição típica. O handler recebe a requisição, dispara uma consulta SQL e chama ResultSet.next(). Essa chamada acaba fazendo uma leitura de socket, e a thread não consegue avançar até que os bytes cheguem. O sistema operacional a coloca em espera: seus 512 KB a 1 MB de pilha continuam alocados, seus registradores são salvos e o escalonador segue em frente. Durante os 20 ms da consulta, a thread não faz nada e ocupa memória.

Com um pool de 200 threads e uma latência média de dependências de 20 ms, a conta não perdoa. A lei de Little diz que a concorrência é igual ao throughput multiplicado pela latência; portanto, 200 threads simultâneas a 20 ms rendem, na melhor das hipóteses, cerca de 10 000 requisições por segundo, e cada requisição que chega além disso entra na fila, o que acrescenta latência, o que aumenta a concorrência, o que alonga a fila. Esse é o mecanismo por trás do precipício do qual os servidores bloqueantes despencam, explorado em mais detalhes em desempenho de aplicações.

O I/O não bloqueante quebra o acoplamento entre requisição e thread. O socket é registrado em um seletor; quando há dados prontos, uma thread do event loop pega a continuação e a executa. Um punhado de threads de event loop (por convenção, uma por núcleo) carrega milhares de requisições em andamento, porque uma requisição em andamento custa um objeto pequeno, e não uma pilha de thread.

O preço é que o código deixa de ser uma sequência de instruções. A continuação precisa ser expressa como callback, e callbacks que chamam callbacks ficam ilegíveis rapidamente. As bibliotecas reativas existem para tornar essa composição suportável.

Reactive Streams e backpressure

O push assíncrono tem um modo de falha próprio: um produtor mais rápido que seu consumidor enche a memória até o processo morrer. Backpressure é a capacidade do consumidor de sinalizar quanto consegue absorver.

A especificação Reactive Streams define o contrato mínimo para isso, com quatro interfaces, espelhadas no JDK como java.util.concurrent.Flow.

public interface Publisher<T> { void subscribe(Subscriber<? super T> s); }

public interface Subscriber<T> {
    void onSubscribe(Subscription s);
    void onNext(T item);          // never called more times than requested
    void onError(Throwable t);    // terminal
    void onComplete();            // terminal
}

public interface Subscription {
    void request(long n);         // demand: "I can handle n more"
    void cancel();
}

public interface Processor<T, R> extends Subscriber<T>, Publisher<R> {}

A regra essencial é que nada flui até que o assinante chame request(n). O publisher pode emitir no máximo n elementos antes que o assinante peça mais; assim, a demanda é puxada enquanto os dados são empurrados. Os sinais são serializados (nunca há onNext concorrentes para um mesmo assinante), e onError e onComplete são terminais: depois deles, nada mais chega. Como o contrato é um conjunto de interfaces e não uma implementação, as bibliotecas interoperam: um Flux do Reactor pode assinar um publisher produzido por outra implementação.

Quando uma fonte realmente não pode ser desacelerada (leituras de sensores, um feed de dados de mercado, um upstream que não respeita a demanda), você precisa escolher o que sacrificar.

EstratégiaComportamentoAdequada quando
BufferEnfileirar o excedente em memóriaOs picos são curtos e limitados; sempre defina um limite
DropDescartar itens novos quando o consumidor está atrasadoFeeds de último valor, em que dado desatualizado é pior que perda
LatestManter apenas o item mais recenteDashboards, cotações de preço, atualizações de posição
ErrorFalhar o stream com um sinal de overflowPerder dados é inaceitável e falhar ruidosamente é o correto
Bloquear a fonteDesacelerar o produtor na origemO produtor é seu e pode ser limitado

Escolher "buffer" sem limite é o mesmo que escolher "ficar sem memória mais tarde".

Reactor na prática

O Project Reactor implementa Reactive Streams e oferece dois tipos de publisher: Mono<T> para zero ou um elemento e Flux<T> para zero a muitos. Ambos são lazy: declarar um pipeline não faz nada até que alguém o assine, uma fonte frequente de confusões do tipo "meu código nunca rodou".

Mono<OrderView> orderView(String orderId) {
    Mono<Order> order = orders.findById(orderId)                 // Mono<Order>
        .switchIfEmpty(Mono.error(new UnknownOrder(orderId)));

    return order.flatMap(o ->
        Mono.zip(                                                // run both calls concurrently
                customers.findById(o.customerId())
                         .timeout(Duration.ofMillis(300))
                         .onErrorResume(e -> Mono.just(Customer.unknown())),
                pricing.quote(o)
                       .retryWhen(Retry.backoff(3, Duration.ofMillis(50)).jitter(0.5))
            )
            .map(t -> new OrderView(o, t.getT1(), t.getT2())));
}

Três hábitos separam o código Reactor que dá para manter daquele que as equipes acabam abandonando. Use flatMap quando a própria transformação retorna um publisher e map quando não retorna; confundir os dois produz Mono<Mono<T>>. Use zip para executar chamadas independentes em paralelo em vez de encadeá-las, já que chamadas flatMap sequenciais esperam umas pelas outras. E coloque timeout, retryWhen e onErrorResume onde a falha faz sentido, e não uma única vez no fim do pipeline.

Fazer streaming de um conjunto de resultados grande é onde o modelo realmente brilha, porque nada é materializado por inteiro:

Flux<Trade> stream = trades.findByBook(bookId)          // reactive driver, backpressure-aware
    .limitRate(256)                                     // request in batches of 256
    .filter(t -> t.notional().compareTo(THRESHOLD) > 0)
    .buffer(Duration.ofSeconds(1))                      // group by time window
    .flatMap(batch -> enrichment.enrich(batch), 4)      // at most 4 concurrent enrichments
    .onBackpressureBuffer(10_000,
        dropped -> log.warn("dropping {}", dropped.id()),
        BufferOverflowStrategy.DROP_OLDEST);

O agendamento é explícito e vale a pena entendê-lo. O Reactor não escolhe as threads por você: por padrão, o trabalho roda na thread que emitiu o sinal. subscribeOn decide onde a assinatura (e, portanto, a fonte) roda, e afeta toda a cadeia upstream. publishOn troca a thread de tudo o que vem downstream daquele ponto. Quando código bloqueante é inevitável, isole-o:

Mono.fromCallable(() -> legacyJdbcLookup(id))       // blocking, third-party, no reactive driver
    .subscribeOn(Schedulers.boundedElastic())       // dedicated pool, never the event loop
    .timeout(Duration.ofSeconds(2));

A depuração merece um alerta. Um stack trace de um pipeline reativo mostra a maquinaria dos operadores, não a linha de código de negócio que montou o pipeline. Ative Hooks.onOperatorDebug() em desenvolvimento ou adicione o agente reactor-tools, e propague um identificador de correlação pelo contexto do Reactor em vez de um ThreadLocal, porque não há afinidade de thread em que confiar. Pelo mesmo motivo, o logging baseado em MDC precisa de ajuda explícita.

Quando o reativo compensa, e quando não

O modelo só justifica sua complexidade em uma faixa estreita de cargas de trabalho.

Compensa quando um serviço é um agregador limitado por I/O que mantém muitas conexões, em sua maioria ociosas; quando você precisa de semântica de streaming (server-sent events, WebSockets, um consumidor Kafka com controle de fluxo, como em produtores e consumidores Kafka); quando a memória por conexão é a restrição determinante; ou quando a carga realmente tem formato de stream e o vocabulário de operadores para combinar, janelar e limitar a taxa economiza bastante código.

Não compensa quando o trabalho é limitado por CPU, já que I/O não bloqueante não faz nada pela computação. Não compensa quando a stack bloqueia em algum ponto que importa: uma camada web reativa na frente de um driver JDBC bloqueante entrega a dificuldade de depuração sem nada da escalabilidade. Não compensa para serviços que atendem algumas centenas de requisições simultâneas, que são a maioria dos serviços corporativos internos. E não compensa quando a equipe não vai mantê-lo: código reativo é mais difícil de ler, mais difícil de depurar e implacável com uma única chamada bloqueante, e uma base de código convertida pela metade é pior que qualquer um dos extremos.

Virtual threads como alternativa

As virtual threads, padronizadas no JDK 21, mudam essa conta para a maioria das aplicações. Uma virtual thread é agendada pela JVM sobre um pequeno pool de carrier threads; quando executa uma operação bloqueante em um channel ou socket, a JVM a desmonta do carrier, que passa a executar outra coisa. O custo do bloqueio desaparece e o código continua sendo Java sequencial comum.

AspectoReativo (Reactor)Virtual threads
Estilo de códigoPipelines de operadores, callbacksCódigo sequencial comum
Chamadas bloqueantesPrecisam ser isoladas em um scheduler separadoSem problema: é justamente essa a ideia
Stack tracesFrames de operadores, exige hooks de depuraçãoCompletos e familiares
BackpressureEmbutida no contratoNão oferecida; use filas limitadas e semáforos
Operadores de streamingVocabulário ricoManual, ou com uma biblioteca de streams por cima
Profiling e ferramentasEspecializadosJFR padrão, thread dumps, depuradores
Curva de aprendizadoÍngremeDesprezível

A orientação prática para novos serviços JVM em 2026: comece com virtual threads e um modelo de thread por requisição, que é mais simples e entrega a maior parte do benefício de concorrência. Recorra a uma biblioteca reativa quando precisar de backpressure entre componentes ou do vocabulário de operadores de streaming, e não simplesmente porque precisa lidar com muitas conexões. O guia de Java sobre concorrência e executors cobre o modelo de threads em detalhe. Atenção às ressalvas que restam: em versões mais antigas, uma virtual thread fixada (pinned) por um bloco synchronized que segura um lock durante uma chamada bloqueante continua ocupando o carrier, então prefira ReentrantLock nos caminhos críticos; e colocar virtual threads em um pool anula completamente o propósito delas.

Principais pontos

  • Um sistema reativo é uma propriedade arquitetural (responsivo, resiliente, elástico, orientado a mensagens); programação reativa é uma forma de implementar partes dele, não a mesma coisa.
  • O benefício concreto do I/O não bloqueante é desacoplar as requisições em andamento das threads, o que muda a forma como um servidor se degrada sob alta concorrência, e não a velocidade de uma requisição.
  • Backpressure é a característica que define Reactive Streams: nada é emitido além do que o assinante solicitou, e buffer sem limite é uma falha adiada.
  • Flux e Mono do Reactor são lazy, se compõem de forma declarativa e exigem agendamento deliberado: qualquer chamada bloqueante deve ser isolada em boundedElastic.
  • O reativo compensa em cargas limitadas por I/O, de streaming e com muitas conexões, e custa caro em todo o resto, especialmente quando alguma camada ainda bloqueia.
  • Para a maioria dos novos serviços JVM, virtual threads entregam a escalabilidade com código e ferramentas comuns; escolha o reativo quando precisar especificamente de backpressure ou de operadores de streams.

Fontes e leituras complementares

Perguntas frequentes

Código reativo é mais rápido que código bloqueante?
Não por requisição: uma chamada individual costuma levar um pouco mais de tempo, por causa do agendamento e do overhead dos operadores. O que muda é o formato da curva sob concorrência: um servidor bloqueante se degrada bruscamente quando o pool de threads se esgota, enquanto um servidor não bloqueante continua aceitando conexões com um crescimento modesto de memória. Se o seu serviço atende algumas centenas de requisições simultâneas, você não verá benefício nenhum.
O que acontece de fato se eu chamar uma API bloqueante dentro de um pipeline reativo?
Você ocupa uma das pouquíssimas threads do event loop (normalmente uma por núcleo de CPU) durante toda a chamada, e todas as outras requisições atribuídas a essa thread ficam esperando. Algumas poucas chamadas assim podem travar o servidor inteiro. Se você precisar chamar código bloqueante, agende-o em um scheduler bounded elastic com subscribeOn ou publishOn para que ele rode fora do event loop.
As virtual threads tornam a programação reativa obsoleta?
Elas eliminam a principal justificativa dela para a maioria das aplicações. As virtual threads oferecem o comportamento de escalabilidade do I/O não bloqueante mantendo código sequencial comum, stack traces depuráveis e try/catch. O que elas não oferecem é backpressure nem um vocabulário declarativo de operadores para combinar, bufferizar e janelar streams; por isso, cargas de trabalho que realmente têm formato de stream continuam se beneficiando de uma biblioteca reativa.
Qual a diferença entre um Flux e um Stream do Java?
Um Stream é puxado de forma síncrona pelo consumidor e termina quando a operação terminal acaba, então não consegue representar algo que produz valores ao longo do tempo. Um Flux é empurrado de forma assíncrona, pode ser infinito, trata conclusão e erro como sinais de primeira classe e permite que o assinante solicite um número limitado de elementos. Streams modelam coleções; Flux modela eventos chegando.