思考1 - 缓存和数据库一致性问题

191 阅读8分钟

为什么要引入缓存?

让我们从基本开始讲起。

当系统的流量较小时,那么无论是读请求还是写请求,直接操作数据库便好了。

此时我们的操作是这样子的:

image.png

可是随着公司发展,客户越来越多,业务越来越大,网站请求也越来越多,响应也开始变慢了起来。

尽管我们还在努力的优化sql,分库分表来加快响应,但大功臣【数据库】却跟掀桌子了,凭什么要我一直加班,干不了一点点。

image.png

那完蛋了,大功臣一旦不干,查询怎么办?保存怎么办?这一大家子全靠他撑着呢!

没办法,公司便安排你去慰问一下,在经过涨薪和协谈下,【数据库】还是答应回来工作,但是要求多招聘一个人来分担一下读压力,让数据库轻松一点。

此时操作便是以下流程:

image.png

那么在经过多次面试和性价比考察下,我们便发现了 Redis,这位新晋员工不仅性能优秀,还能高效的满足业务需求。

那么在招聘 Redis 之后,新的矛盾就出现了: 之前数据都是存在数据库中的,现在要放入缓存中,该怎么存呢?

此时数据库提出了一个想法,为了避免各位同事加班,那么我全量数据刷新到缓存中:

  • 将数据库中的数据,全量存入缓存中
  • 写请求更新数据库,不更新缓存
  • 设置定时任务,定时更新缓存中的数据

说干就干,很快第一版本方案便完成了。

查询确实快了很多,毕竟此时读请求都可以直接命中缓存的,不再需要数据库IO,性能非常快。 但是也有两个明显的问题:

  • 缓存利用率低:全量刷新到缓存,不常用的数据也在缓存中
  • 数据不一致:由于是定时更新的,缓存和数据存在不一致。

好吧,你缓存也招引进了,数据库也没罢工了,但是在高并发的情况下还有问题,那就是人的问题了。

为了保住自己的工作,我们只好开始动脑子想办法了。

缓存利用率问题

如何提高缓存利用率?

很简单,就和水多了加盐,盐多了加水一样。我们去除多余的 Key,只保留最近访问的热点数据就好了。

那么就可以基于以上思想小小优化一下

  • 读请求先读缓存,如果缓存不存在,再去数据库中查询。
  • 将查询到的数据存入缓存中,并且设置过期时间
  • 写请求依旧只改数据库。

image.png 这样一来,保存在缓存中的数据便是近期访问的数据,便保证了利用率的最大化。

一致性问题

更新缓存,还是删除缓存,这是一个严重的问题。

针对缓存操作

如果要保证数据一致问题,则需要在操作数据库数据同时,也要修改缓存中的数据。

前文提到,引入缓存便是位了降低数据库压力,那么在高并发的情况下,我们进行更新缓存操作,可能会出现一下情况呢?

image.png

图有点粗糙,但是也不是很难理解。以上实/虚线标识两位用户操作。

  1. A 用户保存 x = 1,B用户保存 x = 2。
  2. 此时 B 线程走完,提前更新缓存库。
  3. 此时 A 线程走完,再次更新缓存库。
  4. 查询,返回 x = 1 。

哦吼,此时数据库中 x = 2,数据有不一致了。

通过分析,我们能明白以上问题产生的关键在于:缓存被存回了旧值,并不能保证最新数据。

那么为了避免以上情况的发生,更新缓存我们便只能舍弃了。所以此时需要考虑另外一种方案:删除缓存

那么删除操作也有先后顺序,如下:

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

先删除缓存,在更新数据库

还是由并发情况来分析以上情况。(x = 0)

  1. 用户 A 开启 x = 1 事务,先删除缓存
  2. 用户 B 查询,发现缓存不在,从数据库中获取 x = 0,预备更新缓存
  3. 用户 A 提交事务,更新缓存 x = 1
  4. 用户 B 更新缓存, x = 0。

好吧,讨论了这么多还是回到了老问题: 缓存被存回了旧值。

那么该如何解决此类问题呢? 通用办法就是 延迟双删 。

延迟双删策略

顾名思义,该操作就是删除两次。在更新完数据库操作后,使操作休眠一段时间,在对缓存进行第二次删除。

这样,下次查询时,便是从数据库中获取最新值,写入缓存。

但是问题又来了,这个休眠时间,到底该休眠多久呢?

根据以上分析,缓存操作不能线程 B 完成操作之前,不然休眠休眠了个寂寞。理论上应该是休眠时间 > 线程B 读取数据库 + 更新缓存的时间

但是,这个时间让我们来预测,也只能根据经验来设计时间,让其尽可能的减低读取到旧值的概率。

因此,该方案也只是尽可能的保证一致性而已。

先更新数据库,在删除缓存。

依旧是并发情况来分析。(x = 0)

  1. 用户 A 更新 x = 1,删除缓存
  2. 用户 B 查询,发现缓存不在,从数据库中获取(x = 1)

image.png 好吧,此时的你肯定很懵逼,你之前的分析都是线程提交时间不一样,现在就这么草率?

开个玩笑,我们来分析失败的情况。

  1. 缓存失效,用户 A 从数据库中获取 x = 0
  2. 线程 B 更新数据库 x = 1,并且删除缓存
  3. 线程 A 就旧值写入缓存中

这才对位嘛。那以上两种思路都不行吗?

错啦,以上方法需要满足一下条件才存在。

  1. 缓存失效
  2. 更新数据库 + 删除缓存时间 < 读数据库 + 写缓存时间

我们都知道,在更新数据库的过程中,会自动加上意向锁,甚至是行锁。所以通常来说写数据库是要比读久的。只有超级小概率下才会发生以上条件。

错误补偿

经过以上分析,我们明白了针对缓存的操作。那么二者要是执行顺利的情况下,是能保证实现任务的。

但关键是错误了怎么办?

更新数据库错误

其实错误就错误了,也没什么问题,此时我们更新数据库失败,缓存还没更新呢,并不影响其他用户获取最新值。

删除缓存错误

在系统中,如果一个操作错误了,我们通常会先道歉,然后让客户再次重试操作

当然,我们此时也可以使用重试操作,如果删除缓存失败了,我们便能发起重试,尽可能的做出补偿。

就像客户存在耐心阈值,我们也不可能一直异步重试

通常我们是把重复请求写入到消息队列中,这样就可以由专门的消费者进行消费,直到成功。

当然也可以更加牛逼一点,将修改缓存放入到消息队列中,由消费者来进行缓存操作。

因为消息队列能保证以下优点:

  • 保证可靠性:成功消费前并不会丢失(重启项目也不担心)
  • 保证消息成功投递:成功消费后才会删除信息

但是这样子公司就又招了一个新员工,工资开销又多了一笔,维护成本也提高了,这点优略点就要自己想清楚啦。

订阅 Cannal 组件

当然,有人会说,我就是都不想写,有没有什么一键绑定部署的,那就是通用答案:订阅数据库变更日志,在操作缓存

让我们拿 Mysql 举例,当一条数据发生修改,Mysql 会生成一条日志(Binlog),然后我们获取这条日志的相关信息,再去进行相应的缓存操作。

以上优点就是:

  • 无需考虑消息队列失败情况
  • 自动投递到下游

当然,我们要是关注数据库更新说明,其实各大库都在缓慢提供订阅日志功能,相比以后会有接口实现的。

想要保证数据库和缓存一致性,推荐采用「先更新数据库,再删除缓存」方案,并配合「消息队列」或「订阅变更日志」的方式来做。

可以做到强一致性吗?

嗨,朋友,世界上没有完美的圆。

因此,世界上也没有完美的系统设计,我们只是让他尽力让他变得更完美而已。

我们也可以使用 2PC、3PC、Raft 这种一致性协议,也可以使用分布式锁,但他的性能也比较差,也会让架构方案变得更加复杂。

并且,不要忘记我们为什么要引入缓存的原因,那就是提升性能

所以不要捡了芝麻丢了西瓜,忘记了我们引入缓存的初心。

当然,如果真有客户获取到了旧值,我们还有最终方案,那就是红豆泥私密马赛,然后进行人工维护。