Caching: cache-aside, write-through und TTL

1. Übersicht

Cache reduziert die Last auf der Datenbank und verkürzt die Antwortzeit. Dafür müssen Sie akzeptieren, dass die Daten möglicherweise veraltet sind. Dieser Artikel stellt zwei gängige Read/Write-Cache-Strategien vor: cache-aside und write-through. Der Artikel erklärt auch, wie man TTL (time-to-live) nutzt, um die Veralterung der Daten zu begrenzen.

Nach dem Lesen können Sie beide Strategien mit reinem Java implementieren, zu Spring Cache Annotations übergehen, TTL für Caffeine und Redis konfigurieren und einige häufige Fehler vermeiden.

2. Grundkonzepte

Bevor wir zum Code gehen, einigen wir uns auf einige Begriffe:

  • Cache hit: Die Daten sind in dem Cache vorhanden, werden sofort zurückgegeben.
  • Cache miss: Der Cache hat die Daten nicht, Sie müssen aus der Quelle (Datenbank, anderer Service) gelesen werden.
  • Stale data: Die Daten im Cache unterscheiden sich von der Datenbank.
  • TTL: Die Lebensdauer eines Eintrags. Nach Ablauf der TTL wird der Eintrag als ungültig betrachtet.

Jede Cache-Strategie dreht sich um eine Frage: Wenn sich Daten ändern, wer ist dafür verantwortlich, den Cache zu aktualisieren, und wann?

Wir verwenden eine einfache Domain für den gesamten Artikel:

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

public interface ProductRepository {
    Optional<Product> findById(long id);
    Product save(Product product);
}

3. Ein selbst geschriebener Cache mit TTL

Um den Mechanismus zu sehen, schreiben wir einen kleinen Cache mit TTL selbst. Die Zeit wird über einen Supplier<Instant> abgerufen, so dass Tests die “Uhr vorstellen” können.

public class TtlCache<K, V> {

    private record Entry<V>(V value, Instant expiresAt) {}

    private final Map<K, Entry<V>> store = new ConcurrentHashMap<>();
    private final Duration ttl;
    private final Supplier<Instant> now;

    public TtlCache(Duration ttl, Supplier<Instant> now) {
        this.ttl = ttl;
        this.now = now;
    }

    public Optional<V> get(K key) {
        Entry<V> entry = store.get(key);
        if (entry == null) {
            return Optional.empty();
        }
        if (now.get().isAfter(entry.expiresAt())) {
            store.remove(key, entry);
            return Optional.empty();
        }
        return Optional.of(entry.value());
    }

    public void put(K key, V value) {
        store.put(key, new Entry<>(value, now.get().plus(ttl)));
    }

    public void evict(K key) {
        store.remove(key);
    }
}

So funktioniert diese Klasse:

  • put speichert den Wert zusammen mit dem Ablaufzeitpunkt expiresAt = now + ttl.
  • get überprüft die Frist. Wenn der Eintrag abgelaufen ist, wird er gelöscht und die Funktion gibt Optional.empty() zurück, was ein Cache Miss ist.
  • store.remove(key, entry) löscht nur genau den Eintrag, der gerade gelesen wurde. Wenn also ein anderer Thread gerade einen neuen Wert put hat, wird dieser Wert nicht versehentlich gelöscht.

Diese Klasse reicht für den Demonstrationszweck. In der Produktion sollten Sie Caffeine oder Redis verwenden (Abschnitt 6).

4. Cache-aside

4.1. Konzept

Bei cache-aside (auch lazy loading genannt) verwaltet die Anwendung den Cache selbst:

  1. Lesen: Schauen Sie zuerst im Cache nach. Bei einem Miss lesen Sie aus der Datenbank, speichern das Ergebnis im Cache und geben es zurück.
  2. Schreiben: Aktualisieren Sie zuerst die Datenbank, dann löschen Sie den Eintrag aus dem Cache. Der nächste Lesevorgang wird die neuen Daten neu laden.
public class ProductService {

    private final ProductRepository repository;
    private final TtlCache<Long, Product> cache;

    public ProductService(ProductRepository repository, TtlCache<Long, Product> cache) {
        this.repository = repository;
        this.cache = cache;
    }

    public Optional<Product> findById(long id) {
        Optional<Product> cached = cache.get(id);
        if (cached.isPresent()) {
            return cached;                       // cache hit
        }
        Optional<Product> fromDb = repository.findById(id);
        fromDb.ifPresent(p -> cache.put(id, p)); // in den Cache laden
        return fromDb;
    }

    public Product updatePrice(long id, BigDecimal newPrice) {
        Product current = repository.findById(id).orElseThrow();
        Product saved = repository.save(new Product(id, current.name(), newPrice));
        cache.evict(id);                         // löschen, nachdem DB geschrieben hat
        return saved;
    }
}

Erläuterung:

  • findById ruft repository nur bei Cache Miss auf. Wenn das Produkt nicht vorhanden ist, werden keine Daten gecacht.
  • updatePrice schreibt zuerst in die Datenbank und dann erst evict. Diese Reihenfolge ist wichtig, Abschnitt 8 erklärt warum.
  • Wir löschen den Eintrag, anstatt den Wert zu überschreiben. Dies ist einfacher, da Cache nur aus einer Stelle geladen wird: dem Lesepfad.

4.2. Wann ist das sinnvoll?

Cache-aside passt gut zu:

  • Die Leserate ist viel höher als die Schreibrate, z. B. Produktkatalog oder Benutzerprofil.
  • Sie möchten nur die Daten cachen, die tatsächlich aufgerufen werden.
  • Das System muss auch funktionieren, wenn der Cache ausfällt. Dann gehen alle Anfragen direkt in die Datenbank: langsamer, aber ohne Fehler.

Nachteile: Die erste Anfrage nach jedem Miss ist immer langsam. Wenn ein anderer Prozess die Datenbank direkt ändert, ohne den Cache zu löschen, sind die Daten alt, bis die TTL abläuft.

5. Write-through

5.1. Konzept

Bei write-through durchlaufen alle Schreibvorgänge die Cache-Schicht. Diese schreibt synchron in die Datenbank und aktualisiert dann den Cache mit dem neuen Wert. Wenn der Schreibbefehl zurückkommt, sind Cache und Datenbank identisch.

public class WriteThroughProductStore {

    private final ProductRepository repository;
    private final TtlCache<Long, Product> cache;

    public WriteThroughProductStore(ProductRepository repository, TtlCache<Long, Product> cache) {
        this.repository = repository;
        this.cache = cache;
    }

    public Product save(Product product) {
        Product saved = repository.save(product); // 1. DB schreiben (synchron)
        cache.put(saved.id(), saved);             // 2. Cache aktualisieren
        return saved;
    }

    public Optional<Product> findById(long id) {
        return cache.get(id).or(() -> {
            Optional<Product> fromDb = repository.findById(id);
            fromDb.ifPresent(p -> cache.put(id, p));
            return fromDb;
        });
    }
}

Unterschiede zum cache-aside:

  • save überschreibt den Cache mit dem gerade gespeicherten Wert, anstatt ihn zu löschen. Der nächste Lesevorgang ist ein Cache Hit.
  • Wir schreiben nur dann in den Cache, wenn repository.save erfolgreich ist. Wenn die Datenbank eine Exception wirft, wird der Cache nicht berührt, daher besteht keine Chance, dass Cache Daten enthält, die nie gespeichert wurden.
  • findById benötigt immer noch einen Fallback-Zweig, da der Eintrag möglicherweise abgelaufen ist oder nie über save geschrieben wurde.

5.2. Wann ist das sinnvoll?

Write-through passt gut, wenn Daten, die gerade geschrieben wurden, sofort wieder gelesen werden, z. B. Warenkorb oder Benutzereinstellungen. Der Preis dafür:

  • Jeder Schreibvorgang ist etwas langsamer, da zwei Orte geschrieben werden müssen.
  • Der Cache könnte viele Daten enthalten, die niemand liest. Kombinieren Sie dies mit TTL und Größenlimit.

Hinweis: Verwechseln Sie write-through nicht mit write-behind (write-back). Write-behind schreibt Cache zuerst, dann asynchron in die Datenbank. Schneller, aber wenn der Cache-Knoten stirbt, bevor er leert, verlieren Sie Daten. Verwenden Sie write-behind nur, wenn Sie dieses Risiko akzeptieren.

6. Spring Cache verwenden

Selbst schreiben hilft, den Mechanismus zu verstehen, aber in echten Projekten sollten Sie Spring Cache mit Caffeine (lokaler Cache) oder Redis (verteilter Cache) verwenden. Abhängigkeit hinzufügen:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

Aktivieren Sie den Cache mit @EnableCaching:

@SpringBootApplication
@EnableCaching
public class ShopApplication {
    public static void main(String[] args) {
        SpringApplication.run(ShopApplication.class, args);
    }
}

6.1. Cache-aside mit @Cacheable und @CacheEvict

@Service
public class CatalogService {

    private final ProductRepository repository;

    public CatalogService(ProductRepository repository) {
        this.repository = repository;
    }

    @Cacheable(cacheNames = "products", key = "#id", unless = "#result == null")
    public Optional<Product> findById(long id) {
        return repository.findById(id);
    }

    @CacheEvict(cacheNames = "products", key = "#product.id")
    public Product update(Product product) {
        return repository.save(product);
    }
}
  • @Cacheable ist das cache-aside in deklarativer Form. Spring prüft zuerst den Cache, bei Miss führt die Method aus und speichert das Ergebnis.
  • Mit dem Rückgabetyp Optional cached Spring den Wert darin. unless = "#result == null" verhindert das Cachen, wenn nichts gefunden wird.
  • @CacheEvict läuft standardmäßig nach erfolgreicher Methodenausführung. Dies ist genau die Reihenfolge “DB schreiben, dann Cache löschen”.

6.2. Write-through mit @CachePut

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

@CachePut führt die Methode immer aus und schreibt den Rückgabewert in den Cache. Dies entspricht WriteThroughProductStore.save aus Abschnitt 5.

Hinweis: Annotation Caching funktioniert über einen Proxy. Wenn Sie this.findById(...) von einer anderen Methode in derselben Klasse aufrufen, wird der Cache vollständig umgangen. Rufen Sie über einen anderen Bean auf.

7. TTL konfigurieren

7.1. Caffeine

spring:
  cache:
    type: caffeine
    cache-names: products
    caffeine:
      spec: maximumSize=10000,expireAfterWrite=10m
  • expireAfterWrite=10m: Eintrag verfällt 10 Minuten nach dem letzten Schreiben.
  • maximumSize=10000: Wenn überschritten, löscht Caffeine weniger genutzte Einträge. Legen Sie diese Grenze immer fest, um zu verhindern, dass der Cache den Heap aufbraucht.

7.2. Redis

spring-boot-starter-data-redis hinzufügen, dann konfigurieren:

spring.cache.type=redis
spring.cache.redis.time-to-live=10m
spring.cache.redis.key-prefix=shop::

Redis löscht Keys automatisch, wenn die TTL abläuft. Dieser Cache wird zwischen mehreren Instanzen der Anwendung geteilt.

7.3. Welche TTL wählen?

Es gibt keine universelle Zahl. Fragen Sie sich: Wie alt dürfen die Daten maximal sein?

Wechselkurs, Lagerbestand        : einige Sekunden – 1 Minute
Produktpreis, Katalog            : 5 – 30 Minuten
Konfiguration, Liste Bundesländer: mehrere Stunden – 1 Tag

Kurze TTL = frischere Daten, aber niedrigere Hit-Rate. Lange TTL ist das Gegenteil. Selbst wenn Sie proaktiv löschen, sollten Sie immer noch TTL als Sicherheitsnetz setzen, falls ein Löschvorgang übersehen wird.

7.4. Jitter hinzufügen, um gleichzeitiges Ablaufen zu vermeiden

Angenommen, Sie laden 10.000 Produkte gleichzeitig mit einer TTL von 10 Minuten. Sie verfallen alle gleichzeitig und die Datenbank bekommt einen Ansturm von Anfragen. Dies wird cache stampede genannt. Die Lösung besteht darin, der TTL einen zufälligen Offset zu addieren:

public void put(K key, V value) {
    long jitterSeconds = ThreadLocalRandom.current().nextLong(0, 60);
    Instant expiresAt = now.get().plus(ttl).plusSeconds(jitterSeconds);
    store.put(key, new Entry<>(value, expiresAt));
}

Mit Spring + Caffeine können Sie @Cacheable(..., sync = true) hinzufügen. Wenn mehrere Threads einen Key verpassen, wird nur ein Thread in die Datenbank aufgerufen, die anderen warten auf das Ergebnis.

8. Tests schreiben

Wir verwenden ein Fake-Repository, um die Anzahl der Datenbankzugriffe zu zählen, und eine steuerbare Uhr:

class ProductServiceTest {

    static class CountingRepository implements ProductRepository {
        final Map<Long, Product> data = new HashMap<>();
        int findCalls = 0;

        public Optional<Product> findById(long id) {
            findCalls++;
            return Optional.ofNullable(data.get(id));
        }

        public Product save(Product p) {
            data.put(p.id(), p);
            return p;
        }
    }

    AtomicReference<Instant> clock = new AtomicReference<>(Instant.parse("2026-01-01T00:00:00Z"));
    CountingRepository repository = new CountingRepository();
    ProductService service = new ProductService(
            repository, new TtlCache<>(Duration.ofMinutes(10), clock::get));

    @BeforeEach
    void setUp() {
        repository.save(new Product(1L, "Nón lá", new BigDecimal("50000")));
    }

    @Test
    void secondReadIsServedFromCache() {
        service.findById(1L);
        service.findById(1L);

        assertThat(repository.findCalls).isEqualTo(1);
    }

    @Test
    void entryExpiresAfterTtl() {
        service.findById(1L);
        clock.set(clock.get().plus(Duration.ofMinutes(11)));
        service.findById(1L);

        assertThat(repository.findCalls).isEqualTo(2);
    }

    @Test
    void updateEvictsStaleValue() {
        service.findById(1L);
        service.updatePrice(1L, new BigDecimal("45000"));

        assertThat(service.findById(1L))
                .get()
                .extracting(Product::price)
                .isEqualTo(new BigDecimal("45000"));
    }
}
  • secondReadIsServedFromCache: Der zweite Lesevorgang berührt nicht die Datenbank.
  • entryExpiresAfterTtl: Nachdem die Uhr um 10 Minuten vorgespult wurde, kommt es zu einem Cache Miss und die Datenbank wird wieder aufgerufen.
  • updateEvictsStaleValue: Nach einer Aktualisierung sieht der Leser den neuen Preis, nicht den alten Preis im Cache.

9. Häufige Fehler

Cache löschen vor dem Datenbankschreiben. Betrachten Sie dieses Szenario: Thread A löscht den Cache. Thread B liest, hat einen Miss, holt den alten Wert aus der Datenbank und schreibt ihn in den Cache. Danach schreibt Thread A den neuen Wert in die Datenbank. Das Ergebnis ist, dass der Cache den alten Wert für die restliche TTL hält. Schreiben Sie zuerst die Datenbank, löschen Sie dann den Cache.

Cache innerhalb einer nicht committeten Transaktion. Wenn Sie @CachePut in einer @Transactional Methode verwenden und die Transaktion später rückgängig gemacht wird, enthält der Cache Daten, die nicht in der Datenbank vorhanden sind. Platzieren Sie Cache-Operationen außerhalb der Transaktion oder verwenden Sie TransactionAwareCacheManagerProxy.

Lokaler Cache über mehrere Instanzen. Caffeine läuft in der JVM jeder Instanz. @CacheEvict auf Instanz A löscht nicht den Cache von Instanz B. Wenn Sie mehrere Instanzen ausführen und Konsistenz benötigen, verwenden Sie Redis oder akzeptieren Sie Veralterung mit kurzer TTL.

Cache enthält veränderbare Objekte. Caffeine gibt die gleiche Referenz zurück, die im Cache liegt. Wenn der Code das Objekt ändert, ändert er auch den Cache. Verwenden Sie record oder unveränderliche Objekte wie im Beispiel.

Hinweis: Cachen Sie nicht, wenn sich die Daten häufig ändern und strikte Konsistenz erforderlich ist, z. B. Kontostand oder verfügbare Plätze beim Buchen. An diesen Stellen ist direktes Datenbankauslesen mit geeigneter Sperre sicherer.

10. Fazit

Der Artikel hat zwei Hauptcaching-Strategien verglichen:

  • Cache-aside: Die Anwendung lädt selbst den Cache bei einem Miss und löscht den Cache nach dem Schreiben. Das ist die einfachste und fehlertoleranteste Methode, geeignet für die meisten Leseszenarien.
  • Write-through: Alle Schreibvorgänge aktualisieren synchron Datenbank und Cache, was sofort nach dem Schreiben frische Daten liefert.

Welche Strategie Sie auch wählen, Sie benötigen immer TTL als Sicherheitsnetz, sollten die Cache-Größe begrenzen und Jitter hinzufügen, um stampede zu vermeiden. Beginnen Sie mit @Cacheable + Caffeine. Wenn die Anwendung auf mehrere Instanzen skaliert wird, wechseln Sie zu Redis und haben Sie immer Tests, die beweisen, dass der Cache tatsächlich die Datenbankzugriffe reduziert.

system-designcachingspring-bootcaffeineredis