Cache mit Redis: TTL, Eviction und Cache Stampede

1. Übersicht

Redis ist eine beliebte Wahl zum Cachen von Daten, um die Last auf der Datenbank zu reduzieren und die Response-Zeit zu beschleunigen. Aber Redis zu nutzen ist nicht so einfach wie nur set und get — du musst verstehen, wie TTL (Time To Live) funktioniert, nach welchem Mechanismus Redis Keys löscht, wenn der Speicher voll ist (Eviction), und vor allem: wie du Cache Stampede vermeidest — eine Situation, bei der Hunderte von Anfragen gleichzeitig auf die Datenbank zugreifen, wenn ein Hot Key abläuft.

Dieser Artikel behandelt die grundlegende TTL-Konfiguration, die Eviction Policies von Redis und praktische Techniken gegen Stampede: Mutex Lock, Logical Expiration und Request Coalescing. Nach dem Lesen wirst du wissen, wie du eine Cache-Schicht entwirfst, die hohe Lasten verträgt, ohne die dahinterliegende Datenbank zu überlasten.

2. Einrichtung von Redis mit Spring Boot

Zunächst fügen Sie die Abhängigkeit hinzu:

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

Konfigurieren Sie die Verbindung:

spring:
  data:
    redis:
      host: localhost
      port: 6379
      timeout: 2000ms
      lettuce:
        pool:
          max-active: 16
          max-idle: 8

Das RedisTemplate Bean wird verwendet, um Key/Value-Operationen mit benutzerdefinierten Datentypen durchzuführen:

@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
    RedisTemplate<String, Object> template = new RedisTemplate<>();
    template.setConnectionFactory(factory);
    template.setKeySerializer(new StringRedisSerializer());
    template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
    return template;
}

GenericJackson2JsonRedisSerializer speichert das Objekt als JSON mit Klasseninformationen, was beim Lesen das Deserialisieren des richtigen Typs ermöglicht — bequemer als JdkSerializationRedisSerializer, das mit redis-cli schwer zu lesen ist.

3. TTL — Time To Live

3.1. TTL beim Schreiben eines Keys setzen

@Service
public class ProductCacheService {

    private final RedisTemplate<String, Object> redisTemplate;

    public ProductCacheService(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void cacheProduct(Long productId, Product product) {
        String key = "product:" + productId;
        redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10));
    }
}

Die Zeile set(key, product, Duration.ofMinutes(10)) schreibt den Key mit einer TTL von 10 Minuten. Nach dieser Zeit löscht Redis den Key automatisch — dies wird Passive Expiration kombiniert mit Active Expiration genannt (Redis scannt alle 100ms zufällig Keys mit TTL, um abgelaufene Keys frühzeitig zu entfernen, anstatt abgelaufene Keys im RAM zu halten, bis sie gelesen werden).

TTL überprüfen, die noch verbleibt:

redis-cli TTL product:123
(integer) 542

Der Wert -1 bedeutet, dass der Key existiert, aber keine TTL hat (existiert auf Dauer). Der Wert -2 bedeutet, dass der Key nicht existiert.

3.2. Feste TTL vs. zufällige TTL (Jitter)

Wenn Sie Tausende von Keys gleichzeitig mit derselben TTL cachen, laufen sie alle zur gleichen Zeit ab — dies verursacht eine Welle von “Cache Miss”. Die Lösung ist, einen Jitter zu addieren: zufällig einen kleinen Betrag zur TTL hinzufügen.

public void cacheProductWithJitter(Long productId, Product product) {
    long baseSeconds = 600;
    long jitter = ThreadLocalRandom.current().nextLong(0, 60);
    redisTemplate.opsForValue()
        .set("product:" + productId, product, Duration.ofSeconds(baseSeconds + jitter));
}

Jeder Key läuft zu einer leicht versetzten Zeit ab (600–660 Sekunden), was den “Thundering Herd”-Effekt vermeidet, wenn viele Keys gleichzeitig ablaufen.

Hinweis: Jitter löst nicht das Cache Stampede-Problem für einen einzelnen Hot Key (z.B. Homepage, Best-Seller-Produkt). Das Problem erfordert Techniken aus Abschnitt 5.

4. Eviction Policy wenn Redis den Speicher voll hat

TTL kümmert sich um das automatische Ablaufen von Keys nach einer Zeit. Wenn Redis aber die Grenze maxmemory erreicht, muss es Keys nach einer Eviction Policy löschen, unabhängig davon, ob der Key eine TTL hat oder nicht.

Konfiguration in redis.conf:

maxmemory 512mb
maxmemory-policy allkeys-lru

Häufig verwendete Policies:

Policy Bedeutung
noeviction Nichts löschen, OOM Fehler zurückgeben, wenn mehr geschrieben wird. Standard von Redis.
allkeys-lru Löscht den am wenigsten kürzlich verwendeten Key (Least Recently Used) auf alle Keys angewendet.
volatile-lru Wie oben, aber nur auf Keys mit TTL angewendet.
allkeys-lfu Löscht den Key mit der niedrigsten Zugriffshäufigkeit (Least Frequently Used).
volatile-ttl Bevorzugt das Löschen von Keys mit der kürzesten verbleibenden TTL.
volatile-random Löscht zufällig aus der Menge von Keys mit TTL.

Für reine Cache-Anwendungen ist allkeys-lru oder allkeys-lfu eine sichere Wahl, da Redis den Speicher automatisch freigeben wird, anstatt Fehler zurückzugeben. Wenn du Redis sowohl als Cache als auch zur Speicherung wichtiger Daten (z.B. Session-Tokens) nutzt, die nicht verloren gehen dürfen, solltest du die Instanzen trennen oder volatile-lru verwenden, um nur Keys mit TTL zu löschen.

Hinweis: noeviction (Standard) führt zu Fehlern OOM command not allowed, wenn Redis voll wird. Wenn du Redis als Cache nutzt, solltest du immer maxmemory-policy setzen — verlasse dich nicht auf den Standard.

Überprüfe die aktuelle Konfiguration:

redis-cli CONFIG GET maxmemory-policy
1) "maxmemory-policy"
2) "allkeys-lru"

5. Cache Stampede — Was ist das?

Cache Stampede (auch Dogpile Effect genannt) tritt auf, wenn ein Hot Key — der Hunderte/Tausende Male pro Sekunde gelesen wird — plötzlich abläuft. Alle Anfragen verpassen den Cache fast gleichzeitig, greifen alle auf die Datenbank zu, um die Daten neu zu erstellen, was die Datenbank für mehrere Hundert Millisekunden überlastet.

public Product getProduct(Long id) {
    String key = "product:" + id;
    Product cached = (Product) redisTemplate.opsForValue().get(key);
    if (cached != null) {
        return cached;
    }
    // Key ist gerade abgelaufen -> hundert Threads laufen hier gleichzeitig ein
    Product product = productRepository.findById(id)
        .orElseThrow(() -> new ProductNotFoundException(id));
    redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10));
    return product;
}

Bei einem Best-Seller-Produkt ist dieser Code normalerweise völlig vernünftig, aber genau zum Zeitpunkt der TTL-Ablauf können productRepository.findById Hunderte Male gleichzeitig für die gleiche id aufgerufen werden.

5.1. Lösung: Distributed Lock (Mutex)

Nur ein Request darf den Cache neu erstellen, während andere warten oder den alten Wert verwenden.

public Product getProductSafely(Long id) {
    String key = "product:" + id;
    Product cached = (Product) redisTemplate.opsForValue().get(key);
    if (cached != null) {
        return cached;
    }

    String lockKey = "lock:product:" + id;
    Boolean acquired = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, "1", Duration.ofSeconds(5));

    if (Boolean.TRUE.equals(acquired)) {
        try {
            Product product = productRepository.findById(id)
                .orElseThrow(() -> new ProductNotFoundException(id));
            redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10));
            return product;
        } finally {
            redisTemplate.delete(lockKey);
        }
    } else {
        // Lock nicht erhalten -> kurz warten und Cache erneut versuchen zu lesen
        return waitAndRetry(key, id);
    }
}

private Product waitAndRetry(String key, Long id) {
    for (int i = 0; i < 10; i++) {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        Product cached = (Product) redisTemplate.opsForValue().get(key);
        if (cached != null) {
            return cached;
        }
    }
    // Wartezeit abgelaufen, Fallback: direkt Datenbank abfragen
    return productRepository.findById(id)
        .orElseThrow(() -> new ProductNotFoundException(id));
}

setIfAbsent entspricht dem Redis-Befehl SET lockKey 1 NX EX 5 — atomar, nur ein Thread erhält erfolgreich das Lock. Threads ohne Lock versuchen alle 100ms erneut den Cache zu lesen, anstatt selbst die Datenbank abzufragen.

5.2. Lösung: Logical Expiration (kein echtes TTL verwenden)

Anstatt Redis den Key selbst löschen zu lassen, speicherst du zusammen mit dem Wert einen Ablaufzeitpunkt, und der Key verschwindet niemals wirklich. Wenn erkannt wird, dass die Daten logisch abgelaufen sind, erstellt ein Thread den Cache im Hintergrund neu, während andere Threads sofort den alten Wert zurückgeben (Stale-While-Revalidate).

public record CachedValue<T>(T data, Instant logicalExpireAt) {}

public Product getProductWithLogicalTtl(Long id) {
    String key = "product:" + id;
    CachedValue<Product> cached =
        (CachedValue<Product>) redisTemplate.opsForValue().get(key);

    if (cached == null) {
        return rebuildAndCache(id, key);
    }

    if (Instant.now().isAfter(cached.logicalExpireAt())) {
        String lockKey = "lock:product:" + id;
        Boolean acquired = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, "1", Duration.ofSeconds(5));
        if (Boolean.TRUE.equals(acquired)) {
            // Asynchron neu erstellen, aber aktuellen Request mit alten Daten bedienen
            CompletableFuture.runAsync(() -> {
                try {
                    rebuildAndCache(id, key);
                } finally {
                    redisTemplate.delete(lockKey);
                }
            });
        }
    }
    return cached.data(); // immer Daten zurückgeben, auch wenn möglicherweise veraltet
}

private Product rebuildAndCache(Long id, String key) {
    Product product = productRepository.findById(id)
        .orElseThrow(() -> new ProductNotFoundException(id));
    CachedValue<Product> value = new CachedValue<>(product, Instant.now().plusSeconds(600));
    // Physische TTL länger als logische TTL, um zu verhindern, dass der Key während des Neu-Aufbaus verschwindet
    redisTemplate.opsForValue().set(key, value, Duration.ofMinutes(15));
    return product;
}

Der Hauptpunkt: Die physische TTL in Redis (15 Minuten) ist immer länger als die logische TTL (10 Minuten). Der Key wird von Redis nie plötzlich gelöscht — er wird nur als “alt” markiert und im Hintergrund aktualisiert. Anfragen müssen nie auf die Datenbank warten, nicht mal unmittelbar nach Ablauf der logischen Daten.

6. Testen des Stampede-Verhaltens

Schreibe einen Test, um sicherzustellen, dass die Datenbank nur einmal aufgerufen wird, wenn viele Threads den Cache verfehlen:

<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
class ProductCacheServiceTest {

    @Autowired
    private ProductCacheService cacheService;

    @MockBean
    private ProductRepository productRepository;

    @Test
    void nurEinThreadDarfDatenbankAbfragenWennCacheVerpasst() throws InterruptedException {
        Long productId = 42L;
        Product product = new Product(productId, "Mechanische Tastatur");
        given(productRepository.findById(productId)).willReturn(Optional.of(product));

        int threadCount = 50;
        ExecutorService executor = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executor.submit(() -> {
                cacheService.getProductSafely(productId);
                latch.countDown();
            });
        }
        latch.await(5, TimeUnit.SECONDS);
        executor.shutdown();

        verify(productRepository, times(1)).findById(productId);
    }
}
@Test
void ohneLockWirdDatenbankHäufigAbgefragt() {
    Long productId = 99L;
    Product product = new Product(productId, "Mechanische Tastatur");
    given(productRepository.findById(productId)).willReturn(Optional.of(product));

    List<Product> results = IntStream.range(0, 50)
        .parallel()
        .mapToObj(i -> cacheService.getProductUnsafe(productId))
        .toList();

    assertThat(results).hasSize(50);
    verify(productRepository, atLeast(2)).findById(productId); // beweist, dass Stampede auftritt
}

Der erste Test verwendet getProductSafely (mit Lock) und erwartet, dass findById genau 1 Mal aufgerufen wird, obwohl 50 Threads laufen. Der zweite Test verwendet die unsichere Version, um das Gegenteil zu zeigen — du wirst sehen, dass findById mehrfach aufgerufen wird, was beweist, dass Stampede ohne Behandlung auftritt.

7. Häufig auftretende Fehler

  • TTL vergessen zu setzen: Key existiert auf Dauer, Redis speichert sich langsam voll, muss sich vollständig auf Eviction Policy verlassen — Risiko, versehentlich benötigte Keys zu löschen.
  • TTL zu kurz für Hot Keys: erhöht die Cache-Miss-Häufigkeit, macht Stampede wahrscheinlicher. Bei Keys mit hohem Traffic sollte Logical Expiration gegenüber kurzer physischer TTL bevorzugt werden.
  • Lock ohne TTL: Wenn ein Thread, der das Lock hält, abstürzt, bleibt das Lock für immer bestehen, andere Threads warten auf Dauer. Setze immer eine TTL für Locks (setIfAbsent mit Duration).
  • noeviction für Redis Cache nutzen: die Anwendung wirft einen Fehler, wenn Redis voll wird, anstatt automatisch aufzuräumen. Das ist nur sinnvoll, wenn Redis zum Speichern unverzichtbarer Daten verwendet wird.
  • Objektserialisierung inkonsistent: Wenn Felder in der Klasse Product geändert werden ohne Versionierung, kann das Deserialisieren aus dem alten Cache fehlschlagen. Implementiere einen Cache-Busting-Mechanismus (Änderung des Key-Präfixes), wenn sich das Schema ändert.

8. Fazit

TTL hilft Cache-Daten zeitgesteuert zu aktualisieren, benötigt aber Jitter, um zu verhindern, dass viele Keys gleichzeitig ablaufen. Eviction Policy entscheidet, wie Redis mit vollem Speicher umgeht — wähle allkeys-lru/allkeys-lfu für reines Caching, vermeide das Standard noeviction. Bei Hot Keys reicht eine einfache TTL nicht aus: du brauchst einen Mutex Lock oder Logical Expiration, um sicherzustellen, dass nur ein Thread den Cache neu erstellt, der Rest benutzt alte Daten, anstatt alle zur Datenbank zu laufen. Wenn diese drei Techniken richtig angewendet werden, verträgt deine Cache-Schicht viel höhere Lasten als nur einfaches set/get.

rediscachespring-bootperformancejava