如何保证缓存(redis)与数据库(MySQL)的一致性

【说明】
  对于热点数据(经常被查询,但不经常被修改的数据),我们可以将其放入redis缓存中,以增加查询效率,但需要保证从redis中读取的数据与数据库中存储的数据最终是一致的。本文基于“孤独烟”与“58沈剑”两位的文章,针对一致性的问题进行了汇总总结,两位的原文链接见文末。

【前言】

  客户端对数据库中的数据主要有两类操作,读(select)与写(DML)。针对放入redis中缓存的热点数据,当客户端想读取的数据在缓存中就直接返回数据,即命中缓存(cache hit),当读取的数据不在缓存内,就需要从数据库中将数据读入缓存,即未命中缓存(cache miss)。所以读操作并不会导致缓存与数据库中的数据不一致。
  对于写操作(DML),缓存与数据库中的内容都需要被修改,但两者的执行必定存在一个先后顺序,这可能会导致缓冲与数据库中的数据不再一致,此时主要需要考虑两个问题:
  1、执行顺序的问题:先更新缓存还是先更新数据库?
  2、更新缓存的策略问题:当缓存中的内容变化时,是选择修改缓存(update),还是直接淘汰缓存(delete)?

针对这两点问题,一共可以分为四种方案:
  1、先更新缓存,再更新数据库;
  2、先更新数据库,再更新缓存;
  3、先淘汰缓存,再更新数据库;
  4、先更新数据库,再淘汰缓存。

【疑问一】更新cache还是淘汰cache?

  我们先来讨论缓存更新的策略问题:即更新缓存时,是直接淘汰cache中的旧数据,还是将更新操作也放在缓存中进行?

淘汰cache:
优点:操作简单,无论更新操作是否复杂,直接将缓存中的旧值淘汰
缺点:淘汰cache后,下一次查询无法在cache中查到,会有一次cache miss,这时需要重新读取数据库
更新cache:
  更新chache的意思就是将更新操作也放到缓冲中执行,并不是数据库中的值更新后再将最新值传到缓存
优点:命中率高,直接更新缓存,不会有cache miss的情况
缺点:更新cache消耗较大
  当更新操作简单,如只是将这个值直接修改为某个值时,更新cache与淘汰cache的消耗差不多
  但当更新操作的逻辑较复杂时,需要涉及到其它数据,如用户购买商品付款时,需要考虑打折等因素,这样需要缓存与数据库进行多次交互,将打折等信息传入缓存,再与缓存中的其它值进行计算才能得到最终结果,此时更新cache的消耗要大于直接淘汰cache
所以选择直接淘汰缓存更好,如果之后需要再次读取这个数据,最多会有一次缓存失败

【更新cache的另一个问题】
  我们现在已经知道直接淘汰cache比更新cache要更好,现在再进一步思考下更新cache的其它问题。
  对于上文列举的四种方案的前两种,即:
    1、先更新(update)缓存,再更新数据库;
    2、先更新数据库,再更新(update)缓存;
  当并发较大,同时有两个线程需要对同一个数据进行更新时,可能会出现以下问题:
方案一、先更新(update)缓存,再更新数据库
  线程A更新了缓存
  线程B更新了缓存
  线程B更新了数据库
  线程A更新了数据库
方案二、先更新数据库,再更新(update)缓存
  线程A更新了数据库
  线程B更新了数据库
  线程B更新了缓存
  线程A更新了缓存
如果不同的线程对同一个数据进行更新时,更新的先后顺序有明确要求,那么上述两种方案都会导致数据的不一致
解决的思路是“串行化”,即对同一个数据的修改,要以串行化的方式先后执行

结论:更新cache的消耗更大,且很有可能造成数据的不一致,所以推荐直接淘汰cache

【疑问二】执行顺序的问题

  究竟是先淘汰缓存还是先更新数据库?
这里主要分为两个方面来考虑:
  1、更新数据库与淘汰缓存是两个步骤,只能先后执行,如果在执行过程中后一步执行失败,哪种方案的影响最小?
  2、如果不考虑执行失败的情况,但更新数据库与淘汰缓存必然存在一个先后顺序,在上一个操作执行完毕,下一个操作还未完成时,如果并发较大,仍旧会导致数据库与缓存中的数据不一致,在这种情况下,用哪种方案影响最小?

另外,对于数据库而言,读写操作可以只作用在同一台服务器上,即底层只有一个数据库,也可以将读操作放在从库,写操作放在主库,即底层是主从架构,对于主从架构还需要考虑主从延迟,本文针对的是单节点模式。

【数据库是单节点】

情景一:更新数据库与淘汰缓存需要先后执行,如果在执行过程中后一步执行失败,哪种方案对业务的影响最小?
  方案一、先淘汰缓存,再更新数据库
如果第一步淘汰缓存成功,第二步更新数据库失败,此时再次查询缓存,最多会有一次cache miss
  方案二、先更新数据库,再淘汰缓存
如果第一步更新数据库成功,第二部淘汰缓存失败,则会出现数据库中是新数据,缓存中是旧数据,即数据不一致
解决办法:为确保缓存删除成功,需要用到“重试机制”,即当删除缓存失效后,返回一个错误,由业务代码再次重试,直到缓存被删除。

但对于方案一,如果更新数据库失败其实也是一个问题,为了确保数据库中的数据被正常更新,也需要“重试机制”,即当数据库中的数据更新失败后,也需要人工或业务代码再次重试,直到更新成功。

【结论】总体而言,虽然方案二导致数据不一致的可能性更大,但在业务中,无论是淘汰缓存还是更新数据库,我们都需要确保它们真正完成了,所以个人认为在情景一下两种方案并没有什么优劣之分。

重试机制的原理图:
如何保证缓存(redis)与数据库(MySQL)的一致性

情景二:假设没有操作会执行失败,但执行前一个操作后无法立即完成下一个操作,在并发较大的情况下,可能会导致数据不一致。此时,哪种方案对业务的影响最小?

方案一、先淘汰缓存,再更新数据库

1、在正常情况下,A、B两个线程先后对同一个数据进行读写操作:
  A线程进行写操作,先淘汰缓存,再更新数据库
  B线程进行读操作,发现缓存中没有想要的数据,从数据库中读取更新后的新数据
此时没有问题
2、在并发量较大的情况下,采用同步更新缓存的策略:
  A线程进行写操作,先成功淘汰缓存,但由于网络或其它原因,还未更新数据库或正在更新
  B线程进行读操作,发现缓存中没有想要的数据,从数据库中读取数据,但此时A线程还未完成更新操作,所以读取到的是旧数据,并且B线程将旧数据放入缓存。注意此时是没有问题的,因为数据库中的数据还未完成更新,所以数据库与缓存此时存储的都是旧值,数据没有不一致
  在B线程将旧数据读入缓存后,A线程终于将数据更新完成,此时是有问题的,数据库中是更新后的新数据,缓存中是更新前的旧数据,数据不一致。如果在缓存中没有对该值设置过期时间,旧数据将一直保存在缓存中,数据将一直不一致,直到之后再次对该值进行修改时才会在缓存中淘汰该值
此时可能会导致cache与数据库的数据一直或很长时间不一致

3、在并发量较大的情况下,采用异步更新缓存的策略:
  A线程进行写操作,先成功淘汰缓存,但由于网络或其它原因,还未更新数据库或正在更新
  B线程进行读操作,发现缓存中没有想要的数据,从数据库中读取数据,但B线程只是从数据库中读取想要的数据,并不将这个数据放入缓存中,所以并不会导致缓存与数据库的不一致
  A线程更新数据库后,通过订阅binlog来异步更新缓存
此时数据库与缓存的内容将一直都是一致的

进一步分析:
如果采取同步更新缓存的策略,即如果缓存中没有数据,就读取数据库并将数据直接放入缓存,可能会导致数据长时间的不一致
在这种情况下,可以用一些方法来进行优化:
1、用串行化的思路
  即保证对同一个数据的读写严格按照先后顺序串行化进行,避免并发较大的情况下,多个线程同时对同一数据进行操作时带来的数据不一致性。
  关于如何用串行化保证一致性,详见“58沈剑”的文章“缓存与数据库一致性保证”,原文链接见文末。
2、延时双删+设置缓存的超时时间
  不一致的原因是,在淘汰缓存之后,旧数据再次被读入缓存,且之后没有淘汰策略,所以解决思路就是,在旧数据再次读入缓存后,再次淘汰缓存,即淘汰缓存两次(延迟双删)
引入延时双删后,执行步骤变为下面这种情形:
  A线程进行写操作,先成功淘汰缓存,但由于网络或其它原因,还未更新数据库或正在更新
  B线程进行读操作,从数据库中读入旧数据,共耗时N秒
  在B线程将旧数据读入缓存后,A线程将数据更新完成,此时数据不一致
  A线程将数据库更新完成后,休眠M秒(M比N稍大即可),然后再次淘汰缓存,此时缓存中即使有旧数据也会被淘汰,此时可以保证数据的一致性
  其它线程进行读操作时,缓存中无数据,从数据库中读取的是更新后的新数据

利用延迟双删,可以很好的解决数据不一致的问题,其中A线程休眠的M秒,需要根据业务上读取的时间来衡量,只要比正常读取消耗的实际稍大就可以。但是个人感觉实际业务中需要根据场景来设置休眠的时间,这个不好确定。

引入延时双删后,存在两个新问题:
  1、A线程需要在更新数据库后,还要休眠M秒再次淘汰缓存,等所有操作都执行完,这一个更新操作才真正完成,降低了更新操作的吞吐量
解决办法:用“异步淘汰”的策略,将休眠M秒以及二次淘汰放在另一个线程中,A线程在更新完数据库后,可以直接返回成功而不用等待。
  2、如果第二次缓存淘汰失败,则不一致依旧会存在
解决办法:用“重试机制”,即当二次淘汰失败后,报错并继续重试,直到执行成功个人

“异步淘汰”策略:
如何保证缓存(redis)与数据库(MySQL)的一致性
A线程执行完步骤2不再休眠Ms,而是往消息总线esb发送一个消息,发送完成之后马上就能返回

【小结】
在单节点下,用“先删缓存,再更新”的策略,如果采用同步更新缓存的策略,可能会导致数据长时间的不一致,可以通过一些方法来尽量避免不一致;如果采用异步更新缓存的策略,就不会导致数据不一致

方案二、先更新数据库,再淘汰缓存

在正常情况下:
  A线程进行写操作,更新数据库,淘汰缓存
  B线程进行读操作,从数据库中读取新的数据
不会有问题

在并发较大的情况下,情形1:
  A线程进行写操作,更新数据库,还未淘汰缓存
  B线程从缓存中可以读取到旧数据,此时数据不一致
  A线程完成淘汰缓存操作
  其它线程进行读操作,从数据库中读入最新数据,此时数据一致
不过这种情况并没有什么大问题,因为数据不一致的时间很短,数据最终是一致的

在并发较大的情况下,情形2:
  A线程进行写操作,更新数据库,但更新较慢,缓存也未淘汰
  B线程进行读操作,读取了缓存中的旧数据
但这种情况没什么问题,毕竟更新操作都还未完成,数据库与缓存中都是旧数据,没有数据不一致

在并发较大的情况下,情形3:
  A线程进行读操作,缓存中没有相应的数据,将从数据库中读数据到缓存,
此时分为两种情况,还未读取数据库的数据,已读取数据库的数据,不过由于网络等问题数据还未传输到缓存
  B线程执行写操作,更新数据库,淘汰缓存
  B线程写操作完成后,A线程才将数据库的数据读入缓存,对于第一种情况,A线程读取的是B线程修改后的新数据,没有问题,对于第二种情况,A线程读取的是旧数据,此时数据会不一致
不过这种情况发生的概率极低,因为一般读操作要比写操作要更快
万一担心存在这种可能,可以用“延迟双删”策略,在A线程读操作完成后再淘汰一次缓存

【小结】
在该方案下,无论是采用同步更新缓存(从数据库读取的数据直接放入缓存中),还是异步更新缓存(数据库中的数据更新完成后,再将数据同步到缓存中),都不会导致数据的不一致
该方案主要只需要担心一个问题:如果第二步淘汰缓存失败,则数据会不一致
解决办法之前也提到过,用“重试机制”就可以,如果淘汰缓存失败就报错,然后重试直到成功

【单节点下两种方案对比】
先淘汰cache,再更新数据库:
  采用同步更新缓存的策略,可能会导致数据长时间不一致,如果用延迟双删来优化,还需要考虑究竟需要延时多长时间的问题——读的效率较高,但数据的一致性需要靠其它手段来保证
  采用异步更新缓存的策略,不会导致数据不一致,但在数据库更新完成之前,都需要到数据库层面去读取数据,读的效率不太好——保证了数据的一致性,适用于对一致性要求高的业务
先更新数据库,再淘汰cache:
  无论是同步/异步更新缓存,都不会导致数据的最终不一致,在更新数据库期间,cache中的旧数据会被读取,可能会有一段时间的数据不一致,但读的效率很好——保证了数据读取的效率,如果业务对一致性要求不是很高,这种方案最合适

【其它】
重试机制可以采利用“消息队列MQ”来实现
通过订阅binlog来异步更新缓存,可以通过canal中间件来实现

原文链接:
【58沈剑原文链接】
缓存架构设计细节二三事2016-03-08
缓存与数据库一致性保证2016-03-16
主从DB与cache一致性 2016-03-24
缓存,究竟是淘汰,还是修改?2018-07-02
究竟先操作缓存,还是数据库? 2018-07-09
Cache Aside Pattern 2018-07-11
缓存与数据库不一致,咋办? 2018-07-12

【孤独烟原文链接】
分布式之数据库和缓存双写一致性方案解析 2018-05-15
分布式之数据库和缓存双写一致性方案解析(二) 2018-06-28
分布式之数据库和缓存双写一致性方案解析(三)2018-07-13

上一篇:阿里云ECS服务器使用体验


下一篇:gearman实现redis缓存mysql