文章目录
一、同时更新数据库与缓存
当数据发生更新时,我们不仅要操作数据库,还要一并操作缓存。具体操作就是,修改一条数据时,不仅要更新数据库,也要连带缓存一起更新。
在这个过程中不能保证 数据库 与 缓存 中的数据都能成功更新!因此会出现数据不一致的问题。
有两种更新方式。
1. 先更新缓存再更新数据库
如果缓存更新成功了,但数据库更新失败,那么此时缓存中是最新值,但数据库中是旧值。
虽然此时读请求可以命中缓存,拿到正确的值,但是,一旦缓存失效,就会从数据库中读取到旧值,重建缓存也是这个旧值。
2. 先更新数据库再更新缓存
如果数据库更新成功了,但缓存更新失败,那么此时数据库中是最新值,缓存中是旧值。
之后的读请求读到的都是旧数据,只有当缓存失效后,才能从数据库中得到正确的值。
可见,无论谁先谁后,但凡后者发生异常,就会出现数据不一致的情况
并发问题
假设我们采用先更新数据库,再更新缓存的方案,并且两步都可以成功执行的前提下,如果存在并发,情况会是怎样的呢?
缓存 | 数据库 | 线程 | 操作 |
---|---|---|---|
o | a | A | 修改数据库 |
o | b | B | 修改数据库 |
b | b | B | 修改缓存 |
a | b | A | 修改缓存 |
最终在缓存中是 a,在数据库中是 b,发生不一致。
也就是说,A 虽然先于 B 发生,但 B 操作数据库和缓存的时间,却要比 A 的时间短,执行时序发生错乱,最终这条数据结果是不符合预期的。
除此之外,我们从缓存利用率的角度来看也是不太推荐的。
这是因为每次数据发生变更,都更新缓存,但是缓存中的数据不一定会被马上读取,这就会导致缓存中可能存放了很多不常访问的数据,浪费缓存资源。
而且很多情况下,写到缓存中的值,并不是与数据库中的值一一对应的,很有可能是先查询数据库,再经过一系列计算得出一个值,才把这个值才写到缓存中。
由此可见,这种更新数据库 + 更新缓存的方案,不仅缓存利用率不高,还会造成机器性能的浪费。
二、删除缓存
1. 先删除缓存,后更新数据库
A 写,B 读
缓存 | 数据库 | 线程 | 操作 |
---|---|---|---|
- | o | A | 删除缓存 |
- | o | B | 读取缓存为空 |
- | o | B | 读取数据库为o |
- | a | A | 修改数据库 |
o | a | B | 写入缓存o |
可见并发下,存在读写数据不一致情况
2. 先更新数据库,后删除缓存
A 读,B 写
缓存 | 数据库 | 线程 | 操作 |
---|---|---|---|
- | o | - | 缓存过期 |
- | o | A | 读取数据库为o |
- | o | B | 读取缓存为o |
- | b | B | 修改数据库 |
b | b | B | 写入缓存 |
o | b | A | 写入缓存o |
可见并发下,也会出现读写数据不一致的情况
在实际情况下,写的速度是远慢于读的,因此这种不一致出现的概率非常低。可以考虑延迟双删(删除缓存)方式来尝试解决。
如果数据库更新成功,缓存删除失败如何解决?
缓存删除重试,引入消息队列,将重试请求写到消息队列中,然后由专门的消费者来重试,直到成功。
因为消息队列的特性,正好符合我们的需求:
- 消息队列保证可靠性:写到队列中的消息,成功消费之前不会丢失(重启项目也不担心)
- 消息队列保证消息成功投递:下游从队列拉取消息,成功消费后才会删除消息,否则还会继续投递消息给消费者(符合我们重试的场景)
至于写队列失败和消息队列的维护成本问题:
- 写队列失败:操作缓存和写消息队列,「同时失败」的概率其实是很小的
- 维护成本:我们项目中一般都会用到消息队列,维护成本并没有新增很多
所以,引入消息队列来解决这个问题,是比较合适的。
现有的流行解决方案:订阅数据库变更日志,再操作缓存。
我们的业务应用在修改数据时,只需修改数据库,无需操作缓存。
通过订阅数据库的变更日志(Binlog),拿到具体操作的数据然后再根据这条数据去删除对应的缓存
订阅变更日志,目前也有了比较成熟的开源中间件,例如阿里的 canal,使用这种方案的优点在于:
- 无需考虑写消息队列失败情况:只要写 MySQL 成功,Binlog 肯定会有
- 自动投递到下游队列:canal 自动把数据库变更日志投递给下游的消息队列
我们可以得出结论,想要保证数据库和缓存一致性,推荐采用先更新数据库,再删除缓存方案,并配合消息队列或订阅变更日志的方式来做。应对高并发可能出现的旧数据回写缓存问题可以通过延迟双删来优化。
总结
-
性能和一致性不能同时满足,为了性能考虑,通常会采用最终一致性的方案
-
掌握缓存和数据库一致性问题,核心问题有 3 点:缓存利用率、并发、缓存 + 数据库一起成功问题
-
失败场景下要保证一致性,常见手段就是重试,同步重试会影响吞吐量,所以通常会采用异步重试的方案
-
订阅变更日志的思想,本质是把权威数据源(例如 MySQL)当做 leader 副本,让其它异质系统(例如 Redis)成为它的 follower 副本,通过同步变更日志的方式,保证 leader 和 follower 之间保持一致
如果需要强一致性则需要引入分布式锁等机制,这样将会大大影响性能,让缓存丢失了本该拥有的高性能