难度:⭐⭐⭐⭐⭐ | 适合人群:想掌握Redis生产级方案的开发者
💥 开场:一次"血的教训"
时间: 周五晚上8点
地点: 公司(准备下班)
事件: 生产事故
告警短信疯狂轰炸: 📱📱📱
【告警】数据库连接池耗尽!
【告警】API响应超时!
【告警】服务器CPU 100%!
【告警】Redis宕机!
我: "完了完了..." 😱(赶紧打开电脑)
查看监控:
18:00 - Redis QPS:1万/秒(正常)
18:05 - Redis QPS:10万/秒(暴增!)
18:10 - Redis宕机
18:11 - MySQL QPS:从100/秒 → 5万/秒(爆炸!)
18:12 - MySQL连接池耗尽
18:13 - 整个系统崩溃
我: "怎么回事?好端端的Redis怎么就崩了?" 😰
紧急查看Redis日志:
[18:05:23] 大量查询不存在的key:product:-1
[18:05:24] 大量查询不存在的key:product:-2
[18:05:25] 大量查询不存在的key:product:-3
...
[18:10:15] OOM command not allowed when used memory > 'maxmemory'
[18:10:16] Redis crashed
哈吉米(电话里): "这是典型的缓存穿透攻击!有人在恶意查询不存在的商品!"
我: "缓存穿透?什么意思?" 🤔
南北绿豆(也紧急上线): "查询不存在的数据,Redis没有,每次都打到数据库,数据库也扛不住..."
阿西噶阿西: "这是缓存的三大经典问题之一,另外两个是缓存击穿和缓存雪崩。来,我给你讲讲怎么解决..."
🕳️ 第一问:缓存穿透
什么是缓存穿透?
定义: 查询一个不存在的数据,缓存和数据库都没有,但每次请求都会打到数据库。
正常流程:
请求 product:1
↓
查询Redis → 没有
↓
查询MySQL → 有
↓
存入Redis
↓
返回数据
↓
下次请求直接从Redis返回 ✅
穿透流程:
请求 product:-1(不存在的ID)
↓
查询Redis → 没有
↓
查询MySQL → 也没有
↓
返回null(不缓存)
↓
下次请求又查Redis → 没有
↓
又查MySQL → 还是没有
↓
每次都穿透到数据库! ❌
危害
阿西噶阿西: "缓存穿透的危害巨大!"
恶意攻击场景:
使用不存在的ID疯狂请求
↓
每次都穿透到数据库
↓
数据库压力暴增
↓
数据库崩溃
↓
整个系统崩溃
实际案例:
// 攻击者发起100万次请求
for (int i = -1; i >= -1000000; i--) {
http.get("/api/product/" + i); // 查询不存在的商品
}
// 每次请求都打到MySQL
// MySQL瞬间崩溃
解决方案1:缓存空值
哈吉米: "最简单的方法:把null也缓存起来!"
@Service
public class ProductService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private ProductDao productDao;
public Product getProduct(Long productId) {
String key = "product:" + productId;
// 1. 查询Redis
String json = redisTemplate.opsForValue().get(key);
if (json != null) {
if ("null".equals(json)) {
return null; // 缓存的空值
}
return JSON.parseObject(json, Product.class);
}
// 2. 查询数据库
Product product = productDao.findById(productId);
// 3. 存入Redis
if (product != null) {
redisTemplate.opsForValue().set(key, JSON.toJSONString(product),
1, TimeUnit.HOURS);
} else {
// 缓存空值(过期时间短一点)
redisTemplate.opsForValue().set(key, "null",
5, TimeUnit.MINUTES);
}
return product;
}
}
效果:
第1次请求 product:-1
↓
Redis没有 → MySQL没有
↓
缓存"null"(5分钟)
↓
第2次请求 product:-1
↓
Redis有"null" → 直接返回null
↓
不再打到数据库 ✅
优点:
- ✅ 简单易实现
- ✅ 能解决大部分穿透问题
缺点:
- ❌ 占用Redis内存
- ❌ 如果攻击ID每次都不同,还是会穿透
解决方案2:布隆过滤器(推荐)
南北绿豆: "布隆过滤器才是最优解!"
什么是布隆过滤器?
布隆过滤器 = 一个超大的位数组 + 多个哈希函数
特点:
- 判断元素一定不存在 ✅ 100%准确
- 判断元素可能存在 ⚠️ 有一定误判率
工作原理:
添加元素:
1. 计算多个哈希值
2. 将对应位置设为1
product:1
↓
hash1(product:1) = 10 → bit[10] = 1
hash2(product:1) = 25 → bit[25] = 1
hash3(product:1) = 99 → bit[99] = 1
查询元素:
1. 计算多个哈希值
2. 检查对应位置是否都为1
查询product:1:
bit[10]=1 && bit[25]=1 && bit[99]=1 → 可能存在 ✅
查询product:-1:
bit[5]=0 || bit[18]=0 || bit[77]=0 → 一定不存在 ✅
Redisson实现:
@Configuration
public class BloomFilterConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://localhost:6379");
return Redisson.create(config);
}
@Bean
public RBloomFilter<Long> productBloomFilter(RedissonClient redissonClient) {
RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter("product:bloom");
// 初始化布隆过滤器
// 预计元素数量:1000万,误判率:0.01
bloomFilter.tryInit(10000000L, 0.01);
return bloomFilter;
}
}
使用布隆过滤器:
@Service
public class ProductService {
@Autowired
private RBloomFilter<Long> productBloomFilter;
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private ProductDao productDao;
/**
* 初始化:将所有商品ID加入布隆过滤器
*/
@PostConstruct
public void initBloomFilter() {
List<Long> productIds = productDao.findAllIds();
for (Long id : productIds) {
productBloomFilter.add(id);
}
System.out.println("布隆过滤器初始化完成,加载了 " + productIds.size() + " 个商品ID");
}
/**
* 获取商品
*/
public Product getProduct(Long productId) {
// 1. 布隆过滤器判断(快速过滤不存在的)
if (!productBloomFilter.contains(productId)) {
System.out.println("布隆过滤器:商品不存在 " + productId);
return null; // 一定不存在,直接返回
}
String key = "product:" + productId;
// 2. 查询Redis
String json = redisTemplate.opsForValue().get(key);
if (json != null) {
return JSON.parseObject(json, Product.class);
}
// 3. 查询数据库(可能存在)
Product product = productDao.findById(productId);
// 4. 存入Redis
if (product != null) {
redisTemplate.opsForValue().set(key, JSON.toJSONString(product),
1, TimeUnit.HOURS);
}
return product;
}
/**
* 创建商品时,加入布隆过滤器
*/
public void createProduct(Product product) {
productDao.save(product);
// 加入布隆过滤器
productBloomFilter.add(product.getId());
}
}
效果对比:
恶意请求 product:-1(不存在)
不用布隆过滤器:
Redis查询 → MySQL查询 → 返回null
耗时:10ms
用布隆过滤器:
布隆过滤器判断 → 直接返回null
耗时:0.1ms
性能提升100倍!
⚡ 第二问:缓存击穿
什么是缓存击穿?
定义: 热点Key过期的瞬间,大量请求同时打到数据库。
场景:
热点商品(iPhone新品)
↓
缓存Key:product:99999
过期时间:1小时
↓
11:00:00 - Key过期
11:00:00 - 同时来了10000个请求
↓
10000个请求都发现Redis没有
↓
10000个请求同时查询MySQL
↓
MySQL瞬间崩溃!
和穿透的区别:
缓存穿透:
查询的数据不存在
每次请求都打到数据库
缓存击穿:
查询的数据存在
只是热点Key过期的瞬间,大量请求打到数据库
解决方案1:互斥锁
哈吉米: "只允许一个线程去查数据库,其他线程等待。"
@Service
public class ProductService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private ProductDao productDao;
public Product getProduct(Long productId) {
String key = "product:" + productId;
// 1. 查询Redis
String json = redisTemplate.opsForValue().get(key);
if (json != null) {
return JSON.parseObject(json, Product.class);
}
// 2. Redis没有,获取锁
String lockKey = "lock:product:" + productId;
String lockValue = UUID.randomUUID().toString();
try {
// 尝试获取锁(10秒过期)
Boolean locked = redisTemplate.opsForValue().setIfAbsent(
lockKey, lockValue, 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
// 获取锁成功,查询数据库
System.out.println("获取锁成功,查询数据库:" + productId);
Product product = productDao.findById(productId);
// 存入Redis
if (product != null) {
redisTemplate.opsForValue().set(key, JSON.toJSONString(product),
1, TimeUnit.HOURS);
}
return product;
} else {
// 获取锁失败,等待后重试
System.out.println("获取锁失败,等待重试:" + productId);
Thread.sleep(50); // 等待50ms
// 再次查询Redis(其他线程可能已经加载了)
json = redisTemplate.opsForValue().get(key);
if (json != null) {
return JSON.parseObject(json, Product.class);
}
// 还是没有,递归重试
return getProduct(productId);
}
} catch (Exception e) {
e.printStackTrace();
return null;
} finally {
// 释放锁(判断是自己加的锁)
String currentValue = redisTemplate.opsForValue().get(lockKey);
if (lockValue.equals(currentValue)) {
redisTemplate.delete(lockKey);
}
}
}
}
效果:
10000个并发请求 product:99999(热点Key刚过期)
↓
线程1获取锁成功 → 查询MySQL → 缓存到Redis
线程2-10000获取锁失败 → 等待 → 从Redis获取
↓
只有1个请求打到MySQL ✅
解决方案2:热点数据永不过期
南北绿豆: "对于热点数据,可以设置永不过期。"
@Service
public class ProductService {
public Product getProduct(Long productId) {
String key = "product:" + productId;
// 查询Redis
String json = redisTemplate.opsForValue().get(key);
if (json != null) {
ProductCache cache = JSON.parseObject(json, ProductCache.class);
// 检查逻辑过期时间
if (cache.getExpireTime() > System.currentTimeMillis()) {
return cache.getData(); // 未过期,直接返回
}
// 逻辑过期,异步更新
CompletableFuture.runAsync(() -> {
refreshCache(productId);
});
// 返回旧数据(不阻塞)
return cache.getData();
}
// 缓存没有,查询数据库
return refreshCache(productId);
}
private Product refreshCache(Long productId) {
String key = "product:" + productId;
Product product = productDao.findById(productId);
if (product != null) {
// 封装缓存对象(带逻辑过期时间)
ProductCache cache = new ProductCache();
cache.setData(product);
cache.setExpireTime(System.currentTimeMillis() + 3600000); // 1小时后逻辑过期
// 存入Redis(永不过期)
redisTemplate.opsForValue().set(key, JSON.toJSONString(cache));
}
return product;
}
}
/**
* 缓存对象
*/
class ProductCache {
private Product data;
private Long expireTime; // 逻辑过期时间
// Getter和Setter...
}
优点:
- ✅ 永远有缓存,不会击穿
- ✅ 异步更新,不阻塞请求
缺点:
- ❌ 可能返回过期数据
- ❌ 实现复杂
解决方案3:随机过期时间
// 给缓存加上随机过期时间,避免同时过期
int randomExpire = 3600 + new Random().nextInt(300); // 1小时 + 0-5分钟随机
redisTemplate.opsForValue().set(key, json, randomExpire, TimeUnit.SECONDS);
❄️ 第三问:缓存雪崩
什么是缓存雪崩?
定义: 大量Key同时过期,或Redis宕机,导致大量请求打到数据库。
场景1:大量Key同时过期
批量导入商品(10000个)
↓
全部设置1小时过期
↓
1小时后,10000个Key同时过期
↓
大量请求同时打到数据库
↓
数据库崩溃
场景2:Redis宕机
Redis服务器宕机
↓
所有请求都打到数据库
↓
数据库瞬间压力暴增
↓
数据库崩溃
↓
整个系统雪崩
危害演示
阿西噶阿西: "我们模拟一下雪崩的威力。"
// 批量设置缓存(都是1小时过期)
for (int i = 1; i <= 10000; i++) {
redisTemplate.opsForValue().set("product:" + i, "data", 1, TimeUnit.HOURS);
}
// 1小时后,10000个Key同时过期
// 此时并发请求
ExecutorService executor = Executors.newFixedThreadPool(1000);
for (int i = 1; i <= 10000; i++) {
final int id = i;
executor.submit(() -> {
productService.getProduct(id); // 10000个请求同时打到MySQL
});
}
// MySQL连接数瞬间飙升
// 数据库崩溃!
解决方案1:随机过期时间
哈吉米: "最简单的方法:让Key不要同时过期!"
public void cacheProduct(Product product) {
String key = "product:" + product.getId();
// 基础过期时间:1小时
int baseExpire = 3600;
// 随机过期时间:0-300秒(5分钟)
int randomExpire = new Random().nextInt(300);
// 最终过期时间:1小时 + 随机5分钟
int expire = baseExpire + randomExpire;
redisTemplate.opsForValue().set(key, JSON.toJSONString(product),
expire, TimeUnit.SECONDS);
}
效果:
Key过期时间分布:
product:1 → 3610秒后过期
product:2 → 3750秒后过期
product:3 → 3590秒后过期
...
不会同时过期,避免雪崩 ✅
解决方案2:多级缓存
南北绿豆: "不要只依赖Redis,建立多级缓存!"
请求
↓
本地缓存(Caffeine/Guava Cache)
↓ 没有
Redis缓存
↓ 没有
MySQL数据库
实现:
@Service
public class ProductService {
// 本地缓存
private Cache<Long, Product> localCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build();
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private ProductDao productDao;
public Product getProduct(Long productId) {
// 1. 查询本地缓存
Product product = localCache.getIfPresent(productId);
if (product != null) {
System.out.println("本地缓存命中");
return product;
}
// 2. 查询Redis
String key = "product:" + productId;
String json = redisTemplate.opsForValue().get(key);
if (json != null) {
System.out.println("Redis缓存命中");
product = JSON.parseObject(json, Product.class);
// 存入本地缓存
localCache.put(productId, product);
return product;
}
// 3. 查询数据库
System.out.println("查询数据库");
product = productDao.findById(productId);
if (product != null) {
// 存入Redis
redisTemplate.opsForValue().set(key, JSON.toJSONString(product),
1, TimeUnit.HOURS);
// 存入本地缓存
localCache.put(productId, product);
}
return product;
}
}
优势:
- ✅ 即使Redis挂了,还有本地缓存
- ✅ 降低Redis压力
- ✅ 响应更快(本地缓存)
解决方案3:Redis集群 + 熔断降级
Redis集群:
Redis主从 + 哨兵
↓
主节点宕机 → 自动故障转移
↓
从节点升级为主节点
↓
服务继续可用
熔断降级:
@Service
public class ProductService {
@Autowired
private CircuitBreaker circuitBreaker; // Hystrix或Resilience4j
public Product getProduct(Long productId) {
return circuitBreaker.run(
// 正常逻辑
() -> getProductFromCache(productId),
// 降级逻辑(Redis挂了)
(throwable) -> {
System.out.println("Redis异常,降级处理");
// 直接查数据库,但限流
return getProductFromDB(productId);
}
);
}
}
💥 第四问:三大问题对比
对比表格
| 问题 | 原因 | 现象 | 危害 | 解决方案 |
|---|---|---|---|---|
| 缓存穿透 | 查询不存在的数据 | 每次都打DB | 恶意攻击可击垮DB | 布隆过滤器、缓存空值 |
| 缓存击穿 | 热点Key过期 | 瞬间大量请求打DB | DB瞬时压力大 | 互斥锁、永不过期 |
| 缓存雪崩 | 大量Key同时过期或Redis宕机 | 大规模请求打DB | 系统崩溃 | 随机过期、多级缓存、集群 |
问题识别
如何判断遇到了哪种问题?
南北绿豆: "看监控指标!"
缓存穿透:
Redis未命中率突然升高
大量查询不存在的Key
数据库QPS异常升高
缓存击穿:
某个时刻数据库QPS突然暴增
只持续很短时间
Redis某个热点Key刚好过期
缓存雪崩:
Redis整体不可用
或大量Key同时过期
数据库QPS持续暴增
系统整体响应变慢
💻 第五问:完整的生产级解决方案
综合方案
阿西噶阿西: "生产环境要综合使用多种方案!"
@Service
public class ProductService {
// 本地缓存
private Cache<Long, Product> localCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build();
// 布隆过滤器
@Autowired
private RBloomFilter<Long> productBloomFilter;
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private ProductDao productDao;
/**
* 综合方案获取商品
*/
public Product getProduct(Long productId) {
// 第1层:布隆过滤器(防穿透)
if (!productBloomFilter.contains(productId)) {
return null; // 一定不存在
}
// 第2层:本地缓存(防雪崩)
Product product = localCache.getIfPresent(productId);
if (product != null) {
return product;
}
// 第3层:Redis缓存
String key = "product:" + productId;
String json = redisTemplate.opsForValue().get(key);
if (json != null) {
product = JSON.parseObject(json, Product.class);
localCache.put(productId, product);
return product;
}
// 第4层:数据库(加锁防击穿)
product = getProductWithLock(productId);
if (product != null) {
// 随机过期时间(防雪崩)
int expire = 3600 + new Random().nextInt(300);
redisTemplate.opsForValue().set(key, JSON.toJSONString(product),
expire, TimeUnit.SECONDS);
localCache.put(productId, product);
} else {
// 缓存空值(防穿透)
redisTemplate.opsForValue().set(key, "null", 5, TimeUnit.MINUTES);
}
return product;
}
/**
* 加锁查询数据库(防击穿)
*/
private Product getProductWithLock(Long productId) {
String lockKey = "lock:product:" + productId;
String lockValue = UUID.randomUUID().toString();
try {
Boolean locked = redisTemplate.opsForValue().setIfAbsent(
lockKey, lockValue, 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
return productDao.findById(productId);
} else {
Thread.sleep(50);
// 递归重试...
}
} catch (Exception e) {
e.printStackTrace();
} finally {
releaseLock(lockKey, lockValue);
}
return null;
}
private void releaseLock(String lockKey, String lockValue) {
String currentValue = redisTemplate.opsForValue().get(lockKey);
if (lockValue.equals(currentValue)) {
redisTemplate.delete(lockKey);
}
}
}
防护层级
请求
↓
┌────────────────┐
│ 布隆过滤器 │ ← 防穿透:过滤不存在的
└────────────────┘
↓
┌────────────────┐
│ 本地缓存 │ ← 防雪崩:Redis挂了还能用
└────────────────┘
↓
┌────────────────┐
│ Redis缓存 │ ← 主缓存
└────────────────┘
↓
┌────────────────┐
│ 分布式锁 │ ← 防击穿:只有一个查DB
└────────────────┘
↓
┌────────────────┐
│ 数据库 │
└────────────────┘
💡 知识点总结
三大问题核心要点
✅ 缓存穿透
- 原因:查询不存在的数据
- 危害:每次都打DB,恶意攻击
- 解决:布隆过滤器(推荐)、缓存空值
✅ 缓存击穿
- 原因:热点Key过期
- 危害:瞬时大量请求打DB
- 解决:互斥锁、永不过期、随机过期
✅ 缓存雪崩
- 原因:大量Key同时过期或Redis宕机
- 危害:系统整体崩溃
- 解决:随机过期、多级缓存、Redis集群、熔断降级
✅ 综合方案
- 布隆过滤器(防穿透)
- 本地缓存(防雪崩)
- 分布式锁(防击穿)
- 随机过期(防雪崩)
- 集群部署(高可用)
✅ 监控指标
- Redis未命中率
- 数据库QPS
- Redis QPS
- 响应时间
记忆口诀
穿透查询不存在,
布隆过滤挡门前。
击穿热点同时访,
互斥锁来保平安。
雪崩大量齐过期,
随机时间来分散。
多级缓存加集群,
生产环境要全面。
🤔 常见面试题
Q1: 缓存穿透、击穿、雪崩的区别?
A:
穿透:查询不存在的数据,每次都打DB
击穿:热点Key过期,瞬间大量请求打DB
雪崩:大量Key同时过期或Redis宕机,系统崩溃
记忆:
- 穿透 = 打洞(每次都穿过缓存)
- 击穿 = 打点(打穿热点)
- 雪崩 = 崩塌(整体崩溃)
Q2: 如何解决缓存穿透?
A:
方案1:布隆过滤器(推荐)
- 快速判断数据是否存在
- 不存在直接返回
- 几乎不占内存
方案2:缓存空值
- 查询不到也缓存null
- 设置较短过期时间
- 简单但占内存
方案3:接口校验
- 参数合法性校验
- 限流
- 黑名单
Q3: 生产环境如何防止缓存问题?
A:
综合方案:
1. 缓存设计
- 随机过期时间
- 热点数据永不过期
- 合理的过期时间
2. 多层防护
- 布隆过滤器
- 本地缓存
- Redis集群
- 分布式锁
3. 降级熔断
- 限流
- 熔断
- 降级方案
4. 监控告警
- Redis监控
- 数据库监控
- 及时告警
💬 写在最后
从缓存穿透到击穿再到雪崩,我们深入学习了Redis的三大经典问题:
- 🕳️ 理解了三大问题的原因和危害
- 🛡️ 掌握了多种解决方案
- 💻 完成了生产级综合方案
- 🔧 学会了如何监控和预防
这篇文章,希望能让你的Redis应用更加稳定可靠!
如果这篇文章对你有帮助,请:
- 👍 点赞支持
- ⭐ 收藏备用
- 🔄 转发分享
- 💬 评论交流
下一篇我们聊Redis分布式锁! 👋