先提个问题,你们觉得 4个9(99.99%)的数据一致性 SLA Service Level Agreement是不是很高?但对于像 AWS S3这样的成熟服务,数据 SLA 高达11个9这的确很惊人;每增加一个9,其实现的难度和复杂性会呈指数级增长,因此对于创业公司基本上没有资源来维护非常高的 SLA。
名词解释:
- 【一致性】:缓存和数据库数据之间的一致性。
- 【SLA】:Service Level Agreement
- 【TAO】:TAO 是一个分布式缓存,具有非常高的 SLA (10个9)。然而,要操作这样一个服务,其背后有一个非常复杂的体系结构,甚至对缓存的监视也是非常大的,这对于普通公司来说是负担不起的。
1. 为什么要用缓存呢?直接数据库不行吗?原因有三点如下:
- 首先:数据库的价格很高。为了提供数据持久性和尽可能多的高可用性,即使是关系数据库也提供了 ACID 保证,这使得数据库的实现变得复杂,也消耗了硬件资源。无论是硬盘驱动器、内存还是 CPU,数据库都必须得到良好的硬件规范的支持才能正常工作,这也导致了数据库本身的高昂价格。
- 其次:数据库的性能是有限的。为了保持数据的持久性,写入数据库的数据必须写入硬盘,这也造成了数据库的性能瓶颈,毕竟,硬盘的读写效率比内存差得多
- 最后:数据库远离用户。在这里,“远”意味着物理距离。如第一点所述,由于数据库费用高昂,而且需要尽可能集中数据以便进一步分析和利用,因此没有在世界各地建立全球服务数据库。最常见的做法是选择一个固定的位置。例如, AWS的亚洲数据中心在新加坡数,亚洲用户经常选择它,但对于日本用户,网络距离增加,传输速率降低。
- 所以我们需要缓存,因为缓存不需要是持久的,所以它可以使用内存作为存储介质,所以它是廉价的并且具有优秀的性能。由于价格低廉,缓存可以放在离用户尽可能近的地方,例如,缓存可以放在河北廊坊、灵丘、怀来、青岛等,这样中国的用户就可以在附近使用它们。
2. 6种缓存模式
- Cache Expiry
- Read Aside
- Read Through
- Write Through
- Write Ahead (Write Behind)
- Double Delete
Cache Expiry
-
读路径
- 从缓存读取数据
- 如果缓存数据不存在
- 则从数据库读取
- 然后写回缓存(当写回缓存时,我们为每个数据添加一个 TTL)
-
写路径
-
潜在问题
- 在更新数据时,会发生不一致,因为数据只写回数据库。不一致的时间取决于 TTL 的设置,然而,很难为 TTL 选择一个合适的值。如果 TTL 设置得太长,不一致性时间将增加,相反,缓存将不会有效。值得一提的是,构建缓存是为了减少数据库的负载并提供性能,而且非常短的 TTL 将使缓存无用。例如,如果某个数据的 TTL 被设置为1秒,但是没有人在1秒内读取它,那么缓存的数据将根本没有值。
-
如何改进
- 虽然是通常的做法,但是在更新数据库时,还应该有一种更新缓存数据的机制。这也是“Read Aside”。
Read Aside
Read Through
-
读路径
- 从缓存读取数据
- 如果缓存数据不存在
- 通过缓存从数据库读取
- 缓存返回数据到应用程序客户端
-
写路径
- 同 Write Through or Write Ahead 实现
-
潜在问题
- 这种方法的最大问题是不支持所有缓存,本文中的 Redis 示例不支持这种方法。当然,也支持一些缓存,比如 NCache,但是 NCache 也有它的问题。首先,它不支持许多客户端 SDK。.NET Core 是本地支持语言,没有多少选项可供选择。此外,它还分为开放源码版本和企业版本,但是如果开放源码版本没有被很多人使用,那么出现问题就是一场悲剧。即便如此,企业版本还是需要许可证费用,不仅是基础设施,还包括软件许可证。
-
如何改进
- 由于 NCache 的成本很高,我们能够自己实现 Read Through 吗? 答案是肯定的。
对于应用程序,我们并不真正关心它后面是什么类型的缓存,只要它能够足够快地为我们提供数据,这就是我们所需要的。因此,我们可以将 Redis 打包为称为数据访问层(DAL)的独立服务,并使用内部 API 服务器来协调缓存和数据库。应用程序只需要使用定义的 API 从 DAL 获取数据,而不需要关心缓存如何工作或数据库在哪里。
Write Through
-
读路径
-
写路径
-
潜在问题
- 与 Read Through 一样,并不支持每个缓存,而且必须自己实现。此外,缓存的设计目的不是用于数据操作。许多数据库具有缓存所不具备的功能,特别是关系数据库的 ACID 保证。更重要的是,缓存不适合于数据持久性。当应用程序写入缓存并考虑完成更新时,缓存仍然可能由于“某种原因”丢失数据。然后,当前的更新将永远不会再次发生。
-
如何改进
- 与 Read Through 一样,必须实现 DAL,但仍然没有解决 ACID 和持久性问题。因此,“Write Ahead”诞生了
Write Ahead (Write Behind)

-
读路径
-
写路径
-
问题
- 尽管读取路径和写入路径看起来与 Write Through 相同,但它背后的实现却大不相同。“提前写入”是为了解决“完成写入”的问题而创建的。
- 我们还将实现一个 DAL,它实际上是一个内部消息队列,而不是一个缓存。从上面的图中可以看到,整个 DAL 体系结构变得更加复杂。正确使用消息队列需要更多的领域知识和更多的人力资源来设计和实现。
-
如何改进
- 通过使用消息队列,可以有效地确保更改的持久性,并且消息队列还保证一定程度的原子性和隔离性,这虽然不像关系数据库那样完整,但仍具有基本的可靠性。
- 此外,消息队列可以将片段更新合并为批处理。例如,当应用程序希望更新三个缓存以便发送三条消息时,DAL 工作者可以将三条消息合并为一个单独的 SQL 语法,以减少对数据库的访问。
- 需要注意的是,必须使用消息队列来确保消息的顺序,因为对于数据库更新,插入和然后删除的含义与删除和然后插入的含义非常不同。对于每个消息队列,确保消息顺序的方法略有不同,对于 Kafka,可以通过使用正确的分区键来实现。
- 然而,实现预写的复杂性非常高。如果您无法承受这样的复杂性,那么Read Aside仍然是一个更好的选择。
Double Delete
我们已经讨论了两种主要类型的缓存模式,它们分别是
- Read Aside
- Read Through, Write Through, Write Ahead
这两种类型之间最根本的区别在于实现的复杂性。在“Read Aside”的情况下,实现起来非常容易,而且做对也非常简单。但是,在许多交互中,Read ASide 有很多弊端case。
另一方面,通过实现 DAL 可以避免弊端问题,但是正确实现 DAL 非常困难,而且需要大量的领域知识才能正确实现,这使得 DAL 的实现更加困难。
那么,DAL 是否是减少弊端案例数量的唯一方法呢?其实不是的,这就是 Double Delete 模式试图解决的问题。
-
读路径
- Reading data from cache
- If the cache data does not exist
- Read from the database instead
- and write back to the cache
-
写路径
- Clear the cache first
- Then write the data into the database
- Wait for a while, then clear the cache again
- 潜在问题
- 双重删除的目的是尽量减少由于读取Read Aside 弊端case1 case3 案例而造成的灾难所花费的时间。整个不一致性完全取决于等待时间。case2 程序被killed的场景仍然无法解决。
-
如何改进
- 尽可能通过优雅关机避免case2 程序被killed的场景。
小结
- 在本文中,我们介绍了许多提高一致性的方法。一般来说,当一致性不是一个关键的需求时,Cache Expiry 就足够了,并且需要非常低的实现工作量。实际上,广泛使用的 CDN 仅仅是使用 Cache Expiry 的情况之一。
- 随着场景变得越来越关键,并且需要越来越高的一致性,那么考虑使用“读取旁边”甚至“双重删除”来实现它。这两种方法的正确实现足以保证满足大多数场景的一致性。
- 为了进一步提高一致性,有必要使用更高级的技术,如一致性算法,以确保缓存和数据库内容的多数一致性的一致性。这也是 TAO 背后的概念....
- 在一般组织中,对一致性的要求不像10个或更多个9那样严格,一般组织不能操作如此复杂和庞大的体系结构。我们可以选择上面简单的实践 Read Aside Cache Expiry Doubble Delete等,但是即使它们是简单的实践,如果正确实现的话,已经有了足够高的一致性。