Arquitectura basada en eventos: cómo sustituimos un sistema monolítico que costaba 50 000 dólares al mes
Una guía práctica sobre la arquitectura basada en eventos con ejemplos reales de código «antes y después», en la que se muestra cómo descompusimos un sistema de procesamiento de pedidos muy interconectado en un flujo de eventos resistente y escalable.
El problema: un monolito que no podía escalar
Nuestro cliente contaba con una aplicación monolítica de Django que gestionaba el procesamiento de pedidos. Cada pedido activaba una cadena de operaciones sincrónicas: validar el inventario → cobrar el pago → enviar un correo electrónico de confirmación → actualizar las estadísticas → notificar al almacén. Si el servicio de correo electrónico era lento, todo el proceso de pago se bloqueaba. Si el sistema de estadísticas no funcionaba, los pedidos fallaban.
El sistema procesaba 200 pedidos por minuto en horario normal, pero se colapsaba durante las ventas relámpago, cuando alcanzaba los 2.000 por minuto. Para compensar esto, pagaban 50.000 dólares al mes por servidores con un exceso de capacidad.
Antes: La cadena sincrónica del dolor
# La forma antigua: todo sincrónico, todo acoplado
def process_order(request):
order = Order.objects.create(**request.data)
# Si CUALQUIERA de estas operaciones falla, el pedido falla
inventory_service.reserve(order) # 200 ms
payment_service.charge(order) # 800 ms
email_service.send_confirmation(order) # 500 ms
analytics_service.track(order) # 300 ms
warehouse_service.notify(order) # 400 ms
# Total: tiempo de respuesta mínimo de 2200 ms
# Si el correo electrónico no funciona: el pedido falla
# Si el análisis es lento: el proceso de pago es lento
return Response({'order_id': order.id})La arquitectura: eventos y comandos
Hemos dividido el sistema en dos conceptos:
Operaciones (que DEBEN completarse con éxito): reserva de existencias + pago. Estas se mantienen en modo síncrono porque el pedido depende de ellas.
Eventos (cosas que DEBERÍAN ocurrir): correo electrónico, análisis, notificaciones del almacén. Estos se publican como eventos y se procesan de forma asíncrona. Si fallan, se vuelven a intentar; el cliente no tiene que esperar.
A continuación: Programación basada en eventos con Celery + Redis
# La nueva forma: sincrónico para la ruta crítica, eventos para el
resto def process_order(request):
order = Order.objects.create(**request.data)
# Ruta crítica: debe completarse con éxito (sincrónico)
inventory_service.reserve(order) # 200 ms
payment_service.charge(order) # 800 ms
# Publicar evento: «disparar y olvidar» (asíncrono)
publish_event('order.completed', {
'order_id': order.id,
'customer_email': order.customer.email,
'total': str(order.total),
})
# Total: 1000 ms de tiempo de respuesta
# ¿El correo electrónico no funciona? El pedido sigue completándose. Se reintenta más tarde.
return Response({'order_id': order.id})
# Gestores de eventos (trabajadores independientes)
@celery_app.task(bind=True, max_retries=5, default_retry_delay=60)
def handle_order_completed(self, event_data):
"""Cada gestor es independiente y se puede reintentar"""
try:
email_service.send_confirmation(event_data)
except Exception as exc:
self.retry(exc=exc, countdown=2 ** self.request.retries * 60)El patrón de bus de eventos
Hemos creado un bus de eventos ligero utilizando Redis Streams (no solo pub/sub: Streams ofrece durabilidad y grupos de consumidores):
import redisimport
jsonfrom datetime import
datetimeclass EventBus:
def __init__(self):
self.redis = redis.Redis(host='redis', port=6379, db=0)
def publish(self, event_type: str, data: dict):
"""Publicar evento en Redis Stream"""
event = {
'type': event_type,
'data': json.dumps(data),
'timestamp': datetime.utcnow().isoformat(),
}
self.redis.xadd(f'events:{event_type}', event)
def subscribe(self, event_type: str, group: str, consumer: str):
"""Leer eventos con un grupo de consumidores (cada evento se procesa una vez)"""
try:
self.redis.xgroup_create(
f'events:{event_type}', group, id='0', mkstream=True
)
except redis.ResponseError:
pass # El grupo ya existe
while True:
events = self.redis.xreadgroup(
group, consumer,
{f'events:{event_type}': '>'},
count=10, block=5000
)
for stream, messages in events:
for msg_id, fields in messages:
yield msg_id, json.loads(fields[b'data'])
self.redis.xack(stream, group, msg_id)Resultados tras 3 meses
| Sistema métrico | Antes | Después de |
|---|---|---|
| Tiempo de respuesta del proceso de pago | 2 200 ms | 1 000 ms |
| Rendimiento máximo | 200 pedidos por minuto | 5.000 pedidos por minuto |
| Coste mensual del servidor | 50 000 dólares | 12 000 dólares |
| Repercusiones de la interrupción del servicio de correo electrónico | Todos los pedidos fallan | Los pedidos se procesan, los correos electrónicos se ponen en cola |
| Riesgo de implementación | Todo o nada | Implementar servicios de forma independiente |
Cuándo NO utilizar una arquitectura basada en eventos
El enfoque basado en eventos no siempre es la solución. Evítalo cuando:
1. Es necesario un orden estricto. Los eventos pueden producirse fuera de orden. Si el paso B debe ocurrir después del paso A, manténlos sincronizados.
2. Necesitas consistencia inmediata. Los eventos son consistentes a largo plazo. Si el usuario debe ver el resultado de inmediato, no utilices eventos asíncronos.
3. Tu equipo es pequeño. La arquitectura basada en eventos aumenta la complejidad operativa. Para un equipo de 2 o 3 desarrolladores, es más adecuado un monolito bien estructurado.
4. Tienes menos de 100 solicitudes por minuto. Una aplicación monolítica síncrona se encarga de esto sin problemas. No compliques demasiado el diseño.
Empieza con un monolito. Extrae eventos cuando notes que te cuesta. La peor arquitectura es aquella que se construye para problemas que aún no tienes.
— alokknight Ingeniería
