一、前言:一个热门商品,为何让数据库瞬间崩溃?
你是否遇到过这些现象?
- 某款秒杀商品上线,数据库 CPU 瞬间飙到 100%
- 监控显示大量 SELECT * FROM product WHERE id = 1001 请求
- 缓存命中率从 99% 骤降到 0%,服务响应超时
根本原因:缓存击穿(Cache Breakdown)!
缓存击穿是高并发场景下的“隐形杀手”,看似普通,却能瞬间压垮数据库。本文将带你彻底理解其原理,并掌握三种工业级解决方案,尤其详解分布式锁互斥重建的最佳实践。
二、什么是缓存击穿?
缓存击穿:指某个热点 key 在缓存中过期的瞬间,大量并发请求同时发现缓存失效,全部打到数据库,造成 DB 瞬时压力激增。
🌰 经典场景:
- 商品详情页(如 iPhone 16)被万人同时访问
- 缓存 TTL = 5 分钟,恰好在高峰期过期
- 10,000 个请求同时查 DB → 连接池耗尽
正常流程:
请求 → 缓存(命中)→ 返回 ✅
缓存击穿:
t=0s:缓存过期
t=0.001s:10,000 请求同时发现 miss
t=0.002s:10,000 条 SQL 打到 DB ❌
⚠️ 与缓存穿透、雪崩的区别:
| 缓存穿透 | 不存在的 key | 恶意查询无效 ID | 请求永远不命中 |
| ✅ 缓存击穿 | 单个热点 key | 高并发 + 缓存过期 | 瞬时 DB 压力 |
| 缓存雪崩 | 大量 key | 集体过期 / Redis 宕机 | 全局性崩溃 |
三、解决方案一:永不过期(慎用)
原理:
对极核心热点数据,设置永不过期(TTL = -1),由后台线程异步更新。
实现方式:
// 启动定时任务,每 5 分钟刷新一次
@Scheduled(fixedDelay = 300_000)
public void refreshHotProduct() {
Product product = productMapper.selectById(1001);
redis.set("product:1001", JSON.toJSONString(product));
}
✅ 优点:
- 彻底避免击穿
- 读性能极致
⚠️ 缺点:
- 数据可能陈旧(最长 5 分钟延迟)
- 仅适用于变更频率极低的热点数据(如首页 Banner、热门排行榜)
📌 不推荐用于商品价格、库存等实时性要求高的场景
四、解决方案二:互斥重建(Mutex Rebuild)—— 推荐方案!
原理:
当缓存失效时,只允许一个线程去查数据库并重建缓存,其他线程等待或重试。
核心步骤:
代码实现(Java + Redisson):
@Autowired
private RedissonClient redisson;
public Product getProduct(Long id) {
String key = "product:" + id;
// 1. 先查缓存
String json = redis.get(key);
if (json != null) {
return JSON.parseObject(json, Product.class);
}
// 2. 缓存 miss,尝试获取分布式锁
String lockKey = "lock:product:" + id;
RLock lock = redisson.getLock(lockKey);
try {
boolean isLocked = lock.tryLock(1, 3, TimeUnit.SECONDS);
if (!isLocked) {
// 未获取到锁,短暂等待后重试(或返回旧数据)
Thread.sleep(50);
return getProduct(id); // 递归重试
}
// 3. 双重检查:可能其他线程已重建
json = redis.get(key);
if (json != null) {
return JSON.parseObject(json, Product.class);
}
// 4. 查数据库并写缓存
Product product = productMapper.selectById(id);
if (product != null) {
redis.setex(key, 300, JSON.toJSONString(product)); // 5分钟
}
return product;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
} finally {
lock.unlock(); // 释放锁
}
}
✅ 优点:
- 保证只有一个线程查 DB
- 数据强一致
- 适用于所有热点 key
⚠️ 注意事项:
- 必须做双重检查(Double Check),防止锁竞争后重复查 DB
- 锁超时时间要合理(避免死锁)
- 失败时要有 fallback 策略(如重试、返回默认值)
五、解决方案三:逻辑过期(Logical Expire)
原理:
缓存中不设物理过期时间,而是存储一个“逻辑过期时间”字段。后台异步更新,读请求发现过期则触发重建(但不阻塞)。
数据结构示例:
{
"data": { "id": 1001, "name": "iPhone 16" },
"expireTime": 1712345678 // 时间戳
}
流程:
- 请求读取缓存
- 若 expireTime < now,则异步启动重建任务
- 当前请求仍返回旧数据(容忍短暂不一致)
适用场景:
- 对实时性要求不高,但可用性要求极高的系统
- 如新闻、文章、非交易类数据
💡 优势:读请求永不阻塞,用户体验流畅
六、三大方案对比总结
| 永不过期 | 弱(有延迟) | ⭐⭐⭐⭐⭐ | ⭐ | 静态热点数据 |
| ✅ 互斥重建 | 强 | ⭐⭐⭐ | ⭐⭐⭐ | 通用推荐(商品、订单等) |
| 逻辑过期 | 最终一致 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | 高可用优先场景 |
📌 生产环境首选:互斥重建 + 分布式锁
七、避坑指南:常见错误实践
❌ 错误 1:只加本地锁(synchronized)
后果:多实例部署时,每个 JVM 各自加锁,DB 仍被打爆 正解:必须使用分布式锁(Redis、ZooKeeper)
❌ 错误 2:忘记双重检查
后果:多个线程拿到锁后都查 DB 正解:加锁后再次检查缓存是否存在
❌ 错误 3:锁没有设置超时
风险:线程异常退出,锁永远不释放 → 死锁 正解:tryLock 必须带超时时间
八、结语
感谢您的阅读!如果你有任何疑问或想要分享的经验,请在评论区留言交流!





