【黑马点评】Redis 缓存穿透深度解析:从布隆过滤器到缓存空对象
前言: 在构建高性能的“黑马点评”商铺查询功能时,我们面临的一个严峻挑战是缓存穿透。当黑客利用脚本发起海量不存在的 Key(如 id = -1)进行攻击时,请求会瞬间穿透 Redis 直接打在数据库上,极易导致数据库宕机。本文将深入探讨缓存穿透的原理,对比主流解决方案,并重点解析我们采用的“缓存空对象”方案及其兜底逻辑。
一、 核心概念:什么是缓存穿透?
缓存穿透 (Cache Penetration) 是指客户端请求的数据在缓存中不存在,同时在数据库中也不存在。
由于数据库中没有数据,所以我们无法将数据写入缓存。这就导致了每次针对该 Key 的请求,都会绕过 Redis,直接打到数据库。
它与缓存击穿、缓存雪崩的区别在于:
- 击穿:Key 对应的数据存在,只是突然过期了(点)。
- 雪崩:大量的 Key 同时过期(面)。
- 穿透:数据根本就不存在(空)。
二、 解决方案大比拼:布隆过滤器 vs 缓存空对象
针对缓存穿透,业界主要有两种主流的解决方案,它们各有优劣:
|
方案 |
原理简述 |
优点 |
缺点 |
|
方案一:缓存空对象 (Caching Null Object) |
当 DB 查不到数据时,往 Redis 存一个空值(如 ),并设置较短的 TTL。 |
实现简单,维护方便,数据一致性好(配合 TTL)。 |
1. 内存消耗:如果黑客随机生成海量 Key,Redis 会存海量空值。
2. 短期不一致:若 DB 后来真的有了数据,缓存里短暂还是空值。 |
|
方案二:布隆过滤器 (Bloom Filter) |
在访问 Redis 前,通过一个巨大的位图(Bitmap)判断 Key 是否可能存在。 |
内存占用极少(只存 0/1 位)。 |
1. 实现复杂:需要引入额外组件。
2. 存在误判:它说“存在”不一定真存在,但说“不存在”一定不存在。
3. 删除困难:很难从过滤器中删除数据。 |
三、 技术选型:为什么我们选择了“缓存空对象”?
虽然布隆过滤器在内存占用上具有绝对优势,但在现阶段,我们最终选择了看似“笨重”的缓存空对象方案。主要基于以下三点深层思考:
- 开发成本与维护性:
项目处于快速迭代期,引入布隆过滤器意味着需要维护一个新的组件(或 Redis 的 Bitmap 结构),增加了系统的复杂度。而“缓存空对象”仅需在原有逻辑上增加几行代码,性价比极高。
2.兜底逻辑解决了“内存消耗”痛点:
大家最担心的就是黑客刷几亿个随机 Key 把 Redis 内存撑爆。为了解决这个问题,我们在代码中设置了较短的 TTL(过期时间)(例如 2 分钟)。
限制内存:即使攻击流量进来,产生的空对象也会在 2 分钟后自动销毁,Redis 会自动清理这些垃圾数据,保证内存占用维持在一个动态平衡的范围内,不会无限膨胀。
3.数据一致性兜底:
如果数据库随后真的插入了该数据(比如运营新建了 id=1 的店铺),TTL 保证了 Redis 里的空对象不会永久存在。最长等待 2 分钟,用户就能查到最新的数据。
四、 代码落地与实战
基于上述思考,我们在 ShopServiceImpl 中实现了如下逻辑。

代码实现:
Java
public Shop queryWithPathThrough(Long id){
String key = CACHE_SHOP_KEY + id;
// 1. 从 redis 查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是否存在(StrUtil.isNotBlank 过滤了 null 和 "")
if (StrUtil.isNotBlank(shopJson)) {
// 3. 如果命中真实数据 - 直接返回
Shop shop = BeanUtil.toBean(shopJson, Shop.class);
return shop;
}
// 🚩 核心逻辑 1:拦截空值
// 如果 shopJson 不为 null(说明是 ""),则直接返回错误,不再查库
if(shopJson != null ){
return null;
}
// 4. 如果不存在(shopJson == null) - 根据 id 查询数据库
Shop shop = getById(id);
// 5. 数据库不存在
if (shop == null) {
// 🚩 核心逻辑 2:缓存空对象 + 兜底 TTL
// 将空值写入 redis,并设置较短的过期时间(CACHE_NULL_TTL),防止内存爆炸
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 6. 数据库存在 - 写入 redis 并设置正常过期时间
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
return shop;
}
关键代码解析:
if (shopJson != null):这是防止穿透的第一道防线。既然isNotBlank没通过,且对象不为 null,那它只能是我们在步骤 5 存入的空字符串""。此时直接返回,保护数据库。set(..., "", CACHE_NULL_TTL):这是防止穿透的第二道防线。当数据库查不到时,我们主动存入空值。注意这里的 TTL 通常设置得比较短(如 2-5 分钟),这正是我们对抗“内存消耗”的杀手锏。
五、进阶思考:主动防御 vs 被动防御
布隆过滤器 和 缓存空对象 本质上是一种 Redis 层面的被动防御(见招拆招)。
但我们能做的远不止于此,我们可以在业务层“主动出击”。
- 增强 ID 复杂度:
- 问题:如果使用数据库自增 ID(如 1, 2, 3...),攻击者很容易推测出规律,编写脚本遍历所有 ID 进行攻击。
- 对策:采用 Snowflake(雪花算法) 或 UUID 生成复杂的非连续 ID。让攻击者无法猜测 ID 规律,从源头掐断攻击脚本的生成。
- 基础格式校验:
- 对策:在 Controller 层或网关层对参数进行严格校验。例如 ID 必须是 Long 类型且大于 0,或者 ID 的长度必须符合特定规则。如果是
id=-1或id=abc这种显然非法的请求,直接驳回,根本不需要去烦劳 Redis 和数据库。 - 加强用户权限校验:
- 对策:绝大多数系统查询都需要登录。在网关层强制校验 User Token,未登录用户或权限不足的用户直接拦截。这就过滤掉了绝大部分无成本的脚本攻击。
- 热点参数限流:
- 对策:利用 Sentinel 或 Nginx 对同一 IP、同一用户的请求频率进行限制。如果检测到某个 IP 在 1 秒内发起了 1000 次查询不存在 Key 的请求,直接将其拉黑。
六、 总结
通过“缓存空对象”配合“TTL 过期策略”,我们以极低的开发成本,有效解决了缓存穿透问题。虽然它在极端攻击下会短暂占用内存,但对于目前的业务量级和 Redis 集群能力来说,这是一个性价比极高的选择。
解决了“穿透”问题后,如果一个存在的热点 Key 突然过期,导致万级并发瞬间打向数据库(缓存击穿),我们又该如何应对?下一篇我们将探讨互斥锁与逻辑过期的实战应用。
更多推荐




所有评论(0)