场景再现:
2022年某电商平台黑色星期五大促,某款爆款手机(SPU_ID=888)在零点突然降价。当缓存过期瞬间,每秒10万+的请求如潮水般涌向数据库,导致数据库连接池被打满,整个商品服务瘫痪15分钟...
一、缓存击穿的三大特征
graph TD
A[热点Key] --> B[缓存突然失效]
B --> C[高并发请求]
C --> D[数据库过载]
1.1 与传统问题的区别
| 缓存击穿 | 缓存穿透 | 缓存雪崩 | |
|---|---|---|---|
| 触发条件 | 单个热点Key失效 | 查询不存在的数据 | 大量Key同时失效 |
| 数据特征 | 真实存在的热点数据 | 非法/虚构的数据 | 正常业务数据 |
| 危害程度 | 可能引发连锁故障 | 资源消耗型攻击 | 系统性风险 |
二、传统解决方案的致命缺陷
2.1 简单互斥锁的问题
// 典型错误实现示例
public Object getData(String key) {
Object value = redis.get(key);
if (value == null) {
synchronized (this) { // 单机锁在分布式环境下失效
value = db.query(key);
redis.set(key, value);
}
}
return value;
}
缺陷分析:
- 单机锁无法应对分布式集群
- 未设置锁超时可能导致死锁
- 锁粒度过粗引发性能瓶颈
2.2 永不过期策略的隐患
// 伪代码:后台异步更新
public void init() {
scheduleAtFixedRate(() -> {
Object value = db.query(key);
redis.set(key, value); // 不设置过期时间
}, 0, 5, MINUTES);
}
风险点:
- 数据更新延迟可能长达5分钟
- 服务重启时缓存真空期
- 内存泄漏风险(无过期淘汰)
三、分布式互斥锁的工业级实现
3.1 Redisson分布式锁方案
public Object getDataWithLock(String key) {
RLock lock = redisson.getLock(key + ":lock");
try {
Object value = redis.get(key);
if (value != null) return value;
if (lock.tryLock(3, 30, TimeUnit.SECONDS)) { // 等待3秒,持有30秒
value = db.query(key);
redis.setex(key, 300, value); // 设置正常过期时间
return value;
} else {
return getDataWithLock(key); // 递归重试
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
关键设计点:
- 锁等待超时:防止线程饥饿
- 锁自动续期:通过看门狗机制(默认30秒)
- 锁标识校验:避免误删其他线程的锁
- 可重入设计:支持同一线程多次加锁
3.2 锁方案适用场景
- 数据强一致性要求高(如库存扣减)
- 重建成本高的复杂查询(涉及多表关联)
- 写少读多的热点数据(如明星商品详情)
四、逻辑过期方案的优雅实践
4.1 数据结构设计
{
"data": "{...}", // 真实业务数据
"expire_ts": 1672531200 // 逻辑过期时间戳
}
4.2 核心处理流程
sequenceDiagram
participant Client
participant Redis
participant ThreadPool
Client->>Redis: 1. 查询数据
alt 数据存在且未逻辑过期
Redis-->>Client: 直接返回数据
else 数据已逻辑过期
Redis-->>Client: 返回旧数据
Client->>ThreadPool: 2. 提交异步任务
ThreadPool->>Redis: 3. 获取分布式锁
Redis-->>ThreadPool: 获得锁
ThreadPool->>DB: 4. 查询最新数据
ThreadPool->>Redis: 5. 更新数据+逻辑过期时间
ThreadPool->>Redis: 释放锁
end
4.3 代码实现示例
// 逻辑过期时间(30分钟)
private static final long LOGIC_EXPIRE_TIME = 30 * 60;
public Object getDataWithLogicExpire(String key) {
String json = redis.get(key);
if (StringUtils.isNotEmpty(json)) {
DataWrapper wrapper = JSON.parseObject(json, DataWrapper.class);
if (wrapper.getExpireTs() > System.currentTimeMillis() / 1000) {
return wrapper.getData();
} else {
// 异步更新
executor.execute(() -> refreshData(key));
return wrapper.getData();
}
}
// 缓存不存在走互斥锁流程
return getDataWithLock(key);
}
private void refreshData(String key) {
RLock lock = redisson.getLock(key + ":lock");
try {
if (lock.tryLock()) {
Object newData = db.query(key);
DataWrapper wrapper = new DataWrapper(
newData,
System.currentTimeMillis() / 1000 + LOGIC_EXPIRE_TIME
);
redis.set(key, JSON.toJSONString(wrapper));
}
} finally {
lock.unlock();
}
}
五、方案对比与选型指南
| 维度 | 分布式互斥锁方案 | 逻辑过期方案 |
|---|---|---|
| 数据一致性 | 强一致性 | 最终一致性(最大延迟30秒) |
| 用户体验 | 可能有短暂等待 | 始终快速响应 |
| 实现复杂度 | 中(需处理锁问题) | 高(需维护两套时间体系) |
| 适用场景 | 金融交易、库存管理等 | 商品详情、资讯类内容 |
| 风险点 | 锁竞争可能影响性能 | 可能返回旧数据 |
六、生产环境增强策略
6.1 监控体系搭建
graph TD
A[Prometheus] --> B[缓存命中率]
A --> C[锁等待时间]
A --> D[异步更新成功率]
B --> E[Grafana看板]
C --> E
D --> E
6.2 熔断降级配置
# Sentinel配置示例
spring:
cloud:
sentinel:
datasource:
ds1:
nacos:
server-addr: localhost:8848
dataId: degrade-rules
rule-type: DEGRADE
降级策略:
- 当缓存未命中率 > 40% 时自动降级
- 直接返回默认值(如商品库存显示"库存紧张")
6.3 热点发现系统
// 基于滑动窗口的热点发现
public class HotKeyDetector {
private ConcurrentHashMap<String, LongAdder> counter = new ConcurrentHashMap<>();
private ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
public void init() {
scheduler.scheduleAtFixedRate(() -> {
counter.entrySet().removeIf(entry -> {
if (entry.getValue().sum() > 1000) { // 阈值
notifyHotKey(entry.getKey());
}
return true;
});
}, 1, 1, TimeUnit.SECONDS); // 每秒清理
}
}
七、血的教训:我们踩过的坑
7.1 锁超时时间设置不当
事故现象:某次大促期间出现库存超卖
原因分析:锁超时时间(10s) < 数据库查询时间(15s)
解决方案:
- 动态计算超时时间:
锁时间 = 平均查询时间 * 3 - 实现锁自动续期机制
7.2 逻辑过期时间同步问题
事故现象:集群节点时间不同步导致逻辑判断错误
解决方案:
- 使用NTP统一服务器时间
- 改用Redis服务器时间:
redis.time()
结语:没有银弹,只有平衡
缓存击穿的防御本质是在一致性、可用性、性能之间寻找平衡点。建议:
- 核心业务(如交易)使用互斥锁方案
- 非核心业务(如商品描述)使用逻辑过期
- 配合完善的监控和熔断机制
技术选型就像选择武器——互斥锁是精准的手术刀,逻辑过期是灵活的软猬甲,只有理解业务本质,才能做出最合适的选择。