Backpressure en Zeebe¶
Resumen: Zeebe implementa un sistema de backpressure de dos capas (request limiter de concurrencia + write rate limiter basado en Guava RateLimiter) que prefiere dropping sobre buffering — los comandos son rechazados y el cliente reintenta. El límite de concurrencia usa
StabilizingAIMDLimit, una variante custom de AIMD que solo reduce el límite cuando el inflight es ≤ limit, evitando oscilaciones. Ciertos comandos críticos (COMPLETE, FAIL, CANCEL, DEPLOY) bypass backpressure completamente. El tracking de requests inflight usa un ring buffer conAtomicReferenceArray.
Filosofía: dropping over buffering¶
El sistema de backpressure de Zeebe toma una decisión de diseño fundamental: rechazar comandos (dropping) en lugar de encolarlos (buffering).
Razones para dropping¶
- Bounded memory: buffering requiere memoria proporcional a la carga. Bajo presión sostenida, un buffer eventualmente se llena o causa OOM.
- Latencia predecible: un comando buffered puede esperar un tiempo arbitrario. Al rechazarlo inmediatamente, el cliente sabe que debe reintentar y puede tomar decisiones informadas (backoff, circuit breaker).
- Señal clara al cliente: un error de backpressure (gRPC status
RESOURCE_EXHAUSTED) es una señal inequívoca de que el sistema está saturado. - Simplicidad: no se necesita gestión de colas, prioridades, timeouts de buffer, ni dead letter queues para comandos encolados.
Comportamiento del cliente¶
Cuando un comando es rechazado por backpressure:
flowchart LR
A[Cliente] --> B[gRPC RESOURCE_EXHAUSTED]
B --> C[SDK intercepta]
C --> D[Retry con backoff exponential]
D --> E[Reintenta el comando]
Los SDKs oficiales de Zeebe implementan retry automático con backoff configurable para errores de backpressure.
Arquitectura de dos capas¶
El sistema de backpressure tiene dos capas independientes que operan en serie:
flowchart TD
Req[Request entrante] --> L1
L1[Capa 1: Request Limiter<br/>StabilizingAIMDLimit<br/>demasiados requests inflight?]
L1 -->|pasa| L2[Capa 2: Write Rate Limiter<br/>Guava RateLimiter<br/>demasiadas writes/segundo?]
L2 -->|pasa| SP[Stream Processor]
L1 -.->|rechaza| X1[RESOURCE_EXHAUSTED]
L2 -.->|rechaza| X2[RESOURCE_EXHAUSTED]
Capa 1: Request Limiter (concurrency)¶
Controla el número de requests concurrentes (inflight) que el broker acepta.
- Métrica: número de requests que han sido aceptados pero cuyo procesamiento no ha completado.
- Límite: dinámico, ajustado por el algoritmo AIMD.
- Decisión: si
inflight >= limit→ rechazar.
Capa 2: Write Rate Limiter¶
Controla la tasa de escrituras al stream processor.
- Implementación:
Guava RateLimiter(token bucket). - Métrica: writes por segundo al log de comandos.
- Límite: configurable, con un rate máximo de writes por segundo.
- Decisión: si no hay tokens disponibles → rechazar.
Por qué dos capas¶
Cada capa protege contra un tipo diferente de sobrecarga:
| Escenario | Capa que protege |
|---|---|
| Muchos clientes enviando requests simultáneamente | Request Limiter |
| Pocos clientes pero con requests muy pesados (batch) | Request Limiter |
| Tasa sostenida de writes que supera capacidad del disco/Raft | Write Rate Limiter |
| Burst corto seguido de periodo tranquilo | Request Limiter (permite el burst hasta el limit) |
StabilizingAIMDLimit¶
AIMD clásico¶
AIMD (Additive Increase, Multiplicative Decrease) es un algoritmo de control de congestión:
- Additive Increase: cuando las cosas van bien, incrementar el límite linealmente (+1).
- Multiplicative Decrease: cuando hay congestión, reducir el límite multiplicativamente (×0.5 o similar).
Problema con AIMD clásico¶
En el contexto de Zeebe, el AIMD clásico tiene un problema: cuando se detecta congestión y se reduce el límite, los requests inflight que ya fueron aceptados pueden exceder el nuevo límite reducido. Esto causa que el algoritmo siga reduciendo el límite en cada check, creando una espiral descendente hasta llegar al mínimo.
Ejemplo de espiral descendente:
t=0: limit=100, inflight=90 → OK
t=1: congestión detectada, limit=50 (×0.5)
t=2: inflight=80 (los 90 anteriores aún procesándose, menos 10 completados)
80 > 50 → "aún congestionado" → limit=25 (×0.5)
t=3: inflight=60 → 60 > 25 → limit=12
... espiral hasta el mínimo
Solución: StabilizingAIMDLimit¶
El StabilizingAIMDLimit de Zeebe es una variante custom que agrega una condición: solo reduce el límite cuando inflight ≤ limit.
función onSample(inflight, latency):
si latency > threshold: // hay congestión
si inflight <= limit:
// La congestión NO es por exceso de inflight
// Reducir es seguro porque no estamos en espiral
limit = max(limit * backoffRatio, minLimit)
// sino: no reducir, ya que los inflight actuales
// que exceden el límite causan la congestión
sino:
limit = min(limit + 1, maxLimit) // additive increase
Parámetros configurables¶
| Parámetro | Default | Descripción |
|---|---|
| minLimit | 1 | Límite mínimo de concurrencia |
| maxLimit | 1000 | Límite máximo de concurrencia |
| backoffRatio | 0.9 | Factor de reducción (menos agresivo que 0.5 clásico) |
| initialLimit | 200 | Límite inicial |
Whitelisted commands¶
Ciertos comandos nunca son rechazados por backpressure, independientemente del estado de las dos capas:
| Comando | Razón para bypass |
|---|---|
COMPLETE_JOB |
Rechazar completions causaría timeout del job y retry innecesario, empeorando la congestión |
FAIL_JOB |
Similar a complete; rechazar fallos dejaría jobs en limbo |
CANCEL_PROCESS_INSTANCE |
Cancelaciones liberan recursos; rechazarlas empeoraría la presión |
DEPLOY_RESOURCE |
Deployments son infrecuentes y críticos para operaciones |
Lógica del bypass¶
La decisión de qué comandos bypass backpressure se basa en un principio: rechazar estos comandos empeoraría la congestión en lugar de aliviarla.
Si un COMPLETE_JOB es rechazado:
1. El job timeout expira.
2. El job se reactiva automáticamente.
3. Un worker lo toma y lo procesa de nuevo.
4. El worker intenta completarlo de nuevo.
5. Net result: más trabajo, no menos.
Ring buffer tracking con AtomicReferenceArray¶
Problema¶
Para implementar el request limiter, Zeebe necesita trackear el número de requests inflight con mínimo overhead y alta concurrencia (múltiples threads gRPC aceptando requests simultáneamente).
Solución: ring buffer con AtomicReferenceArray¶
En lugar de un simple AtomicInteger para contar inflight (que se convertiría en un bottleneck de contención), Zeebe usa un ring buffer implementado con AtomicReferenceArray:
AtomicReferenceArray<Request> inflight = new AtomicReferenceArray<>(capacity);
// Registrar request
función acquire(request):
slot = hash(request) % capacity
si inflight.compareAndSet(slot, null, request):
return true // request aceptado
return false // slot ocupado, rechazar
// Liberar request
función release(request):
slot = hash(request) % capacity
inflight.compareAndSet(slot, request, null)
Ventajas del ring buffer¶
- Distributed contention: en lugar de que todos los threads compitan por un solo
AtomicInteger, cada thread accede a un slot diferente del array, reduciendo la contención. - Lock-free: usa CAS (Compare-And-Swap) para operaciones atómicas sin locks.
- O(1): tanto
acquirecomoreleaseson O(1). - Cache-friendly: el array es contiguo en memoria, aprovechando cache lines del CPU.
Trade-offs¶
- False contention: dos requests que hashean al mismo slot compiten innecesariamente. El tamaño del array se dimensiona para minimizar esto.
- Approximate count: el "count" de inflight es una aproximación basada en slots ocupados, no un conteo exacto. Esto es aceptable para backpressure, donde precisión exacta no es necesaria.
Configuración¶
El sistema de backpressure es configurable vía properties de Zeebe:
zeebe:
broker:
backpressure:
enabled: true
algorithm: aimd # aimd | fixed | vegas | gradient | gradient2
aimd:
initialLimit: 200
minLimit: 1
maxLimit: 1000
backoffRatio: 0.9
requestTimeout: 10s
Algoritmos disponibles¶
Zeebe soporta múltiples algoritmos de control de concurrencia (del proyecto Netflix Concurrency Limits):
| Algoritmo | Descripción |
|---|---|
aimd |
StabilizingAIMDLimit (default, recomendado) |
fixed |
Límite fijo, sin ajuste dinámico |
vegas |
TCP Vegas-inspired, basado en RTT |
gradient |
Gradient-based, más sensible a cambios |
gradient2 |
Gradient2, variante mejorada |
El default aimd fue elegido por su estabilidad y comportamiento predecible.
Monitoreo¶
Métricas expuestas para observabilidad del backpressure:
| Métrica | Descripción |
|---|---|
zeebe_backpressure_inflight_requests_count |
Requests actualmente inflight |
zeebe_backpressure_requests_limit |
Límite actual del request limiter |
zeebe_backpressure_requests_dropped_total |
Total de requests rechazados por backpressure |
zeebe_write_rate_limit |
Rate limit actual de escrituras |
Consideraciones para un MVP¶
- Esencial: alguna forma de backpressure es necesaria para un engine de producción. Sin ella, un burst de requests puede saturar el engine y causar cascading failures.
- Esencial: el bypass de COMPLETE/FAIL es crítico — sin él, el backpressure puede empeorar la congestión.
- Simplificable: para un MVP, un
fixedlimit en lugar de AIMD dinámico es suficiente. AIMD agrega complejidad pero es más adaptable. - Simplificable: la capa de write rate limiting puede omitirse inicialmente si el request limiter es suficiente.
- Simplificable: el ring buffer con
AtomicReferenceArrayes una optimización de rendimiento. UnAtomicIntegersimple funciona para un MVP con menos concurrencia.