Prévenir la perte de messages dans RabbitMQ : Pièges courants et solutions

Moyens pratiques de réduire la perte de messages RabbitMQ avec les confirmations, les accusés de réception, les files d'attente durables, les DLQ et un comportement de nouvelle tentative plus sûr.

Prévenir la perte de messages dans RabbitMQ : Pièges courants et solutions

La perte de messages RabbitMQ est rarement causée par une seule panne dramatique du courtier. Le plus souvent, elle provient d'un petit écart dans le chemin de publication ou de consommation : un éditeur suppose qu'une écriture de socket signifie que le courtier a accepté le message, un consommateur accuse réception avant que la validation de la base de données ne soit terminée, ou une file d'attente est durable mais les messages qui lui sont envoyés sont transitoires.

La façon la plus sûre de travailler avec la fiabilité de RabbitMQ est de suivre le message du producteur au courtier, puis du courtier au consommateur. À chaque étape, décidez qui est autorisé à dire "ce message est sûr maintenant". Cette décision doit être explicite dans le code et visible dans la surveillance.

Comprendre le cycle de vie des messages et les points de perte potentiels

Avant de plonger dans les solutions, il est essentiel de comprendre où les messages peuvent être perdus dans le parcours RabbitMQ :

  • Côté éditeur : Un message peut être envoyé par l'éditeur mais ne jamais atteindre le courtier RabbitMQ en raison de problèmes réseau, d'indisponibilité du courtier ou d'erreurs de l'éditeur.
  • Côté courtier : Une fois qu'un message est dans RabbitMQ, il peut être perdu si le courtier plante avant que le message ne soit persistant sur le disque ou si la file d'attente dans laquelle il réside est supprimée de manière inattendue.
  • Côté consommateur : Un consommateur peut recevoir un message mais échouer à le traiter avec succès en raison d'erreurs d'application, de plantages ou d'un accusé de réception prématuré, entraînant la suppression du message.

Techniques clés pour prévenir la perte de messages

RabbitMQ offre plusieurs fonctionnalités intégrées et modèles recommandés pour améliorer la durabilité et la fiabilité des messages. Leur mise en œuvre est cruciale pour éviter la perte de données.

1. Confirmations de l'éditeur

Les confirmations de l'éditeur fournissent un mécanisme par lequel l'éditeur est notifié par le courtier lorsqu'un message a été reçu et traité avec succès. Ceci est essentiel pour garantir que les messages ne disparaissent pas entre l'éditeur et le courtier.

Comment ça marche :

  1. L'éditeur envoie un message à RabbitMQ.
  2. RabbitMQ, après avoir reçu le message, peut être configuré pour renvoyer un accusé de réception à l'éditeur. Cet accusé de réception indique que le message a été accepté.
  3. Si RabbitMQ ne peut pas accepter le message (par exemple, en raison d'une file d'attente pleine ou d'une clé de routage invalide), il enverra un accusé de réception négatif (nack).

Configuration :

Les confirmations de l'éditeur sont activées en définissant confirm.select sur un canal. Cela signale à RabbitMQ que le canal doit fonctionner en mode confirmation.

Exemple (avec la bibliothèque pika de Python) :

import pika

connection = pika.BlockingConnection(pika.ConnectionParameters('localhost'))
channel = connection.channel()

channel.confirm_delivery()

try:
    channel.basic_publish(
        exchange='',
        routing_key='my_queue',
        body='Hello, World!',
        properties=pika.BasicProperties(delivery_mode=2) # Rendre le message persistant
    )
    print(" [x] Sent 'Hello, World!'")
    # Si aucune exception n'est levée, le message a été confirmé par le courtier
except pika.exceptions.UnroutableMessageError as e:
    print(f"Le message n'a pas pu être routé : {e}")
except pika.exceptions.ChannelClosedByBroker as e:
    print(f"Canal fermé par le courtier : {e}")
    # Gérer les problèmes de connexion ou de courtier ici
except Exception as e:
    print(f"Une erreur inattendue s'est produite : {e}")

connection.close()

Meilleure pratique : Implémentez toujours la gestion des erreurs autour des appels basic_publish lors de l'utilisation des confirmations de l'éditeur pour gérer correctement les nacks ou les fermetures de canal.

2. Accusés de réception du consommateur (Ack/Nack)

Les accusés de réception du consommateur sont essentiels pour garantir que les messages ne sont pas perdus une fois qu'ils ont été livrés à un consommateur. Ils permettent au consommateur de signaler à RabbitMQ si un message a été traité avec succès.

Types d'accusés de réception :

  • Accusé de réception automatique (auto_ack=True) : RabbitMQ considère qu'un message est livré et le supprime de la file d'attente dès qu'il l'envoie au consommateur. Si le consommateur plante avant le traitement, le message est perdu.
  • Accusé de réception manuel (auto_ack=False) : Le consommateur indique explicitement à RabbitMQ quand il a fini de traiter un message. Cela permet une nouvelle livraison si le consommateur échoue.

Flux d'accusé de réception manuel :

  1. Le consommateur reçoit un message.
  2. Le consommateur traite le message.
  3. Si le traitement réussit, le consommateur envoie un basic_ack à RabbitMQ.
  4. Si le traitement échoue, le consommateur peut :
    • Envoyer un basic_nack (ou basic_reject) avec requeue=True pour remettre le message dans la file d'attente afin qu'un autre consommateur le prenne.
    • Envoyer un basic_nack (ou basic_reject) avec requeue=False pour supprimer le message ou l'envoyer vers un échange de lettres mortes (DLX).

Exemple (avec la bibliothèque pika de Python) :

import pika
import time

def callback(ch, method, properties, body):
    print(f" [x] Received {body}")
    try:
        # Simuler le traitement
        if b'error' in body:
            raise Exception("Erreur de traitement simulée")
        # Si le traitement réussit :
        ch.basic_ack(delivery_tag=method.delivery_tag)
        print(" [x] Acknowledged message")
    except Exception as e:
        print(f"Le traitement a échoué : {e}")
        # Rejeter et remettre en file d'attente le message
        ch.basic_nack(delivery_tag=method.delivery_tag, requeue=True)
        print(" [x] Rejected and requeued message")

connection = pika.BlockingConnection(pika.ConnectionParameters('localhost'))
channel = connection.channel()

channel.queue_declare(queue='my_queue')

channel.basic_consume(queue='my_queue', on_message_callback=callback, auto_ack=False)

print(' [*] Waiting for messages. To exit press CTRL+C')
channel.start_consuming()

Attention : L'utilisation de requeue=True indéfiniment peut entraîner des boucles de messages si un message échoue systématiquement au traitement. C'est là que la mise en lettre morte devient cruciale.

3. Persistance des messages

Par défaut, les messages dans RabbitMQ sont transitoires. Si le courtier redémarre, tous les messages transitoires seront perdus. Pour éviter cela, les messages et les files d'attente doivent être déclarés comme durables.

Files d'attente durables :

Lors de la déclaration d'une file d'attente, définissez le paramètre durable sur True.

channel.queue_declare(queue='my_durable_queue', durable=True)

Messages persistants :

Lors de la publication d'un message, définissez la propriété delivery_mode sur 2.

channel.basic_publish(
    exchange='',
    routing_key='my_durable_queue',
    body='Persistent message',
    properties=pika.BasicProperties(delivery_mode=2) # Persistant
)

Remarque importante : La persistance des messages n'est pas une solution miracle. Un message n'est persistant sur le disque qu'après avoir été écrit dans la file d'attente. Les confirmations de l'éditeur sont toujours nécessaires pour garantir que le message a atteint le courtier et a été écrit dans la file d'attente durable avant que l'éditeur ne le considère comme envoyé. De plus, si le disque lui-même tombe en panne, les messages persistants peuvent encore être perdus sans une redondance de disque appropriée.

4. Mise en lettre morte (DLX)

La mise en lettre morte est un mécanisme puissant pour gérer les messages qui ne peuvent pas être traités avec succès ou qui ont expiré. Au lieu d'être supprimés ou remis en file d'attente indéfiniment, ces messages peuvent être redirigés vers un 'échange de lettres mortes' désigné.

Scénarios de mise en lettre morte :

  • Un consommateur rejette explicitement un message avec requeue=False.
  • Un message expire en raison de son paramètre de durée de vie (TTL).
  • Une file d'attente atteint sa limite de longueur maximale.

Configuration :

  1. Déclarez un échange de lettres mortes (DLX) : Il s'agit d'un échange régulier vers lequel les messages seront envoyés.
  2. Déclarez une file d'attente de lettres mortes (DLQ) : Une file d'attente liée au DLX.
  3. Configurez la file d'attente d'origine : Lors de la déclaration de la file d'attente qui pourrait produire des messages mis en lettre morte, spécifiez les arguments x-dead-letter-exchange et x-dead-letter-routing-key.

Exemple :

import pika

connection = pika.BlockingConnection(pika.ConnectionParameters('localhost'))
channel = connection.channel()

# 1. Déclarer DLX et DLQ
channel.exchange_declare(exchange='my_dlx', exchange_type='topic')
channel.queue_declare(queue='my_dlq')
channel.queue_bind(queue='my_dlq', exchange='my_dlx', routing_key='dead')

# 2. Déclarer la file d'attente principale avec les arguments DLX/DLQ
channel.queue_declare(
    queue='my_processing_queue',
    durable=True,
    arguments={
        'x-dead-letter-exchange': 'my_dlx',
        'x-dead-letter-routing-key': 'dead'
    }
)

# Lier la file d'attente de traitement à son échange consommateur prévu (le cas échéant)
# Pour simplifier, supposons une publication directe dans la file d'attente pour cet exemple

# Dans votre consommateur, si un message échoue, rejetez-le :
# channel.basic_nack(delivery_tag=method.delivery_tag, requeue=False)

print("Files d'attente et échanges configurés pour la mise en lettre morte.")
connection.close()

Lorsqu'un message est rejeté avec requeue=False depuis my_processing_queue, il sera routé vers my_dlx avec la clé de routage dead, puis vers my_dlq. Vous pouvez ensuite configurer un consommateur séparé pour surveiller my_dlq pour inspection, retraitement ou archivage.

5. Haute disponibilité et réplication

Pour les applications critiques, un seul nœud RabbitMQ est un point de défaillance unique. Le clustering et les types de files d'attente répliquées peuvent réduire le risque de temps d'arrêt ou de perte de données lors d'une panne de nœud, mais ils doivent être choisis et testés pour votre version et votre charge de travail RabbitMQ.

  • Clustering : Plusieurs nœuds RabbitMQ travaillent ensemble comme une seule unité. Les files d'attente peuvent être déclarées sur plusieurs nœuds.
  • Files d'attente répliquées : Les déploiements RabbitMQ modernes utilisent couramment des files d'attente de quorum pour les charges de travail durables répliquées. Les anciens modèles HA classiques doivent être évalués par rapport aux directives actuelles de RabbitMQ avant une nouvelle utilisation.

La réplication améliore la disponibilité, mais elle ajoute également du travail réseau et disque. Testez la latence de confirmation de l'éditeur, le comportement de basculement et la nouvelle livraison du consommateur avant de lui faire confiance pour un flux de travail critique.

Le contrat de fiabilité dont vous avez réellement besoin

Prévenir la perte de messages dans RabbitMQ est plus facile à raisonner lorsque vous rédigez le contrat pour chaque file d'attente. Toutes les files d'attente ne méritent pas la même protection. Une file d'attente transportant des événements d'invalidation de cache peut tolérer un message manqué car le cache peut expirer ou être reconstruit. Une file d'attente transportant des demandes de capture de paiement, des demandes d'e-mail de réinitialisation de mot de passe, des changements de statut d'expédition ou des événements d'audit nécessite généralement un contrat beaucoup plus solide.

Le contrat doit répondre à quatre questions simples :

  • Si l'éditeur plante après l'envoi, peut-il réessayer en toute sécurité ?
  • Si RabbitMQ redémarre, le message doit-il toujours exister ?
  • Si le consommateur plante au milieu du travail, le message doit-il être réessayé ?
  • Si le message échoue constamment, où va-t-il et qui le regarde ?

La plupart des incidents réels de perte de messages se produisent parce qu'aucune de ces questions n'a jamais été répondue. Le code peut utiliser une file d'attente, mais le système n'a pas d'accord sur ce que "envoyé" signifie ou ce que "traité" signifie.

Un éditeur plus sûr traite un message comme envoyé seulement après que le courtier l'a confirmé. Une file d'attente plus sûre est durable lorsque le message doit survivre au redémarrage du courtier. Un message plus sûr est publié comme persistant lorsque le contenu est important. Un consommateur plus sûr accuse réception seulement après que l'effet secondaire durable est terminé. Un chemin d'échec plus sûr envoie les messages empoisonnés vers une file d'attente de lettres mortes au lieu de tourner indéfiniment.

Cela semble beaucoup, mais en pratique, cela devient une courte liste de contrôle que vous pouvez appliquer à chaque flux de travail important.

Un modèle d'échec réel : L'accusé de réception précoce

Le bogue de perte de messages RabbitMQ le plus courant que je vois n'est pas exotique. Il ressemble à ceci :

  1. Le consommateur reçoit un événement de commande.
  2. Le consommateur accuse réception du message immédiatement.
  3. Le consommateur appelle une API de facturation externe.
  4. Le processus plante ou la requête API expire.

RabbitMQ a fait exactement ce qu'on lui a dit. Le consommateur a dit "j'ai fini", donc le courtier a supprimé le message. L'opération commerciale n'était pas terminée, mais le courtier n'avait aucun moyen de le savoir.

La solution est de déplacer l'accusé de réception après le travail irréversible :

def callback(ch, method, properties, body):
    try:
        event = parse_order_event(body)
        charge_id = charge_customer(event)
        save_charge_result(event["order_id"], charge_id)
        ch.basic_ack(delivery_tag=method.delivery_tag)
    except TemporaryBillingError:
        ch.basic_nack(delivery_tag=method.delivery_tag, requeue=True)
    except InvalidOrderError:
        ch.basic_nack(delivery_tag=method.delivery_tag, requeue=False)

Cela laisse encore un problème subtil : que se passe-t-il si le consommateur enregistre le résultat de la facturation, puis plante avant basic_ack ? RabbitMQ redistribuera le message. Ce n'est pas une perte, mais cela peut devenir un traitement en double. Les consommateurs RabbitMQ fiables doivent généralement être idempotents. Utilisez un ID de message, un ID de commande ou une clé métier afin que la répétition du même message ne répète pas l'effet secondaire réel.

Par exemple, un consommateur qui écrit order_id et charge_id dans une table avec une contrainte unique peut gérer en toute sécurité la redistribution. Lors de la deuxième exécution, il voit que l'enregistrement existe déjà et accuse réception du message sans facturer à nouveau.

Les confirmations de l'éditeur ne sont pas facultatives pour les messages importants

Sans confirmations de l'éditeur, l'éditeur sait seulement qu'il a écrit des octets sur un socket. Il ne sait pas si RabbitMQ a accepté le message, l'a routé, l'a persisté ou a perdu la connexion avant que le courtier ne puisse le traiter.

Pour la télémétrie de type "fire-and-forget", cela peut être acceptable. Pour les files d'attente de travail qui représentent des actions commerciales, ce n'est pas suffisant.

Un bon chemin d'éditeur fait généralement trois choses :

  • Active les confirmations de l'éditeur sur le canal.
  • Marque les messages importants comme persistants.
  • Gère les messages non routables avec mandatory=True ou un échange alternatif.

La partie concernant les messages non routables est facile à manquer. Si vous publiez sur un échange avec une clé de routage qui ne correspond à aucune file d'attente, RabbitMQ peut accepter la publication mais ne la router nulle part à moins que vous n'ayez demandé à être informé. Cela ressemble à une perte de message du point de vue de l'application.

Dans pika, le comportement exact dépend du mode du canal et de la gestion des exceptions, mais l'intention est la suivante :

channel.confirm_delivery()

channel.basic_publish(
    exchange="orders",
    routing_key="created",
    body=payload,
    mandatory=True,
    properties=pika.BasicProperties(
        delivery_mode=2,
        message_id=order_id,
        content_type="application/json",
    ),
)

Si la publication échoue, réessayez avec précaution. Une boucle de nouvelle tentative ne doit pas créer aveuglément des événements commerciaux en double. Stockez d'abord un événement sortant dans la base de données de votre application, publiez-le, puis marquez-le comme publié après confirmation. Ce modèle "outbox" est courant car il gère le décalage gênant entre les validations de base de données et la publication de messages.

La persistance a trois éléments

La durabilité dans RabbitMQ est souvent mal comprise car elle a plus d'un interrupteur.

L'échange doit être durable si vous vous attendez à ce qu'il existe après le redémarrage. La file d'attente doit être durable si vous vous attendez à ce qu'elle existe après le redémarrage. Le message doit être persistant si vous vous attendez à ce que son contenu survive au redémarrage.

Omettre l'un de ces éléments peut vous surprendre. Un message persistant envoyé à une file d'attente non durable ne rend pas la file d'attente durable. Une file d'attente durable recevant des messages transitoires peut toujours perdre ces messages transitoires lors du redémarrage. Un échange durable et une file d'attente durable n'aident pas si votre déploiement supprime et recrée la topologie de manière incorrecte.

Utilisez le code de démarrage ou l'automatisation de l'infrastructure pour déclarer la topologie de manière cohérente :

channel.exchange_declare(
    exchange="orders",
    exchange_type="topic",
    durable=True,
)

channel.queue_declare(
    queue="order_processing",
    durable=True,
    arguments={
        "x-dead-letter-exchange": "orders.dlx",
        "x-dead-letter-routing-key": "order_processing.failed",
    },
)

channel.queue_bind(
    queue="order_processing",
    exchange="orders",
    routing_key="created",
)

La persistance réduit la perte lors du redémarrage du courtier, mais elle ne remplace pas les sauvegardes, la redondance du disque, la réplication du quorum ou les confirmations de l'éditeur. Elle a également un coût. Les messages persistants nécessitent un travail sur le disque, et des taux de publication élevés peuvent exposer rapidement un stockage lent. Ce n'est pas une raison pour éviter la persistance des données importantes. C'est une raison pour tester votre charge de travail réelle au lieu de supposer qu'un benchmark sur ordinateur portable s'applique à la production.

Réessayer sans créer une boucle de messages empoisonnés

basic_nack(..., requeue=True) est utile pour les pannes temporaires, mais cela peut devenir dangereux. Si un message échoue toujours, il sera livré encore et encore. Le courtier dépense du travail pour le redistribuer. Les consommateurs dépensent du travail pour le faire échouer. Les bons messages derrière lui peuvent attendre plus longtemps qu'ils ne le devraient.

Un meilleur modèle consiste à séparer les nouvelles tentatives rapides des nouvelles tentatives retardées et de l'échec final.

Une configuration simple :

  • Premier échec : remettre en file d'attente une fois si l'erreur est clairement temporaire.
  • Échec répété : rejeter avec requeue=False.
  • File d'attente de lettres mortes : stocker le message échoué avec les en-têtes et le contexte de routage.
  • Outil de rejeu : permettre à un opérateur ou à un travail planifié d'inspecter et de republier après la résolution de la cause racine.

Pour les nouvelles tentatives retardées, de nombreuses équipes utilisent une file d'attente de nouvelles tentatives avec TTL et un échange de lettres mortes de retour vers la file d'attente d'origine. Cela donne à la dépendance défaillante le temps de récupérer sans la marteler chaque milliseconde.

Soyez prudent avec les en-têtes. RabbitMQ ajoute des métadonnées de lettres mortes telles que x-death. Votre consommateur peut les lire pour décider si un message a déjà été réessayé trop de fois. Ne vous fiez pas uniquement à la mémoire dans le processus consommateur ; cet état disparaît au redémarrage.

Vérifications opérationnelles avant de faire confiance à la file d'attente

Après avoir configuré le code, testez les cas désagréables intentionnellement.

Arrêtez le consommateur pendant la publication des messages. La profondeur de la file d'attente devrait augmenter, et les messages devraient rester après un redémarrage du courtier s'ils sont censés être durables. Redémarrez le consommateur et confirmez qu'il vide la file d'attente.

Tuez le consommateur pendant le traitement. Avec les accusés de réception manuels, le message en vol devrait devenir à nouveau prêt après la fermeture du canal. S'il disparaît, vous accusez réception trop tôt ou utilisez un accusé de réception automatique quelque part.

Publiez avec une clé de routage incorrecte. L'éditeur devrait remarquer l'échec via un retour, une erreur liée à la confirmation ou un chemin d'échange alternatif. Si l'appel de publication semble réussi et que le message n'atterrit nulle part, votre filet de sécurité de routage est incomplet.

Remplissez la file d'attente de lettres mortes avec un message connu comme étant mauvais. Vous devriez pouvoir voir pourquoi il a échoué, combien de fois il a été essayé et s'il peut être rejoué en toute sécurité. Une DLQ sans propriétaire est juste un moyen plus lent de perdre des messages.

Surveillez ces métriques pendant les tests :

  • messages_ready : messages en attente de consommateurs.
  • messages_unacknowledged : messages livrés mais pas encore acquittés.
  • latence de confirmation de publication du côté client.
  • taux d'erreur du consommateur et nombre de nouvelles tentatives.
  • profondeur de la file d'attente de lettres mortes.
  • alarmes de mémoire et de disque.

L'objectif n'est pas de faire en sorte que RabbitMQ garantisse magiquement tous les résultats commerciaux. L'objectif est de rendre chaque échec visible et récupérable.

Vérification finale de la fiabilité

Pour chaque flux de travail RabbitMQ important, confirmez que l'éditeur attend la confirmation du courtier, que l'échange et la file d'attente sont durables lorsqu'ils doivent survivre au redémarrage, que le message lui-même est persistant lorsque son contenu est important, et que le consommateur accuse réception seulement après que le travail réel est terminé. Testez ensuite les cas d'échec : clé de routage incorrecte, redémarrage du courtier, plantage du consommateur, échec de traitement répété et rejeu DLQ.

Si ces tests se comportent comme votre entreprise l'attend, vous ne faites plus qu'espérer que RabbitMQ garde les messages en sécurité. Vous avez un chemin de récupération lorsque quelque chose se casse.