Perte de messages Redis Pub/Sub : Causes et alternatives fiables
Découvrez pourquoi Redis Pub/Sub perd des messages lors de déconnexions réseau ou de consommateurs lents, et explorez des modèles comme Redis Streams et les files d'attente basées sur des listes pour une livraison garantie.
Perte de messages Redis Pub/Sub : Causes et alternatives fiables
Je me souviens de la première fois que Redis Pub/Sub m'a brûlé. Il était tard, environ 23 heures, et notre système de notifications a commencé à perdre des messages. Pas tous — juste assez pour que les utilisateurs le remarquent avant nous. L'ingénieur d'astreinte (moi, malheureusement) a passé deux heures à fouiller les logs d'application avant que la vérité évidente n'éclate : Redis Pub/Sub ne met rien en file d'attente. Ce n'est pas un courtier de messages. C'est une lance à incendie, et si vous n'êtes pas directement devant avec la bouche ouverte, vous allez en rater.
C'est ce que personne ne vous dit quand vous utilisez Redis Pub/Sub pour la première fois. C'est dans la documentation, techniquement, mais c'est facile à ignorer quand on est enthousiaste à propos de la simplicité de l'API. Vous publiez d'un côté, vous vous abonnez de l'autre, et ça marche. Jusqu'à ce que ça ne marche plus.
La réalité du "fire-and-forget"
Redis Pub/Sub fonctionne sur un principe brutalement simple : lorsque vous publiez un message, Redis le pousse vers chaque abonné connecté à ce canal à cet instant précis. Si un abonné n'est pas connecté, ou s'il est connecté mais ne peut pas suivre, le message s'évapore. Il n'y a pas de couche de persistance, pas de mécanisme d'accusé de réception, pas de file d'attente de lettres mortes. Le message n'existe qu'en transit.
Laissez-moi vous donner un exemple concret. Disons que vous avez un service qui publie des mises à jour de statut de commande, et un autre service qui s'abonne pour envoyer des e-mails de confirmation. Sous charge normale, tout fonctionne bien. Puis votre service d'e-mail a un hoquet — peut-être que le relais SMTP est lent, ou qu'il y a une pause de garbage collection. Pendant ce hoquet, Redis continue de pousser des messages. Le buffer TCP de l'abonné se remplit. Finalement, la connexion tombe. Quand l'abonné se reconnecte, il reprend à partir de maintenant, pas de là où il s'était arrêté. Chaque message publié pendant la fenêtre de déconnexion est perdu.
J'ai mesuré cela en pratique avec une configuration de test simple : un éditeur envoyant 10 000 messages par seconde, et un abonné qui se bloque occasionnellement pendant 50 millisecondes. Même avec une seule brève pause, vous perdrez des dizaines de messages. L'abonné ne sait jamais qu'ils ont été envoyés. L'éditeur ne sait jamais qu'ils ont été perdus. Redis est parfaitement content — il a fait exactement ce pour quoi il a été conçu.
Qu'est-ce qui cause réellement la perte de messages
Il y a trois scénarios principaux où Pub/Sub perd des messages, et ils valent tous la peine d'être compris car ils se manifesteront de différentes manières.
L'instabilité réseau est la plus évidente. Toute partition réseau temporaire entre l'abonné et Redis rompt la connexion. Redis détecte cela via le délai d'attente client (60 secondes par défaut, mais vous l'avez peut-être réglé plus bas). Pendant cette fenêtre, tous les messages publiés sont perdus pour cet abonné. D'autres abonnés peuvent les recevoir parfaitement, ce qui rend le débogage encore plus amusant — vous verrez un état incohérent entre les services et vous vous demanderez si vous devenez fou.
Les consommateurs lents sont plus insidieux car la connexion reste ouverte. Redis utilise un modèle push, ce qui signifie qu'il écrit sur les sockets des abonnés aussi vite que les éditeurs produisent. Si un abonné ne peut pas traiter les messages assez vite, le buffer de réception TCP du noyau se remplit. Une fois ce buffer plein, Redis ne peut plus écrire de données, et la connexion finit par échouer. L'abonné pourrait même ne pas remarquer qu'il est en retard jusqu'à la déconnexion.
J'ai vu cela se produire avec des abonnés qui effectuent des écritures synchrones en base de données pour chaque message. À faible volume, ça va. En période de pointe, la base de données devient le goulot d'étranglement, l'abonné prend du retard, et les messages s'accumulent dans le buffer TCP. Quand ce buffer déborde, la connexion se réinitialise, et l'abonné perd tout ce qu'il n'avait pas encore lu du socket.
Les déconnexions client lors des déploiements ou redémarrages constituent la troisième grande catégorie. Si vous faites des déploiements progressifs et qu'une instance d'abonné tombe, elle manque tout ce qui a été publié pendant son absence. Il n'y a pas de mécanisme de "rattrapage". Quand elle revient en ligne, elle repart à zéro.
Une chose qui m'a surpris : même un arrêt propre n'aide pas. Si votre abonné se désabonne gracieusement avant de quitter, il manque quand même les messages publiés entre le désabonnement et son retour. Le désabonnement est instantané — il n'y a pas d'option "gardez mes messages une minute".
Quand Pub/Sub est en fait acceptable
Je ne veux pas donner l'impression que Redis Pub/Sub est inutile. Il est excellent pour des cas d'utilisation spécifiques, et je l'utilise encore régulièrement. La clé est de comprendre quels sont ces cas d'utilisation.
Les notifications en temps réel où une perte occasionnelle est acceptable fonctionnent à merveille. Pensez aux scores sportifs en direct, aux tickers boursiers ou aux indicateurs de frappe dans une application de chat. Si un utilisateur rate une mise à jour de score, la suivante arrive dans quelques secondes de toute façon. Les données ont une courte durée de vie et aucune exigence de durabilité.
La découverte de services et la diffusion de configuration sont un autre point fort. Lorsque vous modifiez un feature flag et le publiez à toutes les instances d'application, il est acceptable qu'une instance en cours de redémarrage manque la mise à jour — elle récupérera l'état actuel lorsqu'elle reviendra en ligne ou lors de la prochaine actualisation périodique.
J'ai également utilisé Pub/Sub avec succès pour l'invalidation de cache sur plusieurs serveurs d'application. Publiez une clé de cache à invalider, et chaque serveur vide son cache local. Si un serveur manque le message, le pire qui puisse arriver est qu'il serve des données obsolètes jusqu'à ce que l'entrée de cache expire naturellement. Pas idéal, mais pas catastrophique non plus.
Le fil conducteur ici : Pub/Sub fonctionne lorsque les messages sont éphémères par nature, lorsque la perte est récupérable via d'autres mécanismes, et lorsque vous n'avez pas besoin de garanties d'ordre ou de livraison exactement une fois.
Redis Streams : l'alternative intégrée
Redis Streams, introduit dans Redis 5.0, est ce que j'utilise maintenant quand j'ai besoin d'une livraison fiable de messages. Ce n'est pas Pub/Sub avec persistance ajoutée — c'est un modèle fondamentalement différent, plus proche d'un journal distribué comme Kafka que d'un mécanisme de diffusion.
Avec Streams, les messages sont ajoutés à un journal et y restent jusqu'à ce qu'ils soient explicitement accusés réception. Les consommateurs peuvent se déconnecter, redémarrer, prendre du retard et toujours rattraper. Le flux conserve les messages en fonction soit d'une longueur maximale, soit d'une période de rétention, vous contrôlez donc l'historique à conserver.
Voici comment le modèle mental diffère. Dans Pub/Sub, vous vous abonnez à un canal et les messages vous parviennent. Dans Streams, vous tirez les messages à votre propre rythme. Un groupe de consommateurs suit les messages que chaque consommateur a accusés réception, vous pouvez donc avoir plusieurs consommateurs lisant le même flux sans duplication (ou avec duplication intentionnelle, si vous voulez une diffusion en étoile).
Une configuration de base de Streams ressemble à ceci :
XADD orders * status confirmed order_id 12345
Cela ajoute un message au flux orders. Le * indique à Redis de générer automatiquement un ID. Ensuite, votre consommateur lit avec :
XREADGROUP GROUP email-processor worker-1 COUNT 10 STREAMS orders >
Le > signifie "donne-moi les messages qui n'ont pas encore été livrés à un consommateur de ce groupe". Après traitement, le consommateur accuse réception :
XACK orders email-processor <message-id>
Si le consommateur plante avant d'accuser réception, le message reste en attente. Un autre consommateur du groupe peut le réclamer avec XCLAIM après un délai d'attente. C'est le mécanisme d'accusé de réception et de redistribution que Pub/Sub n'a pas du tout.
Le modèle de groupe de consommateurs en pratique
Les groupes de consommateurs sont ce qui rend Streams vraiment utile pour un traitement fiable. Chaque groupe maintient sa propre position dans le flux, vous pouvez donc avoir un groupe pour les notifications par e-mail, un autre pour les analyses, et un autre pour la journalisation d'audit — tous lisant le même flux indépendamment.
Au sein d'un groupe, les messages sont distribués entre les consommateurs. Cela vous donne une scalabilité horizontale : ajoutez plus d'instances de consommateurs, et elles partageront la charge. Si une instance meurt, ses messages en attente deviennent disponibles pour que d'autres instances les réclament.
J'ai trouvé que la liste des entrées en attente est inestimable pour la surveillance. Vous pouvez exécuter XPENDING pour voir quels messages n'ont pas été accusés réception et depuis combien de temps ils sont en suspens. Cela révèle immédiatement les consommateurs lents — bien mieux que de découvrir une perte de messages des jours plus tard via des plaintes d'utilisateurs.
Un piège avec Streams : les IDs de messages sont des horodatages monotoniquement croissants, ce qui signifie que vous ne pouvez pas facilement insérer des messages dans le désordre. Si vous avez besoin d'un ordre strict dans un flux, c'est en fait une fonctionnalité. Si vous devez prioriser certains messages, vous aurez besoin de plusieurs flux ou d'une approche différente.
Files d'attente basées sur des listes pour des besoins plus simples
Avant l'existence de Streams, le modèle standard pour une messagerie fiable avec Redis était les files d'attente basées sur des listes avec des pops bloquants. Ce modèle est toujours parfaitement viable, surtout si vous utilisez une version plus ancienne de Redis ou si vous voulez quelque chose de très simple.
L'idée est simple : les producteurs font LPUSH ou RPUSH de messages sur une liste, et les consommateurs font BLPOP ou BRPOP pour bloquer jusqu'à l'arrivée d'un message. Le pop bloquant est crucial — sans lui, vous feriez du polling, ce qui gaspille du CPU et ajoute de la latence.
La fiabilité vient d'une liste secondaire de "traitement". Le consommateur déplace atomiquement un message de la file d'attente en attente vers une file de traitement en utilisant BRPOPLPUSH (ou LMOVE dans Redis 6.2+). Après traitement, il supprime le message de la file de traitement. Si le consommateur plante, la file de traitement conserve le message, et un processus de surveillance peut déplacer les éléments obsolètes vers la file d'attente en attente.
J'ai construit ce modèle plusieurs fois, et ça marche, mais c'est plus de code que vous ne le pensez. Vous devez gérer les délais d'attente, décider combien de temps un message peut rester dans la file de traitement avant d'être considéré comme abandonné, et gérer les cas limites autour du traitement en double. Streams formalise essentiellement tout cela, c'est pourquoi j'ai surtout abandonné les files d'attente manuelles basées sur des listes.
Le seul endroit où j'utilise encore des files d'attente basées sur des listes est pour les files de travail où l'ordre de traitement n'a pas d'importance et où je veux l'implémentation la plus simple possible. Parfois, une liste et une boucle BLPOP suffisent, et ajouter Streams serait de la sur-ingénierie.
Sharding Pub/Sub dans Redis 7
Redis 7 a introduit le Pub/Sub shardé, qui mérite d'être mentionné car il résout un problème différent de la perte de messages. Avec Pub/Sub classique, chaque message est diffusé à chaque nœud d'un cluster, même si aucun abonné sur un nœud donné ne se soucie de ce canal. Cela gaspille de la bande passante d'interconnexion du cluster.
Le Pub/Sub shardé lie les canaux à des slots de cluster spécifiques, de sorte que les messages ne se propagent qu'aux nœuds qui ont réellement des abonnés pour ce canal. C'est une optimisation de performance, pas une fonctionnalité de fiabilité. Vous perdrez toujours des messages en cas de déconnexion. Mais si vous utilisez Pub/Sub à grande échelle dans un environnement clusterisé, cela vaut la peine de le savoir.
Faire le choix : Pub/Sub vs Streams vs listes
Après avoir vécu avec ces modèles pendant des années, mon processus de décision s'est simplifié à quelques questions.
Premièrement : pouvez-vous tolérer la perte de messages ? Si oui, et si les données sont éphémères, Pub/Sub est probablement acceptable. Vous obtiendrez la latence la plus faible et le modèle opérationnel le plus simple.
Deuxièmement : avez-vous besoin de persistance et de rejeu des messages ? Si oui, Streams est la réponse. La capacité de retraiter des messages après une correction de bogue de consommateur m'a sauvé plus d'une fois. Avec Pub/Sub, si votre consommateur avait un bogue qui l'a amené à mal gérer les messages pendant une heure, ces messages sont perdus à jamais. Avec Streams, vous pouvez réinitialiser la position du groupe de consommateurs et les rejouer.
Troisièmement : avez-vous besoin de plusieurs groupes de consommateurs indépendants lisant les mêmes données ? Streams gère cela nativement. Avec Pub/Sub, chaque abonné reçoit chaque message, ce qui pourrait être ce que vous voulez, mais il n'y a aucun moyen d'avoir différents groupes d'abonnés maintenant des positions indépendantes.
Quatrièmement : quelle est votre version de Redis ? Si vous êtes bloqué sur une version antérieure à 5.0, Streams n'est pas disponible, et vous devez envisager des files d'attente basées sur des listes ou un courtier de messages externe. J'ai été dans cette situation, et honnêtement, si vous avez besoin d'une messagerie fiable et ne pouvez pas utiliser Streams, je me demanderais si Redis est le bon outil du tout. RabbitMQ ou NATS pourraient être de meilleurs choix.
Le côté opérationnel dont personne ne parle
Voici quelque chose que j'ai appris à la dure : surveiller Pub/Sub est étonnamment difficile. Vous pouvez surveiller les nombres de connexions et les abonnements aux canaux avec PUBSUB NUMSUB, mais vous ne pouvez pas voir combien de messages sont perdus. Il n'y a pas de métrique pour "messages publiés mais non reçus" car Redis ne suit pas cela.
Avec Streams, vous obtenez de la visibilité. XINFO GROUPS vous montre le retard du consommateur. XPENDING vous montre les messages non accusés réception. Vous pouvez configurer des alertes lorsque le retard dépasse un seuil. Cette visibilité opérationnelle seule a rendu Streams intéressant pour moi.
La gestion de la mémoire est une autre considération. Les messages Pub/Sub n'existent qu'en mémoire et seulement en transit, donc l'utilisation de la mémoire est limitée par votre taux de publication et la vitesse du consommateur. Streams stocke les messages jusqu'à ce qu'ils soient élagués, vous devez donc réfléchir aux politiques de rétention. Je définis généralement une longueur de flux maximale (MAXLEN) en fonction du débit attendu et de la mémoire disponible, et je surveille la longueur du flux pour détecter les accumulations inattendues.
Ce que je fais réellement maintenant
De nos jours, je me tourne par défaut vers Redis Streams pour tout nouveau cas d'utilisation de messagerie nécessitant une fiabilité. L'API est légèrement plus complexe que Pub/Sub, mais pas de beaucoup, et les garanties de fiabilité en valent la peine. Je garde Pub/Sub pour les trucs éphémères — invalidation de cache, présence en temps réel, ce genre de choses.
Pour les messages particulièrement critiques (traitement des paiements, exécution des commandes), j'ai abandonné Redis entièrement et j'utilise des courtiers de messages dédiés. Redis est fantastique pour beaucoup de choses, mais il n'est pas optimisé pour la persistance sur disque de files d'attente de messages à haut volume. Si vous avez besoin que les messages survivent à un redémarrage complet de Redis avec zéro perte, vous devez configurer la persistance AOF avec appendfsync always, ce qui réduit les performances d'écriture. À ce stade, quelque chose comme Kafka ou Pulsar a plus de sens.
Mais pour le vaste entre-deux — où la perte de messages serait ennuyeuse ou coûteuse mais pas catastrophique, et où vous voulez rester dans l'écosystème Redis que vous connaissez déjà — Streams atteint un point idéal. Cela a été suffisamment fiable pour moi en production, et la simplicité opérationnelle de ne pas introduire un nouveau composant d'infrastructure a une réelle valeur.
L'erreur originale que j'ai faite avec Pub/Sub n'était pas vraiment à propos de la technologie. C'était de ne pas lire les petits caractères, de supposer que "messagerie" impliquait "garanties de livraison de messages". Redis Pub/Sub ne fait aucune garantie de ce genre, et il ne prétend pas le faire. Une fois que vous comprenez cela, vous pouvez l'utiliser de manière appropriée et vous tourner vers Streams quand vous avez besoin de plus.