美文网首页缓存架构程序员
关于如何更新缓存的探讨

关于如何更新缓存的探讨

作者: 不知名的程序员 | 来源:发表于2019-01-11 15:12 被阅读56次

    写这篇文章的原因

    现在我们的系统都需要使用缓存提高性能,使用缓存就需要对缓存进行维护,那么当数据发生变化时我们应该先操作缓存还是先操作数据库呢?网上有两篇很好的文章,一篇是来自58沈剑的架构师之路系列之缓存架构设计缓存架构设计,一篇来自于左耳朵耗子陈皓的缓存更新的套路,两位老师给出了很好的分析,这里分别总结一下,希望能对看过的同学有所帮助。

    架构师之路

    先来说一下沈剑老师的文章。

    当数据库执行更新操作时,我们会进行缓存的淘汰,由于操作缓存与操作数据库并不能保证原子性,所以解题思路就是:如果出现不一致,谁先做对业务的影响较小,就谁先执行。分别分析下

    先写数据库的情况:第一步写数据库操作成功,第二步淘汰缓存失败,则会出现DB中是新数据,Cache中是旧数据,数据不一致。

    先淘汰缓存的情况:第一步淘汰缓存成功,第二步写数据库失败,则只会引发一次Cache miss。

    所以结论是:先淘汰缓存,再写数据库

    陈皓-酷壳

    刚开始看的时候让我惊讶的是,陈皓老师文章开篇就指出了先淘汰缓存再更新数据库的做法是错误的。

    给出的理由如下:两个并发操作,一个是更新操作,另一个是查询操作,更新操作删除缓存后,查询操作没有命中缓存,先把老数据读出来后放到缓存中,然后更新操作更新了数据库。于是,在缓存中的数据还是老的数据,导致缓存中的数据是脏的,而且还一直这样脏下去了(好像没毛病)

    接下来列举了几个常用的缓存模式

    首先是Cache aside

    以下是对Cache aside几种缓存状态的处理:

    失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。

    命中:应用程序从cache中取数据,取到后返回。

    更新:先把数据存到数据库中,成功后,再让缓存失效。

    这个更新操作就不会发生陈皓老师开篇时提到的问题。举个🌰,一个查询操作和一个更新操作的并发,首先,没有了删除cache数据的操作了,而是先更新了数据库中的数据,此时,缓存依然有效,所以,并发的查询操作拿的是没有更新的数据,但是,更新操作马上让缓存的失效了,后续的查询操作再把数据从数据库中拉出来。这样后续的查询操作就会拉取最新的数据。

    并且陈皓老师也指出,Facebook的论文《Scaling Memcache at Facebook》也使用了这个策略。这样做的目的主要是避免两个并发的写操作导致脏数据。

    但是随后又指出这个模式也会出现不一致的情况,举个🌰,一个是读操作,但是没有命中缓存,然后就到数据库中取数据,此时来了一个写操作,写完数据库后,让缓存失效,然后,之前的那个读操作再把老的数据放进去,所以,会造成脏数据。这个case理论上会出现,不过出现的概率可能非常低,因为这个条件需要发生在读缓存时缓存失效,而且并发着有一个写操作。而实际上数据库的写操作会比读操作慢得多,而且还要锁表,而读操作必需在写操作前进入数据库操作,而又要晚于写操作更新缓存,所有的这些条件都具备的概率基本并不大。

    所以使用先操作数据库后操作缓存的方法会大大降低并发时脏数据的概率,并且为了尽量避免上文的低概率事件,最好为缓存设置过期时间。

    这里陈皓老师得出了与沈剑老师相反的结论,陈皓老师在文章的末尾给出了答案“上面,我们没有考虑缓存(Cache)和持久层(Repository)的整体事务的问题”,假设原子性得以保障(可以使用2PC,3PC,Paxos等算法进行保障),那么先操作数据库则是最优的选择。两位老师的结论先放一边,继续。

    陈皓老师又给我们开了点小灶,介绍了其他常用的缓存模式。

    Read/Write Through Pattern

    Read Through 套路就是在查询操作中更新缓存,也就是说,当缓存失效的时候(过期或LRU换出),Cache Aside是由调用方负责把数据加载入缓存,而Read Through则用缓存服务自己来加载,从而对应用方是透明的。

    Write Through 套路和Read Through相仿,不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由Cache自己更新数据库(这是一个同步操作)

    Write Behind Caching Pattern

    Write Behind 又叫 Write Back。Write Back一句说就是,在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。这个设计的好处就是让数据的I/O操作飞快无比,因为异步,write backg还可以合并对同一个数据的多次操作,所以性能的提高是相当可观的。

    但是,其带来的问题是,数据不是强一致性的,而且可能会丢失(我们知道Unix/Linux非正常关机会导致数据丢失,就是因为这个事)。另外,Write Back实现逻辑比较复杂,因为他需要track有哪数据是被更新了的,需要刷到持久层上。操作系统的write back会在仅当这个cache需要失效的时候,才会被真正持久起来,比如,内存不够了,或是进程退出了等情况,这又叫lazy write。

    转折

    本来看到这里我本以为沈剑老师没有考虑到并发读写的问题,导致文章出了纰漏,直到我看到了他的第二篇文章数据与缓存一致性优化

    文章开头给出了读写并发时导致数据不一致的case,同陈皓老师举的🌰一样,就不多说了。不过文章后半部分对于先操作缓存,后操作数据库的做法给出了优化。

    让同一个数据的访问能串行化

    在一个服务中如何做到“让同一个数据的访问串行化”,只需要“让同一个数据的访问通过同一条DB连接执行”就行。如何做到“让同一个数据的访问通过同一条DB连接执行”,只需要“在DB连接池层面稍微修改,按数据取连接即可”。将从连接池获取数据库连接的操作修改为CPool.GetDBConnection(longid)【返回id取模相关联的DB连接】。

    当有多份服务时,方案同上,想办法让对同一数据的访问落在同一服务上即可。同样CPool.GetServiceConnection(longid)【返回id取模相关联的Service连接】。

    总结一下:

    (1)修改服务Service连接池,id取模选取服务连接,能够保证同一个数据的读写都落在同一个后端服务上

    (2)修改数据库DB连接池,id取模选取DB连接,能够保证同一个数据的读写在数据库层面是串行的

    自己的一些思考

    总结完了两位老师的文章,最后是自己的一些感悟与思考。

    因为我上学的时候就已经看过沈剑老师的第一篇文章,当时看完有种豁然开朗,吊吊吊的感觉,从那以后就一直把先操作缓存后更新数据的做法当做了最标准的做法(实际上工作之后发现项目里也基本都是这样做的)。直到有一天看到了酷壳上陈皓老师的文章,和我认为的“标准做法”完全相反啊,这是怎么回事?后来经过对文章的仔细阅读才理清楚,看到了沈剑老师的第二篇文章也才明白第一篇文章只是个上集,原来还有下集。总结一点,学知识不能快餐文化,也不能“逆来顺受”,更不能“浅尝辄止”,我们需要有自己的思考,需要自己的总结。(写博客就是挺好的一种总结方式)

    最后关于缓存更新俩种方案该选择哪一种,我认为,如果系统并发量较小,那么选择先淘汰缓存的做法(不做后续连接取模等操作)是比较好的。如果并发量较大,并且缓存系统做了集群,网络极少发生抖动(也就是极大程度可以保证原子性),那么选择先操作数据库后操作缓存的做法较好。而关于做连接取模与使用2PC等方案保证数据一致性,个人感觉没有必要,徒增复杂性,因为涉及库存等重要的数据操作无论如何最后都要查询真实的DB,给缓存数据设置过期时间减少不一致发生的概率与存在时间即可。

    相关文章

      网友评论

        本文标题:关于如何更新缓存的探讨

        本文链接:https://www.haomeiwen.com/subject/zzitdqtx.html