卡飞资源网

专业编程技术资源共享平台

高并发架构实践:Redis与数据库一致性解决方案

对于数据库与缓存数据的一致性问题相信对于大部分开发人员来说并不陌生,也经常遇到。实际上,只要是我们使用到缓存,那么必然就会产生数据库与缓存间的数据不一致的问题。

既然是“必然产生数据不一致性问题”,那么我们在设计程序时就需要考虑这些问题:系统是否必须要求数据库与缓存间数据达到完全一致性(即强一致性)?系统是否能够接受一定时间下的数据不一致性,能够接受多少时间下的数据不一致性?

带着上面的问题,我们又可以将“一致性”分为如下两种形式:

  • 1)强一致性

它要求数据库与缓存间数据要达到完全一致性。要达到强一致性,就只能通过加锁将请求变成串行化执行,但这样系统的吞吐量也就大大降低了。很显然,这样并不是设计缓存的初衷,实际上也没有必要要求强一致性。

  • 2)弱一致性

也称为最终一致性,它能接受一定时间内的数据库与缓存间数据存在短暂的不一致性性,只要求最终的数据是一致的即可。这样既提高了系统的并发性与吞吐量,也能够保障了数据最终的一致性。

01 数据一致性问题

了解完上述的“一致性”问题,那么在什么情况下会导致数据库与缓存间数据的不一致问题呢?简单来讲,主要就是“并发请求与更新问题”与“更新/删除异常问题”两个问题所导致的,具体场景如下四种情况。

1.1 先更新数据库,再更新缓存

这种场景要求数据更新时,先更新数据库,待数据库更新成功之后,再更新缓存数据,如下图所示:

如上图所示,很显然,这种方式存在如下两个问题:

  • 1)如果数据库先更新成功了,还未对Redis缓存进行更新的间隙期间(即这个间隙期间数据库是更新后的新数据,而Redis缓存是更新前的旧数据)。如果在这个间隙期间有新的数据读取请求过来,则读到的都是Redis缓存的更新前的旧数据。
  • 2)如果数据库先更新成功了,再次更新Redis缓存时,数据更新失败了(即更新异常,这个时候数据库是更新后的新数据,而Redis缓存是更新前的旧数据)。如果这种更新异常情况发生,那么后面的所有数据读取请求读到的都是Redis的更新前的旧数据,这种情况可能要持续到缓存过期失效之后,才能请求到正确的数据。

1.2 先更新缓存,再更新数据库

这种场景要求数据更新时,先更新缓存,待缓存更新成功之后,再更新数据库,如下图所示:

如上图所示,这种方式相对于“先更新数据库,再更新缓存”看似可以解决读取旧数据的问题,但依然存在如下两个问题:

  • 1)如果Redis缓存先更新成功了,还未对数据库进行更新的间隙期间(即这个间隙期间数据库是更新前的旧数据,而Redis缓存是更新后的新数据),这个间隙期间会导致数据的不一致问题出现;
  • 2)如果Redis缓存先更新成功了,再次更新数据库时,数据更新失败了(即更新异常,这个时候数据库是更新前的旧数据,而Redis缓存是更新后的新数据),这同样会导致数据的不一致问题出现。

面对上面两种数据不一致的情况,如果这个时候有数据读取请求过来,那么读取到的都是Redis缓存未生效的新数据。这种胀读Redis缓存未生效的新数据会导致系统出现严重的后果,如遇到关联查询或者关联业务操作都会面临不可预知的一系列错误。

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

这种场景要求数据更新时,先更新数据库,待数据库更新成功之后,再删除缓存数据,如下图所示:

如上图所示,它看似可以解决“并发更新”的问题,但依然会存在下面的两个问题:

  • 1)如果数据库先更新成功了,还未对Redis缓存数据进行删除的间隙期间(即这个间隙期间数据库是更新后的新数据,而Redis缓存是更新前的旧数据)。如果在这个间隙期间有新的数据读取请求过来,则读到的都是Redis缓存的更新前的旧数据。
  • 2)如果数据库先更新成功了,再次删除Redis缓存数据时,数据删除失败了(即更新异常,这个时候数据库是更新后的新数据,而Redis缓存是更新前的旧数据)。如果这种删除异常情况发生,那么后面的所有数据读取请求读到的都是Redis的更新前的旧数据,这种情况可能要持续到缓存过期失效之后,才能请求到正确的数据。

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

这种场景要求数据更新时,先删除缓存数据,待缓存数据删除成功之后,再更新数据库,如下图所示:

如图上所示,相对于“先更新数据库,再删除缓存”,这种方式在没有高并发的情况下,是有可能保持数据一致性的。它解决了如下两个问题:

  • 1)如果Redis缓存先删除成功了,还未对数据库进行更新的间隙期间,此间隙期间的数据读取请求查询不到任何数据,也就不存在数据一致性的问题;
  • 2)如果Redis缓存先删除成功了,再次更新数据库时,数据更新失败了。同样,之后的数据读取请求查询不到任何数据,也就不存在数据一致性的问题。

尽管如此,但如果是处于读写并发的情况下,还是会出现数据不一致的情况,如下图所示:

如上图所示:

  • 1)请求A的数据写请求先执行将Redis缓存数据删除,删除成功后,但由于网络延迟原因,还没有来得及执行写数据库操作;
  • 2)在此间隙期间,请求B的数据读请求开始查询Redis缓存,发现Redis缓存没数据。接下来再继续请求查询数据库,数据库中有原来的旧数据。请求B获取数据库中原来的旧数据,并同时将其更新到Redis缓存中;
  • 3)此时,请求A网络延迟结束,把新数据写入数据库。这时,导致数据库与Redis缓存出现数据不一致问题。

其实,面对上面这些场景所导致的数据不一致性问题,在实际开发中没有十分完美的解决方案,只有根据自己的应用场景找到最适合方案。

一般使用场景简单且并发较低的情况,解决这些数据不一致性问题可以使用“传统的单删(即先删除缓存,再更新数据库)或者延时双删”就可以满足其要求;如果使用场景复杂、对并发要求较高的场景下,则就需要选择“消息队列异步重试、定时任务异步重试、Binlog日志订阅”等方案。

02 延时双删策略

在解决上面的单删(即先删除缓存,再更新数据库)所面临的“在读写并发进行时,会产生缓存是旧数据,而数据库是新数据”的数据不一致问题,我们就可以使用延时双删方案来进行解决。

顾名思义,延时双删就是指:在进行数据更新时,先删除Redis缓存中的数据,然后更新数据库的值。在更新完数据库值之后,我们可以让线程先休眠 一小段时间后再进行一次Redis缓存数据删除操作。有了休眠的这段时间,即使有其他线程从数据库中读取到旧的数据并重新更新到Redis缓存中,我们也能够将其再次删除,以保证Redis缓存中会是新的值。如下图所示:

现在,通过延时双删方案,我们再来分析下图的示例:

  • 1)请求A的数据写请求先执行将Redis缓存数据删除,删除成功后,但由于网络延迟原因,还没有来得及执行写数据库操作;
  • 2)在此间隙期间,请求B的数据读请求开始查询Redis缓存,发现Redis缓存没数据。接下来再继续请求查询数据库,数据库中有原来的旧数据。请求B获取数据库中原来的旧数据,并同时将其更新到Redis缓存中;
  • 3)此时,请求A网络延迟结束,把新数据写入数据库,完成数据库写入后进入休眠;
  • 4)休眠一段时间后,请求A再次删除Redis缓存数据,从而保证了数据的一致性。

对于延时双删方案,这个“延时”到底要延时多久才合适呢?

就如上面的例子,如果请求A再次删除Redis缓存数据太快(即在请求B将数据库中的旧值更新到缓存之前,就已经把缓存删除了),那么这次删除就没任何意义,也解决不了一致性问题。所以,这个休眠时间设置是关键所在。

但不幸的是,这确实很难给定一个准确答案,一般设置原则是“大于缓存的读写时间即可,又或者是大于从数据库读取数据+写入缓存的时间和即可”。实际上,这个时间只又在经过不断的压测和实际环境运行,才能够找到一个合理的预估时间,从而尽可能的去降低发生数据不一致性问题的概率。

03 消息队列异步重试删除策略

在“先更新数据库,再删除缓存”的场景中,我们就可以借助消息队列的重复消费功能来解决“缓存删除失败”的情况。如下图所示:

如上图所示,该策略的执行流程如下:

  • 1)先更新数据库数据,再删除缓存数据;
  • 2)当缓存数据删除失败时,将删除失败的缓存 Key 写入消息队列中;
  • 3)消费者程序从消息队列中获取删除失败的缓存 Key重新执行缓存删除操作;
  • 4)同时,通过消息队列ACK确认机制,确认消息被成功消费后删除此消息,若消费失败则按照所设置的消息队列重试策略进行重试删除操作。

该方案属于比较轻量级的异步重试策略,它优先尝试直接删除缓存数据,当缓存删除失败时才写入消息队列进行补偿重试处理,这样可以最大化地减少消息队列消息挤压的情况。

04 消息队列与日志订阅异步重试删除策略

该策略是通过使用阿里的开源组件Canal去订阅MySQL数据库的Binlog日志,当数据库发生数据变更时,我们就可以通过Binlog日志获取到相关数据的操作信息,然后再执行后续的相关操作。如下图所示:

如上图所示,该策略的执行流程如下:

  • 1)先更新数据库数据,在更新数据的同时,MySQL数据库会将数据更新写入Binlog日志中;
  • 2)通过Canal中间件去订阅与消费MySQL的Binlog日志,并解析提取日志中的更新数据,直接调用程序进行缓存数据删除操作;
  • 3)当缓存数据删除失败时,将删除失败的缓存 Key 写入消息队列中;
  • 4)消费者程序从消息队列中获取删除失败的缓存 Key重新执行缓存删除操作;
  • 5)同时,通过消息队列ACK确认机制,确认消息被成功消费后删除此消息,若消费失败则按照所设置的消息队列重试策略进行重试删除操作。

05 消息队列与日志订阅异步重试更新策略

该策略与上面一样需要借助Canal中间件来订阅与消费MySQL中的Binlog的日志,不同之处如下图所示:

如上图所示,该策略的执行流程如下:

  • 1)先更新数据库数据,在更新数据的同时,MySQL数据库会将数据更新写入Binlog日志中;
  • 2)通过Canal中间件去订阅与消费MySQL的Binlog日志,并解析提取日志中的更新数据,同时写入消息队列;
  • 3)消费者程序从消息队列中获取更新数据,根据更新数据信息再次请求数据库获取最新版本的数据。此做法是为了避免因为消息积压现象而导致延时较长,从而使消息中的数据成为“旧数据”,这样再请求数据库一次保证了数据的一致性;
  • 4)从数据库获取到数据之后,执行缓存数据更新操作;
  • 5)同时,通过消息队列ACK确认机制,确认消息被成功消费后删除此消息,若消费失败则按照所设置的消息队列重试策略进行重试更新操作。

该策略相对于上面的03与04两个策略,可以通过消息队列减少对数据库数据更新洪峰时的访问压力,同时也保证了因为消息延时导致的数据一致性问题。

控制面板
您好,欢迎到访网站!
  查看权限
网站分类
最新留言