La file d'attente dans Redis : la liste, le stream, et le choix qui ne se voit qu'après
C'est là que la plupart des gens s'arrêtent. C'est aussi là que les ennuis commencent, mais ils commencent plus tard, un dimanche soir, quand un worker ne consomme plus rien et que personne ne sait pourquoi.
Redis n'est pas un système de files d'attente. Redis est une structure de données en mémoire qui, accessoirement, fait très bien le travail d'une file d'attente tant que tu restes dans certaines limites. Savoir où sont ces limites, c'est tout l'objet de ce qui suit.
La liste, et pourquoi elle suffit souvent
Une liste Redis est une collection ordonnée de chaînes. Tu pousses d'un côté, tu retires de l'autre, et tu obtiens un comportement FIFO. `LPUSH` avec `RPOP`, ou `RPUSH` avec `LPOP` — les deux fonctionnent, la différence est une question de convention et d'habitudes dans ta base de code.``` LPUSH ma:file "tache:1" LPUSH ma:file "tache:2" RPOP ma:file ```
Le deuxième `LPUSH` place `tache:2` en tête de liste. Le `RPOP` sort `tache:1`, parce qu'il est à l'autre bout. Premier entré, premier sorti.
Ce qui coûte : rien. Une liste Redis, c'est O(1) en tête et en queue. Tu peux empiler des millions d'entrées sans que les opérations ralentissent. C'est simple, c'est rapide, et pour 80 % des cas d'usage — envoyer un e-mail, générer un PDF, redimensionner une image — c'est largement suffisant.
Le problème n'est pas la liste. Le problème est ce qui se passe quand la liste est vide.
Le polling, et la facture qu'il cache
Un worker qui fait `RPOP` sur une liste vide reçoit `nil`. Immédiatement. Pas d'attente, pas de blocage. Il doit donc redemander. Et redemander encore. Et encore.``` while true: tache = RPOP ma:file if tache is None: sleep(1) else: traiter(tache) ```
Ce `sleep(1)` paraît anodin. Multiplie-le par vingt workers, et tu as vingt requêtes par seconde qui ne font rien d'autre que vérifier qu'il n'y a rien à faire. Redis encaisse, mais tu gaspilles du CPU, de la bande passante réseau et de la latence sur chaque tâche, parce que le worker dort une seconde entière au moment précis où une tâche arrive.
Redis a prévu le coup. `BRPOP` et `BLPOP` sont les versions bloquantes : le worker reste suspendu jusqu'à ce qu'un élément apparaisse, ou jusqu'à expiration d'un délai que tu fixes.
``` BRPOP ma:file 0 ```
Le `0` signifie : attends indéfiniment. Le worker se réveille à la milliseconde où une tâche arrive. Plus de polling, plus de latence artificielle, plus de CPU gaspillé.
Ce que ça coûte : une connexion Redis occupée par worker en attente. Si tu as deux cents workers, tu as deux cents connexions qui ne font rien mais qui existent. Redis gère ça sans transpirer, mais il faut le savoir avant de dimensionner ton `maxclients`.
Et il y a un piège que personne ne mentionne dans les tutoriels : `BRPOP` ne garantit pas l'ordre de réveil des workers. Si trois tâches arrivent en même temps et que cinq workers attendent, ce n'est pas forcément le premier arrivé qui prend la première tâche. Pour une file d'attente simple, ça n'a aucune importance. Pour un système où l'ordre de traitement entre workers compte, ça en a.
La priorité, quand l'ordre d'arrivée ne suffit plus
Une liste ne connaît qu'un seul ordre : celui dans lequel tu l'as construite. Si tu veux qu'une tâche urgente passe avant une tâche ordinaire arrivée cinq minutes plus tôt, la liste ne t'aidera pas.Il faut passer aux sorted sets. Chaque tâche reçoit un score, et Redis trie automatiquement par ce score.
``` ZADD ma:file:prioritaire 1 "tache:urgente" ZADD ma:file:prioritaire 5 "tache:normale" ZRANGE ma:file:prioritaire 0 0 ```
`ZRANGE` avec `0 0` retourne l'élément au score le plus bas. Tu retires avec `ZREM` une fois traité.
Ce que ça coûte : les opérations sur les sorted sets sont O(log N), pas O(1). Sur dix mille entrées, la différence est invisible. Sur dix millions, elle commence à se sentir. Et tu perds le blocage : `BZPOPMIN` existe, mais il est moins souple que `BRPOP`, et le pattern de retrait devient plus délicat à écrire correctement.
C'est le premier vrai arbitrage. La liste est simple et rapide mais rigide. Le sorted set est flexible mais plus lourd, et te demande de gérer toi-même la cohérence entre l'ajout, la lecture et la suppression.
Le stream, et le saut conceptuel
Redis 5.0 a introduit les streams. C'est une structure conçue dès le départ pour les flux de messages, pas détournée d'un usage voisin.Un stream garde l'historique. Il numérote chaque entrée. Il permet à plusieurs consommateurs de lire le même flux sans se marcher dessus, via des consumer groups. Et surtout, il sait ce qui a été traité et ce qui ne l'a pas été.
``` XADD ma:stream * tache "envoyer_email" destinataire "x@exemple.be" XGROUP CREATE ma:stream groupe1 0 XREADGROUP GROUP groupe1 worker1 COUNT 1 STREAMS ma:stream > ```
Le `>` signifie : donne-moi un message qui n'a encore été livré à personne dans ce groupe. Redis assigne le message à `worker1`. Si `worker1` meurt avant d'avoir acquitté, le message reste dans la pending entries list du groupe, et un autre worker peut le récupérer avec `XCLAIM`.
C'est la différence fondamentale avec la liste. Avec `BRPOP`, le message disparaît de Redis au moment où tu le retires. Si ton worker plante entre le `BRPOP` et la fin du traitement, la tâche est perdue. Avec un stream et un consumer group, la tâche reste marquée comme non acquittée, et tu peux la récupérer.
Ce que ça coûte : de la complexité. Il faut gérer les acquittements (`XACK`), surveiller les messages en attente, décider d'une politique de réclamation, nettoyer les streams qui grossissent. Ce n'est pas insurmontable, mais c'est un vrai morceau de code en plus, et il est facile de l'écrire à moitié.
Ce que Redis ne fera pas pour toi
Redis garde tout en mémoire. La taille de ta file d'attente est donc plafonnée par ta RAM, point.Redis peut persister sur disque, via RDB ou AOF. Mais ce n'est pas Kafka. Ce n'est pas RabbitMQ. Ce n'est pas un système de files d'attente conçu pour ne rien perdre, jamais, quoi qu'il arrive. Si tu cherches cette garantie-là, Redis n'est pas le bon outil, et prétendre le contraire te coûtera cher un jour.
Pour beaucoup d'usages, perdre une tâche sur un million est acceptable. Un e-mail de confirmation qui n'part pas, tu le renvoies. Un redimensionnement d'image qui échoue, tu le relances. Mais une transaction financière qui disparaît dans le vide parce que le worker a crashé entre le `BRPOP` et le commit en base — ça, ce n'est pas acceptable, et Redis seul ne te protégera pas.
La question à te poser n'est pas « est-ce que Redis est rapide ». Il l'est. La question est : « qu'est-ce que je fais quand une tâche disparaît ». Si tu n'as pas de réponse, tu n'as pas encore choisi ton outil.
Le point de bascule
Il n'y a pas de règle universelle. Il y a des seuils qui dépendent de ton trafic, de ta tolérance à la perte, et du temps que tu veux passer à maintenir ton infrastructure.Une liste avec `BRPOP` suffit pour une application web qui envoie quelques centaines de tâches par jour. Tu l'écris en dix minutes, tu l'oublies pendant deux ans. C'est un choix parfaitement défendable.
Un stream avec consumer groups devient nécessaire dès que tu as plusieurs workers, que tu veux savoir ce qui a été traité, et que la perte d'une tâche te pose un problème réel. C'est plus de code, plus de surveillance, mais tu dors mieux.
Et si tu as besoin de garanties fortes, de rétention longue, de débit soutenu sur des téraoctets, alors ni la liste ni le stream ne suffiront. Kafka ou RabbitMQ feront le travail, avec leurs propres complexités, leurs propres coûts d'exploitation, et leurs propres nuits blanches.
Le vrai critère n'est pas la performance brute. C'est ce que tu es prêt à perdre, et combien de temps tu es prêt à passer à comprendre pourquoi tu l'as perdu.