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 个条件:
- 缓存刚好已失效
- 读请求 + 写请求并发
- 更新数据库 + 删除缓存的时间(步骤 5-8),要比读数据库 + 写缓存时间短(步骤 3 和 9)
仔细想一下,条件 3 发生的概率其实是非常低的。因为写数据库一般会先加锁,所以写数据库,通常是要比读数据库的时间更长的。以上其实就是很典型的Cache Aside Pattern(旁路缓存模式)的写操作。这么来看,【先更新数据库 + 再删除缓存】的方案,是可以保证数据一致性的。所以,我们应该采用这种方案,来操作数据库和缓存。
解决了并发问题,我们继续来看前面遗留的:第二步执行失败,导致数据不一致的问题。
2.4、第二步执行成功是关键
前面我们分析到,无论是更新缓存还是删除缓存,只要第二步发生失败,那么就会导致数据库和缓存不一致。
**第二步执行成功,就是解决问题的关键。**由此,我们能很简单想到就是失败后进行重试
,关键在于重试
,尽可能地去做补偿。现实情况往往没有想的这么简单,失败后无脑重试的问题在于:
- 立即重试很大概率还会失败
- 重试次数设置多少才合理
- 重试会一直占用这个线程资源,无法服务其它客户端请求
虽然我们想通过重试的方式解决问题,但这种同步重试的方案依旧不严谨。那更好的方案,我们也不难想到异步重试。
异步重试,我们不难想到,就是引入消息队列,进行异步消费执行第二步。不难想到其实会有以下问题:
- 写消息队列也有可能会失败啊?
- 引入消息队列,这又增加了更多的维护成本,这样做值得吗?
我们权衡一下。引入消息队列,正好符合我们的需求:
- 消息队列保证可靠性:写到队列中的消息,成功消费之前不会丢失,重启项目也不担心,
- 消息队列保证消息成功投递:下游从队列拉取消息,成功消费后才会删除消息,否则还会继续投递消息给消费者,符合我们重试的场景
至于写队列失败和消息队列的维护成本问题:
- 写队列失败:操作缓存和写消息队列,同时失败的概率其实是很小的
- 维护成本:我们项目中一般都会用到消息队列,维护成本并没有新增很多
所以,引入消息队列来解决这个问题,是比较合适的。这时系统架构模型是这样的:
那如果你确实不想在应用中去写消息队列,是否有更简单的方案,同时又可以保证一致性呢?方案还是有的,这就是近几年比较流行的解决方案:订阅数据库变更日志,再操作缓存。
比如MySQL ,当一条数据发生修改时,MySQL 就会产生一条变更日志存储在Binlog,我们可以通过程序订阅这个日志,拿到具体操作的数据,然后推送到消息队列,消费者再根据这条数据,去删除对应的缓存。
订阅变更日志,目前也有了比较成熟的开源中间件,例如阿里的 canal。
至此,我们可以得出结论,想要保证数据库和缓存一致性,推荐采用【先更新数据库,再删除缓存】方案,并配合【消息队列】或【订阅变更日志】的方式来做。
2.5、强一致思考
基于此,以上的讨论,都是怎么保证数据库和缓存最终一致性。要想做到强一致,最常见的方案是 2PC、3PC、Paxos、Raft 这类一致性协议,但它们的性能往往比较差,而且这些方案也比较复杂,还要考虑各种容错问题。相反,这时我们换个角度思考一下,我们引入缓存的目的是什么?没错,性能。所以,其实就是一种权衡的过程。而且我们最终采用的方案,缓存都是有失效时间的,就算在这期间存在短期不一致,我们依旧有失效时间来兜底,这样也能达到最终一致。
3、总结
通过以上的分析,解决方案的核心其实都是Cache Aside Pattern(旁路缓存模式)的读操作和写操作:
读操作:
- 首先从缓存中查询数据,如果缓存命中则直接返回
- 缓存未命中,则去数据库中读取
- 将从数据库中读取的结果的副本放入到缓存中,并返回
写操作:
- 首先更新数据库
- 然后删除缓存中的数据
再根据以上思路,确保写操作的第二步执行成功,通过重试策略的异步重试思路,引入消息队列或者订阅数据库变更日志等,确保最终一致性。性能和一致性不能同时满足,为了性能考虑,通常会采用最终一致性的方案。很多方案,其实都是一个权衡的过程,我们要学会从中思考。