前言:此篇文章系本人学习过程中记录下来的笔记,里面难免会有不少欠缺的地方,诚心期待大家多多给予指教。
基础篇:
- Redis(一)
- Redis(二)
- Redis(三)
- Redis(四)
- Redis(五)
- Redis(六)
- Redis(七)
- Redis(八)
进阶篇:
- Redis(九)
- 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、问题①
场景模拟一:
- A update myslq 100 --success
- A update redis 100 -- error
最终结果,数据库里面和缓存redis里面数据不一致,下一次读到redis脏数据。
1.2、问题②
场景模拟二:
【正常逻辑】:先更新数据库,再更新缓存,A、B两个线程发起调用
- A update mysql 100
- A update redis 100
- B update mysql 80
- B update redis 80
最终结果,mysql和redis数据一致,皆大欢喜。
【异常逻辑】:多线程环境下,A、B两个线程有快有慢,有前有后并行。
- A update mysql 100
- B update mysql 80
- B update redis 80
- A update redis 100
最终结果,mysql和redis数据不一致,o(╥﹏╥)o。
2、先更新缓存,在更新数据库
出现的异常问题跟第一种策略类似。此外,业内一般把数据库作为底单数据库,保证最后解释,一切都以数据库为主。
3、先更新数据库,在删除缓存
3.1、异常问题
先更新数据库,再删除缓存场景模拟,A、B两个线程发起调用
- A update mysql 100-80 --(更新耗时)
- B read redis 100
- A del redis
- A set redis 20
最终结果:假如缓存删除失败或者还没来得及更新,导致请求再次访问redis时缓存命中,读取到的是缓存旧值。
3.2、解决方案
参考微软、阿里巴巴的解决思想,引入中间件来解决问题,最终实现数据库和缓存数据一致。
解释说明:
- 可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中(例如使用Kafka/RabbitMQ等)。
- 当程序没有能够成功地删除缓存值或者是更新数据库值时,可以从消息队列中重新读取这些值,然后再次进行删除或更新。
- 如果能够成功地删除或更新,我们就要把这些值从消息队列中去除,以免重复操作,此时,我们也可以保证数据库和缓存的数据一致了,否则还需要再次进行重试。
- 如果重试超过的一定次数后还是没有成功,我们就需要向业务层发送报错信息了,通知运维人员。
4、先删除缓存,在更新数据库
4.1、异常问题
场景模拟:
先删除缓存,在更新数据库,A、B两个线程发起调用:
- A del redis,A update mysql 100-80 --(更新耗时,没有commit)
- B read redis == null
- B select mysql 100, B set redis 100
- 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 {
// 日志记录
}
});
}
}
(三)、小总结
在实际的项目,以上四种方案应该如何选择呢?
第一二种,在高并发的场景下肯定是少用的,第三四种中选择的话,一般会选择使用先更新数据库,再删除缓存的方案。理由如下:
- 先删除缓存值再更新数据库,有可能导致请求因缓存缺失而访问数据库,给数据库带来压力导致数据库可能宕机。
- 如果业务应用中读取数据库和写缓存的时间不好估算,那么,延迟双删中的等待时间也是不好设置。
此外,如果业务层要求必须读取一致性的数据,那么我们就需要在更新数据库时,先在Redis缓存客户端暂停并发读请求,等数据库更新完、缓存值删除后,再读取数据,从而保证数据一致性,这是理论可以达到的效果,但实际,不推荐,因为真实生产环境中,分布式下很难做到实时一致性,一般都是最终一致性。
最后一图总结三、四方案
六、CANAL双写一致性案例
(一)、CANAL基础知识
1、定义
CANAL 是阿里巴巴开源的一款基于数据库增量日志解析,提供增量数据订阅和消费的中间件。其设计灵感来源于 MySQL 的主从复制原理。在 MySQL 的主从架构中,主库会将数据变更记录到二进制日志(binlog)中,从库通过解析这些 binlog 来获取数据变更并同步数据。CANAL 模拟了从库的行为,伪装成 MySQL 的从库,向 MySQL 主库发送 dump 协议请求 binlog 日志,然后对获取到的 binlog 进行解析,将其转换为易于理解的结构化数据,如 SQL 语句或数据对象的变更信息,提供给下游的应用进行消费和处理。
简单来说,CANAL 就像是一个数据变更的 “监听者” 和 “搬运工”,能够实时捕获数据库中的数据变化,并将这些变化传递给其他需要的系统,以实现数据的同步、缓存更新、搜索索引构建等功能。
官网地址:GitHub - alibaba/canal: 阿里巴巴 MySQL binlog 增量订阅&消费组件
2、具备什么功能
- 数据库镜像
- 数据库实时备份
- 索引构建和实时维护(拆分异构索引、倒排索引等)
- 业务CACHE刷新
- 带业务逻辑的增量数据处理
3、工作原理
其设计灵感来源于MySQL的主从复制原理。
MYSQL主从复制原理:
- 当 master 主服务器上的数据发生改变时,则将其改变写入二进制事件日志文件中;
- salve 从服务器会在一定时间间隔内对 master 主服务器上的二进制日志进行探测,探测其是否发生过改变,如果探测到 master 主服务器的二进制事件日志发生了改变,则开始一个 I/O Thread 请求 master 二进制事件日志;
- 同时 master 主服务器为每个 I/O Thread 启动一个dump Thread,用于向其发送二进制事件日志;
- slave 从服务器将接收到的二进制事件日志保存至自己本地的(Replay log)中继日志文件中;
- salve 从服务器将启动 SQL Thread 从中继日志中读取二进制日志,在本地重放,使得其数据和主服务器保持一致;
- 最后 I/O Thread 和 SQL Thread 将进入睡眠状态,等待下一次被唤醒;
Canal工作原理:
- canal 模拟 MySQL slave 的交互协议,伪装自己为 MySQL slave ,向 MySQL master 发送dump 协议。
- MySQL master 收到 dump 请求,开始推送 binary log 给 slave (即 canal )。
- canal 解析 binary log 对象(原始为 byte 流)。
- canal在转发给其他中间件。
(二)、实操
1、MYSQL端设置
1.1、确认mysql版本
select version();
注:不规定必须使用一样版本,但要确保后续下载canal支持对应的mysql版本。
1.2、查看主机二进制日志
SHOW MASTER STATUS;
| 当前正在使用的二进制日志文件的名称。例如 |
| 二进制日志文件中的当前位置,即下一个要写入的事件的偏移量。它是一个整数,用于标识从服务器应该从哪个位置开始读取二进制日志。 |
| 显示主服务器配置的只记录特定数据库更改的列表。如果设置了该选项,主服务器只会将指定数据库的更改记录到二进制日志中。 |
| 显示主服务器配置的忽略记录更改的数据库列表。主服务器不会将这些数据库的更改记录到二进制日志中。 |
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("================> 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:努力到底,让持续学习成为贯穿一生的坚守。学习笔记持续更新中。。。。