数据库和缓存的一致性如何保证

时间:2022-11-25 13:07:41

最近帮组里做讲座预约系统,虽然使用人数不多,但终于还是遇到了一些系统经典问题,比如数据库与缓存的一致性问题,很有意思,好记性不如烂笔头,学习了一些思路以后决定记录下来与大家分享。


什么是数据库与缓存的一致性问题

程序员应该没人不懂这个,但我还是觉得应该写上,有头有尾。所谓数据库与缓存的一致性问题,可以说是伴随着计算机这个东西一路走来的古老问题。

就我所知最早的缓存一致性问题经典案例就是CPU的Cache与内存之间的缓存一致性问题。

学过计组的都知道,每个CPU都有自己独享的高速缓存Cache,而Cache本质上是对于内存的缓存,当多个CPU共享一个内存时,该问题就来了,比如A,B两个CPU的高速缓存中都存储了内存上0x00001111这个地址的数据,如果此时A处理完了该数据并写回了内存,那么显然B的Cache中的该数据就过期了,如果B又读取了该数据进行处理,那么就使用了错误的数据。我们必须保证所有CPU读取到的缓存中的内容是真实的,不然处理虚假的数据只会造成错误的结果。而这一点引申至一切数据库+缓存的结构中都适用。

CPU的解决方案是基于总线嗅探机制的 MESI 协议,这个大家也都学过不细说,总之该协议保证了写传播和事物的串行化,解决了CPU的缓存一致性问题,保证了我们使用计算机所获得的服务质量。

扯远了,总之缓存一致性问题本质上就是缓存数据与数据库数据之间的同步问题,一旦数据库中的数据被修改,就必须要让所有缓存了该数据的用户都知道该数据缓存已经失效,需要读取最新值。


缓存一致性的解决方案在不同场景下的分析

解决方案无论好坏我都列在下面,如果你希望找一个靠谱的方案请选后面的,前面的例子主要还是给自己看看理解理解。

  • 利用写入顺序的方案
    • 先写缓存再写数据库
    • 先写数据库再写缓存
  • 删除缓存方案
    • 先删除缓存,再写数据库
    • 先写数据库,再删缓存

先写缓存,再写数据库

目前没人会用的方案,先写缓存风险太大,因为要明确当今主流的微服务架构下,任何服务都是不那么可靠的,如果先写缓存成功,再写数据库却失败了,这时我们的缓存中就出现了假数据,这是不可接受的,所以目前这种方案采用的很少。


先写数据库,再写缓存

虽然没有假数据那么严重但还是存在同样的问题,如果先写数据库成功,再写缓存失败,那么数据库中数据虽然真实但是也读取不到,还是没有意义,指望服务自己争气不要出错等同于给自己埋雷。

也有人在这里会说可以把写数据库和写缓存都放在一个事务中,借助事务的原子性来保证正确。这还是会存在非常多的问题,在小并发量下勉强能用,但是这个做法将会严重影响接口性能,不过有时候我很怀疑学校自己的抢课系统是不是就是这么做的,不然怎么能每次抢课都那么卡...

但是一旦并发量起来,这个方案还是会遇到先后顺序的问题,比如A,B两个用户在几乎相同的时间开启了事务准备写回数据,其中A先写完了数据库,但是写缓存时网络波动被延迟,所以又慢于B写缓存,那么两个事务执行完,你就会发现数据库中是B写的,缓存中是A写的,还是不一致,更不用说每次写数据库操作还需要附带一次写缓存,本身就是对于系统资源的一种浪费。

那如果通过加锁来防止并发事务出错,首先你需要在这里引入分布式锁问题,相当复杂,其次,这将进一步影响本来就不太行的系统性能,大大折损整个系统的吞吐量,所以总的来说这个方案还是抛弃比较好。


先删除缓存,再写数据库

高并发下容易出现问题的方案,老样子A,B两个用户,同时发起请求,A打算写,B打算读。

假设A删除了缓存,然后网络卡顿,没及时更新数据库。这时B请求,缓存未命中,于是请求数据库,查到了旧值,随后写入了缓存。此时A的卡顿结束,更新了数据库。你就会发现数据库中是新值,缓存是旧值,二者不一致。

那么能不能对这个问题再进行解决呢?还真的有,那就是缓存双删,很好理解,就是写之前删除一次,写完以后再删一次,这样就能保证后面的缓存和数据库的一致性了。不过这里要注意一点,那就是第二次删除一定要间隔一段时间,不能一完成数据库的更新就立马删除,因为此时数据库刚刚更新,可能有别的请求正拿着旧数据还没写完缓存,你前脚刚删它后脚就又写上了,那不是白费力气吗?所以这里必须要隔一段缓冲时间,等读了旧数据的请求都处理完了,再去第二次删除缓存。

不过这里还有一个问题,如果双删的第二次删除失败了怎么办呢?这里先按下不表,后面再聊。


先写数据库,再删除缓存

这个看起来非常合理,上面那个方案既然先的那一次删缓存会导致一致性失效,那么我干脆不做第一次删除,更新数据库后,隔一段时间我再删除不就可以保证缓存一致性了吗?

没错,这种情况下想要出差错非常困难,只有当满足以下三个条件时才会发生错误

  • 缓存刚好过期
  • 在缓存过期时发生了查询请求,且查询时写请求还没完成数据库更新操作
  • 查询请求的写缓存操作比写请求的删除操作来得更晚
    在满足上面三种条件的情况下,同样还是会因为查询请求读取并更新了旧值,导致缓存一致性遭到破坏的,不过这几个条件你也可以看出,概率是相当低了。所以这个方案是非常推荐的。

问题:删除缓存的方案如果删除失败了怎么办?

答:加入重试机制,若更新数据库成功,但更新缓存失败,我们就需要重试此操作,如果重试成功,那么一切照旧没有问题,如果重试到了指定最大次数还没有成功,那么我们写入数据库并等待后续处理。

但是这里的重试机制水同样很深,如果同步重试,并发量高时非常影响性能。而异步重试就引入了非常多的可能和变数。所以这里也产生了很多种用于处理删除缓存的方案。

如下

  • 每次重试单独创建一个线程
    • 不建议,高并发时可能会创建大量线程引发OOM问题,非常严重
  • 将重试任务交给指定线程池
    • 解决了可能OOM的问题,但如果宕机有丢数据风险
  • 将重试数据写表,再使用elastic-job这样的定时任务进行
    • 该方案执行需要先将更新数据写到重试表中,表中存在已重试次数和重试状态字段
    • 然后隔一段指定时间就进行一次重试,若成功则返回,失败则已重试次数+1,如果超过最大次数,则记录重试状态为失败,等待后续处理
    • 优点:数据落库,缺点:实时性差
  • 将重试作为事件发布到MQ中,等待Consumer处理
    • 实时性较高
  • 监听bin log
    • 直接建立一个订阅者监听bin log的改动,每次bin log改动则由该订阅者全权负责处理删除,更新请求只需要写完数据库就可以走人,bin log订阅者可以采用上面的那些异步方式进行处理和重试,是一种优雅的解决方案。