如何保证缓存与数据库双写时的数据一致性?第2部分
这是该系列的最后一篇文章。上一篇我们介绍了为什么需要缓存,也介绍了ReadAside的流程和潜在的问题,当然也解释了如何提高ReadAside的一致性。尽管如此,ReadAside还不足以满足高一致性要求。
ReadAside会导致问题的原因之一是因为所有用户都可以访问缓存和数据库。当用户同时操作数据时,由于操作顺序的各种组合而导致不一致。
那么我们就可以通过限制操作数据的行为来有效避免不一致,这就是后面几个方法的核心概念。通读
读取路径从缓存中读取数据如果缓存数据不存在通过缓存从数据库读取缓存返回给应用客户端
写入路径无所谓,通常与WriteThrough或WriteAhead结合使用。
潜在问题
这种方式最大的问题是不是所有的缓存都支持,本文的Redis例子不支持这种方式。
当然,有些缓存是支持的,比如NCache,但是NCache也有它的问题。
首先,它不支持很多客户端SDK。。NETCore是原生支持语言,剩下的选项不多了。
另外,它分为开源版和企业版,但是要知道,如果开源版的人用的不是很多,那么出了问题就悲剧了。即便如此,企业版不仅需要支付基础设施费用,还需要支付软件许可证费用。
如何提高
NCache的成本很高,是否可以自己实现ReadThrough?答案是肯定的。
对于应用程序来说,我们并不关心它背后有什么样的缓存,只要它足够快地为我们提供数据,这就是我们所需要的。因此,我们可以将Redis封装为一个独立的服务,称为数据访问层(DAL),内部有一个API服务器来协调缓存和数据库。
应用程序只需要使用定义的API从DAL中获取数据,而不需要关心缓存如何工作或数据库在哪里。直写
读取路径不用管,实际工作一般都是通过ReadThrough完成的。
写入路径只为缓存而写的数据通过缓存更新数据库
潜在问题
与ReadThrough一样,并非每个缓存都受支持,必须自行实现。
此外,缓存并非设计用于数据操作。很多数据库都有缓存不具备的能力,尤其是关系型数据库的ACID保证。
更重要的是,缓存不适合数据持久化。当应用程序写入缓存并认为更新已完成时,缓存可能仍会因为某种原因而丢失数据。然后,当前的更新将永远不会再发生。
如何提高?
与ReadThrough一样,必须实施DAL,但ACID和持久性问题仍未克服。于是,
WriteAhead诞生了。提前写
读取路径不用管,实际工作一般都是通过ReadThrough完成的。
写入路径只为缓存而写的数据通过缓存更新数据库
潜在问题
同样,许多缓存也不支持WriteAhead。尽管读取路径和写入路径看起来与WriteThrough相同,但其背后的实现却大不相同。
WriteAhead是为了解决WriteThrough的问题而产生的,所以先介绍一下。
我们还将实现一个DAL,但与WriteThrough不同,它实际上是一个内部消息队列而不是缓存。从上图可以看出,整个DAL架构变得更加复杂。要正确使用消息队列,需要更多的领域知识和更多的人力资源来设计和实现。
如何提高?
通过使用消息队列,可以有效保证变化的持久性,同时消息队列也保证了一定的原子性和隔离性,虽然没有关系型数据库那么完备,但仍然具有基本的可靠性。
此外,消息队列可以将碎片化的更新合并成批次。例如,当一个应用程序想要更新三个缓存以便发送三个消息时,DALworker可以将这三个消息合并为一个SQL语法以减少对数据库的访问。
需要注意的是必须使用消息队列来保证消息的顺序,因为对于数据库的更新来说,先插入再删除和先删除再插入的意义是截然不同的。每个消息队列确保消息顺序的方法略有不同:
1)Kafka,可以通过使用正确的分区键来实现。
2)RocketMQ,可以基于队列(分区)的顺序消费,顺序消费,分为全局顺序消费,和局部顺序消费。
全局顺序消费:只能有一个数据队列(queue),和一个消费者实例。原因是RocketMQ只提供在单个queue上使用FIFO顺序的有序消息。多个queue之间并不能保证消息的严格先后性。
局部顺序消费:通常在实际应用中,我们需要将同一个订单号的相关操作,按照规则(可以是hash或取模等)发送到同一个queue上(使用MessageQueueSelector),然后消费者实例,使用顺序消费模式消费消息(使用MessageListenerOrderly)
然而,实现WriteAhead的复杂度非常高。如果您负担不起这样的复杂性,那么ReadAside是更好的选择。双重删除
我们已经讨论了两种主要类型的缓存模式,它们是读一边通读、通写、预写
这两种类型之间最根本的区别是实现的复杂性。以ReadAside为例,实现起来非常容易,做对也非常简单。但是,ReadAside在很多交互下很容易产生各种cornercase。
另一方面,通过实现DAL可以避免cornercases,但是正确实现DAL非常困难,并且需要广泛的领域知识才能正确实现,这进一步使DAL难以实现。
那么,DAL是减少极端案例数量的唯一方法吗?不,不是真的。
这就是DoubleDelete模式试图解决的问题。
读取路径从缓存中读取数据如果缓存数据不存在改为从数据库读取并写回缓存
该过程与ReadAside完全相同。
写入路径先清除缓存然后将数据写入数据库稍等片刻,然后再次清除缓存
潜在问题
DoubleDelete的目的是尽量减少因ReadAsidecornercases而导致灾难的时间。
整个不一致完全取决于等待时间,等于最大等待时间。
但如何等待也是一个棘手的实际问题。如果让client本来就开始处理,那么cornercase2的killed场景还是解决不了。如果其他人以异步的方式执行,那么这之间的通信契约和工作流控制就会很复杂。
如何提高
与ReadAside相同的极端情况2,但同样,它可以通过正常关闭来减少。结论
在这篇文章中,我们介绍了很多提高一致性的方法。通常,当一致性不是关键要求时,缓存过期就足够了并且需要非常低的实现工作量。事实上,广泛使用的CDN只是使用CacheExpiry的其中一种情况。
随着场景越来越关键,对一致性要求越来越高,那就考虑用ReadAside甚至DoubleDelete来实现。这两种方法的正确实现足以使一致性满足大多数场景。
然而,随着一致性要求的不断提高,需要更复杂的实现,例如ReadThrough和WriteThrough甚至WriteAhead。虽然这可以提高一致性,但成本也很高。首先,它需要足够的人力和领域知识来实施。此外,实施的时间成本和事后的维护成本也明显更高。此外,运营这样的基础设施还有额外的费用。
为了进一步提高一致性,需要使用更先进的技术,如共识算法,以多数共识的方式保证缓存和数据库内容的一致性。这也是TAO背后的理念,但我不打算介绍这么复杂的做法,毕竟我们不是Meta,至少我不是。
在一般的组织中,对一致性的要求没有那么严格,比方说,10个或更多的9,一般的组织无法操作如此复杂和庞大的架构。
因此,在本文中,我选择了我们都可以实现的做法,但即使是简单的做法,如果正确实施,也已经具有足够高的一致性。