如何保证数据库、缓存的双写一致?

文章目录

  • 前言
  • 常见方案
  • 更新缓存的方案
    • [1. 先写缓存,再写数据库](#1. 先写缓存,再写数据库)
    • [2. 先写数据库,再写缓存](#2. 先写数据库,再写缓存)
      • [2.1. 写缓存失败](#2.1. 写缓存失败)
      • [2.2. 高并发问题](#2.2. 高并发问题)
      • [2.3. 性能浪费](#2.3. 性能浪费)
    • [3. 先删缓存,再写数据库](#3. 先删缓存,再写数据库)
      • [3.1. 高并发问题](#3.1. 高并发问题)
      • [3.2. 缓存双删](#3.2. 缓存双删)
    • [4. 先写数据库,再删缓存](#4. 先写数据库,再删缓存)
    • [5. 删除缓存失败怎么办?](#5. 删除缓存失败怎么办?)

前言

在我们日常研发过程中,由于数据库的一些限制,我们经常使用缓存(如:Redis)来提升访问速率。此时,数据库和缓存双写数据就存在一致性问题,这个问题跟开发语言无关,在高并发场景下,问题更加严重。

另外,在面试、工作中也会经常遇到这个问题。所以这里跟大家一起探讨下数据库和缓存双写一致性问题的解决文案。

常见方案

通常,我们使用缓存的主要目的就是为了提升查询性能。所以,我们一般这样使用缓存:
是 否 是 否 用户请求 查询缓存 是否存在? 返回 查询数据库 是否存在? 放入缓存

  1. 用户请求数据,先查询缓存中是否有相关数据,如果有则直接返回
  2. 如果缓存没数据,再继续查询数据库
  3. 如果数据库有相关数据,则将查询出来的数据放入缓存中,然后返回该数据
  4. 如果数据库也没数据,则直接返回空

这是缓存的常见用法,粗看之下,好像没啥问题。但这个方案忽略了一个非常重要的细节:如果数据库中的某条数据放入缓存后又立即更新了,那么如何更新缓存呢?

答案是:在很长的一段时间内(取决于缓存的过期时间),用户请求从缓存中取到的数据都可能是旧值,而非数据库的最新值。

更新缓存的方案

那该如何主动更新缓存呢?有以下四种方案:

  • 先写缓存,再写数据库
  • 先写数据库,再写缓存
  • 先删缓存,再写数据库
  • 先写数据库,再删缓存
    接下来,我们分别探讨下这四种方案

1. 先写缓存,再写数据库

很多人第一想法是在写操作中直接更新缓存(写缓存),直接明了。那么问题是:在写操作中,先写缓存,还是先写数据库呢?
用户写操作 写缓存 写数据库

如果用户刚写完缓存,突然网络异常导致写数据库失败了,那结果会怎么样呢?
X网络异常 用户写操作 写缓存 写数据库

结果是缓存更新为最新数据,但数据库没有更新,这样缓存中的数据就变成了脏数据。如果此时用户查询该数据,就会出现问题。因为数据库中根本不存在该数据。

原本缓存的主要目的是把数据库中的数据临时保存在内存,便于后续的查询,提升查询速度。但如果数据库中不存在某数据,缓存中存在该数据,那缓存中的这份"假数据"有啥意义?

因此,该方案不可取,实际工作中用得很少。

2. 先写数据库,再写缓存

既然上面的方案不行,那调整下顺序,先写数据库,再写缓存呢?
用户写操作 写数据库 写缓存

这样可以避免之前的"假数据"问题,但它又带来了新的问题。

2.1. 写缓存失败

X网络异常 用户写操作 写数据库 写缓存

如果把写数据库和写缓存的操作,放在同一个事务中,写缓存失败了,可以回滚写数据库的数据。

如果并发量比较少,对接口性能要求不高,系统中可以这样写。但如果是高并发的业务场景中,写数据库和写缓存,都属于远程操作。为了防止出现大事务造成的死锁问题,通常建议写数据库和写缓存不要放在同一个事务中。也就是说在该方案中,如果写数据库成功了,但是写缓存失败了,数据库中已经写入的数据不会回滚。这就会出现:数据库是新数据,缓存是旧数据,两个数据不一致的情况。

2.2. 高并发问题

在高并发场景中,针对同一个用户的同一条数据,有两个写数据请求:a和b,它们同时请求。其中请求a获取的是旧数据,请求b获取的是新数据。如图:
请求b redis服务器 请求a 1 2网络卡顿 3 4 5写入新值 6写入旧值 用户写操作 写数据库 写缓存 缓存 用户写操作 写数据库 写缓存

  1. 请求a先到,先写数据库,然后由于网络原因卡顿,还没来得及写缓存
  2. 请求b到,先写数据库,然后写缓存
  3. 此时,请求a卡顿结束,继续写缓存

显然,在这个过程中,请求b在缓存中的新数据,被请求a的旧数据覆盖了。也就是说:在高并发场景中,如果多个线程同时执行先写数据库,再写缓存的操作,可能会出现数据库是新值,而缓存中是旧值,两边数据不一致的情况。

2.3. 性能浪费

该方案还有一个比较大的问题:每个写操作,写完数据库,会马上写缓存,比较浪费系统资源,进而造成性能浪费。

例如:写的缓存并不是简单的数据内容,而是要经过非常复杂的计算得出的最终结果。这样每写一次缓存,都需要经过一次非常复杂的计算,这样非常浪费系统资源,尤其是CPU和内存资源。

还有一些业务场景比较特殊:写多读少。如果在这些业务场景中,每个写操作,都需要写一次缓存,也有点儿得不偿失。

所以,在高并发场景中,先写数据库,再写缓存,这套方案问题不少,也不太建议使用。

3. 先删缓存,再写数据库

如果换一种思路:不去直接更新缓存,而改为删除缓存呢?

那么同样有两种方式:

    1. 先删缓存,再写数据库
    1. 先写数据库,再删缓存

先看看第一种情况:
用户写操作 删除缓存 写数据库

这个方案在单机情况下还可以,但是在高并发场景下也会有一样的问题。

3.1. 高并发问题

假设在高并发场景中,同一个用户的同一条数据,有一个读数据请求r,还有另一个写数据请求w,同时请求。如图:
请求r redis服务器 请求w 1 8网络卡顿 3 5 6 2清空 4 7写入旧值 用户查询操作 查询缓存 查询数据库 更新缓存 缓存 用户写操作 删除缓存 写数据库

  1. 请求w先到,把缓存删除。由于网络原因,卡顿一下,未及时写数据库
  2. 请求r到,先查缓存没数据,查询数据库,有数据且还是旧值
  3. 请求r将数据库中的旧值更新到缓存中
  4. 此时,请求w卡顿结束,把新值写入数据库

在这个过程中,请求w中的新值未被请求r写入缓存,同样会导致缓存和数据库的数据不一致。如何解决呢?

3.2. 缓存双删

在上面的场景中,一个读数据,一个写数据。当写数据请求把缓存删了之后,读数据请求可能把当时从数据库查询出来的旧值写入缓存中。可以采用重新删除缓存数据的方式解决。这就是缓存双删,即在写数据库之前删除一次,写完数据库之后再删除一次。

第二次删除缓存,不是立即删除,而是在一定的时间间隔之后(如500ms)。

为什么一定要间隔一段时间之后才能删除呢?因为请求w卡顿结束,把新值写入数据库后,请求r将数据库中的旧值更新到缓存中。如果请求w删除太快,请求r还未将旧值更新到缓存,这次删除将没有任何意义。必须要在请求r更新缓存后再及时删除缓存。所以需要在请求w中添加一个时间间隔。

4. 先写数据库,再删缓存

用户写操作 写数据库 删除缓存

在高并发场景中,有一个读数据请求,一个写数据请求,更新过程如下:

  1. 请求a先写数据库,由于网络原因卡顿一下,没来得及删除缓存
  2. 请求b查询缓存,发现缓存中有数据,直接返回该数据
  3. 请求a删除缓存

在这个过程中,只有请求b读了一次旧数据,后来旧数据就被请求a及时删除了,看起来问题不大。如果是读数据请求先来呢?

  1. 请求a查询缓存,发现缓存中有数据,直接返回该数据
  2. 请求b先写数据库
  3. 请求b删除缓存

这种情况看起来也是没有问题的。但有一种极限情况,缓存已经失效,如图:
请求b redis服务器 请求a 5 6 2 4 8网络卡顿 7清空 3 9写入旧值 用户查询操作 查询缓存 查询数据库 更新缓存 缓存 用户写操作 写数据库 删除缓存

  1. 缓存过期时间到了,自动失效
  2. 请求b查询缓存,发现缓存中没有数据,查询数据库,发现旧值,但由于网络卡顿,没来得及更新缓存
  3. 请求a先写数据库,然后删除缓存
  4. 请求b更新旧值到缓存中

此时,缓存和数据库的数据同样出现不一致的情况。但这种情况比较少,需要同时满足以下条件才会发生:

  • 缓存刚好自动失效
  • 请求b从数据库查出旧值,更新缓存的耗时,比请求a写数据库并删除缓存的时间还长

一般情况下,查询数据库的速度比写数据库更快,何况写完数据库还要删除缓存。所以,系统同时满足上述两个条件的概率非常小。推荐大家使用该方案。

5. 删除缓存失败怎么办?

其实上面两个方案还有一个共同的风险点,即:如果缓存删除失败了,也会导致数据不一致。应该怎么解决这个问题呢?

答案是重试机制。

在接口中如果更新数据库成功了,但更新缓存失败了,可以立即重试三次。如果其中有任何一次成功,则直接返回成功。如果三次都失败了,则写入数据库,准备后续再处理。

当然,如果在接口中直接同步重试,该接口并发量比较高的时候,可能会影响接口性能。这时,可以修改为异步重试。

异步重试方式有多种:

  1. 每次单独起线程,该线程专门做重试工作。但在高并发场景下,可能会创建太多的线程,导致系统OOM问题,不建议使用
  2. 将重试的任务交给线程池处理,但如果服务器重启,部分数据可能丢失
  3. 将重试数据写表,然后用elastic-job等定时任务进行重试
  4. 将重试请求写入mq等消息中间件中,在mq的consumer中处理
  5. 订阅mysql的binlog,在订阅者中,如果发现了更新数据请求,则删除相应的缓存
相关推荐
数据智能老司机2 分钟前
精通 Python 设计模式——创建型设计模式
python·设计模式·架构
菜鸟谢28 分钟前
Manjaro Tab 无自动补全
后端
Java水解28 分钟前
JAVA经典面试题附答案(持续更新版)
java·后端·面试
Java水解34 分钟前
Mysql查看执行计划、explain关键字详解(超详细)
后端·mysql
数据智能老司机1 小时前
精通 Python 设计模式——SOLID 原则
python·设计模式·架构
追逐时光者2 小时前
.NET Fiddle:一个方便易用的在线.NET代码编辑工具
后端·.net
林树的编程频道2 小时前
快递的物流地图是怎么实现的
后端
洛小豆3 小时前
在Java中,Integer.parseInt和Integer.valueOf有什么区别
java·后端·面试
八怪3 小时前
联合索引使用高分区度字段的一个例子
后端
IT_陈寒3 小时前
JavaScript 性能优化:5 个被低估的 V8 引擎技巧让你的代码快 200%
前端·人工智能·后端