Bloom filter con Redis: contra penetración de caché en Spring Boot

1. Descripción general

La penetración de caché ocurre cuando el cliente solicita continuamente claves que no existen. La caché no tiene datos, por lo que cada solicitud cae en la base de datos. Este artículo explica por qué el filtro Bloom es un escudo barato y efectivo para este escenario. También aprenderás cómo implementarlo en Redis mediante Redisson en una aplicación Spring Boot 3.x.

Al terminar, sabrás cómo elegir parámetros para el filtro, cargar datos iniciales, integrar el filtro en el flujo de lectura y escribir pruebas que verifiquen su comportamiento.

2. ¿Qué es la penetración de caché?

Supongamos que tienes un ProductService que usa el patrón cache-aside familiar:

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); // consulta BD
    fromDb.ifPresent(p -> bucket.set(p, Duration.ofMinutes(30)));
    return fromDb;
}

Este flujo funciona bien con datos reales. Ahora veamos qué sucede cuando alguien envía miles de solicitudes con id = -1, id = 999999999 o IDs aleatorios:

  • Redis devuelve null porque no existe la clave.
  • El código baja a la BD, y la BD tampoco la encuentra.
  • Nada se escribe en la caché, por lo que la siguiente solicitud repite exactamente el proceso anterior.

El resultado es que la caché se vuelve inútil y la BD soporta toda la carga. Esto podría ser un error del cliente, un rastreador, o un ataque deliberado.

La primera solución común es cachear el valor nulo (null object) con un TTL corto. Esto funciona bien cuando el conjunto de claves malas es pequeño. Falla cuando el atacante usa IDs aleatorios: cada ID aparece solo una vez, y tienes que almacenar millones de claves vacías sin sentido. Este es el momento en que el filtro Bloom hace su magia.

3. ¿Cómo funciona el filtro Bloom?

El filtro Bloom es una estructura de datos probabilística que consta de dos componentes:

  • Un array de m bits, inicialmente todos en 0.
  • k funciones hash, cada una mapea un elemento a una posición en el array.

Sus dos operaciones principales son:

  • add(x): calcula k hashes de x e enciende los bits correspondientes a 1.
  • contains(x): calcula k hashes de x. Si algún bit es 0, x definitivamente nunca se añadió. Si todos son 1, x probablemente se añadió.

La propiedad más importante es que no hay falsos negativos, solo falsos positivos. Cuando el filtro responde “no existe”, puedes estar completamente seguro y devolver 404 inmediatamente sin necesidad de tocar la caché ni la BD. Cuando el filtro responde “podría existir”, continúas con el flujo normal. Esta pequeña tasa de error es completamente aceptable.

El tamaño del filtro se calcula a partir del número esperado de elementos n y la tasa de falso positivo deseada p:

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

Por ejemplo, con n = 1_000_000 productos y p = 0.01 (1%):

m ≈ 9_585_059 bits ≈ 1.14 MB
k ≈ 7 funciones hash

Solo unos 1 MB de RAM en Redis son suficientes para proteger un millón de IDs. Esta cifra es mucho menor que almacenar los IDs mismos en un Set.

4. Configurar Redisson

Redisson proporciona RBloomFilter, implementado usando bitmap puro de Redis. Por lo tanto, funciona en cualquier versión de Redis sin necesidad de módulos adicionales.

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

El starter lee automáticamente la configuración de conexión desde las propiedades spring.data.redis.*:

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

Después de esto, puedes inyectar un bean RedissonClient en cualquier parte de la aplicación.

5. Inicializar el filtro Bloom

Crea el filtro como un bean para que toda la aplicación lo comparta:

@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) {
            // El filtro ya existe en Redis, mantén la configuración anterior
            log.info("Bloom filter {} ya existe, tamaño={} bits, k={}",
                    PRODUCT_FILTER, filter.getSize(), filter.getHashIterations());
        }
        return filter;
    }

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

El código anterior hace lo siguiente:

  • getBloomFilter(...) solo crea un objeto proxy y no escribe nada en Redis todavía.
  • tryInit(1_000_000L, 0.01) calcula m, k según la fórmula en la sección 3, luego guarda la configuración en un hash auxiliar en Redis. El método devuelve false si el filtro ya fue inicializado antes, por ejemplo cuando otra instancia de la aplicación se ejecutó primero.
  • getSize() y getHashIterations() te muestran cuántos bits y cuántas funciones hash está utilizando realmente el filtro.

Con los parámetros anteriores, el log imprimirá algo como:

Bloom filter bf:product:ids ya existe, tamaño=9585058 bits, k=7

Nota: Cuando el filtro ya existe, tryInit no cambiará la configuración. Si cambias expectedInsertions en el código pero no eliminas el filtro antiguo, la nueva configuración se ignorará sin advertencia.

6. Cargar datos iniciales

Un filtro vacío responderá “no existe” para cualquier ID, incluso los reales. Por lo tanto, debes cargar todos los IDs existentes antes de habilitar la verificación.

@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; // ya cargado por otra instancia
        }
        List<Long> ids = productRepository.findAllIds();
        ids.forEach(productIdFilter::add);
        log.info("Se cargaron {} IDs en el filtro Bloom", ids.size());
    }

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

Aquí, ProductRepository.findAllIds() solo trae la columna id, por ejemplo @Query("select p.id from Product p") si usas JPA. count() devuelve una estimación del número de elementos basada en los bits encendidos, así que es suficientemente bueno para verificar si el filtro ya tiene datos.

Con millones de registros, deberías leer en páginas (pagination o Stream con fetch size) en lugar de una List. Cada comando add es un viaje redondo a Redis, así que considera ejecutar el warm-up como un job separado en lugar de ralentizar el inicio de la aplicación.

7. Integrar en ProductService

Ahora coloca el filtro antes de la caché en el flujo de lectura:

@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(); // definitivamente no existe
        }
        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 aquí es un record simple record Product(Long id, String name, BigDecimal price). Veamos cada paso:

  • Si contains(id) devuelve false significa que el ID definitivamente no existe en el sistema. La solicitud se bloquea inmediatamente con el costo de un comando Redis, sin gastar ninguna consulta a la BD.
  • Si el filtro devuelve true, la solicitud continúa con el flujo cache-aside como antes. Alrededor del 1% de IDs inexistentes aún pasan debido a falsos positivos. La BD puede manejar fácilmente esta pequeña cantidad.
  • En create, el nuevo ID se añade al filtro. Si olvidas este paso, el producto recién creado “desaparecerá” para el usuario.

Respecto al orden de escritura, si add se ejecuta dentro de una transacción pero el commit falla, el filtro contendrá un ID inexistente. La consecuencia es solo un falso positivo adicional, completamente inofensivo. El caso opuesto es preocupante: si solo haces add después del commit, habrá un breve período en el que el producto existe en la BD pero el filtro aún devuelve “no existe”. Ese es un falso negativo que has creado tú mismo. Por lo tanto, prioriza agregar al filtro antes o al mismo tiempo que escribes en la BD.

8. Probar el comportamiento del filtro

Para probar con Redis real, añade Testcontainers:

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

La siguiente prueba verifica dos propiedades esenciales: sin falsos negativos, y la tasa de falsos positivos cerca del valor configurado.

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

La primera prueba afirma que todos los IDs añadidos son reconocidos. La segunda prueba cuenta los IDs nunca añadidos que el filtro aún reporta como “posiblemente existe”. Con p = 0.01, la tasa medida típicamente fluctúa alrededor del 1%. El umbral del 2% ayuda a que la prueba no sea inestable.

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

9. Alternativa: comandos Bloom nativos de Redis

Si usas Redis Stack o Redis 8 o superior (que ya integran tipos de datos probabilísticos), puedes usar directamente los comandos BF.*:

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

La versión nativa tiene varias ventajas. Cada comando BF.EXISTS calcula el hash una sola vez en el servidor. BF.MADD permite añadir múltiples elementos en un solo comando. Además, el filtro puede escalar automáticamente cuando excede su capacidad. Por el contrario, RBloomFilter de Redisson funciona en Redis “puro”, incluso en servicios gestionados que no soportan módulos. Elige según tu infraestructura actual.

10. Errores comunes y cuándo no usar

Nota: El filtro Bloom clásico no soporta eliminación. Apagar un bit podría “perder” otro elemento que comparte ese bit, creando un falso negativo.

Algunos puntos a recordar:

  • Eliminar datos: cuando se elimina un producto, su ID permanece en el filtro. Esto es seguro porque solo se convierte en un falso positivo. Sin embargo, si la tasa de eliminación es alta, el filtro se “ensuciará” gradualmente. La solución es reconstruir periódicamente el filtro en una nueva clave (por ejemplo bf:product:ids:v2) y luego cambiar usando RENAME. Otra opción es usar Cuckoo filter (CF.*), una estructura que soporta eliminación.
  • Exceder la capacidad esperada: si en realidad hay 5 millones de elementos mientras el filtro se declaró para 1 millón, la tasa de falsos positivos aumentará dramáticamente. Monitorea count() frente a getExpectedInsertions() y reconstruye antes de alcanzar el límite.
  • Olvidar cargar o cargar incompleto: un filtro vacío o incompleto bloqueará incluso solicitudes válidas. Puedes usar un feature flag para habilitar la verificación solo cuando el warm-up se complete.
  • Múltiples caminos de escritura: si los datos se crean mediante batch jobs, importación CSV o servicios externos, todas esas rutas deben actualizar el filtro. Una forma más segura es actualizar mediante CDC o eventos.
  • No usar cuando: el conjunto de claves es pequeño (algunos miles de elementos, un Redis Set es suficiente), las claves no se pueden enumerar con anticipación (por ejemplo, resultados de búsqueda libre), o el sistema no tolera retrasos de sincronización entre la BD y el filtro.

Combinar Bloom filter con null cache de TTL corto es una defensa de múltiples capas razonable. El filtro Bloom bloquea la mayoría de los IDs basura, mientras que el null cache maneja el pequeño número de falsos positivos que se solicitan repetidamente.

11. Conclusión

La penetración de caché vuelve la caché inútil cuando las solicitudes apuntan a claves inexistentes. El filtro Bloom resuelve esto con un costo muy bajo: alrededor de 1 MB para un millón de IDs, un comando Redis por cada verificación, y garantiza sin falsos negativos.

En este artículo, has visto cómo calcular el tamaño del filtro e inicializarlo con Redisson RBloomFilter. También cargaste datos iniciales, colocaste el filtro antes de la caché en ProductService, y escribiste pruebas que verifican el comportamiento con Testcontainers. Finalmente, siempre planifica reconstruir el filtro periódicamente y mantén todas las rutas de escritura de datos sincronizadas con él.

redisbloom-filtercachespring-bootredisson