对于很多想晋升为架构师的同学来讲,缓存这部分内容是必不可少的。
那小谷今天给大家带来一篇“缓存”的万字干货内容,知识很多,慢慢看。
那么,缓存?说的是什么呢?
缓存,就是将一些需要读取数据放在磁盘或者内存中,但大部分为了追求速度,一般都放在内存中。
在读取数据的时候,一般是从关系型数据库中读取数据,在数据库层面也可以进行各种优化,例如读性能不足,这样可以添加几个从库,实现数据库的一主多从;例如写性能不足,那么可以分库分表。
在有些场景中,要使用缓存,是因为无法解决读的速度,例如count(*)的操作,无论从数据库的层面如何优化,都不可能提高;还有一种就是sql的执行本身就必须消耗很多资源和时间。
例如各种关联查询子查询,这些时候,都可以将这些数据放在缓存当中,从而大大的减轻数据库的压力。
01为什么使用缓存
发量支持非常不友好,如果并发量太多导致数据库压力太大可能导致数据库崩溃或者卡死。
缓存是能够最快提高服务响应速度的优化,没有之一。
如上图的应用程序直接连接数据库进行查询操作,假如有一个操作过来需要500ms,并且每一次查询都需要经过数据库,性能非常低,并且对于数据库并发量支持非常不友好,如果并发量太多导致数据库压力太大可能导致数据库崩溃或者卡死。
如上图,如果数据库崩溃,则依赖于数据库的其他应用都会无法运行
高性能
这个是大部分使用缓存的目的,能够最快以非常高的效率提高应用的性能
假设遇到一些查询速度很慢,比如权限,查询速度很慢,并且查询出来后很少发生变化,这种情况下大量查询对数据压力很大,并且性能不高。
我们将缓存中的key保存到缓存中,然后在需要查询的时候直接查询缓存,而不走数据库,这样响应数据非常快,并且对于数据库的压力很小。
一般缓存的查询都在微秒级,分布式缓存Redis中查询数据也在1ms中可以查询出来,这样在系统架构不进行大的变化的情况下完成了500倍的性能提升。
所以对于一些需要复杂操作耗时查出来的结果,确定后面不怎么变化,但是有很多读请求,直接将查询出来的结果放在缓存中,后面直接读缓存就好。
高并发
mysql 数据库对于高并发来说天然支持不好,mysql 单机支撑到 2000QPS 也开始容易报警了
所以若是系统高峰期一秒钟有1万个请求,那么一个 mysql 单机绝对会死掉,这个时候就只能上缓存,把很多数据放入缓存,别放入 mysql。
缓存功能简单,说白了就是 key-value 式操作,单机支撑的并发量一秒可达几万十几万,单机承载并发量是 mysql 单机的几十倍。
02收益与成本
收益
通过缓存加速读写速度:在内存中读写比硬盘速度快
降低数据库服务器的负载:比如业务端的请求的数据大多数都由Redis服务器来处理,大大减轻MySQL服务器的压力
成本
数据不一致问题:比如Redis服务器与数据库服务器之间的某些数据可能会发生不一致问题,这是由两个服务器的数据更新策略不同引起的
代码维护成本:需要添加数据缓存的逻辑代码
运维成本:比如需要维护RedisCluster
03缓存分类之系统划分
应用级缓存
应用级缓存也就是我们平时写的应用程序中所使用的缓存。
在平时程序中一般是按照如下操作流程来实现缓存的操作,首先张三用户读取数据库,并将读取的数据存入到缓存中,其他用户读取的时候,直接从缓存中读取,而不用查询数据库,从而提高程序的执行速度和效率。
CPU在操作数据的时候,先读取1级缓存,1级缓存如果没有数据则读取2级缓存,2级缓存没有数据则读取3级缓 存,3级缓存如果没有数据就直接从主存储器(存储指令和数据)读取数据。
04缓存分类之设计划分
本地缓存
直接运行在应用程序本地的缓存组件, 比如 JVM 中的 Map 数据结构,可以作为最简单的数据缓存。
如果你的应用程序只需要运行在一台服务器上,并且多个应用程序之间不需要共享缓存的数据(比如用户token),可以直接采用本地缓存,访问缓存时不需要通过网络传输,非常地方便迅速。
但是本地缓存会和你的应用程序强耦合,应用程序停止,本地缓存也就停止了,而且如果是在分布式场景下,多个机器都要使用缓存,此时如果在每个服务器上单独维护一份本地缓存,不仅无法共享数据,而且非常浪费内存(因为每台机器可能缓存了相同的数据)。
分布式缓存
分布式缓存是指独立的缓存服务,不和任何一个具体的应用耦合,可以独立运行并搭建缓存集群。
类似数据库,所有的应用程序都可以连接同一个缓存服务以获取相同的缓存数据。
除了数据共享外,分布式缓存的优点还有很多,比如不需要每台机器单独维护缓存、可以集中管理缓存和整体管控分析、便于扩展和容错等,但是应用必须要通过网络访问分布式缓存服务,会产生额外的网络开销成本
并且每台机器都有可能会对整个分布式缓存服务产生影响,而一旦分布式缓存挂了,所有的应用都可能出现瘫痪(缓存雪崩)。
多级缓存
上述两种缓存没有绝对的优劣,要根据实际的业务场景进行选型。
其实还可以将本地缓存与分布式缓存相结合,形成多级缓存服务,架构如下:
当首次查询时(不存在缓存),会同时将数据写入本地缓存和分布式缓存,之后的查询优先查询分布式缓存,而如果分布式缓存宕机,则从本地缓存获取数据,通过多级缓存机制,能够起到兜底的作用,即使缓存挂掉,也能支撑应用运行一段时间。
05缓存淘汰策略
使用了缓存后,缓存的容量是有限的,如果缓存满了之后如何淘汰一些数据呢?
刚刚提到的 Map 数据结构是一个思路,但是和我们自己的电脑存储文件、或者是和 JVM 存储对象一样,内存当然不是无限的。
因此在实现缓存时,必须要设计一套缓存淘汰策略,按照某种机制回收缓存占用的内存,保证缓存数据不会无限地增长直到撑爆内存。
LRU
近期少使用
LRU(Least Recently Used)是最经典的内存淘汰策略,其设计原则是 “如果一个数据在最近一段时间没有被访问到,那么在将来它被访问的可能性也很小”。
即根据数据的最近访问时间来进行淘汰,缺点是可能会由于一次冷数据的批量查询而误删除大量的热点数据。
近似 LRU 算法
类似 LRU 算法,只是每次随机选择一批数据进行 LRU 淘汰,而不是全量 LRU 运算,牺牲部分准确度,以提升算法执行效率。
Redis 3.0 之后对其进行了优化,维护了一个侯选池,将随机选择的数据放入侯选池中进行 LRU 运算。当侯选池放满后,新随机的数据会替换掉池中最近被访问的数据。
TTL
超时时间
TTL(Time To Live)是指用户为缓存设置的过期时间,当前时间到达过期时间时,将删除缓存;如果缓存空间已满,则优先淘汰最接近过期时间的数据。
LFU
最近最不经常使用
LFU(Least Frequently Used)策略会记录每个缓存数据的最近访问次数(频率),并优先清除使用次数较少的数据。这种算法存在的显著缺点是,最新写入的数据由于访问次数少,常常刚被缓存就删除了。
FIFO
先进先出
FIFO(First In First Out)先进先出策略会将数据按照写入缓存的顺序进行排队,当缓存空间不足时,最先进入缓存的数据会被优先删除。是一种比较死板的策略,不考虑数据热度,可能会淘汰大量的热点数据,但是实现起来相对容易。
Random
随机淘汰策略,没啥好说的,一般不建议使用
06缓存应用场景
下面我们分析下缓存使用的一些场景
频繁查询数据缓存
有一些数据经常被访问,而且变更频率较低,实时性要求不高的数据,可以把它存储到缓存中,每次读取数据直接读缓存即可,从而提升数据的加载速度和系统的性能。
列表排序分页数据
一些变更频率较低查询频次较高的列表、分页、排序数据,可以存入到Redis缓存,每次查询分页或者排序的时 候,直接从Redis缓存中获取
计数器
网站中用于统计访问频次、在线人数、商品抢购次数等,也可以使用缓存来实现。
详情内容
站点中,资讯内容、商品详情等较大变更频率又低的内容,可以采用缓存来加速数据的读取,降低IO操作。
分布式Session
实现会话共享的时候,可以使用Session来存储需要共享的会话,从而节省内存空间。
热点排名
我们可以使用ZSet来存储热数据,并实现热点数据的排名
发布订阅
用Redis也可以实现发布与订阅,但不推荐,推荐用MQ。
分布式锁
可以使用Redisson结合Redis实现分布式锁,Redis实现的分布式锁效率极高,得到了市场的广泛使用****
07使用缓存会遇到哪些问题
虽然缓存可以提高整体性能,但是它也可能会带来别的问题。
例如使用缓存之后,就相当于把数据存放了2份,一份是在数据库中,另一份存放在缓存中。
当有新的数据要写入或者旧数据需要更新的时候,如果我们只更新了其中一份数据源,那两边的数据就不一致了,所以这里就存在一个缓存数据与数据库数据如何进行有效且快速的同步问题,才可以保证数据的最终一致性。
另外,加上缓存服务其实也引入了系统架构的复杂度,还需要额外的关注缓存自身带来的下列问题:
缓存的过期问题
设计缓存的过期时间需要非常的有技巧,且必须与业务实际情况相结合。
因为如果设计的过期时间太短了,那会导致缓存效果不佳,且还会造成频繁的从数据库中往缓存里写数据。
如果缓存设计的过期时间太长了,又会导致内存的浪费,这里需要根据业务把控一个度。
缓存的命中率问题
这也是设计缓存中需要存放哪些数据的很重要一点,如果设计的不好,可能会导致缓存命中率过低,失去缓存效果。
一般对于热点数据而言,要保证命中率达到70%以上效果最佳,这里就要求并不是所有的业务都适合使用缓存。
08缓存一致性问题
缓存由于其适应高并发和高性能的特性,已经在项目中被广泛使用。在读取缓存方面,大家没啥疑问,都是按照下图的流程来进行业务操作。
但是在更新缓存方面,对于更新完数据库,是更新缓存呢,还是删除缓存,又或者是先删除缓存,再更新数据库,其实存在很大的争议。
被动失效
被动失效是给缓存设置过期时间,是保证最终一致性的解决方案。
在这种方案下,我们可以对存入缓存的数据设置过期时间,所有的写操作以数据库为准,对缓存操作只是尽最大努力即可,也就是说如果数据库写成功,缓存更新失败,那么只要到达过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓存。
但是这种方案在更新数据后需要等一段时间才能更新成最新值,时效性不高,并且依赖于过期时间,如果设置为永不过期,则可能缓存永远得不到更新。
主动更新
相对于被动失效,主动更新是大部分场景使用的方案,常见的更新方案有以下几种:
☑ 先更新数据库,再更新缓存
☑ 先删除缓存,再更新数据库
☑ 先更新数据库,再删除缓存
☑ 先更新数据库,再更新缓存
这套方案,一般是不被大家所接收的,主要有以下两方面原因
线程安全角度:
同时有请求A和请求B进行更新操作,那么有可能出现如下的场景
这就出现请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A更早更新了缓存。这就导致了脏数据,因此这一套方案被废弃掉不予考虑。
业务场景角度:
如果你是一个写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能。
如果你写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存,那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。
☑ 先删缓存,再更新数据库
我们会基于这个方案去实现缓存更新,但是不代表这个方案在并发情况下没问题
*数据不一致
同时有一个请求A进行更新操作,另一个请求B进行查询操作
上述情况就会导致不一致的情形出现,而且,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。
如何保证一致性
没有办法做到绝对的一致性,这是由CAP理论决定的,缓存系统适用的场景就是非强一致性的场景,所以,我们得委曲求全,可以去做到BASE理论中说的最终一致性。
最终一致性强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态,因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。
达到最终一致性的解决思路,主要是针对先删缓存,再更新数据库/先更新数据库,再删缓存的策略导致的脏数据问题,进行相应的处理,来保证最终一致性。
延时双删
先删缓存,再更新数据库,然后等待一段时间,在删除缓存,等待数据完全落盘后删除缓存完成同步操作。
伪代码
如何确定休眠时间
延时双删为了保证第二次删除没有问题,如何确定延时时间呢?
针对不同的业务场景,应该自己跟根据项目评估出来一个靠谱的休眠时间,然后写数据的休眠时间则在读数据业务逻辑的耗时基础上,加几百ms即可,这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。
读写分离
在读写分离的场景下延时时间还需要考虑主从同步的时间,如果不考虑将出现以下问题
上述情形,就是数据不一致的原因,还是使用双删延时策略,只是,睡眠时间修改为在主从同步的延时时间基础上,加几百ms
第二次删除失败怎么办
第二次删除可能因为各种原因导致失败,虽然概率不高但是还是可能出现的,还是有两个请求,一个请求A进行更新操作,另一个请求B进行查询操作,为了方便,假设是单库。
如果第二次删除缓存失败,会再次出现缓存和数据库不一致的问题,为了防止这种情况产生,可以采用下面的先更新数据库,在删除缓存的方案
☑ 先更新数据库,再删缓存
Cache-Aside pattern
这是国外提出的一个缓存更新的思路,名为《Cache-Aside pattern》
失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
命中:应用程序从cache中取数据,取到后返回。
更新:先把数据存到数据库中,成功后,再让缓存失效。
操作步骤
我们用三个线程A、B、C来模拟以下多线程下的操作
这钟情况下线程B有短暂的不一致情况的产生,但是下一次查询就会被正确的数据给修复
并发问题
先更新数据库在更新问题也存在一些一致性问题,但是概率很低,假设这会有两个请求,一个请求A做查询操作,一个请求B做更新操作,那么会有如下情形产生。
步骤2读数据库操作要比步骤3写数据库操作时间要长,才有可能发生步骤4先于步骤5发生
⬜ 但是实际上数据库的读操作的速度远快于写操作的,因此这种情况下只有很小的概率发生
如何解决并发问题
只要感觉可能发生,在以后很有可能发生,怎么避免呢?
可以采用设置缓存的失效时间来避免,因为概率很低,并且有失效时间限制,定时会进行数据的同步,可以在一定程度上避免上述问题的发生,但是还是还是有概率发生的
☑ 异步删除方案
上面的缓存同步方案都这样那样的问题,如何一揽子解决呢,可以考虑异步删除方案
方案架构
可以采用这种基于监控Binlog的同步方案,并且使用消息队列做一次缓存的异步删除
具体流程图下
1.更新数据库数据
2.数据库会将操作信息写入binlog日志当中,并且同步到canal中
3.订阅程序会从canal中提取出所需要的数据以及key,并且进行过滤清洗
4.另起一段非业务代码,获得该信息
5.尝试删除缓存操作,发现删除失败
6.将这些信息发送至消息队列
7.重新从消息队列中获得该数据,重试操作
优点
零入侵: 上面不管是先删缓存还是先更新数据库都需要对业务代码进行更改,但是这种方案可以在不用更改任何代码的基础上进行操作
高可用: 如果删除失败,我们还可以基于MQ的重试机制进行保证一定会删除成功的
顺序性: 不会出现并发问题,这里binlog同步必然是在数据库操作成功后才会触发后续的操作
缺点
缺点是复杂性高,需要引入canal以及MQ等中间件,维护成本高。
缓存使用的时候还需要注意一些高并发下的安全性问题,以及使用的时候需要注意的一些问题,比如缓存穿透、击穿以及缓存雪崩问题。
09
缓存穿透
访问一个数据库和缓存中都不存在的 key,缓存不起作用,请求会穿透到 DB,流量大时 DB 会挂掉。
缓存穿透的危害
如果存在海量请求查询根本就不存在的数据,那么这些海量请求都会落到数据库中,数据库压力剧增,可能导致数据库崩溃,一旦数据库崩溃会导致关联的很多依赖于该数据库的服务无法提供服务。
☑ 为什么会发生缓存穿透
发生缓存穿透的原因有很多,一般为两种:
1️⃣ 恶意攻击,故意营造大量不存在的数据请求我们的服务,由于缓存总并不存在这些数据,因此海量请求均落在数据库中,从而可能会导致数据库崩溃。
2️⃣ 代码逻辑错误,这是程序员的锅,没啥好讲的,开发中一定要避免。
解决缓存穿透的方法
下面介绍两种解决缓存穿透的方案:
1️⃣ 缓存空对象
之所以发生缓存穿透,是因为缓存中没有存储这些空数据的key,导致这些请求全部都打到数据库上,那么,我们可以稍微改一下业务系统中的代码,将数据库查询结果为空的key也存储在缓存中,当后续又出现该key的查询请求时,缓存直接返回null,而无需查询数据库。
造成的问题就是可能会需要更多的key,一般会给这些key设置一个较短的过期时间,另一个问题就是缓存层和数据库层出现短时间的数据不一致,这个也可以通过设置过期时间解决。
2️⃣ 布隆过滤器
第二种避免缓存的方式即为使用布隆过滤器,也就是BloomFilter,它需要在缓存之前再加一道屏障,里面存储目前数据库中存在的所有key。
可以理解为将key放置在布隆过滤器中,如果请求数据的key存在,则通过请求,否则阻止请求通过,将所有可能存在的数据的key哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力
当业务系统有查询请求的时候,首先去BloomFilter中查询该key是否存在,若不存在,则说明数据库中也不存在该数据,因此缓存都不需要了,直接返回null,若存在,则继续执行后续的流程,先前往缓存中查询,缓存中没有的话再前往数据库中的查询。
☑ 两种方法的比较
这两种方案都能解决缓存穿透的问题,但使用场景却各不相同。
对于一些恶意攻击,查询的key往往各不相同,而且数据非常大,此时,第一种方案就显得提襟见肘了,因为它需要存储所有空数据的key,而这些恶意攻击的key往往各不相同,而且同一个key往往只请求一次,因此即使缓存了这些空数据的key,由于不再使用第二次,因此也起不了保护数据库的作用。
因此,对于空数据的key各不相同、key重复请求概率低的场景而言,应该选择第二种方案,而对于空数据的key数量有限、key重复请求概率较高的场景而言,应该选择第一种方案。
☑ 什么是布隆过滤器
布隆过滤器(Bloom Filter)是1970年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中,它的优点是空间效率和查询时间都比一般的算法要好的多,缺点是有一定的误识别率和删除困难。
布隆过滤器可以理解为一个不怎么精确的 set 结构,当你使用它的 contains 方法判断某个对象是否存在时,它可能会误判。
但是布隆过滤器也不是特别不精确,只要参数设置的合理,它的精确度可以控制的相对足够精确,只会有小小的误判概率。
布隆过滤器的特点。
◾ 只要返回数据不存在,则肯定不存在。
◾ 返回数据存在,但只能是大概率存在。
◾ 同时不能清除其中的数据
在有限的数组长度中存放大量的数据,即便是再完美的 Hash 算法也会有冲突,所以有可能两个完全不同的 A、B 两个数据最后定位到的位置是一模一样的。
删除数据也是同理,当我把 B 的数据删除时,其实也相当于是把 A 的数据删掉了,这样也会造成后续的误报。
基于以上的 Hash 冲突的前提,所以 Bloom Filter 有一定的误报率,这个误报率和 Hash 算法的次数 H,以及数组长度 L 都是有关的。
10缓存击穿
缓存穿透是指在查询缓存数据时,缓存中没有对应数据,还需要去存储系统中查询数据。
某个 key 非常热点,访问非常频繁,处于集中式高并发访问的情况,当这个 key 在失效的瞬间,大量的请求就击穿了缓存,直接请求数据库,就像是在一道屏障上凿开了一个洞。
缓存穿透的危害
缓存在某个时间点过期的时候,恰好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。
☑ 为什么会发生缓存穿透
并发量高: 在并发量不高的情况下,使用懒加载缓存没有什么问题的,但是在一些秒杀场景如果出现懒加载,特别是java代码还没有什么控制,瞬间大量并发读取缓存时不存在,然后都请求数据库就会发生缓存穿透。
缓存生成时间长: 缓存生成的时间比较长,这种情况下缓存一旦失效,就会有大量请求穿透缓存来的后端。
大量缓同时失效: 在系统中可能设置大量缓存的失效时间都是一样的,在一瞬间大量缓存同时失效,同时将大量请求全部压到数据库中,可能造成缓存穿透。
解决方案
☑ 使用互斥锁(mutex key)
业界比较常用的做法,是使用mutex
简单地来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去load db,而是先使用setNX设置一个互斥锁。
当设置成功后,使用分布式缓存互斥锁的特性,这个时候只有一个线程可以获取这把锁,获取锁的这个线程就会去查询数据库,然后将值设置到缓存中,并且删除互斥锁的对象,其他的线程继续查询的时候就会走缓存而不会直接打到数据库中了
这里主要使用分布式锁的特性,很多线程访问的时候只允许一个线程查询DB并且设置值,完成后其他的线程直接可以通过缓存获取值了
☑ 伪代码
可以使用如下的方式使用分布式锁来防止多个线程因为缓存不存在而同时访问数据库
☑ 提前”使用互斥锁(mutex key)
即在value内部设置1个超时值(timeout1),timeout1比实际的redis timeout(timeout2)小。
当从cache读取到timeout1发现它已经过期时候,马上延长timeout1并重新设置到cache,然后再从数据库加载数据并设置到cache中,方案2和方案1的区别在于,如果缓存有数据,但是已经过期,会提前使用互斥锁,查询DB最新数据再缓存起来。
☑ 永远不过期
这里的“永远不过期”包含两层意思
1️⃣ 从redis上看,确实没有设置过期时间,这就保证了,不会出现热点key过期问题,也就是“物理”不过期。
2️⃣ 从功能上看,如果不过期,那不就成静态的了吗,所以我们把过期时间存在key对应的value里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建,也就是“逻辑”过期
从实战看,这种方法对于性能非常友好,唯一不足的就是构建缓存时候,其余线程(非构建缓存的线程)可能访问的是老数据,但是对于一般的互联网功能来说这个还是可以忍受。
伪代码
| 解决方案 | 优点 | 缺点 |
|---|---|---|
| 使用互斥锁 | 1.思路简单2.保证一致性 | 1. 代码复杂度增大2. 存在死锁的风险 |
| 提前使用互斥锁 | 1. 保证一致性 | 同上 |
| 缓存永不过期 | 1. 异步构建缓存,不会阻塞线程池 | 1.不保证一致性2.代码复杂度增大(每个value都要维护一个timekey)3.占用一定的内存空间(每个value都要维护一个timekey) |
三种方案对比
11缓存雪崩
缓存雪崩就是指缓存由于某些原因(比如 宕机、cache服务挂了或者不响应)整体crash掉了,导致大量请求到达后端数据库,从而导致数据库崩溃,整个系统崩溃,发生灾难。
如果缓存中部分key集中在一段时间内失效,发生大量的缓存穿透,所有的查询都落在数据库上,造成了缓存雪崩,造成这个问题的原因除了是key失效以外,还可能是缓存集群宕机。
缓存最关键的地方就是内存,当内存满了之后,会有各种策略将缓存进行失效,在分布式环境下,如果有一个缓存失效,而恰好这个缓存是一个热点数据,前端有10个应用都需要访问这个缓存,并且TPS很高的话,那么全部的线程都会去访问数据库,从而能直接将数据库拖垮。
发生的步骤
-
redis集群彻底崩溃
-
缓存服务大量对redis的请求hang住,占用资源
-
缓存服务大量的请求打到源头服务去查询mysql,直接打死mysql
-
源头服务因为mysql被打死也崩溃,对源服务的请求也hang住,占用资源
-
缓存服务大量的资源全部耗费在访问redis和源服务无果,最后自己被拖死,无法提供服务
-
nginx无法访问缓存服务,redis和源服务,只能基于本地缓存提供服务,但是缓存过期后,没有数据提供
-
网站崩溃
解决方案
加锁排队
简单来说就是使用互斥锁,出现问题时使用锁只允许一个线程去加载数据,然后其他线程等待一直到数据加载到缓存中。
在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量,比如对某个key只允许一个线程查询数据和写缓存,其他线程等待,可以参考上文的《使用互斥锁(mutex key)》。
数据预热
可以通过缓存reload机制,预先去更新缓存,再即将发生大并发访问前手动触发加载缓存不同的key,设置不同的过期时间,让缓存失效的时间点尽量均匀。
随机时间失效
可以在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
服务降级处理
发生缓存雪崩时,针对不同的数据采取不同的处理方式。
1️⃣ 当业务应用访问的是非核心数据时,暂时停止从缓存中查询这些数据,而是直接返回预定义信息、空值或是错误信息;
2️⃣ 当业务应用访问的是核心数据时,仍然允许查询缓存,如果缓存缺失,也可以继续通过数据库读取。
三者区别
缓存穿透是指数据库原本就没有的数据,请求如入无人之境,直奔数据库。
缓存击穿,则是指数据库有数据,缓存也本应该有数据,但是突然缓存过期了,这层保护屏障被击穿了,请求直奔数据库。
缓存雪崩则是指很多缓存同一个时间失效了,流量全部涌入数据库,造成数据库极大的压力。
无底洞问题
通常情况下,可以通过增加集群部署的机器数量来提升性能,但是在2010年,FaceBook发现在部署了3000个节点后发现性能反而下降;
也就是说,集群中有更多的机器不代表有更好的性能,但随着数据量和并发处理量的提升,又必须提升集群的机器数量,这就是无底洞问题,这个问题没有好的解决办法,只能是通过在细节方面的优化处理来尽量提高性能,比如优化IO操作、优化Redis集群中的批量命令执行等。