自学笔记:canal


canal

个人学习当时所使用的环境

canal版本:1.1.5
canal安装系统:window

mysql版本:5.7
mysql安装在远程服务器linux(CentOS 7)

canal为何存在?

在现代的系统开发中, 为了提高搜索效率 , 以及搜索的精准度, 会大量的使用 redis ,memcache 等 nosql 系统的数据库 ;但这就会长生一个问题:如何让redis和mysql双方的数据及时同步?

为了解决这个问题,有以下几种不同的方案:

  1. 使用业务代码实现同步

    在业务层执行增加、修改、删除改变mysql数据库之后,也执行操作redis的逻辑代码。

    优点:操作简单
    缺点:与业务操作代码耦合度变高;执行效率低。

  2. 使用MQ实现同步

    在执行完增加、修改、删除之后, 往MQ中发送一条消息 ;同步程序作为MQ中的消费者,从消息队列中获取消息,然后执行同步redis数据库的逻辑。

    优点:业务代码解耦, 并且可以做到准实时
    缺点:需要在业务代码中加入发送消息到MQ中的代码 , API耦合。

  3. 定时任务同步

    在执行完增加、修改、删除,操作数据库中的数据变更之后 ,通过定时任务定时的将数据库的数据同步到solr的索引库中。

    定时任务技术 : SpringTask , Quartz

    优点:同步redis数据库操作与业务代码完全解耦。
    缺点:数据的实时性并不高。

  4. binglog来实现同步canal

    binglog实现同步的方法再细分不止一种,这个笔记主要学习canal,所以以canal为例。而且canal不止可以将数据同步给redis,也可以同步给其他类型的数据库。

    基于binlog使用canal,将数据库中的数据同步到Redis。

    通过Canal来解析数据库的日志信息, 来检测数据库中表结构的数据变化,从而更新redis数据库。

    优点:与业务代码完全解耦,API完全解耦,可以做到准实时。

    缺点:canal是第三方实现的,需要学习成本(学无止尽,技多不压身)。


canal简介

canal ,译意为水道/管道/沟渠,主要用途是基于 MySQL 数据库增量日志解析,提供增量数据订阅和消费。
在这里插入图片描述

早期阿里巴巴因为杭州和美国双机房部署,存在跨机房同步的业务需求,实现方式主要是基于业务 trigger 获取增量变更。从 2010 年开始,业务逐步尝试数据库日志解析获取增量变更进行同步,由此衍生出了大量的数据库增量订阅和消费业务。

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

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

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

顾名思义,就像两种数据库之间的一根管道一样。两种数据库想要实现数据同步,canal就作为两种数据库之间的传话管道,当主数据库发成变化时,canal会即使的通知从库做了哪些改变,从库也跟着做相应的改变,完成同步。

本次学习使用的canal版本的是 1.1.5 。


canal的工作原理

MySQL主从复制(AB复制)原理图:

主从复制(也称 AB 复制)允许将来自一个MySQL数据库服务器(主服务器)的数据复制到一个或多个MySQL数据库服务器(从服务器)。

在这里插入图片描述

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

canal 工作原理:

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

个人理解,canal就是伪装成mysql的从库,向mysql主库请求 binary log 。当拿到 binary log 后开始解析,再将解析结果给其他类型数据库。

原本其他类型数据库,例如redis没有办法直接和mysql沟通,也就不知道mysql主库发生了哪些变化,就无法及时同步数据。但是canal成为一个中间管道,把mysql主库的操作都及时的通知redis,这样redis就可以和主库进行数据同步了。


快速入门

一个简单的demo,可以快速看到canal带来的效果。

参考:https://blog.youkuaiyun.com/yehongzhi1994/article/details/107880162

环境准备

  1. Mysql主数据库

    具体的安装不细说,教程很多。

    需要注意的一些配置以及操作

    1. 修改Mysql主库的配置文件:/etc/my.cnf配置文件,先开启Binlog写入功能,配置binlog-format为ROW模式,以及server_id。

      [mysqld]
      # 开启 binlog
      log-bin=mysql-bin
      # 选择 ROW 模式
      binlog-format=ROW 
      # 配置 MySQL replaction 需要定义,不要和 canal 的 slaveId 重复
      server_id=1 
      

      配置信息改好后重启以下mysql服务。

    2. 为canal创建一个连接Mysql主库的账号,并且该账号具有作为MySQL slave的权限。

      -- 创建用户 用户名:canal 密码:canal
      CREATE USER 'canal'@'%' IDENTIFIED BY 'canal';
      -- 授权 *.*表示所有库
      GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%' IDENTIFIED BY 'canal';
      
  2. canal

    • 下载canal: https://github.com/alibaba/canal

      我用的是canal.deployer-1.1.5-SNAPSHOT.tar.gz

      然后解压。

      在这里插入图片描述

    • 更改配置文件 conf/example/instance.properties

      ## mysql serverId , v1.0.26+ will autoGen
      ## v1.0.26版本后会自动生成slaveId,所以可以不用配置
      # canal.instance.mysql.slaveId=0
      
      # 数据库地址
      canal.instance.master.address=Mysql主库的Ip地址(本地的话127.0.0.1):3306
      # binlog日志名称
      canal.instance.master.journal.name=mysql-bin.000001
      # mysql主库链接时起始的binlog偏移量
      canal.instance.master.position=154
      # mysql主库链接时起始的binlog的时间戳
      canal.instance.master.timestamp=
      canal.instance.master.gtid=
      
      # username/password
      # 在MySQL服务器授权的账号密码
      canal.instance.dbUsername=canal
      canal.instance.dbPassword=canal
      # 字符集
      canal.instance.connectionCharset = UTF-8
      # enable druid Decrypt database password
      canal.instance.enableDruid=false
      
      # table regex .*\\..*表示监听所有表 也可以写具体的表名,用,隔开
      canal.instance.filter.regex=.*\\..*
      # mysql 数据解析表的黑名单,多个表用,隔开
      canal.instance.filter.black.regex=
      
    • 通过bin/startup.bat启动canal

      在这里插入图片描述

编写java客户端

  1. 创建一个springboot项目。

  2. 导入canal的依赖。

    		<dependency>
    			<groupId>com.alibaba.otter</groupId>
    			<artifactId>canal.client</artifactId>
    			<version>1.1.4</version>
    		</dependency>
    
  3. 然后创建一个canan客户端类。要实现接口InitializingBean。

    import com.alibaba.otter.canal.client.CanalConnector;
    import com.alibaba.otter.canal.client.CanalConnectors;
    import com.alibaba.otter.canal.protocol.CanalEntry.Column;
    import com.alibaba.otter.canal.protocol.CanalEntry.Entry;
    import com.alibaba.otter.canal.protocol.CanalEntry.EntryType;
    import com.alibaba.otter.canal.protocol.CanalEntry.EventType;
    import com.alibaba.otter.canal.protocol.CanalEntry.RowChange;
    import com.alibaba.otter.canal.protocol.CanalEntry.RowData;
    import com.alibaba.otter.canal.protocol.Message;
    import org.springframework.beans.factory.InitializingBean;
    import org.springframework.stereotype.Component;
    
    import java.net.InetSocketAddress;
    import java.util.List;
    
    
    @Component
    public class CanalClient implements InitializingBean {
        //指定每次拉取数据的大小
        private final static int BATCH_SIZE = 1000;
    
        @Override
        public void afterPropertiesSet() throws Exception {
            //1.创建链接
            CanalConnector connector = CanalConnectors.newSingleConnector(new InetSocketAddress("127.0.0.1", 11111), "example", "", "");
            try {
                //2.打开连接
                connector.connect();
                //3.订阅数据库表,全部表
                connector.subscribe(".*\\..*");
                //回滚到未进行ack的地方,下次fetch的时候,可以从最后一个没有ack的地方开始拿
                connector.rollback();
    
                //4.不断的从主库拉取数据
                while (true) {
                    // 获取指定数量的数据
                    Message message = connector.getWithoutAck(BATCH_SIZE);
                    //获取批量ID
                    long batchId = message.getId();
                    //获取批量的数量
                    int size = message.getEntries().size();
                    //如果没有数据
                    if (batchId == -1 || size == 0) {
                        try {
                            //线程休眠2秒
                            Thread.sleep(2000);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    } else {
                        //如果有数据,处理数据
                        printEntry(message.getEntries());
                    }
                    //进行 batch id 的确认。确认之后,小于等于此 batchId 的 Message 都会被确认。
                    connector.ack(batchId);
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                connector.disconnect();
            }
        }
    
        /**
         * 打印canal server解析binlog获得的实体类信息
         */
        private static void printEntry(List<Entry> entrys) {
            for (Entry entry : entrys) {
                if (entry.getEntryType() == EntryType.TRANSACTIONBEGIN || entry.getEntryType() == EntryType.TRANSACTIONEND) {
                    //开启/关闭事务的实体类型,跳过
                    continue;
                }
                //RowChange对象,包含了一行数据变化的所有特征
                //比如isDdl 是否是ddl变更操作 sql 具体的ddl sql beforeColumns afterColumns 变更前后的数据字段等等
                RowChange rowChage;
                try {
                    rowChage = RowChange.parseFrom(entry.getStoreValue());
                } catch (Exception e) {
                    throw new RuntimeException("ERROR ## parser of eromanga-event has an error , data:" + entry.toString(), e);
                }
                //获取操作类型:insert/update/delete类型
                EventType eventType = rowChage.getEventType();
    
                //打印Header信息
                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));
                //判断是否是DDL语句
                if (rowChage.getIsDdl()) {
                    System.out.println("================》;isDdl: true,sql:" + rowChage.getSql());
                }
                //获取RowChange对象里的每一行数据,打印出来
                for (RowData rowData : rowChage.getRowDatasList()) {
                    //如果是删除语句
                    if (eventType == EventType.DELETE) {
                        printColumn(rowData.getBeforeColumnsList());
                        //如果是新增语句
                    } else if (eventType == EventType.INSERT) {
                        printColumn(rowData.getAfterColumnsList());
                        //如果是更新的语句
                    } else {
                        //变更前的数据
                        System.out.println("-------修改前-------");
                        printColumn(rowData.getBeforeColumnsList());
                        //变更后的数据
                        System.out.println("-------修改后-------");
                        printColumn(rowData.getAfterColumnsList());
                    }
                }
            }
        }
    
        //打印
        private static void printColumn(List<Column> columns) {
            for (Column column : columns) {
                System.out.println(column.getName() + " : " + column.getValue() + "    update=" + column.getUpdated());
            }
        }
    }
    

测试

  1. Mysql主库,canal都启动,启动java客户端。

  2. 修改mysql数据库的数据。
    在这里插入图片描述

  3. 则可以观察到canal客户端打印出我们刚才的操作。

    在这里插入图片描述


canal的数据格式

参考:https://blog.youkuaiyun.com/weixin_41047933/article/details/85293002

Entry
    Header
        version         [协议的版本号,default = 1]
        logfileName     [binlog文件名]
        logfileOffset   [binlog position]
        serverId        [服务端serverId]
        serverenCode    [变更数据的编码]
        executeTime     [变更数据的执行时间]
        sourceType      [变更数据的来源,default = MYSQL]
        schemaName      [变更数据的schemaname]
        tableName       [变更数据的tablename]
        eventLength     [每个event的长度]
        eventType       [insert/update/delete类型,default = UPDATE]
        props           [预留扩展]
        gtid            [当前事务的gitd]
    entryType           [事务头BEGIN/事务尾END/数据ROWDATA/HEARTBEAT/GTIDLOG]
    storeValue          [byte数据,可展开,对应的类型为RowChange]    
RowChange
    tableId             [tableId,由数据库产生]
    eventType           [数据变更类型,default = UPDATE]
    isDdl               [标识是否是ddl语句,比如create table/drop table]
    sql                 [ddl/query的sql语句]
    rowDatas            [具体insert/update/delete的变更数据,可为多条,1个binlog event事件可对应多条变更,比如批处理]
        beforeColumns   [字段信息,增量数据(修改前,删除前),Column类型的数组]
        afterColumns    [字段信息,增量数据(修改后,新增后),Column类型的数组] 
        props           
    props               
    ddlSchemaName       [ddl/query的schemaName,会存在跨库ddl,需要保留执行ddl的当前schemaName]
Column 
    index               [字段下标]      
    sqlType             [jdbc type]
    name                [字段名称(忽略大小写),在mysql中是没有的]
    isKey               [是否为主键]
    updated             [是否发生过变更]
    isNull              [值是否为null]
    props
    value               [字段值,timestamp,Datetime是一个时间格式的文本]
    length              [对应数据对象原始长度]
    mysqlType           [字段mysql类型]

Mysql同步数据到Redis

与快速入门的区别在于,多了一个将数据同步进redis的步骤。

因此,需要添加Redis的依赖,以及自定义一个Redis的工具类:用来将canal解析的数据添加进redis。

  1. 导入依赖。

    		<dependency>
    			<groupId>com.alibaba.otter</groupId>
    			<artifactId>canal.client</artifactId>
    			<version>1.1.4</version>
    		</dependency>
    
    		<dependency>
    			<groupId>redis.clients</groupId>
    			<artifactId>jedis</artifactId>
    			<version>2.4.2</version>
    		</dependency>
    
  2. 编写redis工具类,redisUnti.class

public class RedisUtil {

    // Redis服务器IP
    private static String ADDR = "Ip地址";

    // Redis的端口号
    private static int PORT = 6379;

    // 访问密码
    private static String AUTH = "密码";


    // 可用连接实例的最大数目,默认值为8;
    // 如果赋值为-1,则表示不限制;如果pool已经分配了maxActive个jedis实例,则此时pool的状态为exhausted(耗尽)。
    private static int MAX_ACTIVE = 1024;

    // 控制一个pool最多有多少个状态为idle(空闲的)的jedis实例,默认值也是8。
    private static int MAX_IDLE = 200;

    // 等待可用连接的最大时间,单位毫秒,默认值为-1,表示永不超时。如果超过等待时间,则直接抛出JedisConnectionException;
    private static int MAX_WAIT = 10000;

    // 过期时间
    protected static int  expireTime = 60 * 60;

    // 连接池
    protected static JedisPool pool;


    //初始化连接池
    static {
        JedisPoolConfig config = new JedisPoolConfig();
        //最大连接数
        config.setMaxTotal(MAX_ACTIVE);
        //最多空闲实例
        config.setMaxIdle(MAX_IDLE);
        //超时时间
        config.setMaxWaitMillis(MAX_WAIT);
        //
        config.setTestOnBorrow(false);
        pool = new JedisPool(config, ADDR, PORT, 1000,AUTH);
    }

    /**
     * 获取jedis实例
     */
    protected static synchronized Jedis getJedis() {
        Jedis jedis = null;
        try {
            jedis = pool.getResource();
        } catch (Exception e) {
            e.printStackTrace();
            if (jedis != null) {
                pool.returnBrokenResource(jedis);
            }
        }
        return jedis;
    }


    /**
     * 释放jedis资源
     */
    protected static void closeResource(Jedis jedis, boolean isBroken) {
        try {
            if (isBroken) {
                pool.returnBrokenResource(jedis);
            } else {
                pool.returnResource(jedis);
            }
        } catch (Exception e) {

        }
    }

    /**
     *  是否存在key
     *
     * @param key
     */
    public static boolean existKey(String key) {
        Jedis jedis = null;
        boolean isBroken = false;
        try {
            jedis = getJedis();
            jedis.select(0);
            return jedis.exists(key);
        } catch (Exception e) {
            isBroken = true;
        } finally {
            closeResource(jedis, isBroken);
        }
        return false;
    }

    /**
     *  删除key
     *
     * @param key
     */
    public static void delKey(String key) {
        Jedis jedis = null;
        boolean isBroken = false;
        try {
            jedis = getJedis();
            jedis.select(0);
            jedis.del(key);
        } catch (Exception e) {
            isBroken = true;
        } finally {
            closeResource(jedis, isBroken);
        }
    }

    /**
     *  取得key的值
     */
    public static String stringGet(String key) {
        Jedis jedis = null;
        boolean isBroken = false;
        String lastVal = null;
        try {
            jedis = getJedis();
            jedis.select(0);
            lastVal = jedis.get(key);
            jedis.expire(key, expireTime);
        } catch (Exception e) {
            isBroken = true;
        } finally {
            closeResource(jedis, isBroken);
        }
        return lastVal;
    }

    /**
     *  添加string数据
     */
    public static String stringSet(String key, String value) {
        Jedis jedis = null;
        boolean isBroken = false;
        String lastVal = null;
        try {
            jedis = getJedis();
            jedis.select(0);
            lastVal = jedis.set(key, value);
            jedis.expire(key, expireTime);
        } catch (Exception e) {
            e.printStackTrace();
            isBroken = true;
        } finally {
            closeResource(jedis, isBroken);
        }
        return lastVal;
    }

    /**
     *  添加hash数据
     */
    public static void hashSet(String key, String field, String value) {
        boolean isBroken = false;
        Jedis jedis = null;
        try {
            jedis = getJedis();
            if (jedis != null) {
                jedis.select(0);
                jedis.hset(key, field, value);
                jedis.expire(key, expireTime);
            }
        } catch (Exception e) {
            isBroken = true;
        } finally {
            closeResource(jedis, isBroken);
        }
    }
}
  1. 编写Canal客户端。与入门相比,添加了对redis库增删改的方法,再将原来输出方法替换为写入redis方法。变化不大。

    @Component
    public class CanalClient implements InitializingBean {
        //指定每次拉取数据的大小
        private final static int BATCH_SIZE = 1000;
    
        @Override
        public void afterPropertiesSet() throws Exception {
            //1.创建链接
            CanalConnector connector = CanalConnectors.newSingleConnector(new InetSocketAddress("127.0.0.1", 11111), "example", "", "");
            try {
                //2.打开连接
                connector.connect();
                //3.订阅数据库表,全部表
                connector.subscribe(".*\\..*");
                //回滚到未进行ack的地方,下次fetch的时候,可以从最后一个没有ack的地方开始拿
                connector.rollback();
    
                //4.不断的从主库拉取数据
                while (true) {
                    // 获取指定数量的数据
                    Message message = connector.getWithoutAck(BATCH_SIZE);
                    //获取批量ID
                    long batchId = message.getId();
                    //获取批量的数量
                    int size = message.getEntries().size();
                    //如果没有数据
                    if (batchId == -1 || size == 0) {
                        try {
                            //线程休眠2秒
                            Thread.sleep(2000);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    } else {
                        //如果有数据,处理数据
                        printEntry(message.getEntries());
                    }
                    //进行 batch id 的确认。确认之后,小于等于此 batchId 的 Message 都会被确认。
                    connector.ack(batchId);
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                connector.disconnect();
            }
        }
    
        /**
         * 打印canal server解析binlog获得的实体类信息
         */
        private static void printEntry(List<Entry> entrys) {
            for (Entry entry : entrys) {
                if (entry.getEntryType() == EntryType.TRANSACTIONBEGIN || entry.getEntryType() == EntryType.TRANSACTIONEND) {
                    //开启/关闭事务的实体类型,跳过
                    continue;
                }
                //RowChange对象,包含了一行数据变化的所有特征
                //比如isDdl 是否是ddl变更操作 sql 具体的ddl sql beforeColumns afterColumns 变更前后的数据字段等等
                RowChange rowChage;
                try {
                    rowChage = RowChange.parseFrom(entry.getStoreValue());
                } catch (Exception e) {
                    throw new RuntimeException("ERROR ## parser of eromanga-event has an error , data:" + entry.toString(), e);
                }
                //获取操作类型:insert/update/delete类型
                EventType eventType = rowChage.getEventType();
    
                //打印Header信息
                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));
                //判断是否是DDL语句
                if (rowChage.getIsDdl()) {
                    System.out.println("================》;isDdl: true,sql:" + rowChage.getSql());
                }
                //获取RowChange对象里的每一行数据,打印出来
                for (RowData rowData : rowChage.getRowDatasList()) {
    
                    if (eventType == EventType.DELETE) {        //如果是删除语句
                        //printColumn(rowData.getBeforeColumnsList());
                        redisDelete(rowData.getBeforeColumnsList());
    
                    } else if (eventType == EventType.INSERT) {     //如果是新增语句
                        //printColumn(rowData.getAfterColumnsList());
                        redisInsert(rowData.getAfterColumnsList());
    
                    } else {        //如果是更新的语句
    
                        //System.out.println("-------修改前-------");        //变更前的数据
                        //printColumn(rowData.getBeforeColumnsList());
    
                        //System.out.println("-------修改后-------");        //变更后的数据
                        //printColumn(rowData.getAfterColumnsList());
    
                        redisUpdate(rowData.getAfterColumnsList());
                    }
                }
            }
        }
    
        //打印
        private static void printColumn(List<Column> columns) {
            for (Column column : columns) {
                System.out.println(column.getName() + " : " + column.getValue() + "    update=" + column.getUpdated());
            }
        }
    
        private static void redisInsert( List<Column> columns){
            JSONObject json=new JSONObject();
            for (Column column : columns) {
                json.put(column.getName(), column.getValue());
            }
            if(columns.size()>0){
                RedisUtil.stringSet("user:"+ columns.get(0).getValue(),json.toJSONString());
            }
        }
    
        private static  void redisUpdate( List<Column> columns){
            JSONObject json=new JSONObject();
            for (Column column : columns) {
                json.put(column.getName(), column.getValue());
            }
            if(columns.size()>0){
                RedisUtil.stringSet("user:"+ columns.get(0).getValue(),json.toJSONString());
            }
        }
    
        private static  void redisDelete( List<Column> columns){
            JSONObject json=new JSONObject();
            for (Column column : columns) {
                json.put(column.getName(), column.getValue());
            }
            if(columns.size()>0){
                RedisUtil.delKey("user:"+ columns.get(0).getValue());
            }
        }
        
    }
    

结果测试:

  1. 在mybatis数据库中增加一个数据。
    在这里插入图片描述

  2. 查看canal客户端输出

    在这里插入图片描述

  3. 查看redis客户端是否增加新内容。
    在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值