侧边栏壁纸
博主头像
孔子说JAVA博主等级

成功只是一只沦落在鸡窝里的鹰,成功永远属于自信且有毅力的人!

  • 累计撰写 292 篇文章
  • 累计创建 132 个标签
  • 累计收到 4 条评论

目 录CONTENT

文章目录

redis如何保证数据一致性

孔子说JAVA
2022-09-20 / 0 评论 / 0 点赞 / 30 阅读 / 3,359 字 / 正在检测是否收录...

在并发低、用户少的情况下,可以每次都去数据库查询数据返回,但在高并发情况下,每一个读请求都到数据库查询会导致数据库压力太大。一般我们会使用Redis/Memcache做一个缓冲,减轻数据库的压力。这时就会涉及到缓存与数据库之间数据的一致性问题,我们以Redis为例讲解如何保证数据库与缓存的一致性。

1、一致性介绍

一致性是指使数据保持一致,在分布式系统中,可以理解为多个节点中的数据是一致的。本教程中指的是缓存与数据库中数据是一致的。因为数据最终是以数据库为准的(这是我们的原则),如果Redis没有数据,就不存在这个问题。当Redis和数据库都有同一条记录,而这条记录发生变化的时候,就可能出现一致性的问题。一致性可以分为以下几种:

  • 强一致性:用户写入什么数据,就可以读出什么数据。这种一致性最符合用户的直觉,用户体验好,但实现起来往往对系统的性能影响最大。
  • 弱一致性:在用户写入系统成功后,不承诺可以立即读出写入的数据,也不承诺多久数据可以达到一致,但会尽可能地保证到某个时间级别(比如秒级别)后,数据能够达到一致状态。
  • 最终一致性:最终一致性是弱一致性的一种特例,系统会保证在一定时间内,数据能够达到一致的状态。这里之所以将最终一致性单独提出来,是因为它是弱一致性中非常推崇的一种一致性模型,也是业界在大型分布式系统的数据一致性上比较推崇的模型。

2、需求起因

在高并发的业务场景下,数据库大多数情况都是用户并发访问最薄弱的环节。所以,就需要使用 redis 做一个缓冲操作,让请求先访问到 redis,而不是直接访问 MySQL 等数据库。

image-1663634225216

这个业务场景主要是解决从 Redis 缓存读数据,一般都是按照下图的流程来进行业务操作。

image-1663634264600

读取缓存步骤一般是没有问题的,但是一旦涉及到数据更新:数据库和缓存更新,就容易出现缓存 (Redis) 和数据库(MySQL)间的数据一致性问题。

redis是删除还是更新

当存储的数据发生变化,Redis的数据也要更新的时候,更新redis数据有两种方案,一种就是直接更新Redis数据,调用set;还有一种是直接删除Redis数据,让应用在下次查询的时候重新写入。

这两种方案怎么选择呢?这里我们主要考虑更新缓存的代价。

更新缓存之前,是不是要经过其他表的查询、接口调用、计算才能得到最新的数据,而不是直接从数据库拿到的值。如果是的话,建议直接删除缓存,这种方案更加简单,而且避免了数据库的数据和缓存不一致的情况。在一般情况下,我们也推荐使用删除的方案。

所以,更新操作和删除操作,只要数据变化,都用删除。这一点明确之后,现在我们就剩一个问题:是先更新数据库,再删除缓存;还是先删除缓存,再更新数据库呢?

3、缓存更新方案

不管是先写 MySQL 数据库,再删除 Redis 缓存;还是先删除缓存,再写库,都有可能出现数据不一致的情况。

3.1 先更新数据库,再删除缓存

如果先写了MySQL库,在删除缓存时线程宕机了,导致redis缓存删除失败,也会出现数据不一致的情况。

3.1.1 场景分析

正常情况:

  • 更新数据库,成功。
  • 删除缓存,成功。

异常情况:

  • 更新数据库失败,程序捕获异常,不会走到下一步,所以数据不会出现不一致。
  • 更新数据库成功,删除缓存失败。数据库是新数据,缓存是旧数据,发生了不一致的情况。

3.1.2 方案一:重试机制

这种问题怎么解决呢?我们可以提供一个重试的机制。比如:如果删除缓存失败,我们捕获这个异常,把需要删除的key发送到消息队列。然后自己创建一个消费者消费,尝试再次删除这个key.

  • 这种方式有个缺点,会对业务代码造成入侵。

3.1.3 方案二:异步更新缓存

因为更新数据库时会往binlog写入日志,所以我们可以通过一个服务来监听binlog的变化(比如阿里的canal),然后在客户端完成删除key的操作。如果删除失败的话再发送到消息队列。

技术整体思路:

MySQL binlog 增量订阅消费 + 消息队列 + 增量数据更新到 redis

  • 1)读 Redis:热数据基本都在 Redis
  • 2)写 MySQL: 增删改都是操作 MySQL
  • 3)更新 Redis 数据:MySQL 的数据操作 binlog 更新到 Redis

Redis 更新

1)数据操作主要分为两大块:

  • 一个是全量 (将全部数据一次写入到 redis)
  • 一个是增量(实时更新)

本文指的是增量,即 mysql 的 update、insert、delete 等变更数据。

2)读取 binlog 后分析,利用消息队列,推送更新 redis 缓存数据。

这样一旦 MySQL 中产生了新的写入、更新、删除等操作,就可以把 binlog 相关的消息推送至 Redis,Redis 再根据 binlog 中的记录,对 Redis 进行更新。这种机制类似 MySQL 的主从备份机制,因为 MySQL 的主备也是通过 binlog 来实现的数据一致性。

这里可以结合使用 canal (阿里的一款开源框架),通过该框架可以对 MySQL 的 binlog 进行订阅,而 canal 正是模仿了 mysql 的 slave 数据库的备份请求,使得 Redis 的数据更新达到了相同的效果。当然,这里的消息推送工具你也可以采用别的第三方:kafka、rabbitMQ 等来实现推送更新 Redis。

对于后删除缓存失败的情况,我们的做法是不断地重试删除,直到成功。无论是重试还是异步删除,都是最终一致性的思想。

3.2 先删除缓存,再更新数据库

如果先删除了缓存 Redis,还没有来得及写入MySQL库,这时另一个线程就来读取,发现缓存为空,则去数据库中读取数据写入缓存,此时缓存中为脏数据。

3.2.1 场景分析

正常情况:

  • 删除缓存,成功。
  • 更新数据库,成功。

异常情况:

  • 删除缓存失败,程序捕获异常,不会走到下一步,所以数据不会出现不一致。
  • 删除缓存成功,更新数据库失败。因为以数据库的数据为准,所以不存在数据不一致的情况。

看起来好像没问题,但是如果有程序并发操作的情况下:

  1. 线程A需要更新数据,首先删除了Redis缓存
  2. 线程B查询数据,发现缓存不存在,到数据库查询旧值,写入Redis,返回
  3. 线程A更新了数据库

这个时候,Redis是旧的值,数据库是新的值,发生了数据不一致的情况。

这个是由于线程并发造成的问题。能不能让对同一条数据的访问串行化呢?

  • 代码肯定保证不了,因为有多个线程,即使做了任务队列也可能有多个应用实例(应用做了集群部署)。

  • 数据库也保证不了,因为会有多个数据库的连接。只有一个数据库只提供一个连接的情况下,才能保证读写的操作是串行的,或者我们把所有的读写请求放到同一个内存队列当中,但是强制串行操作,吞吐量太低了。

怎么办呢?删一次不放心,隔一段时间再删一次。所以我们有一种延时双删的策略,在写入数据之后,再删除一次缓存。

3.2.2 延时双删策略+设置缓存过期时间

所谓双删策略,就是在更新库的前后都进行Redis缓存的删除,但是更新后的删除时延时删除,并且设定合理的超时时间。伪代码如下:

public void update(){
  // 1.删除Redis缓存
  redisTemplate.delete(key);
  // 2.更新数据库
  mysqlConnection.update();
  // 3.休眠 200 毫秒
  Thread.sleep(200);
  // 4.再次删除Redis缓存
  redisTemplate.delete(key);
}

等待的时间如何确定

等待的时间(例子中的休眠200毫秒)为读线程读取数据的一个耗时,包括Redis主从同步,网络耗时等。如何确定?

  • 首先需要评估自己的项目的读数据业务逻辑的耗时。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。当然这种策略还要考虑 redis 和数据库主从同步的耗时。最后的写数据的休眠时间:则在读数据业务逻辑的耗时基础上,加几百 ms 即可。比如:休眠 1 秒。

为什么要使用延时删除

为了防止在更新数据的过程中有读线程进来缓存数据,所以更新后估算一个时间再删除Redis缓存。

为什么要设置缓存过期时间

从理论上来说,给缓存设置过期时间,是保证数据最终一致性的解决方案。因为在最坏情况下,脏数据的时间最多为设置的过期时间,只要到达缓存过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓存。

该方案有什么弊端

  1. 最坏情况是在一个过期时间内会存在脏数据。
  2. 因为有延时,所以增加了写线程的执行时间。
0

评论区