缓存击穿模式:互斥锁、逻辑过期、分布式锁,以及热点Key的解决方案
缓存击穿,说白了就是某个热点Key突然失效了,然后大量请求直接怼到数据库上。我刚开始做高并发系统时,就被这个问题坑过一次——某个秒杀活动的商品详情页,缓存刚好在流量高峰时过期,数据库瞬间被打爆,整个服务都跟着雪崩了。
嗯,今天我们就来聊聊怎么解决这个问题。我会结合自己踩过的坑,把几种主流方案掰开揉碎了讲清楚。
什么是缓存击穿?
先明确一下概念。缓存击穿指的是:一个热点Key在缓存中过期,大量并发请求同时穿透缓存,直接打到数据库。
你想想看,平时缓存扛着99%的请求,数据库岁月静好。突然缓存里那个最热的数据没了,成千上万个请求同时涌向数据库——这谁顶得住?
这和缓存雪崩不一样。雪崩是大量Key同时失效,击穿是单个热点Key失效。但破坏力同样惊人。
核心问题:热点Key失效 → 大量并发请求 → 数据库压力暴增 → 可能引发服务雪崩
方案一:互斥锁(Mutex Lock)
这是最直观的方案。当缓存失效时,只让一个线程去查数据库重建缓存,其他线程等待。等缓存重建好了,后面的请求直接走缓存。
我习惯用Redis的SETNX命令来实现这个锁。Java里可以用Redisson,C++里可以用Redis的原子操作。
来看代码:
// Java实现 - 基于Redis的互斥锁
public String getData(String key) {
String value = redis.get(key);
if (value != null) {
return value;
}
// 尝试获取锁
String lockKey = "lock:" + key;
boolean locked = redis.setnx(lockKey, "1", 3, TimeUnit.SECONDS);
if (locked) {
try {
// 双重检查,防止重复查询
value = redis.get(key);
if (value != null) {
return value;
}
// 查询数据库
value = queryFromDB(key);
redis.set(key, value, 60, TimeUnit.SECONDS);
return value;
} finally {
redis.delete(lockKey);
}
} else {
// 没拿到锁,等待重试
Thread.sleep(50);
return getData(key); // 递归重试
}
}
我的经验:互斥锁方案简单可靠,但要注意锁的超时时间。设太短,业务还没执行完锁就释放了;设太长,万一持有锁的线程挂了,其他线程会一直等。我一般设3-5秒,配合监控告警。
方案二:逻辑过期(Logical Expiration)
互斥锁有个问题:它会阻塞请求。如果数据库查询很慢,所有线程都得等着。逻辑过期方案可以解决这个问题。
思路是这样的:缓存永不过期,但数据里带一个逻辑过期时间。每次读取时,检查逻辑时间是否过期。如果过期了,异步去更新缓存,同时返回旧数据给调用方。
// Java实现 - 逻辑过期方案
public class CacheItem<T> {
private T data;
private long expireTime; // 逻辑过期时间戳
}
public T getData(String key) {
CacheItem<T> item = redis.get(key);
if (item == null) {
return null; // 缓存不存在,走降级
}
// 检查逻辑是否过期
if (item.getExpireTime() > System.currentTimeMillis()) {
return item.getData(); // 未过期,直接返回
}
// 逻辑过期,尝试获取更新锁
String lockKey = "renew:" + key;
boolean locked = redis.setnx(lockKey, "1", 1, TimeUnit.SECONDS);
if (locked) {
// 异步线程去更新缓存
executor.submit(() -> {
try {
T newData = queryFromDB(key);
CacheItem<T> newItem = new CacheItem<>();
newItem.setData(newData);
newItem.setExpireTime(System.currentTimeMillis() + 60000);
redis.set(key, newItem);
} finally {
redis.delete(lockKey);
}
});
}
// 返回旧数据
return item.getData();
}
这个方案的好处是:读请求永远不会被阻塞。用户看到的数据可能延迟几毫秒,但系统整体吞吐量很高。
我曾经踩过的坑:逻辑过期方案在极端情况下,如果异步更新线程一直失败,旧数据会一直返回。一定要加监控和兜底策略。我后来加了一个「强制刷新」接口,运维可以手动触发。
方案三:分布式锁(Distributed Lock)
当你的服务部署了多个实例,单机锁就不够用了。这时候需要分布式锁。
Redis分布式锁是最常见的实现方式。我一般用Redisson,它封装了锁的自动续期、可重入等特性。
// Java实现 - Redisson分布式锁
public String getDataWithDistributedLock(String key) {
String value = redis.get(key);
if (value != null) {
return value;
}
RLock lock = redissonClient.getLock("lock:" + key);
try {
// 尝试加锁,最多等待100ms,锁有效期30s
boolean locked = lock.tryLock(100, 30, TimeUnit.SECONDS);
if (locked) {
// 双重检查
value = redis.get(key);
if (value != null) {
return value;
}
value = queryFromDB(key);
redis.set(key, value, 60, TimeUnit.SECONDS);
return value;
} else {
Thread.sleep(50);
return getDataWithDistributedLock(key);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return queryFromDB(key); // 降级
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
我建议:分布式锁不是万能的。锁的粒度要尽量小,锁的持有时间要尽量短。如果业务逻辑很重,考虑用「分段锁」或者「读写锁」来优化。
热点Key的解决方案
热点Key是缓存击穿的「重灾区」。有些Key的访问量可能是普通Key的几百倍。比如微博热搜、电商大促的商品详情。
我总结了几种应对策略:
| 策略 | 说明 | 适用场景 |
|---|---|---|
| 本地缓存 | 在应用层加一层本地缓存(如Caffeine、Guava Cache) | 读多写少、数据量小 |
| Key分散 | 把热点Key拆成多个子Key,分散到不同Redis节点 | 单个Key访问量极大 |
| 永不过期 | 热点Key不设过期时间,由后台定时更新 | 数据变化不频繁 |
| 读写分离 | 主库写,从库读,缓存失效时从从库重建 | 有主从架构 |
我个人最常用的是「本地缓存 + 分布式缓存」两级缓存方案。本地缓存扛住大部分请求,分布式缓存做数据一致性保障。
// Java实现 - 两级缓存
public String getDataWithTwoLevelCache(String key) {
// 第一级:本地缓存
String value = localCache.get(key);
if (value != null) {
return value;
}
// 第二级:Redis缓存
value = redis.get(key);
if (value != null) {
localCache.put(key, value, 10, TimeUnit.SECONDS);
return value;
}
// 查数据库
value = queryFromDB(key);
redis.set(key, value, 60, TimeUnit.SECONDS);
localCache.put(key, value, 10, TimeUnit.SECONDS);
return value;
}
注意:本地缓存要控制大小和过期时间。我遇到过本地缓存撑爆堆内存的情况,后来用Caffeine的weakKeys和softValues解决了。
核心逻辑流程图
下面这张图展示了缓存击穿的核心处理流程:
方案对比与选型建议
这么多方案,到底选哪个?我根据自己的经验整理了一个对比表:
| 方案 | 优点 | 缺点 | 推荐场景 |
|---|---|---|---|
| 互斥锁 | 实现简单,数据一致性好 | 会阻塞请求,吞吐量下降 | 数据一致性要求高,并发量适中 |
| 逻辑过期 | 不阻塞请求,吞吐量高 | 数据短暂不一致,实现复杂 | 高并发,允许短暂不一致 |
| 分布式锁 | 支持多实例,可靠性高 | 依赖Redis,有网络开销 | 分布式部署,需要强一致性 |
| 热点Key方案 | 针对性解决热点问题 | 需要额外开发成本 | 有明显热点Key的场景 |
我的建议:没有银弹。我一般先评估业务对一致性的要求。如果允许短暂不一致,优先用逻辑过期方案。如果必须强一致,用互斥锁或分布式锁。热点Key一定要单独处理,别指望通用方案能扛住。
避坑指南
最后分享几个我踩过的坑:
- 锁超时时间设太短:我曾经把锁超时设成1秒,结果数据库查询用了2秒,锁提前释放了,多个线程同时查数据库。后来改成动态续期。
- 忘记释放锁:代码异常时锁没释放,导致死锁。一定要用try-finally或者try-with-resources。
- 热点Key没做本地缓存:所有请求都走Redis,Redis扛不住。加一层本地缓存后,Redis压力降了80%。
- 缓存重建太慢:数据库查询耗时100ms,所有请求都得等。后来用异步重建+预加载解决了。
嗯,缓存击穿这个问题,说难不难,说简单也不简单。关键是要理解每种方案的适用场景,然后根据业务特点做选择。希望今天的分享对你有帮助。
公众号:蓝海资料掘金营,微信deep3321