学习使我快乐!!!

Redis的缓存一致性问题详解

1、三种常用的缓存模式

1.旁路缓存模式

一般来说,如果允许缓存可以稍微的跟数据库偶尔有不一致的情况,也就是说如果你的系统不是严格要求 “缓存+数据库” 必须保持一致性的话,最好不要做这个方案,即:读请求和写请求串行化串到一个内存队列里去。

采用缓存 + 数据库读写的方式,就是 Cache Aside Pattern(旁路缓存模式)。

  • 读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。
  • 更新的时候,先更新数据库,然后再删除缓存

2.读写穿透模式

Read/Write Through Pattern 中服务端把 cache 视为主要数据存储,从中读取数据并将数据写入其中。cache 服务负责将此数据读取和写入 db,从而减轻了应用程序的职责。

写(Write Through):先查 cache,cache 中不存在,直接更新 db;cache 中存在,则先更新 cache,然后 cache 服务自己更新 db(同步更新 cache 和 db

读(Read Through):从 cache 中读取数据,读取到就直接返回 ;读取不到的话,先从 db 加载,写入到 cache 后返回响应。

Read-Through Pattern 实际只是在 Cache-Aside Pattern 之上进行了封装。在 Cache-Aside Pattern 下,发生读请求的时候,如果 cache 中不存在对应的数据,是由客户端自己负责把数据写入 cache,而 Read Through Pattern 则是 cache 服务自己来写入缓存的,这对客户端是透明的。和 Cache Aside Pattern 一样, Read-Through Pattern 也有首次请求数据一定不再 cache 的问题,对于热点数据可以提前放入缓存中。

3.异步缓存写入

异步缓存写入(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 的写性能非常高,非常适合一些数据经常变化又对数据一致性要求没那么高的场景,比如浏览量、点赞量。

2、缓存存在的问题?

1.为什么先更新后删除?

结论:无论先删除还是先更新数据库都存在数据一致性问题,那么矮个子里选将军,选个发生问题概率小的,就是先更新数据库后删除缓存。

先删除缓存,再更新数据库:如果删除缓存失败了,那么会导致数据库中是新数据,缓存中是旧数据,数据就出现了不一致。

2 个线程要并发「读写」数据,可能会发生以下场景:

  1. 线程 A 要更新 X = 2(原值 X = 1)
  2. 线程 A 先删除缓存
  3. 线程 B 读缓存,发现不存在,从数据库中读取到旧值(X = 1)
  4. 线程 A 将新值写入数据库(X = 2)
  5. 线程 B 将旧值写入缓存(X = 1)

最终 X 的值在缓存中是 1(旧值),在数据库中是 2(新值),发生不一致。

先更新数据库,再删除缓存:先删除了缓存,然后要去修改数据库,此时还没修改。一个请求过来,去读缓存,发现缓存空了,去查询数据库,查到了修改前的旧数据,并将其放到了缓存中。随后数据变更的程序完成了数据库的修改。数据库和缓存中的数据不一样了

  1. 缓存中 X 不存在(数据库 X = 1)
  2. 线程 A 读取数据库,得到旧值(X = 1)
  3. 线程 B 更新数据库(X = 2)
  4. 线程 B 删除缓存
  5. 线程 A 将旧值写入缓存(X = 1)

最终 X 的值在缓存中是 1(旧值),在主从库中是 2(新值),也发生不一致。

这 2 个问题的核心在于:缓存都被回种了「旧值」

矮个子里选将军

第二种方法其实概率很低,这是因为它必须满足 3 个条件:

  1. 缓存刚好已失效
  2. 读请求 + 写请求并发
  3. 更新数据库 + 删除缓存的时间(步骤 3-4),要比读数据库 + 写缓存时间短(步骤 2 和 5)

仔细想一下,条件 3 发生的概率其实是非常低的。

因为写数据库一般会先「加锁」,所以更新数据库,通常是要比读数据库的时间更长的,并且因为缓存的写入速度是比数据库的写入速度快很多。这么来看,「先更新数据库 + 再删除缓存」的方案,是可以保证数据一致性的。所以,我们应该采用这种方案,来操作数据库和缓存。

2.解决方法

最有效的办法就是,把缓存删掉。但是,不能立即删,而是需要「延迟删」,即:缓存延迟双删策略

解决第一个问题:在线程 A 删除缓存、更新完数据库之后,先「休眠一会」,再「删除」一次缓存。

解决第二个问题:线程 A 可以生成一条「延时消息」,写到消息队列中,消费者延时「删除」缓存。

这两个方案的目的,都是为了把缓存清掉,这样一来,下次就可以从数据库读取到最新值,写入缓存。

3.如何保证删除缓存成功?

方案一:重试

首先想到的一个方案是:执行失败后,重试。失败后立即重试的问题在于:

  • 立即重试很大概率「还会失败」
  • 「重试次数」设置多少才合理?
  • 重试会一直「占用」这个线程资源,无法服务其它客户端请求

方案二:异步重试

异步重试其实就是:把重试请求扔到「消息队列」中,然后由专门的消费者来重试,直到成功。把重试或第二步操作放到另一个服务中,这个服务用消息队列来进行重试操作。

3、异步重试方案-canal

我们的业务应用在修改数据时,「只需」修改数据库,无需操作缓存。拿 MySQL 举例,当一条数据发生修改时,MySQL 就会产生一条变更日志(Binlog),我们可以订阅这个日志,拿到具体操作的数据,然后再根据这条数据,去删除对应的缓存。订阅变更日志,目前也有了比较成熟的开源中间件,例如阿里的 canal,使用这种方案的优点在于:

  • 无需考虑写消息队列失败情况:只要写 MySQL 成功,Binlog 肯定会有
  • 自动投递到下游队列:canal 自动把数据库变更日志「投递」给下游的消息队列

想要保证数据库和缓存一致性,推荐采用「先更新数据库,再删除缓存」方案,并配合「消息队列」或「订阅变更日志」的方式来做

图片

参考: https://www.cnblogs.com/myseries/p/12068845.html

3种常用的缓存读写策略详解

缓存和数据库一致性问题,看这篇就够了 - 水滴与银弹

posted @ 2023-03-13 20:50  yyyyyu  阅读(292)  评论(0编辑  收藏  举报