Cache und Datenbank mit Redis konsistent halten

1. Übersicht

Redis vor der Datenbank einzusetzen hilft, die Last zu reduzieren und Lesezugriffe zu beschleunigen. Im Gegenzug haben Sie zwei Orte, an denen sich die gleichen Daten befinden, und sie können auseinanderdriften. Ein Benutzer ändert einen Produktpreis, aber die Detailseite zeigt noch 10 Minuten lang den alten Preis an. Das ist ein typischer Konsistenzfehler.

Dieser Beitrag verwendet das Cache-Aside-Muster: Beim Lesen wird zuerst aus dem Cache abgerufen, beim Schreiben wird die Datenbank aktualisiert und der Cache wird nach dem Transaktions-Commit gelöscht. Wir werden uns die verbleibenden Race Conditions ansehen und wie wir sie reduzieren können. Am Ende des Beitrags gibt es einen Integrationstest mit echtem Redis, der mit Testcontainers läuft.

2. Warum Cache und Datenbank auseinanderdriften

Datenbank und Redis teilen sich keine Transaktion. Es gibt keine Möglichkeit, in beide atomare Schreibzugriffe zu gewährleisten. Die Reihenfolge der Operationen und der Zeitpunkt der Operationen entscheiden darüber, ob die Daten auseinanderdriften oder nicht.

Die drei häufigsten Fehler sind:

  • Cache aktualisieren, anstatt ihn zu löschen. Zwei gleichzeitig schreibende Anfragen können Daten in der Datenbank in der Reihenfolge A→B schreiben, aber in den Cache in der Reihenfolge B→A. Der Cache behält den Wert von A für immer oder bis zum Ablauf der TTL.
  • Cache vor dem Commit löschen. Eine Leseanfrage kann zwischen dem Löschen des Cache und dem Commit eintreten. Diese Anfrage liest den alten Wert aus der Datenbank und lädt ihn wieder in den Cache.
  • Das Löschen des Cache schlägt fehl und niemand bemerkt es. Redis-Timeout, Exception werden geschluckt, und der Cache behält die alten Daten.

Der Rest des Beitrags befasst sich nacheinander mit jedem dieser Fehler.

3. Projektaufbau

Fügen Sie Dependencies für Redis, JPA und eine Datenbank zum Testen hinzu:

<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>

Redis-Verbindung konfigurieren:

spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.data.redis.timeout=500ms

Das kurze Timeout ist beabsichtigt. Wenn Redis langsam ist, möchten Sie, dass Anfragen schnell zur Datenbank zurückfallen, nicht dass sie einige Sekunden lang hängenbleiben.

Entity und Repository:

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

    public Long getId() { return id; }
    public String getName() { return name; }
    public BigDecimal getPrice() { return price; }
    public void setPrice(BigDecimal price) { this.price = price; }
}

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

Wir cachen die Entity nicht direkt, sondern einen unveränderlichen Record:

public record ProductView(Long id, String name, BigDecimal price) {
    static ProductView from(Product p) {
        return new ProductView(p.getId(), p.getName(), p.getPrice());
    }
}

Dies vermeidet Fehler bei der Serialisierung von Hibernate-Proxies. Es hält auch das Format im Cache unabhängig von der Tabellenstruktur.

4. Lesen mit Cache-Aside

@Service
public class ProductService {

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

    private final ProductRepository repository;
    private final StringRedisTemplate redis;
    private final ObjectMapper mapper;
    private final ApplicationEventPublisher events;

    public ProductService(ProductRepository repository, StringRedisTemplate redis,
                          ObjectMapper mapper, ApplicationEventPublisher events) {
        this.repository = repository;
        this.redis = redis;
        this.mapper = mapper;
        this.events = events;
    }

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

    public ProductView findById(Long id) {
        String cached = redis.opsForValue().get(key(id));
        if (cached != null) {
            return read(cached);
        }
        ProductView view = repository.findById(id)
                .map(ProductView::from)
                .orElseThrow(() -> new NoSuchElementException("Product " + id));
        redis.opsForValue().set(key(id), write(view), TTL);
        return view;
    }

    private ProductView read(String json) {
        try { return mapper.readValue(json, ProductView.class); }
        catch (JsonProcessingException e) { throw new IllegalStateException(e); }
    }

    private String write(ProductView view) {
        try { return mapper.writeValueAsString(view); }
        catch (JsonProcessingException e) { throw new IllegalStateException(e); }
    }
}

Der Verarbeitungsfluss ist wie folgt:

  • redis.opsForValue().get(...) versucht, aus dem Cache abzurufen. Falls vorhanden, wird es sofort zurückgegeben. Das ist ein Cache Hit.
  • Falls nicht vorhanden (Cache Miss), liest der Service aus der Datenbank und schreibt mit einer TTL von 10 Minuten in Redis.
  • Die TTL ist ein Sicherheitsnetz. Jeder Mechanismus in den folgenden Abschnitten kann fehlschlagen. Die TTL stellt sicher, dass falsche Daten nicht länger als 10 Minuten bestehen.

Bei dem ersten Aufruf mit id = 1 enthält Redis:

127.0.0.1:6379> GET product:1
"{\"id\":1,\"name\":\"Ban phim co\",\"price\":1200000}"
127.0.0.1:6379> TTL product:1
(integer) 598

5. Schreiben: Datenbank aktualisieren, dann Cache nach Commit löschen

5.1. Löschen anstatt aktualisieren

Wenn sich der Preis ändert, löschen wir den Key, anstatt den neuen Preis in den Cache zu schreiben. Der nächste Lesezugriff wird die Daten erneut aus der Datenbank laden, und die Datenbank ist die Quelle der Wahrheit. Löschen ist eine idempotente Operation: Zweimaliges Löschen führt zum gleichen Ergebnis. Die Reihenfolge zwischen den Löschbefehlen spielt auch keine Rolle. Dadurch verschwindet der Fehler beim Überschreiben mit abweichender Reihenfolge aus Abschnitt 2.

5.2. Auf Transaktions-Commit warten

Wenn Sie redis.delete(...) in einer mit @Transactional markierten Methode aufrufen, wird der Löschbefehl vor dem Commit ausgeführt. Dies ist ein mögliches Szenario:

T1 (Schreib)                     T2 (Lesen)
UPDATE price = 900 (noch kein Commit)
DEL product:1
                                 GET product:1 -> miss
                                 SELECT -> price = 1200 (alter Wert)
                                 SET product:1 = 1200
COMMIT
=> Cache behält 1200 bis TTL abläuft

Die Lösung besteht darin, ein Event zu publishen und es in der Phase AFTER_COMMIT zu verarbeiten:

public record ProductChangedEvent(Long productId) {}

Fügen Sie eine Schreibmethode zum ProductService hinzu:

@Transactional
public void updatePrice(Long id, BigDecimal newPrice) {
    Product product = repository.findById(id).orElseThrow();
    product.setPrice(newPrice);
    events.publishEvent(new ProductChangedEvent(id));
}

Der Listener ist verantwortlich für das Löschen des Cache:

@Component
public class ProductCacheEvictor {

    private static final Logger log = LoggerFactory.getLogger(ProductCacheEvictor.class);
    private final StringRedisTemplate redis;

    public ProductCacheEvictor(StringRedisTemplate redis) {
        this.redis = redis;
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onProductChanged(ProductChangedEvent event) {
        evict(ProductService.key(event.productId()));
    }

    void evict(String key) {
        try {
            redis.delete(key);
        } catch (RuntimeException e) {
            log.warn("Cache {} konnte nicht gelöscht werden, wird sich auf TTL verlassen", key, e);
        }
    }
}

Einige Punkte sind zu beachten:

  • @TransactionalEventListener(phase = AFTER_COMMIT) läuft nur, wenn der Transaktions-Commit erfolgreich ist. Wenn ein Rollback der Transaktion auftritt, wird der Cache nicht gelöscht. Das ist richtig, denn die Daten haben sich nicht geändert.
  • Ein Fehler beim Löschen des Cache zerstört die Schreibanfrage nicht. Die Datenbank hat bereits committed, das Werfen einer Exception an diesem Punkt würde den Client glauben machen, dass der Vorgang fehlgeschlagen ist.
  • Das Warning-Log hilft Ihnen, die Häufigkeit von Fehlern zu verfolgen. Unterdessen begrenzt die TTL die Zeit, die alte Daten bestehen.

Hinweis: Wenn publishEvent an einem Ort aufgerufen wird, an dem keine Transaktion vorhanden ist, wird @TransactionalEventListener das Event standardmäßig ignorieren und der Cache wird nie gelöscht. Stellen Sie sicher, dass die Schreibmethode @Transactional hat. Wenn nötig, setzen Sie fallbackExecution = true.

6. Verbleibende Race Conditions und Delayed Double Delete

Selbst wenn Sie nach dem Commit löschen, gibt es immer noch ein seltenes Szenario:

T2 (Lesen)                        T1 (Schreib)
GET product:1 -> miss
SELECT -> price = 1200
                                  UPDATE price = 900, COMMIT
                                  DEL product:1
SET product:1 = 1200   <- Schreib verzögert

T2 liest den alten Wert vor dem Commit von T1, schreibt aber in den Cache nach dem Löschen von T1. Dieses Szenario erfordert, dass T2 zum richtigen Zeitpunkt verlangsamt wird, z.B. durch eine GC-Pause oder Netzwerkstörungen. Es ist selten, aber möglich.

Ein einfacher Weg, dies zu mindern, ist das Delayed Double Delete: Löschen Sie noch einmal nach einer kurzen Verzögerung.

@Component
public class DelayedCacheEvictor {

    private final ProductCacheEvictor evictor;
    private final TaskScheduler scheduler;

    public DelayedCacheEvictor(ProductCacheEvictor evictor, TaskScheduler scheduler) {
        this.evictor = evictor;
        this.scheduler = scheduler;
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void scheduleSecondDelete(ProductChangedEvent event) {
        String key = ProductService.key(event.productId());
        scheduler.schedule(() -> evictor.evict(key),
                Instant.now().plusMillis(500));
    }
}

Spring Boot konfiguriert automatisch einen TaskScheduler, wenn Sie @EnableScheduling auf einer Konfigurationsklasse aktivieren. Das zweite Löschen nach 500 ms wird den alten Wert löschen, den T2 später geschrieben hat. Wählen Sie eine Verzögerung, die größer ist als Ihre typische Datenbanklesedauer.

Hinweis: Delayed Double Delete verengt nur das Fehlerfenster, eliminiert es nicht vollständig. Der geplante Task befindet sich im Speicher und geht verloren, wenn die Anwendung neu gestartet wird. Betrachten Sie es nicht als absolute Garantie.

7. Wenn Cache-Invalidierung garantiert erfolgreich sein muss

Bei Daten wie Kontostand oder Bestand kann es inakzeptabel sein, sich nur auf TTL zu verlassen. In diesem Fall gibt es zwei gängige Ansätze:

  • Transactional Outbox: Schreiben Sie eine cache_invalidation-Reihe in die Datenbank selbst, in der gleichen Transaktion wie die Geschäftsänderung. Ein periodischer Job liest diese Tabelle, löscht den Key in Redis und markiert die Reihe als erledigt nur wenn das Löschen erfolgreich ist. Da die Outbox-Reihe mit den Daten committed, geht das Cache-Invalidierungsereignis nie verloren.
  • Change Data Capture (CDC): Ein Prozess, der das Binlog/WAL der Datenbank liest und den entsprechenden Cache löscht. Dies trennt die Cache-Logik vollständig vom Geschäftscode, erfordert aber eine komplexere Infrastruktur.

Beide sind eine Form von Eventual Consistency. Sie garantieren, dass der Cache schließlich korrekt sein wird, aber nicht, dass er sofort korrekt ist.

8. Testen mit echtem Redis

Mock StringRedisTemplate kann Fehler beim Commit-Timing nicht erkennen. Testen Sie mit echtem Redis über Testcontainers:

<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>
@SpringBootTest
@Testcontainers
class ProductServiceIT {

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

    @Autowired ProductService productService;
    @Autowired ProductRepository repository;
    @Autowired StringRedisTemplate redis;

    @Test
    void updatePrice_evictsCache_andNextReadReturnsNewPrice() {
        Long id = repository.save(new Product("Ban phim co", new BigDecimal("1200000"))).getId();

        productService.findById(id); // Cache laden
        assertThat(redis.hasKey(ProductService.key(id))).isTrue();

        productService.updatePrice(id, new BigDecimal("900000"));

        assertThat(redis.hasKey(ProductService.key(id))).isFalse();
        assertThat(productService.findById(id).price())
                .isEqualByComparingTo("900000");
    }
}

Dieser Test überprüft drei Dinge:

  • @ServiceConnection(name = "redis") zeigt automatisch auf spring.data.redis.* zum Container, daher benötigen Sie keine zusätzliche Konfiguration.
  • Nach updatePrice ist der Key nicht mehr in Redis. Dies zeigt, dass der Listener AFTER_COMMIT ausgeführt wurde.
  • Der nächste Lesevorgang gibt den neuen Preis aus der Datenbank zurück.

Hinweis: Markieren Sie diese Testmethode nicht als @Transactional. Spring wird die Transaktion des Tests am Ende zurückrollen, daher wird AFTER_COMMIT nie ausgelöst und die Assertion schlägt fehl.

Ergebnis beim Ausführen:

[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0

9. Wann Sie diesen Ansatz nicht verwenden sollten

  • Daten, die starke Konsistenz erfordern, wie der Kontostand, der über die Genehmigung von Transaktionen entscheidet. Lesen Sie sie nicht aus dem Cache. Lesen Sie direkt aus der Datenbank mit geeigneten Sperren.
  • Daten mit mehr Schreibzugriffen als Lesezugriffen. Der Cache wird ständig gelöscht, daher ist die Hit Rate niedrig. Sie zahlen die Komplexität, gewinnen aber kaum etwas.
  • Keys, die aus mehreren Entitäten zusammengesetzt sind, z.B. eine Liste der Top-10-Produkte. Es ist schwierig, zu wissen, welche Keys gelöscht werden müssen, wenn sich ein Produkt ändert. Bei dieser Art von Key ist eine kurze TTL normalerweise einfacher und sicherer als präzise Invalidierung.
  • Mehrere Services schreiben in die gleiche Tabelle. Events in Ihrer Anwendung sehen nicht die Änderungen anderer Services. In diesem Fall ist CDC sinnvoller.

10. Fazit

Redis und eine Datenbank konsistent zu halten bedeutet nicht, sie immer perfekt synchron zu halten. Das Ziel ist es, das Drift-Fenster klein und begrenzt zu halten. Die grundlegende Formel besteht aus vier Schritten:

  1. Lesen mit Cache-Aside, immer mit TTL.
  2. In die Datenbank schreiben, dann den Key löschen, nicht aktualisieren.
  3. Nur nach Commit löschen, unter Verwendung von @TransactionalEventListener(phase = AFTER_COMMIT).
  4. Fügen Sie Delayed Double Delete, Outbox oder CDC hinzu, je nach Sensibilität der Daten.

Abschließend sollten Sie mit echtem Redis testen und @Transactional auf Test-Methoden nicht verwenden, da es das Commit-Verhalten verdeckt.

rediscachespring-bootconsistencyjava