当前位置: 首页 > 科技观察

缓存的一些常见陷阱,你遇到过哪些,如何解决?

时间:2023-03-19 21:36:05 科技观察

为什么要用缓存在高并发请求的时候,我们经常会提到缓存技术的使用。最直接的原因是磁盘IO和网络开销是直接请求内存IO的数百倍。做一个简单的计算,如果我们需要某个数据。从数据库磁盘读取数据需要0.0045S,通过网络请求传输需要0.0005S。那么每个请求至少需要0.005S才能完成。数据服务器每秒最多只能响应200个请求,而如果数据存储在本地内存中,读取出来只需要100us,那么每秒可以响应10000个请求。通过将数据存储在离CPU更近的地方,减少数据传输时间,提高处理效率,这就是缓存的意义所在。哪些场景适合使用缓存读密集型应用热点数据应用对响应时间要求高的应用需要分布式锁时对一致性要求不高的场景更新频繁,数据量大的场景更新频率,频繁同步缓存中数据的代价可能会高于不使用缓存的代价。对于读很少的场景,使用缓存就完全没有意义了。使用缓存的比较是为了更高效地读取数据。缓存收益的成本与加速读写的收益相比。因为缓存通常是全内存系统,使用缓存可以有效提高用户的访问速度,优化用户体验。减少后端负载。通过添加缓存,如果程序没有问题,命中率还可以,可以帮助后端减少访问和复杂的计算,大大减轻后端的负担。成本数据不一致。无论设计得多么好,缓存数据与真实数据源之间必然存在一定的数据不一致的时间窗口代码维护成本。有了缓存后,代码会在原有数据源的基础上添加缓存相关的代码。比如本来只是一些SQL,现在加入缓存必然会增加代码维护成本。架构复杂性。缓存可用后,需要专门的管理人员维护主从缓存系统,这也增加了架构的复杂性和维护成本。高并发场景下的常见问题在高并发场景下,缓存主要会导致以下问题:1.缓存一致性2.缓存并发(缓存击穿)3.缓存穿透4.缓存雪崩(缓存无效)比如你是一个很有钱的人,你满脑子都是百度云,腾讯视频的各种会员,但是你不是Netflix的会员,然后你把这些账号和密码发布到一个你自己做网站的网站上,然后你就有了一个每隔十秒检查一次您的网站并发现您的网站没有Netflix会员资格然后打电话给您要求的朋友。你相当于一个数据库,网站就是Redis。这就是缓存穿透。大家都很喜欢在腾讯视频看周星驰的《喜剧之王》,但是你的会员突然到期了,大家在你的网站上看不到腾讯视频的账号,还打电话问你,这是你的缓存故障各种成员突然同时过期,那么这就是缓存雪崩。让我们一一介绍吧!缓存一致性问题当对数据的时效性要求很高时,需要保证缓存中的数据与数据库中的数据一致,同时还需要保证缓存节点中的数据和副本也是一致的,不会出现偏差。这个更依赖于缓存的过期和更新策略。一般当数据发生变化时,主动更新缓存中的数据或者移除相应的缓存。下图的情况会导致数据一致性问题和缓存击穿问题。对于一些设置了过期时间的key,这些key可能在某个时间点被高并发访问,是一个非常“热”的数据。这时候就要考虑缓存被“击穿”的问题了。和cacheavalanche不同的是,这里是针对某个key的cache,而cacheavalanche是针对很多key的。如图:解决方案:业界普遍的做法是使用mutex(互斥锁)。1.使用互斥键(mutexkey)这个方案的思路比较简单,就是只让一个线程建立缓存,其他线程等待建立缓存的线程执行完毕,然后再次从缓存中获取数据。如果是单机,可以使用synchronized或者lock。对于处理,如果是分布式环境,可以使用分布式锁(redis的setnx,zookeeper的添加节点操作)。redis伪代码如下:publicStringget(Stringkey){Stringvalue=storeClient.get(key);StoreKeykey_mutex=newMutexStoreKey(key);if(value==null){//代表缓存值过期//设置超时2分钟,防止删除缓存操作失败,下次缓存过期时,将无法获取DB数据if(storeClient.setnx(key_mutex,1,2*60)){//代表设置成功value=db.get(key);storeClient.set(key,value,3*3600);storeClient.delete(key_mutex);}else{sleep(1000);//此时,它意味着其他线程同时获取了DB数据并设置回缓存。此时重试获取缓存值returnget(key);//retry}}returnvalue;}2.《进阶》使用互斥锁(mutexkey)是在value里面设置一个超时值(timeout1),timeout1小于redis实际的超时时间(timeout2)。当从缓存中读取timeout1,发现已经过期,立即延长timeout1,重置到缓存中。然后从数据库中加载数据并设置到缓存中。方案二和方案一的区别在于,如果缓存中有数据但已经过期,会提前使用mutex查询DB中的最新数据,然后缓存。伪代码如下,注意代码else中的逻辑。publicStringget(Stringkey){MutexDTOvalue=storeClient.get(key);StoreKeykey_mutex=newMutexStoreKey(key);if(value==null){if(storeClient.setnx(key_mutex,3*60*1000)){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*1000)){value.setTimeout(value.getTimeout()+3*60*1000);storeClient.set(key,value,3*3600*2);value=db.get(key);//获取最新的DB更新数据value.setTimeout(value.getTimeout()+3*60*1000);storeClient.set(key,value,3*3600*2);storeClient.delete(key_mutex);}else{sleep(50);get(key);}}}returnvalue.getValue();}3.缓存“永不过期”这里的“永不过期”包含两层意思:从redis的角度来说,没有设置过期时间,保证不会出现热键过期问题,即“物理”不过期.从功能上看,如果没有过期,不就是静态的吗?所以我们将过期时间存储在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;}三种方式对比如下:方案优缺点使用互斥锁1.思路简单2.保证一致性1.增加代码复杂度2.有死锁风险。提前使用互斥量1.保证一致性同上。.2、代码复杂度增加(每个值都要维护一个timekey)。3、占用一定的内存空间(每个值都要维护一个timekey)。缓存穿透问题缓存穿透是指查询一定不存在的数据。由于缓存在缺失时是被动写入的,而且为了容错,如果从存储层找不到数据,就不会写入缓存,这会导致这个不存在的数据不得不被每次请求都在存储层查询,就失去了缓存的意义。当流量大的时候,如果DB不能承受瞬时的流量冲击,DB可能会挂掉。如图:解决方法:有很多方法可以有效解决缓存穿透问题。比较简单粗暴的方法是使用缓存的空数据。如果查询返回的数据为空(数据库中不存在该数据),仍然缓存这个空结果(过期时间一般较短)。另一种方法是使用常用的Bloomfilter,将所有可能的数据hash成一个足够大的bitmap,一个一定不存在的数据会被这个bitmap拦截,从而避免了对底层存储系统的查询压力。1.缓存空数据客户端请求MISS时,依然将空对象保留在Cache中(可能会保留一段时间,具体问题具体分析),下一个新的Request(相同的key)将从缓存中获取数据,保护后端数据库。伪代码如下:publicclassCacheNullService{privateCachecache=newCache();privateStoragestorage=newStorage();/***模拟普通模式*@paramkey*@return*/publicStringgetNormal(Stringkey){//从缓存中获取数据StringcacheValue=cache.get(key);//缓存为空if(StringUtils.isBlank(cacheValue)){//从storage中获取StringstorageValue=storage.get(key);//如果存储的数据不为空,设置存储的value到缓存if(StringUtils.isNotBlank(storageValue)){cache.set(key,storageValue);}returnstorageValue;}else{//缓存不为空returncacheValue;}}/***模拟反渗透模式*@paramkey*@return*/publicStringgetPassThrough(Stringkey){//从缓存中获取数据StringcacheValue=cache.get(key);//缓存为空if(StringUtils.isBlank(cacheValue)){//从缓存中获取StringstorageValuestorage=storage.get(key);cache.set(key,storageValue);//如果存储数据为空,需要设置一个过期时间(300秒)if(StringUtils.isBlank(storageValue)){cache.expire(key,60*5);}returnsstorageValue;}else{//缓存非空returncacheValue;}}}2.布隆过滤器BloomFilter是一个非常有趣的数据结构,不仅可以阻断非法key攻击,还可以可以对海量数据进行低成本、高性能的判断,比如一个系统有上亿用户,上百亿条新闻推送,可以使用BloomFilter来判断用户是否阅读了某个新闻推送。在访问所有资源(缓存、DB)之前,用Bloom过滤已有的key设备提前保存,一级拦截算法简单图示如下:使用布隆过滤器,只需要判断客户端传过来的userCode是否存在即可。图中的hash1、hash2、hash3分别代表三种哈希算法,不同的userCode对应不同的数据位。当需要校验时,判断各个算法得到的字节位是否相同。只要有一位不同,那么我们就可以认为这个userCode不存在。一般BloomFilter需要缓存全量的key,这就要求全量key的数量不多,最好少于10亿条数据,因为10亿条数据会占用1.2GB左右的空间记忆。您还可以使用BloomFilter来缓存非法密钥。每次发现某个key是不存在的非法key,都会记录到BloomFilter中。这种记录方案会导致BloomFilter中存储的key持续高速增长。为了避免记录太多key导致错误判断率增加,需要定时清除Bloom。用例如下:importcom.google.common.base.Charsets;importcom.google.common.hash.BloomFilter;importcom.google.common.hash.Funnels;importjava.util。*;publicclassBFDemo{privatestaticfinalintinsertions=1000000;//100Wpublicstaticvoidmain(String[]args){BloomFilterbf=BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8),insertions);Setsets=newHashSet(insertions);Listlists=newArrayList(插入);for(inti=0;i