缓存穿透是缓存架构中一种典型的异常访问模式。正常查询会先访问Redis,命中则直接返回;未命中再回源数据库,并把结果写入缓存。但如果请求的键在数据库中根本不存在,缓存永远不会产生有效值,每次请求都会穿过Redis直接打到数据库。攻击者可以利用这一特点,构造大量随机不存在的键,绕过缓存层对数据库进行持续冲击,轻则拖慢接口响应,重则导致数据库连接池耗尽、服务不可用。

缓存穿透的成因与常见应对方案
缓存穿透的本质是缓存与数据库之间存在空值约定缺失。对于数据库查询返回空的结果,很多业务代码并不会主动写入缓存,因为空值没有业务含义,写了也可能造成大量无意义的缓存条目。于是每一个不存在的键都会完整走一遍Redis查询、数据库查询的链路。当无效请求量大时,数据库就成了整个系统的薄弱点。
缓存空值是最直接的解决办法,也就是在数据库查询不到数据时,仍然向Redis写入一个带短过期时间的空值标记。这样后续相同键的请求至少能命中缓存,不会再打到数据库。但缓存空值的缺点也很明显:如果攻击者不断变化无效键,缓存空值只能覆盖已经请求过的键,新键依然会穿透。同时,大量空值条目会占用Redis内存,尤其是当无效键数量巨大时,会挤占正常业务数据的空间。
另一种常见方案是互斥锁或限流,只在第一个请求到达时回源数据库,其他请求等待结果。这种方式可以限制数据库的并发压力,但实现起来会引入锁等待、超时控制、锁粒度选择等复杂度。在高并发场景下,大量请求阻塞在锁上,响应延迟会明显上升。相比之下,布隆过滤器可以在请求进入缓存查询之前进行快速拦截,以很小的内存代价判断一个键是否可能存在,从源头过滤掉绝大多数无效请求,是处理缓存穿透非常有效的方案。
布隆过滤器的数据结构与误判率
布隆过滤器由一个长度为m的位数组和k个相互独立的哈希函数组成。初始时位数组所有位都是0。向过滤器添加一个元素时,用k个哈希函数分别计算该元素的哈希值,然后对m取模得到k个位置,把这k个位置全部置为1。查询一个元素是否存在时,同样计算这k个位置,如果其中任意一个位置是0,说明该元素一定没有被添加过;如果这k个位置全部是1,则说明该元素可能存在,也可能是因为其他元素的哈希置位导致误判。
这种结构决定了布隆过滤器是一种概率型数据结构:查询结果只会出现一定不存在和可能存在两种情况,不会出现漏判,但会出现误判。假设插入n个元素,位数组长度为m,哈希函数个数为k,某个位在一次哈希后仍然为0的概率是1 - 1/m,经过kn次哈希后仍为0的概率约为e^(-kn/m)。因此误判时k个位全部为1的概率为(1 - e^(-kn/m))^k。通过求导可以得出,当k = (m / n) * ln2时,误判率最低。
工程上通常会先确定预计写入的数据量n和可接受的误判率p,然后反推需要的位数组长度m和哈希函数个数k。计算公式为m = - n * ln p / (ln 2)^2,再代入k = (m / n) * ln2得到最优哈希次数。举个例子,如果预计写入1亿条数据,希望误判率控制在万分之一,那么大约需要19亿位,约占用230MB内存,哈希函数个数约为13个。可以看出,布隆过滤器在允许极小误判的前提下,可以用非常少的空间保护数据库,性能远高于直接查询缓存和数据库。
基于Redisson的布隆过滤器落地实现
在Java技术栈中,Redisson提供了对布隆过滤器的直接支持,底层基于Redis的Bitmap实现,适合分布式环境使用。首先创建Redisson客户端,然后获取RBloomFilter实例。初始化时需要指定期望插入的元素数量和允许的误判率,这两个参数会直接决定Redis中位数组的大小。
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient redisson = Redisson.create(config);
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("product:bloom");
boolean initialized = bloomFilter.tryInit(100000L, 0.01);
if (initialized) {
bloomFilter.add("product:1001");
}
boolean exists = bloomFilter.contains("product:1001");
if (!exists) {
// 直接拒绝请求,不查询数据库
}
这里tryInit方法只能在过滤器首次创建时调用,如果对应的Redis键已经存在,初始化会失败,这可以避免误操作覆盖已有数据。添加元素使用add方法,查询使用contains方法。当contains返回false时,说明该键一定不存在,请求可以在进入Redis前直接返回空结果,避免后续无意义的缓存查询和数据库查询。
实际业务中,布隆过滤器通常放在缓存查询链路的最前面。先判断键是否可能存在,如果不存在则直接返回;如果可能存在,再查询Redis缓存,缓存未命中时回源数据库。由于布隆过滤器存在误判,即使返回可能存在,也仍然需要走完整的缓存与数据库查询流程,不能据此认为数据一定存在。
public Product getProduct(String id) {
String cacheKey = "product:" + id;
if (!bloomFilter.contains(id)) {
return null;
}
Product product = (Product) redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
return product;
}
product = productMapper.selectById(id);
if (product != null) {
redisTemplate.opsForValue().set(cacheKey, product, 30, TimeUnit.MINUTES);
} else {
// 布隆过滤器误判或数据确实不存在时,写入短过期空值兜底
redisTemplate.opsForValue().set(cacheKey, "", 1, TimeUnit.MINUTES);
}
return product;
}
工程实践中的容量规划与避坑要点
布隆过滤器不支持删除操作是一个必须正视的问题。因为位数组中的某一位可能被多个元素共同置为1,直接将该位清零会影响其他元素的判断。如果业务中存在大量删除或过期数据,继续使用传统布隆过滤器会导致误判率逐渐升高。常见的做法是定期重建过滤器,或者引入支持删除的计数布隆过滤器、布谷鸟过滤器。对于大多数商品、用户ID等基础数据来说,数据变更频率较低,定期重建可以满足需求。
容量规划是布隆过滤器落地的关键。初始化时如果对数据量n估计过低,位数组很快会变得密集,误判率会超过预期;如果估计过高,则会造成Redis内存浪费。由于位数组长度在初始化后无法直接调整,需要在设计阶段充分评估业务数据的增长趋势,预留合理冗余。当数据量接近原定容量上限时,应提前创建新的过滤器,通过双写或数据迁移的方式完成扩容,避免在单个过滤器上继续叠加导致性能下降。
布隆过滤器返回可能存在时,并不意味着数据一定存在于数据库。它有天然的误判率,因此业务层仍需保留短过期空值缓存作为兜底。即使某个不存在的键偶尔穿过布隆过滤器,也只会有一次数据库查询,随后空值写入缓存后,后续相同键的请求不会继续穿透。监控方面,应关注过滤器的内存占用、误判情况以及穿透到数据库的无效请求比例,以便及时发现容量不足或参数不合理的问题。
布隆过滤器是解决Redis缓存穿透的一把利器,但也不是银弹。它最适合键空间相对稳定、允许极小误判率的场景。在设计高并发缓存体系时,可以把布隆过滤器、缓存空值、请求限流和数据校验等手段结合起来,形成纵深防御。只有根据业务量认真规划容量和误判率,并在运行过程中持续观察调整,才能让布隆过滤器真正发挥精准拦截的作用。