缓存一致性问题

缓存一致性问题

到底是更新缓存还是删缓存?
到底选择先更新数据库,再删除缓存,还是先删除缓存,再更新数据库?
为什么要引入消息队列保证一致性?
延迟双删会有什么问题?到底要不要用?

最简单直接的方案是「全量数据刷到缓存中」:

  • 数据库的数据,全量刷入缓存(不设置失效时间)
  • 写请求只更新数据库,不更新缓存
  • 启动一个定时任务,定时把数据库的数据,更新到缓存中
    这个方案的优点是,所有读请求都可以直接「命中」缓存,不需要再查数据库,性能非常高。
    但是要想保证缓存和数据库「实时」一致,那就不能再用定时任务刷新缓存了。
    但数据库和缓存都更新,又存在先后问题,那对应的方案就有 2 个:
  • 先更新缓存,后更新数据库
  • 先更新数据库,后更新缓存

并发引发的一致性问题

先更新数据库,再更新缓存场景:
有线程 A 和线程 B 两个线程,需要更新「同一条」数据,会发生这样的场景:

  1. 线程 A 更新数据库(X = 1)
  2. 线程 B 更新数据库(X = 2)
  3. 线程 B 更新缓存(X = 2)
  4. 线程 A 更新缓存(X = 1)
    最终 X 的值在缓存中是 1,在数据库中是 2,发生不一致。

那怎么解决这个问题呢?这里通常的解决方案是,加「分布式锁」。
这么做的目的,就是为了只允许一个线程去操作数据和缓存,避免并发问题。
但是加锁之后造成资源利用率下降
所以此时我们需要考虑另外一种方案:删除缓存。

删除缓存对应的方案(并发)

也有 2 种:
先删除缓存,后更新数据库
先更新数据库,后删除缓存

1、先删除缓存,后更新数据库
如果有 2 个线程要并发「读写」数据,可能会发生以下场景:
线程 A 要更新 X = 2(原值 X = 1)
线程 A 先删除缓存
线程 B 读缓存,发现不存在,从数据库中读取到旧值(X = 1)
线程 A 将新值写入数据库(X = 2)
线程 B 将旧值写入缓存(X = 1)
最终 X 的值在缓存中是 1(旧值),在数据库中是 2(新值),发生不一致。
可见,先删除缓存,后更新数据库,当发生「读+写」并发时,还是存在数据不一致的情况。
2、先更新数据库,后删除缓存
依旧是 2 个线程并发「读写」数据:
缓存中 X 不存在(数据库 X = 1)
线程 A 读取数据库,得到旧值(X = 1) (此时需要缓存的数据刚好失效)
线程 B 更新数据库(X = 2)
线程 B 删除缓存
线程 A 将旧值写入缓存(X = 1)
最终 X 的值在缓存中是 1(旧值),在数据库中是 2(新值),也发生不一致。

其实概率「很低」,这是因为它必须满足 3 个条件:

  • 缓存刚好已失效
  • 读请求 + 写请求并发
  • 更新数据库 + 删除缓存的时间(步骤 3-4),要比读数据库 + 写缓存时间短(步骤 2 和 5)
    仔细想一下,条件 3 发生的概率其实是非常低的。
    因为写数据库一般会先「加锁」,所以写数据库,通常是要比读数据库的时间更长的。
    这么来看,「先更新数据库 + 再删除缓存」的方案,是可以保证数据一致性的。
    所以,我们应该采用这种方案,来操作数据库和缓存。
    好,解决了并发问题,我们继续来看前面遗留的,第二步执行「失败」导致数据不一致的问题。

两步走中某一个阶段失效导致的不一致问题

保证第二步成功执行,就是解决问题的关键。
想一下,程序在执行过程中发生异常,最简单的解决办法是什么?
答案是:异步重试。
其实就是把重试请求写到「消息队列」中,然后由专门的消费者来重试,直到成功。
那如果你确实不想在应用中去写消息队列,是否有更简单的方案,同时又可以保证一致性呢?
方案还是有的,这就是近几年比较流行的解决方案:订阅数据库变更日志,再操作缓存。
具体来讲就是,我们的业务应用在修改数据时,「只需」修改数据库,无需操作缓存。
那什么时候操作缓存呢?这就和数据库的「变更日志」有关了。
拿 MySQL 举例,当一条数据发生修改时,MySQL 就会产生一条变更日志(Binlog),我们可以订阅这个日志,拿到具体操作的数据,然后再根据这条数据,去删除对应的缓存。目前也有了比较成熟的开源中间件,例如阿里的 canal。

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

主从库延迟和延迟双删问题

前面的几种策略都有缓存中是旧值的问题
最有效的办法就是,把缓存删掉。但是,不能立即删,而是需要「延迟删」,这就是业界给出的方案:缓存延迟双删策略。
延迟时间到底设置要多久呢?
问题1:延迟时间要大于「主从复制」的延迟时间
问题2:延迟时间要大于线程 B 读取数据库 + 写入缓存的时间
但是,这个时间在分布式和高并发场景下,其实是很难评估的。

可以做到强一致吗?

要想做到强一致,最常见的方案是 2PC、3PC、Paxos、Raft 这类一致性协议,但它们的性能往往比较差,而且这些方案也比较复杂,还要考虑各种容错问题。
相反,这时我们换个角度思考一下,我们引入缓存的目的是什么?
没错,性能。
一旦我们决定使用缓存,那必然要面临一致性问题。性能和一致性就像天平的两端,无法做到都满足要求。
而且,就拿我们前面讲到的方案来说,当操作数据库和缓存完成之前,只要有其它请求可以进来,都有可能查到「中间状态」的数据。
所以如果非要追求强一致,那必须要求所有更新操作完成之前期间,不能有「任何请求」进来。
虽然我们可以通过加「分布锁」的方式来实现,但我们也要付出相应的代价,甚至很可能会超过引入缓存带来的性能提升。
所以,既然决定使用缓存,就必须容忍「一致性」问题,我们只能尽可能地去降低问题出现的概率。
​同时我们也要知道,缓存都是有「失效时间」的,就算在这期间存在短期不一致,我们依旧有失效时间来兜底,这样也能达到最终一致。

参考

http://kaito-kidd.com/2021/09/08/how-to-keep-cache-and-consistency-of-db/

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值