Arquitectura de software
Sistemas reactivos: backpressure, Reactive Streams y Project Reactor
Avanzado10 min de lecturaActualizado
Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés
La programación reactiva resuelve un problema concreto: atender muchas conexiones simultáneas sin dedicar un hilo a cada una, y permitir que un consumidor lento pida a un productor rápido que baje el ritmo. Esta guía separa las propiedades arquitectónicas del modelo de programación, explica con precisión la backpressure y el contrato Reactive Streams, recorre código de Reactor y dice sin rodeos cuándo el modelo no compensa lo que cuesta.
Lo que aprenderás
- Explicar las cuatro propiedades de un sistema reactivo y en qué se diferencian de la programación reactiva
- Describir qué bloquea un hilo y por qué la I/O no bloqueante cambia la curva de escalado
- Aplicar el contrato Reactive Streams y razonar sobre las estrategias de backpressure
- Componer pipelines asíncronos con Flux y Mono de Reactor, incluido el manejo de errores
- Decidir entre un stack reactivo y los virtual threads para una carga de trabajo concreta
«Reactivo» designa dos cosas distintas, y mezclarlas provoca casi toda la confusión que rodea al término. Un sistema reactivo es una propiedad arquitectónica: responde bien bajo carga y ante fallos, y lo consigue aislando componentes y conectándolos mediante mensajes asíncronos. La programación reactiva es un estilo de código que expresa flujos de datos asíncronos como pipelines de operadores. Puedes construir un sistema completamente reactivo con colas normales y consumidores bloqueantes, y puedes escribir código reactivo que no te aporte ninguna de esas propiedades arquitectónicas. Conviene tener claro de cuál de las dos se está hablando.
El problema de ingeniería de fondo es acotado y real. Un servidor que dedica un hilo del sistema operativo a cada petición en curso puede sostener unos pocos miles antes de que los costes de memoria y de planificación se impongan, y la mayoría de esos hilos se pasan el tiempo bloqueados esperando a una base de datos o a una API aguas abajo, sin calcular nada. Las bibliotecas reactivas permiten que un número pequeño de hilos lleve muchas peticiones en curso, y añaden un mecanismo para que un consumidor lento limite a un productor rápido. Todo lo demás es consecuencia de ello.
Las cuatro propiedades
El manifiesto reactivo considera reactivo un sistema cuando presenta cuatro propiedades relacionadas.
Responsivo: responde en un tiempo predecible y, cuando no puede, lo comunica rápido en lugar de dejar esperando a quien llama. Este es el objetivo; las otras tres son medios.
Resiliente: el fallo de un componente queda contenido y no se convierte en el fallo del conjunto. Se logra mediante aislamiento, replicación y delegando la recuperación en un supervisor, en vez de esperar que el componente que falla se arregle solo.
Elástico: el throughput escala con los recursos en ambas direcciones, lo que exige que no haya un cuello de botella central ni estado mutable compartido que haya que coordinar.
Orientado a mensajes: los componentes se comunican mediante paso de mensajes asíncrono, que es lo que crea las fronteras que hacen posibles el aislamiento y la elasticidad, y lo que permite expresar de forma explícita la gestión de carga y la backpressure.
Visto así, «sistema reactivo» describe objetivos que ya persigue cualquier plataforma de microservicios orientada a eventos bien construida. La aportación del manifiesto es poner nombre a la relación entre las fronteras asíncronas y la resiliencia, no inventar una arquitectura nueva.
Lo que cuesta bloquear
Para ver por qué importa no bloquear, sigue a un hilo a lo largo de una petición típica. El handler recibe la petición, lanza una consulta SQL y llama a ResultSet.next(). Esa llamada acaba haciendo una lectura de socket, y el hilo no puede continuar hasta que lleguen los bytes. El sistema operativo lo aparca: sus 512 KB a 1 MB de pila siguen reservados, sus registros se guardan y el planificador pasa a otra cosa. Durante los 20 ms que tarda la consulta, el hilo no hace nada y ocupa memoria.
Con un pool de 200 hilos y una latencia media de las dependencias de 20 ms, las cuentas no perdonan. La ley de Little dice que la concurrencia es igual al throughput multiplicado por la latencia, así que 200 hilos concurrentes a 20 ms dan, como mucho, unas 10 000 peticiones por segundo; cada petición que llega por encima de eso se encola, lo que añade latencia, lo que aumenta la concurrencia, lo que alarga la cola. Ese es el mecanismo detrás del precipicio por el que caen los servidores bloqueantes, que se analiza con más detalle en rendimiento de aplicaciones.
La I/O no bloqueante rompe el acoplamiento entre petición e hilo. El socket se registra en un selector; cuando hay datos listos, un hilo del event loop recoge la continuación y la ejecuta. Un puñado de hilos de event loop (por convención, uno por núcleo) lleva miles de peticiones en curso, porque una petición en curso cuesta un objeto pequeño y no una pila de hilo.
El precio es que el código deja de ser una secuencia de sentencias. La continuación tiene que expresarse como un callback, y los callbacks que llaman a otros callbacks se vuelven ilegibles enseguida. Las bibliotecas reactivas existen para que esa composición resulte llevadera.
Reactive Streams y backpressure
El push asíncrono tiene su propio modo de fallo: un productor más rápido que su consumidor llena la memoria hasta que el proceso muere. La backpressure es la capacidad del consumidor de indicar cuánto puede asumir.
La especificación Reactive Streams define el contrato mínimo para ello con cuatro interfaces, replicadas en el 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> {}
La regla esencial es que no fluye nada hasta que el suscriptor llama a request(n). El publisher puede emitir como máximo n elementos antes de que el suscriptor pida más, de modo que la demanda se tira mientras los datos se empujan. Las señales están serializadas (nunca hay onNext concurrentes para un mismo suscriptor), y onError y onComplete son terminales: después no llega nada más. Como el contrato es un conjunto de interfaces y no una implementación, las bibliotecas interoperan: un Flux de Reactor puede suscribirse a un publisher creado por otra implementación.
Cuando una fuente realmente no se puede frenar (lecturas de sensores, un feed de datos de mercado, un upstream que no respeta la demanda), tienes que elegir qué sacrificar.
| Estrategia | Comportamiento | Adecuada cuando |
|---|---|---|
| Buffer | Encolar el excedente en memoria | Las ráfagas son cortas y acotadas; pon siempre un límite |
| Drop | Descartar los elementos nuevos cuando el consumidor va retrasado | Feeds de último valor, donde un dato desfasado es peor que perderlo |
| Latest | Quedarse solo con el elemento más reciente | Dashboards, tickers de precios, actualizaciones de posición |
| Error | Hacer fallar el stream con una señal de desbordamiento | Perder datos es inaceptable y fallar ruidosamente es lo correcto |
| Bloquear la fuente | Frenar al productor en origen | El productor es tuyo y se puede limitar |
Elegir «buffer» sin límite equivale a elegir «quedarse sin memoria más tarde».
Reactor en la práctica
Project Reactor implementa Reactive Streams y ofrece dos tipos de publisher: Mono<T> para cero o un elemento y Flux<T> para cero o muchos. Ambos son perezosos: declarar un pipeline no hace nada hasta que alguien se suscribe, lo que provoca a menudo el clásico «mi código nunca se ejecutó».
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())));
}
Tres hábitos separan el código Reactor mantenible del que los equipos acaban abandonando. Usa flatMap cuando la transformación devuelve a su vez un publisher y map cuando no; confundirlos produce Mono<Mono<T>>. Usa zip para ejecutar en paralelo llamadas independientes en lugar de encadenarlas, porque los flatMap secuenciales se esperan unos a otros. Y coloca timeout, retryWhen y onErrorResume allí donde el fallo tiene sentido, no una sola vez al final del pipeline.
Hacer streaming de un conjunto de resultados grande es donde el modelo brilla de verdad, porque nunca se materializa nada por completo:
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);
La planificación es explícita y conviene entenderla. Reactor no elige los hilos por ti: por defecto, el trabajo se ejecuta en el hilo que emitió la señal. subscribeOn decide dónde se ejecuta la suscripción (y, por tanto, la fuente) y afecta a toda la cadena aguas arriba. publishOn cambia de hilo para todo lo que viene aguas abajo de ese punto. Cuando el código bloqueante es inevitable, aíslalo:
Mono.fromCallable(() -> legacyJdbcLookup(id)) // blocking, third-party, no reactive driver
.subscribeOn(Schedulers.boundedElastic()) // dedicated pool, never the event loop
.timeout(Duration.ofSeconds(2));
La depuración merece una advertencia. Un stack trace de un pipeline reactivo muestra la maquinaria de los operadores, no la línea de código de negocio que montó el pipeline. Activa Hooks.onOperatorDebug() en desarrollo o añade el agente reactor-tools, y propaga un identificador de correlación a través del contexto de Reactor en lugar de un ThreadLocal, porque no hay afinidad de hilo en la que apoyarse. Por la misma razón, el logging basado en MDC necesita ayuda explícita.
Cuándo compensa lo reactivo y cuándo no
El modelo justifica su complejidad solo en una franja estrecha de cargas de trabajo.
Compensa cuando un servicio es un agregador limitado por I/O que mantiene muchas conexiones casi siempre inactivas, cuando necesitas semántica de streaming (server-sent events, WebSockets, un consumidor de Kafka con control de flujo, como en productores y consumidores de Kafka), cuando la memoria por conexión es la restricción determinante, o cuando la carga tiene de verdad forma de stream y el vocabulario de operadores para combinar, agrupar en ventanas y limitar el ritmo ahorra una cantidad considerable de código.
No compensa cuando el trabajo está limitado por CPU, porque la I/O no bloqueante no hace nada por el cálculo. No compensa cuando el stack bloquea en algún punto relevante: una capa web reactiva delante de un driver JDBC bloqueante te da la dificultad de depuración sin nada del escalado. No compensa en servicios que atienden unos pocos cientos de peticiones concurrentes, que son la mayoría de los servicios internos de empresa. Y no compensa cuando el equipo no lo va a mantener: el código reactivo es más difícil de leer, más difícil de depurar e implacable con una sola llamada bloqueante, y una base de código convertida a medias es peor que cualquiera de los dos extremos.
Los virtual threads como alternativa
Los virtual threads, estandarizados en el JDK 21, cambian estas cuentas para la mayoría de las aplicaciones. La JVM planifica cada virtual thread sobre un pequeño pool de carrier threads; cuando realiza una operación bloqueante sobre un channel o un socket, la JVM lo desmonta de su carrier y este ejecuta otra cosa. El coste del bloqueo desaparece y el código sigue siendo Java secuencial corriente.
| Aspecto | Reactivo (Reactor) | Virtual threads |
|---|---|---|
| Estilo de código | Pipelines de operadores, callbacks | Código secuencial corriente |
| Llamadas bloqueantes | Hay que aislarlas en un scheduler aparte | Sin problema: esa es la gracia |
| Stack traces | Frames de operadores, requiere hooks de depuración | Completos y familiares |
| Backpressure | Integrada en el contrato | No incluida; usa colas acotadas y semáforos |
| Operadores de streaming | Vocabulario amplio | A mano, o con una biblioteca de streams encima |
| Profiling y herramientas | Especializadas | JFR estándar, thread dumps, depuradores |
| Curva de aprendizaje | Empinada | Insignificante |
La recomendación práctica para servicios JVM nuevos en 2026: empieza con virtual threads y un modelo de un hilo por petición, que es más sencillo y da casi todo el beneficio en concurrencia. Recurre a una biblioteca reactiva cuando necesites backpressure entre componentes o el vocabulario de operadores de streaming, no simplemente porque tengas que gestionar muchas conexiones. La guía de Java sobre concurrencia y executors cubre el modelo de hilos en detalle. Ten en cuenta las salvedades que quedan: en versiones anteriores, un virtual thread fijado (pinned) por un bloque synchronized que mantiene un lock durante una llamada bloqueante sigue ocupando su carrier, así que prefiere ReentrantLock en las rutas críticas; y meter virtual threads en un pool anula por completo su razón de ser.
Ideas clave
- Un sistema reactivo es una propiedad arquitectónica (responsivo, resiliente, elástico, orientado a mensajes); la programación reactiva es una forma de implementar partes de él, no lo mismo.
- La ventaja concreta de la I/O no bloqueante es desacoplar las peticiones en curso de los hilos, lo que cambia cómo se degrada un servidor bajo alta concurrencia, no lo rápida que es una petición.
- La backpressure es el rasgo que define Reactive Streams: no se emite nada más allá de lo que el suscriptor ha pedido, y un buffer sin límite es un fallo aplazado.
FluxyMonode Reactor son perezosos, se componen de forma declarativa y exigen una planificación deliberada: cualquier llamada bloqueante debe aislarse enboundedElastic.- Lo reactivo compensa en cargas limitadas por I/O, de streaming y con muchas conexiones, y sale muy caro en todo lo demás, sobre todo si alguna capa sigue bloqueando.
- Para la mayoría de los servicios JVM nuevos, los virtual threads dan el escalado con código y herramientas corrientes; elige lo reactivo cuando necesites específicamente backpressure u operadores de streams.
Fuentes y lecturas recomendadas
Preguntas frecuentes
- ¿El código reactivo es más rápido que el código bloqueante?
- No por petición: una llamada individual suele tardar algo más, por la planificación y la sobrecarga de los operadores. Lo que cambia es la forma de la curva bajo concurrencia: un servidor bloqueante se degrada bruscamente en cuanto agota su pool de hilos, mientras que uno no bloqueante sigue aceptando conexiones con un crecimiento de memoria moderado. Si tu servicio atiende unos pocos cientos de peticiones concurrentes, no notarás ninguna ventaja.
- ¿Qué pasa realmente si llamo a una API bloqueante dentro de un pipeline reactivo?
- Ocupas uno de los poquísimos hilos del event loop (normalmente uno por núcleo de CPU) durante toda la llamada, y todas las demás peticiones asignadas a ese hilo esperan. Un puñado de llamadas así puede paralizar el servidor entero. Si no tienes más remedio que llamar a código bloqueante, planifícalo en un scheduler bounded elastic con subscribeOn o publishOn para que se ejecute fuera del event loop.
- ¿Los virtual threads dejan obsoleta la programación reactiva?
- Le quitan su principal justificación en la mayoría de las aplicaciones. Los virtual threads te dan el comportamiento de escalado de la I/O no bloqueante manteniendo código secuencial normal, stack traces depurables y try/catch. Lo que no te dan es backpressure ni un vocabulario declarativo de operadores para combinar, bufferizar y agrupar en ventanas los streams, así que las cargas que de verdad tienen forma de stream siguen beneficiándose de una biblioteca reactiva.
- ¿En qué se diferencia un Flux de un Stream de Java?
- El consumidor tira de un Stream de forma síncrona y este termina cuando acaba la operación terminal, así que no puede representar algo que produce valores a lo largo del tiempo. Un Flux se empuja de forma asíncrona, puede ser infinito, trata la finalización y el error como señales de primera clase y permite al suscriptor pedir un número acotado de elementos. Los Streams modelan colecciones; Flux modela eventos que van llegando.