一、缓存穿透
原理:用户的大量请求未命中到缓存,如客户端发送了一个数据库没有的请求给缓存,缓存发现没有该key对应的数据(数据库没有缓存肯定不会有)即命令发送到数据库请求获取,数据库也没有key对应的数据即返回空,但是这样就不会存到缓存中。导致每次该请求都会到达数据库进行无用操作,缓存就形同虚设(穿过缓存到数据库请求,缓存命中率问题)。
发生原因:业务代码问题(如没调缓存或缓存设置有问题)、恶意攻击(如故意用不存在的key请求接口)
优化:
- 缓存空对象:即不管请求的数据返回了什么,哪怕是空,也存储到缓存(
设置个默认值,也缓存起来
)。这样就会导致更多的键放到了缓存,包括不存在的乱七八糟的key(bigkey),通过设置过期时间降低风险。也有可能硬盘在某个瞬间有问题导致请求访问出错,缓存这时候保存了个null,导致缓存和存储层数据不一致。 - 使用布隆过滤器拦截(很小内存实现对数据的过滤):服务开始时先将所以key以一种算法使数据以最简的方式加入到布隆过滤器并预热,在每次请求发生时都会先查看该请求对应的key是否存在,不存在直接返回null而不进行其他请求(将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉)。对于相对固定的数据比较实用。
二、缓存更新策略
缓存更新策略(缓存中数据过期时机,直接影响到缓存数据与数据库数据的一致性)有如下三种:
- LRU、LFU、FIFO算法移除:设置当缓存达到最大内存之后才进行删除,该策略一致性最差但维护成本低。其中FIFO算法:先进先出,即先缓存,先被淘汰。LFU算法,把最不经常使用的缓存淘汰。LRU算法,把近期最少使用的缓存淘汰。
- 超时移除:给缓存中每个key设置超时时间,超过时间即删除,该策略一致性较差但维护成本低
- 主动更新:在数据库数据发生变化时主动让缓存进行更新,例如订阅模式,数据库发生修改主动提醒缓存层修改数据。该策略一致性高但维护成本也高
建议:一般生产环境一致性要求较高,所以使用超时移除和主动更新结合。
三、缓存粒度控制
定义:添加缓存层之后会将数据库获取到的数据缓存到redis中,而存储到redis中的数据量就是缓存粒度问题。如数据库中查出很多数据,但是只有部分数据会使用,是否将数据库中的全部属性添加到缓存,直接影响存储量。从通用角度考虑(如后期业务扩展)肯定全部存储较好,但是从空间占用角度考虑,还是存储有用数据比较好。需要结合实际应用场景考虑。
四、无底洞问题
定义:当集群机器数量添加到一定数量之后会发现再加机器,集群的性能没提升反而下降,即更多机器并不意味着更高的性能,可能会因为需要更多时间进行节点通信、客户端使用批处理命令需要的时间更长(请求次数增加)等因素照成性能下降。
优化:
- 尽量少用慢查询的命令及查询所有(all)等的命令
- 尽量减少网络通信次数,即客户端发到服务端的请求次数
五、缓存雪崩问题及优化
定义:正常情况下redis服务承载着大量的请求,到达存储层的请求比较少。当redis服务异常/宕机,或者大批原有缓存失效而未有新缓存到达(如把缓存都设置了相同的过期时间导致同一时刻大量缓存过期),这时候如果有大量请求过来都直接打到存储层,压向后端组件(mysql),对存储层造成巨大压力,极易造成其宕机等,因为其一般无法承受太大压力。
优化:
- 保证缓存高可用,保证一台宕机缓存仍能正常使用
- 可以通过给数据库的读写操作加锁或者队列方式保证同一时间不会有大量并发读写数据库的请求线程造成雪崩现象(限流)。但是这种优化在高并发下,会导致线程阻塞而用户等待超时。另外在分布式环境下要注意分布式锁的问题,高并发下不建议使用。
- 给每个缓存的固定失效时间加个随机数,这样就会降低每个缓存的过期时间重复率,即将缓存失效时间分散开,缓存大批失效的概率就降低了。
- 提前演练:压力测试等
-
使用缓存标记及缓存数据,缓存标记用于记录数据是否过期,过期则让后台线程更新对应缓存,而缓存的数据会比缓存标记时间长,即缓存标记在认为某个key过期时,会让后台进程去更新该key对应缓存,但这个时候缓存数据其实还没过期,请求过来时会返回久的缓存数据给请求,当后台线程更新完之后的请求才会返回新的缓存数据。
代码示例:
(1)加锁操作代码:
public object GetProductListNew() {
int cacheTime = 30;
String cacheKey = "product_list";
String lockKey = cacheKey;
String cacheValue = CacheHelper.get(cacheKey); //到缓存取数据
if (cacheValue != null) {
return cacheValue;
} else {
synchronized(lockKey) {
cacheValue = CacheHelper.get(cacheKey); //再到缓存获取数据,有可能其他线程更新了
if (cacheValue != null) {
return cacheValue;
} else {
cacheValue = GetProductListFromDB(); //到sql查找数据
CacheHelper.Add(cacheKey, cacheValue, cacheTime);
}
}
return cacheValue;
}
}
(2)给每一个缓存数据增加相应的缓存标记,记录缓存是否失效,如果缓存标记失效,则更新数据缓存:
public object GetProductListNew() {
int cacheTime = 30;
String cacheKey = "product_list";
//缓存标记
String cacheSign = cacheKey + "_sign";
String sign = CacheHelper.Get(cacheSign); //查看缓存标记是否失效
String cacheValue = CacheHelper.Get(cacheKey); //获取缓存数据
if (sign != null) {
return cacheValue; //未过期,直接返回
} else {
CacheHelper.Add(cacheSign, "1", cacheTime);
ThreadPool.QueueUserWorkItem((arg) -> {
//这里一般是 sql查询数据
cacheValue = GetProductListFromDB();
//日期设缓存时间的2倍,用于脏读
CacheHelper.Add(cacheKey, cacheValue, cacheTime * 2);
});
return cacheValue;
}
}
六、缓存预热
定义:在系统上线或使用人数较少时先将数据加载到缓存中,这样就不会造成开始时用户请求全部都会到数据库查询的问题。
操作:
- 当数据量较小时项目启动自动加载
- 定义一个缓存操作,进行手动操作
- 夜间定时将数据缓存
七、缓存降级
定义:由于系统访问量剧增导致服务出现问题(响应慢等),这时候应尽量舍弃非核心(可以丢弃)服务而保证核心服务可用,即对非核心服务进行降级操作。可以让程序实施自动降级,也可以人工紧急降级。另外服务一般都会进行分级,对于不同的异常情况对不同的服务进行降级。
八、热点key重建
定义:(基本同雪崩)对于热点key(访问量较大的key)失效时,有对应的请求则需要到缓存层重新获取,因为热点key访问的请求较多,同一时间由于高并发可能会有多个线程同时到缓存查数据发现没有,即到存储层进行查找并进行对key的缓存进行重建,就会出现同时有多个线程到数据库进行查找该key,对缓存层造成压力。另外数据库操作时间久的key也可能会出现这种问题。
解决:
- 互斥锁: 在一个线程发现缓存需要重建时,加锁,完成重建后释放锁。在这个过程中有其他线程发现缓存没数据,要进行重建时发现线程被锁住了,则进入等待状态,直到锁解开再获取数据。这种方式优点是思路简单,保证一致性。缺点为代码复杂,存在死锁风险,且会存在大量线程等待问题。
- 热点key永不过期:在缓存层面,即redis上不为key设置过期时间(expire),但是在功能层面上为每个value设置逻辑过期时间,即在缓存的时候顺便为对应value添加一个逻辑过期时间属性,在每次get到value时会顺便拿出逻辑过期时间,判断到逻辑过期时间过期,会用单独的线程为其作缓存的重建。但是会存在数据不一致问题,因为每次拿到缓存就使用,在发现逻辑时间过期会掉重建,但是输出的依然是旧值,此时如果重建时间久,接下来的请求依然会使用久的值知道重建完成,容易曹诚数据不一致。重建过程一样添加互斥锁,相对上面好就好在重建过程依然可以在缓存获取到数据而不用进行等待。该方式优点是杜绝热点key重建 。 缺点是不保证一致性,逻辑过期时间属性增加了维护成本和内存成本。
互斥锁:
String get(String key){
String value=redis.get(key);
if(value==null){
String mutexKey=”mutex:key:”+key;
if(redis.set(mutexKey,”1”,”ex 180”,”nx”)){
value=db.get(key);
redis.set(key,value);
redis.delete(mutexKey);
}else{
//其他线程休息50ms后重试
Thread.sleep(50);
get(key);
}
}
return value;
}
永不过期:
String get(String key){
V v=redis.get(key);
String value = v.getValue();
long logicTimeout = v.getLogicTimeout();
if(logicTimeout>=System.currentTimeMillis()){
String mutexKey=”mutex:key:”+key;
if(redis.set(mutexKey,”1”,”ex 180”,”nx”)){
//异步更新后台异常执行
threadPool.execute(new Runnable(){
public void run(){
String dbValue=db.get(key);
redis.set(key,(dbValue,newLogicTimeout));
redis.delete(keyMutex);
}
});
}
}
return value;
}