42.缓存和 DB 的一致性如何保证?
产生原因
主要有两种情况,会导致缓存和 DB 的一致性问题:
并发的场景下,导致读取老的 DB 数据,更新到缓存中。
这里,主要指的是,更新 DB 数据之前,先删除 Cache 的数据。在低并发量下没什么问题,但是在高并发下,就会存在问题。在(删除 Cache 的数据, 和更新 DB 数据)时间之间,恰好有一个请求,我们如果使用被动读,因为此时 DB 数据还是老的,又会将老的数据写入到 Cache 中。
缓存和 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 事务?」 问题。
- 1、在缓存值中,拼接上数据版本号或者时间戳。例如说:
3)基于数据库的 binlog 日志
如下内容,引用自 《技术专题讨论第五期:论系统架构设计中缓存的重要性》 文章,超哥对这个问题的回答。
binlog 方案
- 应用直接写数据到数据库中。
- 数据库更新binlog日志。
- 利用Canal中间件读取binlog日志。
- Canal借助于限流组件按频率将数据发到MQ中。
- 应用监控MQ通道,将MQ的数据更新到Redis缓存中。
可以看到这种方案对研发人员来说比较轻量,不用关心缓存层面,而且这个方案虽然比较重,但是却容易形成统一的解决方案。
当然,以上种种方案,各有其复杂性,如果胖友心里没底,还是仅仅使用如下任一方案:
“先淘汰缓存,再写数据库”的方案,并且无需引入分布式锁。
沈剑大佬,比较支持这种方案,见 《缓存架构设计细节二三事》 。
“先写数据库,再更新缓存”的方案,并且无需引入定时任务或者消息队列。
左耳朵耗子,比较支持这种方案,《缓存更新的套路》。
原因如下:
FROM 基友老梁的总结
使用缓存过程中,经常会遇到缓存数据的不一致性和脏读现象。一般情况下,采取缓存双淘汰机制,在更新数据库的前淘汰缓存。此外,设定超时时间,例如三十分钟。
极端场景下,即使有脏数据进入缓存,这个脏数据也最存在一段时间后自动销毁。
- 重点,是最后一句话哟。
- 真的,和几个朋友沟通了下,真的出现不一致的情况,靠缓存过期后,重新从 DB 中读取即可。
另外,在 DB 主从架构下,方案会更加复杂。详细可以看看 《主从 DB 与 cache 一致性优化》 。
这是一道相对复杂的问题,重点在于理解为什么产生不一致的原因,然后针对这个原因去解决。