缓存更新的套路
看到好些人在写更新缓存数据代码时,先删除缓存,然后再更新数据库,而后续的操作会把数据再装载的缓存中。然而,这个是逻辑是错误的。试想,两个并发操作,一个是更新操作,另一个是查询操作,更新操作删除缓存后,查询操作没有命中缓存,先把老数据读出来后放到缓存中,然后更新操作更新了数据库。于是,在缓存中的数据还是老的数据,导致缓存中的数据是脏的,而且还一直这样脏下去了。
我不知道为什么这么多人用的都是这个逻辑,当我在微博上发了这个贴以后,我发现好些人给了好多非常复杂和诡异的方案,所以,我想写这篇文章说一下几个缓存更新的Design Pattern(让我们多一些套路吧)。
这里,我们先不讨论更新缓存和更新数据这两个事是一个事务的事,或是会有失败的可能,我们先假设更新数据库和更新缓存都可以成功的情况(我们先把成功的代码逻辑先写对)。
更新缓存的的Design Pattern有四种:Cache aside, Read through, Write through, Write behind caching,我们下面一一来看一下这四种Pattern。
目录
Cache Aside Pattern
这是最常用最常用的pattern了。其具体逻辑如下:
- 失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
- 命中:应用程序从cache中取数据,取到后返回。
- 更新:先把数据存到数据库中,成功后,再让缓存失效。
注意,我们的更新是先更新数据库,成功后,让缓存失效。那么,这种方式是否可以没有文章前面提到过的那个问题呢?我们可以脑补一下。
一个是查询操作,一个是更新操作的并发,首先,没有了删除cache数据的操作了,而是先更新了数据库中的数据,此时,缓存依然有效,所以,并发的查询操作拿的是没有更新的数据,但是,更新操作马上让缓存的失效了,后续的查询操作再把数据从数据库中拉出来。而不会像文章开头的那个逻辑产生的问题,后续的查询操作一直都在取老的数据。
这是标准的design pattern,包括Facebook的论文《Scaling Memcache at Facebook》也使用了这个策略。为什么不是写完数据库后更新缓存?你可以看一下Quora上的这个问答《Why does Facebook use delete to remove the key-value pair in Memcached instead of updating the Memcached during write request to the backend?》,主要是怕两个并发的写操作导致脏数据。
那么,是不是Cache Aside这个就不会有并发问题了?不是的,比如,一个是读操作,但是没有命中缓存,然后就到数据库中取数据,此时来了一个写操作,写完数据库后,让缓存失效,然后,之前的那个读操作再把老的数据放进去,所以,会造成脏数据。
但,这个case理论上会出现,不过,实际上出现的概率可能非常低,因为这个条件需要发生在读缓存时缓存失效,而且并发着有一个写操作。而实际上数据库的写操作会比读操作慢得多,而且还要锁表,而读操作必需在写操作前进入数据库操作,而又要晚于写操作更新缓存,所有的这些条件都具备的概率基本并不大。
所以,这也就是Quora上的那个答案里说的,要么通过2PC或是Paxos协议保证一致性,要么就是拼命的降低并发时脏数据的概率,而Facebook使用了这个降低概率的玩法,因为2PC太慢,而Paxos太复杂。当然,最好还是为缓存设置上过期时间。
Read/Write Through Pattern
我们可以看到,在上面的Cache Aside套路中,我们的应用代码需要维护两个数据存储,一个是缓存(Cache),一个是数据库(Repository)。所以,应用程序比较啰嗦。而Read/Write Through套路是把更新数据库(Repository)的操作由缓存自己代理了,所以,对于应用层来说,就简单很多了。可以理解为,应用认为后端就是一个单一的存储,而存储自己维护自己的Cache。
Read Through
Read Through 套路就是在查询操作中更新缓存,也就是说,当缓存失效的时候(过期或LRU换出),Cache Aside是由调用方负责把数据加载入缓存,而Read Through则用缓存服务自己来加载,从而对应用方是透明的。
Write Through
Write Through 套路和Read Through相仿,不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由Cache自己更新数据库(这是一个同步操作)
下图自来Wikipedia的Cache词条。其中的Memory你可以理解为就是我们例子里的数据库。
Write Behind Caching Pattern
Write Behind 又叫 Write Back。一些了解Linux操作系统内核的同学对write back应该非常熟悉,这不就是Linux文件系统的Page Cache的算法吗?是的,你看基础这玩意全都是相通的。所以,基础很重要,我已经不是一次说过基础很重要这事了。
Write Back套路,一句说就是,在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。这个设计的好处就是让数据的I/O操作飞快无比(因为直接操作内存嘛 ),因为异步,write backg还可以合并对同一个数据的多次操作,所以性能的提高是相当可观的。
但是,其带来的问题是,数据不是强一致性的,而且可能会丢失(我们知道Unix/Linux非正常关机会导致数据丢失,就是因为这个事)。在软件设计上,我们基本上不可能做出一个没有缺陷的设计,就像算法设计中的时间换空间,空间换时间一个道理,有时候,强一致性和高性能,高可用和高性性是有冲突的。软件设计从来都是取舍Trade-Off。
另外,Write Back实现逻辑比较复杂,因为他需要track有哪数据是被更新了的,需要刷到持久层上。操作系统的write back会在仅当这个cache需要失效的时候,才会被真正持久起来,比如,内存不够了,或是进程退出了等情况,这又叫lazy write。
在wikipedia上有一张write back的流程图,基本逻辑如下:
再多唠叨一些
1)上面讲的这些Design Pattern,其实并不是软件架构里的mysql数据库和memcache/redis的更新策略,这些东西都是计算机体系结构里的设计,比如CPU的缓存,硬盘文件系统中的缓存,硬盘上的缓存,数据库中的缓存。基本上来说,这些缓存更新的设计模式都是非常老古董的,而且历经长时间考验的策略,所以这也就是,工程学上所谓的Best Practice,遵从就好了。
2)有时候,我们觉得能做宏观的系统架构的人一定是很有经验的,其实,宏观系统架构中的很多设计都来源于这些微观的东西。比如,云计算中的很多虚拟化技术的原理,和传统的虚拟内存不是很像么?Unix下的那些I/O模型,也放大到了架构里的同步异步的模型,还有Unix发明的管道不就是数据流式计算架构吗?TCP的好些设计也用在不同系统间的通讯中,仔细看看这些微观层面,你会发现有很多设计都非常精妙……所以,请允许我在这里放句观点鲜明的话——如果你要做好架构,首先你得把计算机体系结构以及很多老古董的基础技术吃透了。
3)在软件开发或设计中,我非常建议在之前先去参考一下已有的设计和思路,看看相应的guideline,best practice或design pattern,吃透了已有的这些东西,再决定是否要重新发明轮子。千万不要似是而非地,想当然的做软件设计。
4)上面,我们没有考虑缓存(Cache)和持久层(Repository)的整体事务的问题。比如,更新Cache成功,更新数据库失败了怎么吗?或是反过来。关于这个事,如果你需要强一致性,你需要使用“两阶段提交协议”——prepare, commit/rollback,比如Java 7 的XAResource,还有MySQL 5.7的 XA Transaction,有些cache也支持XA,比如EhCache。当然,XA这样的强一致性的玩法会导致性能下降,关于分布式的事务的相关话题,你可以看看《分布式系统的事务处理》一文。
(全文完)
(转载本站文章请注明作者和出处 宝酷 – sou-ip ,请勿用于任何商业用途)
《缓存更新的套路》的相关评论
沙发! 难于置信,哈哈
补充一个参考链接 https://vladmihalcea.com/2015/04/20/a-beginners-guide-to-cache-synchronization-strategies/
拜读了
对缓存策略的认识又提高了一层,很多设计都是相通的。
当看到 write through, write back 的时候一下子想起了计算机组成原理 和 体系结构的老师
the paper “Scaling Memcache at Facebook” provides some best practices for cache system
Cache Aside Pattern模式,两个更新操作同时进来,也可能会有cache脏数据的问题啊
顺序如下:
第一个写数据库,第二个写数据库,第二个写cache,第一个写cache
这样cache里是第一个数据,而数据库里是第二个
谢谢,文中有错误。对于写操作,应该是先更新数据库,然后删除缓存。
如果考虑到数据库读写分离、主从复制的问题呢?
耗子哥真是套路,这都拿出了设计模式,赞
和耗子哥心有灵犀啊,感觉今天会更新,真出一篇新文章了
第四个write的时候,如果not dirty,为啥需要先需要load data from lower memory to the cache block ?
确实好像没必要啊。not dirty说明old data已经持久化了,直接换出就行了;先load出来再换出不是多此一举么。
可能执行的更新操作是n+1这种类型,需要先读出n的值,然后再执行+1操作。
原来更新缓存一直用的是 Cache Aside Pattern, 瞬间感觉高大上许多, 话说真有人会用删除缓存再写的作法吗?!
平常如果是先删除缓存再读数据库,通常都会用事务保证的。
评论不能删除?
读完后最大收获认识了修改缓存的机制比如write through ,还有从底层设计来学习宏观系统架构设计思路。只是write back流程图看有点不明白。在读类型的情况下如果没有命中,为啥还要执行脏数据判断这个步骤?不是数据还未加载到缓存?还有既然缓存有脏数据 为啥要把先前值写入数据库。。
好文,赞各种思想
Write back流程图read操作在缓存未命中之后的is it dirty判断是什么意思,不太明白
@Mr.zhou
逮着机会就更新一次,也是为了后面设置为no-dirty
应该出个 Best Practice 系列文章
学习到了。以前心里最清楚的用法只有第一种。
基础很重要!
基础很重要,基础很重要,基础很重要~
看完耗子叔的介绍,才知道我们之前设计的缓存更新策略使用的是Read/Write Through Pattern,如果缓存有则更新缓存,然后将缓存中变动的数据异步刷新到Mysql。缓存无时,则直接更新数据库。。。
高可用和高性性是有冲突的。
高性性=>高性能
在做更新操作时不可以更改数据库后直接更新缓存吗?这样最多也就一两次脏数据
基础很重要
@BravoZu
你这个异步的应是Write Behind Caching Pattern
先更新数据库,再删缓存正解。https://www.quora.com/Why-does-Facebook-use-delete-to-remove-the-key-value-pair-in-Memcached-instead-of-updating-the-Memcached-during-write-request-to-the-backend
学习了。
就算是先更新数据库再删除缓存也是有可能存在脏数据的,步骤是:1.读操作读缓存未命中 2.读操作读数据库拿到老数据 3.更新操作更新数据库并删除缓存 4.读操作把2读到的老数据写入缓存@chen chen
是的。不过,我想实际上出现的概率可能非常低,因为这个条件需要发生在读缓存时缓存失效,而且并发着有一个写操作。而实际上数据库的写操作会比读操作慢得多,而且还要锁表,所以读操作必需在写操作前进入数据库操作,而又要晚于写操作更新缓存,所有的这些条件都具备的概率基本并不大。
这也就是Quora上的那个答案里说的,要么通过2PC或是Paxos协议保证一致性,要么就是拼命的降低并发时脏数据的概率,而Facebook使用了这个降低概率的玩法,因为2PC太慢,而Paxos太复杂。
更新操作先删除缓存,再更新数据库,读操作发现没有命中,进库读取,这是说读操作在写操作后。这时写操作锁住了表记录进行更新,读的操作能读出老数据吗?
@jenkinv
加个数据时间戳就可以
Cache Aside Pattern中,更新的时候,先更新DB成功,然后删除CACHE失败,不就会有不一致的情况产生吗
多谢耗子哥回复,“Cache Aside Pattern”是简单方案前题下效果最优的@宝酷
监听数据库变动(比如binlog),同步更新到cache, 感觉这样最完美
假设binlog的数据太多,同步更新到cache需要时间,此时的cache数据就可能不是最新的
原文中“那么,是不是Data Aside这个就不会有并发问题了?” 是不是应该是”Cache Aside”的误写?
谢谢,已更正。应该是 CAche Aside
程序员必备!谷歌镜像大全!天天更新!
http://google.adwiki.cn/
牛呀,拜读了
你好
“因为异步,write backg还可以合并对同一个数据的多次操作,所以性能的提高是相当可观的。”
write backg可能是筆誤?
受教了,缓存的处理对系统的性能影响非常大。
多机分布式下有套路么
@hlm
看看《分布式系统的事务处理》
先写数据库,再淘汰缓存:第一步写数据库操作成功,第二步淘汰缓存失败,则会出现DB中是新数据,Cache中是旧数据,数据不一致。分布式环境下就没有完美的解决方案,都是各种权衡,哈哈
同样的疑惑,为什么更新操作只是删除缓存或者把缓存设置为无效,而不是更新缓存呢?
1. 读线程查key未在cache中;2.读线程从db读数据;3.写线程更改数据库;4.写线程看key未在cache中,无法更新cache;5.读线程将旧数据写入cache中。这种场景旧数据可能在cache存在很长时间