Bloom filter com Redis: prevenindo cache penetration em Spring Boot
1. Visão Geral
A penetração de cache ocorre quando o cliente continuamente solicita chaves que não existem. O cache não possui os dados, então todas as requisições caem diretamente no banco de dados. Este artigo explica por que o Bloom filter é uma proteção barata e eficaz para essa situação. Você também aprenderá como implementá-lo no Redis usando Redisson em uma aplicação Spring Boot 3.x.
Ao final, você saberá como escolher parâmetros para o filter, carregar dados iniciais, integrar o filter no fluxo de leitura e escrever testes para verificar seu comportamento.
2. O que é penetração de cache?
Suponha que você tenha um ProductService usando o padrão 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 fluxo funciona bem com dados reais. Agora veja o que acontece quando alguém envia milhares de requisições com id = -1, id = 999999999 ou ids aleatórios:
- Redis retorna
nullporque a chave não existe. - O código vai para o BD, que também não encontra nada.
- Nada é escrito no cache, então a próxima requisição repete exatamente o mesmo fluxo.
O resultado é que o cache se torna inútil e o BD carrega todo o tráfego. Isso pode ser um bug do lado do cliente, um crawler ou um ataque deliberado.
O primeiro remédio comum é cachear o valor nulo (null object) com um TTL curto. Isso funciona bem quando o conjunto de chaves ruins é pequeno. Falha quando o atacante usa ids aleatórios: cada id aparece apenas uma vez, e você precisa armazenar milhões de chaves vazias inúteis. É aqui que o Bloom filter entra em ação.
3. Como funciona o Bloom filter
O Bloom filter é uma estrutura de dados probabilística composta de dois componentes:
- Um array de
mbits, inicialmente todos 0. kfunções hash, cada uma mapeando um elemento para uma posição no array.
Suas duas operações principais são:
- add(x): calcula
khashes dexe define os bits correspondentes como 1. - contains(x): calcula
khashes dex. Se qualquer bit for 0,xdefinitivamente nunca foi adicionado. Se todos forem 1,xpode ter sido adicionado.
A propriedade mais importante é não há falsos negativos, apenas falsos positivos. Quando o filter responde “não existe”, você pode confiar absolutamente e retornar 404 imediatamente sem tocar no cache ou BD. Quando o filter responde “pode existir”, você segue o fluxo normal. Essa pequena taxa de erro é completamente aceitável.
O tamanho do filter é calculado a partir do número esperado de elementos n e da taxa de falso positivo desejada p:
m = -n * ln(p) / (ln 2)^2
k = (m / n) * ln 2
Por exemplo, com n = 1_000_000 produtos e p = 0.01 (1%):
m ≈ 9_585_059 bits ≈ 1.14 MB
k ≈ 7 funções hash
Apenas cerca de 1 MB de RAM no Redis é suficiente para proteger um milhão de ids. Esse número é muito menor do que armazenar os próprios ids em um Set.
4. Configurando o Redisson
Redisson fornece RBloomFilter, implementado usando um bitmap puro do Redis. Portanto, funciona em qualquer versão do Redis sem exigir módulos adicionais.
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.45.1</version>
</dependency>
Este starter lê automaticamente a configuração de conexão das propriedades spring.data.redis.*:
spring.data.redis.host=localhost
spring.data.redis.port=6379
Após isso, você pode injetar um bean RedissonClient em qualquer lugar da aplicação.
5. Inicializando o Bloom filter
Crie o filter como um bean para que toda a aplicação o compartilhe:
@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 já existe no Redis, mantém configuração antiga
log.info("Bloom filter {} já existe, size={} bits, k={}",
PRODUCT_FILTER, filter.getSize(), filter.getHashIterations());
}
return filter;
}
private static final Logger log = LoggerFactory.getLogger(BloomFilterConfig.class);
}
O código acima faz o seguinte:
getBloomFilter(...)apenas cria um objeto proxy e ainda não escreve nada no Redis.tryInit(1_000_000L, 0.01)calculam,kconforme a fórmula na seção 3, então salva a configuração em um hash auxiliar no Redis. O método retornafalsese o filter já foi inicializado antes, por exemplo, quando outra instância da aplicação já foi executada.getSize()egetHashIterations()informam quantos bits e quantas funções hash o filter está realmente usando.
Com esses parâmetros, o log será impresso assim:
Bloom filter bf:product:ids já existe, size=9585058 bits, k=7
Nota: Quando o filter já existe,
tryInitnão alterará a configuração. Se você mudarexpectedInsertionsno código sem excluir o filter antigo, a nova configuração será silenciosamente ignorada.
6. Carregando dados iniciais
Um filter vazio responderá “não existe” para todos os ids, mesmo os ids reais. Portanto, você deve carregar todos os ids existentes antes de ativar a verificação.
@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; // já foi carregado por outra instância
}
List<Long> ids = productRepository.findAllIds();
ids.forEach(productIdFilter::add);
log.info("Carregados {} ids no Bloom filter", ids.size());
}
private static final Logger log = LoggerFactory.getLogger(ProductFilterWarmUp.class);
}
Aqui, ProductRepository.findAllIds() apenas seleciona a coluna id, por exemplo @Query("select p.id from Product p") se você usar JPA. count() retorna o número de elementos estimado com base no número de bits ativados, portanto é suficientemente bom para verificar se o filter já possui dados.
Com milhões de registros, você deve ler por páginas (paginação ou Stream com tamanho de fetch) em vez de um List. Cada comando add é uma ida e volta ao Redis, então considere executar o warm-up em um job separado em vez de desacelerar a inicialização da aplicação.
7. Integrando no ProductService
Agora coloque o filter antes do cache no fluxo de leitura:
@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 não 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 aqui é um registro simples record Product(Long id, String name, BigDecimal price). Vamos analisar cada etapa:
contains(id)retornafalsesignifica que o id definitivamente não existe no sistema. A requisição é bloqueada imediatamente com o custo de um comando Redis, sem nenhuma consulta ao BD.- Se o filter retorna
true, a requisição continua o fluxo cache-aside como antes. Cerca de 1% dos ids inexistentes ainda passam devido a falsos positivos. O BD lida facilmente com essa pequena quantidade. - No
create, o novo id é adicionado ao filter. Se esquecer essa etapa, o produto recém-criado “desaparecerá” para o usuário.
Quanto à ordem de escrita, se add é executado dentro de uma transação mas o commit falha, o filter conterá um id inexistente. A consequência é apenas um falso positivo adicional, completamente inofensivo. O caso oposto é mais preocupante: se você adicionar apenas após o commit, haverá um pequeno período de tempo em que o produto já existe no BD, mas o filter ainda retorna “não existe”. Esse é um falso negativo que você mesmo criou. Portanto, priorize adicionar ao filter antes ou no mesmo momento em que você grava no BD.
8. Testando o comportamento do filter
Para testar em um Redis real, adicione Testcontainers:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.20.4</version>
<scope>test</scope>
</dependency>
O teste abaixo verifica as duas propriedades essenciais: nenhum falso negativo e a taxa de falsos positivos próxima ao 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);
}
}
O primeiro teste afirma que todo id adicionado é reconhecido. O segundo teste conta quantos ids nunca adicionados o filter ainda reporta como “pode existir”. Com p = 0.01, a taxa medida geralmente oscila em torno de 1%. Um limite de 2% ajuda a manter o teste estável.
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
9. Alternativa: comando Bloom nativo do Redis
Se você usar Redis Stack ou Redis 8+ (que já integra tipos de dados probabilísticos), pode usar diretamente os 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
A versão nativa tem algumas vantagens. Cada comando BF.EXISTS calcula o hash apenas uma vez no servidor. BF.MADD permite adicionar múltiplos elementos em um único comando. Além disso, o filter pode escalar automaticamente quando ultrapassa a capacidade. Por outro lado, RBloomFilter do Redisson funciona em Redis “vanilla”, inclusive em serviços gerenciados que não suportam módulos. Escolha conforme sua infraestrutura.
10. Erros comuns e quando não usar
Nota: O Bloom filter clássico não suporta deleção. Desativar um bit pode fazer “desaparecer” outro elemento que compartilha o mesmo bit, criando um falso negativo.
Alguns pontos para você lembrar:
-
Deletar dados: quando um produto é deletado, seu id permanece no filter. Isso é seguro porque apenas se torna um falso positivo. No entanto, se a taxa de exclusão for alta, o filter ficará “sujo” gradualmente. A solução é periodicamente reconstruir o filter em uma nova chave (por exemplo,
bf:product:ids:v2) e depois alternar usandoRENAME. Outra opção é usar Cuckoo filter (CF.*), uma estrutura que suporta deleção. -
Exceder a capacidade esperada: se houver realmente 5 milhões de elementos enquanto o filter foi declarado para 1 milhão, a taxa de falso positivo aumentará drasticamente. Monitore
count()comparado comgetExpectedInsertions()e reconstrua antes de atingir o limite. -
Esquecer de carregar dados ou carregar incompleto: um filter vazio ou com dados incompletos bloqueará até requisições válidas. Você pode usar uma feature flag para ativar a verificação apenas quando o warm-up estiver concluído.
-
Múltiplos caminhos de escrita: se dados são criados via job em lote, importação CSV ou outro serviço, todos esses caminhos precisam atualizar o filter. Uma forma mais segura é atualizar via CDC ou eventos.
-
Não use quando: o conjunto de chaves é pequeno (alguns milhares de elementos, um Redis Set é suficiente), as chaves não podem ser enumeradas antecipadamente (por exemplo, resultados de busca livre) ou o sistema não tolera latência de sincronização entre BD e filter.
Combinar Bloom filter com um cache null com TTL curto é uma defesa em profundidade razoável. O Bloom filter bloqueia a maioria dos ids ruins, enquanto o null cache lida com os poucos falsos positivos que são chamados novamente.
11. Conclusão
A penetração de cache torna o cache inútil quando as requisições visam chaves inexistentes. O Bloom filter resolve esse problema com custo muito baixo: cerca de 1 MB para um milhão de ids, um comando Redis por verificação, e garante nenhum falso negativo.
Neste artigo, você viu como calcular o tamanho do filter e inicializá-lo com RBloomFilter do Redisson. Você também carregou dados iniciais, colocou o filter antes do cache em ProductService e escreveu testes para verificar o comportamento com Testcontainers. Por fim, sempre planeje reconstruir o filter periodicamente e mantenha todos os caminhos de escrita de dados sincronizados com ele.