JAVA面试系列:你了解缓存一致性吗?

1、背景

面试官问这个,主要想考察 更新缓存还是删缓存? 进一步到底选择先更新数据库,再删除缓存,还是先删除缓存,再更新数据库? 消息队列保证一致性?等等。要想学问大,就要多读、多抄、多写。

2、解答

2.1、缓存的好处

如果你的业务处于初期阶段,流量很小,那无论是读请求还是写请求,直接操作数据库即可,系统的架构模型是这样的:
在这里插入图片描述

但随着业务量的增长,你的项目请求量有一定的量级,这时可能会演变成读请求操作从库,写请求操作主库,读写分离会有数据实时一致性等问题,系统架构是这样的:
在这里插入图片描述

但随着业务量的增长,你的项目请求量越来越大,这时如果每次都从数据库中读数据,那肯定会有性能问题。这个阶段通常的做法是,引入缓存来提高读性能,架构模型是这样的:

缓存中间件, 我们一般都是使用Redis ,它不仅性能非常高,还提供了很多友好的数据类型,可以很好地满足我们的业务需求。引入缓存中间件,系统也变复杂了,不得不面临的问题是:数据库中的数据,放到缓存中读取,具体怎么存储呢?

最简单直接粗暴的方案是【全量数据更新到缓存中,只从缓存读取】:

  • 数据库中的数据,全量刷入缓存,不设置缓存失效时间
  • 写请求只操作数据库,不操作缓存
  • 数据库的数据提前预热,更新到缓存中
  • 启动一个定时任务,定时把数据库的数据,更新到缓存中
    在这里插入图片描述
    这个方案的优点是,所有读请求都可以直接「命中」缓存,不需要再查数据库,性能非常高。

但缺点也很明显,主要有以下问题:

  • 缓存利用率低:不经常访问的数据,会一直留在缓存中
  • 数据不一致:因为是定时刷新缓存,缓存和数据库会存在不一致,取决于定时任务的执行频率

所以,这种简单直接粗暴的方案一般更适合业务体量小,且对数据一致性要求不高的业务场景。

那如果我们的业务体量很大,可以怎么去解决这些问题呢?

2.2、缓存利用率和一致性

2.2.1、提高缓存利用率

想要提高缓存利用率,我们很容易想到的方案是,缓存中只保留最近访问的热点数据。

我们可以这样优化:

  • 写请求依旧只写数据库
  • 读请求先读缓存,如果缓存不存在,则从数据库读取,并写入缓存
  • 写入缓存中的数据,都设置失效时间


这样一来,缓存中不经常访问的数据,随着时间的推移,都会逐渐过期淘汰掉,最终缓存中保留的,都是经常被访问的热点数据,缓存利用率得以最大化。

2.2.2、数据一致性

要想保证缓存和数据库实时一致,那就不能再用定时任务刷新缓存了。所以,当数据发生更新时,我们不仅要操作数据库,还要一并操作缓存。具体操作就是,修改一条数据时,不仅要更新数据库,也要连带缓存一起更新。

但数据库和缓存都更新,又存在先后问题:

  • 先更新缓存,后更新数据库
  • 先更新数据库,后更新缓存

哪个方案更好呢?

先不考虑并发问题,正常情况下,无论谁先谁后,都可以让两者保持一致,但现在我们需要重点考虑异常情况。因为操作分为两步,那么就很有可能存在【第一步成功、第二步失败】的异常情况发生。我们一个个来分析。

1)先更新缓存,后更新数据库

  • 如果缓存更新成功了,但数据库更新失败,那么此时缓存中是最新值,但数据库中是旧值
  • 虽然此时读请求可以命中缓存,拿到正确的值,但是,一旦缓存失效,就会从数据库中读取到旧值,重建缓存也是这个旧值。
  • 这时用户会发现自己之前修改的数据又变回去了,对业务造成影响。

2)先更新数据库,后更新缓存

  • 如果数据库更新成功了,但缓存更新失败,那么此时数据库中是最新值,缓存中是旧值
  • 之后的读请求读到的都是旧数据,只有当缓存失效后,才能从数据库中得到正确的值。
  • 这时用户会发现,自己刚刚修改了数据,但却看不到变更,一段时间过后,数据才变更过来,对业务也会有影响。

可见,无论谁先谁后,但凡后者发生异常,就会对业务造成影响。我们继续分析,除了操作失败问题,还有什么场景会影响数据一致性?这里我们还需要重点关注:并发问题

2.2.3、并发导致的一致性问题

假设我们采用【先更新数据库,再更新缓存】的方案,并且两步都可以成功执行的前提下,如果存在并发,情况会是怎样的呢?

假设线程 A 和线程 B 两个线程,需要更新同一条数据,会存在这样的场景:

A线程 B线程 缓存 数据库 更新数据库,a = 1 1 更新数据库成功 2 更新数据库,a = 2 3 更新数据库成功 4 更新缓存,a = 2 5 更新缓存成功 6 更新缓存,a = 1 7 更新缓存成功 8 缓存: a = 1,数据库:a = 2,数据不一致 A线程 B线程 缓存 数据库

也就是说,A 虽然先于 B 发生,但 B 操作数据库和缓存的时间,却要比 A 的时间短,执行时序发生错乱,最终缓存与数据库数据不一致。

同样地,采用【先更新缓存,再更新数据库】的方案,跟上面并发异常情况类似,可以自行体会

从缓存利用率的角度来看,这个方案也是不推荐的。

因为每次数据发生变更,都无脑更新缓存,但是缓存中的数据不一定会被马上读取,这就会导致缓存中可能存放了很多不常访问的数据,浪费缓存资源。

而且很多情况下,写到缓存中的值,并不是与数据库中的值一一对应的,很有可能是先查询数据库,再经过一系列计算得出一个值,才把这个值才写到缓存中。

由此可见,这种方案,不仅缓存利用率不高,还会造成机器性能的浪费。所以此时我们需要考虑另外一种方案:删除缓存

2.3、删除缓存

删除缓存对应的方案也有 2 种:

  • 先删除缓存,后更新数据库
  • 先更新数据库,后删除缓存

经过前面的分析我们已经得知,但凡第二步操作失败,都会导致数据不一致。我们重点来看并发问题。

1)先删除缓存,后更新数据库

如果有 A、B线程要并发读写同一个数据,可能会发生以下场景:

A线程 B线程 缓存 数据库 初始化数据库和缓存一致,a = 1 更新 a = 2,删除缓存 1 删除缓存成功 2 读取缓存 3 缓存不存在 4 从数据库读取 5 返回旧值,a = 1 6 写入数据库,a = 2 7 写入数据库成功 8 写入缓存,a = 1 9 写入缓存成功 10 缓存: a = 1,数据库:a = 2,数据不一致 A线程 B线程 缓存 数据库

可见,先删除缓存,后更新数据库,当发生【读+写】并发时,还是存在数据不一致的情况。

2)先更新数据库,后删除缓存

还是假设A、B线程要并发读写同一个数据,可能会发生以下场景:

A线程 B线程 缓存 数据库 缓存不存在,数据库:a = 1 查询缓存 1 返回缓存不存在 2 查询数据库 3 返回数据库数据,a = 1 4 更新数据库,a = 2 5 更新数据库成功 6 删除缓存 7 返回删除缓存成功 8 更新缓存,a = 1 9 更新缓存成功 10 缓存: a = 1,数据库:a = 2,数据不一致 A线程 B线程 缓存 数据库

这种情况理论来说是可能发生的,但实际真的有可能发生吗?

其实概率很低,这是因为它必须满足 3 个条件:

  1. 缓存刚好已失效
  2. 读请求 + 写请求并发
  3. 更新数据库 + 删除缓存的时间(步骤 5-8),要比读数据库 + 写缓存时间短(步骤 3 和 9)

仔细想一下,条件 3 发生的概率其实是非常低的。因为写数据库一般会先加锁,所以写数据库,通常是要比读数据库的时间更长的。以上其实就是很典型的Cache Aside Pattern(旁路缓存模式)的写操作这么来看,【先更新数据库 + 再删除缓存】的方案,是可以保证数据一致性的。所以,我们应该采用这种方案,来操作数据库和缓存。

解决了并发问题,我们继续来看前面遗留的:第二步执行失败,导致数据不一致的问题

2.4、第二步执行成功是关键

前面我们分析到,无论是更新缓存还是删除缓存,只要第二步发生失败,那么就会导致数据库和缓存不一致。

**第二步执行成功,就是解决问题的关键。**由此,我们能很简单想到就是失败后进行重试,关键在于重试,尽可能地去做补偿。现实情况往往没有想的这么简单,失败后无脑重试的问题在于:

  • 立即重试很大概率还会失败
  • 重试次数设置多少才合理
  • 重试会一直占用这个线程资源,无法服务其它客户端请求

虽然我们想通过重试的方式解决问题,但这种同步重试的方案依旧不严谨。那更好的方案,我们也不难想到异步重试

异步重试,我们不难想到,就是引入消息队列,进行异步消费执行第二步。不难想到其实会有以下问题:

  • 写消息队列也有可能会失败啊?
  • 引入消息队列,这又增加了更多的维护成本,这样做值得吗?

我们权衡一下。引入消息队列,正好符合我们的需求:

  • 消息队列保证可靠性:写到队列中的消息,成功消费之前不会丢失,重启项目也不担心,
  • 消息队列保证消息成功投递:下游从队列拉取消息,成功消费后才会删除消息,否则还会继续投递消息给消费者,符合我们重试的场景

至于写队列失败和消息队列的维护成本问题:

  • 写队列失败:操作缓存和写消息队列,同时失败的概率其实是很小的
  • 维护成本:我们项目中一般都会用到消息队列,维护成本并没有新增很多

所以,引入消息队列来解决这个问题,是比较合适的。这时系统架构模型是这样的:

那如果你确实不想在应用中去写消息队列,是否有更简单的方案,同时又可以保证一致性呢?方案还是有的,这就是近几年比较流行的解决方案:订阅数据库变更日志,再操作缓存

比如MySQL ,当一条数据发生修改时,MySQL 就会产生一条变更日志存储在Binlog,我们可以通过程序订阅这个日志,拿到具体操作的数据,然后推送到消息队列,消费者再根据这条数据,去删除对应的缓存。

订阅变更日志,目前也有了比较成熟的开源中间件,例如阿里的 canal。

至此,我们可以得出结论,想要保证数据库和缓存一致性,推荐采用【先更新数据库,再删除缓存】方案,并配合【消息队列】或【订阅变更日志】的方式来做

2.5、强一致思考

基于此,以上的讨论,都是怎么保证数据库和缓存最终一致性。要想做到强一致,最常见的方案是 2PC、3PC、Paxos、Raft 这类一致性协议,但它们的性能往往比较差,而且这些方案也比较复杂,还要考虑各种容错问题。相反,这时我们换个角度思考一下,我们引入缓存的目的是什么?没错,性能。所以,其实就是一种权衡的过程。而且我们最终采用的方案,缓存都是有失效时间的,就算在这期间存在短期不一致,我们依旧有失效时间来兜底,这样也能达到最终一致。

3、总结

通过以上的分析,解决方案的核心其实都是Cache Aside Pattern(旁路缓存模式)读操作写操作

读操作:

  1. 首先从缓存中查询数据,如果缓存命中则直接返回
  2. 缓存未命中,则去数据库中读取
  3. 将从数据库中读取的结果的副本放入到缓存中,并返回

写操作:

  1. 首先更新数据库
  2. 然后删除缓存中的数据

再根据以上思路,确保写操作的第二步执行成功,通过重试策略的异步重试思路,引入消息队列或者订阅数据库变更日志等,确保最终一致性。性能和一致性不能同时满足,为了性能考虑,通常会采用最终一致性的方案。很多方案,其实都是一个权衡的过程,我们要学会从中思考。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值