如何维持缓存的一致性?

如何维持缓存的一致性?

May 31, 2020

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

缓存 & 缓存一致性

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

这里我们将缓存定义为:

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

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

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

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

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

为什么使用缓存

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

数据不一致

示例

ID = 1 的用户的年龄从 19 更新为 20:

updateDB(1, 20)
updateCache(1, 20)

上面代码会造成数据不一致

原因

两个请求同时过来,数据库变成了 21,缓存依然是 20:

requestA.updateDB(1, 20)
requestB.updateDB(1, 21)
requestB.updateCache(1, 21)
requestA.updateCache(1, 20)

原因就在于 updateDBupdateCache 是两个独立的操作。

解决

不更新缓存,删除缓存

updateDB(1, 20)
deleteCache(1, 20)

读取的时候,发现缓存没有了数据,再从数据库读取数据,更新到缓存。这个策略就是 Cache-Aside (旁路缓存) 策略。

特殊情况

新注册一个用户,写入数据库,清理缓存(么有数据可以清理)。注册完用户后,需要立马读出来,而数据库主从分离,主从延迟导致读取不到用户信息

此时,反而需要插入数据库后,插入缓存才可以:

insertDB(1, '张三')
insertCache(1, '张三')

而且新注册用户,也不存在并发更新用户信息的情况。

缺点

写入比较频繁,缓存频繁被清理,对命中率造成影响。

  • (1) 引入分布式锁
updateDB(1, 20)
redis.lock()
updateCache(1, 20)
redis.unlock()
  • (2) 引入 TTL 过期时间
updateDB(1, 20)
updateCache(1, 20, 5, TimeUnit.SECONDS) // 5 秒后过期

即使出现不一致,也会很快过期,对业务影响有限。

携程如何保证缓存一致性

参考自携程技术公众号 《分布式缓存与 DB 秒级一致设计实践》

携程开发了缓存组件,做到两件事情:

  • 按需异步将缓存中需要增、删、改的键值对通过消息传递给缓存更新平台,让其进行实际的缓存更新操作。
  • 热点 key 进行本地缓存与更新,避免对某个 key 的大量请求直接打到缓存导致缓存雪崩。

通过消息机制异步更新缓存

业务允许异步更新缓存,只要最终一致即可,传统的同步更新缓存机制,在多线程下可能会导致缓存中存储的是旧的数据的问题:

T1: value1 = readFromDB()
    T2: writeToDB(value2)
        T3: value2 = readFromDB()
        T3: writeToCache(value2)
T1: writeToCache(value1)

故引入消息机制使同一个 key 的并行操作变为串行操作

消息投递失败/事务问题

  • 如果投递消息失败了怎么办
  • 业务希望数据更新成功后缓存务必更新成功,也就是说希望 DB 数据更新和缓存更新近乎在一个事务里面,这该怎么办?

在这个组件中,我们通过引入一张存放于业务的 DB 的消息记录表来解决上述两个问题。它相当于是一个容灾方案,只要消息进入这张表,缓存更新平台就保证这条消息必然会被消费。

识别/存储/更新热点 key

  • 动态识别:主要针对部分 key 的访问流量增长相对平稳没那么陡的场景,使应用有能力应对线上一些无法预知的突发情况。
  • 预设:业务识别预热的商品的 key

热点 key 存放于应用服务器的内存中,进行一个很短时间(热点 key 的访问流量通常是增长快下降也快)的缓存,原因:

  • 应用服务器一般可以弹性扩缩容。
  • 应用服务器可用内存都在 50% 以上。

缓存更新平台,可以将发生变化的缓存 key 通过消息队列广播给所有缓存访问组件,组件消费到这条消息后,若 key 是热点 key,则进行本地缓存的更新。

缓存更新平台

  • 消息中携带缓存的 key,计算 key 的哈希值,然后取模,将相同的 key 分配给相同的线程处理,保证多个消息被同一个单线程消费。
  • 判断缓存内容新旧:引入了缓存版本的概念来解决这个问题,我们认为每条缓存的数据都应该有一个版本号(业务提供,例如可以是修改数据的时间戳,只要满足单调递增即可)。删除流程中关心的是消息中的版本是否大于等于缓存中的版本,而新增&修改缓存流程只关心消息中的版本是否大于缓存中的版本。
  • 保障消息一定被处理:通过业务提供的接口增量轮询该消息记录 DB 表,确保所有消息都被及时消费掉。通过这样的容错措施,确保不会因为单点故障导致缓存来不及更新。

携程强/最终一致性缓存

参考自携程技术公众号 《携程最终一致和强一致性缓存实践》

最终一致性

最终一致性场景的基本思路是:读缓存优先,数据可以容忍暂时不一致,因此重点在及时补偿。

数据准确性设计,其中红色虚线框内的步骤需要通过分布式锁加锁控制:

我们系统从建立之初就有自己的 MySQL 规范,每张表都必须有 update_time 字段,且设置为 ON UPDATE CURRENT_TIMESTAMP,但是并没有约束时间字段的精度,大部分都是秒级别的,因此在同一秒内的多次更新操作就无法识别出数据的新老。针对同一秒数据的更新策略我们采用的方案是:先进行数据对比,若当前数据与缓存数据不相等,则直接更新,并且发送一条延迟消息,延迟 1 秒后再次触发更新流程。

上述设计方式不支持 DB 的删除操作,因为删除操作和 update 操作无法进行数据对比,无法确定操作的先后顺序,进而可能导致更新错乱。而在数据异常宝贵的时代,一般的业务系统中也没有物理删除的逻辑

强一致性

强一致性场景的基本思路是:读 DB 优先,缓存仅作为“DB 降压”的辅助手段,在不确定缓存数据是否最新的情况下,宁可多查询几次 DB,也不要查询到缓存中的不一致数据。

缓存的处理我们采用了较为常见的处理思路:在更新操作中,先更新数据库,再删除缓存,查询操作中,触发缓存更新。

在此过程中,若不加控制,则会存在数据不一致性问题:

(1)缓存读取和 DB 更新并发

(2)缓存更新与 DB 更新并发

基于以上分析,为了避免并发带来的缓存不一致问题,需要将"更新 DB"+“删除缓存”、“查询DB”+“更新缓存"两个流程都进行加锁。加锁后的读写整体流程如下:

更新 DB 成功但删除缓存失败

本节接上一节强一致性内容。

此种情况往往因应用服务器故障、网络故障、redis 故障等原因导致。若应用服务器突然故障,则服务整体不可用,跟缓存就没多大关系了。若是由于网络、redis故障等原因导致的删除缓存失败,此时查询缓存也不可用,查询走 DB,但需要可靠地记录下哪些数据做了变更,待 redis 可用后需要进行恢复,需要将中间变更的记录对应的缓存全部删除。

我们的方案是构建一张简易的记录表(代表发生变更的 DB 数据),每次 DB 变更后,将该变更记录表的插入和业务 DB 操作放在一个事务中处理。事务提交后,对应的变更记录持久化,之后进行删除缓存,若缓存删除成功,则将对应的记录表数据也删除掉。若缓存删除失败,则可根据记录表的数据进行补偿删除,而在 redis 的恢复流程中,需要校验记录表中是否存在数据,若存在则表示有变更后的数据对应的缓存未清除,不可进行缓存读取的恢复。此外删除操作还要进行异步重试,来避免偶尔超时引起的缓存删除失败。

基于 Spring 的事务同步机制 TransactionSynchronization,可以很容易实现该方案。简单来说,该机制提供了 Spring 环境中事务执行前后的 AOP 功能,可以在 spring 事务的执行前后添加自己的操作。

美团 Kafka 缓存问题

参考美团技术公众号的《基于 SSD 的 Kafka 应用层缓存架构设计与实现》

当前 Kafka 支撑的实时作业数量众多,单机承载的 Topic 和 Partition 数量很大。这种场景下很容易出现的问题是:同一台机器上不同 Partition 间竞争 PageCache 资源,相互影响,导致整个 Broker 的处理延迟上升、吞吐下降

Kafka 读写流程

对于 Produce 请求:Server 端的 I/O 线程统一将请求中的数据写入到操作系统的 PageCache 后立即返回,当消息条数到达一定阈值后,Kafka 应用本身或操作系统内核会触发强制刷盘操作。

对于 Consume 请求:主要利用了操作系统的 ZeroCopy 机制,当 Kafka Broker 接收到读数据请求时,会向操作系统发送 sendfile 系统调用,操作系统接收后,首先试图从 PageCache 中获取数据;如果数据不存在,会触发缺页异常中断将数据从磁盘读入到临时缓冲区中,随后通过 DMA 操作直接将数据拷贝到网卡缓冲区中等待后续的 TCP 传输。

综上所述,Kafka 对于单一读写请求均拥有很好的吞吐和延迟。处理写请求时,数据写入 PageCache 后立即返回,数据通过异步方式批量刷入磁盘,既保证了多数写请求都能有较低的延迟,同时批量顺序刷盘对磁盘更加友好。处理读请求时,实时消费的作业可以直接从 PageCache 读取到数据,请求延迟较小,同时 ZeroCopy 机制能够减少数据传输过程中用户态与内核态的切换,大幅提升了数据传输的效率。

但当同一个 Broker 上同时存在多个 Consumer 时,就可能会由于多个 Consumer 竞争 PageCache 资源导致它们同时产生延迟。下面我们以两个 Consumer 为例详细说明:

如上图所示,Producer 将数据发送到 Broker,PageCache 会缓存这部分数据。当所有 Consumer 的消费能力充足时,所有的数据都会从 PageCache 读取,全部 Consumer 实例的延迟都较低。此时如果其中一个 Consumer 出现消费延迟,根据读请求处理流程可知,此时会触发磁盘读取,在从磁盘读取数据的同时会预读部分数据到 PageCache 中。当 PageCache 空间不足时,会按照 LRU 策略开始淘汰数据,此时延迟消费的 Consumer 读取到的数据会替换 PageCache 中实时的缓存数据。后续当实时消费请求到达时,由于 PageCache 中的数据已被替换掉,会产生预期外的磁盘读取。这样会导致两个后果:

  • 消费能力充足的 Consumer 消费时会失去 PageCache 的性能红利。
  • 多个 Consumer 相互影响,预期外的磁盘读增多,HDD 负载升高。

不同类型的缓存

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 放入了缓存)
// 先从缓存中获取数据
value = myCache.getIfPresent(key);
if (value == null) {
    // 从 DB 获取数据
    value = loadFromDB(key);
    // 数据放入缓存
    myCache.put(key, value);
}

从那时起,客户端将继续从缓存中获取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 值到缓存失败,因为租约不匹配)

事情维持了一致:)

Read/Write Through 读穿/写穿

需要缓存中间件的支持。它的核心原则是只与缓存打交道

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

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

// Read-through 模式
LoadingCache<Integer, Result<Category>> getCache = 
    CacheBuilder.newBuilder()
        .softValues()
        .maximumSize(5000)
        .expireAfterWrite(2, TimeUnit.MINUTES)
        .build(new CacheLoader<Integer, Result<Category>>() {
            @Override
            public Result<Category> load(final Integer softId) throws Exception {
                return categoryService.get(softId);
            }
        })

现在你得到一张表格 (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 就属于这一类。

Eureka 多级缓存

本节参考自 详解Eureka 缓存机制Eureka微服务注册中心如何承载千万级别的访问

Eureka 其优先保证可用性(A)和分区容错性(P)、不保证强一致性(C)的特点。如果需要优先保证强一致性(C),则应该考虑使用ZooKeeper等CP系统作为服务注册中心。

Eureka Server 端的多级缓存

各个服务拉取、注册、下线、故障等都是直接处理的这个位于内存中的 registry 缓存:

public abstract class AbstractInstanceRegistry implements InstanceRegistry {

    // key: 服务名称
    // value: 一个服务的多个实例
    //     value 的 key: 一个服务实例的 ID
    //     value 的 value: InstanceInfo 服务实例的具体信息
    private final ConcurrentHashMap<String, Map<String, Lease<InstanceInfo>>> registry
            = new ConcurrentHashMap<String, Map<String, Lease<InstanceInfo>>>();

}

然后默认每隔 60s 剔除超过 90s 未续约的节点:

// evictionIntervalTimerInMs 默认值 60 秒
evictionTimer.schedule(evictionTaskRef.get(),
    serverConfig.getEvictionIntervalTimerInMs(),
    serverConfig.getEvictionIntervalTimerInMs());

默认 expired 时间:

public class Lease<T> {

    public static final int DEFAULT_DURATION_IN_SECS = 90;

}

为了避免同时读写内存数据结构造成的并发冲突 (尽可能避免出现频繁的并发读写问题,nacos 使用的是 CopyOnWrite 思想防止并发冲突)问题,还采用了多级缓存机制来进一步提升服务请求的响应速度:

public class ResponseCacheImpl implements ResponseCache {

    private final ConcurrentMap<Key, Value> readOnlyCacheMap = new ConcurrentHashMap<Key, Value>();

    // 默认 180 秒
    private final LoadingCache<Key, Value> readWriteCacheMap;

}

其中 readWriteCacheMap 写入之后默认过期时间是 180 秒:

this.readWriteCacheMap =
    CacheBuilder.newBuilder().initialCapacity(serverConfig.getInitialCapacityOfResponseCache())
            // 180 秒
            .expireAfterWrite(serverConfig.getResponseCacheAutoExpirationInSeconds(), TimeUnit.SECONDS);

readOnlyCacheMap 默认每隔 responseCacheUpdateIntervalMs = 30sreadWriteCacheMap 同步一次数据:

if (shouldUseReadOnlyResponseCache) {
    timer.schedule(getCacheUpdateTask(),
            new Date(((System.currentTimeMillis() / responseCacheUpdateIntervalMs) * responseCacheUpdateIntervalMs)
                    + responseCacheUpdateIntervalMs),
            responseCacheUpdateIntervalMs);
}

Client 拉取注册表

  • 首先从 readOnlyCacheMap 里查缓存的注册表
  • 再从 readWriteCacheMap 里查找缓存的注册表
  • 再从内存的 registry 中查找实际的注册表数据

参考

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