Le modèle à quatre étapes
Le défaut réside dans quatre étapes séquentielles non atomiques :
// DÉFAUT
Value value = cache.get(key);
if (value == null) {
value = expensiveCompute(key);
cache.put(key, value);
}
return value;
Étape 1 : vérifier le cache. Étape 2 : échec. Étape 3 : calculer. Étape 4 : stocker. Les quatre étapes ne sont pas atomiques. Entre l'étape 1 et l'étape 4, n'importe quel nombre de threads peut exécuter l'étape 1 et tous voient null.
Le piège d'idempotence
Le raisonnement qui protège ce pattern : « il est acceptable que deux threads calculent et stockent la même valeur. Le résultat est idempotent. Aucune corruption de données ne se produit. »
Ce raisonnement : correct sur la justesse. Fatal sur le coût.
À 1 000 threads sur un cache miss : 1 000 threads exécutent chacun expensiveCompute(key). Si expensiveCompute interroge une base de données, 1 000 requêtes simultanées sont envoyées. S’il appelle un service externe, 1 000 requêtes HTTP sont lancées simultanément. Le système qui produit des résultats corrects s’effondre sous le coût de leur production.
Trois déclencheurs
Un Thundering Herd se déclenche lorsqu’une clé de cache passe de chaude à froide simultanément sur de nombreux threads :
Cold start : le service redémarre avec un cache vide. Première vague de requêtes : chaque clé est manquante. Tous les calculs s’exécutent simultanément.
Redémarrage du service : un redémarrage progressif réinitialise le cache sur toutes les instances. Le trafic est redistribué vers des instances froides.
Expiration TTL : une clé à fort trafic expire. N threads vérifient tous, tous manquent, tous calculent avant que le premier thread ne stocke le résultat.
Les trois déclencheurs : corrélés avec les pics de trafic. Le troupeau se déclenche lorsque le trafic atteint son maximum et que le cache est froid. Le pire moment possible.
L’exemple d’Elasticsearch EnrichCache
Elasticsearch EnrichCache : le commentaire de la documentation indique « intentionnellement non verrouillant pour simplifier… OK si nous ré-insérons la même clé/valeur lors d’une condition de concurrence ». À 10 000 documents par seconde avec un enrich cache froid : les 10 000 requêtes atteignent simultanément l’index d’enrichissement. L’index d’enrichissement, conçu pour des recherches occasionnelles, fait face à 10 000 requêtes concurrentes. Le cluster devient instable.
Le raisonnement d’idempotence : correct dans le commentaire du code. Catastrophique à 10 000 documents par seconde.
Couplage avec MOAD-0001
MOAD-0001 (Sedimentary Defect) crée un goulot d’étranglement O(N²) dans les systèmes à haut débit. Corriger MOAD-0001 (O(N²) vers O(N)) débloque cette station de travail. Le débit plus rapide envoie davantage de requêtes en aval. Les caches en aval, auparavant protégés par le goulot d’étranglement MOAD-0001, reçoivent désormais des pics de trafic corrélés. MOAD-0005 se déclenche dans des caches qui ne l’avaient jamais déclenché auparavant. Corriger un MOAD ; préparer l’autre.
Ce que le piège d’idempotence comprend mal
Le commentaire Elasticsearch représente un raisonnement d’ingénierie soigneux appliqué à la mauvaise question. L’idempotence : une propriété réelle qui mérite d’être analysée. Le piège : s’arrêter à la correction sans poursuivre jusqu’au coût.
computeIfAbsent & singleflight
La correction : rendre atomique l’opération de vérification et de calcul. Un seul thread effectue le calcul. Tous les autres attendent le résultat.
Java : computeIfAbsent
// DÉFAUT : quatre étapes non atomiques
Value value = cache.get(key);
if (value == null) {
value = expensiveCompute(key);
cache.put(key, value);
}
return value;
// CORRECTION : vérification atomique et calcul
return cache.computeIfAbsent(key, k -> expensiveCompute(k));
computeIfAbsent : si la clé est absente, calcule exactement une fois, stocke et retourne. Tous les autres threads appelant computeIfAbsent pour la même clé attendent le premier calcul. Pas de calcul N fois. Pas d’effet de ruée.
Go : singleflight.Group
var g singleflight.Group
func getOrCompute(key string) (Value, error) {
v, err, _ := g.Do(key, func() (interface{}, error) {
return expensiveCompute(key)
})
return v.(Value), err
}
singleflight : si un calcul pour une clé est déjà en cours, tous les appelants pour la même clé attendent et partagent le résultat unique. Un seul calcul, N attendants, un résultat partagé. L'abstraction 'flight' : dédupliquer les requêtes en vol.
Verrou vs singleflight
Un verrou naïf par clé sérialise : le thread 1 calcule, le thread 2 attend, le thread 3 attend. Une fois le thread 1 terminé, le thread 2 entre et vérifie le cache (succès). Le thread 3 entre et vérifie le cache (succès). N-1 acquisitions de verrou et lectures de cache.
singleflight déduplique : le thread 1 calcule, les threads 2 à N attendent tous le résultat du thread 1. Pas d'acquisitions de verrou séparées. Pas de lectures de cache séparées. Un seul calcul, un seul résultat, distribué à N attendants. Moins d'opérations qu'avec un verrou par clé.
Les deux empêchent la ruée. singleflight empêche le travail redondant de manière plus complète.
Réécrire le modèle
Appliquer la correction à un scénario concret.
// Un cache de profil utilisateur dans un service Java à fort trafic
public UserProfile getProfile(String userId) {
UserProfile profile = profileCache.get(userId);
if (profile == null) {
profile = database.loadProfile(userId); // coûteux : requête DB de 50 ms
profileCache.put(userId, profile);
}
return profile;
}
Le service redémarre tous les matins à 2 h. À 8 h, 10 000 utilisateurs demandent leur profil simultanément.