【Redis】数据库和缓存如何保证一致性?

news2025/7/25 10:12:20

【Redis】数据库和缓存如何保证一致性?

文章目录

  • 【Redis】数据库和缓存如何保证一致性?
    • 常见方案
    • 先更新缓存,再更新数据库
    • 先更新数据库,再更新缓存
      • 并发情况下的思考
    • 先删除缓存,再更新数据库
    • 先更新数据库,再删除缓存
    • 小小总结
    • 关于数据一致性的补充
    • 数据一致性解决方案
      • 如何保证这两段代码一起执行成功
        • 1)引子
        • 2)重试
        • 3)消息队列-异步重试
        • 4)定时任务-异步重试
        • 5)Canal 订阅日志实现
      • 延时双删策略

常见方案

通常情况下,我们使用缓存的主要目的是为了提升查询的性能。 大多数情况下,我们是这样使用缓存的:

image-20230207172343410

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

这是缓存非常常见的用法。一眼看上去,好像没有啥问题。

但你忽略了一个非常重要的细节:如果数据库中的某条数据,放入缓存之后,又立马被更新了,那么该如何更新缓存呢?

数据库和缓存的数据不一致问题,大都是产生在更新数据时。

在更新的时候,操作缓存和数据库无疑就是以下四种可能之一:

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

一个一个分析,为什么会产生数据不一致问题?

先更新缓存,再更新数据库

操作流程大致如下:问题出现在第四个操作上

image-20230207015344523

如果我成功更新了缓存,但是在执行更新数据库的那一步,服务器突然宕机了,那么此时,我的缓存中是最新的数据,而数据库中是旧的数据

脏数据就因此诞生了,并且如果我缓存的信息(是单独某张表的),而且这张表也在其他表的关联查询中,那么其他表关联查询出来的数据也是脏数据,结果就是直接会产生一系列的问题。

先更新数据库,再更新缓存

先更新数据库,再更新缓存,其实还是存在类似的问题。

image-20230207015455959

只有等到缓存过期之后,才能访问到正确的信息。那么在缓存没过期的时间段内,所看到的都是脏数据。

从上面两张图中,大家也能看出,无论咋样,只要执行第二步时失败了,就必然会产生脏数据。

思考:如果如果如果两步都能执行成功?能保证数据一致性吗?

其实也不能,因为还有Java常考的并发

并发情况下的思考

如果上面的两小节,两步操作都能成功,在并发情况下是怎么样的呢?

image-20230207015536496

换成是先更新数据库,再更新缓存,也是一样的。

image-20230207015545605

在这里可以看到当执行时序被改变,那么就必然会产生脏数据

看到这里,也许学过 Java 锁知识的小伙伴可能会说,咱们可以加锁啊,这样就不会产生这样的问题啦~

在这里确实可以加锁,以保证用户的请求顺序,来达到数据一致性。


虽然加锁确实可以通过牺牲一些性能来保证一定数据一致性,但我还是不推荐更新缓存的方式。

原因如下:

  1. 首先加入缓存的主要作用是提高系统性能。
  2. 其次更新缓存的代价并不低。
    • 复杂场景下:比如可能更新了某个表的一个字段,然后其对应的缓存,是需要查询另外两个表的数据并进行运算,才能计算出缓存最新的值的。
    • 可能一些场景是需要这样的。
  3. 缓存利用率问题。一个频繁更新的缓存,它是否会被频繁的访问呢?
    • 一个缓存在很短的时间内,更新10次,20次或者更多,但是实际访问次数只有1、2次,这其实也是一种浪费。
    • 如果采用删除缓存就不会这样,删除了缓存,那么就只会等到有人要使用缓存的时候,才会重新查询数据,放入缓存中。这其实也是懒加载的思想,等到要使用了,再加载。

当然业务场景确实有这样的场景,这么使用也未免不可, 一切都要实事求是,而并非空谈。

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

这种方式在没有高并发的情况下,是可能保持数据一致性的。

image-20230207015640649

如果只有第一步执行成功,而第二步失败,那么只有缓存中的数据被删除了,但是数据库没有更新,那么在下一次进行查询的时候,查不到缓存,只能重新查询数据库,构建缓存,这样其实也是相对做到了数据一致性。

但如果是处于读写并发的情况下,还是会出现数据不一致的情况:

image-20230207015705705

执行完成后,明显可以看出,1号用户所构建的缓存,并不是最新的数据,还是存在问题的~

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

如果更新数据库成功了,而删除缓存失败了,那么数据库中就会是新数据,而缓存中是旧数据,数据就出现了不一致情况。

image-20230207015806536

和之前一样,如果两段代码都执行成功,在并发情况下会是什么样呢

image-20230207015833843

还是会造成数据的不一致性。

但是此处达成这个数据不一致性的条件明显会比起其他的方式更为困难

  • 时刻1:读请求的时候,缓存正好过期
  • 时刻2:读请求在写请求更新数据库之前查询数据库,
  • 时刻3:写请求,在更新数据库之后,要在读请求成功写入缓存前,先执行删除缓存操作。

因为缓存的写入通常要远远快于数据库的写入,所以在实际中很难出现请求 B 已经更新了数据库并且删除了缓存,请求 A 才更新完缓存的情况。

这通常是很难做到的,因为在真正的并发开发中,更新数据库是需要加锁的,不然没一点安全性~

一定程度上来讲,这种方式还是解决了一定程度上的数据不一致性问题的。

为了确保万无一失,还给缓存数据加上了「过期时间」,就算在这期间存在缓存数据不一致,有过期时间来兜底,这样也能达到最终一致。

「先更新数据库, 再删除缓存」其实是两个操作,前面的所有分析都是建立在这两个操作都能同时执行成功,而这次的问题就在于,在删除缓存(第二个操作)的时候失败了,导致缓存中的数据是旧值

好在之前给缓存加上了过期时间,所以才会出现过一段时间才更新生效的现象,假设如果没有这个过期时间的兜底,那后续的请求读到的就会一直是缓存中的旧数据,这样问题就更大了。

小小总结

1、无论选择下列那种方式

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

如果是在多服务或是并发情况下,其实都有可能产生数据不一致性。

不过在这四种选择中,平常都会优先考虑后两种方式。并且市面上对于这后两种选择,也已经有一些解决方案。

在谈解决方案之前,我们先看看需要解决的问题

  1. 我们要如何保证这两段代码一起执行成功

  2. 【先删除缓存,再更新数据库】在读写并发时,会产生一个缓存旧数据,而数据库是新数据的问题,这该如何解决呢?

    image-20230207020021754

关于数据一致性的补充

简单说,只要使用缓存,那么必然就会产生缓存和数据库数据不一致的问题。

在这首先我们要明确一个问题,就是我们的系统是否一定要做到“缓存+数据库”完全一致性?是否能够接受偶尔的数据不一致性问题?能够接受最长时间的数据不一致性?

强一致性

如果缓存和数据库要达到数据的完全一致,那么就只能读写都加锁,变成串行化执行,系统吞吐量也就大大降低了,一般不是必须达到强一致性,不采用这样的方式。

并且实在过于要求强一致性,会采用限流+降级,直接走MySQL,而不是特意加一层 Redis 来处理。

弱一致性(最终一致性)

一般而言,大都数项目中,都只是要求最终一致性,而非强一致性。

最终一致性是能忍受一定时间内的数据不一致性的,只要求最后的数据是一致的即可。

例如缓存一般是设有失效时间的,失效之后数据也会保证一致性,或者是下次修改时,没有并发,也会让数据回到一致性等等。

数据一致性解决方案

如何保证这两段代码一起执行成功

要想第二段代码成功执行,那么重试是必不可少的啦

重试的思想,在学习Java的道路会遇到很多次的哈,

1)引子

像如果学习过Java中锁相关知识的朋友,应该会记得自旋锁和互斥锁~

自旋锁:一种是没有获取到锁的线程就一直循环等待判断该资源是否已经释放锁,它不用将线程阻塞起来(NON-BLOCKING);

互斥锁:把自己阻塞起来,等待重新调度请求。

自旋锁的思想其实也就是一个while(true)一直重试罢了。

还有使用过openfegin的朋友会知道,它在发送请求时,也包含有一个重试机制,很多高可用的场景,都会加上重试~

2)重试

但是重试存在的问题,也有很多,需要重试几次呢?重试的间隔时间是多少呢?重试再失败该如何补偿呢?在重试的过程中,如果程序宕机,重试也就丢失啦

看到这些你有没有头大,有的话,就对了,认真思考每一个点,你都会发现很多其他的知识,这往往比老老实实的学习更有效。

我们如果仍然像锁机制或者是openfeign的机制一样,采取同步重试的方式的话,是解决不了问题的,如同步重试是可能会失败的,如果一直失败,则会一直占用线程资源,导致其他用户的请求无法正常被执行。

应该很容易想到,同步的对立面就是异步,异步重试,交由别人来做这件事情,自己不用去管这件事情即可。

谈到异步,并且是第三方来做的,最快想到的无疑就是消息队列啦~

3)消息队列-异步重试

如果学习过消息队列的朋友,应该很快就能get到,或者自己思考到这一点;

如果没有学习过的话,我觉得学习消息队列还是非常有必要的一件事情。

我们可以把第二步操作交由消息队列去做,达到一个异步重试的效果。并且引入消息队列来实现,代价并非想象中的那么大。

当然大家也会说,如果发送消息也失败呢?

有这种可能,但真的不算高,另外消息队列自身是很好的支持高可用的。

  1. 首先消息队列在高并发的场景下,可以毋庸置疑的说是一个非常重要的组件啦,所以引入消息队列以及维护消息队列,其实都不能算是额外的负担。
  2. 其次消息队列具有持久化,即使项目重启也不会丢失。
  3. 最后消息队列自身可以实现可靠性
    • 保证消息成功发送,发送到交换机;
    • 保证消息成功从交换机发送至队列;
    • 消费者端接收到消息,采用手动ACK确认机制,成功消费后才会删除消息,消费失败则重新投递~

图:

image-20230207020304659

4)定时任务-异步重试

使用定时任务重试的具体方案如下:

当用户操作写完数据库,但删除缓存失败了,需要将用户数据写入重试表中。如下图所示:

image-20230207172605179

在定时任务中,异步读取重试表中的用户数据。重试表需要记录一个重试次数字段,初始值为0。然后重试5次,不断删除缓存,每重试一次该字段值+1。如果其中有任意一次成功了,则返回成功。如果重试了5次,还是失败,则我们需要在重试表中记录一个失败的状态,等待后续进一步处理。

image-20230207172644044

5)Canal 订阅日志实现

消息队列虽然已经比较简单,但是仍然要手动的进行代码的编写,以及写一个消费者来进行监听,可以说还是比较麻烦,每个地方都还要引入消息队列,发送一个消息~,有没有办法省去这一步呢?有的勒,偷懒的人大有人在勒

现有的解决方案中,可以使用 alibaba 的开源组件 Canal,订阅数据库变更日志,当数据库发生变更时,我们可以拿到具体操作的数据,然后再去根据具体的数据,去删除对应的缓存。

当然Canal 也是要配合消息队列一起来使用的,因为其Canal本身是没有数据处理能力的。

相应的流程图大致变成下列这样:

image-20230207020333206

优点:

  • 算的上彻底解耦了,应用程序代码无需再管消息队列方面发送失败问题,全交由 Canal来发送。

缺点:

  • 引入了Canal中间件,需要一定的维护成本,需要实现高可用的话,也需考虑集群等,架构也会进一步变得复杂。

这套方案中业务接口确实简化了一些流程,只用关心数据库操作即可,而在binlog订阅者中做缓存删除工作。

但如果只是按照图中的方案进行删除缓存,只删除了一次,也可能会失败。

如何解决这个问题呢?

答:这就需要加上前面聊过的重试机制了。如果删除缓存失败,写入重试表,使用定时任务重试。或者写入mq,让mq自动重试。

在这里推荐使用mq自动重试机制

image-20230207172808220

在binlog订阅者中如果删除缓存失败,则发送一条mq消息到mq服务器,在mq消费者中自动重试5次。如果有任意一次成功,则直接返回成功。如果重试5次后还是失败,则该消息自动被放入死信队列,后面可能需要人工介入。

延时双删策略

问题:【先删除缓存,再更新数据库】在读写并发时,会产生缓存是旧数据,而数据库是新数据的问题,这该如何解决呢?

image-20230207020406023

(图片说明:上图为产生数据不一致性的情况)

image-20230207020425017

解决这样的问题,其实最好的方式就是在执行完更新数据库的操作后,先休眠一会儿,再进行一次缓存的删除,以确保数据一致性,这也就是市面上给出的主流解决方案–延时双删

延迟双删实现的伪代码如下:

#删除缓存
redis.delKey(X)
#更新数据库
db.update(X)
#睡眠
Thread.sleep(N)
#再删除缓存
redis.delKey(X)

但是更加深入的思考“延时”两字,这个延时到底延时多久合适呢?有什么评判依据吗?

首先延迟删除的时间需要大于 1号用户执行流程的总时间

即:【1号用户从数据库读取数据+写入缓存】时间

但是要说具体是多长,这无法给出一个准确答复,只能经过不断的压测和实验,预估一个大概的时间,尽可能的去降低发生数据不一致的概率罢了。

延迟双删实现的伪代码如下:

#删除缓存
redis.delKey(X)
#更新数据库
db.update(X)
#睡眠
Thread.sleep(N)
#再删除缓存
redis.delKey(X)

但是更加深入的思考“延时”两字,这个延时到底延时多久合适呢?有什么评判依据吗?

首先延迟删除的时间需要大于 1号用户执行流程的总时间

即:【1号用户从数据库读取数据+写入缓存】时间

但是要说具体是多长,这无法给出一个准确答复,只能经过不断的压测和实验,预估一个大概的时间,尽可能的去降低发生数据不一致的概率罢了。

补充:并发问题的解决,最常用的方式无疑就是加锁,那到底是加什么锁呢?在分布式系统中,对于并发,加的无疑就是分布式锁。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/368338.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

StopWatch计时器

前言 开发中,为了评估性能,我们通常会使用System.currentTimeMillis() 去计算程序运行耗时 long startTimeSystem.currentTimeMillis();//业务代码... long endTimeSystem.currentTimeMillis(); System.out.println("耗时:" (endTime-startT…

Java多线程(三)---synchronized、Lock和volatile

Java内存模型(非JVM)Java内存模型(Java Memory Model简称JMM),是一种共享内存模型,是多线程的东西,并不是JVM(Java Virtual Machine(Java虚拟机)的缩写),这是俩玩意儿!&a…

Ubuntu 22.04.2 发布,可更新至 Linux Kernel 5.19

Ubuntu 22.04 LTS (Jammy Jellyfish) Ubuntu 22.04.2 发布,可更新至 Linux Kernel 5.19 请访问原文链接:Ubuntu 22.04 LTS (Jammy Jellyfish),查看最新版。原创作品,转载请保留出处。 作者主页:www.sysin.org 发行说…

ssh远程登录报错:kex_exchange_identification: Connection closed by remote host

基本信息系统:MacOS Catalina 10.15.7报错信息:终端登录远程服务器时报错:kex_exchange_identification: Connection closed by remote host复制然而服务商的一键登录或VNC登录正常。解决方案首先使用以下命令debug登录过程,以便定…

这可能是Spring Boot Starter 讲的最清楚的一次了

前面我们简单介绍了如何使用消息中间件Apache Pulsar,但是在项目中那样使用,显然是不太好的,不管从易用性和扩展性来看,都是远远不够, 为了和springboot项目集成,写一个pulsar-spring-boot-starter是非常有…

Linux核心技能:2023主流监控Prometheus详解,附官方可复制中文文档教程

Prometheus既是一个时序数据库,又是一个监控系统,更是一套完备的监控生态解决方案。作为时序数据库,目前Prometheus已超越了老牌的时序数据库OpenTSDB、Graphite、RRDtool、KairosDB等,如图所示。 (来源网络&#xff0…

QT+OpenGL鼠标操作和模型控制

文章目录QTOpenGL鼠标操作和模型控制鼠标拾取理论有点小复杂从鼠标计算射线第 0 步:2D 视口坐标第 1 步:3d归一化设备坐标第 2 步:4d齐次剪辑坐标第 3 步:4d眼(相机)坐标第 4 步:4d 世界坐标代码展示模型控制多模型加载…

自动执行自动化测试用例

phpunit 接口自动化测试系列 所有自动化测试用例最终的目的都是一样的,实现无人值守的自动化运行。而目前最常用的就是Jenkins来实现这个功能,在前面的WebdriverPython页面自动化的教程中我们已经详细讲解了如何将自动化测试用例接入到Jenkins中。本章我…

RK系列(RK3568) i2s 音频输入 麦克风驱动

平台:Android12SOC:RK3568外围芯片:XS9922i2s简介:从上图看I2s主要的线有:SDO SCLK LRCK MCLK I2S协议只定义三根信号线:串行时钟信号SCLK(BCLK)、数据信号SD和左右声道选择信号WS。(1&#xff…

QT入门Containers之QStackedWidget

目录 一、QStackedWidget界面相关 1、布局介绍 2、插入界面 3、插入类界面 二、Demo展示 此文为作者原创,创作不易,转载请标明出处! 一、QStackedWidget界面相关 1、布局介绍 QStackedWidget这个控件在界面布局时,使用还…

JVM整体分析篇

这里写目录标题JVM的组成部分1.类装载子系统1.1一个类加载到JVM的过程1.2类加载机制1.3为什么设计双亲委派机制1.4怎么打破双亲委派机制2.运行时数据区2.1线程私有及共享2.2JVM内存区结构2.3JVM参数设置经验3.Java对象的生命周期3.1.对象的创建3.2.对象大小的计算(6…

六、程序计数器(PC寄存器)

JVM中的程序计数寄存器(Program Counter Register)中,Register 的命名源于CPU的寄存器,寄存器存储指令相关的现场信息。CPU只有把数据装载到寄存器才能够运行。 这里,并非是广义上所指的物理寄存器,或许将其…

Python中实现将内容进行base64编码与解码

一、需求说明需要使用Python实现将内容转为base64编码,解码,方便后续的数据操作。二、base64简介Base64是一种二进制到文本的编码方式【是一种基于 64 个可打印字符来表示二进制数据的表示方法(由于 2^664,所以每 6 个比特为一个单…

PHP/7.2.11 缺少 apache2/logs/httpd.pid 文件

启动服务时:systemctl restart httpd.service,报错:● httpd.service - httpd serviceLoaded: loaded (/etc/systemd/system/httpd.service; enabled; vendor preset: disabled)Active: failed (Result: exit-code) since 五 2023-02-24 16:1…

Matlab进阶绘图第2期—线型热图

线型热图由共享X轴的多条渐变直线组成,其颜色表示某一特征值。 与传统热图相比,线型热图适应于X轴数据远多于Y轴(条数)的情况,可以很好地对不同组数据间的分布情况进行比较,也因此可以在一些期刊中看到它的…

IM即时通讯架构技术:可靠性、有序性、弱网优化等

消息的可靠性是IM系统的典型技术指标,对于用户来说,消息能不能被可靠送达(不丢消息),是使用这套IM的信任前提。 换句话说,如果这套IM系统不能保证不丢消息,那相当于发送的每一条消息都有被丢失的…

Unity 对接 ML-Agents 初探

一、ML-Agents 是什么 The Unity Machine Learning Agents Toolkit (ML-Agents) is an open-source project that enables games and simulations to serve as environments for training intelligent agents. We provide implementations (based on PyTorch) of state-of-the…

为什么很多人转行IT考虑后端开发Java?

顺应互联网时代发展的选择 在计算机广泛运用于社会的各个角落的今天,选择学习一门计算机语言真的很不错,它会让你的生活从此与众不同。软件渗透到组织的运营和管理的后台之中,形成了组织运营支撑平台。这种形态是传统软件的重要应用场景。在…

matlab simulink Buck三电平dcdc变换器

1、内容简介略653-可以交流、咨询、答疑2、内容说明三电平dc/dc变换器的发展是建立在三电平逆变器的基础上的,由于在三电平dc/dc变换器中每个开关管的电压应力是输入直流电压的一半,具有降低开关管电压应力、减小输入输出滤波器大小、减小储能电感和电容…

【Linux应用】进程间通信消息队列

1.前言 进程间通信简称IPC(Inter process communication),进程间通信就是在不同进程之间传播或交换信息。 消息队列是进程通信的一种方式,本质是一个存储消息的链表,这些消息具有特定的格式及特定优先级。消息队列是…