目录
[一. 业务数据查询,更新顺序简要分析](#一. 业务数据查询,更新顺序简要分析)
[二. 更新数据库、查询数据库、更新缓存、查询缓存耗时对比](#二. 更新数据库、查询数据库、更新缓存、查询缓存耗时对比)
[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 的
SET
或DEL
)。 -
耗时范围 :微秒级(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 下载:如下图所示,点击下载接口

下载完毕后,解压得到如下文件,进入 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双写一定会出现纰漏,虽然做不到强一致性,但可以做到最终一致性,怎么做?