Skip to content

Géo & cache chaud

1. Géo & cache chaud (couche transverse Redis)

Section titled “1. Géo & cache chaud (couche transverse Redis)”

La couche chaude, ici incarnée par Redis, représente principalement toutes les parties des tables SQL relues très fréquemment par des mécanismes qui doivent refléter le réel à la minute près. Ainsi, toutes les données inhérentes au matching, à la tension, aux workers, aux missions et aux compétences sont enregistrées et maintenues en chaud dans Redis. J’ai donc d’un côté MySQL pour le maintien de la vérité, et Redis comme surcouche d’efficience alimentée par SQL.

Prenons un cas concret. Un travailleur se met en ligne. En base, son ready_to_work passe à 1, et dans la foulée de la même action, une fonction utilitaire l’inscrit dans Redis (un set par compétence). SQL et Redis bougent ensemble, jamais l’un sans l’autre. Voici les principaux cas.

Tableau 1 — Synchronisation SQL → Redis par action
Action utilisateurÉcriture SQLÉcriture Redis
Worker passe en ligneready_to_work = 1SADD dans le set de chaque compétence + label « online »
Worker met à jour sa positionmise à jour des coordonnéesGEOADD dans le GeoSet des workers (workers:position), même membre = mise à jour
Worker se déconnecteready_to_work = 0SREM de chaque set
Mission créée ou rouvertestatut openGEOADD (position) dans le GeoSet des missions
Mission fermée ou pourvuestatut ≠ openretrait du GeoSet
Position d’une mission changemise à jour des coordonnéesGEOADD (même membre = mise à jour)

Concrètement, deux règles tiennent cette cohérence.

  • On n’appelle jamais Redis directement pour écrire. J’ai créé des fonctions utilitaires qui, elles, appellent Redis. Ainsi le principe DRY évite bien des erreurs potentielles, avec un seul point de responsabilité et la lib encapsulée.
  • Ensuite, pour chaque action utilisateur concernée, celle qui écrit dans MySQL, j’appelle une de ces fonctions utilitaires, de sorte que Redis ne dérive jamais de SQL.

Les bons choix sont ceux pris en considérant notre contexte spécifique. Je devais donc réfléchir à ce que nous visions, ce que nous étions, le temps que nous avions et choisir la bonne solution au regard des critères ci-dessous :

  • Tout d’abord, bien que nous ayons de hautes ambitions, notre flux se limite aujourd’hui à 500 travailleurs pour 35 restaurants. On peut donc le considérer comme modeste.

  • Notre équipe est tout aussi modeste, composée uniquement de moi-même comme personnel qualifié, et de stagiaires quand le besoin s’en faisait sentir.

  • Mais en dépit de ces contraintes, nous visons des sommets et ne devons surtout pas nous fermer de portes d’entrée de jeu.

Se posent donc trois possibilités pour l’architecture du caching :

Tableau 2 — Les trois stratégies de synchronisation du cache
StratégieAvantagesScalabilitéInconvénient
Inline (write-through)Cohérence immédiate, simplicité, une seule brique, aucune infra en plusBonne à flux modeste ; le coût grandit avec le volume d’écritures (chaque écriture touche Redis)Couplage fort (chaque écriture doit penser à Redis), dérive possible si l’écriture Redis échoue
Job asynchrone (queue)Découplé, ré-essayable, n’alourdit pas la requêteAbsorbe les pics et scale horizontalement (workers de queue)Cohérence en léger retard (eventual), Redis temporairement en retard sur SQL, une queue de plus à opérer
event-driven ou CDCCentralisé, découplé du code métier, difficile d’oublier une écritureRobuste à grande échelle, mais dépend de l’infra CDCInfra plus lourde et complexe, souvent surdimensionnée pour un flux modeste

Vous vous en doutez certainement, à la manière dont j’ai amené le sujet. J’ai adopté la première solution. Nous étions en plein refactor du back-end, avec des enjeux business imminents et une équipe réduite (c’est peu de le dire). Il était impensable de monter une véritable architecture event-driven (ou CDC) dans ces conditions, et c’était inutile. En effet, la première stratégie permet de tenir sans problème notre premier palier de 5 000 utilisateurs (encore loin). À la suite de quoi on pourra commencer à découpler et fiabiliser, en passant sur un système de job asynchrone avec retries.

C’est donc un véritable arbitrage qui a été fait ici, où j’ai choisi sciemment la manière la plus simple à comprendre et à entretenir au quotidien seul, et surtout la plus rapide à livrer sans trop rogner sur la qualité produit. Elle a naturellement des défauts, comme l’hyper-couplage, le fait qu’un oubli puisse créer de la désynchronisation, ou qu’une erreur d’écriture Redis survienne sans ré-essai possible tout en ne bloquant pas la fonction. Mais j’ai estimé que tous ces points étaient une dette acceptable à notre échelle, et que la sur-ingénierie aurait été un mal bien plus grand pour le business.

Toute la robustesse de cette couche tient sur une règle simple, MySQL est la seule vérité, Redis n’en est qu’une projection. Rien ne vit dans Redis sans exister d’abord en base, et tout ce qui est en Redis peut être régénéré depuis MySQL. Redis ne fait donc jamais autorité ; en cas de doute, c’est toujours SQL qui tranche. C’est cet invariant qui rend la réhydratation toujours possible.

S’ajoute une seconde garantie, mes écritures Redis sont idempotentes. Un GEOADD sur un membre déjà présent met simplement à jour sa position, un SADD ou un SREM rejoué ne change rien. Rejouer une écriture, lors d’un retry ou d’une réhydratation complète, ne peut donc jamais corrompre l’état.

Côté pannes, trois cas et leur parade :

  • Redis tombe ou perd sa RAM. Les GeoSets et les sets disparaissent d’un coup. La commande redis:rehydrate reconstruit tout depuis MySQL (missions ouvertes, workers ready_to_work avec leurs compétences et positions) et se relance au démarrage via le flag boot:rehydrated. Comme MySQL détient la vérité, la reconstruction est toujours possible, sans perte.
  • Une écriture Redis échoue alors que le SQL a réussi. C’est le défaut inévitable de mon choix inline, pas de retry immédiat, pas de rollback possible, donc Redis peut se retrouver en léger décalage sur SQL. Ce n’est jamais dangereux, puisque Redis n’est pas autoritaire — au pire un worker ou une mission manque temporairement du chaud, et la prochaine réhydratation resynchronise l’ensemble. À notre échelle, ce risque n’est jamais survenue et est tolérable dans l’absolu.
  • Des entrées périmées s’accumulent. Un worker mal déconnecté peut rester « en ligne » dans Redis. Le cron neeko:auto-offline-users, exécuté toutes les heures, repasse hors ligne tout worker sans signe de vie depuis plus d’une semaine et nettoie ses entrées.

Honnêtement, ce n’est pas très compliqué à cette échelle. Avec 500 travailleurs et 35 restaurants, l’inline tient très large, et je l’estime bon jusqu’à environ 5 000 utilisateurs sans le moindre souci. Le seul goulot quand la charge monte, c’est le couplage lui-même. En effet, chaque écriture SQL paie aussi son écriture Redis, dans la même requête. Négligeable aujourd’hui, mais au-delà d’un certain volume ça alourdit les requêtes et le couplage devient trop risqué.

La montée se fait alors par paliers, exactement dans l’ordre des stratégies présentées en 1.3 :

  • Au-delà de ~5 000 utilisateurs, je passe l’écriture Redis en job asynchrone, elle sort de la requête, part sur la queue avec des retries, ce qui découple, absorbe les pics et permet une meilleur gestion des erreurs potentiels.
  • Plus loin encore, si le volume l’exige vraiment, un CDC (capture des changements en sortie de base) prend le relais pour un découplage total à grande échelle via un système d’évènement. Mais on est encore trés loin de ce cas de figure.

Le point important, c’est que cette montée est peu coûteuse. Comme rien n’appelle Redis directement ( tout passe par mes fonctions utilitaires - voir 1.2 ) passer de l’inline à l’asynchrone ne touche que l’intérieur de ces fonctions, jamais le code métier. J’ai donc choisi le simple aujourd’hui, mais l’architecture garde la porte du scalable déjà ouverte.


Fichiers

  • Services/RedisCache/RedisGeoService — le cœur de la couche : GeoSet des missions ouvertes, sets de workers par compétence, configuration du zoning et réhydratation.
  • Services/RedisCache/WorkerCacheService — la position des workers en GeoSet (workers:position) et leur état (label « en ligne »).
  • Console/Commands/RehydrateRedis — reconstruit les GeoSets depuis MySQL (redis:rehydrate, option --if-needed).
  • Console/Commands/AutoOfflineUsers — repasse hors ligne les workers inactifs (nettoyage d’état).

Dépendances

  • Redis — le stockage chaud en RAM lui-même (GeoSets, sets par compétence).
  • MySQL — source de vérité pour la réhydratation (missions ouvertes, workers ready_to_work, compétences).
  • AppSetting — la configuration du zoning (paliers de rayon selon l’âge de la mission).

Consommé par — le domaine Matching (pré-filtre géographique). Le domaine Tension réutilise le même GeoSet de missions, mais son cache dédié est traité sur sa propre page. C’est une couche transverse, pas un domaine métier.