概念
Redis 缓存击穿,出现在Redis一条热点数据失效之后,导致大量请求进入到服务器之后,发现缓存中没有对应的数据,于是压力转移到了数据库上。
Redis 缓存雪崩,出现在Redis多条热点数据因为失效时间为同一个时间,同时失效,导致大量请求进入到服务器之后,发现缓存中没有对应的数据,于是压力转移到了数据库上。
换粗击穿和缓存雪崩的关系应该是 1 和 n 的关系,所以解决的思路大致相近,都可以通过设置热点数据永不过期,设置互斥锁解决。缓存雪崩除了这两个方式之外,还可以通过在设置失效时间的时候,加上一些随机数来避免大量缓存随时失效。(强迫症向来都是设置整数的过期时间)
代码上如何解决
本文主在通过Java代码用互斥锁 和 信号量 两种思路来解决缓存击穿 & 缓存雪崩的问题。
1. Redis 分布式锁
但需要热点数据的请求进入到服务器时,可以通过Redis 为相应的热点数据设置一把分布式锁,来让一个请求去数据库中取得数据,然后放入缓存。其他请求等待,或者快速降级,来避免数据库多次取得相同信息而降级数据库的服务质量。
在整一个过程中,主要需要考虑的因素有分布式锁怎么加 和 取到 / 没有取到分布式锁的线程处理方式的不同。
分布式锁的处理方式需要注意锁的获取/释放、锁的失效时间两点。锁如果只获取,不释放,那么其他需要热点的请求就会一直被卡住,导致请求永远失败,直到锁被释放。锁的失效也是同理,Java Redis中,锁的获取和设置锁的过期时间由于不是一个原子操作,导致如果这两个原子操作之间,出现了异常情况,比如说服务器重启,在获取锁之后,又进入到了锁的获取 / 释放的场景。
代码如下(示例):
public Object get(String key) {
Object value = redisService.get(key);
if(value != null) {
return value;
}
try {
// Lock 操作包括两步 setIfAbsent 和 expire
while(!redisService.lock("Lock:" + key, "1", 60 * 20)) {
//这块可以考虑 把线程置入sleep 状态 和 设置重试的次数 减少服务器压力
}
//取到分布式锁
// 二次检查 在首个线程获取到权限之后 会取到数据 然后放到缓存中
value = redisService.get(key);
if(value != null) {
return value;
}
// 取数据
value = service.function();
redisService.set(key, value);
return value;
} catch(Exception e) {
throw new RuntimeException("1");
} finally {
redisService.unlock("Lock:" + key);
}
}
public boolean lock(String key, String value, long expireSeconds) {
key = this.addVersion(key);
if (redisTemplate.opsForValue().setIfAbsent(key, value)) {
redisTemplate.expire(key, expireSeconds, TimeUnit.SECONDS);
return true;
}
return false;
}
因为本文的重点是缓存击穿 和 缓存雪崩,所以锁的失效就用了最简单但稳定性也不高的做法。实际上有很多种时间方式,用Lua脚本,或者Redission框架都可以替代。
public boolean lock(String key, String value, long expireSeconds) {
if (redisTemplate.opsForValue().setIfAbsent(key, value)) {
// 锁的过期时间一定不能忘 但服务器出问题之后 无法保存代码执行的状态
redisTemplate.expire(key, expireSeconds, TimeUnit.SECONDS);
return true;
}
return false;
}
2. Java Semaphore
如果说 Redis分布式锁,是所有服务所有线程只能有唯一一个去数据库中取得对应的数据并且修改缓存的话。那么通过Java Semaphore的方式,就是通过限流的方式缓解问题。
Java Semaphore类似与CountDownLatch,但实际的使用场景和它相反。Semaphore 是 指定能够访问某种资源的通行证,而CountDownLatch则是达到了某个临界值之后,临界反应。通过Semaphore 指定在缓存失效之后,最多有多少个线程能够同时访问数据库取到对应的数据库,来防止过多的线程进入到了数据库层面。
代码如下(示例):
static Semaphore semaphore = new Semaphore(10);
public Object get(String key) {
Object value = redisService.get(key);
if(value != null) {
return value;
}
try {
semaphore.acquire();
// 二次检查 在首个线程获取到权限之后 会取到数据 然后放到缓存中
value = redisService.get(key);
if(value != null) {
return value;
}
// 取数据
value = service.function();
redisService.set(key, value, 60 * 20);
return value;
} catch(Exception e) {
//处理异常
throw new RuntimeException("Something error");
} finally {
semaphore.release();
}
}
总结
从代码和解释中,容易看到的是 从一个Redis实现的分布式锁变成了一个本地实现的并发控制。从粒度上,将一个分布式系统的影响降低到了单机系统。