为什么使用缓存
在高并发请求时,我们会频繁提到使用缓存技术,最直接的原因是,磁盘IO及网络开销是直接请求内存IO的千百上千倍
做个简单计算,如果我们需要某个数据,该数据从数据库磁盘读出来需要0.S,经过网络请求传输需要0.S,那么每个请求完成最少需要0.S,该数据服务器每秒最多只能响应个请求,而如果该数据存于本机内存里,读出来只需要us,那么每秒能够响应00个请求。通过将数据存储到离CPU更近的未位置,减少数据传输时间,提高处理效率,这就是缓存的意义。
什么场景下适合使用缓存
读密集型的应用存在热数据的应用对响应时效要求高的应用对一致性要求不严格的场景需要实现分布式锁的时候什么场景下不适合使用缓存
对一致性要求严格的场景更新频繁,更新数据频率过高的场景,频繁同步缓存中的数据所花费的代价可能比不使用缓存的代价还要高读少的场景,对于读少的系统而言,使用缓存就完全没有意义了,比较使用缓存是为了读取数据更高效缓存收益成本对比
收益
加速读写。因为缓存通常都是全内存的系统,通过缓存的使用可以有效提高用户的访问速度同时优化用户体验。降低后端负载。通过添加缓存,如果程序没有什么问题,在命中率还可以的情况下,可以帮助后端减少访问量和复杂计算,很大程度降低了后端的负载。成本
数据不一致性。无论设计做的多么好,缓存数据与真实数据源一定存在着一定时间窗口的数据不一致性代码维护成本。有缓存后,代码就会在原数据源基础上加入缓存的相关代码,例如原来只是一些sql,现在要加入缓存,必然增加代码维护成本。架构复杂度。有缓存后,需要专门的管理人员来维护主从缓存系统,同时也增加了架构的复杂度和维护成本。高并发场景下带来的常见问题
在高并发场景下,缓存主要会带来下面几个问题:
1.缓存一致性
2.缓存并发(缓存击穿)
3.缓存穿透
4.缓存雪崩(缓存失效)
打个比方,你是个很有钱的人,开满了百度云,腾讯视频各种杂七杂八的会员,但是你就是没有netflix的会员,然后你把这些账号和密码发布到一个你自己做的网站上,然后你有一个朋友每过十秒钟就查询你的网站,发现你的网站没有Netflix的会员后打电话向你要。你就相当于是个数据库,网站就是Redis。这就是缓存穿透。
大家都喜欢看腾讯视频上周星驰的《喜剧之王》,但是你的会员突然到期了,大家在你的网站上看不到腾讯视频的账号,纷纷打电话向你询问,这就是缓存击穿
你的各种会员突然同一时间都失效了,那这就是缓存雪崩了。
下面一一介绍!
缓存一致性问题
当数据时效性要求很高时,需要保证缓存中的数据与数据库中的保持一致,而且需要保证缓存节点和副本中的数据也保持一致,不能出现差异现象。这就比较依赖缓存的过期和更新策略。一般会在数据发生更改的时,主动更新缓存中的数据或者移除对应的缓存。
下图情况都会导致数据一致性问题
缓存击穿问题
对于一些设置了过期时间的key,可能这些key会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑缓存被“击穿”的问题,和缓存雪崩的区别在于这里针对某一key缓存,而缓存雪崩则是很多key。
如图所示:
解决方案:
业界比较常用的做法,是使用mutex(互斥锁)。
1.使用互斥锁(mutexkey)
该方案思路比较简单,就是只让一个线程构建缓存,其他线程等待构建缓存的线程执行完,重新从缓存获取数据就可以
如果是单机可以用synchronized或者lock来处理,如果是分布式环境可以用分布式锁(redis的setnx,zookeeper的添加节点操作)
redis伪代码如下:
publicStringget(Stringkey){Stringvalue=storeClient.get(key);StoreKeykey_mutex=newMutexStoreKey(key);if(value==null){//代表缓存值过期//设置2min的超时,防止删除缓存操作失败的时候,下次缓存过期一直不能获取DB数据if(storeClient.setnx(key_mutex,1,2*60)){//代表设置成功value=db.get(key);storeClient.set(key,value,3*);storeClient.delete(key_mutex);}else{sleep(0);//这个时候代表同时候的其他线程已经获取DB数据并回设到缓存了,这时候重试获取缓存值即可returnget(key);//重试}}returnvalue;}
2.“提前”使用互斥锁(mutexkey)
即在value内部设置1个超时值(timeout1),timeout1比实际的redistimeout(timeout2)小。
当从cache读取到timeout1发现它已经过期时候,马上延长timeout1并重新设置到cache。然后再从数据库加载数据并设置到cache中。
方案2和方案1的区别在于,如果缓存有数据,但是已经过期,会提前使用互斥锁,查询DB最新数据再缓存起来
伪代码如下,注意代码else里面的逻辑。
publicStringget(Stringkey){MutexDTOvalue=storeClient.get(key);StoreKeykey_mutex=newMutexStoreKey(key);if(value==null){if(storeClient.setnx(key_mutex,3*60*0)){value=db.get(key);storeClient.set(key,value);storeClient.delete(key_mutex);}else{sleep(50);get(key);}}else{if(value.getTimeout()=System.currentTimeMillis()){if(storeClient.setnx(key_mutex,3*60*0)){value.setTimeout(value.getTimeout()+3*60*0);storeClient.set(key,value,3**2);value=db.get(key);//获取最近DB更新数据value.setTimeout(value.getTimeout()+3*60*0);storeClient.set(key,value,3**2);storeClient.delete(key_mutex);}else{sleep(50);get(key);}}}returnvalue.getValue();}
3.缓存”永不过期“
这里的“永远不过期”包含两层意思:
从redis上看,不设置过期时间,这就保证了,不会出现热点key过期问题,也就是“物理”不过期。从功能上看,如果不过期,那不就成静态的了吗?所以我们把过期时间存在key对应的value里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建,也就是“逻辑”过期。publicStringget(Stringkey){MutexDTOmutexDTO=storeClient.get(key);Stringvalue=mutexDTO.getValue();if(mutexDTO.getTimeout()=System.currentTimeMillis()){ExecutorServicesingleThreadExecutor=Executors.newSingleThreadExecutor();//异步更新后台异常执行singleThreadExecutor.execute(newRunnable(){publicvoidrun(){StoreKeymutexKey=newMutexStoreKey(key);if(storeClient.setnx(mutexKey,1)){storeClient.expire(mutexKey,3*60);StringdbValue=db.get(key);storeClient.set(key,dbValue);storeClient.delete(mutexKey);}}});}returnvalue;}
三种方法如下比较:
缓存穿透问题
缓存穿透是指查询一个一定不存在的数据,由于缓存是不命中时被动写的,并且出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。
在流量大时,要是DB无法承受瞬间流量冲击,DB可能就挂了。
如图所示:
解决方案:
有多种方法可以有效解决缓存穿透问题,一种比较简单粗暴的方法采用缓存空数据,如果一个查询返回的数据为空(数据库中不存在该数据),仍然把这个空结果进行缓存(过期时间一般较短)。
另一种方法则是采用常用的布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。
1.缓存空数据
当Client请求MISS后,仍然将空对象保留到Cache中(可能是保留一段时间,具体问题具体分析),下次新的Request(同一个key)将会从Cache中获取到数据,保护了后端的DB。伪代码如下:
publicclassCacheNullService{privateCachecache=newCache();privateStoragestorage=newStorage();/***模拟正常模式*
paramkey*return*/publicStringgetNormal(Stringkey){//从缓存中获取数据StringcacheValue=cache.get(key);//缓存为空if(StringUtils.isBlank(cacheValue)){//从存储中获取StringstorageValue=storage.get(key);//如果存储数据不为空,将存储的值设置到缓存if(StringUtils.isNotBlank(storageValue)){cache.set(key,storageValue);}returnstorageValue;}else{//缓存非空returncacheValue;}}/***模拟防穿透模式*paramkey*return*/publicStringgetPassThrough(Stringkey){//从缓存中获取数据StringcacheValue=cache.get(key);//缓存为空if(StringUtils.isBlank(cacheValue)){//从存储中获取StringstorageValue=storage.get(key);cache.set(key,storageValue);//如果存储数据为空,需要设置一个过期时间(秒)if(StringUtils.isBlank(storageValue)){cache.expire(key,60*5);}returnstorageValue;}else{//缓存非空returncacheValue;}}}2.布隆过滤器
BloomFilter是一个非常有意思的数据结构,不仅仅可以挡住非法key攻击,还可以低成本、高性能地对海量数据进行判断,比如一个系统有数亿用户和百亿级新闻feed,就可以用BloomFilter来判断某个用户是否阅读某条新闻feed
在访问所有资源(cache,DB)之前,将存在的key用布隆过滤器提前保存起来,做第一层拦截。算法的简单图解如下:
用布隆过滤器实际只需要判断客户端传过来的userCode是否存在就可以,图中的hash1,hash2,hash3分别代表三种hash算法,不同的userCode对应着不同的数据位,当需要校验的时候,判断每一种算法的得出来的byte位是否相同,只要一位不同,那么我们可以认为这个userCode不存在。
一般BloomFilter要缓存全量的key,这就要求全量的key数量不大,10亿条数据以内最佳,因为10亿条数据大概要占用1.2GB的内存。也可以用BloomFilter缓存非法key,每次发现一个key是不存在的非法key,就记录到BloomFilter中,这种记录方案,会导致BloomFilter存储的key持续高速增长,为了避免记录key太多而导致误判率增大,需要定期清零处理
布隆使用案例如下:
import