实战:Canal+RocketMQ同步MySQL到Redis/ES
一、前言
在很多业务情况下,我们都会在系统中加入Redis
缓存做查询优化,使用es
做全文检索。
如果数据库数据发生更新,这时候就需要在业务代码中写一段同步更新Redis
的代码。这种数据同步的代码跟业务代码糅合在一起会不太优雅,能不能把这些数据同步的代码抽出来形成一个独立的模块呢,答案是可以的。
二、canal简介
canal:译意为水道/管道/沟渠,主要用途是基于MySQL
数据库增量日志解析,提供增量数据订阅和消费。
2.1 canal工作原理
canal
是一个伪装成slave
订阅mysql
的binlog
,实现数据同步的中间件。
canal
模拟MySQL slave
的交互协议,伪装自己为MySQL slave
,向MySQL master
发送dump
协议MySQL master
收到dump请求,开始推送binary log
给slave
(即canal
)canal
解析binary log
对象(原始为byte
流)
2.2 canal架构
说明:
server
代表一个canal
运行实例,对应于一个jvm
instance
对应于一个数据队列(1
个server
对应1…n个instance
)
instance模块:
eventParser
(数据源接入,模拟db
的slave
协议和master
进行交互,协议解析)eventSink
(Parser
和Store
链接器,进行数据过滤,加工,分发的工作)eventStore
(数据存储)metaManager
(增量订阅&消费信息管理器)
2.3 同步架构
binlog
同步保障数据一致性的架构
三、环境配置
技术栈主要有SpringBoot
、MySQL
、canal
、RocketMQ
、ElasticSearch
或Redis
。
3.1 MySQL配置
对于自建MySQL
,需要先开启Binlog
写入功能,配置binlog-format
为ROW
模式,my.cnf
中配置如下
[mysqld]
log-bin=mysql-bin # 开启binlog
binlog-format=ROW # 选择ROW模式
server_id=1 # 配置MySQL replaction需要定义,不要和canal的slaveId重复
注意:针对阿里云
RDS for MySQL
,默认已打开binlog
,并且账号默认具有binlog dump
权限,不需要任何权限或者binlog
设置,可以直接跳过这一步
授权canal
连接MySQL
账号具有作为MySQL slave
的权限,如果已有账户可直接使用grant
命令授权。
#创建用户名和密码都为canal
CREATE USER canal IDENTIFIED BY 'canal';
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';
FLUSH PRIVILEGES;
3.2 canal的安装和配置
3.2.1 canal.admin安装和配置
canal
提供web ui
进行Server
管理、Instance
管理。
下载canal.admin
,访问release
页面,选择需要的包下载,如以1.1.4
版本为例
wget https://github.com/alibaba/canal/releases/download/canal-1.1.4/canal.admin-1.1.4.tar.gz
解压完成可以看到如下结构:
我们先配置canal.admin之后。通过web ui来配置canal server,这样使用界面操作非常的方便。
配置修改vi conf/application.yml
server:
port: 8089
spring:
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
spring.datasource:
address: 127.0.0.1:3306
database: canal_manager
username: canal
password: canal
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://${spring.datasource.address}/${spring.datasource.database}?useUnicode=true&characterEncoding=UTF-8&useSSL=false
hikari:
maximum-pool-size: 30
minimum-idle: 1
canal:
adminUser: admin
adminPasswd: admin
初始化元数据库
mysql -h127.0.0.1 -uroot -p
# 导入初始化SQL
> source conf/canal_manager.sql
- 初始化
SQL
脚本里会默认创建canal_manager
的数据库,建议使用root
等有超级权限的账号进行初始化 canal_manager.sql
默认会在conf
目录下,也可以通过链接下载canal_manager.sql
启动
sh bin/startup.sh
启动成功,使用浏览器输入http://ip:8089
会跳转到登录界面
使用用户名:admin 密码为:123456登录
登录成功,会自动跳转到如下界面。这时候我们的canal.admin
就搭建成功了。
3.2.2 canal.deployer部署和启动
下载canal.deployer
,访问release
页面,选择需要的包下载,如以1.1.4
版本为例
wget https://github.com/alibaba/canal/releases/download/canal-1.1.4/canal.deployer-1.1.4.tar.gz
解压完成可以看到如下结构:
进入conf
目录。可以看到如下的配置文件。
我们先对canal.properties
不做任何修改。
使用canal_local.properties
的配置覆盖canal.properties
# register ip
canal.register.ip=
# canal admin config
canal.admin.manager=127.0.0.1:8089
canal.admin.port=11110
canal.admin.user=admin
canal.admin.passwd=4ACFE3202A5FF5CF467898FC58AAB1D615029441
# admin auto register
canal.admin.register.auto=true
canal.admin.register.cluster=
使用如下命令启动canal server
sh bin/startup.sh local
启动成功。同时我们在canal.admin web ui
中刷新server
管理,可以到canal server
已经启动成功。
这时候我们的canal.server
搭建已经成功。
3.2.3 在canal admin ui中配置Instance管理
新建Instance
选择Instance
管理->新建Instance
填写Instance
名称:cms_article
大概的步骤
- 选择 选择所属主机集群
- 选择 载入模板
- 修改默认信息
#mysql serverId
canal.instance.mysql.slaveId=1234
#position info,需要改成自己的数据库信息
canal.instance.master.address=127.0.0.1:3306
canal.instance.master.journal.name=
canal.instance.master.position=
canal.instance.master.timestamp=
#canal.instance.standby.address=
#canal.instance.standby.journal.name=
#canal.instance.standby.position=
#canal.instance.standby.timestamp=
#username/password,需要改成自己的数据库信息
canal.instance.dbUsername=canal
canal.instance.dbPassword=canal
#改成自己的数据库信息(需要监听的数据库)
canal.instance.defaultDatabaseName=cms-manage
canal.instance.connectionCharset=UTF-8
#table regex 需要过滤的表 这里数据库的中所有表
canal.instance.filter.regex=.\*\\..\*
# MQ配置日志数据会发送到cms_article这个topic上
canal.mq.topic=cms_article
# dynamic topic route by schema or table regex
#canal.mq.dynamicTopic=mytest1.user,mytest2\\..*,.*\\..*
#单分区处理消息
canal.mq.partition=0
我们这里为了演示之创建一张表。
配置好之后,我需要点击保存。此时在Instances管理中就可以看到此时的实例信息。
3.2.4 修改canal server的配置文件,选择消息队列处理binlog
canal 1.1.1版本之后,默认支持将canal server接收到的binlog数据直接投递到MQ,目前默认支持的MQ系统有:
- kafka: https://github.com/apache/kafka
- RocketMQ : https://github.com/apache/rocketmq
本案例以RocketMQ
为例
我们仍然使用web ui界面操作。点击server管理 -> 点击配置
修改配置文件,修改好之后保存会自动重启。
# ...
# 可选项: tcp(默认), kafka, RocketMQ
canal.serverMode=RocketMQ
# ...
# kafka/rocketmq 集群配置: 192.168.1.117:9092,192.168.1.118:9092,192.168.1.119:9092
canal.mq.servers=192.168.0.200:9078
canal.mq.retries=0
# flagMessage模式下可以调大该值,但不要超过MQ消息体大小上限
canal.mq.batchSize=16384
canal.mq.maxRequestSize=1048576
# flatMessage模式下请将该值改大,建议50-200
canal.mq.lingerMs=1
canal.mq.bufferMemory=33554432
# Canal的batch size,默认50K,由于kafka最大消息体限制请勿超过1M(900K以下)
canal.mq.canalBatchSize=50
# Canal get数据的超时时间,单位: 毫秒,空为不限超时
canal.mq.canalGetTimeout=100
# 是否为flat json格式对象
canal.mq.flatMessage=false
canal.mq.compressionType=none
canal.mq.acks=all
# kafka消息投递是否使用事务
canal.mq.transaction=false
此时我们就可以在rocketmq
的控制台看到一个cms_article topic
已经自动创建了。
四、更新Redis到MQ消息开发
4.1 引入依赖
<dependency>
<groupId>com.alibaba.otter</groupId>
<artifactId>canal.client</artifactId>
<version>1.1.4</version>
</dependency>
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<version>2.0.2</version>
</dependency>
<!-- 根据个人需要依赖 -->
<dependency>
<groupId>javax.persistence</groupId>
<artifactId>persistence-api</artifactId>
</dependency>
4.2 canal消息的通用解析代码
import com.alibaba.otter.canal.protocol.FlatMessage;
import com.test.springcloud.common.exception.BusinessException;
import com.test.springcloud.common.util.JsonUtil;
import com.test.springcloud.standard.redis.RedisRepository;
import com.google.common.collect.Sets;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.util.ReflectionUtils;
import javax.annotation.Resource;
import javax.persistence.Id;
import java.lang.reflect.Field;
import java.lang.reflect.ParameterizedType;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 抽象CanalMQ通用处理服务
**/
@Slf4j
public abstract class AbstractCanalMQ2RedisService<T> implements CanalSynService<T> {
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
RedisRepository redisRepository;
private Class<T> classCache;
/**
* 获取Model名称
*
* @return Model名称
*/
protected abstract String getModelName();
@Override
public void process(FlatMessage flatMessage) {
if (flatMessage.getIsDdl()) {
ddl(flatMessage);
return;
}
Set<T> data = getData(flatMessage);
if (SQLType.INSERT.equals(flatMessage.getType())) {
insert(data);
}
if (SQLType.UPDATE.equals(flatMessage.getType())) {
update(data);
}
if (SQLType.DELETE.equals(flatMessage.getType())) {
delete(data);
}
}
@Override
public void ddl(FlatMessage flatMessage) {
//TODO : DDL需要同步,删库清空,更新字段处理
}
@Override
public void insert(Collection<T> list) {
insertOrUpdate(list);
}
@Override
public void update(Collection<T> list) {
insertOrUpdate(list);
}
private void insertOrUpdate(Collection<T> list) {
redisTemplate.executePipelined((RedisConnection redisConnection) -> {
for (T data : list) {
String key = getWrapRedisKey(data);
RedisSerializer keySerializer = redisTemplate.getKeySerializer();
RedisSerializer valueSerializer = redisTemplate.getValueSerializer();
redisConnection.set(keySerializer.serialize(key), valueSerializer.serialize(data));
}
return null;
});
}
@Override
public void delete(Collection<T> list) {
Set<String> keys = Sets.newHashSetWithExpectedSize(list.size());
for (T data : list) {
keys.add(getWrapRedisKey(data));
}
//Set<String> keys = list.stream().map(this::getWrapRedisKey).collect(Collectors.toSet());
redisRepository.delAll(keys);
}
/**
* 封装redis的key
*
* @param t 原对象
* @return key
*/
protected String getWrapRedisKey(T t) {
// return new StringBuilder()
// .append(ApplicationContextHolder.getApplicationName())
// .append(":")
// .append(getModelName())
// .append(":")
// .append(getIdValue(t))
// .toString();
throw new IllegalStateException("基类方法'getWrapRedisKey'尚未实现!");
}
/**
* 获取类泛型
*
* @return 泛型Class
*/
protected Class<T> getTypeArguement() {
if (classCache == null) {
classCache = (Class) ((ParameterizedType) this.getClass().getGenericSuperclass())
.getActualTypeArguments()[0];
}
return classCache;
}
/**
* 获取Object标有@Id注解的字段值
*
* @param t 对象
* @return id值
*/
protected Object getIdValue(T t) {
Field fieldOfId = getIdField();
ReflectionUtils.makeAccessible(fieldOfId);
return ReflectionUtils.getField(fieldOfId, t);
}
/**
* 获取Class标有@Id注解的字段名称
*
* @return id字段名称
*/
protected Field getIdField() {
Class<T> clz = getTypeArguement();
Field[] fields = clz.getDeclaredFields();
for (Field field : fields) {
Id annotation = field.getAnnotation(Id.class);
if (annotation != null) {
return field;
}
}
log.error("PO类未设置@Id注解");
throw new BusinessException("PO类未设置@Id注解");
}
/**
* 转换Canal的FlatMessage中data成泛型对象
*
* @param flatMessage Canal发送MQ信息
* @return 泛型对象集合
*/
protected Set<T> getData(FlatMessage flatMessage) {
List<Map<String, String>> sourceData = flatMessage.getData();
Set<T> targetData = Sets.newHashSetWithExpectedSize(sourceData.size());
for (Map<String, String> map : sourceData) {
T t = JsonUtil.mapToPojo(map, getTypeArguement());
targetData.add(t);
}
return targetData;
}
}
4.3 canal消息的订阅代码
rocketMQ
是支持广播消费的,只需要在消费端进行配置即可,默认情况下使用的是集群消费,这就意味着如果我们配置了多个消费者实例,只会有一个实例消费消息。
对于更新Redis来说,一个实例消费消息,完成redis
的更新,这就够了。
import com.alibaba.otter.canal.protocol.FlatMessage;
import com.test.springcloud.seckill.dao.po.SeckillGoodPO;
import com.google.common.collect.Sets;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.annotation.MessageModel;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Slf4j
@Service
//广播模式
//@RocketMQMessageListener(topic = "seckillgood", consumerGroup = "UpdateRedis",
// messageModel = MessageModel.BROADCASTING)
//集群模式
@RocketMQMessageListener(topic = "seckillgood", consumerGroup = "UpdateRedis")
@Data
public class UpdateRedisGoodConsumer extends AbstractCanalMQ2RedisService<SeckillGoodPO>
implements RocketMQListener<FlatMessage> {
private String modelName = "seckillgood";
@Override
public void onMessage(FlatMessage s) {
process(s);
}
/**
* 封装redis的key
*
* @param t 原对象
* @return key
*/
//@Cacheable(cacheNames = {"seckill"}, key = "'seckillgood:' + #goodId")
protected String getWrapRedisKey(SeckillGoodPO t) {
return new StringBuilder()
// .append(ApplicationContextHolder.getApplicationName())
.append("seckill")
.append(":")
// .append(getModelName())
.append("seckillgood")
.append(":")
.append(t.getId())
.toString();
}
/**
* 转换Canal的FlatMessage中data成泛型对象
*
* @param flatMessage Canal发送MQ信息
* @return 泛型对象集合
*/
protected Set<SeckillGoodPO> getData(FlatMessage flatMessage) {
List<Map<String, String>> sourceData = flatMessage.getData();
Set<SeckillGoodPO> targetData = Sets.newHashSetWithExpectedSize(sourceData.size());
for (Map<String, String> map : sourceData) {
SeckillGoodPO po = new SeckillGoodPO();
po.setId(Long.valueOf(map.get("id")));
//省略其他的属性
targetData.add(po);
}
return targetData;
}
}
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· 【.NET】调用本地 Deepseek 模型
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库
· 上周热点回顾(2.17-2.23)
· DeepSeek “源神”启动!「GitHub 热点速览」