缓存在当今互联网应用和实践中是比不可少,由于其高并发和高性能的特性,已经在项目中广泛使用,比较有代表性的缓存如Redis,memcached,并且也逐渐成为很多大厂的面试中开始逐渐成为热点和难点。其中一个重要的难点就是缓存和数据库同步问题。本文通过借鉴计算机系统结构上缓存的设计模式,并结合互联网应用下的场景给出缓存和数据库同步问题解决方案。
直接以伪代码的形式展示这一过程:先读缓存中的数据,如果有直接返回;如果没用读数据库并加载到缓存中:
public Object read(String key){
Object data = redis.get(key); // 先读缓存中的数据
if(data ==null){
data = db.selectData(key) // 缓存中没用读, 数据库
redis.set(key, data ) // 并加载到缓存中
}
return data;
}
可以看到非常的简单就实现了读缓存,在实际使用中也非常常见。比如将用户信息缓存起来,不用每次都去数据库查询了,大大加快了查询的速度, 岂不美哉。但是问题就来了,假如我需要更新数据怎么办?有的人可能马上就想到:那我先更新缓存再更新数据库不就行了。
这种方法非常直观,我读的时候先读缓存再读数据库;那我更新的时候,先更新缓存再更新数据库不就行了。伪代码形式如下:
public Object update(String key,Object data){
redis.set(key,data) // 先更新缓存
db.update(key, data); // 再更新数据库
return data;
}
但是问题以往直观的往往是错的,这个方式有很多的问题。
1. 在并发场景下数据不一致问题
设想一个场景:
同时有请求A和请求B进行更新操作,那么会出现
(1)请求A更新了缓存
(2)请求B更新了缓存
(3)请求B更新了数据库
(4)请求A更新了数据库
这样请求A将旧值写入了数据库造成了脏写,并且数据库中可能永远无法恢复到真实的数据,缓存和数据库出现了数据不一致的现象。
2. 数据库更新不成功
如果数据库更新失败,而缓存更新成功,也会造成数据不一致现象。并且如果更新操作位于一个事务之中,事务发生回滚,也会造成数据不一致现象
3. 资源消耗
从业务场景来说,如果是一个写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能
经过上面的分析可以得知,先更新缓存再更新数据库肯定是不正确的,而且更新时主要问题是双写一致性的问题,也就是当更新缓存和数据库时数据不一致的问题。这也是缓存和数据库同步问题的主要方面。我们的主要目标也就是尽可能消除这个问题。
先做一个说明,从理论上来说,给缓存设置过期时间,是保证最终一致性的解决方案。这种方案下,我们可以对存入缓存的数据设置过期时间,所有的写操作以数据库为准,对缓存操作只是尽最大努力即可。
首先从分布式系统的角度看这个问题,可以看到Redis缓存和数据库组成一个分布式存储系统,既然是分布式系统就可以通过CAP理论去理解:
在一个分布式系统中,一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance)。CAP 原则指的是,这三个要素最多只能同时实现两点,不可能三者兼顾。必须要保证P那只能选择损失强一致性,或者损失可用性。因此也就诞生了两个解决问题的主要方案:
1. 对一致性要求比较高: 实时同步方案
尽可能的保证一致性,尽量对缓存和数据库都进行处理之后才返回,尽可能减少数据不一致的现象。适用场景:一般缓存场景
2. 对并发性要求比较高: 异步方案
尽可能提高响应速度,提高并发性,更新其中一个,然后异步更新另一个。比如先更新缓存,然后异步更新(比如采用消息队列)数据库。适用场景:如秒杀系统
下面主要介绍一下这两种方案的主要实现手段
实时同步方案,比较简单并且适用场景广泛,对于并发要求不高的场景都可以适用。并且在应用层就可以实现,关键在于操作数据库和缓存的顺序,可能影响数据的一致性
这个操作方法与之前先更新缓存再更新数据库的情况差不多,依然可能出现脏写问题,只不过此时脏写发生在缓存中,如果缓存设置了过期时间依然可以达到最终一致性。
(1)线程A更新了数据库
(2)线程B更新了数据库
(3)线程B更新了缓存
(4)线程A更新了缓存
不推荐使用。
既然如果更新数据库同时更新缓存总会出现脏写的问题,那直接删除缓存来强行达到数据一致性。但是操作顺序依然有影响,如果是先删除缓存再更新数据库,同样会出现数据不一致的场景:
(1)请求A进行写操作,删除缓存
(2)请求B查询发现缓存不存在
(3)请求B去数据库查询得到旧值
(4)请求B将旧值写入缓存
(5)请求A将新值写入数据库
又出现了我们熟悉的脏写缓存的问题,但是可以通过延迟双删的方法解决这个问题,伪代码如下:
public void write(String key,Object data){
redis.delKey(key); // 先淘汰缓存
db.updateData(data); // 再写数据库
Thread.sleep(1000); // 休眠一段时间,再次淘汰缓存
redis.delKey(key);
}
通过这种方式就可以有效的清楚脏写缓存的问题,可以通过延迟的淘汰缓存操作,淘汰掉脏写的缓存。
难点在于,延迟时间的确定,需要综合考量系统所需的吞吐量,系统响应时间,数据库执行时间。甚至可以异步执行第二个删除操作,开增加系统吞吐量。但是延迟时间的确定依然是经验的,因此产生了第三种操作方式:
这是最常用最常用的模型了也被称为Cache-Aside pattern。其具体逻辑如下:
查询:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
更新:先把数据存到数据库中,成功后,再让缓存失效。
这种情况难道不存在并发脏写问题吗?
不是的。假设这会有两个请求,一个请求A做查询操作,一个请求B做更新操作,那么会有如下情形产生:
(1)缓存刚好失效
(2)请求A查询数据库,得一个旧值
(3)请求B将新值写入数据库
(4)请求B删除缓存
(5)请求A将查到的旧值写入缓存
当然这种情况发生的前提是(3)要比(2)执行的更快才能保证,(4)比(5)执行的更快。但是实际情况写操作要比读操作慢很多。因此这种情况发生的概率很低。如果有人依然无法忍受这种极低概率情况的发生,那依然可以采用延迟双删的方法解决。
还有一个问题就是当删除缓存失败依然可能造成数据不一致问题。解决方案也很简单:提供一个保障的重试机制即可。比如通过消息队列异步执行删除缓存的命令。
作者强烈推荐这种方式
异步方案,需要并发要求比较高的场景,通常依赖其他一些中间件实现异步操作。在如今互联网应用并发要求比较高的场景,也是比较主流的解决方案。但是因为依赖其他中间件实现,系统的复杂度提高,可靠性降低。主要的实现方式可以通过消息队列,canal等中间件实现,本文主要介绍这两种大致的实现方式
消息队列是实现异步的一种重要方式,一般先通过更新缓存,然后将更新数据库的操作封装成消息队列异步执行。
是阿里开源的中间件可以完成订阅Mysql的binlog日志的功能,大致实现流程如下:
(1)更新数据库数据
(2)数据库会将操作信息写入binlog日志当中
(3)订阅程序提取出所需要的数据以及key
(4)另起一段非业务代码,获得该信息
(5)删除缓存操作
这样就可以实现先更新数据库然后异步更新/删除缓存
采用各种中间件的方式实现,需要
不管怎样,如果对强一致性要求非常高,那就可以不用考虑缓存了,老老实实的每次访问数据库操作就可以了。并且异步的方案在越来越多的大型应用中越来越多的实现,但是具体的实现细节还是需要不断的经验打磨。这作者就无能为力了,而且作为一个小菜鸡,我也是最近有感而发,学习之后总结出这篇文章,仅供学习和总结,如果有写的不对的地方希望大家批评指正。
欢迎评论交流,欢迎关注我这各菜鸡小博主》