解决缓存和数据库双写数据一致性问题

缓存的作用

大部分面向公众的互联网系统,其并发请求数量与在线用户数量都是正相关的,而 MySQL 能够承担的并发读写量是有一定上限的,当系统的访问量超过一定程度的时候,纯 MySQL 就很难应付了。绝大多数互联网系统都是采用 MySQL+Redis 这对经典组合来解决高并发问题的。

Redis 作为 MySQL 的前置缓存,可以应对绝大部分查询请求,从而在很大程度上缓解 MySQL 并发请求的压力。

缓存可以提升性能,缓解数据库压力,但是同时缓存也会出现「缓存和数据库数据不一致」的问题。如果数据不一致,就会导致应用在缓存中读取的不是最新的数据,这显然是不能接受的。

双写不一致的原因

我们先来看看缓存和数据库一致性定义

  • 缓存中有数据,且和数据库数据一致;
  • 缓存中无数据,数据库数据是最新的。

那么不符合这两种情况就属于缓存和数据库不一致的问题了。

当客户端发送一个数据修改的请求,我们不仅要修改数据库,还要一并操作(修改/删除)缓存。对数据库和缓存的操作又存在一个顺序的问题:到底是先操作数据库还是先操作缓存

有好几种解决方案,

1. 先更新缓存,再更新数据库

2. 先更新数据库,再更新缓存

3、先删除缓存,后更新数据库

4、先更新数据库,后删除缓存

我们一一来看看:

更新缓存类

1、先更新缓存,再更新 DB

这个方案我们一般不考虑。原因是更新缓存成功,更新数据库出现异常了,

导致缓存数据与数据库数据完全不一致,而且很难察觉,因为缓存中的数据一直都存在。

2. 先更新 DB,再更新缓存

这个方案也我们一般不考虑,原因跟第一个一样,数据库更新成功了,缓存更新失败,同样会出现数据不一致问题。

到这里可以看出无论这两个操作的执行顺序谁先谁后,只要「第二步的操作」失败了,就会导致客户端读取到旧值。

我们继续分析,除了「第二步操作失败」的问题,还有什么场景会影响数据一致性:并发问题

并发引发的一致性问题

这里列出来所有策略,并且对删除和修改操作分开讨论:

  1. 先更新数据库,后更新缓存
  2. 先更新缓存,后更新数据库
  3. 先更新数据库,后删除缓存
  4. 先删除缓存,后更新数据库

先更新数据库,后更新缓存

假设我们采用「先更新数据库,后更新缓存」的方案,并且在两步都可以成功执行的前提下,如果存在并发,情况会是怎样的呢?

有线程 A 和线程 B 两个线程,需要更新「同一条」数据 x,可能会发生这样的场景:

  1. 线程 A 更新数据库(x = 1)
  2. 线程 B 更新数据库(x = 2)
  3. 线程 B 更新缓存(x = 2)
  4. 线程 A 更新缓存(x = 1)

最后我们发现,数据库中的 x 是2,而缓存中是1。显然是不一致的。

另外这种场景一般是不推荐使用的。因为某些业务因素,最后写到缓存中的值并不是和数据库是一致的,可能需要一系列计算得出的,最后才把这个值写到缓存中;如果此时有大量的对数据库进行写数据的请求,但是读请求并不多,那么此时如果每次写请求都更新一下缓存,那么性能损耗是非常大的。

比如现在数据库中 x = 1,此时我们有10个请求对其每次加一的操作。但是这期间并没有读操作进来,如果用了先更新数据库的办法,那么此时就会有10个请求对缓存进行更新,会有大量的冷数据产生。

至于「先更新缓存,后更新数据库」这种情况和上述问题的是一致的,就不继续讨论。

不管是先修改缓存还是后修改缓存,这样不仅对缓存的利用率不高,还浪费了机器性能。所以此时我们需要考虑另外一种方案:删除缓存

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

假设有两个线程:线程A(更新 x ),线程B(读取 x )。可能会发生如下场景:

  1. 线程 A 先删除缓存中的 x ,然后去数据库进行更新操作;
  2. 线程 B 此时来读取 x,发现数据不在缓存,查询数据库并补录到缓存中;
  3. 而此时线程 A 的事务还未提交。

时间

线程A

线程B

问题

T1

删除数据x的缓存值

T2

读取缓存x数据值,发现缺失
从数据库读取x,读到旧值
把旧值x写到缓存

缓存中是旧值,数据库新值,二者不一致

T3

更新数据库中的x

这个时候「先删除缓存,后更新数据库」仍会产生数据库与缓存的不一致问题。

如何解决呢?其实最简单的解决办法就是延时双删的策略。就是

( 1 )先淘汰缓存

( 2 )再写数据库

( 3 )休眠 1 秒,再次淘汰缓存

这么做,可以将 1 秒内所造成的缓存脏数据,再次删除。

那么,这个 1 秒怎么确定的,具体该休眠多久呢?

针对上面的情形,自行评估自己的项目的读数据业务逻辑的耗时。然后写数

据的休眠时间则在读数据业务逻辑的耗时基础上,加几百 ms 即可。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。

但是上述的保证事务提交完以后再进行删除缓存还有一个问题,就是如果你

使用的是Mysql的读写分离的架构的话,那么其实主从同步之间也会有时间差。

此时来了两个请求,请求 A (更新操作) 和请求 B (查询操作)

请求 A  更新操作,删除了 Redis ,

请求主库进行更新操作,主库与从库进行同步数据的操作,

请 B  查询操作,发现 Redis  中没有数据,

去从库中拿去数据,此时同步数据还未完成,拿到的数据是旧数据。

此时的解决办法有两个:

1 、还是使用双删延时策略。只是,睡眠时间修改为在主从同步的延时时间

基础上,加几百 ms 。

2 、就是如果是对 Redis  进行填充数据的查询数据库操作,那么就强制将其

指向主库进行查询。

继续深入, 采用这种同步淘汰策略,吞吐量降低怎么办?

那就将第二次删除作为异步的。自己起一个线程,异步删除。这样,写的请

求就不用沉睡一段时间后了,再返回。这么做,加大吞吐量。

不过总的来说,先删除缓存值再更新数据库有可能导致请求因缓存缺失而访

问数据库,给数据库带来压力; 2.  业务应用中读取数据库和写缓存的时间有时不好估算,导致延迟双删中的 sleep 时间不好设置。

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

我们还用两个线程:线程 A(更新 x ),线程B(读取 x )举例。

  1. 线程 A 要把数据 x 的值从 1更新为 2,首先先成功更新了数据库;
  2. 线程 B 需要读取 x 的值,但线程 A 还没有把新的值更新到缓存中;
  3. 这个时候线程 B 读到的还是旧数据 1;

时间

线程A

线程B

问题

T1

更新数据库中x的值为2

T2

读取缓存x数据值,缓存命中读到旧值

线程A未删除缓存,导致线程B读到的是旧值

T3

删除缓存中的数据x

不过,这种情况发生的概率很小,线程 A 会很快删除缓存中值。这样一来,其他线程再次读取时,就会发生缓存缺失,进而从数据库中读取最新值。所以,这种情况对业务的影响较小。

由此,我们可以采用这种方案,来尽量避免数据库和缓存在并发情况下的一致性问题。

下面,我们继续分析「第二步操作失败」,我们该如何处理?

如何保证双写一致性

如何保证「第二步操作失败」的双写一致?

前面我们分析到,无论是「更新缓存」还是「删除缓存」,只要第二步发生失败,那么就会导致数据库和缓存不一致。

这里的关键在于如何保证第二步执行成功

首先,介绍一种方法:「基于消息队列的重试机制」。

基于消息队列的重试机制

具体来说,就是把操作缓存,或者操作数据库的请求暂存到队列中。通过消费队列来重新处理这些请求。

流程如下:

  1. 请求 A 先对数据库进行更新操作;
  2. 在对 Redis 进行删除操作的时候发现删除失败;
  3. 此时将 对 Redis 的删除操作 作为消息体发送到消息队列中;
  4. 系统接收到消息队列发送的消息,再次对 Redis 进行删除操作。

 消息队列的两个特性满足了我们重试的需求:

  • 保证可靠性:写到队列中的消息,成功消费之前不会丢失(重启项目也不担心);
  • 保证消息成功投递:下游从队列拉取消息,成功消费后才会删除消息,否则还会继续投递消息给消费者(符合我们重试的场景)。

引入队列带来的问题:

  • 业务代码造成大量的侵入,同时增加了维护成本;
  • 写队列时也会存在失败的问题。

对于这两个问题,第一个,我们在项目中一般都会用到消息队列,维护成本并没有新增很多。而且对于同时写队列和缓存都失败的概率还是很小的。

如果是实在不想在应用中使用队列重试的,目前也有比较流行的解决方案:订阅数据库变更日志,再操作缓存。我们对 MySQL 数据库进行更新操作后,在 binlog 日志中我们都能够找到相应的操作,可以订阅 MySQL 数据库的 binlog 日志对缓存进行操作。

订阅变更日志,目前也有了比较成熟的开源中间件,例如阿里的 canal

大概流程如下:

1、请求 A 先对数据库进行更新操作

2、在对 Redis 进行删除操作的时候发现报错,删除失败

3、此时将 Redis 的 key 作为消息体发送到消息队列中

4、系统接收到消息队列发送的消息后

5、再次对 Redis 进行删除操作

总结

  1. 「更新数据库 + 更新缓存」方案,在「并发」场景下无法保证缓存和数据一致性,且存在「缓存资源浪费」和「机器性能浪费」的情况发生,一般不建议使用
  2. 在「更新数据库 + 删除缓存」的方案中,「先删除缓存,再更新数据库」在「并发」场景下依旧有数据不一致问题,解决方案是「延迟双删」,但这个延迟时间很难评估,所以推荐用「先更新数据库,再删除缓存」的方案
  3. 在「先更新数据库,再删除缓存」方案下,为了保证两步都成功执行,需配合「消息队列」或「订阅变更日志」的方案来做,本质是通过「重试」的方式保证数据一致性
  4. 在「先更新数据库,再删除缓存」方案下,「读写分离 + 主从库延迟」也会导致缓存和数据库不一致,缓解此问题的方案是「强制读主库」或者「延迟双删」,凭借经验发送「延迟消息」到队列中,延迟删除缓存,同时也要控制主从库延迟,尽可能降低不一致发生的概率。

test;

final int nonfairTryAcquireShared(int acquires) {// 入参是获取的资源数 等于acquire(1)方法中的入参值
    for (;;) {
	// available就是创建semaphore的许可资源
        int available = getState();
        int remaining = available - acquires;
	// 当资源小于0或者CAS成功,则返回资源数
        if (remaining < 0 ||
            compareAndSetState(available, remaining))
            return remaining;
    }
}

参考文档:https://blog.csdn.net/yehongzhi1994/article/details/107880162

  • 2
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值