La page est rapide en staging. En production, elle est rapide… jusqu’à ce qu’elle ne le soit plus. Une clé de cache expire, mille requêtes s’accumulent, et soudain votre « base de données de référence » devient aussi votre base de regrets.
Postgres fait maintenant du cardio à 3h du matin pendant que Redis observe depuis la ligne de touche comme un videur qui a oublié pourquoi le club est plein.
Les cache stampedes (alias thundering herds) sont l’un de ces pannes qui paraissent injustes : le système « fonctionne comme prévu », et c’est le problème. Ceci est un guide pratique pour prévenir les stampedes avec Redis et garder PostgreSQL en vie quand le cache ment, expire ou est vidé par quelqu’un « qui ne faisait que dépanner ».
À quoi ressemble un cache stampede en production
Un cache stampede n’est pas un simple « cache miss ». Un cache miss est un événement opérationnel normal. Un stampede survient lorsqu’un cache miss devient synchronisé : de nombreux appelants ratent la même clé en même temps et vont tous vers le magasin de données de backend ensemble.
C’est une défaillance coordonnée sans réunions.
Chronologie typique
- T-0 : une clé chaude expire, ou un déploiement change un namespace de cache, ou Redis redémarre et perd des données.
- T+1s : la latence des requêtes augmente ; les threads de l’application commencent à s’accumuler en attendant Postgres.
- T+10s : Postgres atteint la saturation des connexions ; le CPU monte ; la file d’attente d’E/S grandit ; autovacuum devient alors votre deuxième incendie.
- T+30s : les retries en amont se déclenchent ; le trafic se multiplie ; les caches deviennent une rumeur.
- T+60s : vous commencez à scaler des nœuds applicatifs, ce qui augmente la concurrence et aggrave le problème.
La partie laide est que la plupart des systèmes sont conçus pour échouer ouvert : « si cache miss, lire depuis la BD ». Cela fonctionne à petite échelle.
À grande échelle, c’est une attaque par déni de service que vous vous infligez poliment, avec des tableaux de bord de métriques ouverts.
Une citation exacte, parce qu’elle colle : L’espoir n’est pas une stratégie.
— Général Gordon R. Sullivan
Petite blague #1 : Les cache stampedes sont comme les donuts gratuits au bureau — une personne en parle, et soudain plus personne ne se souvient des « contraintes budgétaires ».
PostgreSQL vs Redis : rôles, forces et modes de défaillance
PostgreSQL est la vérité (et beaucoup de responsabilités)
Postgres est conçu pour être correct : transactions ACID, écritures durables, index, planification de requêtes, niveaux d’isolation, et garde-fous qui privilégient l’intégrité plutôt que la vitesse.
Ce n’est pas un cache. Il peut se comporter comme tel quand vous avez de la RAM et que le working set tient, mais les stampedes se moquent de votre buffer cache.
Postgres échoue de manière prévisible sous les stampedes :
- Épuisement des connexions (trop de clients, trop de requêtes concurrentes)
- Saturation CPU (nombreuses requêtes identiques coûteuses avec caches froids)
- Bloquages I/O (lectures aléatoires et recherches d’index ; checkpointer/wal ; latence de stockage)
- Amplification des verrous (même les lectures peuvent entrer en contention via la métadonnée ou à cause d’effets de mise en file)
- Décalage des réplicas (les replicas de lecture se voient assommés ; le lag augmente ; eventuallement vous lisez des données périmées)
Redis est la machine à rumeurs (rapide, volatile et extrêmement utile)
Redis est un serveur de structures de données en mémoire. Il est rapide, mono-thread par shard en mode classique (et reste effectivement mono-thread pour beaucoup de commandes même dans des configurations plus complexes), et conçu pour des opérations à faible latence.
Ce n’est pas de la magie non plus. Si votre protection contre les stampedes dépend de Redis étant parfaitement disponible, vous avez construit un point de défaillance unique avec de plus jolis tableaux de bord.
Redis échoue différemment :
- Tempêtes d’évictions si la politique de mémoire n’est pas alignée avec la taille des clés et les TTL
- Pics de latence pendant la persistance (RDB/AOF fsync) ou pour les commandes lentes
- Vides de cache (accidentels, opérationnels, ou lors d’un basculement)
- Contention sur clé chaude (une seule clé frappée ; CPU qui monte ; mise en file réseau)
La frontière de décision : quoi mettre où
Mettez ceci dans Postgres :
- Données systémiques de référence
- Chemins d’écriture nécessitant durabilité et contraintes
- Requêtes ad hoc complexes et analyses sur l’état courant (dans une certaine mesure)
Mettez ceci dans Redis :
- Données dérivées et projections optimisées pour la lecture
- Limites de débit, verrous court-terme, clés d’idempotence
- Mémorisation partagée où le recompute est acceptable
- Primitives de coordination (avec précaution) comme des verrous « single-flight »
L’objectif n’est pas « Redis remplace Postgres ». L’objectif est « Redis empêche Postgres de voir le même problème des milliers de fois par seconde ».
Faits intéressants et brève histoire (parce que le contexte aide)
- Postgres a commencé comme POSTGRES au milieu des années 1980 à l’UC Berkeley comme successeur d’Ingres, avec un travail précoce sur l’extensibilité et les règles.
- MVCC n’est pas un truc de performance ; c’est un modèle de concurrence. Le MVCC de Postgres réduit les verrous en lecture, mais signifie aussi que la bloat et le vacuum sont des réalités.
- Redis a été créé en 2009 et est devenu le choix par défaut « cache rapide + primitives simples » pour une génération de systèmes web.
- Le « thundering herd problem » est antérieur au web ; c’est un problème classique des OS et des systèmes distribués où beaucoup d’attendants se réveillent en même temps.
- Le jitter sur les TTL est une vieille astuce des premiers systèmes de cache : randomiser l’expiration pour éviter des invalidations synchronisées.
- Les CDN ont popularisé stale-while-revalidate comme pattern HTTP cache-control ; la même idée fonctionne dans Redis avec un peu de discipline.
- PgBouncer existe en grande partie parce que les connexions Postgres sont coûteuses comparées à beaucoup d’autres systèmes ; trop de clients directs est un piège connu.
- La persistance Redis est optionnelle par conception ; de nombreux déploiements échangent durabilité contre vitesse, ce qui va tant que quelqu’un ne traite pas Redis comme une base de données.
Patrons qui arrêtent réellement les stampedes
1) Coalescence de requêtes (« single-flight ») : un rebuild, de nombreux attendants
Quand une clé manque, n’autorisez pas chaque requête à la reconstruire. Élevez une requête pour faire le travail coûteux ; tous les autres attendent brièvement, ou reçoivent des données périmées.
C’est le contrôle de stampede le plus efficace car il attaque le facteur multiplicatif.
Vous pouvez implémenter la coalescence :
- En-process (fonctionne par instance ; ne coordonne pas toute la flotte)
- Distribué avec des verrous Redis (coordonne entre instances ; nécessite des timeouts prudents)
- Via une file de travailleurs dédiée « cache builder » (plus robuste, plus de composants mobiles)
2) Stale-while-revalidate : servir des données anciennes pendant la actualisation
Un cache à TTL strict a une falaise : à l’expiration, vous avez soit des données, soit rien. Stale-while-revalidate remplace cette falaise par une pente :
conservez un « TTL frais » et un « TTL périmé ». Pendant la fenêtre périmée, vous pouvez servir l’ancienne valeur rapidement pendant qu’un travailleur la rafraîchit.
C’est le pattern à utiliser quand vous vous souciez plus de disponibilité et de latence que de fraîcheur parfaite. La plupart des pages produit, blocs de recommandations et widgets « top N » sont concernés.
« Solde de compte » ne l’est pas.
3) TTL soft + TTL hard : deux expirations, un système
Stockez des métadonnées avec la valeur mise en cache :
- soft_ttl : après ça, il est acceptable de rafraîchir en arrière-plan
- hard_ttl : après ça, vous devez rafraîchir ou échouer fermé / dégrader
Soft/hard TTL est une manière pratique d’encoder la tolérance métier à la périssabilité sans dépendre de Redis pour des sémantiques de cache sophistiquées.
4) Jitter sur le TTL : arrêter les expirations synchronisées
Si un million de clés ont été écrites par le même job avec le même TTL, elles expireront ensemble. Ce n’est pas de la « malchance », c’est des maths.
Ajoutez du jitter : TTL = base ± aléatoire. Gardez-le borné.
Un bon jitter est assez petit pour ne pas violer les besoins produit et assez grand pour désynchroniser les reconstructions. Pour un TTL de 10 minutes, ±60–120 secondes suffit souvent.
5) Cache négatif : mettre en cache « introuvable » et « permission refusée »
Les résultats non trouvés sont quand même des résultats. Si un ID utilisateur manquant ou une page produit absente déclenche une requête BD à chaque fois, des attaquants (ou des clients bogués) peuvent vous marteler avec des misses.
Mettez en cache les 404 et les résultats « no rows » brièvement. Gardez un TTL court pour ne pas masquer des enregistrements nouvellement créés.
6) Façonner les requêtes pour Postgres : réduire le coût par miss
Si chaque cache miss coûte à Postgres une requête multi-join avec tri, vous misez votre disponibilité sur le fait que le cache ne manquera jamais. Ce pari perdra.
Construisez des modèles de lecture bon marché à calculer (ou pré-calculés), et assurez-vous que les index correspondent à vos patterns d’accès.
7) Backpressure et timeouts : échouer vite avant de faire la file indéfiniment
Le tueur de stampede que vous avez déjà, ce sont les timeouts. Le problème est que la plupart des systèmes les fixent trop hauts et de manière incohérente.
Votre appli devrait arrêter d’attendre bien avant que Postgres ne soit totalement hors-service ; sinon vous construisez un arriéré qui devient un multiplicateur d’incident.
Vous voulez :
- Des timeouts plus courts sur les chemins de rebuild du cache que sur les lectures interactives
- Des limites de concurrence par clé ou par endpoint
- Un budget de retries (retries limités, backoff exponentiel, jitter)
8) Protéger Postgres avec des pools et du contrôle d’admission
Les stampedes apparaissent souvent comme « trop de connexions » parce que chaque instance applicative ouvre plus de connexions pour être serviable.
Utilisez PgBouncer, plafonnez la concurrence au niveau applicatif, et envisagez des pools séparés pour :
- Lectures interactives
- Reconstructions de cache en arrière-plan
- Jobs batch
9) Chauffer le cache, mais comme un adulte
Le cache warming fonctionne quand il est borné et mesurable. Il échoue quand c’est un job naïf « recalculer tout maintenant ».
Un warmer sûr :
- Prioritise les clés chaudes principales
- S’exécute avec une limite stricte de concurrence
- S’arrête quand Postgres est sous pression
- Utilise les mêmes contrôles anti-stampede que les lectures de production
Petite blague #2 : « On va juste préchauffer tout le cache » est l’équivalent infrastructurel de dire qu’on va « juste rembourser toute la dette technique ce sprint ».
Guide de diagnostic rapide
Quand la latence monte et que votre tableau de bord ressemble à de l’art moderne, ne commencez pas par débattre d’architecture. Commencez par prouver où le temps passe.
Le diagnostic le plus rapide est une séquence disciplinée.
Première étape : Redis manque-t-il, est-il lent ou vide ?
- Vérifiez la latence et le débit des commandes Redis.
- Confirmez que le taux de hits ne s’effondre pas.
- Recherchez des événements d’éviction ou de redémarrage.
- Identifiez les clés chaudes principales (ou motifs) causant des misses.
Deuxième étape : Postgres est-il saturé ou juste en file d’attente ?
- Vérifiez les connexions actives vs max.
- Vérifiez les wait events (locks, I/O, CPU).
- Trouvez les requêtes répétées principales ; voyez si elles corrèlent avec des misses du cache.
- Vérifiez le lag des réplicas si les lectures vont vers des replicas.
Troisième étape : l’application multiplie-t-elle le problème ?
- Retries, timeouts, coupe-circuits : sont-ils raisonnables ?
- Y a-t-il de la coalescence de requêtes ou un verrou, ou est-ce que vous diffusez les reconstructions ?
- Est-ce que votre pool de connexions provoque de la mise en file (threads en attente de connexions BD) ?
Points de décision
- Si Redis va bien mais Postgres fond, vous avez probablement un faible taux de hits, un namespace expiré, ou un nouveau chemin chaud contournant le cache.
- Si Redis est lent, réparez Redis d’abord ; un cache lent peut être pire que pas de cache car il ajoute de la latence et force quand même du travail BD.
- Si les deux vont bien mais la latence applicative est élevée, vous avez peut-être de la famine de thread pool ou des appels aval qui s’empilent.
Tâches pratiques : commandes, sorties et décisions (12+)
Ce sont des vérifications de qualité production. Chaque tâche inclut : une commande, une sortie d’exemple, ce que cela signifie, et la décision à en tirer.
Les sorties sont représentatives ; vos chiffres seront différents. L’important est la forme des données et l’action qu’elles entraînent.
Task 1: Verify Redis is up and measure instantaneous latency
cr0x@server:~$ redis-cli -h 127.0.0.1 -p 6379 --latency -i 1
min: 0, max: 3, avg: 0.45 (1000 samples)
min: 0, max: 12, avg: 1.10 (1000 samples)
Ce que cela signifie : Redis répond, mais les pics max (12ms) peuvent nuire à la latence tail si votre SLO est strict.
Décision : Si max/avg augmente pendant les incidents, enquêtez sur les commandes lentes, le fsync de persistance, ou des voisins bruyants avant d’accuser Postgres.
Task 2: Check Redis evictions and memory pressure
cr0x@server:~$ redis-cli INFO memory | egrep 'used_memory_human|maxmemory_human|mem_fragmentation_ratio'
used_memory_human:9.82G
maxmemory_human:10.00G
mem_fragmentation_ratio:1.62
Ce que cela signifie : Vous êtes presque au plafond, avec de la fragmentation. Les évictions sont probables et la latence peut augmenter.
Décision : Soit augmenter maxmemory, réduire la taille des clés, soit changer la politique d’éviction. Ne prétendez pas qu’un cache plein est « normal ».
Task 3: Confirm Redis eviction policy and whether it matches your cache design
cr0x@server:~$ redis-cli CONFIG GET maxmemory-policy
1) "maxmemory-policy"
2) "noeviction"
Ce que cela signifie : Avec noeviction, les écritures échoueront sous pression. Votre application peut interpréter ces échecs comme des misses et provoquer un stampede vers la BD.
Décision : Pour des charges de cache, préférez une politique d’éviction comme allkeys-lru ou volatile-ttl selon votre stratégie de clés, et gérez les misses de façon robuste.
Task 4: Spot hot keys by sampling Redis commands
cr0x@server:~$ redis-cli MONITOR
OK
1735588430.112345 [0 10.2.3.14:52144] "GET" "product:pricing:v2:sku123"
1735588430.112612 [0 10.2.3.22:49018] "GET" "product:pricing:v2:sku123"
1735588430.113001 [0 10.2.3.19:58820] "GET" "product:pricing:v2:sku123"
Ce que cela signifie : Une clé est fortement sollicitée. Si elle expire, vous le ressentirez partout.
Décision : Ajoutez de la coalescence par clé et du stale-while-revalidate pour cette classe de clés ; envisagez de scinder la clé ou de mettre en cache par segment.
Task 5: Check Postgres connection pressure
cr0x@server:~$ psql -h 127.0.0.1 -U postgres -d appdb -c "select count(*) as total, sum(case when state='active' then 1 else 0 end) as active from pg_stat_activity;"
total | active
-------+--------
480 | 210
(1 row)
Ce que cela signifie : Vous avez beaucoup de sessions ; beaucoup sont actives. Si max_connections est ~500, vous êtes proche du mur.
Décision : Si vous êtes proche du mur lors des pics, placez les clients derrière PgBouncer et limitez la concurrence applicative sur les chemins de rebuild.
Task 6: Identify what Postgres is waiting on (locks, I/O, CPU)
cr0x@server:~$ psql -h 127.0.0.1 -U postgres -d appdb -c "select wait_event_type, wait_event, count(*) from pg_stat_activity where state='active' group by 1,2 order by 3 desc;"
wait_event_type | wait_event | count
-----------------+---------------------+-------
IO | DataFileRead | 88
Lock | relation | 31
CPU | | 12
(3 rows)
Ce que cela signifie : Les lectures bloquent sur le disque et il y a de la contention de verrous. C’est cohérent avec une tempête de misses.
Décision : Réduisez les lectures coûteuses répétées (coalescence/stale), et vérifiez les index. Pour les waits de verrous, trouvez les requêtes bloquantes.
Task 7: Find the blocking query chain
cr0x@server:~$ psql -h 127.0.0.1 -U postgres -d appdb -c "select a.pid as blocked_pid, a.query as blocked_query, b.pid as blocking_pid, b.query as blocking_query from pg_stat_activity a join pg_stat_activity b on b.pid = any(pg_blocking_pids(a.pid)) where a.state='active';"
blocked_pid | blocked_query | blocking_pid | blocking_query
------------+--------------------------------+--------------+------------------------------
9123 | update inventory set qty=qty-1 | 8871 | vacuum (analyze) inventory
(1 row)
Ce que cela signifie : Une opération de maintenance bloque une écriture, ce qui peut se répercuter en retries et augmenter la charge.
Décision : Si c’est pendant la réponse à un incident, envisagez de mettre la maintenance en pause ou de la replanifier ; puis adressez pourquoi les mises à jour d’inventaire sont sur le chemin chaud des lectures mises en cache.
Task 8: Identify the most expensive repeating queries during the stampede
cr0x@server:~$ psql -h 127.0.0.1 -U postgres -d appdb -c "select calls, total_time, mean_time, left(query,120) as query from pg_stat_statements order by total_time desc limit 5;"
calls | total_time | mean_time | query
-------+------------+-----------+---------------------------------------------------------
92000 | 812340.12 | 8.83 | select price, currency from pricing where sku=$1 and...
48000 | 604100.55 | 12.59 | select * from product_view where sku=$1
Ce que cela signifie : Les mêmes requêtes sont exécutées des dizaines de milliers de fois. C’est une signature de tempête de misses du cache.
Décision : Ajoutez de la coalescence autour de ces clés de cache, et envisagez de stocker le modèle de lecture complet dans Redis pour éviter la requête plus lourde.
Task 9: Check replica lag if reads go to replicas
cr0x@server:~$ psql -h 127.0.0.1 -U postgres -d appdb -c "select now() - pg_last_xact_replay_timestamp() as replica_lag;"
replica_lag
-------------
00:00:17.412
(1 row)
Ce que cela signifie : 17 secondes de lag peuvent transformer « cache miss, lire sur replica » en « cache miss, lire périmé puis invalider puis retry ».
Décision : Pendant une forte charge, cessez de router les lectures critiques vers des réplicas en retard ; préférez un cache périmé à un replica en retard pour les données non critiques.
Task 10: Inspect PgBouncer pool saturation
cr0x@server:~$ psql -h 127.0.0.1 -p 6432 -U pgbouncer -d pgbouncer -c "show pools;"
database | user | cl_active | cl_waiting | sv_active | sv_idle | sv_used | maxwait
----------+-------+-----------+------------+-----------+---------+---------+---------
appdb | app | 120 | 380 | 80 | 0 | 80 | 12.5
(1 row)
Ce que cela signifie : Les clients attendent (380). Les connexions serveur sont plafonnées et entièrement utilisées. Vous vous mettez en file au pool.
Décision : Ne haussez pas aveuglément les tailles de pool. Ajoutez du contrôle d’admission sur les chemins de rebuild ; augmentez la capacité BD seulement après avoir réduit l’amplification du stampede.
Task 11: Confirm Redis keyspace hit/miss trend
cr0x@server:~$ redis-cli INFO stats | egrep 'keyspace_hits|keyspace_misses'
keyspace_hits:182334901
keyspace_misses:44211022
Ce que cela signifie : Les misses sont élevés. Si le taux de miss a soudainement augmenté après un déploiement, vous avez probablement changé le format de clé, le TTL ou la sérialisation.
Décision : Rétablissez les changements de namespace de clé ou ajoutez des lectures rétrocompatibles pour les anciennes clés ; implémentez un déploiement progressif pour les modifications de schéma de cache.
Task 12: Check for Redis persistence stalls (AOF) contributing to latency
cr0x@server:~$ redis-cli INFO persistence | egrep 'aof_enabled|aof_last_write_status|aof_delayed_fsync'
aof_enabled:1
aof_last_write_status:ok
aof_delayed_fsync:137
Ce que cela signifie : Les fsync retardés indiquent que l’OS/le stockage ne suit pas. La latence Redis va piquer, et les clients peuvent timeout puis retomber sur la BD.
Décision : Envisagez de changer la politique de fsync AOF pour une charge de cache, ou déplacez Redis sur un stockage plus rapide / isolez les voisins bruyants. Ajustez aussi les timeouts clients pour éviter des vagues vers la BD.
Task 13: Find whether the application is retrying too aggressively
cr0x@server:~$ grep -R "retry" -n /etc/app/config.yaml | head
42: retries: 5
43: retry_backoff_ms: 10
44: retry_jitter_ms: 0
Ce que cela signifie : 5 retries avec 10ms de backoff et aucun jitter vont marteler les dépendances lors des incidents.
Décision : Réduisez les retries, ajoutez un backoff exponentiel et du jitter, et introduisez un budget de retry par classe de requête.
Task 14: Inspect top Postgres tables for cache-miss-driven I/O
cr0x@server:~$ psql -h 127.0.0.1 -U postgres -d appdb -c "select relname, heap_blks_read, heap_blks_hit from pg_statio_user_tables order by heap_blks_read desc limit 5;"
relname | heap_blks_read | heap_blks_hit
--------------+----------------+---------------
pricing | 9203812 | 11022344
product_view | 7112400 | 15099112
(2 rows)
Ce que cela signifie : Des lectures disque élevées sur un petit ensemble de tables suggèrent que votre working set n’est pas en cache, ou que votre pattern d’accès est suffisamment aléatoire pour manquer les buffers.
Décision : Réduisez le travail BD par requête (coalescence/stale), ajoutez des index, ou redessinez la projection mise en cache pour la rendre moins coûteuse.
Trois mini-histoires d’entreprise depuis le terrain
Mini-histoire 1 : L’incident causé par une mauvaise hypothèse
Une entreprise de type retail avait un « cache de tarification » dans Redis avec un TTL de 5 minutes. L’équipe supposait que puisque Redis était « en mémoire », il était effectivement toujours présent et toujours rapide.
Leur chemin code cache-aside était propre : GET, si miss alors requête Postgres, puis SETEX. Simple. Trop simple.
Un soir, Redis a été redémarré pendant une fenêtre de maintenance de routine. Il est revenu rapidement, mais le dataset était effectivement froid. Les serveurs applicatifs ont interprété cela comme une énorme vague de misses et sont allés directement vers Postgres.
Le trafic n’était pas inhabituel. Le cache était la partie inhabituelle : il était passé de « majoritairement hits » à « majoritairement misses » en quelques secondes.
Postgres n’est pas mort immédiatement. Il s’est mis en file. Les connexions ont monté. Les pools PgBouncer se sont remplis. Les threads applicatifs se sont bloqués en attendant une connexion, et des timeouts ont déclenché des retries.
La mauvaise hypothèse n’était pas « Redis est rapide ». La mauvaise hypothèse était « les misses du cache sont des événements indépendants ». Ils ne l’étaient pas ; ils étaient synchronisés par le redémarrage.
La correction n’était pas exotique. Ils ont ajouté stale-while-revalidate avec un soft TTL et un hard TTL, et la coalescence de requêtes par clé en utilisant un court verrou Redis.
Après cela, un redémarrage de Redis a provoqué des pages plus lentes pendant quelques minutes, pas un incident base de données. C’était moins dramatique, ce qui est le plus grand compliment en exploitation.
Mini-histoire 2 : L’optimisation qui s’est retournée contre eux
Une équipe SaaS B2B est devenue agressive sur la « fraîcheur ». Ils ont raccourci des TTLs de 10 minutes à 30 secondes sur un ensemble de widgets de dashboard parce que les ventes voulaient des mises à jour plus rapides.
Ils ont aussi introduit un job de fond qui « chauffait » le cache en recalculant les clés populaires toutes les 25 secondes. Sur le papier, cela signifiait moins de misses et des données plus fraîches.
En pratique, le job de warming et le trafic réel se sont alignés. Les deux frappaient le même ensemble de clés à peu près en même temps. Avec des TTLs courts, les clés expiraient fréquemment ; avec le job de warming, elles étaient constamment reconstruites.
Ils avaient créé un mini-stampede permanent. Pas un pic dramatique, mais une charge élevée persistante qui rendait tous les autres travaux Postgres fragiles.
Le symptôme était sournois : pas de gros pics de misses, juste une latence moyenne des requêtes augmentée et des waits de verrous occasionnels. Autovacuum a commencé à prendre du retard car le système était suffisamment occupé pour ne jamais avoir un moment calme.
Les ingénieurs ont chassé des « requêtes lentes » et des « mauvais plans » pendant des semaines, parce que le système n’était pas clairement en feu. Il était juste toujours trop chaud.
La correction a été d’arrêter les reconstructions sur un cadence fixe et de passer à une invalidation pilotée par événements pour le petit sous-ensemble de clés qui nécessitaient vraiment de la fraîcheur. Pour le reste : TTLs plus longs, jitter, et rafraîchissement soft uniquement lorsqu’on accède.
Ils ont aussi mis des plafonds stricts sur la concurrence du warmer et l’ont fait revenir en arrière quand la latence Postgres montait.
La fraîcheur n’est pas gratuite. Les TTLs courts sont une taxe que vous payez pour toujours, pas un coût ponctuel que vous négociez avec le produit.
Mini-histoire 3 : La pratique ennuyeuse mais correcte qui a sauvé la journée
Une société média faisait tourner Postgres avec PgBouncer et avait une règle stricte : les reconstructions en arrière-plan et les jobs batch utilisent des pools séparés et des utilisateurs DB séparés.
Ce n’était pas glamour. C’était un tableau de limites et quelques fichiers de config que personne ne voulait toucher.
Lors d’un pic de trafic déclenché par une histoire brûlante, leur cluster Redis a commencé à montrer une latence élevée due à un voisin bruyant sur les hôtes sous-jacents.
Le taux de hit du cache est tombé et les serveurs applicatifs ont commencé à retomber sur Postgres plus que d’habitude. C’est la partie où la plupart des systèmes s’effondrent.
Au lieu de cela, la séparation des pools de PgBouncer a fait son travail silencieusement. Le pool interactif est resté utilisable parce que le pool d’arrière-plan s’est saturé en premier. Les jobs de rebuild se sont mis en file, pas les requêtes utilisateurs.
Le site est devenu plus lent, mais il est resté en ligne. Les éditeurs ont continué à publier, les utilisateurs ont continué à lire, et l’incident est resté un incident au lieu d’un titre au journal.
Après, ils ont ajusté Redis, isolé les hôtes et amélioré les timeouts clients. Mais ce qui a le plus compté ce jour-là, c’était la discipline ennuyeuse de contrôle d’admission et de séparation des pools.
La fiabilité, c’est souvent juste refuser d’être trop malin aux mauvais endroits.
Erreurs courantes : symptômes → cause racine → correction
1) Symptom: sudden surge in Postgres QPS right after a deploy
Cause racine : changement de namespace de clé (préfixe/version bump), équivalant à une invalidation globale du cache.
Correction : versionner les clés progressivement, lire les deux namespaces pendant le rollout, ou préchauffer avec des limites strictes de concurrence plus la coalescence.
2) Symptom: Redis is “up” but app timeouts increase and DB load rises
Cause racine : pics de latence Redis (fsync persistance, commande lente, saturation CPU) ; les clients timeout et retombent sur la BD.
Correction : corriger la latence Redis d’abord ; augmenter légèrement les timeouts clients (pas à l’infini), ajouter du hedging avec précaution, et implémenter des lectures périmées pour que la retombée sur la BD ne soit pas la réaction par défaut.
3) Symptom: “too many connections” on Postgres during traffic spikes
Cause racine : pas de pooling ou pooling inefficace ; les instances applicatives ouvrent plus de connexions sous pression ; les chemins de rebuild partagent le pool avec les lectures interactives.
Correction : déployer PgBouncer, plafonner les pools, séparer pools/utilisateurs pour les reconstructions en arrière-plan, ajouter des limites de concurrence applicatives.
4) Symptom: cache hit rate is decent, but DB still melts on expiration boundaries
Cause racine : TTLs synchronisés pour des clés chaudes ; une cohorte expire en même temps et crée un stampede.
Correction : jitter sur les TTL ; soft TTL avec rafraîchissement en arrière-plan ; coalescence par clé.
5) Symptom: “lock wait timeout” errors increase during cache misses
Cause racine : les requêtes de rebuild incluent des écritures ou des lectures lourdes en verrous ; vacuum/DDL se chevauchent ; les retries amplifient la contention.
Correction : isoler les écritures des rebuilds de lecture ; éviter les patterns lourds en verrous ; réduire les retries ; planifier la maintenance ; s’assurer que les index évitent les scans longue durée.
6) Symptom: Redis memory hits max and keys churn; DB load becomes spiky
Cause racine : inadéquation de la politique d’éviction, valeurs surdimensionnées, ou cardinalité non bornée des clés (ex : cache par explosion de paramètres de requête).
Correction : borner la cardinalité, compresser ou stocker des projections plus petites, choisir une politique d’éviction alignée avec l’usage des TTL, et surveiller les évictions comme signal de première classe.
7) Symptom: after Redis failover, everything slows even though Redis is back
Cause racine : cold start du cache ; tempête de rebuilds ; pas de coalescence ; application reconstruit de manière synchrone sur le chemin de la requête.
Correction : stale-while-revalidate, soft TTL, et file de rebuild en arrière-plan. Rendre les cold starts survivables.
8) Symptom: read replicas fall behind during spikes, then cache invalidation logic goes wild
Cause racine : lag des réplicas + logique « valider contre la BD » ; le stampede cause du lag ; le lag cause plus d’invalidations/retries.
Correction : ne validez pas les lectures mises en cache contre des réplicas en retard ; préférez servir un cache périmé avec une staleness bornée, et routez les lectures critiques vers le primaire seulement si nécessaire.
Listes de contrôle / plan étape par étape
Plan étape par étape : durcir un système cache-aside contre les stampedes
- Inventorier les clés chaudes : identifier les endpoints et clés principaux par taux de requête et impact de miss.
- Définir le budget de périssabilité : par classe de clé, décider quelle staleness est acceptable (secondes/minutes/heures).
- Implémenter la coalescence de requêtes : par clé, s’assurer qu’un seul builder tourne à la fois sur la flotte.
- Ajouter soft TTL + hard TTL : servir périmé durant la fenêtre soft ; arrêter de servir après le hard TTL sauf dégradation explicite.
- Ajouter du jitter sur les TTL : désynchroniser les expirations sur les clés chaudes et cohortes.
- Ajouter du cache négatif : mettre en cache les introuvables et résultats vides brièvement.
- Séparer les pools : lectures interactives vs rebuilds vs jobs batch ; appliquer avec la config PgBouncer et utilisateurs BD.
- Placer les rebuilds derrière un contrôle d’admission : plafonner la concurrence et utiliser du backpressure quand la latence Postgres monte.
- Corriger les retries : appliquer des budgets de retry ; backoff exponentiel ; jitter ; pas de boucles infinies.
- Observer les bons signaux : taux de hit du cache, taux de rebuild, waits de verrous, clients en attente PgBouncer, évictions Redis, wait events Postgres.
- Tester les modes de panne : simuler redémarrage Redis, vidage de cache, et changement de namespace de clé en staging avec une concurrence réaliste.
- Rédiger le runbook : inclure « désactiver les rebuilds », « servir périmé uniquement », et procédures de réduction de charge.
Checklist opérationnelle : avant de déployer un changement de clé de cache
- Le nouveau format de clé coexiste-t-il avec l’ancien pendant le déploiement ?
- Y a-t-il une concurrence maximale de rebuild par classe de clé ?
- Stale-while-revalidate est-il activé pour les clés non critiques ?
- Le jitter TTL est-il activé par défaut ?
- Pouvez-vous rapidement désactiver le rebuild-on-miss et servir périmé ?
- Les évictions Redis, la latence et les wait events Postgres sont-ils sur le même tableau de bord ?
Checklist d’urgence : pendant un stampede en direct
- Arrêtez d’empirer la situation : réduisez les retries et désactivez les warmers agressifs.
- Activez le mode « servir périmé » si disponible ; étendez les TTLs sur les clés chaudes si c’est sûr.
- Serrez la concurrence des rebuilds ; isolez le pool de rebuild du pool interactif.
- Si Redis est le goulot, stabilisez Redis d’abord ; sinon vous ne ferez que refouler vers Postgres.
- Si Postgres est saturé, réduisez la charge : rate limitez les endpoints qui reconstruisent, dégradez les fonctionnalités non critiques, et protégez les chemins login/checkout.
FAQ
1) PostgreSQL peut-il être le cache si j’ajoute juste plus de RAM ?
Parfois. Mais c’est un piège comme stratégie principale. Le buffer cache Postgres aide pour des lectures répétées, mais les stampedes introduisent de la concurrence et de la mise en file que la RAM ne résout pas :
connexions, CPU pour le planning/exécution, et pics d’I/O pour les pages non résidentes. Utilisez la RAM, mais aussi la coalescence et le contrôle d’admission.
2) Redis est-il la seule façon de prévenir les cache stampedes ?
Non. Vous pouvez faire du single-flight en-process, utiliser une file de messages pour sérialiser les rebuilds, ou pré-calculer des projections dans un magasin séparé.
Redis est populaire parce qu’il est souvent déjà présent et fournit des primitives de coordination. L’important est de contrôler le fan-out des rebuilds, pas la marque de l’outil.
3) Dois-je utiliser un verrou distribué Redis pour les rebuilds du cache ?
Oui, mais gardez-le de courte durée et considérez-le comme un indice de coordination, pas un mécanisme de correction. Utilisez un TTL sur le verrou et gérez la perte du verrou en toute sécurité.
Votre rebuild doit être idempotent, et votre système doit tolérer des doubles reconstructions occasionnelles. L’objectif est « majoritairement un », pas « parfaitement un ».
4) Quel TTL devrais-je utiliser ?
La réponse honnête : ce que votre budget de staleness permet, plus une marge pour éviter les reconstructions constantes. Des TTLs plus longs réduisent la pression de rebuild.
Ajoutez un soft TTL pour garder la fraîcheur acceptable et un hard TTL pour éviter de servir des données antiques.
5) Pourquoi les retries aggravent-ils les stampedes ?
Les retries transforment une défaillance partielle en charge amplifiée. Quand Postgres est lent, chaque requête timeoutée peut déclencher plusieurs requêtes supplémentaires.
Ajoutez du jitter et un backoff exponentiel, plafonnez les retries, et préférez servir un cache périmé plutôt que « réessayer immédiatement ».
6) Comment savoir si je subis un stampede vs une vraie régression BD ?
Les stampedes ont une signature : des requêtes identiques répétées montent dans pg_stat_statements, les misses du cache augmentent, et la latence se dégrade fortement autour des frontières de TTL ou des resets de cache.
Une régression BD ressemble plus à une requête qui a changé de plan ou à une table devenue bloatée. Confirmez en corrélant hits/misses du cache avec les requêtes principales et les wait events.
7) Le « stale-while-revalidate » est-il sûr ?
C’est sûr quand les sémantiques métier le permettent. Ce n’est pas sûr pour des lectures critiques de correction comme les soldes, permissions, ou commits d’inventaire.
Pour ceux-là, utilisez des lectures transactionnelles depuis Postgres (et mettez en cache avec invalidation explicite), ou concevez un modèle de lecture dédié avec des règles de mise à jour strictes.
8) Qu’en est-il de mettre en cache des résultats de requête dans Postgres (views matérialisées) ?
Les vues matérialisées et tables de résumé peuvent réduire le calcul par requête, mais elles ne résolvent pas les stampedes seules. Si la couche cache expire et déclenche des rebuilds, vous pouvez aussi submerger le refresh de la vue matérialisée.
Elles sont meilleures comme partie d’un système : modèle de lecture bon marché dans Postgres, plus cache Redis, plus coalescence et service périmé.
9) Ai-je besoin de réplicas de lecture pour survivre aux stampedes ?
Les réplicas aident quand la charge est stable et que vous avez une montée en charge prévisible des lectures. Pendant les stampedes, les réplicas peuvent prendre du retard et devenir un nouveau mode de défaillance.
Corrigez les stampedes au niveau du cache d’abord. Ensuite utilisez les réplicas pour l’échelle en état stable, pas comme votre frein d’urgence principal.
Conclusion : prochaines étapes réalisables cette semaine
Les cache stampedes ne sont pas une malédiction mystérieuse des systèmes distribués. Ce sont ce qui arrive quand « cache miss → aller à la BD » est autorisé à croître linéairement avec le trafic.
Votre travail est de casser cette linéarité.
Si vous ne faites rien d’autre cette semaine, faites ces trois choses :
- Ajoutez la coalescence de requêtes pour vos clés les plus chaudes (même un simple verrou Redis avec TTL vaut mieux qu’un champ de bataille).
- Implémentez stale-while-revalidate pour toute donnée pouvant être légèrement périmée, et ajoutez un hard TTL pour éviter les valeurs zombies.
- Protégez Postgres avec séparation de pools et contrôle d’admission pour que le travail de rebuild ne puisse pas priver le trafic interactif.
Ensuite mesurez : taux de hit, taux de rebuild, waits de verrous, clients en attente PgBouncer, latence Redis, et wait events Postgres. Le tableau de bord n’empêchera pas les incidents, mais il vous évitera de deviner.
Et deviner, c’est comme ça que les stampedes deviennent des outages.