Redis7——进阶篇(三)

 前言:此篇文章系本人学习过程中记录下来的笔记,里面难免会有不少欠缺的地方,诚心期待大家多多给予指教。


基础篇:

  1. Redis(一)
  2. Redis(二)
  3. Redis(三)
  4. Redis(四)
  5. Redis(五)
  6. Redis(六)
  7. Redis(七)
  8. Redis(八)

进阶篇:

  1. Redis(九)
  2. Redis(十)

接上期内容:上期完成了RedisBigKey和MoreKey方面的学习。下面开始学习Redis缓存双写的知识,话不多说,直接发车。


一、引言

在当今高并发、大数据量的应用场景中,缓存技术已经成为提升系统性能和响应速度的关键手段。然而,在使用缓存的过程中,如何保证数据库和缓存之间的数据一致性是一个极具挑战性的问题。缓存双写作为其中一种处理方式,对于理解数据一致性至关重要。本文将探讨缓存双写的相关概念、策略以及如何保证数据库和缓存的一致性。


二、什么是缓存双写?

缓存双写是指在对数据库进行写操作(如插入、更新、删除)时,同时也对缓存进行相应的写操作。其目的是确保数据库和缓存中的数据尽可能保持一致。例如,当应用程序更新了数据库中某条用户记录的信息后,为了让后续的读操作能够从缓存中获取到最新的数据,需要同时更新缓存中对应的用户记录。

缓存双写看似简单直接,但在实际应用中,由于数据库和缓存的读写性能差异、网络延迟、并发操作等因素的影响,要实现完全的一致性并不容易。如果处理不当,可能会导致数据库和缓存中的数据不一致,进而影响到应用程序的正确性。


三、缓存策略

缓存按照操作划分为只读缓存和读写缓存,读写缓存又分为同步直写和异步缓写策略。目的:使redis缓存和数据库数据一致。

(一)、同步直写策略

1、原理

同步直写(Synchronous Write - Through)策略,也叫读写穿透策略。在写操作时,应用程序向数据库发送写请求,数据库接收到请求后,会先将数据写入。当数据库写入成功后,会同步将相同的数据写入缓存中。只有当缓存和数据源都成功写入数据后,才会向应用程序返回写入成功的响应。在读操作时,应用程序先从缓存中读取数据,如果缓存命中,直接返回数据;若缓存未命中,则从数据源中读取数据,然后将数据写入缓存并返回给应用程序。

简单来说:写数据库后也同步写redis缓存,保证缓存和数据库中的数据⼀致;读数据先从缓存读,有返回;无,则从数据库读,在回写到缓存。


2、优劣势

优:

  • 数据一致性高由于每次写操作都同时更新缓存和数据源,能最大程度保证两者的数据一致性,很大程度上避免了数据不一致的情况发生。
  • 实现相对简单该策略的逻辑较为清晰,不需要复杂的异步处理和额外的一致性检查机制,开发和维护相对容易。

劣:

  • 性能瓶颈写操作需要等待缓存和数据源都完成写入后才能返回,这会增加写操作的响应时间。在高并发写操作场景下,可能会导致系统性能下降,因为每次写操作都需要等待,可能会出现大量的请求阻塞。

(二)、异步缓写策略

1、原理

异步缓写(Asynchronous Write-Behind)策略,也叫异步写入缓存策略。在写操作时,应用程序向数据库发送写请求,数据库接收到请求后,会立即将数据写入,并迅速向应用程序返回写入成功的响应,而不会等待缓存的写入完成。然后,程序会以异步(消息队列)的方式将数据写入缓存。在读操作时,与同步直写策略类似,先从缓存中读取数据,如果缓存命中,直接返回数据;若缓存未命中,则从数据源中读取数据,然后将数据写入缓存并返回给应用程序。

简单来说:先将数据写入,在异步将数据写入缓存读数据先从缓存读,有返回;无,则从数据库读,在回写到缓存。


2、优劣势

优:

  • 高写性能:写操作只需要将数据写入数据源,不需要等待缓存写入操作,响应时间短

劣:

  • 数据一致性风险由于数据写入缓存是异步的,可能会出现缓存和底层存储数据不一致的情况。
  • 数据库承受风险大如果在数据还未写入缓存时,缓存为空,过多的请求直接绕过缓存,请求数据库,数据库可能因此宕机。

四、什么是双检加锁?

(一)、概念

双检加锁(Double-Checked Locking)策略常用于多线程环境下,确保在高并发场景下对缓存的操作是线程安全的。

双检加锁策略常用于实现分布式锁,用来保证缓存数据的一致性和高效性,比如在缓存击穿场景中,大量并发请求同时查询一个刚好过期的缓存项,此时多个请求可能会同时去查询数据库并更新缓存,使用双检加锁策略可以避免这种情况。


(二)、原理

1、首先进行第一次检查,判断缓存对象是否已经存在。如果已经存在,直接返回缓存对象,避免不必要的加锁操作,提高性能。

2、如果第一次检查发现缓存对象不存在,则进入同步代码块(加锁)。

3、在同步代码块内,进行第二次检查,再次确认缓存对象是否存在。这是因为在多线程环境下,可能有多个线程同时通过了第一次检查,进入同步代码块之前,其他线程可能已经回写了缓存对象。

4、如果第二次检查发现缓存对象仍然不存在,则从数据库获取数据并回写缓存对象。


(三)、实现

基于双检加锁策略的缓存查询方法的实现。

public String get(String key) {
        String value = redis.get(key);// 查询缓存
        if (value != null) {
            //缓存存在直接返回
            return value;
        } else {
            //缓存不存在则对方法加锁
            //假设请求量很大,缓存过期
            synchronized (this) {
                value = redis.get(key); // 在查一遍redis
                if (value == null) {
                    // 从数据库获取数据
                    value = dao.get(key);
                    // 设置过期时间并回写到缓存
                    redis.setex(key, time, value);
                }
                return value;
            }
        }
    }

五、数据库和缓存一致性更新策略

(一)、目的

目的:最终实现缓存和数据库的数据一致。


(二)、常用策略

1、先更新数据库,在更新缓存

1.1、问题①

场景模拟一:

  1. A update myslq 100   --success
  2. A update redis 100 -- error

最终结果,数据库里面和缓存redis里面数据不一致,下一次读到redis脏数据。


1.2、问题②

场景模拟二:

【正常逻辑】:先更新数据库,再更新缓存A、B两个线程发起调用

  1. A update mysql 100
  2. A update redis 100
  3. B update mysql 80
  4. B update redis 80

最终结果,mysql和redis数据一致,皆大欢喜。


【异常逻辑】:多线程环境下,A、B两个线程有快有慢,有前有后并行

  1. A update mysql 100
  2. B update mysql 80
  3. B update redis 80
  4. A update redis 100

最终结果,mysql和redis数据不一致,o(╥﹏╥)o。


2、先更新缓存,在更新数据库

出现的异常问题跟第一种策略类似。此外,业内一般把数据库作为底单数据库,保证最后解释,一切都以数据库为主。


3、先更新数据库,在删除缓存

3.1、异常问题

先更新数据库,再删除缓存场景模拟A、B两个线程发起调用

  1. A update mysql 100-80  --(更新耗时)
  2. B read redis 100
  3. A del redis
  4. A set redis 20

最终结果:假如缓存删除失败或者还没来得及更新,导致请求再次访问redis时缓存命中,读取到的是缓存旧值。


3.2、解决方案

参考微软、阿里巴巴的解决思想,引入中间件来解决问题,最终实现数据库和缓存数据一致。

解释说明:

  1. 可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中(例如使用Kafka/RabbitMQ等)。
  2. 当程序没有能够成功地删除缓存值或者是更新数据库值时,可以从消息队列中重新读取这些值,然后再次进行删除或更新。
  3. 如果能够成功地删除或更新,我们就要把这些值从消息队列中去除,以免重复操作,此时,我们也可以保证数据库和缓存的数据一致了,否则还需要再次进行重试。
  4. 如果重试超过的一定次数后还是没有成功,我们就需要向业务层发送报错信息了,通知运维人员。

4、先删除缓存,在更新数据库

4.1、异常问题

场景模拟:

先删除缓存,在更新数据库A、B两个线程发起调用:

  1. A del redis,A update mysql  100-80 --(更新耗时,没有commit)
  2. B read redis == null
  3. B select mysql 100, B set redis 100
  4. A update commit 20

最终结果:如果数据库更新失败或超时或返回不及时,导致B线程请求访问缓存时发现redis里面没数据,缓存缺失,B再去读取mysql时,从数据库中读取到旧值,还写回redis,最终导致redis和数据库数据不一致。


4.2、解决方案

通过延迟双删来解决此问题。

①、定义

延迟双删是一种在缓存和数据库数据同步场景中,用于解决缓存和数据库数据不一致问题的策略,常应用于更新数据库数据时对缓存的处理。


②、原理

在更新数据库数据时,由于缓存和数据库的操作不是原子性的,可能会出现缓存和数据库数据不一致的情况。延迟双删策略通过两次删除缓存的操作,中间添加一定的延迟时间,尽量保证在并发场景下缓存和数据库的数据最终一致性。


③、实现

部分逻辑实现。

public void delete(Order order) {
        try (Jedis jedis = redis) {
            // 第一次删除redis缓存
            jedis.del("order:" + order.getId());
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            // 第二次删除redis缓存
            jedis.del("order:"+order.getId());
        }
    }

解释:加上sleep的这段时间,就是为了让线程B能够先从数据库读取数据,再把缺失的数据写入缓存然后,线程A再进行删除。所以,A线程sleep的时间,就需要大于线程B读取数据再写入缓存的时间。这样一来,其它线程读取数据时,会发现缓存缺失,所以会从数据库中读取最新值。因为这个方案会在第一次删除缓存值后,延迟一段时间再次进行删除,所以我们也把它叫做“延迟双删”。


 ④、其他问题

Q1:这个睡眠时间应该是多少?怎么确定?

A1:①、在业务程序运行的时候,统计下线程读数据和写缓存的操作时间,自行评估项目的读数据业务逻辑的耗时,以此为基础来进行估算。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上加百毫秒即可。②、新启动一个后台监控程序,比如WatchDog监控程序。


Q2:这种同步删除的方式降低了系统吞吐量怎么办?

A2:在第二次删除的时候改用异步删除或消息队列来实现。

public void delete(Order order) {
        try (Jedis jedis = redis) {
            // 第一次删除redis缓存
            jedis.del("order:" + order.getId());
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            // 第二次删除redis缓存
            CompletableFuture.supplyAsync(() -> jedis.del("order:" + order.getId())).whenComplete((t, u) -> {
                // 日志记录
            }).exceptionally(e -> {
                // 日志记录
                return null;
            }).thenAccept(r ->{
                if (r != null) {
                    // 日志记录
                }else {
                    // 日志记录
                }
            });
        }
    }

(三)、小总结

在实际的项目,以上四种方案应该如何选择呢?

第一二种,在高并发的场景下肯定是少用的,第三四种中选择的话,一般会选择使用先更新数据库,再删除缓存的方案。理由如下:

  1. 先删除缓存值再更新数据库,有可能导致请求因缓存缺失而访问数据库,给数据库带来压力导致数据库可能宕机。
  2. 如果业务应用中读取数据库和写缓存的时间不好估算,那么,延迟双删中的等待时间也是不好设置。

此外,如果业务层要求必须读取一致性的数据,那么我们就需要在更新数据库时,先在Redis缓存客户端暂停并发读请求,等数据库更新完、缓存值删除后,再读取数据,从而保证数据一致性,这是理论可以达到的效果,但实际,不推荐,因为真实生产环境中,分布式下很难做到实时一致性,一般都是最终一致性。

最后一图总结三、四方案


六、CANAL双写一致性案例

(一)、CANAL基础知识

1、定义

CANAL 是阿里巴巴开源的一款基于数据库增量日志解析,提供增量数据订阅和消费的中间件。其设计灵感来源于 MySQL 的主从复制原理。在 MySQL 的主从架构中,主库会将数据变更记录到二进制日志(binlog)中,从库通过解析这些 binlog 来获取数据变更并同步数据。CANAL 模拟了从库的行为,伪装成 MySQL 的从库,向 MySQL 主库发送 dump 协议请求 binlog 日志,然后对获取到的 binlog 进行解析,将其转换为易于理解的结构化数据,如 SQL 语句或数据对象的变更信息,提供给下游的应用进行消费和处理。

简单来说,CANAL 就像是一个数据变更的 “监听者” 和 “搬运工”,能够实时捕获数据库中的数据变化,并将这些变化传递给其他需要的系统,以实现数据的同步、缓存更新、搜索索引构建等功能。

官网地址:GitHub - alibaba/canal: 阿里巴巴 MySQL binlog 增量订阅&消费组件


2、具备什么功能

  1. 数据库镜像
  2. 数据库实时备份
  3. 索引构建和实时维护(拆分异构索引、倒排索引等)
  4. 业务CACHE刷新
  5. 带业务逻辑的增量数据处理

3、工作原理

其设计灵感来源于MySQL的主从复制原理。

MYSQL主从复制原理:

  1. 当 master 主服务器上的数据发生改变时,则将其改变写入二进制事件日志文件中;
  2. salve 从服务器会在一定时间间隔内对 master 主服务器上的二进制日志进行探测,探测其是否发生过改变,如果探测到 master 主服务器的二进制事件日志发生了改变,则开始一个 I/O Thread 请求 master 二进制事件日志;
  3. 同时 master 主服务器为每个 I/O Thread 启动一个dump  Thread,用于向其发送二进制事件日志;
  4. slave 从服务器将接收到的二进制事件日志保存至自己本地的(Replay log)中继日志文件中;
  5. salve 从服务器将启动 SQL Thread 从中继日志中读取二进制日志,在本地重放,使得其数据和主服务器保持一致;
  6. 最后 I/O Thread 和 SQL Thread 将进入睡眠状态,等待下一次被唤醒;

Canal工作原理:

  1. canal 模拟 MySQL slave 的交互协议,伪装自己为 MySQL slave ,向 MySQL master 发送dump 协议。
  2. MySQL master 收到 dump 请求,开始推送 binary log 给 slave (即 canal )。
  3. canal 解析 binary log 对象(原始为 byte 流)。
  4. canal在转发给其他中间件。

(二)、实操

1、MYSQL端设置

1.1、确认mysql版本
select version();

注:不规定必须使用一样版本,但要确保后续下载canal支持对应的mysql版本。


1.2、查看主机二进制日志
SHOW MASTER STATUS;

File

当前正在使用的二进制日志文件的名称。例如 DESKTOP-HBBL3VO-bin.000141,表示主服务器当前将更改操作记录到这个文件中。

Position

二进制日志文件中的当前位置,即下一个要写入的事件的偏移量。它是一个整数,用于标识从服务器应该从哪个位置开始读取二进制日志。

Binlog_Do_DB

显示主服务器配置的只记录特定数据库更改的列表。如果设置了该选项,主服务器只会将指定数据库的更改记录到二进制日志中。

Binlog_Ignore_DB

显示主服务器配置的忽略记录更改的数据库列表。主服务器不会将这些数据库的更改记录到二进制日志中。


1.3、查看并开启log_bin功能

正常应该是OFF,我这个是开启后的。

SHOW VARIABLES LIKE 'log_bin';

1.4、修改配置、重启mysql服务

找到mysql安装目录,修改my.ini文件(记得备份)

保存,重启mysql服务。

1.5、授权canal

mysql默认没有canal用,这里需要创建用户+授权。

DROP USER IF EXISTS 'canal'@'%';
CREATE USER 'canal'@'%' IDENTIFIED BY 'canal';  
GRANT ALL PRIVILEGES ON *.* TO 'canal'@'%'; 
FLUSH PRIVILEGES;
 
SELECT * FROM mysql.user;

注意:Mysql版本不一样,授权语法不一样。(我的是Mysql8) 

至此,mysql的配置全部搞定,完美收官👍。


2、Canal服务端设置

2.1、前提说明

 安装Canal 之前,需要确保系统已经安装了 Java 环境,因为 Canal 是基于 Java 开发的。

2.2、java环境配置
①、查看jdk版本
dnf search java | grep openjdk


②、下载对应版本

输入yes,下载jdk

dnf install java-17-openjdk-devel


③、 配置环境变量

在末尾插入

export JAVA_HOME=自己安装目录
export PATH=$JAVA_HOME/bin:$PATH
export CLASSPATH=.:$JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar

查看自己jdk安装目录:

alternatives --display java


2.3、下载canal
wget https://github.com/alibaba/canal/releases/download/canal-1.1.8/canal.deployer-1.1.8.tar.gz


2.4、解压canal
tar -zxvf canal.deployer-1.1.8.tar.gz

 *注意:提前建好文件夹 


2.5、配置canal

修改/mycanal/canal-1.1.8/conf/example/instance.properties文件

由于canal连接mysql的账号默认为canal,所以这个配置文件中的用户名密码不需要改动。


2.6、启动并查看canal

进入/mycanal/canal-1.1.8/bin目录下启动canal:

查看canal启动状态,通过日志查看:

tail -f /mycanal/canal-1.1.8/logs/canal/canal.log

看见canal server is running now...代表canal启动成功。


3、Canal客户端配置

3.1、导入相关依赖
        <!--canal-->
        <dependency>
            <groupId>com.alibaba.otter</groupId>
            <artifactId>canal.client</artifactId>
            <version>1.1.4</version>
        </dependency>
        <!--Mysql数据库驱动-->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <version>8.2.0</version>
            <scope>runtime</scope>
        </dependency>
        <!--SpringBoot集成druid连接池-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.1.10</version>
        </dependency>

3.2、修改application.properties文件
#======================alibaba.druid====================
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/mysql库名?useUnicode=true&characterEncoding=utf-8&useSSL=false
spring.datasource.username=账号
spring.datasource.password=密码
spring.datasource.druid.test-while-idle=true

3.3、编写测试例
①、连接redis工具类

public class RedisUtils {
    public static final String REDIS_IP_ADDR = "192.168.112.129";
    public static final String REDIS_pwd = "root";
    public static JedisPool jedisPool;

    static {
        JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
        jedisPoolConfig.setMaxTotal(20);
        jedisPoolConfig.setMaxIdle(10);
        jedisPool = new JedisPool(jedisPoolConfig, REDIS_IP_ADDR, 6379, 10000, REDIS_pwd);
    }

    public static Jedis getJedis() throws Exception {
        if (null != jedisPool) {
            return jedisPool.getResource();
        }
        throw new Exception("Jedis pool is not ok");
    }
}
②、java连接canal服务器的测试类
public class RedisCanalClientExample {
    public static final Integer _60SECONDS = 60;

    private static void redisInsert(List<Column> columns) {
        JSONObject jsonObject = new JSONObject();
        for (Column column : columns) {
            System.out.println(column.getName() + " : " + column.getValue() + "    update=" + column.getUpdated());
            jsonObject.put(column.getName(), column.getValue());
        }
        if (!columns.isEmpty()) {
            try (Jedis jedis = RedisUtils.getJedis()) {
                // 将第一列作为key存入redis缓存,value为整个对象
                jedis.set(columns.get(0).getValue(), jsonObject.toJSONString());
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }


    private static void redisDelete(List<Column> columns) {
        if (!columns.isEmpty()) {
            try (Jedis jedis = RedisUtils.getJedis()) {
                jedis.del(columns.get(0).getValue());
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    private static void redisUpdate(List<Column> columns) {
        JSONObject jsonObject = new JSONObject();
        for (Column column : columns) {
            System.out.println(column.getName() + " : " + column.getValue() + "    update=" + column.getUpdated());
            jsonObject.put(column.getName(), column.getValue());
        }
        if (!columns.isEmpty()) {
            try (Jedis jedis = RedisUtils.getJedis()) {
                // 将第一列作为key存入redis缓存,value为整个对象
                jedis.set(columns.get(0).getValue(), jsonObject.toJSONString());
                System.out.println("---------update after: " + jedis.get(columns.get(0).getValue()));
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    public static void printEntry(List<Entry> entrys) {
        for (Entry entry : entrys) {
            if (entry.getEntryType() == EntryType.TRANSACTIONBEGIN || entry.getEntryType() == EntryType.TRANSACTIONEND) {
                continue;
            }

            RowChange rowChage = null;
            try {
                //获取变更的row数据
                rowChage = RowChange.parseFrom(entry.getStoreValue());
            } catch (Exception e) {
                throw new RuntimeException("ERROR ## parser of romancer-event has an error,data:" + entry, e);
            }
            //获取变动类型
            EventType eventType = rowChage.getEventType();
            System.out.printf("================&gt; binlog[%s:%s] , name[%s,%s] , eventType : %s%n",
                    entry.getHeader().getLogfileName(),
                    entry.getHeader().getLogfileOffset(),
                    entry.getHeader().getSchemaName(),
                    entry.getHeader().getTableName(),
                    eventType);

            for (RowData rowData : rowChage.getRowDatasList()) {
                if (eventType == EventType.INSERT) {
                    redisInsert(rowData.getAfterColumnsList());
                } else if (eventType == EventType.DELETE) {
                    redisDelete(rowData.getBeforeColumnsList());
                } else {//EventType.UPDATE
                    redisUpdate(rowData.getAfterColumnsList());
                }
            }
        }
    }


    public static void main(String[] args) {
        System.out.println("---------O(∩_∩)O哈哈~ initCanal() main方法-----------");

        //=================================
        // 创建链接canal服务端
        CanalConnector connector = CanalConnectors.newSingleConnector(new InetSocketAddress(RedisUtils.REDIS_IP_ADDR, 11111),
                "example", "", "");
        int batchSize = 1000;
        //空闲空转计数器
        int emptyCount = 0;
        System.out.println("---------------------canal init OK,开始监听mysql变化------");
        try {
            connector.connect();
            //connector.subscribe(".*\\..*");
            connector.subscribe("work.user");
            connector.rollback();
            int totalEmptyCount = 10 * _60SECONDS;
            while (emptyCount < totalEmptyCount) {
                System.out.println("我是canal,每秒一次正在监听:" + UUID.randomUUID());
                Message message = connector.getWithoutAck(batchSize); // 获取指定数量的数据
                long batchId = message.getId();
                int size = message.getEntries().size();
                if (batchId == -1 || size == 0) {
                    emptyCount++;
                    try {
                        TimeUnit.SECONDS.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                } else {
                    //计数器重新置零
                    emptyCount = 0;
                    printEntry(message.getEntries());
                }
                connector.ack(batchId); // 提交确认
                // connector.rollback(batchId); // 处理失败, 回滚数据
            }
            System.out.println("已经监听了" + totalEmptyCount + "秒,无任何消息,请重启重试......");
        } finally {
            connector.disconnect();
        }
    }
}

 测试代码主要来自canal的官方文档,修改了部分代码。https://github.com/alibaba/canal/wiki/ClientExample


③、测试功能

直接main方法启动:

java端一直监控本地mysql,一旦本地mysql有增删改操作,会立马写入redis缓存中:

随便改动一条数据进行测试:

redis缓存中没有:

控制台输出:

redis查看:

总结:通过canal消息中间件,可以在一定程度上解决redis和数据库一致性问题。但是在真实生产环境中,特别是分布式下很难做到实时一致性,最终一致性是一种更为合理和可行的选择

一定要记得临时关闭Linux的防火墙,我又在这里栽了一个大跟斗/(ㄒoㄒ)/~~。。。。


④、注意事项
connector.subscribe(".*\\..*")

 根据实际情况来监控

全库全表connector.subscribe(".*\\..*")
指定库全表connector.subscribe("test\\..*")
单库单表connector.subscribe("test.test")
多规则组合connector.subscribe("test\\..*,test.test1,test2.test2,test3.test")

七、经典面试题

1、问题一

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

A1:

1. 缓存失效策略

  • 更新数据库后删除缓存:在更新数据库之后,直接删除对应的缓存。(高并发下慎用)

  • 延迟双删:在更新数据库后,先删除缓存,然后在经过一定的延迟后再次删除缓存。

2. 消息队列异步更新

  • 使用消息队列:将数据库的更新操作发送到消息队列中,消费者从消息队列中获取更新信息,然后更新缓存。这样可以保证缓存的更新和数据库的更新是异步进行的,减少了对业务逻辑的影响。例如,使用 Kafka 或 RabbitMQ 作为消息队列,当数据库更新时,将更新信息发送到消息队列,缓存更新服务从队列中获取消息并更新缓存。

3. 分布式锁

  • 加锁保证一致性:在进行数据库和缓存的更新操作时,使用分布式锁(如 Redis 分布式锁)来保证同一时间只有一个线程可以进行更新操作。


2、问题二

在处理双写一致性问题时,你是先动缓存redis还是数据库,为什么?

A2:

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

  • 原因:这种方式更为推荐,因为数据库是数据的最终存储源,保证数据库的更新成功是首要任务。如果先删除缓存,在更新数据库的过程中,可能会有其他请求读取到旧的数据库数据并重新写入缓存,导致缓存和数据库的数据不一致。而先更新数据库,再删除缓存,即使在删除缓存之前有其他请求读取到了旧的缓存数据,后续的请求也会因为缓存被删除而从数据库中读取最新的数据并更新缓存。


3、问题三

在处理双写一致性问题时,你做过延时双删吗?会有哪些问题?

A3:

做过。可能存在的问题:

  • 延迟时间难以确定:延迟时间设置过短,可能无法保证在这段时间内所有的旧缓存数据都不会被重新写入;延迟时间设置过长,会导致在这段时间内缓存数据一直处于不一致的状态,影响系统的性能和用户体验。

  • 增加系统复杂度:延时双删需要引入定时任务或消息队列等机制来实现延迟操作,增加了系统的复杂度和维护成本。


4、问题四

有这么一种情况,微服务查询缓存没有数据,查询数据库有数据,为保证数据双写一致性,回写到缓存你需要注意什么?双检加锁策略你了解过吗?如何尽量避免缓存击穿?

A4:

1、回写 Redis 注意事项

  • 数据一致性:在回写 Redis 时,要确保从数据库中读取的数据是最新的,避免将旧的数据写入缓存。可以在读取数据库时,使用数据库的事务机制来保证数据的一致性。

  • 并发问题:在高并发场景下,可能会有多个请求同时发现 Redis 中没有数据,然后同时从数据库中读取数据并回写 Redis。这可能会导致多次重复读取数据库,增加数据库的压力。可以使用双检加锁策略来解决这个问题。

2. 双检加锁策略

  • 解释:在从数据库中读取数据并回写 Redis 时,先进行一次缓存检查,如果缓存中没有数据,再获取分布式锁;获取锁成功后,再次检查缓存,如果缓存中仍然没有数据,就从数据库中读取数据并回写 Redis,最后释放锁。

  • 示例:

    public String get(String key) {
            String value = redis.get(key);// 查询缓存
            if (value != null) {
                //缓存存在直接返回
                return value;
            } else {
                //缓存不存在则对方法加锁
                //假设请求量很大,缓存过期
                synchronized (this) {
                    value = redis.get(key); // 在查一遍redis
                    if (value == null) {
                        // 从数据库获取数据
                        value = dao.get(key);
                        // 设置过期时间并回写到缓存
                        redis.setex(key, time, value);
                    }
                    return value;
                }
            }
        }

3. 避免缓存击穿

  • 设置热点数据永不过期:对于一些热点数据,可以设置其在 Redis 中永不过期,然后通过定时任务或其他机制来更新这些数据。

  • 使用布隆过滤器:在查询缓存之前,先使用布隆过滤器判断该数据是否可能存在于缓存中。如果布隆过滤器判断数据不存在,就直接返回,避免了对数据库的查询。


5、问题五

缓存和数据库双写百分百会出纰漏,做不到强一致性,你如何保证最终一致性?

A5:

1. 消息队列重试机制

  • 解释:当数据库更新成功,但缓存更新失败时,将更新信息发送到消息队列中。消息队列的消费者不断重试更新缓存,直到缓存更新成功为止。可以设置最大重试次数和重试间隔时间,避免无限重试。

2. 定时任务检查

  • 解释:定期检查数据库和缓存的数据是否一致,如果发现不一致,就进行相应的更新操作。可以设置检查的时间间隔,根据业务需求来调整。

3. 异步监听binlog

  • 解释:使用Canal监听数据库变更日志,将变更事件发送到消息队列(Kafka/RabbitMQ),消费者根据日志删除/更新缓存。


八、总结

缓存双写一致性是一个复杂而又重要的问题,在实际应用中需要根据具体的业务场景和性能需求选择合适的缓存策略和一致性更新策略。无论是同步直写还是异步缓写入,都有其各自的优缺点。同时,双检加锁等策略可以帮助我们在多线程环境下更好地管理缓存。

而数据库和缓存一致性更新策略的选择,更是需要综合考虑数据一致性要求、系统性能、复杂性等多方面因素。通过不断地实践和优化,我们可以在提升系统性能的同时,尽可能地保证数据库和缓存之间的数据一致性。

最后,分享一些关于此话题的经典面试题,希望熟练掌握这些内容后,在未来求职面试中,胸有成竹、对答如流,早日斩获心仪的 offer!


ps:努力到底,让持续学习成为贯穿一生的坚守。学习笔记持续更新中。。。。

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

云原生时代的技术桥梁

在数字化转型的大潮中&#xff0c;企业面临着数据孤岛、应用间集成复杂、高成本与低效率等问题。这些问题不仅阻碍了企业内部信息的流通和资源的共享&#xff0c;也影响了企业对外部市场变化的响应速度。当前&#xff0c;这一转型过程从IT角度来看&#xff0c;已然迈入云原生时…

ICLR 2025|香港浸会大学可信机器学习和推理课题组专场

点击蓝字 关注我们 AI TIME欢迎每一位AI爱好者的加入&#xff01; AITIME 01 ICLR 2025预讲会团队专场 AITIME 02 专场信息 01 Noisy Test-Time Adaptation in Vision-Language Models 讲者&#xff1a;曹晨涛&#xff0c;HKBU TMLR Group一年级博士生&#xff0c;目前关注基础…

ProfibusDP主站转ModbusTCP网关如何进行数据互换

ProfibusDP主站转ModbusTCP网关如何进行数据互换 在现代工业自动化领域&#xff0c;通信协议的多样性和复杂性不断增加。Profibus DP作为一种经典的现场总线标准&#xff0c;广泛应用于工业控制网络中&#xff1b;而Modbus TCP作为基于以太网的通信协议&#xff0c;因其简单易…

016.3月夏令营:数理类

016.3月夏令营&#xff1a;数理类&#xff1a; 中国人民大学统计学院&#xff1a; http://www.eeban.com/forum.php?modviewthread&tid386109 北京大学化学学院第一轮&#xff1a; http://www.eeban.com/forum.php?m ... 6026&extrapage%3D1 香港大学化学系夏令营&a…

使用IDEA如何隐藏文件或文件夹

选择file -> settings 选择Editor -> File Types ->Ignored Files and Folders (忽略文件和目录) 点击号就可以指定想要隐藏的文件或文件夹

通过微步API接口对单个IP进行查询

import requests import json# 微步API的URL和你的API密钥 API_URL "https://api.threatbook.cn/v3/ip/query" API_KEY "***" # 替换为你的微步API密钥 def query_threatbook(ip):"""查询微步API接口&#xff0c;判断IP是否为可疑"…

第七节:基于Winform框架的串口助手小项目---协议解析《C#编程》

介绍 目标 代码实现 private void serialPort1_DataReceived(object sender, SerialDataReceivedEventArgs e){if (isRxShow false) return;// 1,需要读取有效的数据 BytesToReadbyte[] dataTemp new byte[serialPort1.BytesToRead];serialPort1.Read(dataTemp,0,dataTemp.Le…

关于tresos Studio(EB)的MCAL配置之GPT

概念 GPT&#xff0c;全称General Purpose Timer&#xff0c;就是个通用定时器&#xff0c;取的名字奇怪了点。定时器是一定要的&#xff0c;要么提供给BSW去使用&#xff0c;要么提供给OS去使用。 配置 General GptDeinitApi控制接口Gpt_DeInit是否启用 GptEnableDisable…

C语言基础要素(011):增量、减量运算

让变量自身加一或减一是一种常用的运算&#xff0c;C语言提供了增量与减量运算符支持这些操作。 增量运算() 让变量自身加1&#xff0c;可以这样实现&#xff1a; int size 3; size size 1; // 语句执行后 size 值为 4 size 1; // 语句执行后 size 值为 5使…

深入探索WebGL:解锁网页3D图形的无限可能

深入探索WebGL&#xff1a;解锁网页3D图形的无限可能 引言 。WebGL&#xff0c;作为这一变革中的重要技术&#xff0c;正以其强大的功能和广泛的应用前景&#xff0c;吸引着越来越多的开发者和设计师的关注。本文将深入剖析WebGL的核心原理、关键技术、实践应用&#xff0c;并…

Python +Anaconda,DeepSeeK API入门小例子

一、环境搭建 1.安装pycharm 、anaconda&#xff0c;deepseek官网申请api key(不会的去百度&#xff0c;申请完了可以充值几块钱&#xff0c;现在官网应该没有免费token可以测试了) 2.anaconda创建虚拟环境 &#xff0c;打开windows dos界面依次输入 命令&#xff1a;1) con…

股指期货的交易时间是几点到几点?

股指期货是一种金融衍生品&#xff0c;简单来说&#xff0c;就是以股票指数&#xff08;比如沪深300指数&#xff09;为标的的期货合约。投资者可以通过买卖这些合约来对冲风险或者投机。它的交易方式和股票有点像&#xff0c;但又有自己的特点。 股指期货的交易时间是什么时候…

推流项目的ffmpeg配置和流程重点总结一下

ffmpeg的初始化配置&#xff0c;在合成工作都是根据这个ffmpeg的配置来做的&#xff0c;是和成ts流还是flv&#xff0c;是推动远端还是保存到本地&#xff0c; FFmpeg 的核心数据结构&#xff0c;负责协调编码、封装和写入操作。它相当于推流的“总指挥”。 先来看一下ffmpeg的…

数字电子技术基础(二十四)——TTL门电路的高、低电平的输出特性曲线

目录 1 TTL门电路的特性曲线 1.1 高电平输出特性 1.1.2 高电平输出特性的实验过程 1.1.2 TTL门电路的输出特性的实验结果 1.2 低电平的输出特性 1 TTL门电路的特性曲线 1.1 高电平输出特性 1.1.2 高电平输出特性的实验过程 现在想要测试TTL门电路的输出特性&#xff0c…

盛铂科技SCP4000射频微波功率计与SPP5000系列脉冲峰值 USB功率计 区别

在射频&#xff08;RF&#xff09;和微波测试领域&#xff0c;快速、精准的功率测量是确保通信系统、雷达、卫星设备等高性能运行的核心需求。无论是连续波&#xff08;CW&#xff09;信号的稳定性测试&#xff0c;还是脉冲信号的瞬态功率分析&#xff0c;工程师都需要轻量化、…

GCC RISCV 后端 -- cc1 入口

GCC编译工具链中的 gcc 可执行程序&#xff0c;实际上是个驱动程序&#xff08;Driver&#xff09;&#xff0c;其根据输入的参数&#xff0c;然后调用其它不同的程序&#xff0c;对输入文件进行处理&#xff0c;包括编译、链接等。可以通过以下命令查看&#xff1a; gcc -v h…

C++第二十讲:C++11

C第二十讲&#xff1a;C11 1.列表初始化1.1C98时的{}初始化1.2C11的新规{}初始化1.3initializer_list初始化 2.右值引用和移动语义2.1右值引用2.1.1左值和右值2.1.2左值引用和右值引用2.1.3引用延长声明周期2.1.4左值和右值的参数匹配 2.2右值引用和移动语义的使用2.2.1移动构造…

Finebi_求组内占比和组内累计占比

需求&#xff1a;原始数据结构如下&#xff0c;要求各每个月的各产品销量占比&#xff0c;至每月的各产品销量累计占比 实现步骤&#xff1a; ①维度拖入日期&#xff0c;按年月分组 ②各产品销量占比DEF(SUM_AGG(${&#xfeff;产品销量表&#xfeff;_&#xfeff;销量&…

PE文件结构详解(DOS头/NT头/节表/导入表)使用010 Editor手动解析notepad++.exe的PE结构

一&#xff1a;DOS部分 DOS部分分为DOS MZ文件头和DOS块&#xff0c;其中DOS MZ头实际是一个64位的IMAGE_DOS——HEADER结构体。 DOS MZ头部结构体的内容如下&#xff0c;我们所需要关注的是前面两个字节&#xff08;e_magic&#xff09;和后面四个字节&#xff08;e_lfanew&a…

自由学习记录(41)

代理服务器的核心功能是在客户端&#xff08;用户设备&#xff09;和目标服务器&#xff08;网站/资源服务器&#xff09;之间充当“中介”&#xff0c;具体过程如下&#xff1a; 代理服务器的工作流程 当客户端希望访问某个网站&#xff08;比如 example.com&#xff09;时&…