canal 介绍、搭建、配置
canal [kə'næl] ,译意为水道/管道/沟渠,主要用途是基于 MySQL 数据库增量日志解析,提供增量数据订阅和消费。下载地址:github.com/alibaba/can...
安装 canal admin
管理canal server的WebUI,借助于它我们可以轻松运维canal。
- conf 目录下有 canal admin.sql 文件,是 canal admin的数据库脚本,需要在我们的数据中执行
- 配置 application.yml 文件。
安装 canal server
Canal将自己伪装成从库,监听并接收数据库的Binlog,再同步到其他中间件系统。
- 当使用canal-admin来管理canal-server时,使用的配置文件为
conf/canal_local.properties
- 当手动维护canal-server的时,使用
conf/canal.properties
配置文件
Sh
# 配置文件中的重要参数
vim canal_local.properties
# register ip 这⾥的ip选择您本机的ip(也就是启动canal server机器的所在ip地址)
canal.register.ip = 192.168.0.222
# canal admin config 这⾥是部署canal admin的所在机器的ip,当然也可以把canal admin 和canal server部署到⼀台机器,这⾥的端⼝表示 http访问的端⼝
canal.admin.manager = 192.168.0.221:8089
# 表示canal-admin与canal-server内部通信的TCP端⼝
canal.admin.port = 11110
# canal-admin的⽤户名和密码,canal-server在进⾏访问的时候,需要⽤他们来进⾏认证,如果填写不正确,则会出现⽆法连接
canal.admin.user = admin canal.admin.passwd =6BB4837EB74329105EE4568DDA7DC67ED2CA2AD9 # admin auto register 这⾥⼀定得是true,否则⽆法在启动canal server时候注册到 canal admin上
# 这个表示,启动canal-server时是否⾃动将canal-server的信息 注册到canal-admin上,默认是true,我们不⽤改。
canal.admin.register.auto = true
# 这个表示我们要注册到哪个集群中,我们没有采⽤集群的⽅式部署 canal-server,所以这⾥什么都不⽤写。如果采⽤集群的⽅式这⾥写集群的名称即可。当然我们这 ⾥没有使⽤集群的⽅式来部署canal-server,因为即使我们使⽤集群的⽅式,同⼀时间,也只有⼀ 个canal-server在⼯作,canal-server的集群模式,类似于hdfs中的NameNode的⼯作原理,两个 canal-server同时机器,都会注册到zk上,谁先注册到zk上,谁就会是active节点,剩下的那个作 为standby节点。
canal.admin.register.cluster =
# canal server注册到canal admin控制台的名称 这⾥⽤canal server的ip地址。注意改ip
canal.admin.register.name = 192.168.0.222
canal admin 配置 canal instance 监听数据库

- ⼀个canal-server服务⾥可以有多个canal-instance,⼀般⼀个canal-instance实例作⽤在⼀个数据库实例上。
- 我们根据业务情况,如果我们监听的数据库的负载很大,那么我们在canal server上启动⼀个 canal instalce实例就行。如果负载不大,可以酌情启动多个实例来监听相关的数据库的binlog日志。

- 填写canal-instance的名称
- 选择当前创建的canal-instance所属的canal-server。
- 点击载⼊模板,填写canal-instance的配置
重要配置项:
YAML
# 需要监听数据库的 ip加端⼝
canal.instance.master.address=127.0.0.1:3306
# 需要监听数据库的⽤户名
canal.instance.tsdb.dbUsername=canal
# 需要监听数据库的密码
canal.instance.tsdb.dbPassword=canal
# 这⾥填写topic名称即可,前提是 canal.serverMode 选择的是kafka, rocketMQ, rabbitMQ其中之⼀,(注意这个配置项在canal server的canal.properties配置⽂件下)。 同时如果我们配置了canal.mq.dynamicTopic的配置,则canal.mq.topic 只有在找不到相关topic的情况下,才会发送到canal.mq.topic所指定的topic下,⽤于兜底。
canal.mq.topic=example
# 这⾥填写动态topic的相关配置;
# 冒号前⾯是topic的名称,冒号后⾯是库名.表名,多个动态topic的规则,采⽤逗号分隔。
# 例如我们要把订单的正向操作的binlog 相关表发送到order-forward topic下,把逆向操作产⽣的binlog发送到order-reverse这个topic 下,我们可以这么写:
# order-forward:order\\.order_info, orderreverse:order\\.after_sale_refund 表示,把order数据库下的 order_info表的binlog⽇志发送到名为order-forward的topic下,把order数据库下的 after_sale_refund表的binlog⽇志发送到名为order-reverse的topic下
canal.mq.dynamicTopic=order-forward:order\\.order_info,orderforward:order\\.order_delivery_detail,orderforward:order\\.order_payment_detail,orderreverse:order\\.after_sale_info,orderreverse:order\\.after_sale_refund
# 这里填写订阅的数据库的库与表的相关正则表达式,语法如下:
# 1. 所有表:.* or .*\\..*
# 2. canal schema下所有表: canal\\..*
# 3. canal下的以canal打头的表:canal\\.canal.*
# 4. canal schema下的⼀张表:canal.test1
# 5. 多个规则组合使⽤:canal\\..*,mysql.test1,mysql.test2 (逗号分隔)
# 例如:order.order_info,order.order_delivery_detail,order.or der_payment_detail,order.after_sale_info,order.after_sale_refund 表示我们只关注上⾯这些表的binlog⽇志。
canal.instance.filter.regex=monomer_order\\.order.*
PS:如果要让canal将binlog⽇志投递到mq,那么需要在canal_local.properties中加⼊ RocketMQ 相关配置,这个在canal admin中修改就⾏。
配置如下
sh
# tcp, kafka, RocketMQ,数据同步⽅式 不写则默认使⽤tcp的⼯作⽅式
canal.serverMode=RocketMQ
# 如果是 RocketMQ 是nameSrv的地址 如:xxx.xxx.xxx.xxx:9876
canal.mq.servers=192.168.0.222:9876
开启 mysql binlog
sh
show variables like 'binlog_format';
show variables like 'log_bin';
基于读写队列&定时任务实现高效写入

在增量同步环节,我们是通过canal监听mysql中的binlog日志,然后canal再将监听到的binlog⽇志,将 binlog日志放到RocketMQ中的。那么现在面临几个问题:
- 定时任务消费RocketMQ中的binlog消息时,如何保证从Rocket中获取到的binlog 消息,一定不会丢失呢?
- 如何尽可能的提高写入效率呢?
让我们一起看下吧!
防止增量同步数据丢失
数据库中执行了增删改操作,但是由于消息丢失了,导致其他数据源中却没有对应的增量数据操作,产生数据不一致问题。为此,我们引入了消息拉取和消息提交机制,主要由两个定时任务完成:

-
定时任务1先从RocketMQ中主动拉取消息,然后在旧库中会有⼀张消费记录表,每次从RocketMQ中拉取并消费⼀条消息时,都会对应在消费记录表中,新增⼀条消费记录,每条消费记录的初始状态为未消费。
-
定时任务1再将获取到的binlog消息,在其他数据源中重做binlog⽇志,重做完成之后,回过头来更新刚才添加的消费记录的状态,从未消费更新为已消费状态。
-
消费了RocketMQ的binlog消息之后,并不会主动向RocketMQ提交消息,而是手动提交。所以,为了保证binlog消息不丢失,我们不会自动提交消息,⽽是将提交消息的任务,交给定时任务2来处理,定时任务2会专门从消费记录表中,查询已消费的那些记录,然后向RocketMQ提交消息,下次就不会从RocketMQ中消费到了。
-
向RocketMQ提交完消息之后,同时会将消费记录表中的记录状态,从已消费更新为已提交。通过消息拉取和消息提交机制,我们就可以保证binlog消息,是⼀定不会丢失的。
提高写入效率
为了提高写⼊效率,我们引入数据合并、过滤、读写队列机制。

- 添加完消费记录之后,并不会立马重做,先放到写队列中,与之相应的有一个读队列,读队列专专门给定时任务3,负责处理消息写入操作;
- 数据合并:假设在订单数据1、2在短时间进行了多次操作,我们可以对订单数据1和订单数据2,只保留更新时间为最新的binlog日志。
- 主要是避免旧库中的那些旧数据,覆盖新数据源的新数据,另一方面避免那些没必要执行的删除和更新操作在新库执行
部分代码
java
@Component
public class CanalConsumeTask implements ApplicationRunner {
/**
* rocketmq的nameServer地址
*/
@Value("${rocketmq.name-server:127.0.0.1:9876}")
private String nameServerUrl;
@Override
public void run(ApplicationArguments args) throws Exception {
// 直接会在这里创建一个线程池
ExecutorService executors = Executors.newFixedThreadPool(1);
// 执行拉取任务
executors.execute(new CanalPullRunner("ORDER_TOPIC", nameServerUrl));
// 执行提交任务
executors.execute(new CanalPullCommitRunner("ORDER_TOPIC", nameServerUrl));
}
}
java
private void pullRun() {
try {
DefaultLitePullConsumer litePullConsumer = new DefaultLitePullConsumer("binlogPullConsumer");
litePullConsumer.setAutoCommit(false);
// 此时offset已经被提交了,处理失败了,rocketmq不给我们再次重复消费了
litePullConsumer.setNamesrvAddr(nameServerUrl);
litePullConsumer.subscribe(topic, "*");
litePullConsumer.start();
try {
while (true) {
// 拉取未消费消息
List<MessageExt> messageExts = litePullConsumer.poll();
if (CollUtil.isNotEmpty(messageExts)) {
for (MessageExt messageExt : messageExts) {
byte[] body = messageExt.getBody();
String msg = new String(body);
// 记录queueId和offset
int queueId = messageExt.getQueueId();
long offset = messageExt.getQueueOffset();
String topic = messageExt.getTopic();
// 判断该消息是否已经存在消费记录,如果存在则跳过执行
EtlBinlogConsumeRecord existsRecord = consumeRecordMapper.getExistsRecord(queueId, offset, topic);
if (null == existsRecord) {
// 新增消费记录
processNewMsg(messageExt, msg);
} else {
// 处理已经存在的消费记录
proccessExistsRecord(litePullConsumer, msg, existsRecord);
}
}
} else {
Thread.sleep(5000);
}
}
} finally {
litePullConsumer.shutdown();
}
} catch (InterruptedException | MQClientException e) {
try {
// 假设要拉取消息的主题还不存在,则会抛出异常,这种情况下休眠五秒再重试
Thread.sleep(5000);
pullRun();
} catch (InterruptedException ignored) {
}
}
}
private void processNewMsg(MessageExt messageExt, String msg) {
try {
// 拿到的msg值一个字符串,字符串格式的binlog,但是我们需要对字符串格式的binlog做一个解析
// 把这个解析后的binlog的信息封装到我们自定义的BinlogData对象里去
BinlogData binlogData = BinlogUtils.getBinlogDataMap(msg);
Boolean targetOperateType = BinlogType.INSERT.getValue().equals(binlogData.getOperateType())
|| BinlogType.DELETE.getValue().equals(binlogData.getOperateType())
|| BinlogType.UPDATE.getValue().equals(binlogData.getOperateType());
if (!targetOperateType
|| null == binlogData
|| null == binlogData.getDataMap()) {
return;
}
EtlBinlogConsumeRecord consumeRecord = new EtlBinlogConsumeRecord();
consumeRecord.setQueueId(messageExt.getQueueId());
consumeRecord.setOffset(messageExt.getQueueOffset());
consumeRecord.setTopic(messageExt.getTopic());
consumeRecord.setBrokerName(messageExt.getBrokerName());
consumeRecord.setConsumeStatus(ConsumerStatus.NOT_CONSUME.getValue());
consumeRecord.setCreateTime(new Date());
consumeRecordMapper.insert(consumeRecord);
LocalQueue.getInstance().submit(binlogData, consumeRecord);
} catch (Exception e) {
log.error("新增消费记录失败", e);
}
}
private void proccessExistsRecord(DefaultLitePullConsumer litePullConsumer, String msg, EtlBinlogConsumeRecord existsRecord) {
// 已经存在的消费记录状态为已提交,说明mq里的对应消息修改提交状态失败了
try {
if (ConsumerStatus.COMMITTED.getValue().equals(existsRecord.getConsumeStatus())) {
litePullConsumer.seek(new MessageQueue(existsRecord.getTopic(), existsRecord.getBrokerName(), existsRecord.getQueueId()), existsRecord.getOffset());
//这一步必须,不然手动提交的东西不对
List<MessageExt> committedFaildmessageExts = litePullConsumer.poll();
// 再次提交已消费的消息
litePullConsumer.commitSync();
} else {
BinlogData binlogData = BinlogUtils.getBinlogDataMap(msg);
if (null == binlogData) {
return;
}
LocalQueue.getInstance().submit(binlogData, existsRecord);
}
} catch (Exception e) {
log.error("消息重新消费失败", e);
}
}
java
/**
* 数据缓存阻塞队列类
*
* @author zhonghuashishan
*/
@Slf4j
public class LocalQueue {
private static volatile LocalQueue localQueue;
/**
* 数据同步的写队列
*/
private volatile LinkedList<BinlogData> writeQueue = new LinkedList<>();
/**
* 数据同步的 读队列
*/
private volatile LinkedList<BinlogData> readQueue = new LinkedList<>();
/**
* 提供锁的实例对象
*/
private final PutBinlogLock lock = new PutBinlogLock();
/**
* 是否正在读取数据
* 可能是多线程并发读和写,volatile,保证线程之间的可见性
*/
private volatile boolean isRead = false;
private LocalQueue() {
}
/**
* 构建一个单例模式对象
*
* @return LocalQueue实例
*/
public static LocalQueue getInstance() {
if (null == localQueue) {
synchronized (LocalQueue.class) {
if (null == localQueue) {
localQueue = new LocalQueue();
}
}
}
return localQueue;
}
/**
* 数据写入队列
*
* @param binlogData MySQL的binlog对象
* @param consumeRecord 消费记录
*/
public void submit(BinlogData binlogData, EtlBinlogConsumeRecord consumeRecord) {
lock.lock();
try {
binlogData.setConsumeRecord(consumeRecord);
writeQueue.add(binlogData);
} finally {
lock.unlock();
}
}
/**
* 交换队列
*/
private void swapRequests() {
lock.lock();
try {
log.info("本次同步数据写入:" + writeQueue.size() + "条数");
LinkedList<BinlogData> tmp = writeQueue;
writeQueue = readQueue;
readQueue = tmp;
} finally {
lock.unlock();
}
}
/**
* 将读队列缓存的数据,进行数据合并处理,并写入存储落地
*/
public void doCommit() {
// 标记目前正在读取数据同步落地
isRead = true;
// 读取数据,并写入完成以后,交互一下读 写队列
swapRequests();
if (!readQueue.isEmpty()) {
MergeBinlogWrite mergeBinlogWrite = new MergeBinlogWrite();
// 遍历存储在读队列的数据,进行数据合并,保留时间最新的操作
for (BinlogData binlogData : readQueue) {
// 对数据进行合并处理
mergeBinlogWrite.mergeBinlog(binlogData);
}
mergeBinlogWrite.filterBinlogAging(OperateType.ADD, null);
// 数据写入,按表分组写入
mergeBinlogWrite.write(OperateType.ADD, null);
}
readQueue.clear();
isRead = false;
}
/**
* 获取是否正在读取数据解析落地
*
* @return 是否正在读取
*/
public Boolean getIsRead() {
return this.isRead;
}
}