缓存和数据库一致性问题
在上期我们说到了几种不同的缓存常用更新策略,引入缓存是为了提高性能,如何保证缓存和数据库一致性问题就变成了重中之重。
保证一致性的前提下代表着,当数据发生更新时,我们不仅要操作数据库,还要一并操作缓存。具体操作就是,修改一条数据时,不仅要更新数据库,也要连带缓存一起更新。所以数据库与缓存更新的先后顺序则是一个问题。
一下两者更新策略在不关注并发问题的前提下,这两种方法都能保证两者的一致,但是我们要考虑第一步成功第二步失败后的场景
1.先更新缓存,后更新数据库
如果缓存更新成功了,数据库更新失败,那么缓存拿到的是最新值,但数据库中是「旧值」。
虽然此时读请求可以命中缓存,拿到正确的值,但是,一旦缓存「失效」,就会从数据库中读取到「旧值」,重建缓存也是这个旧值。
这时用户会发现自己之前修改的数据又「变回去」了,会导致业务的超卖,对业务造成影响。
超卖:指缓存中的数据数量比数据库中的数据数量多。缓存中的数据被多次更新而数据库中的数据只被更新了一次,导致缓存中的数据数量比数据库中的数据数量多。
2.先更新数据库,后更新缓存
如果数据库更新成功了,但缓存更新失败,那么此时数据库中是最新值,缓存中是「旧值」。
之后的读请求读到的都是旧数据,只有当缓存「失效」后,才能从数据库中得到正确的值。
这时用户会发现,自己刚刚修改了数据,但却看不到变更,一段时间过后,数据才变更过来,则会导致少卖,这对业务也会有影响。
少卖:指缓存中的数据数量比数据库中的数据数量少。这种情况可能发生在缓存中的数据被删除或失效了,但数据库中的数据仍然存在,导致缓存中的数据数量比数据库中的数据数量少。
可见,无论谁先谁后,但凡后者发生异常,就会对业务造成影响。在解决之前我们还需要关注它的并发问题
3.并发引起的问题
假设我们采用「先更新数据库,再更新缓存」的方案,并且两步都可以「成功执行」的前提下,如果存在并发,情况会是怎样的呢?
有线程 A 和线程 B 两个线程,需要更新「同一条」数据,会发生这样的场景:
- 线程 A 更新数据库(X = 1)
- 线程 B 更新数据库(X = 2)
- 线程 B 更新缓存(X = 2)
- 线程 A 更新缓存(X = 1)
最终 X 的值在缓存中是 1,在数据库中是 2,发生不一致。
也就是说,A 虽然先于 B 发生,但 B 操作数据库和缓存的时间,却要比 A 的时间短,执行时序发生「错乱」,最终这条数据结果是不符合预期的。
同样地,采用「先更新缓存,再更新数据库」的方案,也会有类似问题,这里不再详述。
但这种「更新数据库 + 更新缓存」的方案效率并不高,因为每次数据发生变更,都「无脑」更新缓存,但是缓存中的数据不一定会被「马上读取」,这就会导致缓存中可能存放了很多不常访问的数据,浪费缓存资源。所以此时我们需要考虑另外一种方案:删除缓存。
4.删除策略方案
删除缓存对应的方案也有 2 种:
- 先删除缓存,后更新数据库
- 先更新数据库,后删除缓存
经过前面的分析我们已经得知,但凡「第二步」操作失败,都会导致数据不一致。
这里我不再详述具体场景,你可以按照前面的思路推演一下,就可以看到依旧存在数据不一致的情况。
这里我们重点来看「并发」问题。
先删除缓存,后更新数据库
如果有 2 个线程要并发「读写」数据,可能会发生以下场景:
- 线程 A 要更新 X = 2(原值 X = 1)
- 线程 A 先删除缓存
- 线程 B 读缓存,发现不存在,从数据库中读取到旧值(X = 1)
- 线程 A 将新值写入数据库(X = 2)
- 线程 B 将旧值写入缓存(X = 1)
最终 X 的值在缓存中是 1(旧值),在数据库中是 2(新值),发生不一致。
可见,先删除缓存,后更新数据库,当发生「读+写」并发时,还是存在数据不一致的情况。
先更新数据库,后删除缓存
依旧是 2 个线程并发「读写」数据:
- 缓存中 X 不存在(数据库 X = 1)
- 线程 A 读取数据库,得到旧值(X = 1)
- 线程 B 更新数据库(X = 2)
- 线程 B 删除缓存
- 线程 A 将旧值写入缓存(X = 1)
最终 X 的值在缓存中是 1(旧值),在数据库中是 2(新值),也发生不一致。
这种情况「理论」来说是可能发生的,但实际真的有可能发生吗?
其实概率「很低」,这是因为它必须满足 3 个条件:
- 缓存刚好已失效
- 读请求 + 写请求并发
- 更新数据库 + 删除缓存的时间(步骤 3-4),要比读数据库 + 写缓存时间短(步骤 2 和 5)
仔细想一下,条件 3 发生的概率其实是非常低的。
因为写数据库一般会先「加锁」,所以写数据库,通常是要比读数据库的时间更长的。
这么来看,「先更新数据库 + 再删除缓存」的方案,是可以保证数据一致性的。
所以,我们应该采用这种方案,来操作数据库和缓存。
解决了并发问题后我们还要思考,第二步执行「失败」导致数据不一致的问题。
5.如何保证两步都执行成功
即使我们解决了并发问题无论是更新缓存还是删除缓存,只要第二步发生失败,那么就会导致数据库和缓存不一致。
相信很多人在玩游戏练习帅气的连招时都不是一次性成功,即使是也不熟练,那我们的解决方案是什么呢?不断的练习——重试,这也是程序在执行过程中发生异常,最简单的解决办法。
无论是先操作缓存,还是先操作数据库,但凡后者执行失败了,我们就可以发起重试,尽可能地去做「补偿」。
那这是不是意味着,只要执行失败,我们「无脑重试」就可以了呢?
答案是否定的。现实情况往往没有想的这么简单,失败后立即重试的问题在于:
- 立即重试很大概率「还会失败」
- 「重试次数」设置多少才合理?
- 重试会一直「占用」这个线程资源,无法服务其它客户端请求
更好的方案应该异步重试就是把重试请求写到「消息队列」中,然后由专门的消费者来重试,直到成功。或者更直接的做法,为了避免第二步执行失败,我们可以把操作缓存这一步,直接放到消息队列中,由消费者来操作缓存。
因为消息队列的特性保证了数据一定能更新成功即使是遇到重启或者其他问题
消息队列的特性
- 消息队列保证可靠性:写到队列中的消息,成功消费之前不会丢失(重启项目也不担心)
- 消息队列保证消息成功投递:下游从队列拉取消息,成功消费后才会删除消息,否则还会继续投递消息给消费者(符合我们重试的场景)
写队列失败和消息队列的维护成本问题
- 写队列失败:操作缓存和写消息队列,「同时失败」的概率其实是很小的
- 维护成本:我们项目中一般都会用到消息队列,维护成本并没有新增很多
此时的架构由
![](https://i-blog.csdnimg.cn/blog_migrate/dd0d1a85c9454c2abe3e733b2d96170d.jpeg)
变为
![2](https://i-blog.csdnimg.cn/blog_migrate/6f9a0da5bf4f4bf7d5a929174e76a2c8.jpeg)
如果不想在应用中去写消息队列,有更简单的方案通过订阅数据库变更日志,再操作缓存来保证一致性
由于我对这个方案还没呀深刻的理解与使用,我也没办法进行详细的介绍,希望这篇文章可以帮助到你