⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 blog.csdn.net/ynzhang_it/article/details/104009994/ 「网络」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注**微信公众号:【芋道源码】**有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言将得到认真回复。甚至不知道如何读源码也可以请教噢
  4. 新的源码解析文章实时收到通知。每周更新一篇左右
  5. 认真的源码交流微信群。

在现在的系统架构中,缓存的地位可以说是非常高的。因为在互联网的时代,请求的并发量可能会非常高,但是关系型数据库对于高并发的处理能力并不是非常强,而缓存由于是在内存中处理,并不需要磁盘的IO,所以非常适合于高并发的处理,也就成为了各个系统中必不可少的一部分了。

不过,由此产生的问题也是非常多的,其中一个就是如何保证数据库和缓存之间的数据一致性。

由于数据库的操作和缓存的操作不可能在一个事务中,也就势必会出现数据库写入失败,缓存不能更新,缓存写入失败的补偿机制。具体我们应该怎么做呢?

我们先看一个最常见的读缓存的例子

在读取缓存的方式中,上图这种方式可以说是最为广泛使用的了。读本身是没有什么问题的,但是,写入缓存的方式,就是保证数据一致性的重中之重了。

这里我们不考虑定时刷新缓存的方式,也就是下面这类方式:

写入数据库和写入缓存是独立的,写入数据库操作后,需要等待定时服务执行,执行完成后缓存数据才会刷新。

这种方式会导致数据的不一致时间较长,数据刷新时,不管有没有改变的数据,都会重新加载,效率差。当然,并不是说这种方式就没用,还是有一些场景是可以使用的,例如一些系统配置的缓存,而且,这样做缓存刷新,代码量非常少,也便于维护。

我们今天只考虑双写的数据一致性如何来考虑。由于不同的写入方式,可能带来的结果也就是不同的。通常情况下,我们都有哪些写入数据并刷新缓存的方式呢?

方法一、先更新数据库,在更新缓存

这套方案是最简单的一种缓存双写方案,我们先来看看流程图

使用这种双写的方案,只要在数据成功写入数据库后,刷新缓存就可以了,代码简单,维护也很简单。但是,简单的前提下,带来的问题也是很直接的。

首先,线程数据安全无法保证

例如:我们现在同时有两个请求会操作同一条数据,一个是请求A,一个是请求B。请求A需要先执行,请求B后执行,那么数据库的记录就是请求B执行后的记录。

但是,由于一些网络原因或者其他情况,最终执行的顺序可能就变成了:

请求A Update 数据库 -> 请求B Update 数据库 -> 请求B Update 缓存 -> 请求A Update 缓存。

这样的结果会导致:

  1. 数据库和缓存中的数据不一致,从而缓存中的数据就成为了脏数据。
  2. 写入操作多于读操作,就会频繁的刷新缓存,但是这些数据根本没有被读过。这样就会浪费服务器的资源。

因此,这种双写方式很难保证数据一致性,不建议使用。

方法二、先删除缓存再更新数据库

由于上述方式存在的问题,那么我们就考虑,能不能先删除缓存,在更新数据库,这样,在更新数据库的前后,由于缓存中没有数据了,请求就会穿透到数据库直接读取数据然后放入缓存,这样,缓存就不会被频繁的刷新了。

于是,我们就设置了一个新的执行顺序:

不过,这样一来,新问题又出现了。有两个请求,一个请求A,一个请求B,请求A去写数据,请求B去读数据。当并发量高的时候,就会出现以下情况:

请求A进行写操作,删除缓存 -> 请求B查询发现缓存不存在 -> 请求B去数据库查询得到旧值 -> 请求B将旧值写入缓存 -> 请求A将新值写入数据库

这是,脏数据又出现了。如果我们没有设置缓存的过期时间,那么在下一次下入数据前,脏数据就会一直的存在。针对这种脏数据出现的情况,我们决定在写入数据后,增加一点延时,再删除一次数据,于是就有了方法三。

方法三、延时双删

使用延时双删的策略,就能够很好的解决之前我们应该并发所引起的数据不一致的情况。那是不是延时双删就完全没有问题呢?不。

我们来假设一个场景,就是我们做了读写分离,那么使用延时双删可能问出现什么情况呢?

请求A进行写操作,删除缓存 -> 请求A将数据写入数据库了 -> 请求B查询缓存发现,缓存没有值 -> 请求B去从库查询,这时,还没有完成主从同步,因此查询到的是旧值 -> 请求B将旧值写入缓存 -> 数据库完成主从同步,从库变为新值。

糟糕,又出现数据不一致了。

然后在看看性能如何,由于需要延时,如果是同步执行,性能必定很差,所以第二次删除只有做成异步,避免影响性能。那异步执行删除就会出现新问题,如果异步线程执行失败了,那么旧数据就不会被删除,数据不一致又出现了。

不行,我们需要向一个一劳永逸的办法,单纯的双删还是不可靠。

方法四、队列删除缓存

我们在把数据更新到数据库后,把删除缓存的消息加入到队列中,如果队列执行失败,就再次加入到队列执行直到成功为止。

这样,我们就能够有效的保证数据库和缓存的数据一致性了,不管是读写分离还是其他情况,只要队列消息能够保证安全,那么缓存就一定会被刷新。

当然,根据这个方案,我们还可以进一步优化。因为这里我们的缓存刷新时基于业务代码的,也就是说,业务代码和缓存刷新的耦合度很高。有没有办法能够把缓存刷新独立出来,不基于业务代码执行呢?

方法五、binlog订阅删除缓存

为了保证业务代码的独立性,我们可以通过订阅binlog日志的方式来刷新缓存。我们先启动mysql的binlog日志,然后如下图方式设计流程:

通过binlog的订阅,我们就把业务代码和缓存刷新的非业务代码独立开来。代码量小了,也方便维护了。程序员们也不需要去关心什么时候应该刷新缓存,是不是需要刷新缓存。

当然,实战中,我们还有很多不同的业务场景,可能需要的数据一致性同步方案也不同,这里也只算是一个案例。

文章目录
  1. 1. 我们先看一个最常见的读缓存的例子
  2. 2. 方法一、先更新数据库,在更新缓存
  3. 3. 方法二、先删除缓存再更新数据库
  4. 4. 方法三、延时双删
  5. 5. 方法四、队列删除缓存
  6. 6. 方法五、binlog订阅删除缓存