一文搞懂缓存和数据库的一致性问题(全面总结)

7 篇文章 8 订阅

前言

​ 如果用到了缓存,就会涉及到缓存与数据库双存储双写,只要是双写,就一定会有数据一致性的问题,那么如何解决一致性问题?

​ 既然是对缓存和数据库都进行操作,就包括了两个步骤,那么存在第一步操作成功,第二步操作失败问题。本文将从操作失败高并发两种情况,分析不同更新策略。

一、三种缓存读写策略

1.Cache Aside Pattern(旁路缓存模式)

Cache Aside Pattern 是我们平时使用比较多的一个缓存读写模式,比较适合读请求比较多的场景。步骤如下:

写:

  • 先更新 DB。
  • 然后删除缓存。

读:

  • 从 cache 中读取数据,读取到就直接返回
  • cache中读取不到的话,就从 DB 中读取数据返回,再把数据放到 cache 中。

2.Read/Write Through Pattern(读写穿透)

读写穿透中服务端把 cache 视为主要数据存储,从中读取数据并将数据写入其中。cache 服务负责将此数据读取和写入 DB,从而减轻了应用程序的职责。 Redis 并没有提供 cache 将数据写入DB的功能,该模式用的较少。

写:

  • 先查 cache,cache 中不存在,直接更新 DB。
  • cache 中存在,则先更新 cache,然后 cache 服务自己更新 DB(同步更新 cache 和 DB),对客户端透明。

读:

  • 从 cache 中读取数据,读取到就直接返回 。
  • 读取不到的话,先从 DB 加载,写入到 cache 后返回响应。

3.Write Behind Pattern(异步缓存写入)

异步缓存写入读写穿透很相似,两者都是由 cache 服务来负责 cache 和 DB 的读写。

但是,两个又有很大的不同:读写穿透是同步更新 cache 和 DB,而异步缓存写入则是只更新缓存,不直接更新 DB,而是改为异步批量的方式来更新 DB。

所以如果缓存挂掉而数据没有更新的话,就会造成数据丢失,适用于对数据一致性要求没那么高的场景。

二、先更新缓存,后更新数据库(不推荐)

1.操作失败的情况

​ 如果缓存更新成功,数据库更新失败,此时用户读取数据,仍然是从缓存中读取,读到的是数据最新值;但是过了一段时间缓存失效后,此时再读取数据就需要从数据库中读取,而之前数据库更新失败所以存的仍是旧值,并且重新写到缓存中也是旧值。这就导致用户发现自己修改的数据又变回去了,无疑会对业务造成影响!

2.高并发的情况

​ eg.假设现在有线程A和线程B同时要进行更新操作,那么可能会这样:

(1)线程A更新了缓存;

(2)线程B更新了缓存;

(3)线程B更新了数据库;

(4)线程A更新了数据库;

由于无法保证更新数据库和更新缓存的顺序,所以就会导致脏数据的产生(数据库中)!

三、先更新数据库,后更新缓存(不推荐)

1.操作失败的情况

​ 如果数据库更新成功,缓存更新失败,那么此时数据库中是「新值」,缓存中是「旧值」。之后的请求仍然会先读取缓存获得「旧值」,缓存失效后再读取数据库的「新值」。这就导致用户刚修改的数据却看不到变更,过一段时间才能看到,同样会对业务造成影响!

2.高并发的情况

​ eg.假设现在有线程A和线程B同时要进行更新操作,那么可能会这样:

(1)线程A更新了数据库;

(2)线程B更新了数据库;

(3)线程B更新了缓存;

(4)线程A更新了缓存;

由于无法保证更新数据库和更新缓存的顺序,所以就会导致脏数据的产生(缓存中)!

除此之外,对于二、三的情况,就算两步操作都成功了,从「缓存利用率」的角度来看也是不合适的。

什么是「缓存利用率」呢?

每次数据发生变更的时候,我们都「无脑」更新缓存,但是缓存中的数据不一定会被「马上读取」,这就会导致数据还没读到就被频繁更新,浪费缓存资源。

举个栗子,一个缓存涉及的表的字段,在 1 分钟内就修改了 100 次,那么缓存更新 100 次;但是这个缓存在 1 分钟内只被读取了 1 次,有大量的冷数据

况且在复杂一些的场景下,写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。

所以在实际应用中,我们并不对缓存进行更新,而是进行删除。这是lazy 计算的思想,即当数据需要被使用的时候再重新计算。像 mybatis,hibernate,都有懒加载思想。

四、先删除缓存,后更新数据库

1.操作失败的情况

​ 如果缓存删除成功,数据库更新失败。用户读取数据时,缓存中为空,只能去数据库读取数据。但由于数据库更新失败,所以用户读取到的是旧的数据。

2.高并发的情况

​ eg.假设同时有一个请求A进行更新操作,另一个请求B进行查询操作。那么可能会这样:

(1)请求A进行写操作,删除缓存;

(2)请求B查询发现缓存不存在;

(3)请求B去数据库查询得到旧值;

(4)请求B将旧值写入缓存;

(5)请求A将新值写入数据库;

这样就会导致缓存和数据库不一致的情况!如果不给缓存设置过期时间,那么该数据永远都是脏数据。

如何解决这个问题呢?可以采取「延时双删策略」。

依旧先删除缓存,再更新数据库,等过一段时间后再对缓存进行一次删除,比如 5s 之后。

public void set(key, value) {
    putToDb(key, value);
    deleteFromRedis(key);

    // ... a few seconds later
    deleteFromRedis(key);
}

3.读写分离的情况

​ eg.一个请求A进行更新操作,另一个请求B进行查询操作。

(1)请求A进行写操作,删除缓存;

(2)请求A将数据写入数据库了;

(3)请求B查询缓存发现,缓存没有值;

(4)请求B去从库查询,这时还没有完成主从同步,因此查询到的是旧值;

(5)请求B将旧值写入缓存;

(6)数据库完成主从同步,从库变为新值;

在这种情况下,依旧可以采取「延时双删策略」,把时间在主从同步延迟的基础上增加一些。

五、先更新数据库,后删除缓存(推荐)

1.操作失败的情况

​ 如果数据库更新成功,缓存删除失败。用户读取数据时会先从缓存中读取,而缓存中存储的是旧的数据。如果缓存中的数据没有失效时间,用户就会一直读取旧的数据!

2.高并发的情况

​ eg.一个请求A做查询操作,一个请求B做更新操作。

(1)缓存刚好失效;

(2)请求A查询数据库,得一个旧值;

(3)请求B将新值写入数据库;

(4)请求B删除缓存;

(5)请求A将查到的旧值写入缓存;

这种情况下确实也会产生脏数据,不过这种情况发生的概率是很小的。为什么呢?

要出现以上情况,步骤(3)的写数据库操作比步骤(2)的读数据库操作耗时更短,才有可能使得步骤(4)先于步骤(5)。但是写操作基本不会快于读操作,我们做读写分离的意义也是为了让读操作更快!


那么如何解决由操作失败导致的不一致性呢?
  • 消息队列
  • 订阅变更日志

无论使用哪种策略,操作失败后都可以进行重试!但是重试会一直占用这个线程资源,无法服务其它客户端请求,这时我们可以采取异步重试。就是把重试请求写到消息队列中,然后由专门的消费者来重试,直到成功。

你可能会说,写消息队列也有可能会失败啊?

但其实由于消息队列的特性,并不会造成影响:

  • 消息队列保证可靠性:写到队列中的消息,成功消费之前不会丢失(重启项目也不担心)。
  • 消息队列保证消息成功投递:下游从队列拉取消息,成功消费后才会删除消息,否则还会继续投递消息给消费者(符合我们重试的场景)。

image-20220508180718678

除此之外,还有一种方法,就是订阅数据库变更日志(binlog),再操作缓存

当一条数据发生修改时,MySQL 就会产生一条变更日志(binlog),我们可以订阅这个日志,拿到具体操作的数据,然后再根据这条数据,去删除对应的缓存。

image-20220508181331250

意思就是我们的业务应用在修改数据时,「只需」修改数据库,无需操作缓存。步骤如下:

  1. 在业务接口中写数据库之后,直接返回成功。
  2. mysql服务器会自动把变更的数据写入binlog中。
  3. binlog订阅者(消费者)获取变更的数据,然后删除缓存。

image-20220508181216864

  • 12
    点赞
  • 47
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值