🌪️ Redis缓存穿透:当数据库被"空气"攻击时,如何优雅防御?
"缓存穿透?就是Redis防了个寂寞,数据库被空气打了!"
大家好!今天我们来聊聊Redis缓存中那个让无数开发者头疼的"幽灵攻击"------缓存穿透。它不费一兵一卒,却能让你数据库原地爆炸。本文将从原理到实战,手把手教你如何优雅防御!
一、缓存穿透:一场针对"不存在数据"的恶意攻击
什么是缓存穿透?
当用户疯狂请求数据库中根本不存在的数据 时,请求会绕过缓存层(因为缓存中也没有),直接穿透到数据库。高并发下,数据库可能被压垮。
举个例子🌰:
- 正常请求:查商品ID=1001(存在) → 缓存命中 → 返回结果
- 穿透请求:查商品ID=-1(不存在) → 缓存未命中 → 查数据库 → 数据库返回空 → 下次继续查库(死循环)
二、防御方案对比:哪种才是你的菜?
方案 | 原理 | 优点 | 缺点 |
---|---|---|---|
空对象缓存 | 将"不存在"也缓存 | 简单直接,成本低 | 可能缓存大量无用数据 |
布隆过滤器 | 预存所有合法Key,拦截非法请求 | 内存占用极小,效率高 | 存在误判率,不能删除元素 |
互斥锁 | 防止大量请求同时击穿到DB | 保证数据一致性 | 降低系统并发能力 |
三、实战代码:Java防御三件套
场景:电商平台商品查询
java
// 1. 布隆过滤器初始化(Guava实现)
BloomFilter<Long> bloomFilter = BloomFilter.create(
Funnels.longFunnel(),
1000000, // 预期元素数量
0.01 // 误判率
);
// 初始化合法商品ID(实际应从DB加载)
bloomFilter.put(1001L);
bloomFilter.put(1002L);
// 2. 商品查询服务(三层防护)
public Product getProduct(Long id) {
// 第一关:布隆过滤器拦截
if (!bloomFilter.mightContain(id)) {
log.warn("🚫 非法ID被拦截: {}", id);
return null;
}
// 第二关:尝试从缓存读取
String cacheKey = "product:" + id;
Product product = redisTemplate.opsForValue().get(cacheKey);
// 命中空对象缓存(特殊标记)
if ("NULL_OBJECT".equals(product)) {
log.info("🛑 命中空对象缓存: {}", id);
return null;
}
// 第三关:缓存未命中,查数据库(加互斥锁)
if (product == null) {
synchronized (this) {
// 双重检查锁
product = redisTemplate.opsForValue().get(cacheKey);
if (product == null) {
// 查数据库
product = productDao.findById(id);
if (product == null) {
// 缓存空对象,设置短TTL
redisTemplate.opsForValue().set(cacheKey, "NULL_OBJECT", 5, TimeUnit.MINUTES);
} else {
// 缓存真实数据
redisTemplate.opsForValue().set(cacheKey, product, 1, TimeUnit.HOURS);
}
}
}
}
return product;
}
关键点解析:
- 布隆过滤器:前置屏障,拦截绝对非法ID(如负数、超大数)
- 空对象缓存:为不存在的Key设置特殊值(避免重复查库)
- 互斥锁:synchronized保证单进程重建缓存(分布式用Redisson)
四、原理深潜:布隆过滤器为何如此高效?
布隆过滤器工作流程:
markdown
┌─────────┐ ┌─────────┐
请求ID → │ 哈希函数1 │ → 位位置1 │ │
├─────────┤ │ │
│ 哈希函数2 │ → 位位置2 │ 位数组 │ → 全为1?存在!
├─────────┤ │ │
│ 哈希函数3 │ → 位位置3 │ │
└─────────┘ └─────────┘
- 写入:对Key做K次哈希,将位数组对应位置设为1
- 查询:检查Key的所有哈希位是否均为1(是→可能存在,否→绝对不存在)
- 特点 :宁可错杀一千,绝不放过一个(可能误判合法Key,但绝不放过非法Key)
五、避坑指南:这些雷区千万别踩!
-
空值缓存TTL过短
→ 攻击者会在TTL过期后再次穿透
✅ 解决方案:动态TTL,例如
30s + 随机30s
-
布隆过滤器初始化不全
→ 新商品ID被误判为非法
✅ 解决方案:启动时全量加载+定时增量同步
-
过度依赖单一方案
→ 布隆过滤器有误判率,空对象浪费内存
✅ 终极方案:组合拳!布隆过滤器+空缓存+互斥锁
六、最佳实践:企业级防御架构
graph LR
A[客户端请求] --> B{布隆过滤器}
B -->|合法| C[Redis缓存]
B -->|非法| D[直接拒绝]
C -->|命中| E[返回数据]
C -->|未命中| F{获取分布式锁}
F -->|成功| G[查询数据库]
G -->|存在| H[写入缓存]
G -->|不存在| I[写入空缓存]
F -->|失败| J[短暂等待后重试缓存]
七、面试暴击点:这样答秒杀面试官!
问题1:缓存穿透和缓存击穿有什么区别?
💡 答:
- 穿透:查不存在的数据(恶意攻击)
- 击穿:查存在但过期的热点Key(并发访问导致)
问题2:布隆过滤器为何不能删除元素?
💡 答:
删除元素需将对应位置0,但该位可能被其他元素共享(哈希碰撞)。误删会导致其他元素被误判为不存在!
问题3:如何动态更新布隆过滤器?
💡 答:
- 方案1:重建新布隆过滤器 → 原子替换旧版本
- 方案2:使用Counting Bloom Filter(支持计数删除)
八、总结:防御之道在于分层拦截
"不要让你的数据库为空气打工!"
缓存穿透的本质是对不存在数据的暴力访问,防御核心思路:
- 前置拦截:布隆过滤器阻挡明显非法请求(门卫大爷)
- 中端缓存:空对象缓存吸收重复攻击(僵尸尸体也有价值)
- 后端加锁:互斥锁防止并发击穿(排队别挤!)
终极奥义 :没有银弹!组合方案 + 动态调整才是王道。
思考题:如果攻击者使用随机生成的不存在ID(如UUID),布隆过滤器还有效吗?欢迎评论区讨论! 👇