在高并发系统中,Redis通常被用作数据库前面的缓冲层,用来抵挡绝大部分读请求。但有一种情况会让缓存完全失效:请求查询的数据在缓存和数据库中都不存在。由于缓存中查不到任何结果,每次请求都会穿透到数据库,这就是所谓的缓存穿透。如果攻击者刻意构造大量不存在的key发起请求,数据库可能在短时间内被拖垮。本文将从问题成因入手,详细讲解几种主流的解决方案及其实现细节。

缓存穿透的产生原因与危害
缓存穿透的字面意思是请求像一根针一样穿过缓存层,直接扎在数据库上。正常情况下,一个key第一次被查询时缓存未命中,系统去数据库取数并回填缓存,之后的请求都能命中缓存。但如果这个key在数据库里也不存在,缓存就没有东西可以回填,后续每一个相同key的请求都会继续打到数据库,缓存层形同虚设。
造成这种现象的原因主要有两类:一类是业务层面的正常情况,比如用户访问了一个已经下架的商品、一篇被删除的文章;另一类则是恶意攻击,攻击者用随机生成的id或伪造的用户名发起高频请求,专门探测系统中不存在的数据,这种攻击也常被称为恶意穿透。
危害不容小觑。数据库的连接数和CPU资源是有限的,当穿透请求的量级足够大时,数据库响应变慢,进而拖垮依赖数据库的其他业务,形成连锁故障。因此,在设计缓存架构时,必须提前考虑穿透防护。
方案一:缓存空对象
最直接的做法是:当数据库查询结果为空时,不是什么都不做,而是向Redis写入一个特殊标记值(空对象),并设置一个较短的过期时间。这样同一个key再次被请求时,缓存会命中这个空标记,直接返回空结果,不再访问数据库。
public String queryUser(String userId) {
String cacheKey = "user:" + userId;
String value = redis.get(cacheKey);
// 命中缓存,直接返回(包括空标记)
if (value != null) {
return "NULL_MARKER".equals(value) ? null : value;
}
// 未命中,查询数据库
User user = userDao.findById(userId);
if (user == null) {
// 数据库中也不存在,写入空标记并设置较短过期时间
redis.setex(cacheKey, 60, "NULL_MARKER");
return null;
}
String json = JSON.toJSONString(user);
redis.setex(cacheKey, 3600, json);
return json;
}这个方案的优点是实现简单,对业务代码侵入小,不需要引入额外组件。但它有两个明显的缺点:一是会占用大量缓存空间,如果攻击者用海量随机key发起请求,每个key都会在Redis中留下一个空标记,可能把Redis本身打爆;二是存在短暂的数据不一致窗口,如果在空标记有效期内数据库新增了这条数据,用户会查询不到。因此空对象的过期时间不宜设置太长,一般控制在30秒到几分钟之间。
方案二:布隆过滤器
布隆过滤器是一种空间效率极高的概率型数据结构,用来判断某个元素是否存在于集合中。它的特点是:判断不存在就一定不存在,判断存在则有一定概率误判(可能实际不存在)。这个特性非常适合缓存穿透场景,因为在过滤器中被判定不存在的key,可以直接拒绝请求,根本不会触碰缓存和数据库。
底层原理可以简单理解为:一个很长的位数组加上若干个哈希函数。添加元素时,用多个哈希函数对元素计算,把对应的位数组位置置为1;查询元素时,同样计算这若干个位置,只要有一个位置是0,就说明元素肯定不存在,如果全部是1,则认为可能存在。误判率取决于位数组长度和哈希函数个数,可以通过参数调节。
// 基于Redisson实现布隆过滤器
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("userFilter");
// 预期插入量为100万,期望误判率为0.01
bloomFilter.tryInit(1000000L, 0.01);
// 系统启动或数据变更时,将所有有效用户id加入过滤器
for (Long userId : userDao.getAllIds()) {
bloomFilter.add(String.valueOf(userId));
}
// 查询时先经过过滤器拦截
public String queryUser(String userId) {
// 过滤器判断不存在,直接返回,不会打到数据库
if (!bloomFilter.contains(userId)) {
return null;
}
String value = redis.get("user:" + userId);
if (value != null) {
return value;
}
User user = userDao.findById(Long.valueOf(userId));
if (user == null) {
redis.setex("user:" + userId, 60, "NULL_MARKER");
return null;
}
String json = JSON.toJSONString(user);
redis.setex("user:" + userId, 3600, json);
return json;
}布隆过滤器的优势是内存占用极小,一亿条数据仅需一百多MB,且查询性能为常数级别。不足之处在于维护成本较高:当数据库新增数据时需要同步往过滤器中添加元素,而删除数据时标准布隆过滤器无法直接删除(可以用计数布隆过滤器解决,但内存开销增大)。因此它更适合数据集合相对稳定或新增操作可控的场景,例如已有的用户id、商品id集合。
方案三:接口层校验与限流兜底
除了缓存层面的防护,还应该在入口处做一层防线。很多穿透请求的参数本身就是非法的,例如id为负数、格式明显错误的手机号、超长字符串等。在接口层对这些参数做合法性校验,可以在请求进入缓存逻辑之前就把它们挡掉。
public Result getUser(String userId) {
// 参数格式校验,非法参数直接拒绝
if (userId == null || !userId.matches("\\d{1,10}")) {
return Result.error("参数非法");
}
// 继续后续缓存查询逻辑
...
}对于恶意的穿透攻击,还需要配合限流和熔断机制兜底。可以基于网关或Redis实现接口限流,对单一IP或单一用户的请求频率进行限制;当数据库压力达到阈值时自动熔断降级,返回默认数据或友好提示,避免故障扩散。此外,还可以对同一个key在短时间内的重复查询做合并,也就是常说的请求合并,进一步削减无效流量。
方案对比与选型建议
三种方案各有侧重,实际生产环境通常组合使用。缓存空对象适合处理少量随机的不存在key,实现成本最低;布隆过滤器适合key集合可枚举且相对固定的场景,能以极小的内存代价拦截绝大部分穿透请求;接口校验和限流则是通用的安全兜底手段,无论是否有缓存穿透问题都建议开启。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 缓存空对象 | 实现简单,兼容性好 | 占内存,存在短暂不一致 | 不存在key较少且分散 |
| 布隆过滤器 | 内存占用小,拦截效率高 | 维护成本高,不支持删除 | 数据集合稳定可预加载 |
| 校验与限流 | 防护面广,可对抗攻击 | 无法精确区分业务请求 | 所有场景的兜底防线 |
推荐的组合策略是:接口层做参数校验和限流作为第一道防线,业务层用布隆过滤器拦截确定不存在的key,再用缓存空对象处理过滤器误判和正常的数据缺失。三者叠加后,基本可以杜绝缓存穿透对数据库的冲击,让整个缓存体系更加稳固可靠。