如何解决缓存与数据库不一致

在追求接口性能的伟大征途中,我们常常要与数据库为伍。然而,频繁地查询数据库可谓是犹如一场抓耳挠腮的恶梦。因此,我们需要施展一些技能,以降低数据库查询的次数,并且优化查询的效率。缓存就像是一位Superman,它能以惊人的速度拯救我们。引入缓存的魔法能够减少数据库查询的频率,让我们的接口快如闪电。而且,缓存还能优化查询效率,让数据库变得如临大敌,每次查询都是轻松搞定。但是伴随而来的问题便是如何解决缓存与数据库不一致。

在读写缓存过程中如何保证数据的一致性

读写缓存常见方式:Cache-Aside(读取时更新)

应用程序首先检查缓存中是否存在所需数据,如果存在则直接从缓存中读取,如果不存在则从数据库中读取数据,并将数据存储到缓存中,以供后续的读取请求使用。如图所示:

并发情况下,确保Cache-Aside数据的一致性

在Cache-Aside(读取时更新)模式中,在进行数据更新时,一般是需要考虑并发访问的情况,特别是在高并发环境下。加锁是一种常见的解决方案,以确保数据的一致性。

在更新数据之前,可以使用锁机制(如互斥锁或读写锁)来保护对共享资源的访问。这样可以确保在数据更新期间,其他线程无法读取该数据,直到更新完成。

在使用锁时,需要注意以下几点:

  1. 锁的粒度:根据实际情况,确定需要加锁的代码块的范围,尽量缩小锁的粒度,避免锁的持有时间过长,减少线程间的竞争。

  2. 锁的类型:根据并发访问的特点,选择合适的锁类型。例如,可以使用读写锁来允许多个线程同时读取数据,但只允许一个线程进行写操作。

  3. 死锁风险:在使用多个锁时,要注意避免死锁的情况,即避免不同线程持有不同的锁,但相互等待对方释放锁的情况。

可参考ChatGPT教你如何解决复杂高并发系统缓存设计(上)中的互斥锁(Mutex Lock)或分布式锁解决缓存击穿

在更新过程中如何保证数据的一致性

在更新数据库数据过程中,缓存与数据库不一致可能原因:

  1. 并发问题:在高并发的情况下,多个线程同时进行数据写操作,如果写操作没有正确地同步,可能会导致数据的不一致。例如,两个线程同时对同一数据进行写,其中一个线程写了数据库但没有写缓存,而另一个线程先读取了缓存中的旧数据,就会导致数据不一致。

  2. 失败风险:尽管先写数据库再写缓存可以保证数据的一致性,但在数据库更新成功后,如果缓存更新失败,会导致缓存中的数据与数据库不一致。

因此在缓存过程中如何保证数据的一致性,应该从两点分析:

  1. 在双写情况下的数据一致性问题

  2. 在读写情况下的数据一致性问题(包括失败情况)双更新策略

双更新策略

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

1.1.1在双写情况下的数据一致性问题

由于先更新数据库再更新缓存,数据库写入操作可能比较耗时,导致缓存更新的延迟。在这段延迟期间,缓存中可能存储的是旧的数据,导致缓存与数据库之间存在一段时间的不一致。

举个例子,比如「请求 A 」和「请求 B 」两个请求,同时更新「同一条」数据,则可能出现这样的顺序:

A 请求先将数据库的数据更新为 A,然后在更新缓存前,请求 B 将数据库的数据更新为B,紧接着也把缓存更新为 B,然后 A 请求更新缓存为 A。

此时,数据库中的数据是B,而缓存中的数据却是 A,出现了缓存和数据库中的数据不一致的现象。

解决方法:

        更新数据库和缓存的原子性操作:在某些场景下,可能需要确保数据库和缓存的更新操作是原子性的,要么同时成功,要么同时失败。这可以通过事务来实现,将数据库更新和缓存删除的操作放在同一个事务中,保证操作的一致性。

1.1.2 在读写情况下的数据一致性问题

「请求 A 」和「请求 B 」两个请求在读写都正常情况下,数据保持一致性。(这就不画图了,自己简单画图分析一下)。

针对某一请求失败情况,「请求 A 」和「请求 B 」两个请求,请求A更新数据,请求B读取数据,则可能出现这样的顺序:

A 请求先将数据库的数据更新为 A,然后在更新缓存失败,未将数据更新为B,请求 B 此后B读取数据,读取数据为B这样导致数据出现错误。

解决办法:

  1. 缓存失效策略和更新策略:合理设置缓存的失效策略和更新策略,以尽量保证缓存中的数据与数据库的一致性。可以根据数据的变更频率和实时性要求来设置缓存的失效时间,或者通过监听数据库的变更事件来主动更新缓存。

  2. 使用消息队列实现异步更新:将数据的更新操作发送到消息队列中,由消费者异步地更新数据库和缓存。通过异步更新可以提高系统的并发性能,并且在更新过程中保持缓存和数据库的一致性。

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

1.2.1 在双写情况下的数据一致性问题

举个例子,比如「请求 A 」和「请求 B 」两个请求,同时更新「同一条」数据,则可能出现这样的顺序:

 

A 请求先将缓存的数据更新为 A,然后在更新数据库前,B 请求来了, 将缓存的数据更新为B,紧接着把数据库更新为 B,然后 A 请求将数据库的数据更新为A。

此时,本身应该数据库为B却更改为A,此外,数据库中的数据是 A,而缓存中的数据却是 B,出现了缓存和数据库中的数据不一致的现象。

解决方法:

更新数据库和缓存的原子性操作:在某些场景下,可能需要确保数据库和缓存的更新操作是原子性的,要么同时成功,要么同时失败。这可以通过事务来实现,将数据库更新和缓存删除的操作放在同一个事务中,保证操作的一致性。

1.2.2 在读写情况下的数据一致性问题

「请求 A 」和「请求 B 」两个请求在读写都正常情况下,数据保持一致性。(这就不画图了,自己简单画图分析一下)。

针对某一请求失败情况,「请求 A 」和「请求 B 」两个请求,请求A更新数据,请求B读取数据,则可能出现这样的顺序:

 

A 请求先将缓存更新为 A,然后在更新数据库失败,未将数据更新为A,请求 B 此后读取缓存数据正确,但是数据库数据为更新为B,导致了数据库数据错误,虽然看似正确,但是如果缓存设定过期时间将会导致数据不一致出现。

删除缓存策略

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

2.1.1 在双写情况下的数据一致性问题

「请求 A 」和「请求 B 」两个请求,请求A更新数据,请求B更新数据:

此情况下可以保证数据一致性。

还有另外一种情况,「请求 A 」和「请求 B 」两个请求,请求A更新数据,请求B更新数据,则可能出现这样的顺序:

由于数据库存在行锁的情况,其实这种情况是很难出现的。

2.1.2 在读写情况下的数据一致性问题

在读写情况下,并且未出现错误情况,「请求 A 」和「请求 B 」两个请求,请求A更新数据,请求B读取数据,则可能出现这样的顺序:

 

数据库数据为X,请求 A 要更新数据为A,所以它会删除缓存中的内容。这时,另一个请求 B 要读取数据,它查询缓存发现未命中后,会从数据库中读取到数据X,并且写入到缓存中(这个顺序不一定),然后请求 A 继续更改数据库,将数据修改为A。

可以看到,先删除缓存,再更新数据库,在「读 + 写」并发的时候,还是会出现缓存和数据库的数据不一致的问题。

在失败情况下,即删除缓存失败,如图所示:

 

在该情况下数据依旧可以保存数据的一致性,因其数据库已经正常变更。

2.2 先更新数据库,再删除缓存

2.2.1 在双写情况下的数据一致性问题

「请求 A 」和「请求 B 」两个请求,请求A更新数据,请求B更新数据:

 

该情况无论删除缓存谁在前后都不影响,均可以保证数据的一致性。

2.2.2 在读写情况下的数据一致性问题

在读写情况下并且在缓存存在未过期情况下,并且未出现错误情况,「请求 A 」和「请求 B 」两个请求,请求A更新数据,请求B读取数据,则可能出现这样的顺序,此情况可保证数据的一致性。

当然还有一种可能性,缓存过期或缓存不存在情况下,如图所示:

 从上面的理论上分析,先更新数据库,再删除缓存也是会出现数据不一致性的问题,但是在实际中,这个问题出现的概率并不高。因为缓存的写入通常要远远快于数据库的写入,所以在实际中很难出现请求 B 已经更新了数据库并且删除了缓存,请求 A 才更新完缓存的情况。而一旦请求 A 早于请求 B 删除缓存之前更新了缓存,那么接下来的请求就会因为缓存不命中而从数据库中重新读取数据,所以不会出现这种不一致的情况。

所以,「先更新数据库 + 再删除缓存」的方案,是可以保证数据一致性的。

在失败情况下,即删除缓存失败,如图所示:

 有访问数据 X 的请求,会先在 Redis 中查询,因为缓存并没有 诶删除,所以会缓存命中,但是读到的却是旧值 1。

解决方法(此部分引用小林同学内容):

重试机制

可以引入消息队列,将第二个操作(删除缓存)要操作的数据加入到消息队列,由消费者来操作数据。

  • 如果应用删除缓存失败,可以从消息队列中重新读取数据,然后再次删除缓存,这个就是重试机制。当然,如果重试超过的一定次数,还是没有成功,我们就需要向业务层发送报错信息了。

  • 如果删除缓存成功,就要把数据从消息队列中移除,避免重复操作,否则就继续重试。

举个例子,来说明重试机制的过程。

订阅 MySQL binlog,再操作缓存

「先更新数据库,再删缓存」的策略的第一步是更新数据库,那么更新数据库成功,就会产生一条变更日志,记录在 binlog 里。

于是我们就可以通过订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除,阿里巴巴开源的 Canal 中间件就是基于这个实现的。

Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送 dump 请求,MySQL 收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。

下图是 Canal 的工作原理:

所以,如果要想保证「先更新数据库,再删缓存」策略第二个操作能执行成功,我们可以使用「消息队列来重试缓存的删除」,或者「订阅 MySQL binlog 再操作缓存」,这两种方法有一个共同的特点,都是采用异步操作缓存。

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值