微服务解决方案 -- 数据库增量日志解析 Canal

微服务解决方案 Canal

1. 什么是Canal

canal [kə’næl],译意为水道/管道/沟渠,主要用途是基于MySQL数据库增量日志解析,提供增量数据订阅和消费。

基于日志增量订阅和消费的业务包括

  • 数据库镜像
  • 数据库实时备份
  • 索引构建和实时维护(拆分异构索引、倒排索引等)
  • 业务cache刷新
  • 带业务逻辑的增量数据处理

当前的canal支持源端MySQL版本包括 5.1.x , 5.5.x , 5.6.x , 5.7.x , 8.0.x

2. 为什么使用Canal

2.1 MySQL主备复制原理

  • MySQL master 将数据变更写入二进制日志( binary log, 其中记录叫做二进制日志事件binary log events,可以通过 show binlog events 进行查看)
  • MySQL slave 将 master 的 binary log events 拷贝到它的中继日志(relay log)
  • MySQL slave 重放 relay log 中事件,将数据变更反映它自己的数据

2.2 canal 工作原理

  • canal 模拟 MySQL slave 的交互协议,伪装自己为 MySQL slave ,向 MySQL master 发送dump 协议
  • MySQL master 收到 dump 请求,开始推送 binary log 给 slave (即 canal )
  • canal 解析 binary log 对象(原始为 byte 流)

2.3 使用Canal的场景

由第一张图可知,我们可以向非Mysql的数据库进行数据的同步,比如增量同步到ESRedisOracle等任意地方。这样,我们就不需要自己手动同步到其他数据库里。

3. 如何使用Canal

3.1 Canal 准备 – 数据库

首先准备一个mysql数据库,并且在 mysqld添加如下

[mysqld]
log-bin=D:\mysql-5.7.32\mysql-bin
binlog-format=ROW
server-id=123454

或者使用docker的方式,不过得修改容器内文件/etc/mysql/mysql.conf.d/mysqld.cnf

## 进入容器
docker exec -it mysql-canal /bin/bash 
## 添加如下
log-bin=/var/lib/mysql/mysql-bin
binlog-format=ROW
server-id=123454
## 重启容器
docker restart mysql-canal
show variables like 'log_%';

show binary logs;

3.2 Canal准备 – 服务端

下载canal服务端https://github.com/alibaba/canal/releases

修改配置文件conf/example/instance.properties

# 主数据库地址
canal.instance.master.address = 127.0.0.1:3306
# mysql binary log
canal.instance.master.journal.name = mysql-bin.000001
# 偏移量 show BINARY logs;
canal.instance.master.position = 154

# username/password
# 在MySQL服务器授权的账号密码
canal.instance.dbUsername = root
canal.instance.dbPassword = 123456

# table regex
# 监听所有表,也可以指定表用,分割
canal.instance.filter.regex = .*\\..*

3.3 Canal业务 – Java

GitHub上有他的Example示例这里不多赘述,点击此处跳转示例

3.4 Canal业务 – SpringBoot

这里是我本人写的一个示例,将mysql的数据同步到ES上(本来中间应该加一层MQ ,但是自己的阿里云服务器内存不够用了,所以省略)。

+--------+       +--------+        +--------+        +----------+
|  mysql |  ---> | Canal  |  --->  |   MQ   |  --->  | es/redis |
+--------+       +--------+        +--------+        +----------+

第一部搭建一个ES,可以参考我之前的博客微服务解决方案 – 高效搜索 Elastic Search 7.6.2 (上),里面有用docker的方式搭建一个elastic search

然后引入依赖,分别是Canal的依赖和ES的依赖

<!-- ali canal -->
<properties>
    <ali.canal.version>1.1.4</ali.canal.version>
	<elasticsearch.version>7.6.2</elasticsearch.version>
</properties>
<!-- alibaba canal -->
<dependency>
    <groupId>com.alibaba.otter</groupId>
    <artifactId>canal.client</artifactId>
    <version>${ali.canal.version}</version>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>

<dependency>
    <groupId>org.elasticsearch.client</groupId>
    <artifactId>elasticsearch-rest-high-level-client</artifactId>
</dependency>

配置文件

spring:
  application:
    name: canal-example

server:
  port: 19000
## canal 配置
canal:
  hostname: 127.0.0.1
  port: 11111
  destination: example
## es 配置
es:
  hostname: 127.0.0.1
  port: 9200
  scheme: http

配置2个配置类

package com.laoshiren.hello.canal.es.configure;

import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.client.impl.SimpleCanalConnector;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.net.InetSocketAddress;

/**
 * ProjectName:     hello-canal
 * Package:         com.laoshiren.hello.canal.es.configure
 * ClassName:       CanalConfiguration
 * Author:          laoshiren
 * Git:             15207034473@163.com
 * Description:
 * Date:            2020/10/21 14:05
 * Version:         1.0.0
 */
@Configuration
@Slf4j
public class CanalConfiguration {

    /**
     * canal 服务地址
     */
    @Value(value = "${canal.hostname}")
    private String hostName;

    /**
     * canal 端口
     */
    @Value(value = "${canal.port}")
    private Integer port;

    /**
     * canal 目标
     */
    @Value(value = "${canal.destination}")
    private String destination;

    /**
     * canal 连接器
     * @return  canalConnector
     */
    @Bean("canalConnector")
    public CanalConnector initCanalConnector(){
        log.info("-- canal init --");
        InetSocketAddress address = new InetSocketAddress(hostName, port);
        // canalConnector
        log.info("-- canal params -- {} -- {} --",hostName,port);
        SimpleCanalConnector canalConnector = new SimpleCanalConnector(address, "", "", destination);
        canalConnector.setSoTimeout(60 * 1000);
        canalConnector.setIdleTimeout(-1);
        log.info("-- canal finish --");
        return canalConnector;
    }

}
package com.laoshiren.hello.canal.es.configure;

import lombok.extern.slf4j.Slf4j;
import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestHighLevelClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * ProjectName:     hello-canal
 * Package:         com.laoshiren.hello.canal.es.configure
 * ClassName:       ElasticSearchClientConfiguration
 * Author:          laoshiren
 * Git:             15207034473@163.com
 * Description:
 * Date:            2020/10/23 14:09
 * Version:         1.0.0
 */
@Configuration
@Slf4j
public class ElasticSearchClientConfiguration {

    @Value("${es.hostname}")
    private String hostname;
    @Value("${es.port}")
    private int port;
    @Value("${es.scheme}")
    private String scheme;

    @Bean
    public RestHighLevelClient restHighLevelClient(){
        log.info("-- es init --");
        log.info("-- es params -- {} -- {} -- {} --",hostname,port,scheme);
        log.info("-- es finish --");
        return new RestHighLevelClient(
                RestClient.builder(new HttpHost(hostname,port,scheme))
                        .setRequestConfigCallback(requestConfigBuilder -> {
                            requestConfigBuilder.setConnectTimeout(-1);
                            requestConfigBuilder.setSocketTimeout(30000);
                            requestConfigBuilder.setConnectionRequestTimeout(30000);
                            return requestConfigBuilder;
                        })
        );
    }
}

设计同步的对象

{
	# 因为是增量同步所有库所有表,所有得存储库名和表名
	"schemaName": "xxxx",
	"tableName": "xxx",
	# 记录操作 INSERT UPDATE DELETE
	"eventType": "INSERT",
	# data 记录所有列名(需要处理驼峰)和数据,存储一个json 字符串
	"data": "xxx",
	# 保持记录唯一性 比如ES的ID REDIS的key后缀
	"id":"xxxx"
}

EntryDto.java

package com.laoshiren.hello.canal.es.domain;

import com.alibaba.otter.canal.protocol.CanalEntry;
import com.google.protobuf.InvalidProtocolBufferException;
import com.laoshiren.hello.canal.common.utils.ColumnToPropertyUtils;
import com.laoshiren.hello.canal.common.utils.JsonUtils;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * ProjectName:     hello-canal
 * Package:         com.laoshiren.hello.canal.es.domain
 * ClassName:       EntryDto
 * Author:          laoshiren
 * Git:             15207034473@163.com
 * Description:
 * Date:            2020/10/21 14:32
 * Version:         1.0.0
 */
@Data
@Accessors(chain = true)
@NoArgsConstructor
@AllArgsConstructor
public class EntryDto {

    /**
     * id
     */
    private String id;

    /**
     * 数据库名
     */
    private String schemaName;

    /**
     * 表明
     */
    private String tableName;

    /**
     * 操作类型
     */
    private CanalEntry.EventType eventType;

    /**
     * 实际数据
     */
    private String data;

    /**
     * entry 2 Object
     * @param entry CanalEntry.Entry
     */
    public EntryDto(CanalEntry.Entry entry){
        // 操作数据库名
        this.schemaName = entry.getHeader().getSchemaName();
        // 操作表名
        this.tableName = entry.getHeader().getTableName();
        try {
            CanalEntry.RowChange rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
            // 操作类型
            CanalEntry.EventType eventType = rowChange.getEventType();
            this.eventType = eventType;
            // 实际数据
            List<CanalEntry.RowData> rowDataList = rowChange.getRowDatasList();
            Map<String,Object> dataMap = new HashMap<>();
            for (CanalEntry.RowData rowData : rowDataList) {
                // 获取数据
                List<CanalEntry.Column> columns = rowData.getAfterColumnsList();
                if (eventType.equals(CanalEntry.EventType.DELETE)) {
                    // 如果是删除获取之前的数据
                    columns = rowData.getBeforeColumnsList();
                }
                for (CanalEntry.Column column : columns) {
                    // 主键
                    if (column.getIsKey()) {
                        this.id = column.getValue();
                    }
                    // 转换json
                    dataMap.put(ColumnToPropertyUtils.columnToProperty2(column.getName()),column.getValue());
                }
            }
            this.data = JsonUtils.obj2json(dataMap);
        } catch (InvalidProtocolBufferException e) {
            e.printStackTrace();
        }
    }

    /**
     * 索引名
     * @return  String  dbName_tableName
     */
    public String getIndexName(){
        return this.schemaName +
                "_" +
                this.tableName;
    }

}

Canal操作

/**
 * List<Entry> 转换成对象
 *
 * @param entries entry
 * @return List EntryDto
 */
public List<EntryDto> mappingEntry(List<CanalEntry.Entry> entries) {
    return entries.stream()
            .filter(it -> {
                //开启/关闭事务的实体类型,跳过
                return it.getEntryType() != CanalEntry.EntryType.TRANSACTIONBEGIN &&
                        it.getEntryType() != CanalEntry.EntryType.TRANSACTIONEND;
            })
            .map(EntryDto::new)
            .collect(Collectors.toList());
}

/**
 * 交给es处理
 * @param list entryList
 */
public void elasticHandler(List<EntryDto> list){
    list.forEach( it-> elasticService.documentHandler(it));
}

ES操作

@Resource
private RestHighLevelClient restHighLevelClient;

@Override
public void documentHandler(EntryDto entryDto) {
    log.info("---- index ---- {} ----- {}",entryDto.getIndexName(), JsonUtils.obj2json(entryDto));
    switch (entryDto.getEventType()) {
        case UPDATE: documentUpdate(entryDto);break;
        case INSERT: documentCreate(entryDto);break;
        case DELETE: documentDelete(entryDto);break;
        default: break;
    }
}

// 部分代码省略

定时任务去向Canal的服务拉去数据

boolean initFlag = false;

@Scheduled(cron = "*/2 * * * * ?")
public void canalHandler(){
    boolean init = init();
    if (init) {
        Message message = connector.getWithoutAck(100);
        long batchId = message.getId();
        int size = message.getEntries().size();
        if (batchId == -1 || size == 0) {
            log.info("listen ...... ");
        } else {
            // 转换成EntryDto ,上面设计好的数据对象 
            List<EntryDto> list = canalService.mappingEntry(message.getEntries());
            // 发送到ES上
            canalService.elasticHandler(list);
        }
        connector.ack(batchId); // 提交确认
    }
}

private boolean init(){
    if (!initFlag) {
        connector.connect();
        connector.subscribe(".*\\..*");
        connector.rollback();
        initFlag = !initFlag;
        log.info(" --- init --- init canal job finished");
    }
    return initFlag;
}

当然最好是中间有一层MQ让这个客户端一个一个消费。

4 .测试

插入一个数据ES上就可以看到数据了。

5.更多

从上图可知,我们可以使用MQ来进行消息的异步处理,就不需要如我写的demo一样配置定时任务,刚好我们前一篇博客初步入门了一下RabbitMQ微服务解决方案 – 消息队列 【RabbitMQ】,我会在做完RocketMQ之后,把从canal -> MQ -> ES的完整链路补上。

6 资料

官方示例ClientExample

关于Canal还有一篇写的很好的博客超详细的Canal入门,看这篇就够了!

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值