42.缓存和 DB 的一致性如何保证?

产生原因

主要有两种情况,会导致缓存和 DB 的一致性问题:

  1. 并发的场景下,导致读取老的 DB 数据,更新到缓存中。

    这里,主要指的是,更新 DB 数据之前,先删除 Cache 的数据。在低并发量下没什么问题,但是在高并发下,就会存在问题。在(删除 Cache 的数据, 和更新 DB 数据)时间之间,恰好有一个请求,我们如果使用被动读,因为此时 DB 数据还是老的,又会将老的数据写入到 Cache 中。

  2. 缓存和 DB 的操作,不在一个事务中,可能只有一个 DB 操作成功,而另一个 Cache 操作失败,导致不一致。

当然,有一点我们要注意,缓存和 DB 的一致性,我们指的更多的是最终一致性。我们使用缓存只要是提高读操作的性能,真正在写操作的业务逻辑,还是以数据库为准。例如说,我们可能缓存用户钱包的余额在缓存中,在前端查询钱包余额时,读取缓存,在使用钱包余额时,读取数据库。

解决方案

在开始说解决方案之前,胖友先看看如下几篇文章,可能有一丢丢多,保持耐心。

下面,我们就来看看几种方案。当然无论哪种方案,比较重要的就是解决两个问题:

  • 1、将缓存可能存在的并行写,实现串行写。

    注意,这里指的是缓存的并行写。在被动读中,如果缓存不存在,也存在写。

  • 2、实现数据的最终一致性。

1)先淘汰缓存,再写数据库

因为先淘汰缓存,所以数据的最终一致性是可以得到有效的保证的。为什么呢?先淘汰缓存,即使写数据库发生异常,也就是下次缓存读取时,多读取一次数据库。

但是,这种方案会存在缓存和 DB 的数据会不一致的情况,《缓存与数据库一致性优化》 已经说了。

那么,我们需要解决缓存并行写,实现串行写。比较简单的方式,引入分布式锁。

  • 在写请求时,先淘汰缓存之前,先获取该分布式锁。
  • 在读请求时,发现缓存不存在时,先获取分布式锁。

这样,缓存的并行写就成功的变成串行写落。实际上,就是 「如果避免缓存”击穿”的问题?」 的【方案一】互斥锁的加强版。

整体执行,如下草图:

草图

草图

  • 写请求时,是否主动更新缓存,根据自己业务的需要,是否有,都没问题。

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

按照“先写数据库,再更新缓存”,我们要保证 DB 和缓存的操作,能够在“同一个事务”中,从而实现最终一致性。

基于定时任务来实现

  • 首先,写入数据库。
  • 然后,在写入数据库所在的事务中,插入一条记录到任务表。该记录会存储需要更新的缓存 KEY 和 VALUE 。
  • 【异步】最后,定时任务每秒扫描任务表,更新到缓存中,之后删除该记录。

基于消息队列来实现

  • 首先,写入数据库。
  • 然后,发送带有缓存 KEY 和 VALUE 的事务消息。此时,需要有支持事务消息特性的消息队列,或者我们自己封装消息队列,支持事务消息。
  • 【异步】最后,消费者消费该消息,更新到缓存中。

这两种方式,可以进一步优化,可以先尝试更新缓存,如果失败,则插入任务表,或者事务消息。

另外,极端情况下,如果并发写执行时,先更新成功 DB 的,结果后更新缓存,如下图所示:

草图

草图

  • 理论来说,希望的更新缓存顺序是,线程 1 快于线程 2 ,但是实际线程1 晚于线程 2 ,导致数据不一致。
  • 可能胖友会说,图中不是基于定时任务或消息队列来实现异步更新缓存啊?答案是一直的,如果网络抖动,导致【插入任务表,或者事务消息】的顺序不一致。
  • 那么怎么解决呢?需要做如下三件事情:
    • 1、在缓存值中,拼接上数据版本号或者时间戳。例如说:value = {value: 原值, version: xxx}
    • 2、在任务表的记录,或者事务消息中,增加上数据版本号或者时间戳的字段。
    • 3、在定时任务或消息队列执行更新缓存时,先读取缓存,对比版本号或时间戳,大于才进行更新。? 当然,此处也会有并发问题,所以还是得引入分布式锁或 CAS 操作。
      • 关于 Redis 分布式锁,可以看看 《企业题库 -数据库-Redis》 的 「如何使用 Redis 实现分布式锁?」 问题。
      • 关于 Redis CAS 操作,可以看看 《企业题库 -数据库-Redis》 的 「什么是 Redis 事务?」 问题。

3)基于数据库的 binlog 日志

如下内容,引用自 《技术专题讨论第五期:论系统架构设计中缓存的重要性》 文章,超哥对这个问题的回答。

binlog 方案

binlog 方案

  • 应用直接写数据到数据库中。
  • 数据库更新binlog日志。
  • 利用Canal中间件读取binlog日志。
  • Canal借助于限流组件按频率将数据发到MQ中。
  • 应用监控MQ通道,将MQ的数据更新到Redis缓存中。

可以看到这种方案对研发人员来说比较轻量,不用关心缓存层面,而且这个方案虽然比较重,但是却容易形成统一的解决方案。


当然,以上种种方案,各有其复杂性,如果胖友心里没底,还是仅仅使用如下任一方案:

  • 先淘汰缓存,再写数据库”的方案,并且无需引入分布式锁。

    沈剑大佬,比较支持这种方案,见 《缓存架构设计细节二三事》

  • 先写数据库,再更新缓存”的方案,并且无需引入定时任务或者消息队列。

    左耳朵耗子,比较支持这种方案,《缓存更新的套路》

原因如下:

FROM 基友老梁的总结

使用缓存过程中,经常会遇到缓存数据的不一致性和脏读现象。一般情况下,采取缓存双淘汰机制,在更新数据库的淘汰缓存。此外,设定超时时间,例如三十分钟。

极端场景下,即使有脏数据进入缓存,这个脏数据也最存在一段时间后自动销毁。

  • 重点,是最后一句话哟。
  • 真的,和几个朋友沟通了下,真的出现不一致的情况,靠缓存过期后,重新从 DB 中读取即可。

另外,在 DB 主从架构下,方案会更加复杂。详细可以看看 《主从 DB 与 cache 一致性优化》

这是一道相对复杂的问题,重点在于理解为什么产生不一致的原因,然后针对这个原因去解决。