Cache mit @Cacheable und Redis in Spring Boot

1. Übersicht

Viele Anwendungen rufen dieselbe langsame Abfrage immer wieder auf. Beispiele sind das Abrufen von Produktinformationen, Konfigurationen oder Wechselkursen. Spring Cache Abstraction löst dieses Problem mit wenigen Annotationen. Redis spielt die Rolle des gemeinsamen Cache-Speichers für mehrere Instanzen.

Nach diesem Artikel kannst du folgende Dinge tun:

  • Redis als Cache-Provider konfigurieren.
  • @Cacheable, @CachePut, @CacheEvict verwenden.
  • TTL einzeln für jeden Cache setzen und Daten als JSON speichern.
  • Cache-Verhalten mit JUnit 5 und Testcontainers testen.

2. Maven-Abhängigkeiten

Du brauchst zwei Starter: einen für Spring Cache und einen für Redis.

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

Für die Tests brauchst du zusätzlich Testcontainers. Spring Boot verwaltet bereits die Versionen für diese Abhängigkeiten:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

3. Redis-Verbindung konfigurieren

Deklariere die Redis-Adresse und wähle Redis als Cache-Provider in application.yml:

spring:
  data:
    redis:
      host: localhost
      port: 6379
  cache:
    type: redis

Bedeutung der einzelnen Eigenschaften:

  • spring.data.redis.* konfiguriert die Verbindung. Spring Boot verwendet standardmäßig den Lettuce-Client.
  • spring.cache.type: redis gibt den Provider explizit an. Falls Redis im Classpath vorhanden ist, wählt Spring Boot auch automatisch Redis. Explizit anzugeben hilft, Verwirrung zu vermeiden, wenn der Classpath mehrere Provider enthält.

Du kannst schnell einen lokalen Redis mit Docker starten:

docker run -d --name redis -p 6379:6379 redis:7-alpine

4. Grundlegendes Beispiel mit @Cacheable

4.1. Modell und Repository zum Simulieren langsamer Abfragen

Wir verwenden einen einfachen record für ein Produkt:

public record Product(Long id, String name, BigDecimal price) {}

Das folgende Repository simuliert eine langsame Datenbank. Es zählt auch auf, wie oft es aufgerufen wird, damit wir den Cache überprüfen können:

@Repository
public class ProductRepository {

    private final Map<Long, Product> store = new ConcurrentHashMap<>(Map.of(
            1L, new Product(1L, "Nón lá Huế", new BigDecimal("150000")),
            2L, new Product(2L, "Áo bà ba", new BigDecimal("320000"))
    ));
    private final AtomicInteger queryCount = new AtomicInteger();

    public Optional<Product> findById(Long id) {
        queryCount.incrementAndGet();
        sleep(1000); // simuliert eine Abfrage, die 1 Sekunde dauert
        return Optional.ofNullable(store.get(id));
    }

    public Product save(Product product) {
        store.put(product.id(), product);
        return product;
    }

    public void deleteById(Long id) {
        store.remove(id);
    }

    public int getQueryCount() { return queryCount.get(); }

    public void resetQueryCount() { queryCount.set(0); }

    private static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

4.2. Cache aktivieren und Methoden kennzeichnen

Aktiviere den Caching-Mechanismus mit @EnableCaching. Platziere diese Annotation auf einer @Configuration-Klasse (wird in Abschnitt 5 erläutert) oder auf der Main-Klasse. Dann kennzeichne die Methoden, die gecacht werden sollen:

@Service
public class ProductService {

    private final ProductRepository productRepository;

    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Cacheable(cacheNames = "products", key = "#id")
    public Product findById(Long id) {
        return productRepository.findById(id)
                .orElseThrow(() -> new ProductNotFoundException(id));
    }
}

Der Verarbeitungsablauf ist wie folgt:

  • cacheNames = "products" ist der Cache-Name. Auf Redis hat der Standard-Key die Form products::<key>.
  • key = "#id" ist ein SpEL-Ausdruck, der den Parameter id als Key verwendet. Wenn die Methode nur einen Parameter hat, kannst du diese Eigenschaft weglassen.
  • Beim ersten Aufruf findet Spring den Key nicht im Cache. Es führt die Methode aus und speichert das Ergebnis dann in Redis.
  • Bei nachfolgenden Aufrufen mit derselben id gibt Spring das Ergebnis aus Redis zurück. Die Methode wird nicht ausgeführt.
  • Wenn die Methode eine Exception wirft (ProductNotFoundException), speichert Spring nichts im Cache.

Rufe findById(1L) zweimal auf und überprüfe dann Redis:

redis-cli KEYS '*'
redis-cli TTL products::1
1) "products::1"
(integer) 1798

Der erste Aufruf dauert etwa 1 Sekunde. Der zweite Aufruf dauert nur wenige Millisekunden. Die TTL beträgt etwa 1800 Sekunden, weil wir im nächsten Abschnitt 30 Minuten TTL für den Cache products konfigurieren werden.

5. RedisCacheManager konfigurieren: TTL und Serializer

Standardmäßig verwendet RedisCacheManager JDK-Serialisierung. Diese Methode hat drei Nachteile:

  • Die Klasse muss Serializable implementieren.
  • Die Daten in Redis sind binär und daher schwer zu lesen.
  • Die Daten können beschädigt werden, wenn du die Klassenstruktur änderst.

Standardmäßig hat der Cache auch keine TTL, was bedeutet, dass Einträge ewig leben. Wir beheben beide Punkte:

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public RedisCacheConfiguration defaultCacheConfig() {
        return RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10))
                .disableCachingNullValues()
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer()));
    }

    @Bean
    public RedisCacheManagerBuilderCustomizer productCacheCustomizer() {
        return builder -> builder.withCacheConfiguration("products",
                RedisCacheConfiguration.defaultCacheConfig()
                        .entryTtl(Duration.ofMinutes(30))
                        .disableCachingNullValues()
                        .serializeValuesWith(RedisSerializationContext.SerializationPair
                                .fromSerializer(new Jackson2JsonRedisSerializer<>(Product.class))));
    }
}

Erklärung der einzelnen Beans:

  • Die Bean RedisCacheConfiguration ist die Standardkonfiguration für alle Caches. Spring Boot erkennt diese Bean automatisch. Alle Einträge haben eine TTL von 10 Minuten, null wird nicht gespeichert, und die Value wird als JSON gespeichert.
  • RedisCacheManagerBuilderCustomizer überschreibt die Konfiguration speziell für den Cache products. Dieser Cache hat eine TTL von 30 Minuten und verwendet einen Serializer mit dem spezifischen Typ Product.class.

Warum verwendet products Jackson2JsonRedisSerializer<>(Product.class)? GenericJackson2JsonRedisSerializer muss Typinformationen (@class) im JSON speichern. Bei final-Klassen wie record gibt dieser Mechanismus leicht LinkedHashMap statt des richtigen Typs zurück, und du wirst auf einen ClassCastException stoßen. Ein Serializer mit spezifischem Typ vermeidet dieses Problem vollständig.

Die Daten in Redis sind jetzt direkt lesbar:

redis-cli GET products::1
"{\"id\":1,\"name\":\"Nón lá Huế\",\"price\":150000}"

Achtung: Wenn du eine Bean RedisCacheConfiguration selbst deklarierst, werden die Eigenschaften spring.cache.redis.time-to-live, spring.cache.redis.key-prefix… in application.yml ignoriert. Wähle eine der beiden Konfigurationsmethoden, vermische sie nicht.

6. Cache aktualisieren und löschen: @CachePut und @CacheEvict

Ein Cache ist nur nützlich, wenn die darin enthaltenen Daten korrekt sind. Wenn sich ein Produkt ändert, musst du den entsprechenden Eintrag aktualisieren oder löschen.

@CachePut(cacheNames = "products", key = "#product.id()")
public Product update(Product product) {
    return productRepository.save(product);
}

@CacheEvict(cacheNames = "products", key = "#id")
public void delete(Long id) {
    productRepository.deleteById(id);
}

@CacheEvict(cacheNames = "products", allEntries = true)
public void clearAll() {
    // Beispiel: wird aufgerufen, nachdem Produkte in Massen importiert wurden
}

Unterschiede zwischen den Annotationen:

  • @CachePut führt die Methode immer aus und schreibt dann das zurückgegebene Ergebnis mit dem Key #product.id() in den Cache. Daher muss die Methode das korrekte Objekt zurückgeben, das gecacht werden soll. Wenn du void zurückgibst, hat der Cache keine korrekten Daten.
  • @CacheEvict löscht den Eintrag mit dem angegebenen Key, nachdem die Methode erfolgreich ausgeführt wurde. Wenn du den Eintrag vor Ausführung der Methode löschen möchtest, füge beforeInvocation = true hinzu.
  • allEntries = true löscht den gesamten Cache products. Bei Redis scannt dieser Vorgang Keys nach einem Pattern. Du solltest das nur verwenden, wenn es wirklich nötig ist.

7. Cache-Verhalten steuern: condition, unless, sync

@Cacheable hat einige weitere nützliche Attribute:

@Cacheable(cacheNames = "productSearch",
           key = "#keyword.toLowerCase()",
           condition = "#keyword.length() >= 3",
           unless = "#result.isEmpty()")
public List<Product> search(String keyword) {
    // langsame Abfrage...
    return List.of();
}

@Cacheable(cacheNames = "products", key = "#id", sync = true)
public Product findHotProduct(Long id) {
    return findFromDatabase(id); // private Methode in derselben Klasse, liest direkt aus der Datenbank
}

Bedeutung der einzelnen Attribute:

  • condition wird vor der Methodenausführung bewertet. Hier werden Suchbegriffe mit weniger als 3 Zeichen nicht abgefragt und nicht im Cache gespeichert.
  • unless wird nach der Methodenausführung bewertet und kann #result verwenden. Hier wird eine leere Liste nicht gespeichert.
  • key = "#keyword.toLowerCase()" stellt sicher, dass “Nón” und “nón” denselben Eintrag verwenden.
  • sync = true verhindert Cache Stampede. Wenn mehrere Threads denselben Key verpassen, führt nur ein Thread die Methode aus, die anderen Threads warten auf das Ergebnis. Dieser Mechanismus synchronisiert sich innerhalb einer JVM, es ist kein verteilter Lock zwischen Instanzen.

Der Cache productSearch hat keine eigene Konfiguration und verwendet daher die Standardkonfiguration aus Abschnitt 5, d.h. TTL von 10 Minuten und GenericJackson2JsonRedisSerializer. Bei einem Wert, der List<Product> mit record enthält, solltest du einen Serializer mit spezifischem Typ deklarieren, ähnlich wie bei products. So vermeidest du den ClassCastException-Fehler, der oben erwähnt wurde.

8. Cache-Verhalten testen

Die zuverlässigste Methode ist das Testen mit echtem Redis über Testcontainers. Die Annotation @ServiceConnection konfiguriert Host und Port automatisch für Spring Boot, du musst application.yml nicht ändern.

@SpringBootTest
@Testcontainers
class ProductServiceCacheTest {

    @Container
    @ServiceConnection(name = "redis")
    static GenericContainer<?> redis =
            new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
                    .withExposedPorts(6379);

    @Autowired
    private ProductService productService;

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private CacheManager cacheManager;

    @BeforeEach
    void setUp() {
        cacheManager.getCache("products").clear();
        productRepository.resetQueryCount();
    }

    @Test
    void whenFindByIdCalledTwice_thenRepositoryIsQueriedOnce() {
        Product first = productService.findById(1L);
        Product second = productService.findById(1L);

        assertThat(first).isEqualTo(second);
        assertThat(productRepository.getQueryCount()).isEqualTo(1);
        assertThat(cacheManager.getCache("products").get(1L, Product.class))
                .isEqualTo(first);
    }

    @Test
    void whenProductUpdated_thenCacheReturnsNewValue() {
        productService.findById(2L);

        productService.update(new Product(2L, "Áo bà ba lụa", new BigDecimal("450000")));
        Product cached = productService.findById(2L);

        assertThat(cached.name()).isEqualTo("Áo bà ba lụa");
        assertThat(productRepository.getQueryCount()).isEqualTo(1);
    }
}

Diese beiden Tests überprüfen die folgenden Punkte:

  • Der erste Test: Zweimal aufgerufen, aber das Repository wird nur einmal aufgerufen. Der Eintrag ist auch tatsächlich im Cache mit dem korrekten Typ Product vorhanden. Das bestätigt, dass der Serializer korrekt funktioniert.
  • Der zweite Test: @CachePut schreibt den neuen Wert, also wird beim nächsten Lesen aus dem Cache gelesen, ohne dass das Repository erneut aufgerufen wird.
  • @BeforeEach löscht den Cache und den Zähler, damit die Tests voneinander unabhängig sind. Der Container ist static, daher wird er nur einmal für die gesamte Klasse gestartet.

9. Häufige Fehler und wann man Cache nicht verwenden sollte

Self-invocation. Spring Cache funktioniert über Proxies. Wenn eine Methode in ProductService this.findById(id) aufruft, geht der Aufruf direkt zum Objekt, nicht über den Proxy, daher wird der Cache nicht angewendet. Das ist auch der Grund, warum das Beispiel findHotProduct in Abschnitt 7 direkt aus der Datenbank liest, anstatt findById aufzurufen. Annotationen auf private-Methoden haben auch keine Auswirkung. Die Lösung ist, die Methode, die gecacht werden soll, in eine andere Bean zu verschieben.

Null-Werte cachen, wenn Null deaktiviert ist. Mit disableCachingNullValues() führt eine Methode, die null zurückgibt, zu einem Fehler:

java.lang.IllegalArgumentException: Cache 'products' does not allow 'null' values;
Avoid storing null via '@Cacheable(unless="#result == null")' ...

Du hast zwei Möglichkeiten: Füge unless = "#result == null" hinzu, oder wirf eine Exception wie im Beispiel in Abschnitt 4.

Klassenstruktur ändern. Du fügst ein Feld hinzu oder änderst den Namen eines Feldes in Product, aber Redis speichert immer noch JSON im alten Format. Das Ergebnis kann ein Deserialisierungsfehler sein oder es fehlen Felder. Du solltest den Cache umbenennen (z.B. products-v2) oder den Cache beim Deployment leeren.

JPA-Entitäten cachen. Entitäten haben Lazy-Proxies und bidirektionale Beziehungen, die beim Serialisieren sehr leicht zu Fehlern führen. Cache stattdessen DTOs oder record.

Achtung: Verwende keinen Cache, wenn die Daten strikte Konsistenz erfordern, wie Kontostände oder Bestand bei der Bezahlung. Verwende auch keinen Cache, wenn sich die Daten ständig ändern, da die Hit-Rate niedrig ist und du nur einen zusätzlichen Netzwerkaufruf zu Redis hinzufügst. Wenn nur eine Instanz läuft und die Daten klein sind, ist der In-Memory-Cache wie Caffeine normalerweise schneller und einfacher als Redis.

10. Fazit

Du hast Redis als Cache-Provider für Spring Boot konfiguriert. Du verwendest @Cacheable zum Lesen, @CachePut zum Aktualisieren, @CacheEvict zum Löschen von Einträgen. Du hast auch TTL und JSON-Serializer einzeln für jeden Cache gesetzt und das Cache-Verhalten mit echtem Redis über Testcontainers getestet.

Drei Dinge, die du dir merken solltest:

  • Immer TTL setzen.
  • DTOs statt Entitäten cachen.
  • Vorsicht vor Self-invocation.

Wenn du diese drei Punkte beachtest, vermeidest du die meisten Probleme, wenn du Cache in die Produktion bringst.

spring-bootrediscachespring-cachetestcontainers