66

缓存实战二三事

 6 years ago
source link: http://mp.weixin.qq.com/s/1fncm4s82C7J9r79w0pk8A
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

缓存实战二三事

丁浪 技术琐话 2018-01-19 10:22 Posted on

上次列举了一些缓存相关的常见问题和解决思路,这些问题是在实际工作中可能会遇到的。很多系统在业务量不大时可能不会暴露出问题,但是遇到“高并发”就会产生很多问题,缓存也不例外。所以在选择缓存策略和使用缓存之前,我们非常有必要结合当前的业务场景和需求去分析设计。一般我会从访问频率、读写比例、一致性要求等几个维度去分析。

访问频度低,缓存的收益不明显。

访问频度高,缓存收益一般明显。

是否使用缓存,优先参考的是业务的访问频度和并发量,而不是执行速度。什么意思呢?比如某个业务执行需要3s才能完成,我们觉得有点慢,但它本身使用频度很低,也不存在什么并发,那我们肯定会优先去给那些执行1s但是存在并发的业务加缓存。

读多写少:

适合缓存,收益明显。

不太适合缓存,收益不明显,额外增加系统复杂度。

一致性要求低:

业务可以容忍(某段时间)出现不一致,可以最终一致。先天适合缓存,设计难度较低。

一致性要求高:

业务数据敏感,无法容忍不一致(或者可容忍时间非常短)。缓存设计难度相对较大。

面对的场景不同,缓存设计和处理策略也不同。我曾经见过一个系统的代码,为了避免上面提到的“缓存并发”问题,直接在缓存帮助类中公共的get方法上加了lock,这显然是不合理的。首先,并不是所有业务都会有“缓存穿透”的问题,其次,这种处理方式也太低级。

缓存并发导致的穿透问题如何解决

下面具体的聊聊我在实际工作中一般是如何应对解决“缓存并发穿透”问题的。

方案A(后台刷新):

在缓存过期之前,通过后台线程或者job主动更新缓存。例如,缓存的过期时间为30分钟,而后台job则每隔29分钟执行一次(job中查询出最新的数据并写入到缓存中)。

这种方案比较容易理解,但会增加系统复杂度。比较适合那些key相对固定、cache粒度较大的业务,key比较分散的则不太适合,实现起来也比较复杂。

方案B(检查更新):

将缓存key的过期时间(绝对时间)也一起保存到缓存中(可以拼接,也可以加新字段,也可以采用单独的key保存,反正需要两者建立好关联关系)。在每次执行get操作后,都将get出来的缓存过期时间与当前系统时间做一个对比,如果发现缓存过期时间-当前系统时间<=1分钟,则主动更新缓存。这样就能保证缓存中始终是最新的(和方案A的思路本质上一样,就是为了保证缓存“始终是最新的”且“永不过期”),不用担心缓存失效和一致性的问题。当然,这个1分钟只是举例,可以根据实际情况定义或者配置的。

这种方案在特殊情况下也会有问题。假设缓存过期时间是11:30分,而11:29到11:30这1分钟时间里恰好没有get请求过来,恰好请求都在11:30分的时候并发过来,那就悲剧了。这种情况比较极端,但并不是没有可能。因为“高并发”也可能是阶段性在某个时间点爆发。

方案C(分级缓存):

分级缓存。采用L1和L2缓存方式,L1缓存失效时间短,L2缓存失效时间长。请求优先从L1缓存获取数据,如果L1缓存未命中则加锁,只有1个线程获取到锁,改线程从数据库中读取,再将数据set到L1缓存和L2缓存中,而其他线程依旧从L2缓存获取数据并返回。

这种方式,主要是通过避免缓存同时失效并结合锁机制实现。所以,当数据更新时,只能淘汰L1缓存,不能同时将L1和L2中的缓存同时淘汰。L2缓存中可能会存在脏数据,需要业务能够容忍这种短时间的不一致。而且,这种方案可能会造成额外的缓存空间浪费。

方案D(互斥锁):

加锁等待。采用互斥锁的方式。

注意,不能直接在缓存加载逻辑判断时直接采用synchronize。下面列出几种常见做法:

Image

这种方式,确实能够防止缓存失效时并发打到数据库,但缓存没有失效的时候呢?也需要排队获取锁然后去获取数据,岂不是大大降低了系统的吞吐量。

还有另外一种写法:

Image

这种方式,当缓存命中的时候,系统的吞吐量是不会受影响的。但是,缓存失效的时候,请求还是会打到数据库中,只不过不是并发的,而是阻塞进行的,无疑会牺牲用户体验,并给数据库带来额外的压力。

Image

这种做法呢。似乎避免了前面那2种问题,但似乎还是不太完美。因为执行双重检查的那里,虽然是避免了请求打到数据库,但是命中缓存的过程依旧是排队进行的。

例如:当缓存失效时,有30个请求并发读库。使用同步加双重检查机制,可以让1个线程先读库然后写缓存了,剩下的29个线程命中缓存。但是,那29个线程是串行排队的在读缓存,效率方面肯定有影响。

Image

使用互斥锁的方式来实现,可以有效避免前面几种问题。为了方便演示和测试,就直接使用的Java中的ReentranLock。

在实际分布式场景中,可以使用redis、tair、zookeeper等提供的分布式锁来实现,感兴趣的朋友自行查阅相关资料,这里不展开。

新书推荐:《深入分布式缓存》

Image
Image
Image

京东购书,扫描二维码:

Image

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK