对于缓存,你们确定都不陌生,不论是前端仍是服务端开发,缓存几乎都是必不可少的优化方式之一。在实际生产环境中,缓存的使用规范也是一直备受重视的,若是使用的很差,很容易就遇到缓存击穿、雪崩等严重异常情景,从而给系统带来难以预料的灾害。前端
为了不缓存使用不当带来的损失,咱们有必要了解每种异常产生的缘由和解决办法,从而作出更好的预防措施。程序员
缓存穿透
而缓存穿透是指缓存和数据库中都没有的数据,这样每次请求都会去查库,不会查缓存,若是同一时间有大量请求进来的话,就会给数据库形成巨大的查询压力,甚至击垮db系统。web
好比说查询id为-1的商品,这样的id在商品表里确定不存在,若是没作特殊处理的话,攻击者很容易可让系统奔溃,那咱们该如何避免这种状况发生呢?面试
通常来讲,缓存穿透经常使用的解决方案大概有两种:redis
1、缓存空对象算法
当缓存和数据都查不到对应key的数据时,能够将返回的空对象写到缓存中,这样下次请求该key时直接从缓存中查询返回空对象,就不用走db了。固然,为了不存储过多空对象,一般会给空对象设置一个比较短的过时时间,就好比像这样给key设置30秒的过时时间:数据库
redisTemplate.opsForValue().set(key, null, 30, TimeUnit.SECONDS);
这种方法会存在两个问题:数组
- 若是有大量的key穿透,缓存空对象会占用宝贵的内存空间。
- 空对象的key设置了过时时间,这段时间内可能数据库恰好有了该key的数据,从而致使数据不一致的状况。
这种状况下,咱们能够用更好的解决方案,也就是布隆过滤器缓存
2、Bloom Filter服务器
布隆过滤器(Bloom Filter)是1970年由一个叫布隆的小伙子提出的,是一种由一个很长的二进制向量和一系列随机映射函数构成的几率型数据结构,这种数据结构的空间效率很是高,能够用于检索集合中是否存在特定的元素。
设计思想
布隆过滤器由一个长度为m比特的位数组(bit array)与k个哈希函数(hash function)组成的数据结构。原理是当一个元素被加入集合时,经过K个散列函数将这个元素映射成一个位数组中的K个点,把它们置为1。检索时,咱们只要看看这些点是否是都是1就大约知道集合中有没有它了,也就是说,若是这些点有任何一个0,则被检元素必定不在;若是都是1,则被检元素极可能在。
至于说为何都是1的状况只是可能存在检索元素,这是由于不一样的元素计算的哈希值有可能同样,会出现哈希碰撞,致使一个不存在的元素有可能对应的比特位为1。
举个例子:下图是一个布隆过滤器,共有18个比特位,3个哈希函数。当查询某个元素w时,经过三个哈希函数计算,发现有一个比特位的值为0,能够确定认为该元素不在集合中。
优缺点
优势:
- 节省空间:不须要存储数据自己,只须要存储数据对应hash比特位
- 时间复杂度低:基于哈希算法来查找元素,插入和查找的时间复杂度都为O(k),k为哈希函数的个数
缺点:
- 准确率有误:布隆过滤器判断存在,可能出现元素不在集合中;判断准确率取决于哈希函数的个数
- 不能删除元素:若是一个元素被删除,可是却不能从布隆过滤器中删除,这样进一步致使了不存在的元素也会显示1的状况。
适用场景
- 爬虫系统url去重
- 垃圾邮件过滤
- 黑名单
缓存击穿
缓存击穿从字面上看很容易让人跟穿透搞混,这也是不少面试官喜欢埋坑的地方,固然,只要咱们对知识点了然于心的话,面试的时候也不会那么被糊弄
简单来讲,缓存击穿是指一个key很是热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就好像堤坝忽然破了一个口,大量洪水汹涌而入。
当发生缓存击穿的时候,数据库的查询压力会倍增,致使大量的请求阻塞。
解决办法也不难,既然是热点key,那么说明该key会一直被访问,既然如此,咱们就不对这个key设置失效时间了,若是数据须要更新的话,咱们能够后台开启一个异步线程,发现过时的key直接重写缓存便可。
固然,这种解决方案只适用于不要求数据严格一致性的状况,由于当后台线程在构建缓存的时候,其余的线程颇有可能也在读取数据,这样就会访问到旧数据了。
若是要严格保证数据一致的话,能够用互斥锁
互斥锁
互斥锁就是说,当key失效的时候,让一个线程读取数据并构建到缓存中,其余线程就先等待,直到缓存构建完后从新读取缓存便可。
若是是单机系统,用JDK自己的同步工具Synchronized或ReentrantLock就能够实现,但通常来讲,都达到防止缓存击穿的流量了谁还搞什么单机系统,确定是分布式高大上点啊,这种状况咱们就能够用分布式锁来作互斥效果。
为了大家能更懂流程,做为暖男的我仍是一如既往的给大家准备了伪代码啦:
public String getData(String key){ String data = redisTemplate.opsForValue().get(key); if (StringUtils.isNotEmpty(data)){ return data; } String lockKey = this.getClass().getName() + ":" + key; RLock lock = redissonClient.getLock(lockKey); try { boolean boo = lock.tryLock(5, 5, TimeUnit.SECONDS); if (!boo) { // 休眠一下子,而后再请求 Thread.sleep(200L); data = getData(key); } // 读取数据库的数据 data = getDataByDB(key); if (StringUtils.isNotEmpty(data)){ // 把数据构建到缓存中 setDataToRedis(key,data); } } catch (InterruptedException e) { // 异常处理,记录日志或者抛异常什么的 }finally { if (lock != null && lock.isLocked()){ lock.unlock(); } } return data; }
固然,采用互斥锁的方案也是有缺陷的,当缓存失效的时候,同一时间只有一个线程读数据库而后回写缓存,其余线程都处于阻塞状态。若是是高并发场景,大量线程阻塞势必会下降吞吐量。这种状况该如何处理呢?我只能说没什么设计是完美的,你又想数据一致,又想保证吞吐量,哪有那么好的事,为了系统能更加健全,必要的时候牺牲下性能也是能够采起的措施,二者之间怎么取舍要根据实际业务场景来决定,万能的技术方案什么的根本不存在。
缓存雪崩
缓存雪崩也是key失效后大量请求打到数据库的异常状况,不过,跟缓存击穿不一样的是,缓存击穿由于指一个热点key失效致使的状况,而缓存雪崩是指缓存中大批量的数据同时过时,巨大的请求量直接落到db层,引发db压力过大甚至宕机,这也符合字面上的“雪崩”说法。
解决方案
缓存雪崩的解决方案和击穿的思路一致,能够设置key不过时或者互斥锁的方式。
除此以外,由于是预防大面积的key同时失效,能够给不一样的key过时时间加上随机值,让缓存失效的时间点尽可能均匀 ,这样能够保证数据不会在同一时间大面积失效。
redisTemplate.opsForValue().set(Key, value, time + Math.random() * 1000, TimeUnit.SECONDS);
同时还能够结合主备缓存策略来让互斥锁的方式更加的可靠,
主缓存:有效期按照经验值设置,设置为主读取的缓存,主缓存失效后从数据库加载最新值。
备份缓存:有效期长,获取锁失败时读取的缓存,主缓存更新时须要同步更新备份缓存。
通常来讲,上面三种缓存异常场景问的比较多,了解这几种基本就够了,但有些面试官可能喜欢剑走偏锋,进一步延伸其余的异常情景作询问,以防万一,咱们也加个菜,介绍下另外两种常见缓存异常。
缓存预热
缓存预热就是系统上线后,先将相关的数据构建到缓存中,这样就能够避免用户请求的时候直接查库。
这部分预热的数据主要取决于访问量和数据量大小,若是数据的访问量不大的话,那么就不必作预热,都没什么多少请求了,直接按正常的缓存读取流程执行就好。
访问量大的话,也要看数据的大小来作预热措施。
- 数据量不大的时候,工程启动的时候进行加载缓存动做,这种数据通常能够是电商首页的运营位之类的信息;
- 数据量大的时候,设置一个定时任务脚本,进行缓存的刷新;
- 数据量太大的时候,优先保证热点数据进行提早加载到缓存,而且确保访问期间不能更改缓存,好比用定时器在秒杀活动前30分钟就把商品信息之类的刷新到缓存,同时规定后台运营人员不能在秒杀期间更改商品属性。
缓存降级
缓存降级是指缓存失效或缓存服务器挂掉的状况下,不去访问数据库,直接返回默认数据或访问服务的内存数据。
在项目实战中一般会将部分热点数据缓存到服务的内存中,相似HashMap、Guava这样的工具,一旦缓存出现异常,能够直接使用服务的内存数据,从而避免数据库遭受巨大压力。
固然,这样的操做对于业务是有损害的,分布式系统中很容易就出现数据不一致的问题,因此,通常这种状况下,咱们都优先保证从运维角度确保缓存服务器的高可用性,好比Redis的部署采用集群方式,同时作好备份,总之,尽可能避免出现降级的影响。
最后
关于缓存的几大异常处理咱们就讲解到这了,虽然每种异常咱们都给出了解决的方案,但不是说这玩意直接套上就能用了。现实开发过程当中仍是要根据实际状况来针对缓存作相应措施,好比用布隆过滤器预防缓存穿透虽然颇有效,但并不算特别经常使用,这年头,防止恶意攻击什么的都是先在运维层面作限制,业务代码层面更多的是对参数和数据作校验。
若是每一个使用缓存的地方都要考虑的这么复杂的话,那工做量无疑会更加繁杂,过分设计只会让代码维护起来也麻烦,并且实用性还不必定强,不必啊。程序员嘛,给本身增添烦恼的事情越少越好,毕竟咱们最大的敌人不是996,而是那珍贵的发量啊。
本文同步分享在 博客“鄙人薛某”(CSDN)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。