缓存穿透是分布式系统里最经典的高并发问题之一。简单来说,用户请求的数据在缓存和数据库中都不存在,缓存永远无法命中,每一次请求都会穿透缓存直达数据库。正常业务中这类请求比例很低,但一旦遇到恶意攻击(比如用大量不存在的ID发起请求)或者爬虫批量扫描,数据库就会在瞬间承受远超承载能力的压力,严重时直接拖垮整个服务。本文围绕这个问题的两种主流解法:空值缓存和布隆过滤器展开,配合代码示例讲清楚各自的原理、实现和适用边界。

一、缓存穿透到底是怎么发生的
先看一个典型的业务流程:客户端发起请求,服务先查Redis缓存,缓存未命中就查MySQL,拿到结果后写回缓存再返回。这个逻辑在数据存在的时候没有任何问题,但如果请求的ID在数据库里压根不存在呢?比如用户访问一个商品详情页,传入的goodsId为-1或者一个随机的长串数字,数据库查询返回空,而空结果通常不会被写入缓存。于是下一个相同的请求到来时,缓存依然未命中,又去查一次数据库。
在低并发的正常业务中,这种空查询的代价可以忽略。但当有人恶意构造几十万个不存在的ID并发请求时,问题就被急剧放大:每个请求都绕过缓存,MySQL的连接池迅速被占满,CPU和磁盘IO飙升,最终导致整个数据库服务不可用,进而引发连锁故障,依赖该库的其他业务也跟着挂掉。这种情况和缓存击穿(某个热点key过期瞬间大量请求打到数据库)不同,穿透的显著特征是被查询的数据从头到尾就不存在,缓存永远不会被填充。
常见 triggers 包括:恶意攻击者用随机ID刷接口、业务自身允许用户传入任意ID进行查询(如根据订单号查订单)、缓存和数据库数据不一致期间的老ID访问等。理解了成因,对应的解决思路也就清晰了:要么让不存在的数据也能在缓存层被拦截,要么在请求到达数据库之前就判断出数据不存在。
二、方案一:空值缓存,简单直接的止血方案
空值缓存的思路非常朴素:既然数据库查不到结果会导致缓存无法填充,那就把空结果也缓存起来。第一次查询goodsId=-1时数据库返回空,我们在Redis里写入一个特殊标记(比如空字符串或者固定占位值"NULL"),并设置一个较短的过期时间,比如60秒。之后一分钟内所有针对这个ID的请求都会命中缓存拿到空标记,直接返回,不再触碰数据库。
下面是一段基于Java和Redis的示例实现:
public Object queryGoods(Long goodsId) {
String key = "goods:" + goodsId;
// 1. 先查缓存
String value = redis.get(key);
if (value != null) {
// 命中空值标记,直接返回,避免穿透到数据库
if ("NULL".equals(value)) {
return null;
}
return JSON.parseObject(value, Goods.class);
}
// 2. 缓存未命中,查询数据库
Goods goods = goodsMapper.selectById(goodsId);
if (goods == null) {
// 数据库中也不存在,写入空值标记并设置较短过期时间
redis.set(key, "NULL", 60, TimeUnit.SECONDS);
return null;
}
// 3. 正常数据写回缓存,过期时间可以长一些
redis.set(key, JSON.toJSONString(goods), 30, TimeUnit.MINUTES);
return goods;
}
这个方案实现成本极低,几十行代码就能上线,对业务代码的侵入也很小。但它的局限性同样明显:第一,空值的过期时间设长了会占用大量缓存内存,设短了又可能在过期后再次穿透;第二,如果攻击者每次都用不同的随机ID发请求,每个新ID都会产生一次数据库空查询,空值缓存根本拦不住,这种情况下它只能起到缓解作用;第三,空值数据和正常数据共享缓存空间,大量空值可能把热点数据挤出去,需要权衡内存分配。
实践中有一些优化技巧:给空值单独设置更短的TTL;对单ID的空值缓存做次数限制,超过阈值直接拉黑该ID一段时间;或者用独立的Redis实例或单独的key前缀来隔离空值数据。总体来说,空值缓存适合数据不存在的情况有限且可枚举的业务,作为第一道防线非常合适。
三、方案二:布隆过滤器,海量数据下的内存利器
布隆过滤器(Bloom Filter)是一种空间效率极高的概率型数据结构,由一个很长的位数组和若干个独立的哈希函数组成。写入一个元素时,用k个哈希函数分别计算出k个位置,把这些位置的二进制位设置为1;查询某个元素时,同样计算k个位置,只要其中有任何一个位置是0,就可以断定这个元素一定不存在;如果所有位置都是1,则说明这个元素可能存在(因为可能是其他元素把这些位占了,即存在误判率)。
这个特性恰好契合缓存穿透的防护需求:布隆过滤器里存的是所有合法ID,请求到来时先查过滤器,判断为一定不存在就直接拒绝,根本不需要查缓存和数据库。注意布隆过滤器的判断是单向的——说不存在就绝对不存在,说存在可能是误判。所以它适合挡住不存在的数据,而不能替代缓存本身。对于删除场景它也有短板:位数组的位不能直接清零,因为多个元素可能共享同一个位,删除一个元素可能影响其他元素的判断,因此布隆过滤器一般只支持新增不支持删除。
使用Redis实现布隆过滤器有两条路:一条是用Redis 4.0之后官方提供的RedisBloom模块,直接调用BF.ADD和BF.EXISTS命令:
# 创建一个布隆过滤器,预计插入100万元素,误判率0.1% BF.RESERVE goods:filter 0.001 1000000 # 添加元素 BF.ADD goods:filter 10001 # 判断元素是否存在,返回1表示可能存在,返回0表示一定不存在 BF.EXISTS goods:filter 10001 BF.EXISTS goods:filter 999999999
另一条路是自己基于Redis的SETBIT和GETBIT命令实现。下面是Java版本的简化实现:
public class SimpleBloomFilter {
private static final int BIT_SIZE = 2 << 24; // 约3300万个bit
private static final int[] SEEDS = {3, 7, 11, 13, 31, 37, 61};
public void add(String key) {
for (int seed : SEEDS) {
int index = hash(key, seed);
// 对应位置置为1
redis.setBit("bloom:goods", index, true);
}
}
public boolean mightContain(String key) {
for (int seed : SEEDS) {
int index = hash(key, seed);
// 只要有一个位置为0,则一定不存在
if (!redis.getBit("bloom:goods", index)) {
return false;
}
}
return true; // 所有位置都为1,可能存在(有误判概率)
}
private int hash(String key, int seed) {
int h = 0;
for (int i = 0; i < key.length(); i++) {
h = h * seed + key.charAt(i);
}
// 与运算保证索引落在位数组范围内且为正数
return (BIT_SIZE - 1) & h;
}
}
使用时要注意几个关键点:布隆过滤器需要在系统启动或数据变更时预先加载全量合法ID;新增数据时要同步写入过滤器,否则新数据会被误判为不存在;误判率和位数组大小、哈希函数个数成反比,业界常用的经验公式是当元素数量为n、误判率为p时,最优位数组大小约为n乘以负ln(p)再除以ln2的平方。以1亿个元素、1%误判率为例,仅需约120MB内存,远小于直接存储所有ID的开销,这正是它在海量数据场景下无可替代的原因。
四、两种方案对比与组合实践
下表从多个维度对比两种方案,帮助选型:
| 对比维度 | 空值缓存 | 布隆过滤器 |
|---|---|---|
| 实现复杂度 | 低,改动业务代码即可 | 较高,需维护数据结构同步 |
| 内存开销 | 与空值key数量成正比 | 极小,与误判率要求相关 |
| 拦截效果 | 仅能拦截重复出现的相同key | 可拦截所有不存在的key |
| 数据删除支持 | 支持,等TTL过期即可 | 不支持删除(可用计数布隆过滤器变通) |
| 适用场景 | 数据不存在情况有限、并发不高 | 数据量大、key空间不可枚举 |
生产环境中更推荐两者结合使用:请求先经过布隆过滤器,判断一定不存在的直接拒绝;判断可能存在的继续查缓存,缓存未命中查数据库,查到空结果再写入空值缓存兜底。这样既覆盖了随机ID攻击的场景,又兜住了布隆过滤器误判带来的少量穿透流量,形成双保险。
此外还可以补充一些辅助手段增强防护:在接入层做好参数合法性校验,比如ID必须为正整数、格式不符合直接拒绝;对单个用户或IP的查询频率做限流;针对明显异常的访问模式启用风控策略。缓存穿透的防护从来不是单一组件的事,而是从入口校验、缓存策略到数据结构层层设防的体系化工程,理解布隆过滤器和空值缓存这两个核心武器的原理与边界,是搭建这套体系的第一步。