如何保证缓存和数据的双写一致性

时间:2021-5-25 作者:qvyue

引言

为什么写这篇文章?

首先,缓存由于其高并发和高性能的特性,已经在项目中被广泛使用。在读取缓存方面,大家没啥疑问,都是按照下图的流程来进行业务操作。

如何保证缓存和数据的双写一致性
image

但是在更新缓存方面,对于更新完数据库,是更新缓存呢,还是删除缓存。又或者是先删除缓存,再更新数据库,其实大家存在很大的争议。目前没有一篇全面的博客,对这几种方案进行解析。于是博主战战兢兢,顶着被大家喷的风险,写了这篇文章。

文章结构

本文由以下三个部分组成 1、讲解缓存更新策略 2、对每种策略进行缺点分析 3、针对缺点给出改进方案

正文

先做一个说明,从理论上来说,给缓存设置过期时间,是保证最终一致性的解决方案。这种方案下,我们可以对存入缓存的数据设置过期时间,即使本次写入缓存的数据是脏数据,只要到达过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓存。因此,接下来讨论的思路不依赖于给缓存设置过期时间这个方案。 在这里,我们讨论三种更新策略:

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

为什么没有先更新缓存,再更新数据库这种策略?因为如果更新缓存成功,但是数据库更新失败,将会导致数据不一致了。

那么第二种方案为什么可以呢?比如删除缓存成功,但更新数据失败了,此时并不会造成数据的不一致,因为只是单纯的删除了缓存而已,下次获取数据时重写写入缓存即可。

(1)先更新数据库,再更新缓存

这套方案,大家是普遍反对的。为什么呢?有如下两点原因。

原因一(线程安全角度) 同时有请求A和请求B进行更新操作,那么会出现

  • (1)线程A更新了数据库
  • (2)线程B更新了数据库
  • (3)线程B更新了缓存
  • (4)线程A更新了缓存

这就出现请求A更新缓存应该比请求B更新缓存早才对,B却比A更早更新了缓存。这就导致了脏数据,因此不考虑。

正常情况下更新缓存肯定比写入数据库速度快,如果出现上面这种场景,表明2,3速度大于4。出现这种情况,只有一种原因就是线程A更新缓存时由于网络原因阻塞,导致线程B先执行完,出现这种情况的概率还是很小的。

原因二(业务场景角度) 有如下两点:

  • (1)如果你是一个写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能。
  • (2)如果你写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。显然,删除缓存更为适合。

接下来讨论的就是争议最大的,先删缓存,再更新数据库。还是先更新数据库,再删缓存的问题。

解决方案

原因一说的线程安全问题,是由于数据没有加锁导致的,所以我们可以将“先更新数据库后再更新缓存”操作写入到事务内,这样在更新数据时就会对数据加排他锁,只有本事务结束后才可以释放锁,加事务后肯定会降低系统的吞吐量。

(2)先删缓存,再更新数据库

该方案会导致不一致的原因是。同时有一个请求A进行更新操作,另一个请求B进行查询操作。那么会出现如下情形:

  • (1)请求A进行写操作,删除缓存
  • (2)请求B查询发现缓存不存在
  • (3)请求B去数据库查询得到旧值
  • (4)请求B将旧值写入缓存
  • (5)请求A将新值写入数据库 上述情况就会导致不一致的情形出现。而且,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据

正常情况下,写数据库时间肯定大于读数据库时间,2和4步操作是缓存中操作速度很快,第3步又是读库,所以2,3,4操作比第5步操作快的场景是很容易发生的,也就是上面描述的这种场景是很容易发生的。

那么,如何解决呢?采用延时双删策略 伪代码如下

public void write(String key,Object data){
        redis.delKey(key);
        db.updateData(data);
        Thread.sleep(1000);
        redis.delKey(key);
    }

转化为中文描述就是

  • (1)先淘汰缓存
  • (2)再修改数据库(这两步和原来一样)
  • (3)休眠1秒,再次淘汰缓存 这么做,可以将1秒内所造成的缓存脏数据再次删除。

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

针对上面的情形,读者应该自行评估自己的项目的读数据业务逻辑的耗时。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上,加几百ms即可。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。

如果你用了mysql的读写分离架构怎么办?

ok,在这种情况下,同样会造成数据不一致的情况,造成数据不一致的原因如下,还是两个请求,一个请求A进行更新操作,另一个请求B进行查询操作。

  • (1)请求A进行写操作,删除缓存
  • (2)请求A将数据写入数据库了,
  • (3)请求B查询缓存发现,缓存没有值
  • (4)请求B去从库查询,这时,还没有完成主从同步,因此查询到的是旧值
  • (5)请求B将旧值写入缓存
  • (6)数据库完成主从同步,从库变为新值 上述情形,就是数据不一致的原因。

解决方案还是使用延时双删策略。只是,睡眠时间修改为在主从同步的延时时间基础上,加几百ms。

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

ok,那就将第二次删除作为异步的。自己起一个线程,异步删除。这样,写的请求就不用沉睡一段时间后了,再返回。这么做,加大吞吐量。

第二次删除,如果删除失败怎么办?

这是个非常好的问题,如果第二次删除缓存失败,将会出现缓存和数据库不一致的问题,脏数据将会一直存在。 如何解决呢?
可以将第二次删除操作放到队列中,有俩个原因,第一队列可以满足异步的要求,其次队列有重试机制,可以保证最终删除成功。

(3)先更新数据库,再删缓存

首先,先说一下。老外提出了一个缓存更新套路,名为《Cache-Aside pattern》。其中就指出

  1. 失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
  2. 命中:应用程序从cache中取数据,取到后返回。
  3. 更新:先把数据存到数据库中,成功后,再让缓存失效。

另外,知名社交网站facebook也在论文《Scaling Memcache at Facebook》中提出,他们用的也是先更新数据库,再删缓存的策略。

这种情况不存在并发问题么?

不是的。肯定也是会有的并发问题的,有以下俩种情况

1. 数据库为读写分离架构
在这种情况下,同样会造成数据不一致的情况,造成数据不一致的原因如下,还是两个请求,一个请求A进行更新操作,另一个请求B进行查询操作。

(1)请求A进行写操作,更新数据
(2)请求A删除缓存
(3)请求B查询缓存发现,缓存没有值
(4)请求B去从库查询,这时,还没有完成主从同步,因此查询到的是旧值
(5)请求B将旧值写入缓存
(6)数据库完成主从同步,从库变为新值 上述情形,就是数据不一致的原因。

解决方法:和上述的方法一样,采用异步延迟双写策略,在A请求删除完缓存之后,在短暂延迟之后在进行一次清楚缓存。

2. 极端情况
假设这会有两个请求,一个请求A做查询操作,一个请求B做更新操作,那么会有如下情形产生

(1)缓存刚好失效

(2)请求A查询数据库,得一个旧值

(3)请求B将新值写入数据库

(4)请求B删除缓存

(5)请求A将查到的旧值写入缓存 ok,如果发生上述情况,确实是会发生脏数据。

然而,发生这种情况的概率又有多少呢?

发生上述情况有一个先天性条件,就是步骤(3)的写数据库操作和步骤(4)删除缓存操作执行速度要大于第(5)步的写缓存操作。

我们知道写数据库的操作耗时肯定大于读数据库的操作,更何况第(5)步是在缓存中操作,那么出现这种极端情况只有一种可能请求A在写入缓存时由于网络问题遇到阻塞,导致3,4步率先执行。

如何解决上述并发问题?

首先,给缓存设有效时间是一种方案。其次,采用策略(2)里给出的异步延时删除策略,保证读请求完成以后,再进行删除操作。

延时删除失败怎么办

缓存更新策略(2)和缓存更新策略(3)都存在的延迟删除失败的这个问题,上面已经介绍过了,使用一个异步队列即可,下面详细介绍

如何解决? 提供一个保障的重试机制即可,这里给出两套方案。

方案一: 如下图所示

如何保证缓存和数据的双写一致性
image

流程如下所示

  • (1)更新数据库数据;
  • (2)缓存因为种种问题删除失败
  • (3)将需要删除的key发送至消息队列
  • (4)自己消费消息,获得需要删除的key
  • (5)继续重试删除操作,直到成功 然而,该方案有一个缺点,对业务线代码造成大量的侵入。于是有了方案二,在方案二中,启动一个订阅程序去订阅数据库的binlog,获得需要操作的数据。在应用程序中,另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作。 方案二
如何保证缓存和数据的双写一致性
image

流程如下图所示:

  • (1)更新数据库数据
  • (2)数据库会将操作信息写入binlog日志当中
  • (3)订阅程序提取出所需要的数据以及key
  • (4)另起一段非业务代码,获得该信息
  • (5)尝试删除缓存操作,发现删除失败
  • (6)将这些信息发送至消息队列
  • (7)重新从消息队列中获得该数据,重试操作。

备注说明:上述的订阅binlog程序在mysql中有现成的中间件叫canal,可以完成订阅binlog日志的功能。至于oracle中,博主目前不知道有没有现成中间件可以使用。另外,重试机制,博主是采用的是消息队列的方式。如果对一致性要求不是很高,直接在程序中另起一个线程,每隔一段时间去重试即可,这些大家可以灵活自由发挥,只是提供一个思路。

总结

针对第一种方案更新数据库后在更新缓存和第三方案更新数据库后在删除缓存进行比较,到底该使用哪种方案呢?:
这个问题的本质就是更新的数据是不是会被频繁访问到,是不是热点数据了。
如果数据是热点数据,且使用的是第三种方案更新数据库后在删除缓存,这样将会造成查询数据时由于没有查询到缓存而全部命中数据库,导致redis缓存雪崩,所以如果是热点数据,需要使用的是第一种方案更新数据后更新缓存,这种方案可以适合并发大的场景。
如果数据不是热点数据,我们则可以使用第三方案更新数据库后在删除缓存,这种方案可以避免多次写入缓存浪费性能。
如果你是一个写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案更新数据后更新缓存就会导致数据压根还没读到,缓存就被频繁的更新,浪费性能。

第一种方案更新数据库后在更新缓存适合高并发,热点数据的场景。
第三方案更新数据库后在删除缓存适合并发不高的普通场景。

给缓存加过期时间是解决数据一致性的最简单方法,该方法保证最终数据一致性。这种方法的关键是怎么设置过期时间,时间设置过长脏数据则会保留时间过长,时间设置过短则会造成频繁写入缓存,浪费性能。

参考:
https://zhuanlan.zhihu.com/p/59167071,本篇文章是根据这篇文章总结修改出来的。

声明:本文内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎发送邮件至:qvyue@qq.com 进行举报,并提供相关证据,工作人员会在5个工作日内联系你,一经查实,本站将立刻删除涉嫌侵权内容。