面试题
如何保证缓存与数据库的双写一致性?
面试官的心理
你只要用到缓存,就可能会涉及到缓存与数据库双存储双写,你只要是双写,就一定会有数据一致性的问题,那么你是如何解决一致性的问题
基础知识
让我们先从3种常用的缓存读写策略开始吧,先打好基础,而不是单纯的背诵会更好的应对面试的同时,去提升自己
下面介绍的3种常见的模式各有优劣,根据自己的面对的业务场景进行选择
Cache Aside Pattern 旁路缓存模式
Cache Aside Pattern 旁路缓存模式 ,可能是我们接触最多的一种缓存读写模式。
那我们来再熟悉一下这个模型的读写操作过程吧
写过程:
- 先更新db
- 然后直接删除缓存对应的数据
读过程:
- 先查询缓存中对应的数据,如果有则就直接返回
- 如果没有,则取查询数据库中对应的数据
- 查询到了数据库中对应的数据先返回
- 再更新缓存
对于Cache Aside Pattern 旁路缓存模式的追问
在写数据的过程中,可以先删除 cache ,后更新 db 么?
这个肯定是不可以的,为什么呢,我们举个例子就知道了。
假设现在业务A中来了两个请求(请求A和请求B) -> 请求A先删除了cache 并且准备更新db 看起来很正常吧 但是这个时候 请求B过来查询了cache 发现cache中没有这个数据 就会屁颠屁颠的去db读取数据 然后更新 cache ,在这个期间 请求A 可能还在更新数据库的工作中 就会导致 请求B更新了cache (为数据库的旧数据 而 db中的数据 又被请求A 更新为了新数据 。这样子就出现了db和cache中数据不一致的问题
该过程简单描述为:
请求 A 先把 cache 中的 A 数据删除 -> 请求 B 从 db 中读取数据 A->请求 A 再把 db 中的 A 数据更新
在写数据的过程中,先更新 db,后删除 cache 就没有问题了么?
理论上来说,一般是没有问题的。因为缓存的读写读写速度会比数据库的读写速度快很多
过程如下
请求 1 从 db 读数据 A-> 请求 2 更新 db 中的数据 A(此时缓存中无数据 A ,故不用执行删除缓存操作 ) -> 请求 1 将数据 A 写入 cache
Cache Aside Pattern 的缺陷
缺陷一 首次请求数据一定不存在cache的问题
这个很好解决,我们可以提前将热点key在业务上线前先预热,也就是先加载到cache中
缺陷二 对于写操作比较判断场景 会导致cache中的数据被频繁删除 会影响缓存命中率
对于这个解决办法我们需要根据不同的场景来分析
- 数据库和缓存强一致性: 我们可以在更新db的同时也更新cache,当然我们需要将读写上锁/分布式锁 来保证更新cache的时候 不会有线程去读取cache 同时也保证在更新cache时不存在线程安全的问题 这个方案会带来性能的急剧下降
- 数据库和缓存一致性要求没有这么高:更新的db的时候也同步更新cache ,但是这里我们需要给缓存加一个比较短的过期时间,这样子就可以保证数据即使不一致影响也不会这么大 但是会给用户带来一定的体验感下降
Read/Write Through Pattern 读写穿透
对于这个模式 ,我们在平常的业务开发中碰到的情况比较下,因为分布式Redis中不提供写数据库的操作。但是我们也需要来了解一下,万一以后用到了呢
我们也是先从读写操作下来认识这个模式
写:
- 先查cache,然后cache中没有的话,就直接更新db
- 然后cache中有该数据,则更新cache然后再有cache服务自己更新db(同步更新cache和db)
读:
- 从cache中读取数据如果有则直接返回
- 读取不到的话,先从db加载,写入到cache中后返回
Read/Write Through Pattern 实际上是 Cache Aside Pattern 的基础上进行了封装。Cache Aside Pattern 中,读操作下如果在cache中没有这个数据 则是从客户端自己负责将数据写入cache,而Read/Write Through Pattern是cache服务自己来写入缓存的,这对客户端是透明的
Write Behind Pattern 异步缓存写入
Write Behind Pattern 和 Read/Write Through Pattern 很相似,两者都是由 cache 服务来负责 cache 和 db 的读写。
但是,两个又有很大的不同:Read/Write Through 是同步更新 cache 和 db,而 Write Behind 则是只更新缓存,不直接更新 db,而是改为异步批量的方式来更新 db。
很明显,这种方式对数据一致性带来了更大的挑战,比如 cache 数据可能还没异步更新 db 的话,cache 服务可能就就挂掉了。
这种策略在我们平时开发过程中也非常非常少见,但是不代表它的应用场景少,比如消息队列中消息的异步写入磁盘、MySQL 的 Innodb Buffer Pool 机制都用到了这种策略。
Write Behind Pattern 下 db 的写性能非常高,非常适合一些数据经常变化又对数据一致性要求没那么高的场景,比如浏览量、点赞量。
ok,上面三种模式就是比较常见的缓存读写策略
解决方案
前面打好了基础,我们现在来着手解决问题
先更新数据库,还是先更新缓存?
首先,我们第一的反应可能就是数据库和缓存,我们先更新谁呢?我们一个一个来分析
先更新数据库,再更新缓存
我们先来看看先更新数据库,再更新缓存的方案会怎么样?
从单线程的角度出发,好像没有什么问题。更新的请求过来后,我们先更新数据库而后再更新缓存。等下一个查询请求过来的时候,查询缓存拿取对应的数据,就是和数据库一样的数据。
但是如果是并发的时候,问题就出来了吧。举个例子,请求A和请求B两个请求,同时更新一条数据,则就有问题咯,假设在数据库的该条数据为0,请求A修改数据为1,则请求A先更新了数据库为1,而请求B修改数据为2,则在请求A先更新后请求B修改了数据库为2,而后请求B比请求A先快一步将缓存修改了为2,但是请求A还没去修改缓存,请求A在后又修改了缓存为1,这个时候就出现了数据不一致,数据库为2但是缓存为1
所以很明显,先更新数据库再修改缓存是不可行的
先更新缓存,再更新数据库
其实我们想一下就知道了,和上面的先更新数据库再更新缓存的情况是一样的,依然还是出现了并发的问题
所以无论是先更新数据库还是先更新缓存,似乎在高并发的情况下都不能解决数据一致性的问题,难道没办法了吗,不着急让我们继续往下看。
这个时候,聪明的你应该想到了前面我们讲的读写策略模式的第一个 旁路缓存策略了吧?
那么还有一个问题先更新数据库还是先删除缓存?
先更新数据库还是先删除缓存?
先删除缓存再更新数据库?
在前文我们提到了先删除缓存再更新数据库带来的问题,这边我们就不做过多的解析了。我们从前面可以得知,先删除缓存再更新数据库在读写并发的时候,还是会出现缓存和数据库的数据不一致的问题。
先更新数据库再删除缓存的可行性
在前面我们提到了为什么先更新数据库再删除缓存在理论上的可行性,本质上是因为缓存的写入通常远远快于数据库的写入,因此在并发的情况下不会出现另外的请求查询不到缓存而写入的缓存比修改请求还慢的情况。所以看起来先更新数据库再删除缓存的方案非常好,是可以保证数据一致性的。但是要注意这个方案会导致用户的体验感下降,因为数据的更新可能存在一定的延后性。
解决先更新数据库再删除缓存带来的数据一致延后
我们一般会通过下面的两种做法
- 在更新缓存前先加一个分布式锁,保证同一个时间只运行一个请求更新缓存,就不会出现了并发问题,但是带来的问题是引入锁,就会导致性能的下降
- 如果不想导致性能的下降,且不要求实时的数据强一致性,可以在更新完缓存时,给缓存加上较短的过期时间,这样即使出现了缓存不一致的情况,缓存的数据也会很快过期
在面对先删除缓存,再更新数据库 方案在 读写并发请求造成的数据不一样的解决办法就是延迟双删
删除缓存 - 更新数据库 - 睡眠 - 再删除缓存
但是具体的睡眠时间到底要多大是个玄学,也就是说很难保证性能
如何保证先更新数据库再删除缓存两个操作都能执行成功?
消息队列重试机制
这个方案中,我们通过引入消息队列来完成对缓存的删除操作,也就是通过消息队列的消费者来完成对缓存的操作,如果对缓存的数据消费失败的话,就从消息队列中重新读取数据来进行操作,也就是重试机制,我们可以设置一定的重试次数如果达到了重试次数但是还没成功的话,就向业务层发送报错消息。当然如果成功的话,就将消息队列中的数据确认,避免重复消费。关于消息队列的操作,请关注后续。
当然这个方案有缺点,就是代码入侵性比较强,因为需要改造原本业务的代码。
订阅MySQL binlog,再操作缓存
因为是先更新数据库再删除缓存,因此第一步是对数据库的操作,只要数据库发生了变化,就会产生一条变更日志,记录在binlog中。于是我们就可以想到通过订阅binlog日志来拿到要操作的数据,再进行缓存的删除操作。
阿里巴巴开源的Canal中间件就是具于这个实现的,Canal模型MySQL主从复制的交互协议,把自己伪装成一个MySQL的从节点,向MySQL主节点发送dump请求,MySQL收到请求后,会推送Binlog字节流之后,转换为便于读取的结构化数据,供下流程序订阅使用。
而且我们使用Canal的话,就可以很好解决上个方案的缺点(代码入侵性),因为它是直接订阅binlog日志的,和原本系统的业务代码没有耦合性。因此我们可以通过Canal+消息队列的方案来保证数据缓存的一致性。
将binlog日志作为消费对象发送到消息队列中,消费者实现读取根据log来删除对应的数据缓存,并通过ACK机制确认处理这条更新log,保证了数据缓存一致性。当然需要注意,必须是删除了缓存的操作成功,再回ACK机制给消息队列,否则可能出现消息失去的情况出现
所以如果要确保先更新数据库,再删除缓存策略的第二个操作能执行成功,我们可以使用:
- 消息队列来重复尝试删除,优点是保证数据缓存的一致性,缺点会对业务代码入侵
- 订阅MySQL binlog + 消息队列 + 重试缓存的删除,优点是规避了对业务代码入侵 但是缺点是引入的组件比较多,对团队的运维能力比较高
两个处理方案都是采用异步操作缓存