如何更有效的处理数据检索缓存

为什么使用缓存

 大家在使用各式各样的数据检索服务时,可能都会面临一个共性的问题:系统变得越来越慢。互联网有一个8秒原则:用户打开一个网页最高能容忍的时长是8秒,抛开网络时延和下载静态文件的耗时,对检索的性能要求非常高。我们面临的问题:随着数据量的增大检索性能越来越差,数据库中存在着大量沉寂已久的数据,严重的冷热数据分布不均。这种场景下引入“缓存”是非常适合的。

 

01

缓存策略

 

1)LRU(Least recently used)最近最少使用,根据数据的历史访问记录来进行淘汰数据,核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。当存在热点数据时,LRU的效率很好,但偶发性的、周期性的批量操作会导致LRU命中率急剧下降。

 

2)LFU(Least Frequently Used)最近最频繁使用,根据数据的访问频次进行淘汰数据,核心思想是“如果一个数据在最近一段时间内使用次数很少,那么在将来一段时间内被使用的可能性也很小”。

 

LFU和LRU算法的不同之处在于LRU的淘汰规则是基于访问时间,而LFU是基于访问次数的。为了能够淘汰最少使用的数据,LFU算法针对每条数据记录了一个访问频次,当数据项被命中时,访问频次自增,然后定期淘汰访问频次低的数据。

 

如何选择策略,何时进行缓存,何时淘汰数据,一定跟我们的业务紧密相关的。这里以redis做缓存服务为例,给大家介绍几种基于LRU的cache实现方案。


方案A

 

读操作时,首先从cache里读数据,若读不到,则从数据库里读数据,然后将读到的内容写到cache里,并为这条数据设置了一个过期时间,当下次请求同样的数据时将直接命中缓存。


这应该是大部分人都会选择的方案,不过方案存在“第一次访问”的问题,刚才说到当第一次访问没有命中到缓存时,会有两次读操作和一次写操作,要比直接查数据库慢。不过理论上来讲,如果数据的修改不多,热点数据非常集中,就可以让大部分热数据常驻缓存中,缓存命中率会保持在一个较高的水平,收益很明显。

  

对于方案A,有人会问为什么在写数据的时候选择淘汰旧数据,而不是直接将缓存中的数据更新呢?如下图,直接更新并没有增加什么成本却可以提高缓存的命中率,这样做的基础是:用户修改的数据被检索的概率也很高。


方案B

 

这的确是解决问题的一种思路,但更多情况下我们无法预知用户的行为,一条数据被创建修改并不意味着它一定会被检索,是否入缓存还是要依赖于检索发生的时刻。所以在数据被更新时,比较好的做法是淘汰缓存而不是更新缓存。

 

另外一种思路是可以将更新cache做成异步,如下图: 



方案C

 

方案C加了一个异步模块。写cache时,只需保证将cache中的旧数据淘汰即可。在查询发生时,若缓存中不存在所需要的数据,只需发送一条消息到消息队列,然后由异步模块将数据写入到cache中。

 

02

缓存的一致性问题

 

由于cache和数据库的操作是非原子的,若修改数据库或者淘汰cache其中有一步失败,就有可能出现数据库中数据和缓存中数据不一致的情况。



先写数据库,再淘汰缓存,t3时刻淘汰cache失败,cache中的数据将依然为写数据库之前的数据。这也就意味着t3时刻后的读请求读到的都是脏数据,cache与数据库中的内容不一致。

 

所以,要保证数据的最终一致性,需要先淘汰缓存,再写数据库。如下图:



先淘汰cache,再写数据库,t3时刻写数据库写失败,则认为本次写操作失败;此时的读操作,由于t2时刻之前cache中数据已经被清,之后的读请求直接去读数据库;保证数据库和cache的内容保持一致。

 

“先淘汰缓存,再写数据库”已经能够解决大部分问题,但前提是,我们的服务是单机部署没有并发。事实上为了保障系统的可用性,我们的数据库和cache大多是分布式的,缓存一致性问题依然无法彻底解决。如下图:



t2时刻淘汰缓存后开始写数据库,t5时刻才能完成;同时t2时刻收到一次读请求,由于cache中的内容已经被淘汰,此次读操作将当前数据库里的内容load到缓存中,并在t4时刻完成;


注意写操作需要在t5时刻才能完成,而在这之前的t4时刻已经完成了更新缓存的动作,这次读操作已经将脏数据load到cache中了。t5时刻,写操作完成,缓存和数据库中内容不一致。

 

问题总结:分布式环境下,读写数据是并行的,如果在淘汰缓存后,写数据库操作较慢,在写数据库完成之前这段时间内,就有可能发生一次查询,然后因为未命中cache而将脏数据更新到cache。

 

要解决这个问题,关键点是“没有并发”,只要读写全局串行化即可,但串行意味着放弃性能。

 

那么有没有什么方法能够解决以上问题,同时允许并发呢?思路是通过一致性哈希实现局部串行化,即只要保证同一条数据的读写操作串行,就不会出现缓存不一致的问题。


如图,通过一致性哈希,将不同的数据据转发到不同的service上进行处理,每个service对应一个worker以单连接的方式连接数据库,保证当前worker所有的读写操作都是串行的,从而实现局部读写串行化。

 

不过这个方案只能解决上游业务模块分布式部署带来的缓存一致性问题,并不能解决数据库的分布式部署带来的缓存一致性问题。

 

为了提高性能,我们往往在数据库层面进行读写分离,这是一种更为复杂的情况。我们先来看一下问题: 


图中,t3时刻写数据库完成,如果数据库是单机的,t3时刻之后的读请求会触发将刚才写入的数据load到缓存中,这是没有问题的;可是读写分离的模式下,t3写完数据库后要进行主从同步,而读数据又是在从机进行,在主从同步完成(t6)之前,t5已经将从机的旧数据读出来写入缓存了,这时cache里的内容与数据库里的内容又出现了不一致。

 

问题总结:一般来说,我们会写主读从,而主从同步存在一定的时延,在主从同步未完成前发生的读操作,读取数据的请求会落到从机上,导致将旧的数据load到缓存中,出现旧的数据被写入缓存的问题。

 

解决这个问题的思路大致有两种:


1)一种是简单粗暴的方法,阻塞当前的处理,等主从同步完毕后再继续进行,这个方法代价是性能损失很大。


2)另一种方法是“缓存双淘汰”,先淘汰缓存,再写数据库,异步的设置一个计时器,计时结束后再进行一次淘汰缓存。这样在第二次淘汰缓存前即使有脏数据写入最终也会被清除。这个方法的代价是会造成一次cache miss。



 解决分布式导致的缓存不一致问题,无论是一致性哈希,还是缓存双淘汰,实现起来都颇为复杂,这里介绍一下延时淘汰

 

首先,我们采用redis做缓存。利用redis的TTL key设置生命周期,延时淘汰,在需要淘汰cache中数据时不立即进行淘汰,而是改为被淘汰的数据标志删除,设置过期时间同时冻结更新。比如修改过期时间为5秒,并发读写的流程就变成了这样:


t2时刻没有立即淘汰缓存,在接下来t3时刻写数据库完成之前,所有的读请求都依然会命中缓存,也就不会触发旧数据再次被load进缓存的情况。5s结束后,旧的数据失效,当再次接到查询请求时,新的数据会被load到缓存中。这个方法能够很好的解决分布式带来的缓存不一致问题。

 

然后我们再看数据库主从同步时延导致的缓存不一致问题是否也得到了解决:



由于t2没有立即淘汰缓存,在主从同步完成(t4)之前,所有的读请求会继续命中缓存,只要在5s内完成主从同步就不会有问题。

 

利用了redis的特性,这个方案几乎没有增加我们任何的成本,但是业务模块分布式和数据库分布式带来的缓存不一致的问题都得到了完美的解决。关于延时淘汰的时长到底要设多少呢?首先这个时间越长,导致的更新时延就会越长;如果设置太短,可能无法达到预期的效果。原则上,需要根据数据库主从同步完成的时间设定。


获取更多开发教程

了解技术沙龙信息

请扫描关注百度地图开放平台微信公众号



猜你喜欢

转载自blog.csdn.net/baidumap2018/article/details/80052897
今日推荐