栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 软件开发 > 后端开发 > Java

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

Java 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

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

1、背景

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

2、解答 2.1、缓存的好处

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

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

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

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

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

  • 数据库中的数据,全量刷入缓存,不设置缓存失效时间
  • 写请求只操作数据库,不操作缓存
  • 数据库的数据提前预热,更新到缓存中
  • 启动一个定时任务,定时把数据库的数据,更新到缓存中

    这个方案的优点是,所有读请求都可以直接「命中」缓存,不需要再查数据库,性能非常高。

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

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

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

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

2.2、缓存利用率和一致性 2.2.1、提高缓存利用率

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

我们可以这样优化:

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


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

2.2.2、数据一致性

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

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

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

哪个方案更好呢?

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2.3、删除缓存

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

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

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

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

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

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

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. 然后删除缓存中的数据

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

转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/871827.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号