Skip to content

Matching

1. Matching bidirectionnel worker ↔ mission

Section titled “1. Matching bidirectionnel worker ↔ mission”

Neeko est une application de mise en relation travailleur / restaurateur. En ce sens son algorithme de matching est le centre névralgique de sa proposition. Il a pour vocation de trouver un travailleur disponible en moins de 2h à tout restaurant qui recherche de la compétence.

Le matching trouve et ordonne, en temps réel, quels workers et quelles missions sont compatibles. Il fonctionne dans les deux sens : un worker qui se met en ligne reçoit un feed de missions, une entreprise qui poste une mission reçoit des workers. Même recette des deux côtés — un pré-filtre géographique (les candidats proches), puis un affinage (compétences, exclusions, bonus) qui produit un score d’ordonnancement. Le tout en tâche de fond, hors requête, pour tenir la promesse des 2 h.

Bien entendu il y a modulation selon le contexte, similarité et quantité de compétences bonus, profil supérieur et restaurant Premium, ainsi que la localisation effective de la personne. Autant de critères qui permettent de trouver et d’ordonner les bons profils pour les bons restaurants. Ainsi donc le système se décompose en deux séquences :

  • La première est déléguée à Redis. C’est lui qui, via des index géospatiaux, stocke les ID de toutes les missions et de tous les travailleurs. Il peut donc trouver dans un périmètre donné tous les travailleurs disponibles extrêmement rapidement, pour un coût négligeable.
  • Ensuite, le premier filtre géographique passé, on renvoie cette liste à l’algorithme de matching PHP. Lui va, via des requêtes SQL, garder uniquement les travailleurs qui matchent les compétences obligatoires, puis va scorer en fonction des compétences optionnelles, de la qualité du profil et du type de restaurant qui recherche (Premium ou non). Ce scoring va permettre d’ordonnancer la proposition d’offre aux travailleurs, car pour le moment nous avons décidé de laisser le choix final au travailleur.

Socle commun · queue matching

sens worker

WebSocket Reverb

sens mission

WebSocket Reverb

sens mission

Push FCM

Worker passe en ligne

FindMissionsForWorker

Mission postée / modifiée

SyncMissionMatching

Redis GEO — pré-filtre par rayon

SQL — compétences obligatoires

Score — optionnelles, bonus, premium

notifyWorkerOfMissions

Feed ordonné du worker

diffAndNotify

MAJ des workers éligibles

notifyNearbyWorkers

Notif workers à proximité

Figure 1 — Pipeline de matching bidirectionnel

Les développeurs précédents avaient estimé, un peu naïvement, que le SQL Haversine s’imposait pour un matching aussi critique. Un de mes premiers choix d’envergure s’est donc posé très rapidement. Comment architecturer le centre névralgique de notre business ? Or plusieurs contraintes déterminantes n’avaient, de toute évidence, pas été prises en compte dans le choix initial.

  • Tout d’abord, nos utilisateurs se déplaçant, y compris en voiture afin de chercher des missions dans les zones de tension, doivent voir leurs positions réelles reflétées relativement rapidement dans l’algorithme de matching.
  • De la première naît la suivante. Devant mettre à jour la position de centaines d’utilisateurs (voire des milliers) à intervalles réguliers, il me fallait une technologie permettant une mise à jour extrêmement fréquente sans surcharge disproportionnée des serveurs.
  • Enfin, nous ne fonctionnons pas par quartier mais bien par zone kilométrique, ce qui nous dispense des outils SQL traditionnels pour les requêtes géospatiales.

Au regard de nos contraintes, c’est tout naturellement que le SQL Haversine a été écarté. Recalculer la distance de chaque candidat à chaque requête, sur une table réécrite en permanence, faisait exploser la charge serveur. Il me restait donc deux options, Redis et ses fonctions géospatiales en RAM, ou bien MySQL et son éventail d’outils géospatiaux. Avec un usage très borné de la recherche géospatiale (des cercles kilométriques qui s’agrandissent) et des écritures extrêmement fréquentes, j’ai décidé de partir sur Redis qui, de par sa nature RAM, correspondait tout à fait à notre besoin.

Naturellement il n’existe pas de choix meilleur, simplement de plus à propos. Ici mon choix d’architecture nous apporte de la rapidité, des coûts mesurés et de la scalabilité, mais il implique un pipeline en deux temps, une synchronisation à maintenir entre la réalité dure de la base SQL et celle, volatile, de Redis, et aussi un système de réhydratation en cas de coupure ou de désynchronisation. J’estime néanmoins, aujourd’hui encore, que pour notre cas c’était le bon choix.

Le matching s’appuie sur Redis, mais je ne lui ai jamais confié la vérité métier. C’est l’invariant qui tient tout le reste, Redis n’est qu’un pré-filtre géographique, MySQL reste la source de vérité, et l’affinage SQL re-valide systématiquement compétences, exclusions et statut. En conséquence, une donnée Redis périmée ne peut jamais produire un faux match, au pire un candidat de trop éliminé à l’affinage. Redis ne peut donc pas mentir sur le métier.

Les autres garanties que le système tient :

  • Compétences obligatoires — un worker n’est jamais proposé s’il ne possède pas toutes les compétences obligatoires de la mission.
  • Exclusion bidirectionnelle — la blacklist owner ↔ worker s’applique dans les deux sens.
  • Score canonique — le score d’un couple (worker, mission) est identique quel que soit le sens qui l’a initié, l’ordonnancement reste cohérent des deux côtés.
  • Statut — une mission qui n’est plus open n’est jamais re-matchée.

Côté pannes, la volatilité de Redis est le risque que j’ai assumé en 1.3. Je l’ai couvert à trois niveaux :

  • En cas de perte ou de crash serveur, les GeoSets et les sets par métier disparaissent avec la RAM. Une commande redis:rehydrate reconstruit tout depuis MySQL (config de zoning, missions ouvertes, workers ready_to_work avec leurs compétences) et se relance au démarrage (--if-needed via un flag boot:rehydrated). MySQL étant la source de vérité, la reconstruction est toujours possible.
  • En cas de désynchronisation (ce qui ne s’est jamais produit pour le moment), grâce à l’invariant de tête, une désynchronisation ne corrompt jamais un résultat, elle ajoute au pire du bruit éliminé à l’affinage SQL, et la réhydratation resynchronise l’état complet.
  • Un worker resté « en ligne » par erreur ou oublie est repassé hors ligne automatiquement via neeko:auto-offline-users, planifié toutes les heures. Ce cron déconnecte tout worker sans signe de vie depuis plus d’une semaine.

Reste une zone que j’assume comme perfectible. Les jobs de matching sont idempotents et retentés une fois (tries = 2), mais je n’ai pas de failed() de réconciliation dédié : un échec définitif tombe dans les failed jobs d’Horizon. À l’échelle actuelle c’est sans conséquence, c’est un durcissement que je garde en réserve.

Soyons honnêtes sur l’ordre de grandeur. Aujourd’hui, Neeko c’est ~500 travailleurs et 35 restaurants. À cette échelle, le matching ne stresse rien. Les GeoSets tiennent dans quelques kilo-octets de RAM, un GEORADIUS sur une poignée de missions ouvertes est instantané, et la queue matching tourne quasiment à vide. J’ai volontairement dimensionné large (l’infra est très au-dessus de la charge) pour ne pas avoir à ré-architecturer au premier palier de croissance. C’est un choix de marge mesuré, qui ne m’a pas coûté beaucoup plus de temps qu’une autre option et permet d’anticiper l’avenir sereinement.

Tableau 1 — Paliers de scalabilité du matching
ChargeGoulot d’étranglementPalier / réponse
Aujourd’hui (~500 workers, 35 restaurants)aucun, large margerien à faire
×10 à ×50 (milliers de workers)l’affinage SQL par candidatcacher les champs souvent lus via Redis, ajouter des index SQL
×100+ (échelle nationale)débit Redis mono-thread + l’affinage SQLsharder les utilisateurs sur plusieurs serveurs, par région

En somme, le premier goulot ne sera pas Redis, ce sera l’affinage SQL. Redis GEO encaisse des millions de points sans broncher, et mes GeoSets resteront petits longtemps puisqu’ils sont bornés par les missions ouvertes, pas par le total. C’est l’affinage MySQL par candidat (jointures sur les compétences, exclusions, calcul du score) qui montera en pression le premier, le jour où une mission matchera des milliers de candidats. L’avantage, c’est que mon modèle par zones kilométriques se prête naturellement à un découpage par région. Chaque zone pourra tourner sur son propre serveur, sans coordination globale.


Fichiers

  • Services/MatchingService — l’orchestrateur : point d’entrée, calcul du bonus « super neo » et cache de config. Il compose les deux traits ci-dessous.
  • Services/Matching/MatchesMissionsForWorker (trait) — sens worker → missions : construit le feed d’un worker qui se met disponible.
  • Services/Matching/MatchesWorkersForMission (trait) — sens mission → workers : trouve les workers éligibles quand une entreprise poste une mission.
  • Jobs/MatchingMissionJobs/FindMissionsForWorker — job asynchrone déclenché quand un worker passe en ligne (sens worker → missions).
  • Jobs/MatchingMissionJobs/SyncMissionMatching — job asynchrone qui (re)calcule le matching d’une mission créée ou modifiée (sens mission → workers).
  • Jobs/MatchingMissionJobs/{MissionMatchingLifecycleJob, MissionEndedJob, ExpiredMissionCleanupJob} — cycle de vie et nettoyage (fin de mission, expiration).

Dépendances

  • Redis GEO (GEORADIUS, via RedisGeoService) — pré-filtre géographique des candidats.
  • MySQL — compétences (worker_skill_requirements, mission_requirements), exclusions (worker_exclusions), favoris et bonus (favorite_workers, enterprise_profiles), résultats (mission_worker_broadcasts).
  • AppSetting — le zoning par paliers (rayon selon l’âge de la mission) et les seuils du bonus super neo.
  • PremiumService — conditionne le bonus super neo au premium de l’owner.
  • BlacklistService — l’exclusion bidirectionnelle owner ↔ worker.
  • Queue (Horizon) — le matching s’exécute en jobs asynchrones, jamais dans la requête.