如何维持缓存的一致性?

如何维持缓存的一致性?

May 31, 2020

Phil Karlton 曾经说过,“计算机科学中只有两件困难的事情:缓存失效和命名问题。” 这句话还有其他很好的举例。我个人最喜欢 Jeff Atwood 的一句话:“计算机科学中有两件困难的事情:缓存失效、命名和一个错误就关闭。”显然,缓存是困难的。就像分布式系统中的几乎所有东西一样,它甚至可能一眼就看不清。我将介绍分布式系统中几种常见的缓存方法,这些方法应该涵盖您将使用的绝大多数缓存系统。具体来说,我将关注如何维护缓存一致性。

缓存 & 缓存一致性

在讨论不同的缓存方式之前,我们需要非常精确地说明缓存和缓存一致性的含义,特别是因为一致性是一个严重超载的术语。

这里我们将缓存定义为:

一个单独的系统,它存储一个视图,这个视图是底层完整数据存储的一部分。

注意,这是一个非常笼统和轻松的定义。它包括通常被认为是缓存的内容,它存储与(通常是持久的)数据存储相同的值。它甚至包括一些人们通常不认为是缓存的东西。例如,数据库的非聚集二级索引。在我们的定义中,它也可以是一个缓存,保持缓存的一致性很重要。

这里我们称缓存为一致的:

如果 k 存在于缓存中,则键 k 的值最终应与基础数据存储相同。

有了这个定义,如果缓存不存储任何内容,它总是一致的。但那根本没什么意思,因为它完全没用。

为什么使用缓存

通常部署缓存是为了提高读写性能。这里的性能可以是延迟、吞吐量、资源利用率等,并且通常是相关的。保护数据库通常也是构建缓存的一个非常重要的动机。但你可以说这也是它正在解决的一个性能问题。

不同类型的缓存

Look-aside / demand-fill 缓存

对于 look aside 缓存,客户端将在查询数据存储之前首先查询缓存。如果命中,它将返回缓存中的值。如果是未命中,它将从数据存储返回值。它没有说明缓存应该如何填充。它只是指定如何查询它。但通常情况下,是 demand-fill (按需填充)。Demand-fill 意味着在未命中的情况下,客户端不仅使用数据存储中的值,而且还将该值放入缓存中。通常,如果您看到一个look-aside 缓存,它也是一个 demand-fill 缓存。但这不一定。例如,你可以让缓存和数据存储订阅同一个日志(如Kafka)并独立实现。这是一个非常合理的设置。在本例中,缓存是一个 look-aside 缓存,而不是 demand-fill。而且缓存甚至可以拥有比持久数据存储更新鲜的数据。

很简单,对吧?不过,简单的 Look aside/demand fill 缓存可能会有永久的不一致性!由于 look aside 缓存的简单性,这常常被人们忽略。根本上是因为当客户端将一些值放入缓存时,该值可能已经过时。具体来说

- client gets a MISS (客户端未命中)
- client reads DB get value `A` (客户端从数据库读取值:A)
- someone updates the DB to value `B` and invalidates the cache entry (某人刷新了数据库,值变为了 B)
- client puts value `A` into cache (客户端将 A 放入了缓存)

从那时起,客户端将继续从缓存中获取A,而不是B,后者是最新的值。取决于您的用例,这可能是正常的,也可能不是。它还取决于缓存条目是否有 TTL。但在使用 look aside/demand fill 缓存之前,您应该知道这一点。

这个问题可以解决。Memcache使用 lease 来解决这个问题。因为从根本上讲,客户端在缓存上执行read-modify-write操作,而不使用原语来保证操作的安全性。在此设置中,read 从缓存中读取。modify 从数据库中读取。write 就是写回缓存。执行read-modify-write的一个简单解决方案是保留某种 “ticket” 来表示 read 时的缓存的状态,并比较 write 时的“ticket”。这就是 Memcache 解决问题的有效方法。Memcache 将其称为 lease,您可以将其作为简单的计数器,在每次缓存改变时都会碰到它。因此,在 read 时,它从 Memcache 主机获取 lease,在 write 时,客户端将 lease 一起传递。如果主机上的 lease 已更改,Memcache 将无法写入。现在回到前面的例子:

- client gets a MISS with lease `L0` (客户端未命中,租约: L0)
- client reads DB get value `A` (客户端从数据库读取值: A)
- someone updates the DB to value `B` and invalidates the cache entry, which sets lease to `L1` (某人更新了数据库,最新值:B,租约:L1)
- client puts value `A` into cache and fails due to lease mismatch (客户端放入 A 值到缓存失败,因为租约不匹配)

事情维持了一致:)

Write-through / read-through 缓存

Write-through 缓存方式意味着变异,客户端直接写入缓存。缓存负责同步写入到数据库中。它没有提到如何读取值的问题。客户端可以执行 look-aside 读或 read-through。

Read-through 缓存意味着读取,客户端直接从缓存中读取。如果是未命中,cache 负责填充数据存储中的数据并回复客户端的查询。它没有提到写作。客户端可以 demand-fill 写入缓存或 write-through。

现在你得到一张表格 (TAO: Facebook’s Distributed Data Store for the Social Graph):

同时有 write-through 和 look-aside 缓存并不常见。既然您已经构建了一个位于客户端和数据存储中间的服务,知道如何与数据存储对话,那么为什么不同时为读写操作这样做呢。也就是说,在有限的缓存大小下,根据查询模式的不同,write-through 和 look-aside 缓存可能是命中率的最佳选择。例如,如果大多数读操作在写操作之后立即执行,那么 write-through 和 look-aside 缓存可能提供最佳命中率。Read-through 和 demand-fill 的结合没有意义。

现在让我们来看看 write-through 和 read-through 缓存的一致性。对于单个问题,只要正确获取 writeupdate lockreadfill-lock,就可以序列化对同一个 key 的读写操作,并且不难看出缓存的一致性将得到维护。如果存在多个缓存副本,这将成为一个分布式系统问题,可能存在一些潜在的解决方案。保持缓存的多个副本一致的最直接的解决方案是拥有一个突变/事件日志,并基于该日志更新缓存。此日志用于单点序列化。它可以是 Kafka 甚至 MySQL binlog。只要突变是以易于重放这些事件的方式进行了全局的排序,就可以保持最终的缓存一致性。注意,这背后的推理与分布式系统中的同步相同。

Write-back / memory-only 缓存

还有一类缓存会遭受数据丢失的影响。例如,Write-back 缓存会在写入持久数据存储之前确认写入,如果在两者之间崩溃,则很明显会遭受数据丢失。这种类型的缓存有自己的使用场景,通常用于非常高的吞吐量和qps。但不一定太在意持久性和一致性。关闭持久性的 Redis 就属于这一类。

译文来源

扫描下面二维码在手机端阅读: