今天和大家分享Redis作为缓存使用时常见的问题以及解决方案。像缓存穿透、缓存失效(击穿)以及缓存雪崩等问题的解决。
Redis中间件在高并发的系统设计中基本上是比不可少的元素。其中在作为缓存使用时有一些问题需要我们处理好。例如缓存穿透,先解释一下什么是缓存穿透?
缓存穿透是指查询一个根本不存在的数据导致缓存层和持久层都不命中。缓存失去了保护持久层的意义。一般我们都会这样做,数据请求进来,我们会先去缓存中查询,当缓存中没有的时候在去持久层查询,持久层查询以后将查询到的数据写到缓存中,如果持久层没有查询到,就不会写入到缓存中。而正是这种压根不存在的数据如果请求量大的时候最终都会打到持久层,从而导致持久层挂掉。整个系统崩溃。那怎么解决?
解决方案:
缓存空对象
我们把数据库中没查到的数据也放入到缓存中,并为这个空对象设置一个过期时间,这样在这个空对象的过期时间内后面的请求都会去缓存中拿,从而减小了数据库的压力。
代码演示:
@Autowired private StringRedisTemplate stringRedisTemplate; @Autowired private RedisCacheService cacheService; @RequestMapping(value = "/cache_pierce") @ResponseBody public String cachePierce(String key){ if (key == null || key.equals("")){ return "key参数为空"; } String cacheData = stringRedisTemplate.opsForValue().get(key); if(cacheData != null){ //缓存中返回 return cacheData; }else { //缓存为空,去查询数据库,这里就模拟一下 String s = cacheService.searchDB(key); stringRedisTemplate.opsForValue().set(key,s); if(s.equals("null")){ stringRedisTemplate.expire(key,30, TimeUnit.SECONDS); System.out.println("缓存空对对象了"); } return s; } } //模拟查询数据库 public String searchDB(String key){ //我就认为数据库查询到了 if(key != null && key.equals("fafa")){ System.out.println("持久层查到了"); return "fafa"; }else { try { Thread.sleep(1000); }catch (Exception exception){ exception.printStackTrace(); } System.out.println("持久层中不存在"); return "null"; } }
布隆过滤器
对于网络上的恶意请求,我们也还可以使用布隆过滤器进行处理。对于不存在的数据布隆过滤器一般都能过滤掉。也能都防止缓存穿透的事情发生。当请求的数据进来,先经过布隆过滤器,布隆过滤器说这个数据存在,这个数据可能存在,也可能不存在。但是,如果布隆过滤器说这个数据不存在,那这个数据肯定不存在。下面通过一张图简单分析一下布隆过滤器的原理:
布隆过滤器就是一个大型的位数组和几个不一样的无偏 hash 函数。所谓无偏就是能够把元素的 hash 值算得比较均匀。向布隆过滤器中添加 key 时,会使用多个 hash 函数对 key 进行 hash 算得一个整数索引值然后对位数组长度进行取模运算得到一个位置,每个 hash 函数都会算得一个不同的位置。再把位数组的这几个位置都置为 1 就完成了 add 操作。向布隆过滤器询问 key 是否存在时,跟 add 一样,也会把 hash 的几个位置都算出来,看看位数组中这几个位置是否都为 1,只要有一个位为 0,那么说明布隆过滤器中这个key 不存在。如果都是 1,这并不能说明这个 key 就一定存在,只是极有可能存在,因为这些位被置为 1 可能是因为其它的 key 存在所致。如果这个位数组比较稀疏,这个概率就会很大,如果这个位数组比较拥挤,这个概率就会降低。这种方法适用于数据命中不高、 数据相对固定、 实时性低(通常是数据集较大) 的应用场景, 代码维护较为复杂, 但是缓存空间占用很少。
Redisson就可以实现布隆过滤器:
引入依赖:
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.6.5</version> </dependency>
代码如下:
@Autowired private Redisson redisson; //redisson需要先注入到spring中 RBloomFilter<Object> bloomFilter = redisson.getBloomFilter("nameList"); //初始化布隆过滤器:预计元素为100000000L,误差率为3%,根据这两个参数会计算出底层的bit数组大小 bloomFilter.tryInit(100000000L,0.03); bloomFilter.add("tuofafa"); //contains方法返回true,表示这个key在缓存中存在,否则,反之 System.out.println(bloomFilter.contains("xiaohua")); System.out.println(bloomFilter.contains("tuofafa"));
注意:使用布隆过滤器要提前把数据放入到缓存中去,并且在增加数据时也要往布隆过滤器中放。布隆过滤器不能删除数据,如果要删除数据需要重新初始化数据。
缓存失效或者缓存击穿是由于大批量的数据同时失效导致一瞬间大量的请求都打到持久层,导致持久层资源耗尽最终系统崩溃。那导致大批量数据同时失效是因为我们在缓存中存入数据的时候设置的过期时间都是一样的。
解决方案:对大批量的缓存数据的过期时间我们给他设置一个时间范围内。这样,一瞬间过期的几率就很小。
代码如下:
@Autowired private StringRedisTemplate stringRedisTemplate; @Autowired private RedisCacheService cacheService; if (key == null || key.equals("")){ return "key参数为空"; } String cacheData = stringRedisTemplate.opsForValue().get(key); if(cacheData != null){ System.out.println("从缓存中返回"); return cacheData; }else { //缓存为空,去查询数据库,这里就模拟一下 String s = cacheService.searchDB(key); stringRedisTemplate.opsForValue().set(key,s); if(s.equals("null")){ //设置一段时间范围没的时间 int time = new Random().nextInt(100)+100; stringRedisTemplate.expire(key,time, TimeUnit.SECONDS); } return s; }
缓存雪崩是指缓存层支撑不住或者宕机了,所有的流量都疯狂的涌向了持久层。缓存层承载着大量的请求,能够有效的保护持久层,但是如果缓存层设计的不好或者某些原因导致宕机,从而最终就会出现雪崩效应。导致系统无法正常运行。
解决方案:
“缓存+过期时间”的策略既可以加速数据读写, 又保证数据的定期更新, 这种模式基本能够满足绝大部分需求。 但是有两个问题如果同时出现, 可能就会对应用造成致命的危害:
在缓存失效的瞬间, 有大量线程来重建缓存, 造成后端负载加大, 甚至可能会让应用崩溃。要解决这个问题主要就是要避免大量线程同时重建缓存。我们可以利用互斥锁来解决,此方法只允许一个线程重建缓存, 其他线程等待重建缓存的线程执行完, 重新从缓存获取数据即可。
在超高并发的情况下,同时操作数据库和缓存可能会存在数据的不一致性问题。
通过一张图说明什么是双写不一致
通过上图可以看出,有两个线程T1和T2,T1先开始写数据库,过了一会,T2也开始写数据库,T2写完数据库更新最新的数据到缓存,这时,T1线程完成了对数据库的写操作。也把数据更新到缓存。很明显,数据库最新的数据应该是T2,但是缓存中却是T1。这就是双写不一致。
读写并发不一致如上图。在双写不一致的时候,网上有人爆出说什么“延迟双删”,顾名思义就是延迟一段时间在删除,那问题来了,延迟多少时间删除合适。所以没有解决根本问题。
1、对于并发几率很小的数据(如个人维度的订单数据、用户数据等),这种几乎不用考虑这个问题,很少会发生缓存不一致,可以给缓存数据加上过期时间,每隔一段时间触发读的主动更新即可。
2、就算并发很高,如果业务上能容忍短时间的缓存数据不一致(如商品名称,商品分类菜单等),缓存加上过期时间依然可以解决大部分业务对于缓存的要求。
3、如果不能容忍缓存数据不一致,可以通过加读写锁保证并发读写或写写的时候按顺序排好队,读读的时候相当于无锁。
4、也可以用阿里开源的canal通过监听数据库的binlog日志及时的去修改缓存,但是引入了新的中间件,增加了系统的复杂度。
以上我们针对的都是读多写少的情况加入缓存提高性能,如果写多读多的情况又不能容忍缓存数据不一致,那就没必要加缓存了,可以直接操作数据库。放入缓存的数据应该是对实时性、一致性要求不是很高的数据。切记不要为了用缓存,同时又要保证绝对的一致性做大量的过度设计和控制,增加系统复杂性。