Hot key et big key dans Redis: détection et traitement
1. Vue d’ensemble
Redis traite les commandes sur un seul thread principal. Ainsi, une seule clé trop grande (big key) ou une seule clé très accédée (hot key) peut ralentir l’ensemble de l’instance. Dans cet article, vous apprendrez à détecter ces deux types de clés en utilisant les outils natifs de Redis et via du code Java. Ensuite, nous les gérerons dans une application Spring Boot 3.x en utilisant des techniques de fragmentation de clé, suppression sécurisée, cache local et duplication de clés.
2. Qu’est-ce qu’un hot key et un big key?
Big key est une clé qui occupe beaucoup de mémoire ou contient trop d’éléments. Il n’existe pas de seuil standard, mais beaucoup d’équipes utilisent les points de référence suivants:
- String supérieur à environ 10 KB.
- Hash, List, Set, ZSet avec plus d’environ 5 000 éléments, ou volume total dépassant plusieurs MB.
Hot key est une clé qui reçoit un taux de requêtes anormalement élevé par rapport aux autres clés. Par exemple: clé contenant les informations de flash sale, page d’accueil ou configuration partagée.
Ces deux types de clés causent les problèmes suivants:
- Blocage du thread principal:
HGETALLsur un hash avec 1 million de champs, ouDELun ZSet géant, peut bloquer Redis pendant des centaines de millisecondes. Tous les autres clients doivent attendre. - Goulot d’étranglement de la bande passante: lire une valeur de 2 MB avec 5 000 requêtes/seconde équivaut à environ 10 GB/seconde.
- Déséquilibre de charge dans Redis Cluster: une clé réside toujours dans un slot, c’est-à-dire un nœud. Les hot key et big key concentrent la charge sur ce nœud, peu importe le nombre de nœuds du cluster.
- Réplication et failover lents: les big key rendent la synchronisation et le chargement du RDB plus lents.
3. Détecter les big key
3.1. Utiliser redis-cli --bigkeys et --memkeys
Le plus rapide est d’utiliser l’option native de redis-cli:
redis-cli -h 127.0.0.1 -p 6379 --bigkeys -i 0.01
Cette commande parcourt l’intégralité de l’espace des clés avec SCAN, ce qui ne bloque pas le serveur comme KEYS *. Le paramètre -i 0.01 attend 10 ms après chaque 100 commandes SCAN, aidant à réduire la pression sur le serveur de production. Le résultat ressemble à ceci:
[00.00%] Biggest hash found so far '"cart:10023"' with 48211 fields
[12.35%] Biggest string found so far '"product:detail:88"' with 1048576 bytes
[45.10%] Biggest zset found so far '"leaderboard:global"' with 920144 members
-------- summary -------
Biggest hash found '"cart:10023"' has 48211 fields
Biggest string found '"product:detail:88"' has 1048576 bytes
Biggest zset found '"leaderboard:global"' has 920144 members
--bigkeys mesure par nombre d’éléments. Il ne rapporte que la plus grande clé de chaque type de données. Si vous vous intéressez à la mémoire réelle, utilisez --memkeys:
redis-cli --memkeys --memkeys-samples 0
--memkeys-samples 0 demande à Redis de mesurer tous les éléments plutôt que de les échantillonner. Cette méthode est plus précise mais plus lente.
3.2. Scanner avec Java via MEMORY USAGE
redis-cli retourne uniquement la plus grande clé de chaque type. Pour lister toutes les clés au-delà du seuil, vous pouvez scanner vous-même. D’abord, ajoutez la dépendance:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Ensuite, écrivez un composant qui scanne l’espace des clés:
@Component
public class BigKeyScanner {
private final StringRedisTemplate redis;
public BigKeyScanner(StringRedisTemplate redis) {
this.redis = redis;
}
public Map<String, Long> findKeysLargerThan(long thresholdBytes) {
Map<String, Long> result = new LinkedHashMap<>();
ScanOptions options = ScanOptions.scanOptions().match("*").count(500).build();
try (Cursor<String> cursor = redis.scan(options)) {
while (cursor.hasNext()) {
String key = cursor.next();
Long bytes = redis.execute((RedisCallback<Long>) conn ->
(Long) conn.execute("MEMORY",
"USAGE".getBytes(), key.getBytes(), "SAMPLES".getBytes(), "0".getBytes()));
if (bytes != null && bytes > thresholdBytes) {
result.put(key, bytes);
}
}
}
return result;
}
}
Les points clés du code:
redis.scan(options)parcourt l’espace des clés par lots de 500 clés. Le thread principal n’est pas bloqué longtemps.Cursordoit être fermé, donc nous utilisons try-with-resources.MEMORY USAGE key SAMPLES 0retourne le nombre d’octets que la clé occupe, y compris le surcoût. Spring Data Redis n’a pas d’API dédiée pour cette commande, donc nous l’appelons viaconn.execute.
Exemple lors de l’appel findKeysLargerThan(1_000_000):
{cart:10023=3145812, product:detail:88=1048648, leaderboard:global=89231560}
Remarque: Exécutez le scanner sur un replica plutôt que sur le master, pendant les heures creuses. Une autre approche consiste à analyser le fichier RDB hors ligne avec des outils de lecture RDB. Cette méthode n’affecte pas du tout le serveur en cours d’exécution.
4. Détecter les hot key
4.1. Utiliser redis-cli --hotkeys
Redis ne suit la fréquence d’accès que lorsque vous activez la politique d’éviction de type LFU:
# redis.conf
maxmemory-policy allkeys-lfu
Une fois le LFU activé, exécutez:
redis-cli --hotkeys
[00.00%] Hot key '"config:homepage"' found so far with counter 255
[37.21%] Hot key '"product:stock:5001"' found so far with counter 201
-------- summary -------
hot key found with counter: 255 keyname: "config:homepage"
hot key found with counter: 201 keyname: "product:stock:5001"
Le compteur LFU est une valeur logarithmique, maximum 255. Il ne s’agit pas du nombre réel de requêtes. Vous devez uniquement l’utiliser pour comparer relativement entre les clés.
Si vous soupçonnez une clé spécifique, vérifiez directement:
redis-cli OBJECT FREQ config:homepage
(integer) 255
Remarque: N’utilisez pas
MONITORen production pour trouver les hot key.MONITORenvoie chaque commande au client et peut réduire le throughput de plus de moitié.
4.2. Compter les accès côté application
Si vous ne pouvez pas modifier maxmemory-policy, vous pouvez compter directement dans l’application. Le code ci-dessous compte le nombre de lectures de chaque clé dans une fenêtre de temps:
@Component
public class HotKeyDetector {
private final ConcurrentHashMap<String, LongAdder> counters = new ConcurrentHashMap<>();
public void record(String key) {
counters.computeIfAbsent(key, k -> new LongAdder()).increment();
}
public List<Map.Entry<String, Long>> topAndReset(int n) {
Map<String, Long> snapshot = new HashMap<>();
counters.forEach((k, v) -> snapshot.put(k, v.sumThenReset()));
counters.clear();
return snapshot.entrySet().stream()
.sorted(Map.Entry.<String, Long>comparingByValue().reversed())
.limit(n)
.toList();
}
}
Vous appelez record(key) à chaque fois que vous lisez Redis. Ensuite, utilisez un travail @Scheduled qui appelle topAndReset(10) toutes les 10 secondes, puis écrivez le résultat dans les logs ou les métriques. LongAdder réduit la contention lorsque plusieurs threads incrémentent le même compteur. Par conséquent, il est plus adapté que AtomicLong dans cette situation.
5. Gérer les big key
5.1. Fragmentation de clés (sharding)
Supposons que vous stockiez les vues de tous les produits dans un seul hash product:views. Ce hash va gonfler à des millions de champs. La solution est de le diviser en plusieurs petits hash:
public final class ShardRouter {
private ShardRouter() {}
public static String shardKey(String baseKey, String field, int shards) {
int shard = Math.floorMod(field.hashCode(), shards);
return baseKey + ":" + shard;
}
}
@Service
public class ProductViewService {
private static final int SHARDS = 64;
private final StringRedisTemplate redis;
public ProductViewService(StringRedisTemplate redis) {
this.redis = redis;
}
public long increment(String productId) {
String key = ShardRouter.shardKey("product:views", productId, SHARDS);
return redis.opsForHash().increment(key, productId, 1);
}
public long get(String productId) {
String key = ShardRouter.shardKey("product:views", productId, SHARDS);
Object value = redis.opsForHash().get(key, productId);
return value == null ? 0 : Long.parseLong(value.toString());
}
}
Fonctionnement:
Math.floorModretourne toujours un nombre non-négatif, même sihashCode()est négatif. C’est là où beaucoup de gens utilisent%et obtiennent des indices négatifs.- Le produit
"P-1001"tombe toujours dans le même shard, par exempleproduct:views:17. Ainsi, la lecture et l’écriture sont toujours cohérentes. - Chaque hash ne contient maintenant qu’environ 1/64 du nombre de champs. Dans un Redis Cluster, ces 64 clés se trouvent dans des slots différents, donc la charge est distribuée sur plusieurs nœuds.
Remarque: N’ajoutez pas de hash tag comme
product:views:{all}:17si l’objectif est de distribuer la charge. Le hash tag force tous les shards dans le même slot. La taille de chaque clé diminue, mais la charge reste concentrée sur un nœud.
Avec les big key de type String, par exemple JSON de détail produit de 1 MB, séparez la partie peu modifiée et la partie souvent modifiée en clés séparées. Vous pouvez aussi compresser la valeur, ou ne cacher que les champs vraiment nécessaires.
5.2. Supprimer les big key en toute sécurité
DEL libère la mémoire de manière synchrone. Supprimer un ZSet avec 1 million d’éléments peut bloquer Redis pendant des centaines de millisecondes. Utilisez UNLINK à la place:
public void removeLeaderboard() {
redis.unlink("leaderboard:global");
}
UNLINK retire la clé de l’espace des clés immédiatement. La libération de mémoire est repoussée à un thread en arrière-plan. Vous devriez aussi activer lazy free dans la configuration, pour que les opérations de suppression implicites de Redis ne bloquent pas le thread principal:
# redis.conf (Redis 6+)
lazyfree-lazy-user-del yes
lazyfree-lazy-expire yes
lazyfree-lazy-eviction yes
lazyfree-lazy-server-del yes
Avec lazyfree-lazy-user-del yes, même la commande DEL fonctionne comme UNLINK. Ce paramètre vous protège du code ancien qui appelle toujours delete().
Si vous devez exécuter sur une version ancienne de Redis, ou si vous voulez contrôler la charge plus étroitement, supprimez par lots:
public void deleteHashGradually(String key) {
ScanOptions options = ScanOptions.scanOptions().count(500).build();
try (Cursor<Map.Entry<Object, Object>> cursor = redis.opsForHash().scan(key, options)) {
List<Object> batch = new ArrayList<>(500);
while (cursor.hasNext()) {
batch.add(cursor.next().getKey());
if (batch.size() == 500) {
redis.opsForHash().delete(key, batch.toArray());
batch.clear();
}
}
if (!batch.isEmpty()) {
redis.opsForHash().delete(key, batch.toArray());
}
}
redis.unlink(key);
}
Chaque HDEL supprime seulement 500 champs, donc le temps de blocage de chaque commande est très court. Redis permet de modifier un hash pendant qu’il est en cours de HSCAN. Les champs non supprimés sont toujours retournés. La commande unlink finale nettoie les champs restants.
6. Gérer les hot key
6.1. Cache local avant Redis
La façon la plus efficace est de faire en sorte que la plupart des requêtes ne touchent pas Redis. Vous ajoutez Caffeine:
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
Ensuite, mettez en place un cache local à court terme avant Redis:
@Service
public class HomepageConfigService {
private final StringRedisTemplate redis;
private final LoadingCache<String, String> localCache;
public HomepageConfigService(StringRedisTemplate redis) {
this.redis = redis;
this.localCache = Caffeine.newBuilder()
.maximumSize(1_000)
.expireAfterWrite(Duration.ofSeconds(2))
.build(key -> redis.opsForValue().get(key));
}
public String getConfig() {
return localCache.get("config:homepage");
}
}
Supposons que vous ayez 20 instances d’application. Chaque instance n’appelle Redis qu’environ une fois toutes les 2 secondes pour cette clé, ce qui signifie que Redis reçoit environ 10 requêtes/seconde au lieu de dizaines de milliers. LoadingCache fusionne également les chargements simultanés pour la même clé. Ainsi, quand le cache expire, il n’y a pas des centaines de threads qui se ruent sur Redis.
Le prix à payer est que les données peuvent être obsolètes jusqu’à 2 secondes. Pour la configuration de la page d’accueil ou le catalogue de produits, ce niveau de latence est généralement acceptable.
6.2. Duplication de clés
Quand les données doivent être plus fraîches, ou en cas d’exécution dans un Redis Cluster où un nœud est surchargé, vous pouvez écrire la valeur sur plusieurs copies et lire aléatoirement depuis l’une d’elles:
@Service
public class FlashSaleService {
private static final int REPLICAS = 8;
private final StringRedisTemplate redis;
public FlashSaleService(StringRedisTemplate redis) {
this.redis = redis;
}
public void publish(String saleId, String json) {
for (int i = 0; i < REPLICAS; i++) {
redis.opsForValue().set("flashsale:" + saleId + ":r" + i, json, Duration.ofMinutes(10));
}
}
public String read(String saleId) {
int i = ThreadLocalRandom.current().nextInt(REPLICAS);
return redis.opsForValue().get("flashsale:" + saleId + ":r" + i);
}
}
Les 8 clés flashsale:42:r0 à flashsale:42:r7 tombent généralement dans des slots différents. Par conséquent, la charge de lecture est distribuée sur plusieurs nœuds. Cette méthode convient aux données fortement lues, faiblement écrites.
Remarque: L’écriture sur plusieurs copies n’est pas atomique. Pendant un court instant, les copies peuvent diverger. N’utilisez pas cette approche pour les données qui exigent une cohérence absolue, comme le solde d’un compte. Pour un compteur de stock hot key, envisagez de diviser le stock en plusieurs “seaux” (buckets) indépendants. Chaque seau contient une partie de la quantité au lieu de contenir une copie de la même valeur.
Au-delà de ces deux approches, vous pouvez lire à partir d’un replica. Avec Lettuce, configurez ReadFrom.REPLICA_PREFERRED pour répartir la charge de lecture sur le modèle master–replica.
7. Tester la logique de fragmentation de clés
La logique de routage des shards est la partie la plus facile à rater, alors écrivez des tests pour cela:
class ShardRouterTest {
@Test
void sameFieldAlwaysMapsToSameShard() {
String first = ShardRouter.shardKey("product:views", "P-1001", 64);
String second = ShardRouter.shardKey("product:views", "P-1001", 64);
assertThat(first).isEqualTo(second).startsWith("product:views:");
}
@Test
void shardIndexIsNeverNegative() {
// "polygenelubricants" a hashCode() == Integer.MIN_VALUE
String key = ShardRouter.shardKey("product:views", "polygenelubricants", 64);
int index = Integer.parseInt(key.substring(key.lastIndexOf(':') + 1));
assertThat(index).isBetween(0, 63);
}
@Test
void fieldsAreSpreadAcrossShards() {
Map<String, Long> distribution = IntStream.range(0, 100_000)
.mapToObj(i -> ShardRouter.shardKey("product:views", "P-" + i, 64))
.collect(Collectors.groupingBy(k -> k, Collectors.counting()));
assertThat(distribution).hasSize(64);
assertThat(distribution.values()).allSatisfy(count ->
assertThat(count).isBetween(1_000L, 2_200L));
}
}
Le deuxième test vérifie le cas limite avec Integer.MIN_VALUE. Si vous utilisiez Math.abs(hash) % n, ce test échouerait car Math.abs(Integer.MIN_VALUE) est toujours négatif. Le troisième test confirme que 100 000 champs sont répartis relativement uniformément: en moyenne environ 1 560 champs par shard.
8. Erreurs courantes et quand ne pas utiliser
- Exécuter
KEYS *pour trouver des big key: cette commande bloque Redis jusqu’à la fin de l’itération. Utilisez toujoursSCAN. - Fragmenter les clés mais toujours avoir besoin de lire tout: si le business doit souvent faire
HGETALLsur tous les champs, fragmenter transforme juste 1 commande lente en 64 commandes. Repassez en revue le business d’abord, par exemple la pagination ou le précalcul des résultats d’agrégation. - Changer le nombre de shards pendant l’exécution: changer
SHARDSde 64 à 128 fait que la plupart des champs changent de position. Vous avez besoin d’un plan de migration, ou de choisir un nombre de shards suffisamment grand dès le départ. - Cache local pour des données en constante évolution: stock, prix d’enchère… ne correspondent pas avec un TTL de quelques secondes. Si vous l’utilisez quand même, combinez-le avec Pub/Sub ou le cache côté client (
CLIENT TRACKING) de Redis 6+ pour invalider le cache. - Optimiser trop tôt: un hash avec 2 000 champs n’est pas encore un problème. Mesurez avec
--bigkeys,--hotkeyset slowlog (SLOWLOG GET) avant de prendre une décision.
9. Conclusion
Dans cet article, vous avez compris pourquoi les hot key et big key sont dangereux pour Redis qui traite les commandes sur un seul thread. Pour les détecter, vous pouvez utiliser redis-cli --bigkeys, --memkeys, --hotkeys, MEMORY USAGE, OBJECT FREQ, ou compter vous-même côté application. Pour gérer les big key, fragmentez les clés avec Math.floorMod, supprimez avec UNLINK et activez lazy free. Pour réduire la charge des hot key, utilisez le cache local Caffeine ou la duplication de clés. Intégrez le scanning périodique dans votre processus opérationnel, car la détection précoce est toujours moins chère que de gérer Redis quand il est déjà saturé.