Hot Key und Big Key in Redis: Erkennung und Handhabung

1. Übersicht

Redis verarbeitet Befehle in einem Hauptthread. Daher kann allein ein zu großer Key (Big Key) oder ein häufig zugegriffener Key (Hot Key) die gesamte Instanz verlangsamen. In diesem Artikel lernen Sie, wie Sie diese beiden Key-Typen mit den integrierten Redis-Tools und Java-Code erkennen. Anschließend behandeln wir sie in einer Spring Boot 3.x-Anwendung mit Techniken wie Key-Splitting, sicheres Löschen, lokales Caching und Key-Replikation.

2. Was sind Hot Key und Big Key?

Big Key ist ein Key, der viel Speicher belegt oder zu viele Elemente enthält. Es gibt keine einheitliche Schwelle, aber viele Teams verwenden folgende Anhaltspunkte:

  • String größer als etwa 10 KB.
  • Hash, List, Set, ZSet mit mehr als etwa 5.000 Elementen oder einer Gesamtgröße von mehreren MB.

Hot Key ist ein Key, der einen ungewöhnlich hohen Anteil an Anfragen erhält, verglichen mit anderen Keys. Beispiele: Keys, die Flash-Sale-Informationen, die Startseite oder gemeinsame Konfigurationen enthalten.

Diese beiden Key-Typen verursachen folgende Probleme:

  • Hauptthread wird blockiert: HGETALL auf einem Hash mit 1 Million Feldern oder DEL auf einem riesigen ZSet können Redis für hunderte Millisekunden blockieren. Alle anderen Clients müssen warten.
  • Netzwerkengpass: Das Auslesen eines 2-MB-Werts bei 5.000 Anfragen/Sekunde entspricht etwa 10 GB/Sekunde.
  • Unausgewogene Last in Redis Cluster: Ein Key liegt immer in einem Slot, also auf einem Node. Hot Keys und Big Keys konzentrieren die Last auf diesem Node, unabhängig von der Anzahl der Nodes im Cluster.
  • Langsame Replikation und Failover: Big Keys machen Synchronisierung und RDB-Laden zeitaufwendiger.

3. Big Keys erkennen

3.1. Mit redis-cli --bigkeys und --memkeys

Die schnellste Methode ist die Verwendung der integrierten redis-cli-Optionen:

redis-cli -h 127.0.0.1 -p 6379 --bigkeys -i 0.01

Dieser Befehl durchsucht den gesamten Keyspace mit SCAN, blockiert also nicht den Server wie KEYS *. Der Parameter -i 0.01 wartet 10 ms nach jeweils 100 SCAN-Befehlen, was die Last auf einem Production-Server reduziert. Die Ausgabe sieht etwa so aus:

[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 misst nach Anzahl der Elemente. Es zeigt nur den größten Key pro Datentyp an. Wenn Sie sich für tatsächliche Speichernutzung interessieren, verwenden Sie --memkeys:

redis-cli --memkeys --memkeys-samples 0

--memkeys-samples 0 veranlasst Redis, alle Elemente zu messen statt Stichproben zu nehmen. Dies ist genauer, aber langsamer.

3.2. Durchsuchen mit Java über MEMORY USAGE

redis-cli gibt nur den größten Key pro Typ zurück. Um alle Keys oberhalb einer Schwelle aufzulisten, können Sie eine eigene Durchsuchung schreiben. Zunächst die Dependency hinzufügen:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Anschließend einen Komponenten schreiben, die den Keyspace durchsucht:

@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;
    }
}

Wichtige Punkte im Code:

  • redis.scan(options) durchsucht den Keyspace in Batches von 500 Keys. Der Hauptthread wird nicht lange blockiert.
  • Der Cursor muss geschlossen werden, daher verwenden wir try-with-resources.
  • MEMORY USAGE key SAMPLES 0 gibt die Bytes zurück, die der Key belegt, inklusive Overhead. Spring Data Redis hat keine dedizierte API dafür, daher rufen wir den Befehl über conn.execute auf.

Beispielausgabe beim Aufrufen von findKeysLargerThan(1_000_000):

{cart:10023=3145812, product:detail:88=1048648, leaderboard:global=89231560}

Hinweis: Führen Sie die Durchsuchung auf einem Replica statt auf dem Master durch, und am besten in Zeiten mit niedrigem Aufkommen. Eine weitere Möglichkeit ist, eine RDB-Datei offline mit spezialisierten Tools zu analysieren. Dies beeinträchtigt den laufenden Server überhaupt nicht.

4. Hot Keys erkennen

4.1. Mit redis-cli --hotkeys

Redis verfolgt die Zugriffshäufigkeit nur, wenn Sie eine LFU-Eviction-Policy aktivieren:

# redis.conf
maxmemory-policy allkeys-lfu

Nach Aktivierung von LFU führen Sie aus:

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"

Der LFU-Counter ist ein logarithmischer Wert mit maximal 255. Er stellt nicht die Anzahl tatsächlicher Anfragen dar. Sie sollten ihn nur für relative Vergleiche zwischen Keys verwenden.

Wenn Sie einen bestimmten Key verdächtigen, überprüfen Sie ihn direkt:

redis-cli OBJECT FREQ config:homepage
(integer) 255

Hinweis: Verwenden Sie MONITOR nicht auf Production, um Hot Keys zu finden. MONITOR sendet jeden Befehl zum Client und kann den Durchsatz um mehr als die Hälfte reduzieren.

4.2. Zugriffe auf Anwendungsebene zählen

Wenn Sie die maxmemory-policy nicht ändern können oder mehr Kontrolle benötigen, können Sie die Zugriffe direkt in der Anwendung zählen. Der folgende Code zählt Lesezugriffe pro Key in einem Zeitfenster:

@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();
    }
}

Sie rufen record(key) auf, jedes Mal wenn Sie Redis auslesen. Dann verwenden Sie einen @Scheduled-Job, um topAndReset(10) alle 10 Sekunden aufzurufen und das Ergebnis in Logs oder Metrics zu schreiben. LongAdder reduziert Contention, wenn mehrere Threads denselben Counter inkrementieren. Dies macht es besser als AtomicLong in dieser Situation.

5. Big Keys behandeln

5.1. Key-Splitting (Sharding)

Angenommen, Sie speichern Aufrufe für alle Produkte in einem einzigen Hash product:views. Dieser Hash wird zu Millionen Feldern anschwellen. Die Lösung ist, ihn in mehrere kleinere Hashes aufzuteilen:

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());
    }
}

So funktioniert es:

  • Math.floorMod gibt immer eine nicht-negative Zahl zurück, selbst wenn hashCode() negativ ist. Das ist der Punkt, an dem viele Menschen % verwenden und mit negativen Indizes auf Fehler stoßen.
  • Produkt "P-1001" fällt immer in denselben Shard, z.B. product:views:17. Dadurch sind Lese- und Schreibvorgänge immer konsistent.
  • Jeder Hash hat jetzt nur etwa 1/64 der Feldanzahl. In Redis Cluster liegen diese 64 Keys in unterschiedlichen Slots, daher wird die Last auf mehrere Nodes verteilt.

Hinweis: Fügen Sie kein Hash-Tag wie product:views:{all}:17 hinzu, wenn das Ziel Lastverteilung ist. Hash-Tags zwingen alle Shards in den gleichen Slot. Die Größe jedes Keys sinkt, aber die Last konzentriert sich dennoch auf einen Node.

Bei Big Keys vom Typ String, wie 1-MB-JSON mit Produktdetails, teilen Sie häufig unveränderte und häufig ändernde Teile in separate Keys auf. Sie können den Value auch komprimieren oder nur wirklich benötigte Felder cachen.

5.2. Sichere Löschung von Big Keys

DEL gibt Speicher synchron frei. Das Löschen eines ZSet mit 1 Million Elementen kann Redis für hunderte Millisekunden blockieren. Verwenden Sie stattdessen UNLINK:

public void removeLeaderboard() {
    redis.unlink("leaderboard:global");
}

UNLINK entfernt den Key sofort aus dem Keyspace. Die Speicherfreigabe wird an einen Background-Thread delegiert. Sie sollten auch Lazy Free in der Konfiguration aktivieren, damit auch Rediss implizite Löschoperationen den Hauptthread nicht blockieren:

# redis.conf (Redis 6+)
lazyfree-lazy-user-del yes
lazyfree-lazy-expire yes
lazyfree-lazy-eviction yes
lazyfree-lazy-server-del yes

Mit lazyfree-lazy-user-del yes verhält sich sogar das DEL-Kommando wie UNLINK. Diese Einstellung schützt Sie vor älterem Code, der immer noch delete() aufruft.

Wenn Sie auf älteren Redis-Versionen laufen oder die Last genauer kontrollieren möchten, löschen Sie in Batches:

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);
}

Jeder HDEL-Aufruf löscht nur 500 Felder, daher ist die Blockierungszeit pro Befehl sehr kurz. Redis erlaubt, einen Hash während des HSCAN zu verändern. Nicht gelöschte Felder werden weiterhin zurückgegeben. Der abschließende unlink-Aufruf räumt restliche Felder auf.

6. Hot Keys behandeln

6.1. Lokaler Cache vor Redis

Die effektivste Methode ist, die meisten Anfragen Redis überhaupt nicht zu erreichen. Fügen Sie Caffeine hinzu:

<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

Dann platzieren Sie einen kurzfristigen lokalen Cache vor 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");
    }
}

Angenommen, Sie haben 20 Application-Instanzen. Jede Instanz ruft Redis für diesen Key maximal etwa einmal alle 2 Sekunden auf, also erhält Redis etwa 10 Anfragen/Sekunde statt zehntausende. LoadingCache batcht auch gleichzeitige Load-Anfragen für denselben Key. Dies verhindert, dass hunderte Threads auf Redis zugreifen, wenn der Cache abläuft.

Der Preis ist, dass die Daten bis zu 2 Sekunden alt sein können. Bei Konfigurationen für die Startseite oder Produktkategorien ist diese Latenz meist akzeptabel.

6.2. Key-Replikation

Wenn die Daten aktueller sein müssen, oder Sie in Redis Cluster laufen und ein Node überlastet ist, können Sie den Wert mehrfach schreiben und zufällig aus einer Kopie lesen:

@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);
    }
}

Die 8 Keys flashsale:42:r0 bis flashsale:42:r7 liegen normalerweise in verschiedenen Slots. Daher wird die Leseart auf mehrere Nodes verteilt. Diese Methode eignet sich für Daten, die oft gelesen, selten geschrieben werden.

Hinweis: Schreiben zu mehreren Replikationen ist nicht atomar. Für einen kurzen Moment können die Replikationen voneinander abweichen. Verwenden Sie dies nicht für Daten mit strikten Konsistenzanforderungen wie Kontostand. Für heiße Bestandszähler sollten Sie erwägen, den Bestand in mehrere unabhängige “Buckets” aufzuteilen. Jedes Bucket speichert einen Teil der Menge, statt eine Kopie des gleichen Werts zu sein.

Neben diesen zwei Methoden können Sie auch von Replicas lesen. Mit Lettuce konfigurieren Sie ReadFrom.REPLICA_PREFERRED, um die Leseart in einer Master–Replica-Topologie zu verteilen.

7. Logik zum Testen von Key-Splitting

Die Sharding-Routing-Logik ist der fehleranfälligste Teil, daher schreiben Sie Tests dafür:

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" hat 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));
    }
}

Der zweite Test prüft den Grenzfall mit Integer.MIN_VALUE. Wenn Sie Math.abs(hash) % n verwenden würden, würde dieser Test fehlschlagen, da Math.abs(Integer.MIN_VALUE) immer noch negativ ist. Der dritte Test bestätigt, dass 100.000 Felder relativ gleichmäßig verteilt sind: durchschnittlich etwa 1.560 Felder pro Shard.

8. Häufige Fehler und wann Sie das nicht verwenden sollten

  • KEYS * zum Finden von Big Keys verwenden: Dieser Befehl blockiert Redis, bis er fertig ist. Verwenden Sie immer SCAN.
  • Keys teilen, aber alles auslesen müssen: Wenn Ihre Geschäftslogik regelmäßig HGETALL aller Felder braucht, wird 1 langsamer Befehl zu 64 Befehlen. Überdenken Sie die Geschäftslogik, z.B. durch Paginierung oder Pre-Computing von Aggregaten.
  • Sharding-Anzahl während des Betriebs ändern: Das Erhöhen von SHARDS von 64 auf 128 verursacht, dass die meisten Felder ihre Position wechseln. Sie benötigen einen Migrationsplan oder sollten die Shard-Anzahl gleich ausreichend groß wählen.
  • Lokales Caching für sich schnell ändernde Daten: Bestände, aktuelle Auktionspreise… passen nicht zu TTLs von wenigen Sekunden. Wenn Sie es dennoch tun wollen, kombinieren Sie es mit Pub/Sub oder Client-Side-Caching (CLIENT TRACKING) von Redis 6+, um Cache ungültig zu machen.
  • Zu früh optimieren: Ein Hash mit 2.000 Feldern ist kein Problem. Messen Sie mit --bigkeys, --hotkeys und slowlog (SLOWLOG GET), bevor Sie optimieren.

9. Fazit

In diesem Artikel haben Sie erfahren, warum Hot Keys und Big Keys gefährlich für Redis sind, das Befehle in einem einzigen Thread verarbeitet. Um sie zu erkennen, können Sie redis-cli --bigkeys, --memkeys, --hotkeys, MEMORY USAGE, OBJECT FREQ verwenden oder auf Anwendungsebene zählen. Um Big Keys zu behandeln, teilen Sie mit Math.floorMod, löschen Sie mit UNLINK und aktivieren Sie Lazy Free. Um die Last von Hot Keys zu reduzieren, verwenden Sie lokales Caffeine-Caching oder Key-Replikation. Bauen Sie regelmäßige Durchsuchungen in Ihren Operationsprozess ein—frühe Erkennung ist immer billiger als Crisis-Management, wenn Redis bereits überlastet ist.

redisspring-bootperformancecachingjava