Explication détaillée des problèmes de cache Redis

Pénétration de cache
La pénétration de cache se produit lorsque les données demandées par le client n'existent ni dans le cache ni dans la base de données. Le cache ne produit alors jamais d'effet et ces requêtes atteignent directement la base de données.
Si un utilisateur malveillant utilise un nombre considérable de threads pour accéder simultanément à des données inexistantes, toutes ces requêtes atteindront la base de données, ce qui risque de la faire tomber en panne.
Solution
Mettre en cache des objets vides
Idée : lorsqu'un utilisateur demande un id qui n'existe ni dans Redis ni dans la base de données, nous mettons directement en cache la valeur nulle correspondant à cet id dans Redis. Ainsi, la prochaine fois que l'utilisateur redemande cet id, Redis répond (avec une valeur nulle) sans solliciter la base de données.
Avantages : simple à mettre en œuvre, facile à maintenir.
Inconvénients :

Consommation mémoire supplémentaire (peut être résolue en ajoutant un TTL).


- Peut provoquer une incohérence à court terme (la gestion de la durée du TTL peut l'atténuer dans une certaine mesure) : lorsque la valeur nulle est mise en cache, si nous venons de définir la valeur dans la base de données et que la requête de l'utilisateur renvoie null alors que la donnée existe en réalité dans la base de données, cela crée une incohérence (peut être résolu en écrasant automatiquement la valeur nulle précédente lors de l'insertion de données).
Filtre de Bloom
Une couche de filtre de Bloom est ajoutée entre le client et Redis. Lorsque l'utilisateur accède aux données, le filtre de Bloom détermine si les données existent. Si elles n'existent pas, la requête est directement rejetée ; si elles existent, le processus normal peut se poursuivre.
Comment un filtre de Bloom détermine-t-il si les données existent ?

Le filtre de Bloom peut être simplement compris comme un tableau d'octets stockant des bits binaires. Pour déterminer si les données existent dans la base de données, il ne stocke pas directement les données dans le filtre de Bloom, mais calcule la valeur de hachage au moyen d'un algorithme de hachage, puis convertit ces hachages en bits binaires et les stocke dans le filtre de Bloom. Pour déterminer si les données existent, il suffit de vérifier si la position correspondante vaut 0 ou 1 (ce type de détection repose sur une estimation probabiliste et n'est pas précis à 100 % ; si la donnée est indiquée absente, elle n'existe pas forcément, il subsiste donc un risque de pénétration).
Avantages : faible consommation mémoire, aucune clé redondante (binaire).
Inconvénients :

complexe à mettre en œuvre ;
possibilité de jugement erroné (pas nécessairement précis).


Mise en cache d'objets vides : code Java
/**
* Pénétration de cache
*
* @param id
* @return
*/
public Shop queryWithPassThrough(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. Interroger le cache du magasin depuis Redis
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. Déterminer s'il existe
if (StrUtil.isNotBlank(shopJson)) {
// 3. Existe, retourner directement
return JSONUtil.toBean(shopJson, Shop.class);
}
// Déterminer si la valeur trouvée est nulle
if (shopJson != null) {
// retourner un message d'erreur
return null;
}
// 4. N'existe pas, interroger la base de données selon l'id
Shop shop = getById(id);
// 5. n'existe pas, retourner une erreur
if (shop == null) {
// écrire la valeur nulle dans Redis (pénétration de cache)
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 6. Existe, écrire dans Redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 7. retourner
return shop;
}
Avalanche de cache
L'avalanche de cache se produit lorsqu'un grand nombre de clés de cache expirent simultanément ou que le service Redis tombe en panne au même moment, ce qui entraîne un afflux massif de requêtes vers la base de données et lui impose une pression énorme.


Solution

Ajouter des valeurs aléatoires au TTL des différentes clés (pour résoudre le problème d'expiration simultanée) : par exemple, lors du préchauffage du cache, les données de la base de données doivent être importées par lots dans le cache à l'avance. Comme les données sont importées au même moment, leur valeur de TTL est identique. Elles risquent alors d'expirer simultanément à un certain moment, provoquant une avalanche. Pour résoudre ce problème, nous pouvons ajouter un nombre aléatoire au TTL lors de l'importation (par exemple, TTL de 30 ± 1~5), afin que les dates d'expiration de ces clés soient réparties sur une période au lieu d'expirer en même temps, évitant ainsi l'avalanche.
Utiliser un cluster Redis pour améliorer la disponibilité du service (résoudre les pannes de Redis) : grâce au mécanisme de sentinelle Redis, lorsqu'une machine tombe en panne, la sentinelle peut automatiquement sélectionner une machine pour remplacer la machine en panne, et le maître et l'esclave synchronisent les données afin de garantir des performances élevées et la disponibilité de Redis.
Ajouter une stratégie de dégradation et de limitation de débit au service de cache : par exemple l'échec rapide, le refus de service et l'empêchement du transfert des requêtes vers la base de données.
Ajouter des caches multi-niveaux à l'application : les navigateurs peuvent ajouter un cache (généralement pour les ressources statiques), le serveur Nginx (proxy inverse) peut ajouter un cache, en cas d'échec du cache Nginx la requête est adressée à Redis, en cas d'échec du cache Redis elle atteint la JVM, et un cache local peut également être mis en place au sein de la JVM, avant d'atteindre enfin la base de données.
Rupture de cache
Le problème de rupture de cache, également appelé problème de clé chaude, survient lorsqu'une clé soumise à un accès concurrentiel élevé et dont la reconstruction du cache est complexe expire soudainement. Un nombre considérable de requêtes auront alors un impact énorme sur la base de données en un instant.
Reconstruction du cache : le cache dans Redis expire après sa durée de validité et doit être ré-interrogé depuis la base de données puis réécrit dans Redis après expiration. Le processus d'interrogation et de construction des données depuis la base de données peut être complexe, nécessitant des jointures multi-tables, etc., et le résultat est enfin mis en cache. Cette opération peut prendre beaucoup de temps (des dizaines, voire des centaines de millisecondes). Pendant cette période, il n'y a pas de cache dans Redis et les requêtes entrantes atteignent la base de données.


Solution
Verrou mutuel (mutex)
Lorsqu'un thread constate un échec de cache, il acquiert un verrou avant d'interroger la base de données, puis libère le verrou après avoir écrit dans le cache. Ainsi, lorsque d'autres threads constatent un échec, ils tentent eux aussi d'acquérir le verrou mutuel avant d'interroger la base de données. En cas d'échec d'acquisition, le thread se met en veille pendant un certain temps avant de réessayer.
De toute évidence, les autres threads ne peuvent obtenir les données qu'après l'écriture dans le cache. Bien que la cohérence puisse être garantie, les performances sont relativement médiocres et un interblocage peut survenir.


Code Java


/**
* Obtenir le verrou
*
* @param key
* @return
*/
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}


/**
* Libérer le verrou
*
* @param key
*/
private void unlock(String key) {
stringRedisTemplate.delete(key);
}


/**
* Verrou mutuel (mutex)
*
* @param id
* @return
*/
public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. Interroger le cache du magasin depuis Redis
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. Déterminer s'il existe
if (StrUtil.isNotBlank(shopJson)) {
// 3. Existe, retourner directement
return JSONUtil.toBean(shopJson, Shop.class);
}
// Déterminer si la valeur trouvée est nulle
if (shopJson != null) {
// retourner un message d'erreur
return null;
}

// 4. Mettre en œuvre la reconstruction du cache
// 4.1 Acquérir le verrou mutuel
String lockKey = LOCK_SHOP_KEY + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2 Déterminer si l'acquisition a réussi
if (!isLock) {
// 4.3 Échec, mise en veille puis nouvel essai
Thread.sleep(50);
// récursivité
return queryWithMutex(id);
}
// 4.4 Succès, interroger la base de données selon l'id
shop = getById(id);
// simuler le délai de reconstruction
Thread.sleep(200);
// 5. n'existe pas, retourner une erreur
if (shop == null) {
// écrire la valeur nulle dans Redis (pénétration de cache)
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 6. Existe, écrire dans Redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 7. Libérer le verrou mutuel
unlock(lockKey);
}
// 8. retourner
return shop;
}
Testons avec JMeter en envoyant 1 000 requêtes : nous constatons que toutes les requêtes aboutissent et que la base de données n'est interrogée qu'une seule fois.



Expiration logique
Comme son nom l'indique, les données n'expirent pas réellement ; elles peuvent être considérées comme n'expirant jamais. Lorsque nous mettons des données en cache dans Redis, nous ne définissons pas de TTL et ajoutons un champ de date d'expiration (non pas un TTL, mais une date calculée à partir de l'heure actuelle + durée d'expiration, maintenue logiquement) lors du stockage, de sorte que n'importe quel thread puisse les interroger. En cas de succès, il suffit de déterminer logiquement si les données ont expiré.
Comme le montre la figure ci-après, si le thread 1 constate que la date logique a expiré lors de l'interrogation du cache, il doit reconstruire le cache puis acquérir le verrou mutuel (et non effectuer lui-même la reconstruction du cache). Une fois la reconstruction du cache terminée, le verrou est libéré et le thread 1 retourne directement les données expirées. Lorsque d'autres threads constatent également un échec, l'échec d'acquisition du verrou mutuel retourne directement les données expirées. Bien que les performances soient garanties, la cohérence ne peut pas l'être.


Code Java
/**
* Préchauffage du cache
*
* @param id
* @param expireSeconds date d'expiration logique
*/
public void saveShop2Redis(Long id, Long expireSeconds) throws InterruptedException {
// 1. Interroger les données du magasin
Shop shop = getById(id);
Thread.sleep(200);
// 2. Encapsuler la date d'expiration logique
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 3. Écrire dans Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}

private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

/**
* Expiration logique
*
* @param id
* @return
*/
public Shop queryWithLogicalExpire(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. Interroger le cache du magasin depuis Redis
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. Déterminer s'il existe
if (StrUtil.isBlank(shopJson)) {
// 3. Échec, retourner directement
return null;
}
// 4. Succès, désérialiser d'abord le JSON en objet
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 5. Déterminer si les données ont expiré
if (expireTime.isAfter(LocalDateTime.now())) {
// 5.1 Si elles n'ont pas expiré, retourner directement les informations du magasin
return shop;
}
// 5.2 ont expiré et doivent être reconstruites
// 6. Reconstruction du cache
// 6.1 Acquérir le verrou mutuel
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2 Déterminer si le verrou a été acquis avec succès
if (isLock) {
// 6.3 Succès, ouvrir un thread indépendant pour effectuer la reconstruction du cache
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// reconstruire le cache
this.saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// libérer le verrou
unlock(lockKey);
}
});
}
// 6.4 Retourner les informations expirées du magasin
return shop;
}

Articles connexes

Découvrir d'autres offres spéciales

  1. Service de messages courts (SMS) et service de messagerie

    50,000 email package starts as low as USD 1.99, 120 short messages start at only USD 1.00

phone Contacter
Hi, I'm Alibaba Cloud AI Assistant!
I can help with questions and solutions.