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
# The old way: everything synchronous, everything coupled
def process_order(request):
order = Order.objects.create(**request.data)
# If ANY of these fail, the order fails
inventory_service.reserve(order) # 200ms
payment_service.charge(order) # 800ms
email_service.send_confirmation(order) # 500ms
analytics_service.track(order) # 300ms
warehouse_service.notify(order) # 400ms
# Total: 2200ms minimum response time
# If email is down: order fails
# If analytics is slow: checkout is slow
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
# The new way: synchronous for critical path, events for the rest
def process_order(request):
order = Order.objects.create(**request.data)
# Critical path: must succeed (synchronous)
inventory_service.reserve(order) # 200ms
payment_service.charge(order) # 800ms
# Publish event: fire-and-forget (async)
publish_event('order.completed', {
'order_id': order.id,
'customer_email': order.customer.email,
'total': str(order.total),
})
# Total: 1000ms response time
# Email down? Order still succeeds. Retried later.
return Response({'order_id': order.id})
# Event handlers (separate workers)
@celery_app.task(bind=True, max_retries=5, default_retry_delay=60)
def handle_order_completed(self, event_data):
"""Each handler is independent and retriable"""
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 redis
import json
from datetime import datetime
class EventBus:
def __init__(self):
self.redis = redis.Redis(host='redis', port=6379, db=0)
def publish(self, event_type: str, data: dict):
"""Publish event to 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):
"""Read events with consumer group (each event processed once)"""
try:
self.redis.xgroup_create(
f'events:{event_type}', group, id='0', mkstream=True
)
except redis.ResponseError:
pass # Group already exists
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
