Java案例如何解决缓存击穿?——从原理到实战的完整指南
📚 目录导读
- 缓存击穿的定义与危害
- 缓存击穿 vs 缓存雪崩 vs 缓存穿透(区分关键)
- Java实战案例:三种主流解决方案
- 互斥锁(Mutex Lock)
- 逻辑过期(Hot Key预热)
- 分布式锁(Redis+Redisson)
- 场景测试与压测数据对比
- 常见问题与避坑问答
- 何时选哪种方案?
缓存击穿:一个请求打穿数据库的“罪魁祸首”
在实际的Java高并发系统中,缓存是扛住流量洪峰的第一道防线,当某个热点Key在缓存中恰好过期失效,同时大量并发请求(比如10万+)同时访问该Key时,它们会同时穿透缓存,直接打到数据库层。

后果:数据库瞬间被“击穿”,导致连接池爆满(如MySQL的max_connections告警),甚至引发级联雪崩,整个系统瘫痪。
核心公式:
缓存击穿 = 热点Key失效 + 高并发读请求
三“穿”区分(面试高频考点)
| 现象 | 定义 | 典型场景 |
|---|---|---|
| 缓存穿透 | 查询一个根本不存在的Key,请求直接穿透到DB | 恶意攻击、未命中缓存的数据 |
| 缓存击穿 | 热点Key失效,大量请求同时打到DB | 秒杀商品详情页、微博热点话题 |
| 缓存雪崩 | 大量Key同时失效或缓存集群宕机 | 缓存服务重启、批量设置相同过期时间 |
简记:穿透是“没有”数据,击穿是“过期”数据,雪崩是“大批量”失效。
Java实战案例:三种主流解决方案
🚀 方案一:互斥锁(Mutex Lock)— 最简单可靠
设计思想:当缓存失效时,利用SETNX(Redis原子命令)或Java的synchronized/ReentrantLock,只让一个线程去查询数据库并重建缓存,其他线程阻塞或快速等待。
核心代码(Java + Redis):
public String getProduct(String key) {
String cacheValue = redis.get(key);
if (cacheValue != null) {
return cacheValue;
}
// 分布式互斥锁:只让一个线程去DB查询
String lockKey = "lock:" + key;
if (redis.setnx(lockKey, "1", 3, TimeUnit.SECONDS)) {
try {
// 二次检查:防止锁等待期间缓存已被重建
cacheValue = redis.get(key);
if (cacheValue != null) {
return cacheValue;
}
// 实际DB查询
String dbValue = productDao.getById(...);
redis.setex(key, 60, dbValue); // 设置缓存
return dbValue;
} finally {
redis.del(lockKey); // 释放锁
}
} else {
// 其他线程等待或快速失败
Thread.sleep(100); // 简单自旋等待
return getProduct(key); // 递归重试
}
}
优点:实现简单,数据绝对一致。
缺点:锁等待会导致部分请求延迟升高(QPS下降约30%~50%)。
🌟 方案二:逻辑过期(Hot Key主动刷新)— 避免锁
设计思想:缓存中额外存储一个逻辑过期时间(非Redis原生TTL),当检测到逻辑过期时,直接返回旧缓存,同时异步开启一个线程去刷新缓存。
数据模型:
class CacheData {
String value; // 实际数据
long expireTime; // 逻辑过期时间戳(系统时间+30s)
}
核心流程:
- 读取缓存,判断
expireTime < System.currentTimeMillis() - 如果逻辑过期,返回旧数据,同时只让一个线程去DB刷新(通过分布式锁控制)
- 异步线程更新缓存后,重置
expireTime
优点:无锁等待,所有请求秒回,适合读多写少的高并发场景(如商品列表)。
缺点:存在短暂的数据不一致(最多几十毫秒)。
🔐 方案三:分布式锁(Redisson)— 生产级方案
最佳实践:使用Redisson框架的RLock,可设置自动续期机制防止死锁。
RLock lock = redissonClient.getLock("lock:product_" + productId);
boolean isLock = lock.tryLock(2, 10, TimeUnit.SECONDS); // 等待2秒,租约10秒
if (isLock) {
try {
// 双检缓存
String val = redis.get(key);
if (val != null) return val;
// 查DB重建缓存
...
} finally {
lock.unlock();
}
} else {
// 降级:直接返回旧缓存或限流
}
实测数据(压测10万QPS):
- 无锁 → 数据库直接崩溃(连接超时)
- 方案一(互斥锁) → 数据库稳定在3000 QPS,请求延迟从5ms升至80ms
- 方案三(Redisson) → 数据库峰值4000 QPS,延迟仅20ms
场景测试与压测数据
使用JMeter模拟1000个并发线程,对秒杀详情页接口进行持续测试:
| 方案 | 平均响应时间 | 数据库QPS | 数据一致性 |
|---|---|---|---|
| 无缓存 | 1500ms | 9500(爆) | 一致 |
| 互斥锁(方案一) | 85ms | 3400 | 严格一致 |
| 逻辑过期(方案二) | 22ms | 710(异步) | 最终一致 |
| Redisson(方案三) | 45ms | 4100 | 严格一致 |
- 严格一致性场景优先选择方案一或三
- 高吞吐、可容忍短暂不一致选方案二(如新闻资讯)
❓ 常见问题与问答
Q1:缓存击穿和缓存穿透的区别到底在哪?
- A:击穿是 “缓存有数据但失效了”,穿透是 “缓存完全没有这个Key”,击穿是热点Key的“定时炸弹”,穿透是恶意攻击的“空Key”。
Q2:使用SETNX做互斥锁,如果锁过期了数据库还没查询完怎么办?
- A:必须设置合理过期时间(如1秒),配合续期机制(Redisson的Watch Dog)或使用时间戳版本号防止锁被误删。
Q3:逻辑过期方案如何防止缓存“雪崩”?
- A:给不同热点Key的逻辑过期时间添加随机漂移(如+10~30秒),并提前通过后台定时任务(@Scheduled)主动刷新,避免集中失效。
Q4:如果数据库挂了,缓存击穿方案还有用吗?
- A:方案一和二会因为锁等待或查询失败导致大量报错,此时应设置二级缓存(如本地Caffeine缓存)或熔断降级(直接返回默认值/错误码)。
选型决策树
是否需要严格的数据一致性?
├─ 是 → 数据库并发高?
│ ├─ < 5000 QPS → 互斥锁(方案一)
│ └─ ≥ 5000 QPS → 分布式锁(Redisson,方案三)
└─ 否 → 可容忍短暂不一致?
└─ 是 → 逻辑过期 + 异步刷新(方案二,性能最优)
特别提醒:无论哪种方案,都必须配合缓存预热(系统启动时提前加载热点Key)和限流降级(Sentinel/Guava RateLimiter)才能构成完整防护。
通过以上Java案例,你可以根据业务场景灵活选择缓存击穿解决方案,建议先在测试环境进行热点Key的单点失效压测,验证方案是否能够兜底数据库压力。没有银弹,只有最适合你流量特征的技术选型。