面试题(文末有答案)
1、延时双删,还是会面临一定的读取旧值问题,以及最终数据不一致的风险。如果我想实现mysql有了更改后,立即同步到redis,如何实现?
2、如果我能容许一定的旧值读取,应当用何种双写一致性策略降低数据最终不一致的风险到最低?
背景
redis高阶篇的第三章我们已经了解到数据双写一致性的策略中,先更新mysql再删除redis缓存,和先删除redis缓存在更新mysql(延时双删)这两种方案是可行的。
延时双删已经在第三章实现,那么先更新mysql再删除redis缓存这个策略如何实现呢?
思路
mysql的更新,可能是我们服务端人为的,更可能是客户端在各个服务模块中产生的,因此,如何感知mysql的更新是一个问题。
感知到mysql更新后,获取更新了哪些数据后,我们就可以展开对redis的更新了。
引入Canal
为什么引入?
为了感知mysql的更新而引入。
能干嘛?
canal 主要用途是基于MySQL数据库增量日志解析,提供增量数据订阅和消费。实现数据库的增量数据同步。
下载
访问Canal的GitHub页面(GitHub - alibaba/canal: 阿里巴巴 MySQL binlog 增量订阅&消费组件)或其官方网站,下载最新版本的Canal部署包。
工作原理
为了明白canal的工作原理,这里先了解一下传统mysql的主从复制原理。
传统mysql主从复制原理
MySQL的主从复制将经过如下步骤:
1、当 master 主服务器上的数据发生改变时,则将其改变写入二进制事件日志文件中;
2、salve 从服务器会在一定时间间隔内对 master 主服务器上的二进制日志进行探测,探测其是否发生过改变,
如果探测到 master 主服务器的二进制事件日志发生了改变,则开始一个 I/O Thread 请求 master 二进制事件日志;
3、同时 master 主服务器为每个 I/O Thread 启动一个dump Thread,用于向其发送二进制事件日志;
4、slave 从服务器将接收到的二进制事件日志保存至自己本地的中继日志文件中;
5、salve 从服务器将启动 SQL Thread 从中继日志中读取二进制日志,在本地写入,使得其数据和主服务器保持一致;
6、最后 I/O Thread 和 SQL Thread 将进入睡眠状态,等待下一次被唤醒;
canal工作原理
一句话总结(详细工作原理,可以查看下面的介绍)
模拟MySQL从库读取binlog实现数据变更监听。它支持数据过滤、转换,并能将变更数据推送到不同的下游系统,如消息队列和其他数据库。
Canal的工作原理主要基于MySQL的binlog(binary log)机制。MySQL的binlog记录了所有对数据库进行更改的SQL语句,这些日志可以用于数据恢复、主从复制等。Canal通过模拟MySQL的从库(slave),读取并解析这些binlog,从而实现对数据库变更的监听和捕获。以下是Canal的工作原理的详细步骤:
-
模拟MySQL Slave:
- Canal伪装成MySQL的从库,通过MySQL的主从复制协议连接到MySQL的主库(master)。
- 通过这种方式,Canal能够像MySQL的从库一样,从主库获取binlog日志。
-
读取Binlog:
- 一旦连接成功,Canal开始从MySQL主库读取binlog日志。
- MySQL主库会将所有的binlog事件(如INSERT、UPDATE、DELETE等)发送给Canal。
-
解析Binlog:
- Canal接收到binlog日志后,会对这些日志进行解析。
- 解析的内容包括表名、操作类型(INSERT、UPDATE、DELETE)、变更的数据等。
-
数据处理和过滤:
- Canal可以根据用户的配置,对解析后的数据进行处理和过滤。
- 用户可以指定只监听特定的数据库或表,或者对数据进行特定的转换和处理。
-
数据推送:
- 解析和处理后的数据可以通过多种方式推送给订阅者。
- 常见的推送方式包括发送到消息队列(如Kafka、RabbitMQ)、写入到其他数据库(如Elasticsearch、HBase)等。
-
确认和回滚:
- Canal支持对处理后的数据进行确认(ack)和回滚(rollback)。
- 如果数据处理成功,Canal会发送ack确认,表示这批数据已经成功处理。
- 如果数据处理失败,可以进行回滚,重新处理这批数据。
-
高可用性和容错:
- Canal支持集群模式,可以通过多个Canal实例提供服务,增加系统的可用性和稳定性。
- Canal还支持断点续传,即使Canal服务重启,也可以从上次中断的地方继续读取binlog。
通过上述步骤,Canal实现了对MySQL数据库变更的实时监听和捕获,并将变更数据推送给订阅者,从而实现数据同步、缓存更新、搜索引擎索引更新等功能。
实战:利用canal实现Mysql、Redis的双写一致性
mysql环境准备
两件事:1 开启binlog日志功能 2 给canal配置一个mysql用户,名字要求是canal
1 开启binlog日志功能
确保你的MySQL实例已经开启了binlog,并且binlog的格式为ROW
。你可以通过修改MySQL的配置文件my.cnf
或my.ini
来实现:
[mysqld]
log-bin=mysql-bin
binlog-format=ROW
server-id=1
binlog模式介绍
- ROW模式 除了记录sql语句之外,还会记录每个字段的变化情况,能够清楚的记录每行数据的变化历史,但会占用较多的空间。
- STATEMENT模式 只记录了sql语句,但是没有记录上下文信息,在进行数据恢复的时候可能会导致数据的丢失情况;
- MIX模式 比较灵活的记录,理论上说当遇到了表结构变更的时候,就会记录为statement模式。当遇到了数据更新或者删除情况下就会变为row模式;
重启MySQL服务使配置生效。
2 新增canal用户
canal准备
1 配置canal
进入解压后的Canal目录,找到conf
目录下的example
实例,通常情况下,你可以通过修改conf/example/instance.properties
文件来配置Canal连接到MySQL的参数,主要配置项包括:
canal.instance.master.address
:MySQL服务器地址和端口。canal.instance.dbUsername
和canal.instance.dbPassword
:用于连接MySQL的用户名和密码。canal.instance.connectionCharset
:数据库的字符集,通常为UTF-8。canal.instance.tsdb.enable
:是否启用表结构历史记录功能,建议开启。
2 启动canal
在Canal的根目录下,运行bin/startup.sh
脚本启动Canal服务:
./startup.sh
3 验证Canal运行状态
运行bin/startup.sh
后,可以通过查看logs/canal/canal.log
和logs/example/example.log
日志文件来确认Canal服务和实例是否正常启动。
编写canal客户端代码(重点)
1 新建module
2 pom文件
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.atguigu.canal</groupId>
<artifactId>canal_demo02</artifactId>
<version>1.0-SNAPSHOT</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.14</version>
<relativePath/>
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<junit.version>4.12</junit.version>
<log4j.version>1.2.17</log4j.version>
<lombok.version>1.16.18</lombok.version>
<mysql.version>5.1.47</mysql.version>
<druid.version>1.1.16</druid.version>
<mapper.version>4.1.5</mapper.version>
<mybatis.spring.boot.version>1.3.0</mybatis.spring.boot.version>
</properties>
<dependencies>
<!--canal-->
<dependency>
<groupId>com.alibaba.otter</groupId>
<artifactId>canal.client</artifactId>
<version>1.1.0</version>
</dependency>
<!--SpringBoot通用依赖模块-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!--swagger2-->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.9.2</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>2.9.2</version>
</dependency>
<!--SpringBoot与Redis整合依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!--SpringBoot与AOP-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
<!--Mysql数据库驱动-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.47</version>
</dependency>
<!--SpringBoot集成druid连接池-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.10</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>${druid.version}</version>
</dependency>
<!--mybatis和springboot整合-->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>${mybatis.spring.boot.version}</version>
</dependency>
<!--通用基础配置junit/devtools/test/log4j/lombok/hutool-->
<!--hutool-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.2.3</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>${junit.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>log4j</groupId>
<artifactId>log4j</artifactId>
<version>${log4j.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<optional>true</optional>
</dependency>
<!--persistence-->
<dependency>
<groupId>javax.persistence</groupId>
<artifactId>persistence-api</artifactId>
<version>1.0.2</version>
</dependency>
<!--通用Mapper-->
<dependency>
<groupId>tk.mybatis</groupId>
<artifactId>mapper</artifactId>
<version>${mapper.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.8.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
3 yml文件
server.port=5555
# ========================alibaba.druid=====================
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/bigdata?useUnicode=true&characterEncoding=utf-8&useSSL=false
spring.datasource.username=root
spring.datasource.password=123456
spring.datasource.druid.test-while-idle=false
4 工具类
主要用于获取redis连接 jedis 注意编写正确的地址和密码
package com.atguigu.canal.utils;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
/*
redis工具类 使用jedis连接redis
*/
public class RedisUtils
{
public static final String REDIS_IP_ADDR = "192.168.186.128";
public static final String REDIS_pwd = "111111";
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);
}
//获取Jedis对象
public static Jedis getJedis() throws Exception {
if(null!=jedisPool){
return jedisPool.getResource();
}
throw new Exception("Jedispool is not ok");
}
}
5 业务类
package com.atguigu.canal.biz;
import com.alibaba.fastjson.JSONObject;
import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.client.CanalConnectors;
import com.alibaba.otter.canal.protocol.CanalEntry.*;
import com.alibaba.otter.canal.protocol.Message;
import com.atguigu.canal.utils.RedisUtils;
import redis.clients.jedis.Jedis;
import java.net.InetSocketAddress;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/*
redis mysql 双写一致性代码
*/
public class RedisCanalClientExample {
public static final Integer _60SECONDS = 60;
// redis地址
public static final String REDIS_IP_ADDR = "192.168.186.128";
//redis插入数据方法
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.size() > 0) {
try (Jedis jedis = RedisUtils.getJedis()) {
jedis.set(columns.get(0).getValue(), jsonObject.toJSONString());
} catch (Exception e) {
e.printStackTrace();
}
}
}
// redis删除数据方法
private static void redisDelete(List<Column> columns) {
JSONObject jsonObject = new JSONObject();
for (Column column : columns) {
jsonObject.put(column.getName(), column.getValue());
}
if (columns.size() > 0) {
try (Jedis jedis = RedisUtils.getJedis()) {
jedis.del(columns.get(0).getValue());
} catch (Exception e) {
e.printStackTrace();
}
}
}
// redis更新数据方法
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.size() > 0) {
try (Jedis jedis = RedisUtils.getJedis()) {
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 eromanga-event has an error,data:" + entry.toString(), e);
}
//获取变动类型
EventType eventType = rowChage.getEventType();
System.out.println(String.format("================> binlog[%s:%s] , name[%s,%s] , eventType : %s",
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(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("atguigu_jdbc.t_user");
connector.rollback();//回滚到未进行ack确认的地方,确保从最后一个未确认的位置开始获取数据。
int totalEmptyCount = 10 * _60SECONDS;
while (emptyCount < totalEmptyCount) {
System.out.println("我是canal,每秒一次正在监听:" + UUID.randomUUID().toString());
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();
}
}
}
演示
执行方法
手动更新mysql数据,将会被canal自动检测
查看redis是否有及时更新数据?可以看到,数据确实被更新到redis了.
面试题(两问都是一个答案)
1、如果我想实现mysql有了更改后,立即同步到redis,如何实现?
2、如果我能容许一定的旧值读取,应当用何种双写一致性策略降低数据最终不一致的风险到最低?
要实现MySQL有更新后立即同步到Redis,可以采用消息队列结合Canal的方式。首先,通过Canal监听MySQL的binlog,一旦数据库有变更,Canal即可捕获这些变更事件。然后,将这些变更事件发送到消息队列(如Kafka、RabbitMQ等)中。最后,编写一个消费者程序从消息队列中读取这些变更事件,并根据事件内容更新Redis缓存。
这种方式的优点是能够实现近乎实时的数据同步,同时通过消息队列解耦了数据库变更事件的捕获与缓存更新操作,提高了系统的稳定性和扩展性。此外,即使Redis更新操作失败,也可以通过消息队列中的事件重新触发更新操作,增强了数据同步的可靠性。
(延伸问题)3、根据以上你的回答,为什么不用延时双删?
答:延时双删也是可以的,只是延时双删需要面对比canal更久的旧值容忍时间,也就是说,canal实现的双写一致性具有更高的实时性,并且延时双删需要对延迟的时间有一个很好的把握,如果把握不当,可能造成最终数据不一致,也就是说,canal实现的策略更能保证最终一致性。
数据一致性更强:Canal通过监听MySQL的binlog来实现数据的实时同步,这意味着一旦数据库中的数据发生变更,几乎可以立即反映到Redis缓存中。这种方式可以最大程度地减少数据不一致的时间窗口,从而提高数据一致性。
减少冗余操作:延时双删策略需要在更新数据库后,先删除缓存,然后再次延时后删除缓存以确保一致性。这种方法虽然可以一定程度上减少读取旧值的风险,但增加了冗余的缓存删除操作,可能会对系统性能产生影响。而Canal只需根据数据库的实际变更来更新缓存,操作更加精准高效。
更好的扩展性和可维护性:Canal作为一个独立的组件,可以轻松地与现有系统集成,不需要改变现有的数据库和缓存操作逻辑。这种解耦合的设计使得系统的扩展性和可维护性更好。同时,Canal还支持集群模式,提高了系统的可用性和稳定性。
更广泛的应用场景:Canal不仅可以用于缓存更新,还可以用于数据库之间的数据同步、数据变更的监控等多种场景。这种灵活性使得Canal成为一个更具通用性的解决方案。