Redis: Cache-Aside, Write-Through oder Write-Behind wählen

1. Übersicht

Redis vor der Datenbank einzubauen ist der schnellste Weg, um Latenz und Last im System zu reduzieren. Die Schwierigkeit liegt woanders: Du musst entscheiden, wie Du Daten speicherst, damit Cache und Datenbank nicht auseinanderlaufen. Es gibt drei populäre Strategien: Cache-Aside, Write-Through und Write-Behind.

Dieser Beitrag implementiert alle drei mit Spring Boot 3 und Redis. Danach vergleicht er sie nach Konsistenz, Performance und Datenverlustrisiko. Danach wirst Du wissen, welche Strategie für welche Datentypen geeignet ist.

2. Vorbereitung

2.1. Dependency

Wir verwenden Spring Data Redis, Spring Data JPA mit H2 als Beispieldatenbank und Testcontainers für Tests:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <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>
</dependencies>

Konfiguration für die Redis-Verbindung lokal:

spring.data.redis.host=localhost
spring.data.redis.port=6379

2.2. Entity, Repository und RedisTemplate

@Entity
public class Product {

    @Id
    @GeneratedValue
    private Long id;
    private String name;
    private BigDecimal price;

    protected Product() {
    }

    public Product(String name, BigDecimal price) {
        this.name = name;
        this.price = price;
    }

    // Getter und Setter
}

public interface ProductRepository extends JpaRepository<Product, Long> {
}

Spring Boot erstellt automatisch StringRedisTemplate. Um Product als JSON zu speichern, deklarieren wir ein zusätzliches RedisTemplate:

@Configuration
@EnableScheduling
public class RedisConfig {

    @Bean
    RedisTemplate<String, Product> productRedisTemplate(RedisConnectionFactory factory) {
        var template = new RedisTemplate<String, Product>();
        template.setConnectionFactory(factory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new Jackson2JsonRedisSerializer<>(Product.class));
        return template;
    }
}

Der Key hat die Form product:{id}, der Wert ist das JSON von Product. Die Annotation @EnableScheduling wird in dem Write-Behind-Abschnitt verwendet.

Alle drei Strategien müssen einen Code-Abschnitt nach dem Transaction-Commit ausführen. Wir fassen dies in einem Hilfscode zusammen:

public final class AfterCommit {

    public static void run(Runnable action) {
        if (!TransactionSynchronizationManager.isSynchronizationActive()) {
            action.run();
            return;
        }
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                action.run();
            }
        });
    }
}

Wenn wir uns in einer Transaction befinden, läuft action nur bei erfolgreichem Commit. Bei Rollback wird der Cache nicht berührt.

3. Cache-Aside (Lazy Loading)

Bei Cache-Aside verwaltet die Anwendung den Cache selbst:

  • Lesen: Erst in Redis suchen. Bei Miss aus der Datenbank lesen und das Ergebnis mit TTL in Redis speichern.
  • Schreiben: Datenbank aktualisieren, dann den Key in Redis löschen.
@Service
public class ProductCacheAsideService {

    private static final Duration TTL = Duration.ofMinutes(10);

    private final ProductRepository repository;
    private final RedisTemplate<String, Product> redis;

    public ProductCacheAsideService(ProductRepository repository,
                                    RedisTemplate<String, Product> redis) {
        this.repository = repository;
        this.redis = redis;
    }

    public Optional<Product> findById(Long id) {
        Product cached = redis.opsForValue().get(key(id));
        if (cached != null) {
            return Optional.of(cached);
        }
        Optional<Product> fromDb = repository.findById(id);
        fromDb.ifPresent(p -> redis.opsForValue().set(key(id), p, TTL));
        return fromDb;
    }

    @Transactional
    public Product updatePrice(Long id, BigDecimal newPrice) {
        Product product = repository.findById(id).orElseThrow();
        product.setPrice(newPrice);
        AfterCommit.run(() -> redis.delete(key(id)));
        return product;
    }

    static String key(Long id) {
        return "product:" + id;
    }
}

Das Verhalten des obigen Codes:

  • Der erste Aufruf von findById(1L) liest aus der Datenbank und erstellt den Key product:1 mit TTL von 10 Minuten.
  • Nachfolgende Aufrufe innerhalb dieser 10 Minuten geben Werte aus Redis zurück, ohne die Datenbank zu berühren.
  • updatePrice modifiziert die Entity, Dirty Checking von JPA schreibt beim Commit in die Datenbank, dann wird der Key gelöscht. Der nächste Lesezugriff lädt den neuen Wert neu.

Überprüfung mit redis-cli:

redis-cli GET product:1
redis-cli TTL product:1
"{\"id\":1,\"name\":\"Mechanische Tastatur\",\"price\":1200000.00}"
(integer) 587

Hinweis: Beim Schreiben den Key löschen statt einen neuen Wert mit set zu schreiben. Angenommen zwei Anfragen aktualisieren gleichzeitig. Wenn beide set aufrufen, kann die Schreibreihenfolge in Redis der Commit-Reihenfolge in der Datenbank entgegengesetzt sein, und der Cache behält den alten Wert auf unbegrenzte Zeit. Beim Löschen des Keys wird bei der nächsten Lesung immer der neueste Wert geholt. Außerdem muss gelöscht nach dem Commit erfolgen. Wenn vorher gelöscht wird, könnte eine zwischenzeitliche Leseanfrage den alten Wert erneut in den Cache laden.

Selbst wenn richtig gemacht, gibt es in Cache-Aside noch ein kleines Race-Fenster: Eine Leseanfrage könnte alte Daten aus der Datenbank lesen und in den Cache schreiben, nachdem der Key gerade gelöscht wurde. TTL ist das letzte Sicherheitsnetz für diesen Fall. Verwende niemals Cache ohne TTL einzustellen.

Für einfache Daten kannst Du @Cacheable und @CacheEvict von Spring Cache verwenden, um das gleiche Ergebnis mit weniger Code zu erreichen. Ihr Wesen ist immer noch Cache-Aside.

4. Write-Through

Bei Write-Through aktualisiert jeder Schreibzugriff sowohl Datenbank als auch Cache synchron. Die Anfrage kehrt erst zurück, wenn beide fertig sind. Die Leseoperation ist immer noch wie Cache-Aside, aber die Hit-Rate ist höher, da die gerade geschriebenen Daten bereits in Redis vorhanden sind.

@Service
public class ProductWriteThroughService {

    private static final Duration TTL = Duration.ofMinutes(30);

    private final ProductRepository repository;
    private final RedisTemplate<String, Product> redis;

    public ProductWriteThroughService(ProductRepository repository,
                                      RedisTemplate<String, Product> redis) {
        this.repository = repository;
        this.redis = redis;
    }

    @Transactional
    public Product save(Product product) {
        Product saved = repository.saveAndFlush(product);
        AfterCommit.run(() -> redis.opsForValue()
                .set(ProductCacheAsideService.key(saved.getId()), saved, TTL));
        return saved;
    }
}

Die Zeile saveAndFlush schreibt sofort in die Datenbank, um die id zu erhalten und Constraint-Fehler früh zu erkennen. Nach dem Commit wird der Datensatz in Redis geschrieben. Wenn ein gerade erstelltes Produkt erneut gelesen wird, ist das Ergebnis ein Cache-Hit beim ersten Mal.

Vorteile von Write-Through:

  • Der Cache ist immer “warm” mit gerade geschriebenen Daten, es gibt keinen initialen Cache-Miss.
  • Geeignet für Daten, die normalerweise sofort nach dem Schreiben gelesen werden, wie z. B. Customer-Profile oder Warenkörbe.

Nachteile:

  • Jeder Schreibzugriff kostet einen zusätzlichen Round-Trip zu Redis, daher erhöht sich die Schreib-Latenz.
  • Der Cache wird mit Datensätzen gefüllt, die niemand liest. TTL hilft beim Aufräumen, kostet aber immer noch Speicher.
  • Es gibt immer noch Race-Conditions, wenn mehrere Writer gleichzeitig set aufrufen, wie in Abschnitt 3 erwähnt. Bei gleichzeitigen Updates benötigst Du Versionierung, z. B. mit @Version, und schreibst nur über, wenn die Version neuer ist, oder kehrst zur Methode des Key-Löschens zurück.

Hinweis: Wenn Redis nach dem Datenbank-Commit ausfällt, fehlt dem Cache die neueste Version. Fange die Exception ab und lösche den Key als Fallback-Plan. Lass die Anfrage nicht fehlschlagen, wenn die Datenbank bereits erfolgreich geschrieben hat.

5. Write-Behind (Write-Back)

Bei Write-Behind schreibt die Anwendung nur in Redis und kehrt sofort zurück. Ein Hintergrund-Prozess sammelt Änderungen und schreibt sie in Batches in die Datenbank. Die Datenbank liegt nicht mehr auf dem Pfad der Anfrage, daher sind Schreibzugriffe sehr schnell. Im Gegenzug wird Redis vorübergehend zum primären Datenspeicher.

@Service
public class ProductWriteBehindService {

    private static final String DIRTY_SET = "products:dirty";
    private static final Logger log = LoggerFactory.getLogger(ProductWriteBehindService.class);

    private final ProductRepository repository;
    private final RedisTemplate<String, Product> redis;
    private final StringRedisTemplate stringRedis;

    public ProductWriteBehindService(ProductRepository repository,
                                     RedisTemplate<String, Product> redis,
                                     StringRedisTemplate stringRedis) {
        this.repository = repository;
        this.redis = redis;
        this.stringRedis = stringRedis;
    }

    public void updatePrice(Long id, BigDecimal newPrice) {
        String key = ProductCacheAsideService.key(id);
        Product product = Optional.ofNullable(redis.opsForValue().get(key))
                .or(() -> repository.findById(id))
                .orElseThrow();
        product.setPrice(newPrice);
        redis.opsForValue().set(key, product);              // kein TTL
        stringRedis.opsForSet().add(DIRTY_SET, id.toString());
    }

    @Scheduled(fixedDelay = 5000)
    public void flush() {
        String id;
        while ((id = stringRedis.opsForSet().pop(DIRTY_SET)) != null) {
            Product product = redis.opsForValue().get(ProductCacheAsideService.key(Long.valueOf(id)));
            if (product == null) {
                continue;
            }
            try {
                repository.save(product);
            } catch (RuntimeException ex) {
                log.warn("Flush fehlgeschlagen für Product {}, Wiederholung später", id, ex);
                stringRedis.opsForSet().add(DIRTY_SET, id);
                break;
            }
        }
    }
}

Der Ablauf ist wie folgt:

  • updatePrice aktualisiert den Datensatz in Redis und fügt die id zum Set products:dirty hinzu. Die Anfrage ist in wenigen Millisekunden abgeschlossen.
  • Alle 5 Sekunden ruft flush() jede id mit SPOP ab und schreibt den entsprechenden Datensatz in die Datenbank.
  • Wenn ein Produkt 100 Mal in 5 Sekunden aktualisiert wird, erhält die Datenbank nur einen Schreibzugriff. Das ist der größte Vorteil von Write-Behind.
  • Wenn das Schreiben fehlschlägt, wird die id zum Set zurückgelegt, um es später erneut zu versuchen.

Dies ist das riskanteste Muster der drei Strategien. Es gibt mehrere Punkte, die zwingend zu handhaben sind:

  • Datenverlust wenn Redis ausfällt. Du musst AOF mit appendfsync everysec oder always aktivieren und Redis mit Replikation laufen lassen. Dennoch können etwa 1 Sekunde Daten verloren gehen.

  • Eviction. Dirty Keys haben keine TTL. Stelle maxmemory-policy auf noeviction oder volatile-*, sonst könnte Redis Daten löschen, die noch nicht geflüsht wurden.

  • Mehrere Instanzen. Dank der Atomarität von SPOP können zwei Instanzen nicht die gleiche id abrufen. Wenn eine Instanz jedoch sofort nach SPOP und vor save stirbt, bleibt diese Änderung in Redis stecken, bis beim nächsten Update verarbeitet. Für mehr Sicherheit verwendest Du Redis Streams mit Consumer Groups und XACK.

  • Abfragen in der Datenbank zeigen alte Daten. Berichte, andere Jobs oder andere Services, die direkt die Datenbank lesen, sind um ein paar Sekunden hinter der Realität.

Hinweis: Verwende nicht Write-Behind für Kontostände, Order-Datensätze, Zahlungen über PaymentService oder irgendwelche Daten, bei denen der Verlust eines Datensatzes ein Notfall ist. Es passt zu Ansichten, Likes, Leseposition, Last-Seen: Daten, die sehr häufig geschrieben werden und einen kleinen Verlust akzeptieren.

6. Test des Cache-Aside-Verhaltens

Der Test unten verwendet echtes Redis, das in Testcontainers läuft. @ServiceConnection konfiguriert automatisch Host und Port für Spring Boot:

@SpringBootTest
@Testcontainers
class ProductCacheAsideServiceTest {

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

    @Autowired
    ProductCacheAsideService service;

    @Autowired
    ProductRepository repository;

    @Autowired
    RedisTemplate<String, Product> redis;

    @Test
    void shouldCacheOnReadAndEvictAfterUpdate() {
        Product saved = repository.save(new Product("Mechanische Tastatur", new BigDecimal("1200000")));
        String key = ProductCacheAsideService.key(saved.getId());

        service.findById(saved.getId());
        assertThat(redis.hasKey(key)).isTrue();

        service.updatePrice(saved.getId(), new BigDecimal("990000"));
        assertThat(redis.hasKey(key)).isFalse();

        Product reloaded = service.findById(saved.getId()).orElseThrow();
        assertThat(reloaded.getPrice()).isEqualByComparingTo("990000");
        assertThat(redis.hasKey(key)).isTrue();
    }
}

Der Test überprüft den vollständigen Lebenszyklus des Keys: wird beim Lesen erstellt, nach dem Update gelöscht, dann mit dem neuen Wert wieder geladen. Die Test-Klasse ist nicht mit @Transactional versehen. Wenn sie versehen wäre, würde die Transaktion des Tests zurückgerollt, der afterCommit-Callback würde nie laufen, und die zweite Assertion würde fehlschlagen.

7. Vergleich und Auswahl

Kriterium Cache-Aside Write-Through Write-Behind
Latenz beim ersten Lesen Hoch (Miss) Niedrig Niedrig
Schreib-Latenz Niedrig Mittel (schreiben an 2 Orte) Sehr niedrig
Konsistenz mit DB Gut (TTL als Sicherheitsnetz) Gut Eventual, verzögert um Sekunden
Datenverlustrisiko Nein Nein Ja
Komplexität Niedrig Mittel Hoch
Schreib-Last-Reduktion für DB Nein Nein Ja (Batching)

Einige Hinweise bei der Auswahl:

  • Standardmäßig sollte man Cache-Aside verwenden. Es ist einfach, fehlerresistent (wenn Redis ausfällt, können wir immer noch von der Datenbank lesen) und ausreichend für die meisten leseviel-schreibwenig-Daten wie Produktkataloge oder Konfigurationen.

  • Wähle Write-Through, wenn Daten normalerweise sofort nach dem Schreiben gelesen werden und Du den initialen Miss vermeiden möchtest. In der Praxis verwenden viele Systeme eine Kombination: Schreiben wie Write-Through, Lesen wie Cache-Aside.

  • Wähle nur Write-Behind, wenn die Datenbank wirklich der Schreib-Engpass ist und Daten Verlust akzeptieren. Wenn Du nicht mit Messungen belegen kannst, dass die Datenbank ein Engpass ist, zahle nicht diese Komplexität.

  • Du musst dich nicht für eine Strategie für das gesamte System entscheiden. Jeder Datentyp kann eine andere Strategie verwenden.

Einige häufige Fehler in allen drei Strategien:

  • Cache Stampede. Ein heißer Key läuft ab, tausende Anfragen verpassen gleichzeitig und überlasten die Datenbank. Kann mit zufälligem TTL-Jitter oder per Lock mit SET key value NX PX gelöst werden, damit nur eine Anfrage neu lädt.

  • Cache Penetration. Anfragen fragen nach einer nicht existierenden id, daher immer ein Miss. Cache auch leere Ergebnisse mit kurzer TTL.

  • Klassenstruktur ändern und Cache nicht aufräumen. Altes JSON kann nicht in die neue Klasse deserialisiert werden. Füge eine Version zum Key hinzu, z. B. product:v2:{id}.

8. Fazit

Der Beitrag hat drei Cache-Strategien mit Redis und Spring Boot 3 implementiert. Cache-Aside lädt Daten bei Bedarf und löscht Schlüssel nach dem Commit. Write-Through synchronisiert beide Orte beim Schreiben. Write-Behind tauscht Datenhaltbarkeit gegen hohe Schreibgeschwindigkeit aus.

Kurze Faustregel: Starten Sie mit Cache-Aside und stellen Sie immer TTL ein. Upgrade zu Write-Through, wenn Sie einen warmen Cache benötigen. Verwende Write-Behind nur für Daten, die Verlust akzeptieren, zusammen mit AOF, geeigneter Eviction-Policy und klarem Retry-Mechanismus.

rediscachespring-bootjavaperformance