一、缓存穿透、缓存击穿、缓存雪崩
一、缓存穿透
是什么:指查询一个在数据库里也根本不存在的数据,导致请求每次都直接绕开缓存,打到数据库上。
解决方案:
- 缓存空对象:当数据库查询也为空时,将这个空结果(或特殊标记,如
NULL、#)也缓存起来,并设置一个较短的过期时间(如5分钟)。后续请求就能命中缓存,直接返回空。 - 布隆过滤器:这是更专业的方案。在查询缓存前,先用布隆过滤器(一个快速判断“某个元素一定不存在或可能存在”的概率数据结构)判断Key是否存在。如果布隆过滤器说“不存在”,则直接返回,不再查询缓存和数据库。
- 接口层校验:对请求参数做严格的合法性校验,比如ID必须是正整数、有固定格式等,把非法请求挡在最外层。
二、缓存雪崩
是什么:指大量缓存Key在同一时间段内集体过期,或者缓存服务集群整体宕机,导致所有请求都涌向数据库,造成数据库瞬时压力激增甚至崩溃。
解决方案:
- 错峰过期:给缓存Key的过期时间加上一个随机值(例如,基础时间 + 随机1-5分钟),让失效时间点分散开。
- 高可用架构:使用Redis集群,做主从、哨兵或Cluster模式,避免单点故障导致整个缓存层不可用。
- 服务降级与熔断:当数据库压力过大时,对非核心业务进行降级(直接返回默认值、错误页),或对数据库访问进行熔断(快速失败,不再访问),保护数据库。
- 提前预热:在系统高峰来临前(如电商大促前),提前加载热点数据到缓存,并设置合理的、错峰的过期时间。
二、缓存击穿
是什么:指一个访问量极高的热点Key在缓存过期瞬间,大量请求同时涌入,去数据库加载数据,导致数据库瞬时压力过大甚至被打挂。
解决方案:
- 第一级防御(预防) :热点数据设置为永不过期
- 第二级防御(并发控制) :添加互斥锁,同一时刻只允许一条请求查询数据库
- 第三级防御(兜底) :服务降级,防止服务雪崩
1、永不过期策略的数据更新机制
在实际实现中,"永不过期"数据的更新问题该如何处理?
采用后台主动推送 + 定时查询的方式。当数据更新时删除原有缓存,等待下次查询添加,或者直接修改原缓存的值,同时用一个定时查询任务做兜底,去定期更新热点key的值。
补充方案:订阅数据库变更日志(CDC)来触发缓存失效。具体流程:
- 使用Canal或Debezium监听MySQL的Binlog
- 当订单表发生UPDATE时,解析Binlog提取被修改行的主键
- 发送消息到MQ,由独立的缓存刷新服务消费并删除对应缓存
- 优势:解耦业务代码与缓存失效逻辑,实现秒级实时性
2、 互斥锁的精细化实现
添加互斥锁的实现细节:
- 锁的粒度:基于"服务+接口+唯一ID"作为key,防止此ID在其他接口同样被存入缓存,造成Redis的key冲突。
- 锁的实现:使用Redisson工具类,它里面提供了完善的看门狗续签机制和RedLock算法。
- 获取锁失败后的操作:对于没有拿到锁的请求,可以休眠后重试,也可以自旋。我更倾向于休眠后重试,因为自旋会消耗CPU,高并发情况下多条请求同时自旋对CPU消耗太大。同时会设置重试次数,防止无限重试消耗性能。
3、 降级与熔断机制
降级策略可以直接失败,也可以返回默认值,我倾向于直接返回默认值,防止请求失败写入错误日志导致日志过多。
服务降级的预设条件是什么?(可自配)
- 失败率过高:如10秒内超过50%的请求失败
- 线程池耗尽:隔离的线程池和队列已满
- 请求超时:默认1秒未返回则触发降级
Hystrix配置示例:
java
@HystrixCommand(
fallbackMethod = "getProductFallback",
commandProperties = {
@HystrixProperty(name = "circuitBreaker.errorThresholdPercentage", value = "50"),
@HystrixProperty(name = "circuitBreaker.requestVolumeThreshold", value = "10"),
@HystrixProperty(name = "circuitBreaker.sleepWindowInMilliseconds", value = "5000"),
@HystrixProperty(name = "maxQueueSize", value = "100")
},
threadPoolProperties = {
@HystrixProperty(name = "coreSize", value = "20")
}
)
public Product getProduct(String productId) {
// ... 业务逻辑
}
三、分布式锁的深度对比
3.1 Redisson看门狗机制具体是如何工作的?
第一阶段:加锁过程(lock.lock())
1. 客户端调用lock()
java
RLock lock = redisson.getLock("myLock");
lock.lock(); // 加锁
2. 客户端发送Lua脚本到Redis
Redisson发送一个原子性的Lua脚本到Redis:
lua
-- KEYS[1]: 锁的名称,如"myLock"
-- ARGV[1]: 锁的过期时间(默认30秒,30000毫秒)
-- ARGV[2]: 客户端唯一标识,格式:UUID:线程ID,如"123e4567-e89b-12d3-a456-426614174000:1"
-- 1. 检查锁是否存在
if (redis.call('exists', KEYS[1]) == 0) then
-- 2. 不存在,创建hash结构,设置客户端标识和重入次数为1
redis.call('hset', KEYS[1], ARGV[2], 1);
-- 3. 设置过期时间
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
-- 4. 锁已存在,检查是否是当前客户端持有
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
-- 5. 是当前客户端,重入次数+1
redis.call('hincrby', KEYS[1], ARGV[2], 1);
-- 6. 重置过期时间
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
-- 7. 锁被其他客户端持有,返回锁的剩余生存时间
return redis.call('pttl', KEYS[1]);
3. Redis服务端执行脚本
- Redis原子性地执行上述Lua脚本
- 成功时:设置锁key,value为hash结构
{客户端标识: 重入次数},并设置30秒过期时间 - 失败时:返回锁的剩余生存时间,客户端会循环尝试获取
第二阶段:看门狗启动与续期
4. 看门狗线程启动
加锁成功后,Redisson在客户端内部启动一个看门狗线程(Watchdog):
java
// Redisson内部逻辑(简化版)
private void scheduleExpirationRenewal() {
// 创建一个定时任务,每10秒执行一次
Timeout task = commandExecutor.getConnectionManager()
.newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
// 续期逻辑
renewExpiration();
}
},
lockWatchdogTimeout / 3, // 默认10秒(30秒/3)
TimeUnit.MILLISECONDS);
// 将任务加入调度
expirationRenewalMap.put(lockName, task);
}
关键点:
- 看门狗线程在客户端JVM内运行,不是Redis服务端的
- 默认每10秒执行一次续期检查(锁默认30秒过期)
- 每个锁实例有独立的看门狗线程
5. 看门狗执行续期操作
看门狗线程每10秒执行一次续期Lua脚本:
lua
-- KEYS[1]: 锁的名称
-- ARGV[1]: 客户端唯一标识
-- ARGV[2]: 续期时间(默认30秒)
-- 1. 检查锁是否仍由当前客户端持有
if (redis.call('hexists', KEYS[1], ARGV[1]) == 1) then
-- 2. 是,则重置过期时间为30秒
redis.call('pexpire', KEYS[1], ARGV[2]);
return 1; -- 续期成功
end;
return 0; -- 续期失败(锁已被释放或不属于当前客户端)
续期失败的情况:
- 客户端主动释放了锁
- 锁被其他客户端抢占
- Redis连接异常
如果续期失败,看门狗线程会停止,但客户端业务线程可能还在执行,这可能导致数据不一致。
第三阶段:释放锁过程(lock.unlock())
6. 客户端调用unlock()
java
lock.unlock(); // 释放锁
7. 客户端发送释放锁的Lua脚本
lua
-- KEYS[1]: 锁的名称
-- ARGV[1]: 锁的过期时间
-- ARGV[2]: 客户端唯一标识
-- 1. 检查锁是否由当前客户端持有
if (redis.call('hexists', KEYS[1], ARGV[2]) == 0) then
-- 不是当前客户端持有,返回nil
return nil;
end;
-- 2. 减少重入次数
local counter = redis.call('hincrby', KEYS[1], ARGV[2], -1);
if (counter > 0) then
-- 3. 重入次数>0,重置过期时间,不删除锁
redis.call('pexpire', KEYS[1], ARGV[1]);
return 0;
else
-- 4. 重入次数=0,删除锁
redis.call('del', KEYS[1]);
-- 5. 发布释放锁的消息,通知其他等待的客户端
redis.call('publish', KEYS[2], ARGV[3]);
return 1;
end;
8. 停止看门狗线程
释放锁后,Redisson会取消对应的看门狗定时任务:
java
// 从expirationRenewalMap中移除并取消定时任务
Timeout task = expirationRenewalMap.remove(lockName);
if (task != null) {
task.cancel(); // 停止看门狗线程
}
3.2 极端场景下的风险分析
提问:假设业务执行时间非常长,同时应用程序发生长时间Full GC(45秒),看门狗机制会遇到什么问题?
分析:这会导致Redisson无法及时续签,锁过了超时时间会自动释放。虽然不会产生死锁,但同时可能业务逻辑执行到一半锁就被其他线程占有,产生数据问题。这可以看出Redis分布式锁的局限性:依赖网络通讯,服务端无法感知客户端的情况。
3.3 Redis锁与ZooKeeper/etcd锁的对比
| 维度 | 基于Redis的锁(Redisson) | 基于ZooKeeper的锁 | 基于etcd的锁 |
|---|---|---|---|
| 原理 | 基于有过期时间的Key | 基于临时顺序节点 | 基于Lease租约和Revision版本号 |
| 可靠性 | AP系统,极端场景可能不一致 | CP系统,强一致性 | CP系统,强一致性 |
| 性能 | 非常高(内存操作) | 较低(需要达成共识) | 中等(高于ZK) |
| 复杂性 | 较低,部署简单 | 较高,需要维护集群 | 中等 |
在发生长时间GC或网络分区的场景下:
- Redis锁:服务端无法感知客户端状态,可能导致"客户端已失效却仍持有锁"的幻觉
- ZooKeeper/etcd锁:通过会话/租约机制,服务端可主动清理失效锁,避免状态幻觉
3.5 RedLock算法的原理与争议
算法步骤详解:
- 获取当前时间T1
- 依次向N个节点发送加锁命令
- 计算加锁总耗时T2 = 当前时间 - T1
判断加锁成功标准: 半数以上的节点加锁成功且T2 < lock_timeout时,加锁成功
依次获取锁主要是为了:
- 避免时钟跳跃问题;
- 串行化可以精确获取各个节点的加锁时长;
- 将操作简化,更容易处理错误。
RedLock的适用场景:
- 需要可用性而对一致性要求没那么高的系统
- 有时钟同步保障的系统
- 持有锁的时间很短或者业务代码本身就有补偿性和幂等性