Redis Bloom 过滤器:Spring Boot 缓存穿透防护

1. 概述

缓存穿透发生在客户端持续请求不存在的 key 时。缓存中没有数据,所以每个请求都会落到数据库。本文解释了为什么 Bloom 过滤器是这种情况下一个成本低廉且高效的防线。你还将学会如何在 Spring Boot 3.x 应用中通过 Redisson 在 Redis 上实现它。

读完本文后,你将知道如何为过滤器选择参数、加载初始数据、将过滤器集成到读写流程中,以及编写测试来验证它的行为。

2. 缓存穿透是什么?

假设你有一个使用熟悉的 cache-aside 模式的 ProductService:

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); // 查询数据库
    fromDb.ifPresent(p -> bucket.set(p, Duration.ofMinutes(30)));
    return fromDb;
}

这个流程对真实数据来说没问题。现在让我们看看当有人发送成千上万个请求,使用 id = -1、id = 999999999 或随机 id 时会发生什么:

  • Redis 返回 null,因为没有这个 key。
  • 代码落到数据库,数据库也找不到。
  • 没有任何东西被写入缓存,所以下一个请求重复同样的流程。

结果是缓存变得毫无用处,数据库承载全部负载。这可能是客户端的一个 bug、一个爬虫,或一次有意的攻击。

第一个常见的解决方案是缓存空值(null object),设置一个很短的 TTL。这在坏 key 集合很小时有效。当攻击者使用随机 id 时它就失败了:每个 id 只出现一次,你必须存储数百万个无意义的空 key。这就是 Bloom 过滤器大显身手的时候。

3. Bloom 过滤器如何工作

Bloom 过滤器是一个概率数据结构,由两个组件组成:

  • 一个 m 位的数组,初始全为 0。
  • k 个哈希函数,每个函数将一个元素映射到数组中的一个位置。

它的两个主要操作如下:

  • add(x):计算 x 的 k 个哈希值,并将相应位设置为 1。
  • contains(x):计算 x 的 k 个哈希值。如果任何一个位为 0,x 肯定从未被添加过。如果全部为 1,x 可能已被添加过。

最重要的特性是没有假阴性,只有假阳性。当过滤器回答“没有”时,你可以绝对信任并立即返回 404,而无需接触缓存或数据库。当过滤器回答“可能有”时,你继续正常流程。这个小的错误率是完全可以接受的。

过滤器大小由预期元素数 n 和期望的假阳性率 p 计算:

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

例如,使用 n = 1_000_000 个产品和 p = 0.01(1%):

m ≈ 9_585_059 位 ≈ 1.14 MB
k ≈ 7 个哈希函数

在 Redis 上大约 1 MB RAM 就足以保护一百万个 id。这个数字远小于在 Set 中存储这些 id 本身。

4. 设置 Redisson

Redisson 提供 RBloomFilter,使用纯 Redis 位图实现。因此它可以在任何 Redis 版本上运行,无需额外的模块。

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

这个 starter 会自动从 spring.data.redis.* 属性读取连接配置:

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

完成这一步后,你可以将 RedissonClient bean 注入到应用中的任何地方。

5. 初始化 Bloom 过滤器

将过滤器创建为一个 bean,供整个应用共享:

@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) {
            // 过滤器已在 Redis 上存在,保持旧配置
            log.info("Bloom 过滤器 {} 已存在,大小={} 位,k={}",
                    PRODUCT_FILTER, filter.getSize(), filter.getHashIterations());
        }
        return filter;
    }

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

上面的代码做以下事情:

  • getBloomFilter(...) 只是创建一个代理对象,还没有向 Redis 写入任何内容。
  • tryInit(1_000_000L, 0.01) 根据第 3 节的公式计算 m 和 k,然后将配置存储到 Redis 上的一个辅助哈希表中。如果过滤器之前已被初始化(例如应用的另一个实例之前运行过),该方法返回 false。
  • getSize() 和 getHashIterations() 告诉你过滤器实际使用多少位和多少个哈希函数。

使用上述参数,日志会输出如下:

Bloom 过滤器 bf:product:ids 已存在,大小=9585058 位,k=7

注意: 当过滤器已存在时,tryInit 不会改变配置。如果你在代码中改变 expectedInsertions 但没有删除旧过滤器,新配置会被忽略且没有任何警告。

6. 加载初始数据

一个空过滤器会对所有 id 回答“没有”,包括真实存在的 id。因此你必须在启用检查之前加载所有现存的 id。

@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; // 已被另一个实例加载
        }
        List<Long> ids = productRepository.findAllIds();
        ids.forEach(productIdFilter::add);
        log.info("已向 Bloom 过滤器加载 {} 个 id", ids.size());
    }

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

其中,ProductRepository.findAllIds() 仅获取 id 列,例如 @Query("select p.id from Product p")(如果你使用 JPA)。count() 返回基于当前设置的位数的估计元素数量,所以它足以检查过滤器是否已有数据。

对于数百万条记录,你应该分页读取(使用 pagination 或带有 fetch size 的 Stream)而不是 List。每个 add 调用都是到 Redis 的一个往返,所以考虑用一个单独的 job 运行热启,而不是减慢应用启动。

7. 集成到 ProductService

现在将过滤器放在读流程中缓存之前:

@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(); // 肯定不存在
        }
        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 是一个简单的 record record Product(Long id, String name, BigDecimal price)。让我们逐步看:

  • contains(id) 返回 false 意味着 id 肯定不在系统中。请求立即被阻止,只花费一个 Redis 命令,不消耗任何数据库查询。
  • 如果过滤器返回 true,请求继续进行常规的 cache-aside 流程。大约 1% 不存在的 id 因为假阳性会漏过。数据库轻松处理这个小数量。
  • 在 create 中,新 id 被添加到过滤器。如果忘记这一步,新创建的产品对用户来说会“消失”。

关于写的顺序,如果 add 在事务中运行但提交失败,过滤器会包含一个不存在的 id。这只会造成额外的假阳性,完全无害。反过来的情况才需要担心:如果你只在提交后 add,会有一个短时间窗口,产品已在数据库中但过滤器仍然说“没有”。这是由你自己造成的假阴性。因此,在或与写入数据库同时将 id 添加到过滤器。

8. 测试过滤器的行为

要在真实 Redis 上测试,添加 Testcontainers:

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

下面的测试验证两个核心特性:没有假阴性,以及假阳性率接近配置值。

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

第一个测试断言所有已添加的 id 都被识别。第二个测试计数从未添加过但过滤器仍说“可能有”的 id 数。使用 p = 0.01,测量到的比率通常在 1% 左右波动。2% 的阈值帮助测试不会不稳定。

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

9. 替代方案:Redis 原生 Bloom 命令

如果你使用 Redis Stack 或 Redis 8 及以上(已内置概率数据结构),你可以直接使用 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

原生版本有几个优势。每个 BF.EXISTS 命令只在服务端计算一次哈希。BF.MADD 允许在一个命令中添加多个元素。另外,当超过容量时过滤器可以自动扩展。反过来,Redisson 的 RBloomFilter 可以在“纯” Redis 上运行,包括那些不支持模块的托管服务。根据你的基础设施选择。

10. 常见错误和何时不应该使用

注意: 经典 Bloom 过滤器不支持删除。关闭一个位可能会“丢失”共享该位的其他元素,造成假阴性。

有几点你需要记住:

  • 删除数据:当产品被删除时,它的 id 仍在过滤器中。这是安全的,因为它只会成为假阳性。然而,如果删除率高,过滤器会逐渐变“脏”。解决方案是定期构建一个新 key 中的过滤器(例如 bf:product:ids:v2),然后使用 RENAME 切换。另一个选择是使用 Cuckoo 过滤器(CF.*),一种支持删除的结构。
  • 超过预期容量:如果实际有 500 万个元素而过滤器声称 100 万,假阳性率会急剧上升。监视 count() 相对于 getExpectedInsertions() 并在接近限制前重建。
  • 忘记加载数据或加载不完整:一个空过滤器或数据不足的过滤器会阻止合法请求。你可以使用特性开关仅在热启完成时启用检查。
  • 多个写入数据的路径:如果数据通过批处理作业、CSV 导入或其他服务创建,那些路径都必须更新过滤器。更安全的方法是通过 CDC 或事件更新。
  • 何时不应该使用:key 集很小(数千个元素,一个 Redis Set 就够了)、key 无法提前列举(例如自由文本搜索结果),或系统无法接受数据库和过滤器之间的同步延迟。

将 Bloom 过滤器与短 TTL 的空值缓存结合是一个合理的多层防御。Bloom 过滤器阻止大部分垃圾 id,而 null 缓存处理少数被重复调用的假阳性。

11. 总结

当请求针对不存在的 key 时,缓存穿透会使缓存变得无用。Bloom 过滤器用极低的成本解决了这个问题:大约 1 MB 可保护一百万个 id,每次检查一个 Redis 命令,并保证没有假阴性。

在本文中,你学会了如何计算过滤器大小并使用 Redisson RBloomFilter 初始化它。你还加载了初始数据,在 ProductService 中将过滤器放在缓存前面,并用 Testcontainers 编写了测试来验证其行为。最后,记得定期重建过滤器并保持所有数据写入路径与它同步。

redisbloom-filtercachespring-bootredisson