1. 缓存击穿现象的本质与危害
Redis缓存击穿是指某个热点key在缓存失效的瞬间,大量并发请求直接穿透缓存层打到数据库上的现象。这种现象通常发生在以下场景中:
- 某个key承载的业务访问量极大(比如首页推荐商品、秒杀活动商品)
- 该key设置了过期时间(业务主动设置或Redis内存淘汰机制触发)
- key过期瞬间恰好遇到流量高峰
我曾在电商大促期间遇到过典型的缓存击穿案例:某爆款商品详情页的缓存key在00:00准时失效,而秒杀活动也在同一时间开始。结果导致QPS从平时的2000瞬间飙升到8万+,MySQL连接池直接被撑爆,整个商品服务瘫痪了近3分钟。
缓存击穿与缓存雪崩、缓存穿透的区别在于:
- 雪崩是大面积key同时失效
- 穿透是查询不存在的数据
- 击穿是单个热点key失效引发的连锁反应
2. 互斥锁方案的核心实现
2.1 分布式锁的基本原理
互斥锁方案的核心思想是:当缓存失效时,只允许一个线程去重建缓存,其他线程等待或返回旧值。Redis实现分布式锁通常用SETNX命令:
bash复制SETNX lock_key unique_value EX 10 NX
这里有几个关键参数需要注意:
- unique_value建议使用UUID或线程ID,避免误删其他线程的锁
- EX 10表示10秒自动过期,防止死锁
- NX表示只有key不存在时才设置
2.2 完整代码实现示例
以下是Java版本的互斥锁实现:
java复制public String getData(String key) {
String value = redisTemplate.opsForValue().get(key);
if (value == null) {
String lockKey = "lock:" + key;
String lockValue = UUID.randomUUID().toString();
try {
// 尝试获取锁
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, 30, TimeUnit.SECONDS);
if (locked != null && locked) {
// 获取锁成功,查询数据库
value = database.query(key);
// 写入缓存
redisTemplate.opsForValue().set(key, value, 1, TimeUnit.HOURS);
} else {
// 未获取到锁,短暂休眠后重试
Thread.sleep(100);
return getData(key);
}
} catch (Exception e) {
log.error("getData error", e);
} finally {
// 确保只删除自己的锁
String currentValue = redisTemplate.opsForValue().get(lockKey);
if (lockValue.equals(currentValue)) {
redisTemplate.delete(lockKey);
}
}
}
return value;
}
2.3 锁方案的注意事项
-
锁过期时间:建议设置为业务处理时间的3-5倍。我曾遇到过设置10秒过期,但大数据查询需要15秒的情况,导致多个线程同时重建缓存。
-
锁等待策略:直接sleep虽然简单,但在高并发下会大量占用线程资源。更好的做法是:
- 使用Redis的订阅/通知机制
- 采用退避算法(Exponential Backoff)
-
锁删除安全:必须用Lua脚本保证判断和删除的原子性:
lua复制if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
3. 逻辑过期方案的实现细节
3.1 数据结构的特殊设计
逻辑过期方案的核心是不依赖Redis的TTL,而是在value中嵌入过期时间:
json复制{
"data": "真实数据",
"expire": 1672531200 // Unix时间戳
}
3.2 缓存重建的异步机制
当发现数据逻辑过期时,不是立即重建缓存,而是:
- 立即返回已过期的数据(保证可用性)
- 异步发起缓存重建任务
java复制public String getDataWithLogicExpire(String key) {
String json = redisTemplate.opsForValue().get(key);
if (StringUtils.isEmpty(json)) {
return null;
}
DataWrapper wrapper = JSON.parseObject(json, DataWrapper.class);
if (wrapper.getExpire() <= System.currentTimeMillis()/1000) {
// 触发异步重建
threadPoolExecutor.execute(() -> {
try {
String newValue = database.query(key);
DataWrapper newWrapper = new DataWrapper(newValue,
System.currentTimeMillis()/1000 + 3600);
redisTemplate.opsForValue().set(key, JSON.toJSONString(newWrapper));
} catch (Exception e) {
log.error("rebuild cache error", e);
}
});
}
return wrapper.getData();
}
3.3 方案对比与选型建议
| 维度 | 互斥锁方案 | 逻辑过期方案 |
|---|---|---|
| 数据一致性 | 强一致 | 最终一致 |
| 实现复杂度 | 较高(需处理锁) | 较低 |
| 适用场景 | 对一致性要求高的场景(如支付) | 允许短暂不一致的场景(如商品详情) |
| 系统吞吐量 | 受锁竞争影响 | 更高 |
| 额外开销 | 锁竞争带来的延迟 | 需要维护异步线程池 |
在电商系统中,我通常这样混用两种方案:
- 商品详情页用逻辑过期(允许短暂不一致)
- 库存扣减用互斥锁(必须强一致)
4. 生产环境中的进阶优化
4.1 热点key自动发现
通过Redis的monitor命令或开源工具(如京东hotkey)实时监测热点key。我曾用以下Shell脚本识别热点:
bash复制redis-cli monitor |
awk -F '"' '{print $2}' |
sort | uniq -c | sort -nr | head -10
4.2 多级缓存架构
对于极端热点数据(如顶流明星婚讯微博),可以采用:
- JVM本地缓存(Caffeine)作为一级缓存
- Redis集群作为二级缓存
- 数据库作为最后防线
配置示例:
java复制Caffeine<Object, Object> caffeine = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(5, TimeUnit.MINUTES);
Cache<Object, Object> cache = caffeine.build();
4.3 压测与熔断策略
在预发布环境用JMeter模拟缓存击穿场景,观察:
- 数据库QPS变化
- 线程池使用情况
- 接口响应时间
配置Hystrix熔断规则:
java复制HystrixCommandProperties.Setter()
.withCircuitBreakerRequestVolumeThreshold(20) // 20个请求
.withCircuitBreakerErrorThresholdPercentage(50) // 50%错误率
.withCircuitBreakerSleepWindowInMilliseconds(5000); // 5秒熔断
4.4 Redis集群的特别注意事项
在Cluster模式下,锁的实现需要调整:
- 使用Redlock算法(官方不推荐)
- 改用基于Zookeeper的分布式锁
- 每个分片单独加锁(降低一致性要求)
我在实际项目中更倾向第三种方案:
java复制// 对每个可能的master节点加锁
for (RedisNode node : clusterNodes) {
try {
jedis = pool.getResource(node);
locked = jedis.set(lockKey, value, "NX", "EX", 10) != null;
if (locked) break;
} finally {
if (jedis != null) jedis.close();
}
}
5. 面试中的深度问题准备
当面试官问"缓存击穿"时,他们可能想考察:
- 是否理解现象本质(与雪崩、穿透的区别)
- 方案选择的权衡能力(一致性 vs 可用性)
- 实际工程经验(参数设置、异常处理)
建议准备以下问题的回答:
- 互斥锁方案中,为什么需要设置锁过期时间?
- 逻辑过期方案如何避免缓存永久失效?
- 如何监控系统中的热点key?
- Redis主从切换时会出现什么锁问题?
我在技术评审中最常发现的错误是开发者在锁方案中忽略了:
- 未设置锁过期时间导致死锁
- 删除锁时未校验owner导致误删
- 未考虑网络分区时的脑裂问题
