Redis的缓存问题——缓存穿透、缓存击穿、缓存雪崩以及多级缓存架构设计

20 阅读30分钟

概述

本篇文章要点:

  • 缓存穿透
  • 缓存击穿
  • 缓存雪崩
  • 多级缓存架构设计

一、数据一致性问题

只要使用到缓存,无论是本地内存做缓存还是使用 Redis 做缓存,那么就会存在数据同步的问题。以 Web Server 向 MySQL 中写入和删改数据为例,来给你解释一下数据的增删改操作具体是如何进行的。

image.png

image.png

我们分析一下几种解决方案:

  1. 先更新缓存,再更新数据库。
  2. 先更新数据库,再更新缓存。
  3. 先删除缓存,后更新数据库。
  4. 先更新数据库,后删除缓存。

如果是新增数据,数据会直接写到数据库中,不用对缓存做任何操作,此时缓存中本身就没有新增数据,而数据库中是最新值,缓存和数据库的数据是一致的。

1.1 更新缓存

1.1.1 先更新缓存,再更新 DB

这个方案我们一般不考虑。原因是更新缓存成功,更新数据库出现异常了,导致缓存数据与数据库数据完全不一致,而且很难察觉,因为缓存中的数据一直都存在。

image.png

1.1.2 先更新 DB,再更新缓存

这个方案也我们一般不考虑,原因跟第一个一样,数据库更新成功了,缓存更新失败,同样会出现数据不一致问题。同时还有以下问题。

并发问题

同时有请求 A 和请求 B 进行更新操作,那么会出现:

  1. 线程 A 更新了数据库
  2. 线程 B 更新了数据库
  3. 线程 B 更新了缓存
  4. 线程 A 更新了缓存

这本应该是请求 A 更新缓存应该比请求 B 更新缓存早才对,但是因为网络等原因,B 却比 A 更早更新了缓存。这就导致了脏数据,因此不考虑。

业务场景问题

如果你是一个写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能。

1.2 删除缓存

除了更新缓存之外,我们还有一种就是删除缓存。

到底是选择更新缓存还是淘汰缓存呢?

主要取决于“更新缓存的复杂度”,更新缓存的代价很小,此时我们应该更倾向于更新缓存,以保证更高的缓存命中率,更新缓存的代价很大,此时我们应该更倾向于淘汰缓存。

1.2.1 先删除缓存,后更新DB

该方案也会出问题,具体出现的原因如下:

  1. 此时来了两个请求,请求 A(更新操作) 和请求 B(查询操作)。
  2. 请求 A 会先删除 Redis 中的数据,然后去数据库进行更新操作。
  3. 此时请求 B 看到 Redis 中的数据是空的,会去数据库中查询该值,然后填充缓存到 Redis 中。
  4. 但是此时请求 A 并没有更新成功,或者事务还未提交,请求 B 去数据库查询得到旧值。
  5. 那么这时候就会产生数据库和 Redis 数据不一致的问题。

如何解决呢?其实最简单的解决办法就是延时双删的策略。就是:

  1. 先淘汰缓存。
  2. 再写数据库。
  3. 休眠 1 秒,再次淘汰缓存。

伪代码如下:

redis.delKey(X)
db.update(X)
Thread.sleep(N)
redis.delKey(X)

这么做,可以将 1 秒内所造成的缓存脏数据,再次删除。那么,这个 1 秒怎么确定的,具体该休眠多久呢?

针对上面的情形,读该自行评估自己的项目的读数据业务逻辑的耗时。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上,加几百 ms 即可。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。

但是上述的保证事务提交完以后再进行删除缓存还有一个问题,就是如果你使用的是 Mysql 读写分离的架构的话,那么其实主从同步之间也会有时间差。

此时来了两个请求,请求 A(更新操作)和请求 B(查询操作)。请求 A 更新操作,删除了 Redis 缓存,请求主库进行更新操作,主库与从库进行同步数据的操作,请求 B 查询操作,发现 Redis 中没有数据,去从库中获取数据,此时同步数据还未完成,拿到的数据是旧数据。

此时的解决办法有两个:

1、还是使用延时双删策略。只是睡眠时间修改为在主从同步的延时时间基础上,加几百 ms。 2、就是如果是对 Redis 进行填充数据的查询数据库操作,那么就强制将其指向主库进行查询。

继续深入,采用这种同步淘汰策略,吞吐量降低怎么办?

那就将第二次删除作为异步的。自己起一个线程,异步删除。这样写的请求就不用沉睡一段时间后再返回。这么做可以加大吞吐量。

继续深入,第二次删除,如果删除失败怎么办?

所以,我们引出了下面的第四种策略,先更新数据库,再删缓存。

1.2.2 先更新DB,后删除缓存

这种方式,被称为 Cache Aside Pattern。读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。更新的时候,先更新数据库,然后再删除缓存。

一般在线上,更多的偏向于使用删除缓存类操作,因为这种方式会更容易避免一些问题。

因为删除缓存更新缓存的速度比在 DB 中要快一些,所以一般情况下我们可能会先更新 DB,后删除缓存的操作。因为这种情况下缓存不一致性的情况只有可能是查询比删除慢的情况,而这种情况相对来说会少很多。同时结合延时双删的处理,可以有效的避免缓存不一致的情况。

image.png

二、缓存系统性问题

2.1 缓存穿透

是指查询一个根本不存在的数据,缓存层不会命中,于是这个请求就可以随意访问数据库,这个就是缓存穿透,缓存穿透将导致不存在的数据每次请求都要到存储层去查询,失去了缓存保护后端存储的意义。

缓存穿透问题可能会使后端存储负载加大,由于很多后端存储不具备高并发性,甚至可能造成后端存储宕掉。通常可以在程序中分别统计总调用数、缓存层命中数、存储层命中数,如果发现大量存储层空命中,可能就是出现了缓存穿透问题。

造成缓存穿透的基本原因有两个:

  1. 自身业务代码或者数据出现问题,比如我们数据库的 id 都是 1 开始自增上去的,如发起为 id 值为 -1 的数据或 id 为特别大不存在的数据。如果不对参数做校验,数据库 id 都是大于 0 的,我一直用小于 0 的参数去请求你,每次都能绕开 Redis 直接打到数据库,数据库也查不到,每次都这样并发高点就容易崩掉了。

  2. 一些恶意攻击、爬虫等造成大量空命中。

下面我们来看一下如何解决缓存穿透问题。

2.1.1 缓存空对象

当缓存不命中,到数据库查发现也没有命中,那么可以将空对象保留到缓存层中,之后再访问这个数据将会从缓存中获取,这样就保护了后端数据源。

缓存空对象会有两个问题:

  1. 空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存空间(如果是攻击,问题更严重),比较有效的方法是针对这类数据设置一个较短的过期时间,让其自动剔除。

  2. 缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。例如过期时间设置为 5 分钟,如果此时存储层添加了这个数据,那此段时间就会出现缓存层和存储层数据的不一致,此时可以利用消前面所说的数据一致性方案处理。

2.1.2 布隆过滤器拦截

在访问缓存层和存储层之前,将存在的 key 用布隆过滤器提前保存起来,做第一层拦截。例如:一个推荐系统有 4 亿个用户 id,每个小时算法工程师会根据每个用户之前历史行为计算出推荐数据放到存储层中,但是最新的用户由于没有历史行为就会发生缓存穿透的行为,为此可以将所有推荐数据的用户做成布隆过滤器。如果布隆过滤器认为该用户 id 不存在,那么就不会访问存储层,在一定程度保护了存储层。

91ac8664908a42a1bf9a1ce23aeec6c8.png

这种方法适用于数据命中不高、数据相对固定、实时性低(通常是数据集较大)的应用场景,代码维护较为复杂,但是缓存空间占用少。

2.2 缓存击穿

缓存击穿是指一个 key 非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个 key 在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个完好无损的桶上凿开了一个洞。

缓存击穿的话,设置热点数据永远不过期,或者加上互斥锁就能搞定了。

使用互斥锁(mutex key):

业界比较常用的做法,是使用 mutex。简单地来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去 load db,而是先使用缓存工具的某些带成功操作返回值的操作(比如 Redis 的 SETNX 或者 Memcache 的 ADD)去 set 一个 mutex key,当操作返回成功时,再进行 load db 的操作并回设缓存。否则就重试整个 get 缓存的方法。

伪代码如下:

public String get(key) {
    String value = redis.get(key);
    // 缓存过期
    if (value == null) {
        // 设置 3min 超时
        if (redis.setnx(key_mutex, 1, 3*60) == 1) {
            // 成功加锁
            value = db.get(key);
            redis.set(key, value, expire_time);
            redis.del(key_mutex);
        } else {
            // 表示可能被别的线程成功加锁了,当前这个 key 又有数据了。
            sleep(50);
            get(key);
        }
    } else {
        // 缓存没过期就返回
        return value;
    }
}

永远不过期:

这里的“永远不过期”包含两层意思:

  1. 从 Redis 上看,确实没有设置过期时间,这就保证了不会出现热点 key 过期问题,也就是“物理”不过期。
  2. 从功能上看,如果不过期那不就成静态的了吗?所以我们把过期时间存在 key 对应的 value 里 ,如果发现要过期了,通过一个后台的异步线程进行缓存的构建,也就是“逻辑”过期。

从实战看,这种方法对于性能非常友好,唯一不足的就是构建缓存时候,其余线程(非构建缓存的线程)可能访问的是老数据,但是对于一般的互联网功能来说这个还是可以忍受。

2.3 缓存雪崩

由于缓存层承载着大量请求,有效地保护了存储层,但是如果缓存层由于某些原因不能提供服务,比如同一时间缓存数据大面积失效,那一瞬间 Redis 跟没有一样,于是所有的请求都会达到存储层,存储层的调用量会暴增,造成存储层也会级联宕机的情况。

缓存雪崩的英文原意是 stampeding herd(奔逃的野牛),指的是缓存层宕掉后,流量会像奔逃的野牛一样,打向后端存储。

预防和解决缓存雪崩问题,可以从以下几个方面进行着手:

  1. 保证缓存层服务高可用性。和飞机都有多个引擎一样,如果缓存层设计成高可用的,即使个别节点、个别机器、甚至是机房宕掉,依然可以提供服务,例如前面介绍过的 Redis。Sentinel 和 Redis Cluster 都实现了高可用。
  2. 依赖隔离组件为后端限流并降级。无论是缓存层还是存储层都会有出错的概率,可以将它们视同为资源。作为并发量较大的系统,假如有一个资源不可用,可能会造成线程全部阻塞在这个资源上,造成整个系统不可用。
  3. 提前演练。在项目上线前,演练缓存层宕掉后,应用以及后端的负载情况以及可能出现的问题,在此基础上做一些预案设定。
  4. 将缓存失效时间分散开,比如我们可以在原有的失效时间基础上增加一个随机值,比如 1~5 分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。

三、热点Key

在 Redis 中,访问频率高的 key 被称为热点 key。

3.1 产生原因和危害

热点问题产生的原因大致有以下两种:

用户消费的数据远大于生产的数据(热卖商品、热点新闻、热点评论、明星直播)。

在日常工作生活中一些突发的事件,例如:双十一期间某些热门商品的降价促销,当这其中的某一件商品被数万次点击浏览或者购买时,会形成一个较大的需求量,这种情况下就会造成热点问题。同理被大量刊发、浏览的热点新闻、热点评论、明星直播等,这些典型的读多写少的场景也会产生热点问题。

请求分片集中,超过单 Server 的性能极限。在服务端读数据进行访问时,往往会对数据进行分片切分,此过程中会在某一主机 Server 上对相应的 key 进行访问,当访问超过 Server 极限时,就会导致热点 key 问题的产生。

缓存雪崩会产生以下危害:

  1. 流量集中,达到物理网卡上限。
  2. 请求过多,缓存分片服务被打垮。
  3. DB 击穿,引起业务雪崩。

3.2 发现热点Key

3.2.1 预估发现

针对业务提前预估出访问频繁的热点 key,例如秒杀商品业务中,秒杀的商品都是热点key。当然并非所有的业务都容易预估出热点 key,可能出现漏掉或者预估错误的情况。

3.2.2 客户端发现

客户端其实是距离 key 最近的地方,因为 Redis 命令就是从客户端发出的,以 Jedis 为例,可以在核心命令入口,使用这个 Google Guava 中的 AtomicLongMap 进行记录。

使用客户端进行热点 key 的统计非常容易实现,但是同时问题也非常多:

  1. 无法预知 key 的个数,存在内存泄露的危险。
  2. 对于客户端代码有侵入,各个语言的客户端都需要维护此逻辑,维护成本较高。
  3. 规模化汇总实现比较复杂。

3.2.3 服务端发现

monitor 命令可以监控到 Redis 执行的所有命令,利用 monitor 的结果就可以统计出一段时间内的热点 key 排行榜、命令排行榜、客户端分布等数据。

image.png

Facebook 开源的 redis-faina 正是利用上述原理使用 Python 语言实现的,为了减少网络开销以及加快输出缓冲区的消费速度,monitor 尽可能在本机执行。

此种方法会有两个问题:

  1. monitor 命令在高并发条件下,内存暴增同时会影响 Redis 的性能,所以此种方法适合在短时间内使用。
  2. 只能统计一个 Redis 节点的热点 key,对于 Redis 集群需要进行汇总统计。

Redis 在 4.0.3 中为 redis-cli 提供了 --hotkeys,用于找到热点 key。如果有错误,需要先把内存逐出策略设置为 allkeys-lfu 或者 volatile-lfu,否则会返回错误。

3.2.4 抓取TCP包发现

Redis 客户端使用 TCP 协议与服务端进行交互,通信协议采用的是 RESP。如果站在机器的角度,可以通过对机器上所有 Redis 端口的 TCP 数据包进行抓取完成热点 key 的统计。

此种方法对于 Redis 客户端和服务端来说毫无侵入,是比较完美的方案,但是依然存在 3 个问题:

  1. 需要一定的开发成本。
  2. 对于高流量的机器抓包,对机器网络可能会有干扰,同时抓包时候会有丢包的可能性。
  3. 维护成本过高。

对于成本问题,有一些开源方案实现了该功能,例如 ELK(ElasticSearch Logstash Kibana)体系下的 packetbeat 插件,可以实现对 Redis、MySQL 等众多主流服务的数据包抓取、分析、报表展示。

3.3 解决热点Key

3.3.1 使用二级缓存

可以使用 guava-cache 或 ehcache,发现热点 key 之后,将这些热点 key 加载到 JVM 中作为本地缓存。访问这些 key 时直接从本地缓存获取即可,不会直接访问到 Redis 层了,有效的保护了缓存服务器。

3.3.2 key分散

将热点 key 分散为多个子 key,然后存储到缓存集群的不同机器上,这些子 key 对应的 value 都和热点 key 是一样的。当通过热点 key 去查询数据时,通过某种 hash 算法随机选择一个子 key,然后再去访问缓存机器,将热点分散到了多个子 key 上。

四、Big Key

4.1 什么是Big key?

Big Key 是指 key 对应的 value 所占的内存空间比较大,例如一个字符串类型的 value 可以最大存到 512MB,一个列表类型的 value 最多可以存储 23212^{32}-1 个元素。

如果按照数据结构来细分的话,一般分为字符串类型 Big key 和非字符串类型 Big Key。字符串类型:体现在单个 value 值很大,一般认为超过 10KB 就是 Big Key,但这个值和具体的 OPS 相关。

非字符串类型:哈希、列表、集合、有序集合,体现在元素个数过多。

Big Key 无论是空间复杂度和时间复杂度都不太友好,下面我们将介绍它的危害。

4.2 Big key的危害

Big key 的危害体现在三个方面:

  1. 内存空间不均匀:例如在 Redis Cluster 中,Big Key 会造成节点的内存空间使用不均匀。
  2. 超时阻塞:由于 Redis 单线程的特性,操作 Big Key 比较耗时,也就意味着阻塞 Redis 可能性增大。
  3. 网络拥塞:每次获取 Big Key 产生的网络流量较大。

假设一个 Big Key 为 1MB,每秒访问量为 1000,那么每秒产生 1000MB 的流量,对于普通的千兆网卡(按照字节算是 128MB/s)的服务器来说简直是灭顶之灾,而且一般服务器会采用单机多实例的方式来部署,也就是说一个 Big Key 可能会对其他实例造成影响,其后果不堪设想。

Big Key 的存在并不是完全致命的:

如果这个 Big Key 存在但是几乎不被访问,那么只有内存空间不均匀的问题存在,相对于另外两个问题没有那么重要紧急,但是如果 Big Key 是一个热点 key(频繁访问),那么其带来的危害不可想象,所以在实际开发和运维时一定要密切关注 Big Key 的存在。

4.2.1 发现Big key

redis-cli --bigkeys 可以命令统计 Big Key 的分布。

但是在生产环境中,开发和运维人员更希望自己可以定义 Big Key 的大小,而且更希望找到真正的 Big Key 都有哪些,这样才可以去定位、解决、优化问题。

判断一个 key 是否为 Big Key,只需要执行 debug object key 查看 serializedlength 属性即可,它表示 key 对应的 value 序列化之后的字节数。

image.png

如果是要遍历多个,则尽量不要使用 keys 的命令,可以使用 scan 的命令来减少压力。

Redis 从 2.8 版本后,提供了一个新的命令 scan,它能有效的解决 keys 命令存在的问题。和 keys 命令执行时会遍历所有键不同,scan 采用渐进式遍历的方式来解决 keys 命令可能带来的阻塞问题,但是要真正实现 keys 的功能,需要执行多次 scan。可以想象成只扫描一个字典中的一部分键,直到将字典中的所有键遍历完毕。scan 的使用方法如下:

scan cursor [match pattern] [count number]
  • cursor:是必需参数,实际上 cursor 是一个游标,第一次遍历从 0 开始,每次 scan 遍历完都会返回当前游标的值,直到游标值为 0,表示遍历结束。
  • match pattern:是可选参数,它的作用的是做模式的匹配,这点和 keys 的模式匹配很像。
  • count number:是可选参数,它的作用是表明每次要遍历的键个数,默认值是 10,此参数可以适当增大。

除了 scan 以外,Redis 提供了面向哈希类型、集合类型、有序集合的扫描遍历命令,解决诸如 hgetallsmemberszrange 可能产生的阻塞问题,对应的命令分别是 hscansscanzscan,它们的用法和 scan 基本类似,请自行参考 Redis 官网。

渐进式遍历可以有效的解决 keys 命令可能产生的阻塞问题,但是 scan 并非完美无瑕,如果在 scan 的过程中如果有键的变化(增加、删除、修改),那么遍历效果可能会碰到如下问题:新增的键可能没有遍历到,遍历出了重复的键等情况,也就是说 scan 并不能保证完整的遍历出来所有的键,这些是我们在开发时需要考虑的。

如果键值个数比较多,scan + debug object 会比较慢,可以利用 Pipeline 机制完成。对于元素个数较多的数据结构,debug object 执行速度比较慢,存在阻塞 Redis 的可能,所以如果有从节点,可以考虑在从节点上执行。

4.2.2 解决Big Key

主要思路为拆分,对 Big Key 存储的数据(Big Value)进行拆分,变成 value1、value2、valueN 等等。

例如 Big Value 是个大 json 通过 mset 的方式,将这个 key 的内容打散到各个实例中,或者一个 hash,每个 field 代表一个具体属性,通过 hgethmget 获取部分 value,hsethmset 来更新部分属性。

例如 Big Value 是个大 List,可以拆成将 List 拆分成:list_1、list_2、list3、listN。

其他数据类型同理。

五、Redis脑裂

所谓的脑裂,就是指在有主从集群中,同时有两个主节点,它们都能接收写请求。而脑裂最直接的影响就是客户端不知道应该往哪个主节点写入数据,结果就是不同的客户端会往不同的主节点上写入数据。而且严重的话,脑裂会进一步导致数据丢失。

5.1 哨兵主从集群脑裂

现在假设:有 3 台服务器,1 台主服务器,2 台从服务器,还有 1 个哨兵。

基于上边的环境,这时候网络环境发生了波动导致了 sentinel 没有能够心跳感知到 master,但是哨兵与 slave 之间通讯正常。所以通过选举的方式提升了一个 salve 为新 master。如果恰好此时 server1 仍然连接的是旧的 master,而 server2 连接到了新的 master 上,数据就不一致了。哨兵恢复对老 master 节点的感知后,会将其降级为 slave 节点,然后从新 master 同步数据(full resynchronization),导致脑裂期间老 master 写入的数据丢失。

而且基于 setNX 指令的分布式锁,可能会拿到相同的锁。基于 incr 生成的全局唯一 id,也可能出现重复。通过配置参数实现:

min-replicas-to-write 2
min-replicas-max-lag 10

第 1 个参数表示最少的 salve 节点为 2 个。

第 2 个参数表示数据复制和同步的延迟不能超过 10 秒。

配置了这两个参数:如果发生脑裂,原 master 会在客户端写入操作的时候拒绝请求,这样可以避免大量数据丢失。

5.2 集群脑裂

Redis 集群的脑裂一般是不存在的,因为 Redis 集群中存在着过半选举机制,而且当集群 16384 个槽任何一个没有指派到节点时整个集群不可用。所以我们在构建 Redis 集群时,应该让集群 master 节点个数最少为 3 个,且集群可用节点个数为奇数。

不过脑裂问题不是是可以完全避免,只要是分布式系统,必然就会一定的几率出现这个问题,CAP 的理论就决定了。

六、多级缓存架构设计

一个使用了 Redis 集群和其他多种缓存技术的应用系统架构如图:

2067af27ef2a423e9034ca5130382166.png

首先,用户的请求被负载均衡服务分发到 Nginx 上,此处常用的负载均衡算法是轮询或者一致性哈希,轮询可以使服务器的请求更加均衡,而一致性哈希可以提升 Nginx 应用的缓存命中率。

接着,Nginx 应用服务器读取本地缓存,实现本地缓存的方式可以是 Lua Shared Dict,或者面向磁盘或内存的 Nginx Proxy Cache,以及本地的 Redis 实现等,如果本地缓存命中则直接返回。Nginx 应用服务器使用本地缓存可以提升整体的吞吐量,降低后端的压力,尤其应对热点数据的反复读取问题非常有效。

如果 Nginx 应用服务器的本地缓存没有命中,就会进一步读取相应的分布式缓存——Redis 分布式缓存的集群,可以考虑使用主从架构来提升性能和吞吐量,如果分布式缓存命中则直接返回相应数据,并回写到 Nginx 应用服务器的本地缓存中。

如果 Redis 分布式缓存也没有命中,则会回源到 Tomcat 集群,在回源到 Tomcat 集群时也可以使用轮询和一致性哈希作为负载均衡算法。当然,如果 Redis 分布式缓存没有命中的话,Nginx 应用服务器还可以再尝试一次读主 Redis 集群操作,目的是防止当从 Redis 集群有问题时可能发生的流量冲击。

在 Tomcat 集群应用中,首先读取本地平台级缓存,如果平台级缓存命中则直接返回数据,并会同步写到主 Redis 集群,然后再同步到从 Redis 集群。此处可能存在多个 Tomcat 实例同时写主 Redis 集群的情况,可能会造成数据错乱,需要注意缓存的更新机制和原子化操作。

如果所有缓存都没有命中,系统就只能查询数据库或其他相关服务获取相关数据并返回,当然我们已经知道数据库也是有缓存的。

整体来看,这是一个使用了多级缓存的系统。Nginx 应用服务器的本地缓存解决了热点数据的缓存问题,Redis 分布式缓存集群减少了访问回源率,Tomcat 应用集群使用的平台级缓存防止了相关缓存失效崩溃之后的冲击,数据库缓存提升数据库查询时的效率。正是多级缓存的使用,才能保障系统具备优良的性能。

6.1 整体方案

经过几年演进,携程金融形成了自顶向下的多层次系统架构,如业务层、平台层、基础服务层等,其中用户信息、产品信息、订单信息等基础数据由基础平台等底层系统产生,服务于所有的金融系统,对这部分基础数据我们引入了统一的缓存服务(系统名 utag)。

10fbd6aa05c54c078f10268e30f790e7.png

缓存数据有三大特点:全量、准实时、永久有效,在数据实时性要求不高的场景下,业务系统可直接调用统一的缓存查询接口。

在构建此统一缓存服务时候,有三个关键目标:

  1. 数据准确性:DB 中单条数据的更新一定要准确同步到缓存服务。
  2. 数据完整性:将对应 DB 表的全量数据进行缓存且永久有效,从而可以替代对应的 DB 查询。
  3. 系统可用性:我们多个产品线的多个核心服务都已经接入,utag 的高可用性显得尤为关键。

image.png

系统在多地都有部署,故缓存服务也做了相应的异地多机房部署,一来可以让不同地区的服务调用本地区服务,无需跨越网络专线,二来也可以作为一种灾备方案,增加可用性。

对于缓存的写入,由于缓存服务是独立部署的,因此需要感知业务 DB 数据变更然后触发缓存的更新,本着“可以多次更新,但不能漏更新”的原则,设计了多种数据更新触发源:定时任务扫描,业务系统 MQ、binlog 变更 MQ,相互之间作为互补来保证数据不会漏更新。

对于 MQ 使用携程开源消息中间件 QMQ 和 Kafka,在公司内部 QMQ 和 Kafka 也做了异地机房的互通。

使用 MQ 来驱动多地多机房的缓存更新,在不同的触发源触发后,会查询最新的 DB 数据,然后发出一个缓存更新的 MQ 消息,不同地区机房的缓存系统同时监听该主题并各自进行缓存的更新。

对于缓存的读取,utag 系统提供 dubbo 协议的缓存查询接口,业务系统可调用本地区的接口,省去了网络专线的耗时(50ms延迟)。在 utag 内部查询 redis 数据,并反序列化为对应的业务 model,再通过接口返回给业务方。

6.2 数据准确性

不同的触发源,对缓存更新过程是一样的,整个更新步骤可抽象为 4 步:

  1. 触发更新,查询 DB 中的新数据,并发送统一的 MQ。
  2. 接收 MQ,查询缓存中的老数据。
  3. 新老数据对比,判断是否需要更新。
  4. 若需要,则更新缓存。

6.2.1 并发控制

若一条 DB 数据出现了多次更新,且刚好被不同的触发源触发,更新缓存时候若未加控制,可能出现数据更新错乱,如下图所示:

image.png

故需要将第 2、3、4 步加锁,使得缓存刷新操作全部串行化。由于 utag 本身就依赖了 Redis,此处我们的分布式锁就基于 Redis 实现。

6.2.2 基于updateTime的更新顺序控制

即使加了锁,也需要进一步判断当前 DB 数据与缓存数据的新老,因为到达缓存更新流程的顺序并不代表数据的真正更新顺序。我们通过对比新老数据的更新时间来实现数据更新顺序的控制。若新数据的更新时间大于老数据的更新时间,则认为当前数据可以直接写入缓存。

我们系统从建立之初就有自己的 MySQL 规范,每张表都必须有 update_time 字段,且设置为 ON UPDATE CURRENT_TIMESTAMP,但是并没有约束时间字段的精度,大部分都是秒级别的,因此在同一秒内的多次更新操作就无法识别出数据的新老。

针对同一秒数据的更新策略我们采用的方案是:先进行数据对比,若当前数据与缓存数据不相等,则直接更新,并且发送一条延迟消息,延迟 1 秒后再次触发更新流程。

举个例子:假设同一秒内同一条数据出现了两次更新,value=1value=2,期望最终缓存中的数据是 value=2。若这两次更新后的数据被先后触发,分两种情况:

  1. case1:若 value=1 先更新,value=2 后更新,(两者都可更新到缓存中,因为虽然是同一秒,但是值不相等)则缓存中最终数据为 value=2
  2. case2:若 value=2 先更新,value=1 后更新,则第一轮更新后缓存数据为 value=1,不是期望数据,之后对比发现是同一秒数据后会通过消息触发二次更新,重新查询 DB 数据为 value=2,可以更新到缓存中。如下图所示:

b15ba38e63db48e2b18e721961404e18.png

6.3 数据完整性设计

上述数据准确性是从单条数据更新角度的设计,而我们构建缓存服务的目的是替代对应 DB 表的查询,因此需要缓存对应 DB 表的全量数据,而数据的完整性从以下三个方面得到保证:

  1. “把鸡蛋放到多个篮子里”,使用多种触发源(定时任务,业务 MQ,binglog MQ)来最大限度降低单条数据更新缺失的可能性。

单一触发源有可能出现问题,比如消息类的触发依赖业务系统、中间件 canal、中间件 QMQ 和 Kafka,扫表任务依赖分布式调度平台、MySQL 等。中间任何一环都可能出现问题,而这些中间服务同时出概率的可能相对来说就极小了,相互之间可以作为互补。

  1. 全量数据刷新任务:全表扫描定时任务,每周执行一次来进行兜底,确保缓存数据的全量准确同步。
  2. 数据校验任务:监控 Redis 和 DB 数据是否同步并进行补偿。