Redis 缓存的使用,极大的提升了应用程序的性能和效率,特别是数据查询方面。但同时,它也带了一些问题。其中,最要害的是问题:数据一致性的问题,从严格意义上讲,这个问题无解。如果对数据的一致性要求很高,那么不能使用缓存。
另外一些典型的问题就是,缓存穿透、缓存雪崩、缓存击穿。
查询一个根本不存在的数据, 缓存层和存储层都不会命中, 通常出于容错的考虑, 如果从存储层查不到数据则不写入缓存层。缓存穿透将导致不存在的数据每次请求都要到存储层去查询, 失去了缓存保护后端存储的意义。
基本原因有:
(1)自身业务代码或者数据出现问题
(2)一些恶意攻击、 爬虫等造成大量空命中
(1)从缓存中去取,如果有就返回,如果没有就去数据库取。
(2)无论数据库有没有,都往缓存中设置一个对象,如果数据库没有就设置一个空对象,并设置过期时间。
(3)这样,当第二次再去缓存中取的时候,就会取到值,即使是一个空对象,但是这样解决了爬虫大量攻击数据库。
String get(String key) {
// 从缓存中获取数据
String cacheValue = cache.get(key);
// 缓存为空
if (StringUtils.isBlank(cacheValue)) {
// 从存储中获取
String storageValue = storage.get(key);
cache.set(key, storageValue);
// 如果存储数据为空, 需要设置一个过期时间(300秒)
if (storageValue == null) {
cache.expire(key, 60 * 5);
}
return storageValue;
} else {
// 缓存非空
return cacheValue;
}
}
用布隆过滤器先做一次过滤,对于不存在的数据布隆过滤器一般都能够过滤掉,不让请求再往后端发送。当布隆过滤器说某个值存在时,这个值可能不存在;当它说不存在时,那就肯定不存在。
布隆过滤器就是一个大型的位数组和几个不一样的无偏 hash 函数。所谓无偏就是能够把元素的 hash 值算得比较均匀。
(1)add操作:向布隆过滤器中添加 key 时,会使用多个 hash 函数对 key 进行 hash 算得一个整数索引值然后对位数组长度进行取模运算得到一个位置,每个 hash 函数都会算得一个不同的位置。再把位数组的这几个位置都置为 1 就完成了 add 操作。
(2)get操作:向布隆过滤器询问 key 是否存在时,跟 add 一样,也会把 hash 的几个位置都算出来,看看位数组中这几个位置是否都为 1,只要有一个位为 0,那么说明布隆过滤器中这个key 不存在。如果都是 1,这并不能说明这个 key 就一定存在,只是极有可能存在,因为这些位被置为 1 可能是因为其它的 key 存在所致。如果这个位数组长度比较大,存在概率就会很大,如果这个位数组长度比较小,存在概率就会降低。
注:布隆过滤器不能删除数据,如果要删除得重新初始化数据。
这种方法适用于数据命中不高、 数据相对固定、 实时性低(通常是数据集较大) 的应用场景, 代码维护较为复杂, 但是缓存空间占用很少。
布隆过滤器的使用:
①引入相关的依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.6.5</version>
</dependency>
②Java实现
public class RedissonBloomFilter {
public static void main(String[] args) {
Config config = new Config();
config.useSingleServer().setAddress("redis://localhost:6379");
//构造Redisson
RedissonClient redisson = Redisson.create(config);
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList");
//初始化布隆过滤器:预计元素为100000000L,误差率为3%,根据这两个参数会计算出底层的bit数组大小
bloomFilter.tryInit(100000000L,0.03);
//将whr插入到布隆过滤器中
bloomFilter.add("whr");
//判断下面号码是否在布隆过滤器中
System.out.println(bloomFilter.contains("naimei"));//false
System.out.println(bloomFilter.contains("lulu"));//false
System.out.println(bloomFilter.contains("whr"));//true
}
}
伪代码如下:
//初始化布隆过滤器
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList");
//初始化布隆过滤器:预计元素为100000000L,误差率为3%
bloomFilter.tryInit(100000000L,0.03);
//把所有数据存入布隆过滤器
void init(){
for (String key: keys) {
bloomFilter.put(key);
}
}
String get(String key) {
// 从布隆过滤器这一级缓存判断下key是否存在
Boolean exist = bloomFilter.contains(key);
if(!exist){
return "";
}
// 从缓存中获取数据
String cacheValue = cache.get(key);
// 缓存为空
if (StringUtils.isBlank(cacheValue)) {
// 从存储中获取
String storageValue = storage.get(key);
cache.set(key, storageValue);
// 如果存储数据为空, 需要设置一个过期时间(300秒)
if (storageValue == null) {
cache.expire(key, 60 * 5);
}
return storageValue;
} else {
// 缓存非空
return cacheValue;
}
}
缓存击穿是指一个 key 非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个 key 在失效的瞬间(过期),持续的大并发就穿破缓存,直接请求数据库,就像在一个屏障上凿开了一个洞。
与缓存穿透的区别:缓存穿透说的对象是根本不存在的值,而缓存击穿说的对象是一个热key过期所出现的问题
在redis高峰访问前,把一些热门数据提前存入redis中,加大这些热门数据key的时长实时调整 ,现场监控哪些数据是热门数据,实时调整key的过期时长(看情况设置)
在缓存失效的时候(判断拿出来的值为空),不是立即去查数据库,先使用缓存工具的某些带成功操作返回值的操作。比如redis的setnx去set一个mutex key,当操作返回成功时(分布式锁),再查数据库,并回设重建缓存,最后删除mutex key。
当操作返回失败,证明有线程在查询数据库,当前线程睡眠一段时间在重试整个get缓存的方法。
重建缓存的伪代码如下:
String get(String key) {
// 从Redis中获取数据
String value = redis.get(key);
// 如果value为空, 则开始重构缓存
if (value == null) {
// 只允许一个线程重建缓存, 使用nx, 并设置过期时间ex
String mutexKey = "mutext:key:" + key;
if (redis.set(mutexKey, "1", "ex 180", "nx")) {
// 从数据源获取数据
value = db.get(key);
// 回写Redis, 并设置过期时间
redis.setex(key, timeout, value);
// 删除key_mutex
redis.delete(mutexKey);
}// 其他线程休息50毫秒后重试
else {
Thread.sleep(50);
get(key);
}
}
return value;
}
缓存雪崩是指在某一个时间段,缓存集中过期失效。
由于缓存层承载着大量请求, 有效地保护了存储层, 但是如果缓存层由于某些原因不能提供服务(比如超大并发过来,缓存层支撑不住,或者由于缓存设计不好,类似大量请求访问bigkey,导致缓存能支撑的并发急剧下降), 于是大量请求都会打到存储层, 存储层的调用量会暴增, 造成存储层也会级联宕机的情况。
基本原因有两个:
(1)redis服务器挂掉了
(2)对缓存数据设置了相同的过期时间,导致某时间段内缓存集中失效
(1) 保证缓存层(redis)服务高可用性:比如使用Redis Sentinel或Redis Cluster。
(2) 依赖隔离组件为后端限流熔断并降级:
①使用Sentinel或Hystrix限流降级组件;
②服务降级:我们可以针对不同的数据采取不同的处理方式。当业务应用访问的是非核心数据(例如电商商品属性,用户信息等)时,暂时停止从缓存中查询这些数据,而是直接返回预定义的默认降级信息、空值或是错误提示信息;当业务应用访问的是核心数据(例如电商商品库存)时,仍然允许查询缓存,如果缓存缺失,也可以继续通过数据库读取。
(3) 数据预热:在正式部署之前,把可能的数据线预先访问一遍,这样部分可能大量访问的数据就会加载到缓存。在即将发生大并发访问前手动触发加载缓存不同的key,设置不同的过期时间,让缓存失效的时间点尽量均匀。
(4)设置不同的过期时间:设置缓存过期时间时加上一个随机值,避免缓存在同一时间过期。
设置不同的过期时间伪代码如下:
String get(String key) {
// 从缓存中获取数据
String cacheValue = cache.get(key);
// 判断缓存是否为空
if (StringUtils.isBlank(cacheValue)) {
// 从db中获取
String storageValue = storage.get(key);
// 无论数据是否为空,将数据设置到缓存
cache.set(key, storageValue);
//设置一个过期时间(300到600之间的一个随机数),这样每个时间的过期时间就不一致了
int expireTime = new Random().nextInt(300) + 300;
if (storageValue == null) {
cache.expire(key, expireTime);
}
return storageValue;
} else {
// 缓存非空
return cacheValue;
}
}