¿Qué es el backpressure en un sistema distribuido, y cómo se implementa realmente?
Cuando un consumidor no da abasto, alguien tiene que frenar deliberadamente al productor — si no, la JVM o el kernel lo harán por ti, en el peor momento posible.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Qué es el backpressure en un sistema distribuido, y cómo se implementa realmente?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Qué significa realmente el backpressure
El backpressure (contrapresión) es el mecanismo por el cual un consumidor lento le dice a un productor rápido que reduzca el ritmo, en lugar de que el productor acumule trabajo sin límite en algún punto intermedio. Suena a concepto de nicho de streaming, pero aparece por todas partes: un servidor HTTP que acepta conexiones más rápido de lo que puede procesarlas, un consumidor de Kafka que se va quedando atrás de su productor, una base de datos downstream que rechaza escrituras mientras un servicio upstream las sigue encolando, o un manejador de eventos de UI que no da abasto con los eventos de movimiento del ratón. La ausencia de backpressure no significa que el sistema lo tolere en silencio — significa que el exceso de trabajo va a parar a algún sitio: una cola en memoria crece hasta que el proceso se queda sin heap, la cola de un thread pool crece hasta que la latencia se vuelve ilimitada, o el buffer de recepción TCP del sistema operativo se llena y el kernel empieza a descartar paquetes. Nada de eso es "manejar" el problema; es posponer una caída.
#El fallo ingenuo: colas sin límite
El antipatrón más común es una cola de trabajo sin límite entre un productor y un consumidor — un LinkedBlockingQueue<Runnable>() sin capacidad, un ArrayList usado como buffer, un canal sin límite de tamaño. Bajo carga normal esto es invisible. Ante un pico de tráfico, una dependencia downstream lenta, o una pausa de GC, el productor sigue encolando y la cola sigue creciendo, y terminas con un OOM kill o un precipicio de latencia justo cuando menos te lo puedes permitir — normalmente durante un incidente, lo que empeora el incidente.
#Estrategias para implementarlo
En realidad hay cuatro movimientos disponibles una vez decides frenar deliberadamente:
- Buffer acotado + bloqueo — limita el tamaño de la cola y haz que
put()/send()bloquee (o suspenda) cuando esté llena. El productor naturalmente se ajusta al ritmo del consumidor. Simple, pero un productor bloqueado puede convertirse él mismo en un cuello de botella aguas arriba (el backpressure se propaga hacia atrás por toda la cadena, que normalmente es justo lo que quieres). - Buffer acotado + descarte/rechazo — limita la cola y, cuando esté llena, rechaza el trabajo nuevo de inmediato (fail fast, devuelve
429 Too Many Requests) o descarta el elemento más antiguo/más nuevo (load shedding). Bueno cuando la frescura importa más que la completitud (métricas, telemetría, frames de video en vivo). - Basado en pull / reactive streams — en lugar de que el productor empuje cuando quiera, el consumidor pide explícitamente
nelementos más cuando está listo para recibirlos. Este es el modelo de Reactive Streams /java.util.concurrent.Flow, y es el único enfoque que te da backpressure sin ningún buffer en el caso ideal, porque el productor nunca produce más de lo que se le pidió. - Limitación de tasa en el borde — un token bucket o leaky bucket delante de todo el pipeline, independiente de cualquier cola concreta, limita la tasa de admisión en lugar de reaccionar a que una cola se llene. Complementa a los otros tres, no los sustituye.
#Un ejemplo de cola acotada con backpressure real
El Channel de las corrutinas de Kotlin es una forma limpia de mostrar esto porque el send de un canal acotado se suspende — no bloquea un hilo, suspende una corrutina — cuando el canal está lleno, que es exactamente el comportamiento de "el productor se ajusta al ritmo del consumidor":
import kotlinx.coroutines.*import kotlinx.coroutines.channels.Channel// Capacidad de 100: una vez que hay 100 elementos en el buffer,// send() se suspende hasta que el consumidor libere espacio recibiendo uno.val workQueue = Channel<WorkItem>(capacity = 100)fun CoroutineScope.startProducer(source: Iterator<WorkItem>) = launch {for (item in source) {// Esto suspende la corrutina (no el hilo) cada vez que// el canal está lleno -- esa suspensión ES el backpressure.workQueue.send(item)}workQueue.close()}fun CoroutineScope.startConsumer(process: suspend (WorkItem) -> Unit) = launch {for (item in workQueue) {process(item) // el procesamiento lento aquí frena directamente al productor}}data class WorkItem(val id: Long, val payload: ByteArray)
Si process() tarda 200ms por elemento y el productor puede generar 10.000 elementos/segundo, el canal se llena en 10ms y cada send() posterior se suspende hasta que el consumidor libera uno — la tasa efectiva del productor pasa a ser exactamente la tasa del consumidor, sin crecimiento ilimitado en ningún sitio.
#Un ejemplo basado en pull (Reactive Streams)
java.util.concurrent.Flow (las interfaces de Reactive Streams incluidas en el JDK) hace explícito el contrato de "el consumidor pide n":
import java.util.concurrent.Flow.*;import java.util.concurrent.SubmissionPublisher;class ThrottledSubscriber implements Subscriber<WorkItem> {private Subscription subscription;private static final int BATCH = 10;@Overridepublic void onSubscribe(Subscription subscription) {this.subscription = subscription;subscription.request(BATCH); // pide los primeros 10, nada más}@Overridepublic void onNext(WorkItem item) {process(item);// Solo pide uno más cuando este ya terminó -- el publisher// físicamente no puede enviar más rápido de lo que consumimos.subscription.request(1);}@Overridepublic void onError(Throwable t) { /* manejar */ }@Overridepublic void onComplete() { /* manejar */ }private void process(WorkItem item) {// trabajo lento aquí}}
Aquí el SubmissionPublisher tiene contractualmente prohibido llamar a onNext más veces de las solicitadas vía request(n) — el control de flujo está incorporado en el propio protocolo en lugar de añadido a base de una cola.
🧠 Check your understanding
0/2 · 0/2 answered1. ¿Cuál es el riesgo principal de usar una cola en memoria sin límite entre un productor y un consumidor lento?
2. En el modelo de Reactive Streams (java.util.concurrent.Flow), ¿cómo controla el suscriptor la tasa de elementos que recibe?