本文章所有代码在https://github.com/demsiadh/canal_demo,有用的话点个star吧!
背景
面试题:怎么实现双写一致性?
- 延时双删(最终一致)
- 优点:实现简单
- 缺点:还是有读取旧值的问题,以及最终数据不一致的问题,而且对代码侵入比较大
- 分布式锁(强一致)
- 优点:强一致性,数据一致
- 缺点:性能低
- 等等…
如果我们能接受暂时读取一定的旧值,并且达到最终一致性,那么canal或许是一个不错的实现方式!
Canal
介绍
Canal是阿里巴巴的开源项目,采用Java语言开发,主要用于MySQL数据库增量日志的订阅、消费和解析。
GitHub地址:https://github.com/alibaba/canal
工作原理
Canal是通过模仿MySQL主从集群中的slave节点来进行获取MySQL的binlog实现的,所以我们先来看下MySQL的主从复制原理
MySQL的主从复制将经过如下步骤:
1、当 master 主服务器上的数据发生改变时,则将其改变写入二进制事件日志文件中(binlog);
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的工作原理:
canal 模拟 MySQL slave 的交互协议,伪装自己为 MySQL slave ,向 MySQL master 发送dump 协议。MySQL master 收到 dump 请求,开始推送 binary log 给 slave (即 canal )canal 解析 binary log 对象(原始为 byte 流)。
双写一致性实现
环境准备
MySQL
- 开启binlog日志功能
确保MySQL开启了binlog,可以通过修改MySQL的配置文件my.cnf或者my.ini实现:
[mysqld]
log-bin=mysql-bin
binlog-format=ROW
server-id=1
修改完后记得重启MySQL
- 创建一个MySQL用户,并且可以管理所有数据库
CREATE USER 'canal'@'localhost' IDENTIFIED BY 'canal';
GRANT ALL PRIVILEGES ON *.* TO 'canal'@'localhost' WITH GRANT OPTION;
FLUSH PRIVILEGES;
SHOW GRANTS FOR 'canal'@'localhost';
创建之后,验证一下权限,这里采用了本机登录是因为权限过大,把MySQL和canal部署在同一台机器上即可
Canal
# 下载安装包
sudo wget https://github.com/alibaba/canal/releases/tag/canal-1.1.7/canal.deployer-1.1.7.tar.gz
# 解压(任意文件夹即可,这里只是个示例)
sudo mkdir -r /usr/local/canal
sudo tar -zxvf canal.deployer-1.1.7.tar.gz -C /usr/local/canal
# 进入文件夹
sudo cd /usr/local/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:是否启用表结构历史记录功能,建议开启
将上面的配置配置好之后,回到canal文件夹
启动canal:
sudo sh ./bin/startup.sh
运行后,可以通过查看logs/canal/canal.log和logs/example/example.log日志文件来确认Canal服务和实例是否正常启动。
canal默认端口为11111,如果为云服务器一定要放开端口
代码实现
POM
引入需要的依赖
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.3</version>
</parent>
<dependencies>
<!-- 引入Canal客户端依赖,用于同步数据库变更事件 -->
<dependency>
<groupId>com.alibaba.otter</groupId>
<artifactId>canal.client</artifactId>
<version>1.1.0</version>
</dependency>
<!-- 引入Spring Boot的Redis支持,用于开发中与Redis进行交互 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 引入Fastjson库,用于JSON数据的序列化与反序列化 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.73</version>
</dependency>
<!-- 引入Spring Boot Web支持,为基础的Web应用提供必要功能 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<scope>compile</scope>
</dependency>
<!-- lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.22</version>
</dependency>
<!-- spring boot test-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- 引入Canal客户端连接池依赖,用于监听MySQL数据库的变更 -->
<dependency>
<groupId>top.javatool</groupId>
<artifactId>canal-spring-boot-starter</artifactId>
<version>1.2.1-RELEASE</version>
</dependency>
<!-- 添加MySQL数据库连接器依赖,用于建立数据库连接 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.28</version>
</dependency>
<!-- 引入阿里出品的Druid连接池依赖,用于优化数据库连接池管理 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.2.23</version>
</dependency>
</dependencies>
配置文件
application.yaml
spring:
profiles:
active: dev
server:
port: 8080
application-dev.yaml
spring:
application:
name: test-canal
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.jdbc.Driver
username: canal
password: canal
url: jdbc:mysql://tcloud:3306/canal?useUnicode=true&characterEncoding=utf-8&serverTimezone=GMT
# redis 配置
redis:
# 地址
host: tcloud
# 端口,默认为6379
port: 6379
# 数据库索引
database: 1
# 密码
password:
# 连接超时时间
timeout: 10s
lettuce:
pool:
# 连接池中的最小空闲连接
min-idle: 0
# 连接池中的最大空闲连接
max-idle: 8
# 连接池的最大数据库连接数
max-active: 8
# #连接池最大阻塞等待时间(使用负值表示没有限制)
max-wait: -1ms
# 定义与Canal服务器的连接参数
canal:
# canal服务器的地址和端口号
server: tcloud:11111
# 数据目的地,标识数据的变化流向
destination: example
# 关闭canal-boot-starter里面的日志,因为他一直在刷
logging:
level:
top.javatool.canal.client.client: OFF
业务代码
提前准备好数据库表,这里采用user,属性为id和name,比较简单,作为演示
/**
* <big>用户实体类</big>
*
* @author 13684
* @date 2024/8/28
*/
@Data
public class User implements Serializable {
private Integer id;
private String name;
}
由于需要使用到Redis,这里配置Redis(将fastjson作为值得序列化器)
@Configuration
public class RedisConfig {
/**
* 配置RedisTemplate bean,用于自定义Redis的数据序列化和反序列化方式
* 特别说明:此处的注释遵循了函数级别的块注释要求,详细解释了函数的目的、参数和返回值
*
* @param redisConnectionFactory Redis连接工厂,用于建立与Redis服务器的连接
* @return 返回配置好的RedisTemplate实例,该实例用于在应用程序中进行Redis操作
*/
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
// 创建RedisTemplate实例
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
// 设置Redis连接工厂
redisTemplate.setConnectionFactory(redisConnectionFactory);
// key值使用spring默认的StringRedisSerializer
redisTemplate.setKeySerializer(new StringRedisSerializer());
// value值使用fastjson的GenericFastJsonRedisSerializer,提高序列化和反序列化的效率
GenericFastJsonRedisSerializer fastJsonRedisSerializer = new GenericFastJsonRedisSerializer();
redisTemplate.setValueSerializer(fastJsonRedisSerializer);
// hash序列化的配置,同样使用StringRedisSerializer和GenericFastJsonRedisSerializer
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(fastJsonRedisSerializer);
// 返回配置好的RedisTemplate实例
return redisTemplate;
}
}
编写一个Redis操作字符串的工具类,方便我们使用
@Component
@Slf4j
@RequiredArgsConstructor
public class RedisUtil<T> {
private static final int BASE_EXPIRATION = 60 * 60; // 基础过期时间为1小时
private static final int RANDOM_RANGE = 60 * 10; // 随机范围为10分钟
// 采用线程安全的类ThreadLocalRandom进行随机数生成
private static final ThreadLocalRandom RANDOM = ThreadLocalRandom.current();
private final RedisTemplate<String, T> redisTemplate;
/**
* 设置指定的key-value到Redis中
* 为了减少缓存雪崩的风险,通过随机数机制设置key的过期时间
*
* @param key Redis中的键
* @param value 需要存储的值
*/
public void set(String key, T value) {
int nextInt = RANDOM.nextInt(RANDOM_RANGE);
redisTemplate.opsForValue().set(key, value, BASE_EXPIRATION + nextInt, TimeUnit.SECONDS);
}
/**
* 从Redis中根据key获取对应的值
*
* @param key Redis中的键
* @return Redis中的值
*/
public T get(String key) {
if (!exist(key)) {
log.warn("redis key:{}不存在", key);
return null;
}
return redisTemplate.opsForValue().get(key);
}
/**
* 判断指定的键是否存在于Redis中
*
* @param key 要判断的键
* @return 如果键存在,则返回true;否则返回false
*/
public boolean exist(String key) {
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
}
/**
* 删除Redis中的指定键
*
* @param key 要删除的键
*/
public void delete(String key) {
// 执行删除操作
Boolean delete = redisTemplate.delete(key);
// 检查删除是否成功
if (Boolean.FALSE.equals(delete)) {
// 如果删除失败,记录警告日志
log.warn("redis key:{} 删除失败!", key);
}
}
}
最后编写Canal监听代码
@Slf4j
@Component
@CanalTable(value = "user")
@RequiredArgsConstructor
public class UserHandler implements EntryHandler<User> {
private final RedisUtil<User> redisUtil;
/**
* 实现用户信息的插入操作
* 通过将用户对象存储到Redis中,以用户ID作为键,实现快速访问
* 此方法解释了如何在Redis中保存用户对象,强调了使用Redis进行快速数据访问和操作的重要性
*
* @param user 待插入的用户对象,包含用户的唯一ID和其他相关信息
*/
@Override
public void insert(User user) {
redisUtil.set(String.valueOf(user.getId()), user);
}
/**
* 更新用户信息
* 该方法通过使用新的用户对象替换Redis中现有的用户对象来实现用户信息的更新
* 说明了更新操作的实现逻辑,即使用户信息发生变更,通过Redis可以快速高效地进行更新
*
* @param before 更新前的用户对象,用于定位需要更新的用户
* @param after 更新后的用户对象,包含新的用户信息,将以此内容更新原有信息
*/
@Override
public void update(User before, User after) {
redisUtil.set(String.valueOf(after.getId()), after);
}
/**
* 删除用户信息
* 通过用户ID从Redis中移除用户对象,实现用户信息的删除操作
* 解释了删除操作的实现方式,强调了通过Redis可以快速高效地进行数据的删除
*
* @param user 待删除的用户对象,只需提供用户ID即可完成删除操作
*/
@Override
public void delete(User user) {
redisUtil.delete(String.valueOf(user.getId()));
}
}
到此为止,我们的业务代码已经实现了双写一致性,下面就是测试啦,由于没有实现web的增删改查,直接操作数据库既可以看到Redis中已经实现同步!
我们就解决了这一难题!