100K requêtes par seconde : cache single-flight, routage par hachage et un autoscaler maison
En bref — Un cache en processus avec protocole single-flight, combiné à un routage fondé sur le hachage, à une seconde couche de cache partagée et à une mise à l’échelle horizontale, a été validé en 2023 jusqu’à 100 000 requêtes par seconde sur Kubernetes, dans des environnements contrôlés, avec une latence p99 inférieure à 500 ms. Cet article présente l’architecture, le modèle de concurrence et les leçons opérationnelles retenues.
Ce qui a été mesuré et où. Les chiffres de charge (jusqu’à 100K RPS, p99 < 500 ms) proviennent de tests réalisés en 2023 dans des environnements contrôlés avec l’implémentation en Go (
gateway_cache), pas de trafic réel de production. Cette implémentation, elle, fonctionne en production depuis plus de cinq ans sur la plateforme de données sportives de Genius Sports.cpp-cacheest l’implémentation en C++20 du même protocole ; ses mesures de 2026, prises à part et dans un environnement déclaré, sont dans Benchmarks ; nous ne lui attribuons pas les chiffres de 2023. L’architecture, ce qui a été mesuré et qui l’a fait sont résumés dans la fiche d’expérience Cache et architecture pour une API de paris sportifs.
Le problème central : le thundering herd dans les caches
Prenons n’importe quelle opération coûteuse : une requête à la base de données, un appel à une API externe ou un calcul complexe. Sans coordination, plusieurs fils d’exécution qui demandent la même clé en même temps déclencheront chacun l’opération coûteuse de leur côté.
C’est le problème que cpp-cache résout en mettant en œuvre un bouclier contre le thundering herd. Pour comprendre pourquoi ce mécanisme est essentiel à la stabilité du système, au-delà du simple stockage de données, nous recommandons de lire notre article précédent : Quand un cache fait plus que du cache.
Architecture du système
Le système complet comporte trois couches qui travaillent de concert. Le schéma représente l’architecture avec laquelle les chiffres ont été obtenus, où le cache L1 de chaque processus de travail est gateway_cache (Go) ; cpp-cache met en œuvre la même conception en C++20 et occupe la même place dans l’architecture.
flowchart TD
Client["🌐 Clients\n(navigateurs, mobiles, services)"]
LB["⚖️ Répartiteur de charge / passerelle d’API\n(AWS ALB · NGINX · Envoy)"]
Router["🔀 Routeur par hachage\n(anneau de hachage cohérent)"]
subgraph GW ["Couche passerelle — N processus/pods de travail"]
GW1["Processus 1\ncache L1 · partition A"]
GW2["Processus 2\ncache L1 · partition B"]
GWN["Processus N\ncache L1 · partition Z"]
end
L2["🧱 Cache L2 partagé\n(ElastiCache Redis)"]
Backend["🗄️ Backend d’origine\n(base de données · microservice · API externe)"]
Client -->|HTTP/gRPC| LB
LB -->|routage quelconque| Router
Router -->|hachage de clé → partition| GW1
Router -->|hachage de clé → partition| GW2
Router -->|hachage de clé → partition| GWN
GW1 -->|échec en L1| L2
GW2 -->|échec en L1| L2
GWN -->|échec en L1| L2
L2 -->|échec en L2| Backend
Le répartiteur de charge distribue les connexions ; tant que ni la composition de l’anneau ni le processus de chaque nœud ne changent, le routeur par hachage garantit qu’une clé donnée tombe toujours sur le même processus de travail et donc sur la même instance du cache L1. Quand on ajoute ou retire des processus, le hachage cohérent ne réaffecte qu’une fraction des clés (de l’ordre de 1/N), pas toutes ; ces clés repartent à froid sur leur nouveau processus. Et comme le cache L1 vit dans la mémoire du processus, un processus qui redémarre conserve ses clés attribuées mais revient avec un cache vide. Cette affinité de routage transforme des échecs de cache aléatoires en un taux de succès presque déterministe, limité par la capacité de chaque instance (éviction LRU) et par l’expiration des entrées (TTL).
Couche 1 — cpp-cache : architecture et modèle de concurrence
cpp-cache est un cache en processus en C++20 (uniquement des en-têtes) construit sur les structures de données d’Aleph-w. Il met en œuvre le même modèle de concurrence, conçu en C++ en 1998 et qui fonctionne en production depuis plus de cinq ans dans sa version en Go, gateway_cache.
Composants internes
Trois décisions d’implémentation clés déterminent la performance :
OLhashTable(adressage ouvert avec sondage linéaire) offre des recherches favorables au cache du processeur, sans sauts de pointeurs.- Mutex global bref + mutex par entrée avec variable de condition — le mutex global ne protège que la table et la liste LRU pendant des opérations structurelles de quelques microsecondes (recherche, insertion, réordonnancement), y compris lors des succès ; il n’est jamais conservé pendant le calcul d’une valeur. L’attente d’un calcul en cours se fait sur le mutex de l’entrée, si bien que seuls les fils qui demandent la même clé s’attendent entre eux.
- Statistiques sans verrou au moyen de compteurs
std::atomic<size_t>— enregistrer des métriques n’ajoute de contention sur aucun mutex.
Machine à états de l’entrée
Chaque entrée du cache parcourt une machine à états bien définie, qui est le cœur de la prévention du thundering herd. L’état COMPUTING est le bouclier : pendant qu’un « solver » s’exécute, tout autre fil qui demande la même clé trouve l’état COMPUTING et attend sur la variable de condition de l’entrée au lieu de lancer un calcul en double. Quand le solver termine, tous les fils en attente sont notifiés de façon atomique et partagent le résultat.
L’implémentation de production en Go : gateway_cache et gw_cache
Le protocole de concurrence décrit plus haut est antérieur aux deux implémentations : il a été conçu en C++ en 1998. En 2020, sa version en Go, gateway_cache, a été publiée ; elle fonctionne en production depuis plus de cinq ans sur la plateforme de données sportives de Genius Sports, et en 2023 sa capacité a été validée jusqu’à 100K RPS dans des environnements contrôlés. Deux dépôts Go entretiennent cet héritage :
gateway_cache— la bibliothèque Go publiée en 2020 par Genius Sports et utilisée en production ; la copie de l’auteur, avec un développement ultérieur, est danslrleon/gateway_cache.gw_cache— une variante indépendante à la structure plus épurée.
Choisir entre Go et C++
Les deux implémentations suivent le même protocole de concurrence, dont le modèle formel en Promela/SPIN est dans le dépôt (voir l’article précédent et la vérification formelle avec SPIN). En pratique : commencez par Go si votre service est déjà écrit en Go — l’intégration est triviale et l’historique en production est long. Envisagez C++ si vous intégrez le cache dans un binaire natif ou si les pauses du ramasse-miettes (GC) sont inacceptables pour vos objectifs de latence. Éviter le GC aide à obtenir un p99 inférieur à la milliseconde, mais ne le garantit pas à lui seul : la charge, le chemin critique, la mémoire, la synchronisation et l’infrastructure comptent aussi.
Couche 2 — Routage par hachage
Le routage par hachage de la clé transforme une flotte de caches indépendants en un cache logiquement partitionné et collectivement énorme. Sans lui, chaque processus met en cache tout l’espace de clés de façon indépendante : on gaspille de la mémoire et la charge du backend se multiplie avec la taille de la flotte.
Avec 150 nœuds virtuels par nœud physique, la répartition des clés entre les nœuds s’écarte de moins de 5 % de la moyenne pour des distributions de clés typiques. Attention : une répartition équilibrée des clés ne garantit pas une répartition équilibrée du travail. Une clé très chaude concentre tout son trafic sur un seul processus, et ce cas doit être surveillé à part.
Multiplication effective de la capacité
Sans routage, N pods de capacité C offrent une capacité de cache effective d’environ C (chaque pod met en cache les mêmes clés chaudes). Avec le routage par hachage, la capacité s’approche de N × C.
Couche 3 — L’autoscaler prédictif
La configuration la plus courante du Horizontal Pod Autoscaler (HPA) de Kubernetes se règle sur le processeur ou la mémoire et, dans notre cas, réagissait trop tard aux pics rapides de trafic. HPA accepte aussi des métriques personnalisées et externes, comme les RPS ; la comparaison de cet article porte sur HPA configuré sur le processeur et la mémoire, pas sur toutes les configurations possibles. Nous utilisons un autoscaler maison.
Fonctionnement : rétroaction de type PID + anticipation de tendance
L’autoscaler combine deux mécanismes distincts. Un régulateur en boucle fermée (de type PID) corrige le nombre de répliques selon l’écart entre les requêtes par seconde (RPS) observées et la capacité disponible ; à lui seul, il est réactif et ne prévoit pas la demande. La partie anticipatrice est un composant à part qui extrapole la tendance. Son efficacité repose sur deux facteurs :
- Connaissance déterministe de la capacité : nous avons mesuré de façon empirique combien de RPS un seul pod peut soutenir sans dégrader la latence.
- Logique anticipatrice : en analysant les tendances historiques et la vitesse actuelle de croissance du trafic, l’autoscaler « prévoit » le nombre de répliques nécessaires et les provisionne avant que le trafic n’atteigne le pic.
Cela réduit les pics de latence dus au « démarrage à froid », typiques d’une mise à l’échelle purement réactive, ainsi que la probabilité que la flotte soit sous-dimensionnée face à un pic.
Leçons de production
- Le routage par hachage est le multiplicateur de force — l’affinité de clé a plus d’effet que n’importe quelle augmentation de la taille du cache.
- TTL négatif + disjoncteur — un duo essentiel ; l’un évite les avalanches dues aux clés inexistantes, l’autre limite la dégradation du backend.
- Mettre à l’échelle sur le bon signal — dans notre cas, se régler sur les RPS et la tendance a nettement mieux fonctionné que se régler sur le processeur. Avec HPA, cela suppose de configurer des métriques personnalisées au lieu de s’en tenir au processeur et à la mémoire.
- La bascule est le maillon le plus faible — Kubernetes replanifie les pods sans tenir compte de l’état du cache ; le pic de cache froid est le principal risque opérationnel.
- La période de préchauffage compte — augmentez le trafic vers les nouveaux pods de façon linéaire pendant 2 à 5 minutes.
Résumé
| Couche | Technologie | Apport clé |
|---|---|---|
| Cache L1 | gateway_cache (Go) en production ; cpp-cache (C++20) comme implémentation native | Single-flight, LRU, latence d’environ 50 µs |
| Routage | Hachage cohérent (xxHash, 150 nœuds virtuels) | Affinité de clé, capacité effective N× |
| Cache L2 | ElastiCache Redis | Stockage partagé, latence d’environ 500 µs |
| Origine | Aurora + microservices | Reçoit ≈ 0,25 % du trafic total à 100K RPS |
| Autoscaler | Autoscaler maison | Rétroaction de type PID sur les RPS + anticipation de tendance |
Lors des tests de 2023 dans des environnements contrôlés, l’ensemble a atteint 100K RPS avec un p99 inférieur à 500 ms et plus de 95 % de succès en L1. L’origine reçoit bien moins que le taux d’échecs de L1, car la plupart de ces échecs se résolvent en L2 (Redis) : environ 0,25 % du trafic total parvient à l’origine.
Chez SIMYL Research, nous concevons et construisons des systèmes comme celui-ci, de l’architecture à haute performance jusqu’à l’exploitation sur Kubernetes : développement de backends et d’API à haute performance et conseil technique en performance et architecture. Si votre backend souffre sous la charge, le diagnostic de performance et d’architecture est un premier pas à périmètre fermé et prix de référence.
Étiquettes : C++20 cache haute-performance systèmes-distribués routage-par-hachage kubernetes aws ingénierie-backend