Bloom Filter mit Redis: Abwehr von Cache Penetration in Spring Boot

1. Übersicht

Cache Penetration tritt auf, wenn der Client ständig Keys anfordert, die nicht existieren. Der Cache hat keine Daten, deshalb fällt jede Anfrage durch bis zur Datenbank. Dieser Artikel erklärt, warum Bloom Filter eine kostengünstige und effektive Abwehr für diese Situation bietet. Du erfährst auch, wie du ihn auf Redis mittels Redisson in einer Spring Boot 3.x-Anwendung einsetzt.

Nach dem Lesen wirst du wissen, wie du Parameter für den Filter wählst, Anfangsdaten lädt, den Filter in den Lese- und Schreib-Workflow integrierst und Tests schreibst, um das Verhalten zu überprüfen.

2. Was ist Cache Penetration?

Angenommen, du hast einen ProductService, der das bekannte Cache-Aside-Muster nutzt:

public Optional<Product> findById(long id) {
    RBucket<Product> bucket = redisson.getBucket("product:" + id, JsonJacksonCodec.INSTANCE);
    Product cached = bucket.get();
    if (cached != null) {
        return Optional.of(cached);
    }
    Optional<Product> fromDb = productRepository.findById(id); // DB-Abfrage
    fromDb.ifPresent(p -> bucket.set(p, Duration.ofMinutes(30)));
    return fromDb;
}

Dieser Ablauf funktioniert mit echten Daten. Aber schau, was passiert, wenn jemand Tausende von Anfragen mit id = -1, id = 999999999 oder zufälligen Ids sendet:

  • Redis gibt null zurück, weil der Key nicht existiert.
  • Der Code geht zur DB, die auch nichts findet.
  • Nichts wird in den Cache geschrieben, also wiederholt sich die nächste Anfrage exakt genauso.

Das Ergebnis ist, dass der Cache wertlos wird und die DB die ganze Last trägt. Dies könnte ein Bug im Client sein, ein Crawler oder ein gezielter Angriff.

Das erste übliche Mittel ist das Cachen von Nullwerten (Null Object) mit kurzer TTL. Das funktioniert gut, wenn die Menge schlechter Keys klein ist. Es versagt, wenn der Angreifer zufällige Ids nutzt: Jede Id erscheint nur einmal, und du müsstest Millionen sinnloser leerer Keys speichern. Hier glänzt der Bloom Filter.

3. Wie der Bloom Filter funktioniert

Der Bloom Filter ist eine probabilistische Datenstruktur aus zwei Komponenten:

  • Ein Bit-Array m, anfangs überall 0.
  • k Hash-Funktionen, die jedes Element auf eine Position im Array abbilden.

Die zwei Hauptoperationen sind:

  • add(x): Berechne k Hashes von x und setze die entsprechenden Bits auf 1.
  • contains(x): Berechne k Hashes von x. Wenn irgendein Bit 0 ist, ist x garantiert nicht hinzugefügt. Wenn alle 1 sind, könnte x hinzugefügt worden sein.

Die wichtigste Eigenschaft ist keine False Negatives, nur False Positives. Wenn der Filter “nicht vorhanden” antwortet, kannst du dem absolut vertrauen und ohne Cache oder DB sofort eine 404 zurückgeben. Wenn der Filter “möglicherweise vorhanden” antwortet, gehst du den normalen Workflow. Diese kleine Fehlerrate ist völlig akzeptabel.

Die Filtergröße wird aus der erwarteten Anzahl von Elementen n und der gewünschten False-Positive-Rate p berechnet:

m = -n * ln(p) / (ln 2)^2
k = (m / n) * ln 2

Beispiel mit n = 1_000_000 Produkten und p = 0.01 (1%):

m ≈ 9_585_059 Bits ≈ 1,14 MB
k ≈ 7 Hash-Funktionen

Gerade mal etwa 1 MB RAM auf Redis reicht aus, um eine Million Ids zu schützen. Das ist deutlich kleiner als die Ids selbst in einem Set zu speichern.

4. Redisson einrichten

Redisson bietet RBloomFilter, implementiert als reines Redis Bitmap. Es läuft daher auf jeder Redis-Version ohne zusätzliche Module.

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.45.1</version>
</dependency>

Der Starter liest die Verbindungskonfiguration automatisch aus den spring.data.redis.*-Properties:

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

Nach diesem Schritt kannst du einen RedissonClient-Bean überall in der Anwendung injizieren.

5. Bloom Filter initialisieren

Erstelle den Filter als Bean, damit die ganze Anwendung ihn gemeinsam nutzt:

@Configuration
public class BloomFilterConfig {

    public static final String PRODUCT_FILTER = "bf:product:ids";

    @Bean
    public RBloomFilter<Long> productIdFilter(RedissonClient redisson) {
        RBloomFilter<Long> filter = redisson.getBloomFilter(PRODUCT_FILTER);
        boolean created = filter.tryInit(1_000_000L, 0.01);
        if (!created) {
            // Filter existiert bereits auf Redis, ursprüngliche Konfiguration beibehalten
            log.info("Bloom Filter {} existiert bereits, Größe={} Bits, k={}",
                    PRODUCT_FILTER, filter.getSize(), filter.getHashIterations());
        }
        return filter;
    }

    private static final Logger log = LoggerFactory.getLogger(BloomFilterConfig.class);
}

Dieser Code macht folgendes:

  • getBloomFilter(...) erstellt nur ein Proxy-Objekt, schreibt noch nichts zu Redis.
  • tryInit(1_000_000L, 0.01) berechnet m und k nach der Formel aus Abschnitt 3, speichert die Konfiguration dann in einem auxiliary Hash auf Redis. Die Methode gibt false zurück, wenn der Filter bereits initialisiert wurde, etwa von einer anderen Instance der Anwendung.
  • getSize() und getHashIterations() zeigen dir, wie viele Bits und Hash-Funktionen der Filter tatsächlich nutzt.

Mit diesen Parametern gibt das Log etwa aus:

Bloom Filter bf:product:ids existiert bereits, Größe=9585058 Bits, k=7

Hinweis: Wenn der Filter bereits existiert, wird tryInit die Konfiguration nicht ändern. Wenn du expectedInsertions im Code änderst, aber den alten Filter nicht löschst, wird die neue Konfiguration stillschweigend ignoriert.

6. Anfangsdaten laden

Ein leerer Filter antwortet “nicht vorhanden” auf jede Id, auch auf gültige. Du musst alle existierenden Ids laden, bevor du die Überprüfung aktivierst.

@Component
public class ProductFilterWarmUp implements ApplicationRunner {

    private final ProductRepository productRepository;
    private final RBloomFilter<Long> productIdFilter;

    public ProductFilterWarmUp(ProductRepository productRepository,
                               RBloomFilter<Long> productIdFilter) {
        this.productRepository = productRepository;
        this.productIdFilter = productIdFilter;
    }

    @Override
    public void run(ApplicationArguments args) {
        if (productIdFilter.count() > 0) {
            return; // bereits von anderer Instance geladen
        }
        List<Long> ids = productRepository.findAllIds();
        ids.forEach(productIdFilter::add);
        log.info("Habe {} Ids in den Bloom Filter geladen", ids.size());
    }

    private static final Logger log = LoggerFactory.getLogger(ProductFilterWarmUp.class);
}

Hier sollte ProductRepository.findAllIds() nur die Id-Spalte abrufen, etwa mit @Query("select p.id from Product p") wenn du JPA nutzt. count() gibt eine geschätzte Anzahl basierend auf gesetzten Bits zurück, daher reicht es, um zu prüfen, ob der Filter bereits Daten hat.

Bei Millionen von Datensätzen solltest du seitenweise lesen (Pagination oder Stream mit Fetch-Größe) statt einer ganzen List. Jeder add-Aufruf ist ein Round-Trip zu Redis, daher solltest du das Warmup lieber als separaten Job ausführen statt den Applikationsstart zu verlangsamen.

7. In ProductService integrieren

Platziere den Filter vor dem Cache im Leseworkflow:

@Service
public class ProductService {

    private final RedissonClient redisson;
    private final RBloomFilter<Long> productIdFilter;
    private final ProductRepository productRepository;

    public ProductService(RedissonClient redisson,
                          RBloomFilter<Long> productIdFilter,
                          ProductRepository productRepository) {
        this.redisson = redisson;
        this.productIdFilter = productIdFilter;
        this.productRepository = productRepository;
    }

    public Optional<Product> findById(long id) {
        if (!productIdFilter.contains(id)) {
            return Optional.empty(); // garantiert nicht vorhanden
        }
        RBucket<Product> bucket = redisson.getBucket("product:" + id, JsonJacksonCodec.INSTANCE);
        Product cached = bucket.get();
        if (cached != null) {
            return Optional.of(cached);
        }
        Optional<Product> fromDb = productRepository.findById(id);
        fromDb.ifPresent(p -> bucket.set(p, Duration.ofMinutes(30)));
        return fromDb;
    }

    @Transactional
    public Product create(Product product) {
        Product saved = productRepository.save(product);
        productIdFilter.add(saved.id());
        return saved;
    }
}

Product hier ist ein einfacher Record: record Product(Long id, String name, BigDecimal price). Lass mich die Schritte durchgehen:

  • contains(id) gibt false zurück, wenn die Id garantiert nicht im System ist. Die Anfrage wird sofort mit nur einem Redis-Befehl abgelehnt, ohne die DB zu belasten.
  • Wenn der Filter true zurückgibt, geht die Anfrage weiter wie bisher im Cache-Aside-Muster. Etwa 1% nicht existierende Ids gelangen trotzdem durch wegen False Positives. Diese kleine Menge verarbeitet die DB problemlos.
  • In create wird die neue Id zum Filter hinzugefügt. Wenn du diesen Schritt vergisst, wird das neu erstellte Produkt für den Nutzer “verschwinden”.

Zur Reihenfolge des Schreibens: Wenn add innerhalb einer Transaktion läuft, aber das Commit fehlschlägt, wird der Filter eine nicht existierende Id enthalten. Das Ergebnis ist nur ein zusätzliches False Positive, völlig harmlos. Der umgekehrte Fall ist problematischer: Wenn du nur nach dem Commit zu add aufrufen, gibt es einen kurzen Zeitraum, in dem das Produkt in der DB existiert, der Filter aber noch “nicht vorhanden” antwortet. Das ist ein False Negative, das du selbst verursacht hast. Daher solltest du dem Filter die Id vor oder gleichzeitig mit der DB-Speicherung hinzufügen.

8. Das Verhalten des Filters testen

Um auf echter Redis zu testen, füge Testcontainers hinzu:

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>1.20.4</version>
    <scope>test</scope>
</dependency>

Dieser Test überprüft zwei Kernverhalten: keine False Negatives und die False-Positive-Rate liegt nahe am konfigurierten Wert.

@Testcontainers
class ProductBloomFilterTest {

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

    static RedissonClient client;
    static RBloomFilter<Long> filter;

    @BeforeAll
    static void setUp() {
        Config config = new Config();
        config.useSingleServer()
              .setAddress("redis://" + redis.getHost() + ":" + redis.getMappedPort(6379));
        client = Redisson.create(config);

        filter = client.getBloomFilter("test:product:ids");
        filter.tryInit(5_000L, 0.01);
        LongStream.rangeClosed(1, 5_000).forEach(filter::add);
    }

    @AfterAll
    static void tearDown() {
        client.shutdown();
    }

    @Test
    void givenAddedIds_whenContains_thenAlwaysTrue() {
        boolean allPresent = LongStream.rangeClosed(1, 5_000).allMatch(filter::contains);

        assertThat(allPresent).isTrue();
    }

    @Test
    void givenUnknownIds_whenContains_thenFalsePositiveRateIsLow() {
        long falsePositives = LongStream.rangeClosed(5_001, 10_000)
                .filter(filter::contains)
                .count();

        double rate = falsePositives / 5_000.0;
        assertThat(rate).isLessThan(0.02);
    }
}

Der erste Test bestätigt, dass alle hinzugefügten Ids erkannt werden. Der zweite zählt, wie viele nie hinzugefügte Ids der Filter fälschlicherweise als “möglicherweise vorhanden” meldet. Mit p = 0.01 schwankt die gemessene Rate üblicherweise um 1%. Ein Schwellwert von 2% verhindert, dass der Test flaky wird.

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

9. Alternative: native Bloom-Befehle von Redis

Wenn du Redis Stack oder Redis 8+ nutzt (mit integrierten probabilistischen Datentypen), kannst du direkt die BF.*-Befehle verwenden:

redis-cli BF.RESERVE bf:product:ids 0.01 1000000
redis-cli BF.MADD bf:product:ids 1 2 3
redis-cli BF.EXISTS bf:product:ids 2
redis-cli BF.EXISTS bf:product:ids 42
OK
1) (integer) 1
2) (integer) 1
3) (integer) 1
(integer) 1
(integer) 0

Die native Version hat ein paar Vorteile. Jeder BF.EXISTS-Befehl berechnet den Hash nur einmal auf der Server-Seite. BF.MADD erlaubt, mehrere Elemente in einem Befehl hinzuzufügen. Außerdem kann der Filter automatisch skalieren, wenn er die Kapazität überschreitet. Andererseits läuft RBloomFilter von Redisson auf “reinem” Redis, auch auf verwalteten Services ohne Module-Unterstützung. Wähle basierend auf deiner Infrastruktur.

10. Häufige Fehler und wann du den Filter nicht einsetzten solltest

Hinweis: Der klassische Bloom Filter unterstützt kein Löschen. Ein Bit auszuschalten könnte ein anderes Element verlieren lassen, das sich dieses Bit mit ihm teilt, was False Negatives erzeugt.

Einige Punkte, die du beachten solltest:

  • Datenlöschen: Wenn ein Produkt gelöscht wird, bleibt seine Id im Filter. Das ist sicher, da es nur zu einem False Positive wird. Trotzdem wird der Filter mit hoher Löschrate “schmutzig”. Abhilfe ist, den Filter regelmäßig neu zu bauen mit einem neuen Key (etwa bf:product:ids:v2) und dann umzuschalten mit RENAME. Eine Alternative ist der Cuckoo Filter (CF.*), der Löschen unterstützt.
  • Kapazität übersteigen: Wenn du tatsächlich 5 Millionen Elemente hast, aber der Filter nur für 1 Million konzipiert wurde, steigt die False-Positive-Rate stark. Beobachte count() versus getExpectedInsertions() und baue den Filter neu, bevor du ein Limit erreichst.
  • Warmup vergessen oder unvollständig: Ein leerer oder unvollständiger Filter wird auch gültige Anfragen blockieren. Nutze ein Feature Flag, um die Überprüfung erst zu aktivieren, wenn das Warmup fertig ist.
  • Mehrere Schreibpfade: Falls Daten über Batch-Jobs, CSV-Import oder andere Services erzeugt werden, müssen alle diese Pfade den Filter updaten. Sicherer ist, den Filter über CDC oder Events zu aktualisieren.
  • Nicht einsetzten für: kleine Key-Mengen (ein paar tausend, ein Redis Set reicht), Keys, die nicht vorher aufzählbar sind (etwa Suchergebnisse), oder Systeme, die keine Verzögerung zwischen DB und Filter tolerieren.

Die Kombination aus Bloom Filter und kurz-TTL Null-Caching bietet eine praktische, mehrschichtige Abwehr. Der Bloom Filter blockiert die Mehrheit der Müll-Ids, während Null-Cache die seltenen False Positives handhabt.

11. Fazit

Cache Penetration macht den Cache nutzlos, wenn Anfragen nicht existierende Keys abzielen. Der Bloom Filter löst das mit minimalen Kosten: etwa 1 MB für eine Million Ids, ein Redis-Befehl pro Überprüfung, und garantiert keine False Negatives.

In diesem Artikel hast du gesehen, wie du die Filtergröße berechnest und ihn mit Redisson RBloomFilter initialisierst. Du hast auch Anfangsdaten geladen, den Filter vor dem Cache in ProductService platziert und Tests mit Testcontainers geschrieben, um das Verhalten zu überprüfen. Zum Abschluss: Plane immer regelmäßige Neuerstellung des Filters ein und halte alle Datenschreibpfade synchron damit.

redisbloom-filtercachespring-bootredisson