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 编写了测试来验证其行为。最后,记得定期重建过滤器并保持所有数据写入路径与它同步。