网站内容更新机制(缓存中的数据是脏的,但不是因为缓存)
优采云 发布时间: 2022-02-24 13:15网站内容更新机制(缓存中的数据是脏的,但不是因为缓存)
我看到很多人写代码更新缓存数据的时候,都是先删除缓存,再更新数据库,后续的操作都会把数据重新加载到缓存中。然而,这个逻辑是错误的。想象一下两个并发操作,一个是更新操作,另一个是查询操作。更新操作删除缓存后,查询操作没有命中缓存。首先将旧数据读出并放入缓存中,然后更新操作更新数据库。所以缓存中的数据还是旧数据,导致缓存中的数据是脏的,而且会一直这样脏。
我不知道为什么这么多人使用这个逻辑。发到微博后发现很多人给出了很多很复杂很奇怪的解决方案,所以想写这篇文章说说缓存更新的几个设计模式(多做一些套路吧) .
这里先不讨论更新缓存和更新数据是事务,还是有失败的可能。我们假设更新数据库和更新缓存都可以成功(我们先把成功的代码逻辑写在最前面)。
更新缓存有四种设计模式:缓存搁置、读取、写入、缓存后写入。让我们一一来看看这四种模式。
缓存模式
这是最常用的模式。其具体逻辑如下:
1.失败:应用程序先从缓存中取数据,如果没有取到则从数据库中取数据,成功后放入缓存中。
2.命中:应用从缓存中取出数据,取出后返回。
3.更新:先将数据存入数据库,成功后再使缓存失效。
注意我们的更新是先更新数据库,成功后使缓存失效。那么,如果没有文章前面提到的问题,这种方式是否可行?我们可以弄清楚。
一是查询操作,二是更新操作的并发。首先,没有删除缓存数据的操作,而是先更新数据库中的数据。此时缓存仍然有效,所以并发查询操作拿不到更新的数据,但是更新操作立即使缓存失效,后续的查询操作将数据拉出数据库。而不是文章开头的逻辑导致的问题,后续的查询操作总是取旧数据。
这是一种标准设计模式,包括 Facebook 的论文“Scaling Memcache at Facebook”也使用了这种策略。为什么写入数据库后不更新缓存?可以看一下Quora上的这个问答“为什么Facebook在向后端写请求时,会使用delete来删除Memcached中的键值对,而不是更新Memcached?”,主要是因为两个并发的写操作会导致脏数据。
那么,Cache Aside 就没有并发问题吗?不可以,比如一个是读操作,但是没有命中缓存,然后从数据库中取出数据。此时,发生写操作。数据库写入后,缓存失效,然后之前的读操作将数据放入其中,这样会造成脏数据。
不过这种情况理论上会出现,但在实际中出现的概率可能很低,因为这种情况需要在读取缓存的时候出现,并且有并发的写操作。实际上,数据库的写操作会比读操作慢很多,而且需要对表进行加锁。读操作必须在写操作之前进入数据库操作,并在写操作之后更新缓存。这些条件都满足的概率基本不大。
所以,这就是 Quora 上的回答说的,要么通过 2PC 或者 Paxos 协议保证一致性,要么拼命降低并发时脏数据的概率,而 Facebook 用这种方法来降低概率,因为 2PC 太慢了,而且Paxos太复杂了。当然,最好给缓存设置一个过期时间。
通过模式读/写
我们可以看到,在上面的Cache Aside例程中,我们的应用程序代码需要维护两个数据存储,一个是缓存(Cache),另一个是数据库(Repository)。因此,该应用程序相当冗长。Read/Write Through 例程是通过缓存本身代理更新数据库(Repository)的操作,因此对于应用层来说要简单得多。可以理解为应用认为后端是一个单一的存储,存储维护自己的Cache。
1.通读
Read Through 例程是在查询操作过程中更新缓存,即当缓存无效(过期或 LRU 换出)时,Cache Aside 是负责将数据加载到缓存中的调用者,而 Read Through 使用缓存服务本身。加载,因此它对应用程序端是透明的。
2.直写
Write Through 例程类似于 Read Through,但发生在数据更新时。有数据更新时,如果没有命中缓存,直接更新数据库,然后返回。如果命中缓存,则更新缓存,然后Cache自己更新数据库(这是同步操作)
下图来自维基百科的缓存条目。其中的Memory可以理解为我们例子中的数据库。
写在缓存模式后面
回写也称为回写。一些了解Linux操作系统内核的同学应该对回写非常熟悉。这不就是Linux文件系统的Page Cache的算法吗?是的,你看,基础都是一样的。所以,底子很重要,我也没有说过底子很重要。
Write Back的套路,一句话,就是在更新数据的时候,只更新缓存,不更新数据库,我们的缓存会批量异步更新数据库。这种设计的优点是数据I/O操作非常快(因为直接操作内存)。由于是异步的,write backg也可以对同一数据进行多次操作,性能提升相当可观。
但是,它带来的问题是数据不是强一致性,可能会丢失(我们知道Unix/Linux关机会因此导致数据丢失)。在软件设计中,我们基本上不可能做出完美无缺的设计,就像在算法设计中,时间被空间代替,空间被时间代替。有时,强一致性和高性能、高可用性和高性能是矛盾的。软件设计一直是一种权衡取舍。
另外,Write Back的实现逻辑比较复杂,因为它需要跟踪哪些数据已经更新,需要刷到持久层。操作系统的回写只有在缓存需要失效时才会真正持久化,比如内存不够,或者进程退出等,也称为懒写。
wikipedia上有一个回写流程图,基本逻辑如下:
多聊
1)上面提到的这些设计模式,其实并不是软件架构中mysql数据库和memcache/redis的更新策略。这些东西是在计算机体系结构中设计的,比如CPU缓存和硬盘文件系统。缓存,硬盘缓存,数据库缓存。基本上,这些缓存更新的设计模式都是非常老套且久经考验的策略,所以这就是所谓的工程中的最佳实践,只需遵循它即可。
2)有时候,我们觉得能做宏观系统架构的人一定很有经验。事实上,宏观系统架构中的很多设计都是从这些微观的东西中衍生出来的。比如云计算中很多虚拟化技术的原理是不是和传统的虚拟内存很像?Unix下的I/O模型在架构上也被放大为同步和异步模型,难道Unix发明的管道不就是数据流计算架构吗?TCP 的一些设计也用于不同系统之间的通信,如果你仔细观察这些微观层面,你会发现其中很多都是非常微妙的......所以请允许我在这里强调一点——如果你这样做了好的建筑,首先,
3)在软件开发或设计中,我强烈建议参考之前已有的设计和思路,看相应的指南、最佳实践或设计模式,彻底了解这些已有的东西,然后再决定是否重新发明轮子. 不要似是而非,理所当然地做软件设计。
4)上面,我们没有考虑缓存(Cache)和持久层(Repository)的整体事务。比如缓存更新成功,数据库更新失败怎么办?或者反过来。关于这件事,如果需要强一致性,就需要使用“两阶段提交协议”——prepare,commit/rollback,比如Java 7的XAResource,MySQL 5.7的XA Transaction,一些缓存也支持XA,如EhCache。当然,像 XA 这样的强一致性玩法会导致性能下降。分布式事务相关的话题,可以阅读《分布式系统中的事务处理》一文。