数据库和缓存的数据一致性问题一直是老生常谈的话题了,它不仅在面试中十分常见,而且在实际开发中也是需要加以考量的因素。借着难得的空暇时光(其实是晚上不太想写代码),笔者今天想和大家简单讨论一下,数据库和缓存的数据一致性问题,以及如何避免or解决数据库和缓存的数据不一致的问题。
为什么要引入缓存?
在我们的后端系统没有引入缓存之前,我们的后端系统大致来讲应该是类似于这样的模型。
应用服务器中的数据库驱动通过网络I/O向数据库发增删改查操作的请求,数据库通过硬盘I/O在硬盘读/写相应数据之后,再通过网络I/O返回给应用服务器的数据库驱动,然后完成后续的业务操作。
这个模型在请求量较小的时候,是行得通的。但是当请求量变大,就会出现性能问题,而这个性能问题的瓶颈就出在硬盘I/O上。
我们都知道,内存的读写速度大于磁盘的读写速度,那么我们可以考虑将一些数据放到内存中,这样就能有效加快整体的响应速度。此时的模型应当是这样的。
对于缓存,我们有以下几个疑问:
- 对于缓存中存放的数据,是存放全部数据,还是存放热点数据?
- 假如数据库中的数据发生了更新/删除,缓存中对应的数据要怎么处理?
最简单的思路是,将数据库中的所有数据全部刷到缓存,然后定时将数据库的数据再次刷到缓存里面。
但是这又带来了两个问题:
- 缓存的利用率不高。因为有些非热点数据也被放进了缓存,但是这些数据正如其名,很少被访问,所以缓存的利用率并不高
- 定时刷新会带来数据库和缓存的不一致问题。因为这里用了定时刷新缓存的方案,假如数据库中的数据发生了更新/删除,那么需要等到下一次刷新缓存才能把缓存中的数据更新为新的数据。在更新数据库到刷新缓存之间的窗口期就是数据不一致的窗口期,窗口期越长,数据不一致带来的负面影响就越大。
因此这个方案实际上无人采用,我们更倾向于下面这个方案:将数据库中的热点数据刷新到缓存中,并设置一个过期时间。对于热点数据的请求,先查看缓存中是否有对应的数据,如果没有再去查数据库,查询到对应的数据后返回并同时写回缓存中。当数据库的数据发生更新/删除时,缓存中对应的数据也需要做对应操作。(如果这个数据存在的话)
这样,缓存中不常访问的数据,都会随着时间的推移而过期,这样缓存中保存的数据就都是热点数据了。缓存的利用率问题成功解决。
那么,数据不一致的问题呢?我们刚刚提到,当数据库的数据发生更新/删除时,缓存中对应的数据也需要做对应操作,这个操作是更新还是删除?这个操作应该发生在数据库更新/删除之前,还是发生在数据库更新/删除之后?
多级存储结构带来的数据不一致问题
对于这个问题,我们有四个选择(假设此时数据库中的数据发生了更新):
- 先更新数据库,再更新缓存
- 先更新缓存,再更新数据库
- 先更新数据库,再删除缓存
- 先删除缓存,再更新数据库
哪种最可靠呢?实际上四种都不大可靠。对于这四种完全不同的解决方案,我们都可以构造出可以让它们发生数据不一致问题的情形——第二步操作失败。
假设某热点数据存在于数据库和缓存中,初始的数据为10,我们需要将其更新为20。
对于方案1,第一步操作之后数据库的数据更新为20,但是如果缓存中的数据更新失败了,仍然为10,那么就发生了数据不一致。
对于方案4,第一步操作之后缓存中的数据被删除了,但是数据库中的数据更新失败,仍然为10,那么就发生了数据不一致。
方案2和方案3同样很容易构造出数据不一致的情形,以下不再赘述。
因为更新数据库和更新/删除缓存并不是一个原子操作,所以一旦第二步操作失败了,就会发生数据不一致的问题。尽管操作失败的概率非常非常低,但如同墨菲定律——“If it can go wrong, it will.",最恶劣的情况往往不会发生,但不代表一定不会发生。因此我们需要考虑第二步操作失败而带来数据不一致的问题。
在讨论如何解决第二步操作失败而带来的数据不一致之前,让我们先转移一下视线,先看向导致数据不一致的另外一个”罪魁祸首“,高并发环境。
高并发环境带来的数据不一致问题
高并发环境为什么会导致数据不一致?
对于上面提到的四种解决方案,我们假设两步操作都能操作成功,假设现在是在一个多线程的环境下,情况又会如何?
如果我们采用了”先更新数据库,再更新缓存“,假设某热点数据存在于数据库和缓存中,初始的数据为10。
有线程A和线程B两个线程,都需要更新这条数据,那么可能出现如下情况:
- 线程A更新数据为20
- 线程B更新数据为30
- 线程B更新缓存为30
- 线程A更新缓存为20
两步操作都是成功的,但是还是发生了数据不一致!这是为什么?我们发现线程A的两步操作之间被插入了线程B的操作,而线程B的操作直接导致了数据库与缓存的数据不一致。
同样的,”先更新缓存,再更新数据库“的方案也会带来数据不一致,这里不再赘述。
实际上,我们没必要在更新数据库的时候同时更新缓存!从缓存利用率的角度来考虑,假设数据更新完之后,读的次数相对较少,那么缓存利用率就提不起来;而且,如果写入缓存的值并非是数据库中原始的值,而是经过了其他的计算再把值写入缓存,那么“更新缓存”的方案就会带来性能问题
因此我们考虑另外一类方案,“删除缓存”
删除缓存就一定能保证数据一致性吗?
对于“删除缓存”的方案,我们同样有两种选择:
- 先更新数据库,再删除缓存
- 先删除缓存,再更新数据库
我们一个一个来分析。
- 先更新数据库,再删除缓存
假设某热点数据存在于数据库中,初始的数据为10。有线程A和线程B两个线程,线程A需要写数据,线程B需要读数据,那么可能会出现如下情况:
1)线程B读数据,由于缓存中不存在这条数据,因此读数据库中的数据
2)线程B读到数据为10
3)线程A更新数据为20
4)线程A删除缓存
5)线程B将数据10写入缓存
数据不一致由此发生。
- 先删除缓存,再更新数据库
假设某热点数据存在于数据库中,初始的数据为10。有线程A和线程B两个线程,线程A需要写数据,线程B需要读数据,那么可能会出现如下情况:
1)线程A删除缓存
2)线程B读数据,由于缓存中不存在这条数据,因此读数据库中的数据
3)线程B读到数据为10
4)线程A更新数据为20
5)线程B将数据10写入缓存
数据不一致由此发生。
这样看来,无论哪个都没法解决数据不一致的问题,那该如何是好?那么我们只能“矮个子挑高个”,选择一个相对来讲较优的方案。我们都知道,写内存的时间短于写硬盘的时间,那么“先更新数据库,再删除缓存”显然是比“先删除缓存,再更新数据库”更优的,因为数据不一致主要发生在写线程的两步操作之间,而删除缓存的时间显然比更新数据库的时间要短得多,留给读线程的“机会”自然也就更少。
这样看来,我们似乎已经解决了高并发环境下带来的数据不一致问题。但是别忘了我们遗留在前面的一个小tip——如果第二步操作失败了(也就是删除失败),也会导致数据不一致。如何解决?
无脑重试,能解决数据不一致问题?
删除失败了,那就重试呗!
删除失败了,就不断尝试,直到缓存被删除。但是如果一直删除失败呢?是不是要一直尝试?重试需不需要有时间间隔?如果一直失败会阻塞这个线程,那还能接收其他客户端请求吗?这个线程资源不就被浪费了?
由此看来,无脑删除并不能解决数据不一致的问题,尤其是这种“同步”删除。基于这个方案,我们提出了一个更好的解决方案,这就是**“异步”删除**。具体来讲就是把缓存删除的任务放进消息队列,让专门的消费者来删除缓存。
引入消息队列是否会带来额外的维护成本?在我看来是不会的,因为消息队列是非常常见的中间件,不会增加维护成本;而消息队列本身可以做到持久化,在消息被消费之前一般来讲都不会丢失消息,这和“同步”删除简直是大相径庭(“同步”删除会不断地尝试删除缓存,假如此时项目重启,或服务器宕机,那么删除请求就会永久丢失,数据就永远不一致了)
引入消息队列之后的模型应该是这样的。
如果不想写消息队列,有无其他解决方案?
目前比较流行的解决方案就是,通过中间件来监听数据库的变更日志(如MySQL的Binlog),根据变更日志中提到的操作的数据,去缓存中删除对应的缓存。常用的中间件有阿里巴巴开源的canal,通过监听MySQL的Binlog,自动向消息队列投递变更的数据,消费者再去缓存删除对应的数据。
但是这个方案就需要额外维护canal,保证canal的可用性。
延迟双删策略
对于解决数据不一致问题,业界还有另外一个知名的解决方案,那就是延迟双删策略。
延迟双删策略就是在原来“先删缓存,再更新数据库”的基础上,让线程休眠一段时间,再去删除对应的缓存,从而避免缓存中出现脏数据,因而保证数据一致性
那么又引出一个新的问题,“延迟双删策略”的“延迟”,具体要延迟多久?
一般来讲,延迟的时间应当大于读线程读取数据库+写入缓存的时间,但是在高并发环境下,这个时间非常难衡量,而且仍然会有概率导致数据不一致,所以一般推荐“先更新数据库,再删除缓存”,并在这个方案的基础上采用消息队列或canal来实现异步删除。
强一致还是弱一致?
什么是强一致性和弱一致性?
Bing Copilot给了我这样的答案:
强一致性和弱一致性是分布式系统中的两种数据一致性模型。让我为您解释一下它们的区别:
- 强一致性(也称为线性一致性):
- 当一个系统中的数据被修改时,其他所有系统的数据都能实时地反映出这个修改。
- 也就是说,如果在一个系统中修改了某个数据项,那么这个修改将会立即反映到其他所有的系统中。
- 强一致性可以保证从库有与主库一致的数据,即使主库宕机,数据仍然完整。
- 弱一致性:
- 在系统中,数据的修改可能不会立即反映到所有系统中。
- 也就是说,在某个系统中修改了数据项后,可能需要经过一段时间,这个修改才会在其他系统中体现出来。
- 弱一致性允许一些暂时的不一致状态,但最终会达到一致的状态。
在一个引入缓存的系统中,我们能做到强一致性吗?其实几乎是不可能的,或者说可以做到,但是需要一点性能作为代价——引入分布式锁,但是引入分布式锁也就违背了我们引入缓存的初衷——加快系统的响应速度。所以当选择了缓存,也就默认了放弃强一致性,性能和一致性往往是不可兼得的。根据本文上面所提到的内容,我们更多的是追求“弱一致性”,也就是追求最终达到数据一致的状态。即使本文上面所提到的方法都失效了,因为缓存有过期时间可以作为一致性的兜底,即使存在数据不一致的情况,当缓存过期,也就达到了最终一致的状态,尽管这个“最终一致”看起来不太体面罢了。
总结
- 引入缓存可以有效加快系统响应速度。
- 多级缓存结构会带来数据不一致。而解决数据不一致问题我们有四种解决方案。
- 考虑到高并发带来的数据不一致问题,我们推荐使用“删除缓存”的策略。
- 对于“删除缓存”策略而言,更加推荐使用“先更新数据库再删缓存”,“延迟双删策略”看似好用,实则难以估计延迟时间。
- 建议采用消息队列+canal中间件监听MySQL的Binlog的方式实现异步删除缓存