Architecture orientée événements : comment nous avons remplacé un système monolithique qui coûtait 50 000 dollars par mois
Un guide pratique sur l'architecture orientée événements, illustré par des exemples concrets de code « avant/après », qui montre comment nous avons décomposé un système de traitement des commandes étroitement couplé en un pipeline d'événements résilient et évolutif.
Le problème : un système monolithique incapable de s'adapter à la croissance
Notre client disposait d'une application monolithique Django chargée du traitement des commandes. Chaque commande déclenchait une chaîne d'opérations synchrones : validation des stocks → prélèvement du paiement → envoi d'un e-mail de confirmation → mise à jour des statistiques → notification à l'entrepôt. Si le service de messagerie était lent, l'ensemble du processus de paiement se bloquait. Si le système d'analyse était hors service, les commandes échouaient.
Le système traitait 200 commandes par minute en temps normal, mais tombait en panne lors des ventes flash, où le volume atteignait 2 000 commandes par minute. Pour pallier ce problème, l'entreprise dépensait 50 000 dollars par mois en serveurs surdimensionnés.
Avant : La chaîne synchrone de la douleur
# 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})L'architecture : événements et commandes
Nous avons divisé le système en deux concepts :
Commandes (opérations qui DOIVENT aboutir) : réservation de stock + paiement. Ces opérations restent synchrones car la commande en dépend.
Événements (actions qui DOIVENT se produire) : e-mails, analyses, notifications de l'entrepôt. Ceux-ci sont publiés sous forme d'événements et traités de manière asynchrone. En cas d'échec, ils sont relancés — le client n'a pas à attendre.
Après : Approche orientée événements avec 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)Le modèle de bus d'événements
Nous avons mis au point un bus d'événements léger à l'aide de Redis Streams (il ne s'agit pas simplement d'un système pub/sub : les flux offrent une meilleure durabilité et permettent de créer des groupes de consommateurs) :
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)Résultats après 3 mois
| Système métrique | Avant | Après |
|---|---|---|
| Temps de réponse lors du paiement | 2 200 ms | 1 000 ms |
| Débit maximal | 200 commandes par minute | 5 000 commandes par minute |
| Coût mensuel du serveur | 50 000 $ | 12 000 $ |
| Conséquences de la panne du service de messagerie électronique | Toutes les commandes échouent | Les commandes s'enchaînent, les e-mails s'accumulent |
| Risque lié au déploiement | Tout ou rien | Déployer les services de manière indépendante |
Quand NE PAS utiliser une architecture orientée événements
L'approche orientée événements n'est pas toujours la solution. Évitez-la dans les cas suivants :
1. Vous devez respecter un ordre strict. Les événements peuvent se produire dans le désordre. Si l'étape B doit suivre l'étape A, veillez à ce qu'elles restent synchronisées.
2. Vous avez besoin d'une cohérence immédiate. Les événements sont cohérents à terme. Si l'utilisateur doit voir le résultat immédiatement, n'utilisez pas d'événements asynchrones.
3. Votre équipe est réduite. L'architecture orientée événements ajoute à la complexité opérationnelle. Une équipe de deux ou trois développeurs a tout intérêt à opter pour une architecture monolithique bien structurée.
4. Vous recevez moins de 100 requêtes par minute. Une architecture monolithique synchrone suffit amplement. N'en faites pas trop.
Commencez par un monolithe. Définissez des événements lorsque vous rencontrez des difficultés. La pire architecture est celle conçue pour résoudre des problèmes que vous n'avez pas encore.
— alokknight Ingénierie
