Un endpoint de checkout qui redimensionne l’image d’un produit, envoie un email de confirmation et met à jour un moteur de recommandation avant de répondre 200 OK n’est jamais plus rapide que son étape la plus lente. Si l’une d’elles dépasse le délai, toute la requête échoue. Une file de messages permet à l’endpoint de confier ce travail sous forme de messages et de répondre immédiatement, pendant que des workers séparés les traitent à leur propre rythme.

Ce que fait vraiment une file de messages

Une file de messages se place entre deux parties d’un système qui n’ont pas besoin de tourner au même moment. Un producer publie un message décrivant un travail à faire. La file le conserve. Un consumer le récupère, le traite, puis l’acquitte. Les deux ne se parlent jamais directement.

Pensez à une boîte aux lettres. L’expéditeur dépose la lettre et repart ; le destinataire la relève quand il est disponible, et s’il s’absente une heure, les lettres attendent.

Trois choses en découlent, qu’un appel de fonction direct ne peut pas offrir :

  • Découplage. L’endpoint de checkout n’a pas besoin de savoir comment fonctionne le redimensionnement d’image, ni que c’est lent. Il publie {"event": "order_placed", "order_id": 4821} et continue.
  • Absorption des pics. Si 500 commandes arrivent la même seconde, la file absorbe le pic. Les workers les traitent au rythme qu’ils peuvent tenir, au lieu d’avoir 500 requêtes bloquées en même temps.
  • Scalabilité et pannes indépendantes. Lancez trois workers de redimensionnement et un worker email, redémarrez l’un sans toucher à l’autre, déployez le service de checkout sans redéployer ses workers.

Lancer RabbitMQ dans Docker

RabbitMQ est le message broker généraliste le plus répandu et un choix raisonnable si vous n’avez pas encore de file. Récupérez l’image management pour avoir aussi une UI web à côté du broker :

1
2
3
4
5
docker run -d --hostname mq --name rabbitmq \
  -e RABBITMQ_DEFAULT_USER=app \
  -e RABBITMQ_DEFAULT_PASS=changeme \
  -p 5672:5672 -p 15672:15672 \
  rabbitmq:4-management

Le port 5672 est le protocole AMQP auquel se connecte votre application. Le port 15672 est l’UI de gestion : ouvrez http://localhost:15672, connectez-vous avec app / changeme, et vous pouvez observer en direct les files, le débit de messages et les connexions. Le login par défaut guest/guest ne fonctionne que depuis l’intérieur du conteneur, définissez donc votre propre utilisateur pour tout ce à quoi vous vous connecterez depuis l’hôte.

Si vous n’avez jamais utilisé Docker, Qu’est-ce que Docker et à quoi ça sert couvre les images, les conteneurs et les ports. Pour une stack réelle, lancez le broker aux côtés de votre application avec Compose — voir Qu’est-ce que Docker Compose et Comment l’Utiliser :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
services:
  rabbitmq:
    image: rabbitmq:4-management
    environment:
      - RABBITMQ_DEFAULT_USER=app
      - RABBITMQ_DEFAULT_PASS=changeme
    ports:
      - "5672:5672"
      - "15672:15672"

  worker:
    build: ./worker
    depends_on:
      - rabbitmq
    environment:
      - RABBITMQ_URL=amqp://app:changeme@rabbitmq:5672/

worker atteint le broker via le hostname rabbitmq. Compose place les deux services sur le même réseau user-defined, si bien que le nom du service sert aussi de hostname résolvable, le mécanisme décrit dans Qu’est-ce qu’un Réseau Docker et Comment l’Utiliser.

Publier et consommer un message

pika est le client Python standard pour le protocole de RabbitMQ (AMQP 0-9-1). Installez-le avec pip install pika. Un producer qui publie une tâche ressemble à ceci :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import pika

connection = pika.BlockingConnection(
    pika.ConnectionParameters(
        host="localhost",
        credentials=pika.PlainCredentials("app", "changeme"),
    )
)
channel = connection.channel()

channel.queue_declare(queue="resize_image", durable=True)

channel.basic_publish(
    exchange="",
    routing_key="resize_image",
    body='{"order_id": 4821, "image_url": "https://example.com/p/4821.jpg"}',
    properties=pika.BasicProperties(delivery_mode=pika.DeliveryMode.Persistent),
)
connection.close()

durable=True sur queue_declare indique à RabbitMQ de conserver la file elle-même après un redémarrage du broker. delivery_mode=pika.DeliveryMode.Persistent lui indique d’écrire chaque message sur disque au lieu de le garder seulement en mémoire. Sans les deux, un docker restart sur le broker fait disparaître silencieusement tout ce qui n’avait pas encore été consommé.

Le consumer récupère les messages de la même file et n’acquitte chacun qu’une fois le travail réellement terminé :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import pika

connection = pika.BlockingConnection(
    pika.ConnectionParameters(
        host="localhost",
        credentials=pika.PlainCredentials("app", "changeme"),
    )
)
channel = connection.channel()
channel.queue_declare(queue="resize_image", durable=True)
channel.basic_qos(prefetch_count=1)

def handle_message(ch, method, properties, body):
    resize_image(body)  # le vrai travail
    ch.basic_ack(delivery_tag=method.delivery_tag)

channel.basic_consume(queue="resize_image", on_message_callback=handle_message)
channel.start_consuming()

Deux réglages font tout le travail de fiabilité ici :

  • basic_qos(prefetch_count=1) empêche RabbitMQ de donner un deuxième message à un worker avant qu’il ait acquitté le premier. Sans cela, un worker occupé peut se retrouver avec dix messages pendant qu’un worker libre n’en reçoit aucun.
  • basic_ack après le travail, pas avant. Si le processus plante en plein redimensionnement, le message n’a jamais été acquitté, donc RabbitMQ le redistribue à un autre worker au lieu de le perdre. Laissez auto_ack désactivé, ce qui est déjà le comportement par défaut de pika.

Lancez trois copies du script consumer et RabbitMQ répartit la file entre eux en round-robin. C’est toute l’histoire de la scalabilité pour ce pattern : aucun code de coordination, aucun état partagé entre workers.

Certains messages échouent à chaque livraison : un payload malformé, un appel à un service disparu pour de bon. La redistribution transforme cela en boucle infinie à travers vos consumers. Configurez x-dead-letter-exchange sur la file, et les messages que vous rejetez sans les remettre en file (basic_nack avec requeue=False) partent vers un exchange séparé où vous les inspectez à la main.

Comment les exchanges de RabbitMQ acheminent les messages

Les exemples ci-dessus publient avec exchange="", le default exchange, qui achemine un message directement vers la file nommée dans routing_key. Cela couvre la plupart des cas de file de tâches. Le modèle réel de RabbitMQ place un exchange devant chaque file, et changer le type d’exchange change le comportement d’acheminement :

Type d’exchangeAchemine versÀ utiliser pour
direct (le default exchange en est un)La file qui correspond exactement à la routing keyFiles de tâches, un groupe de consumers par file
fanoutToutes les files qui y sont liées, en ignorant la routing keyDiffuser un événement à plusieurs consumers indépendants
topicLes files dont le pattern de binding correspond à la routing key (order.*.created)Diffusion sélective, quand les consumers veulent un sous-ensemble de types d’événements

Un exchange fanout permet de faire du publish/subscribe avec RabbitMQ : vous publiez order_placed une seule fois, et le service email, le service analytics et le service anti-fraude reçoivent chacun leur propre copie via leur propre file, au lieu de se disputer les mêmes messages.

Quand une file de messages n’est pas le bon outil

  • L’appelant a besoin de la réponse pour répondre à son tour. Une file sert à “faire ça plus tard”, pas à “calculer ça maintenant”. Si votre endpoint de checkout a besoin du coût de livraison calculé avant de pouvoir répondre, c’est un appel synchrone, pas un job en file.
  • Vous avez besoin d’un ordre strict sur toute la file. RabbitMQ garantit l’ordre par file avec un seul consumer, mais ajoutez un second consumer pour le débit et deux messages peuvent finir dans le désordre. Si la séquence compte, comme dans un event log ou une machine à états, il vous faut un outil conçu pour des flux ordonnés, comme les partitions Kafka avec une clé sur l’ID de l’entité.
  • Le job est petit, synchrone, et vous ne gérez pas déjà un broker. Un thread en arrière-plan ou un job runner in-process représentent moins de surface opérationnelle que d’installer et surveiller RabbitMQ pour une tâche de taille cron.
  • Vous avez déjà Redis et pouvez tolérer la perte occasionnelle d’un job. Redis Streams ou une librairie comme BullMQ vous donnent une file sans nouvelle infrastructure, au prix de garanties de livraison plus faibles en cas de crash du broker. Vérifiez d’abord ce que Redis fait déjà dans votre stack : Comment Fonctionne le Cache Redis et Comment l’Utiliser.

Faire passer votre première tâche par une file

Choisissez une chose qui bloque une requête sans en avoir besoin : l’email de confirmation, la miniature, le ping analytics. Lancez RabbitMQ dans Docker, déclarez une file durable pour cette tâche, migrez-la. Configurez prefetch_count=1 et acquittez manuellement dès le premier jour, car ajouter de la fiabilité après qu’un crash ait déjà englouti un message coûte bien plus cher qu’écrire ces deux lignes maintenant. Ne passez à un exchange fanout ou topic qu’une fois que vous avez un second consumer qui veut le même événement.

Articles associés