Redis 和 MySQL双写一致性的更新策略有哪些?常见面试题深度解答。

目录

[一. 业务数据查询,更新顺序简要分析](#一. 业务数据查询,更新顺序简要分析)

[二. 更新数据库、查询数据库、更新缓存、查询缓存耗时对比](#二. 更新数据库、查询数据库、更新缓存、查询缓存耗时对比)

[2.1 更新数据库(最慢)](#2.1 更新数据库(最慢))

[2.2 查询数据库(较慢)](#2.2 查询数据库(较慢))

[2.3 更新缓存(次快)](#2.3 更新缓存(次快))

[2.4 查询缓存(最快)](#2.4 查询缓存(最快))

[三. 数据一致性更新策略举例说明](#三. 数据一致性更新策略举例说明)

[3.1 先更新数据库,再更新缓存](#3.1 先更新数据库,再更新缓存)

[3.2 先更新缓存,再更新数据库](#3.2 先更新缓存,再更新数据库)

[3.3 先删除缓存,再更新数据库](#3.3 先删除缓存,再更新数据库)

[3.4 先更新数据库,再删除缓存](#3.4 先更新数据库,再删除缓存)

[3.5 方案对比与选择](#3.5 方案对比与选择)

[3.5.1 最好先操作数据库,后操作缓存](#3.5.1 最好先操作数据库,后操作缓存)

[3.5.2 最好删除缓存,而不是更新缓存](#3.5.2 最好删除缓存,而不是更新缓存)

[3.5.3 具体场景具体分析](#3.5.3 具体场景具体分析)

[四. 低频修改数据场景 的 推荐解决方案](#四. 低频修改数据场景 的 推荐解决方案)

[五. 高频修改数据场景 的 推荐解决方案](#五. 高频修改数据场景 的 推荐解决方案)

[5.1. canal 入门](#5.1. canal 入门)

[5.1.1 canal 简介](#5.1.1 canal 简介)

[5.1.2 canal 下载和配置修改](#5.1.2 canal 下载和配置修改)

[5.1.3 canal 运行和确认](#5.1.3 canal 运行和确认)

[5.2 MySQL 配置](#5.2 MySQL 配置)

[5.2.1 windows 环境配置修改](#5.2.1 windows 环境配置修改)

[5.2.2 windows 环境配置生效验证](#5.2.2 windows 环境配置生效验证)

[5.2.3 创建 canal 所需要的数据库权限](#5.2.3 创建 canal 所需要的数据库权限)

[5.3 Binlog 监听 + 消息队列 流程图简要分析](#5.3 Binlog 监听 + 消息队列 流程图简要分析)

[5.4 代码实例](#5.4 代码实例)

[5.4.1. 业务服务(更新数据库)](#5.4.1. 业务服务(更新数据库))

[5.4.2. Canal 客户端(监听 Binlog)](#5.4.2. Canal 客户端(监听 Binlog))

[5.4.3. 缓存同步服务(删除缓存 + 失败入队)](#5.4.3. 缓存同步服务(删除缓存 + 失败入队))

[5.4.4. 消息队列消费者(重试删除)](#5.4.4. 消息队列消费者(重试删除))

[六. 资金账户类敏感数据 的 推荐解决方案](#六. 资金账户类敏感数据 的 推荐解决方案)

[七. 面试题合集](#七. 面试题合集)


Redis,MySQL 双写一致性主要是指在使用缓存和数据库的同时存储数据时,如果在高并发的场景下,二者可能存在数据不一致的情况,因此希望尽量保证 Redis 中的数据和 MySQL 中的数据尽可能保持一致。

一. 业务数据查询,更新顺序简要分析

如下图所示,最左侧是我们的 Java 程序,最右侧是数据库MySQL,中间这一层就是缓存 Redis。

在实际业务数据查询过程中,用户访问网站数据,通常会发送查询请求,通常分为以下三步。

情况一:先查询 Redis,如果 Redis 有数据,直接返回;

情况二:先查询 Redis,但是 Redis 无数据,MySQL 有数据,再去查询MySQL,然后返回数据,同时将数据回写到 Redis 以便于下次查询;

情况三:先查询 Redis,但是 Redis 无数据、再去查询 MySQL,但是MySQL也没有数据,返回空。

查询数据没什么影响,关键在于更新数据操作,如果要更新数据库,那么缓存也要更新。

这里就会有一个问题?先动缓存还是先动数据库?缓存时更新缓存较好,还是删除缓存较好?

由此而来,就引申出了MySQL,redis 更新策略的四种情况。

情况一:先更新数据库,再更新缓存

情况二:先更新缓存,再更新数据库

情况三:先删除缓存,再更新数据库

情况四:先更新数据库,再删除缓存

二.更新数据库、查询数据库、更新缓存、查询缓存耗时对比

2.1 更新数据库(最慢)
  • 操作逻辑 :写入磁盘(如 MySQL 的 UPDATE),需保证 ACID 特性。

  • 耗时范围毫秒级到秒级(简单更新约 10~100ms,复杂事务或高并发下更慢)。

  • 关键瓶颈

    • 事务提交 :需写事务日志(如 Redo Log)、刷盘(fsync)和同步副本(主从架构)。

    • 锁开销:行锁、间隙锁等可能阻塞其他操作,尤其在并发场景。

    • 索引维护:更新可能触发 B+ 树分裂、索引重建等额外开销。

2.2 查询数据库(较慢)
  • 操作逻辑:从磁盘(如 MySQL)读取数据,可能涉及索引扫描、锁等待或复杂查询。

  • 耗时范围毫秒级(简单主键查询约 1~10ms,复杂查询可达 100ms+)。

  • 关键瓶颈

    • 磁盘 I/O:随机读性能远低于内存(机械硬盘约 1ms/次,SSD 约 0.1ms/次)。

    • 锁竞争:若查询涉及行锁或表锁,可能因事务冲突增加等待时间。

    • 网络延迟:应用层与数据库分离时,需叠加网络 RTT(通常 0.1~1ms)。

2.3 更新缓存(次快)
  • 操作逻辑 :写入内存(如 Redis 的 SETDEL)。

  • 耗时范围微秒级(Redis 单次写操作约 0.1~0.5ms)。

  • 关键差异

    • 写操作可能触发内存分配、序列化或淘汰策略(如 LRU),略慢于读操作。

    • 若开启持久化(如 AOF),写入需追加日志,但通常异步执行,不影响主线程。

2.4 查询缓存(最快)
  • 操作逻辑:直接从内存(如 Redis)读取数据,无磁盘 I/O 或复杂计算。

  • 耗时范围微秒级(Redis 单次读操作约 0.1ms 内)。

  • 关键优势

    • 内存操作,无物理寻址延迟。

    • 单线程模型(如 Redis)避免锁竞争,响应稳定。

不难看出,操作缓存的耗时在操作数据库耗时前几乎约等于没有 ,所以下面我们重点对比线程之间对于数据库操作的耗时即可,了解了这一点,我们再往下来探究一致性更新策略的对比。

三. 数据一致性更新策略举例说明

常见的四种更新策略,为 先更新数据库,再更新缓存、先更新缓存,再更新数据库、先删除缓存,再更新数据库、先更新数据库,再删除缓存

假设A,B两个线程同时发起调用。线程A固定为写操作,线程B可能是读,也可能是写操作。

数据库商品表 product 现在商品数量 number 为100;

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

正常逻辑:

(1)A update mysql = 90

(2)A update redis = 90

(3)B update mysql = 80

(4)B update redis = 80

多线程情况下,A,B会有快有慢,可能出现如下

异常逻辑:

(1)A update mysql = 90

(2)B update mysql = 80

(3)B update redis = 80

(4)A update redis = 90

A 更新数据库,在准备写入缓存时,B先更新了数据库,并将数据写入缓存,然后A又完成了数据库的更新。最终结果导致MySQL值为80,Redis 值为90,数据不一致。

造成这种情况需要的条件(不考虑网络延迟):

如果线程B为写操作,则需要线程A写入缓存的耗时(0.1~0.5ms) > 线程B更新数据(10~100ms)+更新缓存的耗时(0.1~0.5ms);

如果线程B为读操作,则需要线程A写入缓存的耗时要(0.1~0.5ms) > 线程B查询缓存的耗时(0.1ms 内);

考虑实际业务中,读操作往往比写操作多;总的来说,先更新数据库,再更新缓存导致数据不一致这种情况大概率会发生。

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

正常逻辑:

(1)A update redis = 90

(2)A update mysql = 90

(3)B update redis = 80

(4)B update mysql = 80

异常逻辑:

(1)A update redis = 90

(2)B update redis = 80

(3)B update mysql = 80

(4)A update mysql = 90

A 更新缓存,然后更新数据库,但是在更新数据库期间,B先来更新了缓存和数据库,然后A有更新成功了数据库。最终结果导致MySQL值为 90,Redis 值为80,数据不一致。

造成这种情况需要的条件:

若线程B为写操作:则需要线程A更新数据库耗时(10~100ms) > 线程B更新数据库耗时(10~100ms)+更新缓存耗时(0.1~0.5ms);

若线程B为读操作:则需要线程A更新数据库耗时(10~100ms) > 线程B查询缓存耗时(0.1ms 内);

考虑实际业务中,读操作往往比写操作多;总的来说,先更新缓存,再更新数据库导致数据不一致这种情况大概率会发生

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

多线程:举例A,B两个线程同时操作可能出现的问题

(1)A delete redis 100

(2)B get number from redis,值为空;B get 100 from mysql

(3)B set 100 redis

(4)A update mysql 80

A线程先删除 redis 的数据,然后再去更新数据库进行写操作;

但是由于A的写操作慢或网络延迟,导致还未写成功,B线程来读数据,发现缓存未命中,又去数据库读数据;B在读取到就数据之后返回,并将旧数据 number 重新写入 缓存 redis。B操作做完一切之后,A线程完成了写操作,此时 mysql 的新数据80与 redis 的旧数据100不一致,数据不一致。

我们来分析一下这种情况出现的条件

若线程B为写请求:需要线程A更新数据库耗时(10~100ms) > 需要线程B更新数据库耗时(10~100ms)

若线程B为读请求:需要线程A更新数据库耗时(10~100ms) > 需要线程B查询数据库耗时(1~10ms) + 线程B写入缓存耗时(0.1~0.5ms)

考虑实际业务中,读操作往往比写操作多;总的来说,先删除缓存,再更新数据库导致数据不一致这种情况大概率会发生

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

(1)A update mysql 80

(2)B get 100 from redis

(3)A delete redis

举例:A线程先去更新数据库,100变为80,缓存先不动;

然后A再去删除缓存,但是B来了,读取到了缓存中的100,直接返回,

A 完成了删除缓存的操作。

不难看出,这种情况,在A线程成功删除缓存之前,也会造成短时间内的脏数据。

我们来分析一下这种情况出现的条件

若线程B为写请求:需要线程A更新缓存耗时(0.1~0.5ms) > 需要线程B更新数据库耗时(10~100ms)

若线程B为读请求:需要线程A删除缓存耗时(0.1~0.5ms) > 需要线程B查询缓存耗时(0.1ms 内)

考虑实际业务中,读操作往往比写操作多;总的来说,先更新数据库,再删除缓存导致数据不一致这种情况大概率会发生

3.5 方案对比与选择

从上面的四种情况并不难看出,不管我们选择哪一种,缓存数据不一致的情况大概率都会发生。那么我们到底应该选择哪一种策略呢?

3.5.1 最好先操作数据库,后操作缓存

其实就单纯数据库支持事务这一条而言,我们就应该先操作数据库,因为如果数据库更新失败,可以进行事务回滚,或者程序重试。此时我们还尚未操作缓存,不管是更新缓存还是删除缓存,都还未进行,不会对后续其它读写线程造成影响,但如果我们先操作缓存,一旦数据库更新失败,就会导致后续其他线程进行缓存重建,浪费时间和性能,做无用功。

下面是先操作数据库后操作缓存的几个优点。

(1)降低脏数据风险

  • 若先删除缓存再更新数据库,在数据库更新完成前,若有并发请求查询数据,会因为缓存缺失读取数据库的旧值并重新写入缓存,导致缓存中保留旧数据。
  • 若先更新数据库再删除缓存,即使缓存删除失败,缓存中的旧数据也只会短暂存在(下次查询触发缓存重建会自动替换为新值),并且数据库已更新为最新数据,最终一致性可控。

(2)减少不一致的窗口时间

  • 假设更新数据库为10ms,删除缓存耗时2ms;
  • 若先删除缓存再更新数据库,数据不一致窗口期为10ms(数据库更新期间);
  • 但若是先更新数据库再删除缓存,数据不一致窗口期仅为2ms(删除缓存期间);

(3)异常处理的容错性

  • 数据库操作通常支持事务,若更新数据库失败可以回滚,此时还未删除缓存不会引入错误。
  • 可如果先删除缓存,但数据库更新失败,此时缓存已丢失,后续的请求会穿透到数据库,并且数据库未更新成功还会导致缓存重建(额外处理),相对来说耗时间。

(4)避免高并发下的双写覆盖

  • 在高并发场景下,若A线程先删除缓存,B线程在A线程更新数据库前查询旧值并写入缓存,可能导致缓存与数据库长期不一致。
  • 但如果先更新数据库后删除缓存,即使线程B先读取到旧值,旧缓存值也会在线程A更新数据库操作完成后被清除,顶多造成短时间内数据不一致,一旦后续又有新请求,就会触发缓存重建将新数据写入缓存。
3.5.2 最好删除缓存,而不是更新缓存

其实我们也可以用懒加载这一层面来理解这个问题,更新数据库是要比删除数据库更耗费性能的,并且更新的数据不一定会马上被访问,既然如此,不如不做,等待其它读操作在需要的时候再来进行缓存重建即可,这样既tighao性能,还提高了程序的运行效率。

下面是删除缓存相对于更新缓存的优点。

(1)避免并发写入导致脏数据

  • 更新缓存:若线程A更新数据库后未完成更新缓存,由于时序或网络延迟,线程B先完成了更新数据库和缓存,线程A再写入缓存时,会导致缓存仍是旧值(期望是B修改后的值)
  • 删除缓存:无论更新顺序如何,删除缓存强制要求下一次查询加载数据库的最新值,天然规避了上面线程顺序带来的脏数据影响。

(2)降低计算资源的浪费

  • 更新缓存:每次更新数据库都需要重新计算并写入缓存,可能浪费资源(例如数据被频繁被更新但很少被读取)
  • 删除缓存:仅在数据集是被查询时重建缓存,按需使用资源。

(3)防止部分更新导致的数据不一致

  • 更新缓存:在遇到复杂缓存的数据结构时,例如Hash,List,若只更新部分字段,可能因为代码的逻辑错误或网络终端导致缓存数据与数据库不一致。
  • 删除缓存:直接删除整个键,下次查询数据时重建完整数据,避免部分更新风险。

(4)简化异常处理

  • 更新缓存:若缓存更新失败,需回滚数据库事务或重试缓存写入,增加系统复杂性。
  • 删除缓存:若缓存删除失败,可通过异步补偿机制(如消息队列),即使未删除成功,旧数据也仅短暂存在。
3.5.3 具体场景具体分析

没有哪个方案是最好的,实际开发过程中都是需要根据项目的实际情况来进行选择的。

对于数据的操作,无非就是读和写。读操作暂不关心,高频写和低频写使用的方案一般是略有变差的,如下表格所示。

|---------------|------------------------------|-----------|-----------|
| 场景 | 推荐方案 | 一致性级别 | 实现复杂度 |
| 低频修改数据 | 缓存过期 + 延迟双删 | 最终一致性 | |
| 高频修改数据 | Binlog 监听 + 消息队列 | 最终一致性 | |
| 资金账户类敏感数据 | 数据库事务 + 同步更新 (最好使用锁) | 强一致性 | |

下面我们就对这三类场景分别作出分析,并给出一个简要的代码逻辑。

四. 低频修改数据场景 的 推荐解决方案

|------------|-----------------|-----------|-----------|
| 场景 | 推荐方案 | 一致性级别 | 实现复杂度 |
| 低频修改数据 | 缓存过期 + 延迟双删 | 最终一致性 | |

如下示例代码:

java 复制代码
// Redis 产品数量固定字符串 Key 前缀,可有可不有,但标准项目基本都会有 key 前缀,方便管理
public static final String PRODUCT_NUMBER = "PRODUCT:NUMBER:";
// 创建一个固定大小为 4 的线程池
private final ExecutorService asyncExecutor = Executors.newFixedThreadPool(4);
@Transactional
public void updateProductNumber(Long number,String productName){
    // 拼接查询 key
    String key = PRODUCT_NUMBER + productName;
    try{
        // (A1) 线程A首次删除缓存
        stringRedisTemplate.delete(key);

        // 这里要注意,下方查询缓存重建缓存的逻辑,可能是其他线程(线程B)在线程A更新数据库之前就已经完成了
        // (B1) 线程B查询数据库
        String numberStr = stringRedisTemplate.opsForValue().get(key);
        // (B2):线程B判断缓存值是否为空
        if (numberStr == null){
            // (B3) B线程查询数据库
            Long productnumber = orderMapper.selectProductNumber(productName);
            numberStr = String.valueOf(productnumber);
            // (B4) B线程写入缓存,并给缓存一个过期时间,这里为30分钟,如果为热点数据,建议时间更短一些,比如1分钟,3分钟,5分钟等,根据业务需要调整即可
            if (productnumber != null){
                stringRedisTemplate.opsForValue().set(key,numberStr,30,TimeUnit.MINUTES);
            }
            // (B5) 因为线程B为读线程,缓存重建后返回数据
            return ...;
        }
        
        // (A2) 线程A更新数据库
        orderMapper.updateProductNumber(number,productName);
        /* (A3) 线程A 提交事务后开辟新线程异步延迟进行缓存第二次删除(关键!)
         * A再次删除缓存,就像上面线程B那样,因为是查询,比线程A快,还将缓存重建,
         * 所以这里进行二次删除,将线程B写入到缓存的脏数据删除掉。  * */
        asyncExecutor.execute(() -> {
            try {
                // 这里的线程是异步的,所以不会阻塞主线程的执行,但是这样会增加开销,
                // 此外,延时时间需根据业务情况测试,我随便写的,这里随便写为 500ms,需根据业务情况调整。
                Thread.sleep(500);
                // (A4) 线程A第二次删除缓存,如果后续有其它读请求,就会像上面B线程一样将缓存重建
                stringRedisTemplate.delete(key); 
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
        // (A5) 线程A完成操作,返回数据
        return ...;
    }catch (Exception e){
        throw new RuntimeException("程序错误",e);
    }
}

给缓存设计过期时间,定期清理缓存并回写,是保证数据最终一致性的解决方案

所有的写操作都要以数据库为准,对缓存操作只是尽最大努力即可。如果数据库写成功,缓存更新失败,那么只要达到过期时间,则后面的读请求自然会从数据库读取最新的值然后回写到缓存,达到已执行。总而言之,要以数据库(MySQL)写入库的数据为准。

此种方案比较适合数据修改频率较低的情况,当然了,每个项目都有对应的特点,结合项目业务特色选择相应的解决方案即可。

五. 高频修改数据场景 的 推荐解决方案

|------------|----------------------|-----------|-----------|
| 场景 | 推荐方案 | 一致性级别 | 实现复杂度 |
| 高频修改数据 | Binlog 监听 + 消息队列 | 最终一致性 | |

5.1. canal 入门
5.1.1 canal 简介

canal 是阿里巴巴旗下的一款开源项目,基于数据库增量日志解析,提供增量数据订阅&消费主要用途是基于 MySQL 数据库增量日志解析,目前主要支持MySQL。说白了是一个新的技术,第三方中间件,需要额外花时间掌握学习。有兴趣的小伙伴可以查阅下面这边文章,写的非常好!

【Canal】从原理、配置出发,从0到1完成Canal搭建-CSDN博客

canal 的工作原理类似于将自己伪装成 MySQL(主机) 的一条从机(slave),然后只要主机数据发生变化,就会同步MySQL主机的数据。想了解主从复制的可以看博主的另一篇文章:

浅谈 MySQL 主从复制,优点?原理?_mysql主从优势-CSDN博客

5.1.2 canal 下载和配置修改

canal 下载:如下图所示,点击下载接口

canal.deployer-1.1.6.tar.gz

下载完毕后,解压得到如下文件,进入 conf------>example------>instance.properties,修改instance.properties 文件。

将 address 改为自己的MySQL地址,下方两个改为自己的数据库用户名和密码,其它不用动。

5.1.3 canal 运行和确认

OK,保存文件,就改完了,然后 进入 bin 目录,双击运行 startup.bat 脚本运行即可。

然后会出现黑色窗口,我们再单独开一个 cmd 窗口,输入

bash 复制代码
netstat -ano | findstr ":11111"

出现如下就表示运行成功了!

5.2 MySQL 配置
5.2.1 windows 环境配置修改

配置文件路径

通常位于MySQL安装目录下,例如:
C:\Program Files\MySQL\MySQL Server 8.0\my.ini

直接使用文本编辑器打开即可,必须修改的配置项如下

sql 复制代码
[mysqld]
# 启用Binlog,指定日志文件名前缀
log-bin=mysql-bin

# 设置Binlog格式为ROW(Canal依赖此格式)
binlog_format=ROW

# 设置唯一的服务器ID(需确保与其他MySQL实例不冲突)
server-id=1

# 可选:设置Binlog保留天数(默认不删除)
expire_logs_days=7
5.2.2 windows 环境配置生效验证

更改完毕配置文件后,切记最好重启MySQL服务。

然后进入 navicat ,运行如下命令确认是否配置成功。

sql 复制代码
sql
复制
-- 检查Binlog是否启用
SHOW VARIABLES LIKE 'log_bin';

-- 检查Binlog格式是否为ROW
SHOW VARIABLES LIKE 'binlog_format';

-- 检查Server ID
SHOW VARIABLES LIKE 'server_id';

-- 检查Binlog保留天数
SHOW VARIABLES LIKE 'expire_logs_days';
5.2.3 创建 canal 所需要的数据库权限

这里记得换成自己的数据库密码!!!

sql 复制代码
-- 创建用户(需替换 YOUR_PASSWORD)
CREATE USER 'canal'@'%' IDENTIFIED WITH 'mysql_native_password' BY 'YOUR_PASSWORD';

-- 授予复制权限
GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';

-- 授予 Binlog 访问权限(MySQL 8.0 必须)
GRANT SELECT, RELOAD, SHOW DATABASES, LOCK TABLES, REPLICATION CLIENT ON *.* TO 'canal'@'%';

-- 刷新权限
FLUSH PRIVILEGES;
5.3 Binlog 监听 + 消息队列 流程图简要分析

流程关键点总结

步骤 核心目标 技术实现
1-2 更新数据库 业务代码直接操作数据库
3-4 解析 Binlog Canal 客户端监听并提取 Key
5-6 首次删除缓存 独立服务 + 异常降级到消息队列
7-8 重试保证最终一致性 消息队列异步消费
5.4 代码实例

在如下代码中,2,3,4步只需要在项目初期配置好即可,后续就不需要怎么做修改了,顶多在 canal 客户端添加监听的数据库表,此时数据同步代码基本已经与业务代码完全解耦合了。

后续我们只需要关注业务层面即可,无需分心关注数据一致性的问题。

5.4.1. 业务服务(更新数据库)
java 复制代码
// ProductService.java
public class ProductService {
    @Autowired
    private ProductMapper productMapper;

    // 步骤1-2:更新数据库,触发 Binlog 生成
    public void updateProductNumber(Long productId, Long productNumber) {
        // 直接操作数据库
        productMapper.updateProductNumber(productId, productNumber);
    }
}
5.4.2. Canal 客户端(监听 Binlog)

Canal 这里的配置,目前只监听了 test 数据库下的 product 表,也可以监听多张表,或者整个库,或者跨库监听都是可以的。

一般情况下,都是只监听核心业务表(高频操作表),这样不会有冗余数据,并且只监听几张表,资源占用较低。

|---------|---------------------------|-----------------------|
| 场景 | 示例 | 说明 |
| 精确匹配单表 | test.product | 只订阅 test 库的 product 表 |
| 多表逗号分隔 | test.user,test.product | 订阅两个表 |
| 正则表达式匹配 | test\\..* | 订阅test库所有表 |
| 跨库匹配 | db1\\..*,db2.order_.* | 订阅db1所有表和db2的order前缀表 |

通常情况下,canal 还需要在 properties 或 yml 文件中进行配置。

java 复制代码
# Canal 连接 MySQL 的配置
canal.instance.master.address=127.0.0.1:3306
canal.instance.dbUsername=canal
canal.instance.dbPassword=YOUR_PASSWORD
canal.instance.connectionCharset=UTF-8
java 复制代码
// CanalClient.java
public class CanalClient {
    public static void main(String[] args) {
        // 连接 Canal 服务端
        CanalConnector connector = CanalConnectors.newSingleConnector(
            new InetSocketAddress("127.0.0.1", 11111), "example", "", "");
        connector.connect();
        connector.subscribe("test.product"); // 订阅 product 表的 Binlog

        while (true) {
            Message message = connector.getWithoutAck(100); // 拉取 Binlog
            for (CanalEntry.Entry entry : message.getEntries()) {
                if (entry.getEntryType() == CanalEntry.EntryType.ROWDATA) {
                    // 步骤3-4:解析 Binlog,提取 Key
                    RowChange rowChange = RowChange.parseFrom(entry.getStoreValue());
                    for (RowData rowData : rowChange.getRowDatasList()) {
                        Long userId = rowData.getAfterColumnsList().get(0).getValue();
                        String key = "user:" + userId;
                        // 调用缓存同步服务
                        CacheSyncService.process(key);
                    }
                }
            }
            connector.ack(message.getId()); // 确认消费
        }
    }
}
5.4.3. 缓存同步服务(删除缓存 + 失败入队)
java 复制代码
// CacheSyncService.java
public class CacheSyncService {
    private static RedisClient redis = new RedisClient("redis://localhost:6379");
    private static MessageQueue mq = new KafkaMessageQueue("kafka:9092");

    // 步骤5-6:尝试删除缓存,失败则发送到消息队列
    public static void process(String key) {
        try {
            boolean success = redis.delete(key);
            if (!success) {
                mq.send("cache_retry_queue", key); // 发送到重试队列
            }
        } catch (Exception e) {
            mq.send("cache_retry_queue", key); // 异常时也发送
        }
    }
}
5.4.4. 消息队列消费者(重试删除)
java 复制代码
// MQConsumer.java
public class MQConsumer {
    public static void main(String[] args) {
        MessageQueue mq = new KafkaMessageQueue("kafka:9092");
        RedisClient redis = new RedisClient("redis://localhost:6379");

        // 步骤7-8:订阅队列并重试删除
        mq.subscribe("cache_retry_queue", message -> {
            String key = (String) message;
            try {
                boolean success = redis.delete(key);
                if (!success) {
                    System.err.println("重试删除失败: " + key);
                    // 可添加重试次数限制(例如最多重试3次)
                }
            } catch (Exception e) {
                mq.send("cache_retry_queue", key); // 再次入队
            }
        });
    }
}

六. 资金账户类敏感数据 的 推荐解决方案

|---------------|------------------------------|-----------|-----------|
| 场景 | 推荐方案 | 一致性级别 | 实现复杂度 |
| 资金账户类敏感数据 | 数据库事务 + 同步更新 (最好使用锁) | 强一致性 | |

示例代码如下:

没啥可说的,就是使用了分布式锁,强制线程串行化执行,基本不存在并发导致数据不一致的情况发生。

java 复制代码
private final AccountMapper accountMapper;
private final StringRedisTemplate stringRedisTemplate;
private final RedissonClient redissonClient;

@Transactional(rollbackFor = Exception.class)
public void transferMoney(Long fromId, Long toId, BigDecimal amount) {
    RLock lock = redissonClient.getLock("account_lock:" + fromId + ":" + toId);
    try {
        if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
            // MyBatis查询
            Account fromAccount = accountMapper.selectById(fromId);
            Account toAccount = accountMapper.selectById(toId);

            // 余额计算
            fromAccount.setBalance(fromAccount.getBalance().subtract(amount));
            toAccount.setBalance(toAccount.getBalance().add(amount));

            // MyBatis更新
            accountMapper.updateBalance(fromAccount);
            accountMapper.updateBalance(toAccount);

            // 使用StringRedisTemplate存储JSON
            ObjectMapper objectMapper = new ObjectMapper();
            stringRedisTemplate.opsForValue().set(
                    "account:" + fromId,
                    objectMapper.writeValueAsString(fromAccount)
            );
            stringRedisTemplate.opsForValue().set(
                    "account:" + toId,
                    objectMapper.writeValueAsString(toAccount)
            );
        }
    } finally {
        lock.unlock();
    }
}

七. 面试题合集

**问题一:**有这样一种情况,微服务查询 Redis 无数据,MySQL 有数据,为保证数据双写一致性再回写到 Redis 时,需要注意什么?双检加锁策略了解过吗?

双检加锁其实和延时双删思路是一样的,简单来说。在高并发的情况下,如果线程A查询缓存,无数据,然后会查询数据库,发现有数据然后将数据回写到缓存,但在查询数据库期间,可能已经有其他线程(线程B)先一步完成了查询数据库并回写了缓存,此时A再回写缓存已经无意义了。所以在线程A查询到数据准备回写缓存之前,可以再进行一次判断,查看当前缓存中是否依旧为空,如果为空说明缓存还未被重建,则再去回写缓存。

示例代码如下:

java 复制代码
public static final String USER_PREFIX = "USER:";
public User findUserById(Long id){
    // 拼接Redis的key,用户对象,缓存用户字符串对象;
    String key = USER_PREFIX + id;
    User user = null;
    String userStr = null;
    try {
        // 1. 从Redis中查询用户信息:若结果不为空,转化后直接返回
        //                        若结果为空,再去查询数据库
        userStr = stringRedisTemplate.opsForValue().get(key);
        if (userStr != null) {
            return JSONUtil.toBean(userStr, User.class);
        }
        else {
            /*
             * 2. 拿到锁之后,再次查询缓存确保缓存无数据,双重检查,俗称双检
             * 之做所以这样做,是因为在高并发情况下,可能会存在多个线程同时进入,导致缓存已经重建,从而导致数据库被查询多次
             * 会对数据库服务器造成压力
             **/
            userStr = stringRedisTemplate.opsForValue().get(key);
            // 3. 判断是否为空,如果不为空,说明缓存已被其他线程重建,直接返回数据
            //                如果为空,进行缓存重建,从数据库中查询数据,然后重建缓存
            if (userStr != null) {
                return JSONUtil.toBean(userStr, User.class);
            } else {
                // 4. 从数据库中查询用户信息
                user = userMapper.selectById(id);
                if (user == null){
                    // 如果是一个热点key,应该回写到redis里一个空值,避免缓存穿透,时间根据业务需求自己定,我随便写的
                    stringRedisTemplate.opsForValue().set(key, "", 1, TimeUnit.MINUTES);
                    return null;
                }
                // 5. 将对象转为json
                userStr = JSONUtil.toJsonStr(user);
                // 6. 写入Redis
                stringRedisTemplate.opsForValue().set(key, userStr, 3, TimeUnit.DAYS);
            }
        }
    } catch (Exception e) {
       logger.error("程序发生错误失败", e);
    }
    return user;
}

下面这四个问题,答案全都已经在文章中了,就留给小伙伴们自行解答啦!

**问题二:**只要使用缓存,就可能涉及到 Redis 缓存与数据库双存储双写,只要有双写,就一定有数据一致性问题,如何解决数据一致性问题?

**问题三:**双写一致性,先动缓存 Redis 还是先动数据库 MySQL ?原因是什么?

**问题四:**延时双删了解过吗?怎么做?

**问题五:**Redis 和 MySQL双写一定会出现纰漏,虽然做不到强一致性,但可以做到最终一致性,怎么做?

相关推荐
不辉放弃15 分钟前
Java/Scala是什么
java·scala
Asuka0718 分钟前
MySQL数据库和表的操作
数据库·mysql
喵手32 分钟前
Java实现视频格式转换的完整指南:从FFmpeg到纯Java方案!
java·开发语言·ffmpeg
MasterNeverDown37 分钟前
Docker Desktop 安装 Redis:轻松搭建本地缓存服务
redis·缓存·docker
天上掉下来个程小白44 分钟前
Redis-04.Redis常用命令-字符串常用命令
java·数据库·redis·springboot·苍穹外卖
2401_891409261 小时前
国外期货高频数据:历史高频分钟回测数据分享
数据库·金融
喻师傅1 小时前
横扫SQL面试——用户留存率问题
数据库·sql·面试
暗恋 懒羊羊1 小时前
【MySQL】表的操作
数据库·mysql
Zz_waiting.1 小时前
多线程 - 线程安全 2 -- > 死锁问题
java·开发语言
梅西库里RNG1 小时前
缓存使用纪要
缓存