canal源码分析简介-4

7.0 driver模块

 2018-11-10 22:30:19  6,053  4

driver,顾名思义为驱动。熟悉jdbc编程的同学都知道,当项目中需要操作数据库(oracle、sqlserver、mysql等)时,都需要在项目中引入对应的数据库的驱动。以mysql为例,我们需要引入的是mysql-connector-java这个jar包,通过这个驱动包来与数据库进行通信。

那么为什么canal不使用mysql官方提供的驱动包,而要自己编写一个driver模块?原因在于mysql-connector-java驱动包只是实现了JDBC规范,方便我们在程序中对数据库中的数据进行增删改查。

对于获取并解析binlog日志这样的场景,mysql-connector-java并没有提供这样的功能。因此,canal编写了自己的driver模块,提供了基本的增删改查功能,并提供了直接获取原始binlog字节流的功能,其他模块在这个模块的基础上对binlog字节进行解析,parser模块底层实际上就是通过driver模块来与数据库建立连接的。

driver模块目录结构如下所示:

 

 

    最核心的3个类分别是:

  • MysqlConnector:表示一个数据库连接,作用类似于java.sql.Connection

  • MysqlQueryExecutor:查询执行器,作用类似于PrepareStatement.executeQuery()

  • MysqlUpdateExecutor:更新执行器,作用类似于PrepareStatement.executeUpdate() 

在本小节中,我们将首先介绍driver模块的基本使用;接着介绍parser模块是如何使用driver模块的;最后讲解driver模块的实现原理。

1 driver模块的基本使用

    本小节将会介绍MysqlConnector和MysqlQueryExecutor、MysqlUpdateExecutor如何使用。

    假设test库下有一张mysql表:user 

  1. CREATE TABLE `user` (
  2.   `id` int(11) NOT NULL AUTO_INCREMENT,
  3.   `name` varchar(18) NOT NULL,
  4.   `password` varchar(15) NOT NULL,
  5.   PRIMARY KEY (`id`)
  6. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

该表中有2条记录:

  1. mysql> select * from t_user;
  2. +----+---------------+----------+
  3. | id | name          | password |
  4. +----+---------------+----------+
  5. |  1 | tianshozhi    | xx       |
  6. |  2 | wangxiaoxiiao | yy       |
  7. +----+---------------+----------+

 

1.1 MysqlConnector

MysqlConnector相当于一个数据链接,其使用方式如下所示:

  1. @Test
  2. public void testMysqlConnection(){
  3.    MysqlConnector connector = new MysqlConnector();
  4.    try {
  5.       //1 创建数据库连接
  6.       connector = new MysqlConnector();
  7.       // 设置数据库ip、port
  8.       connector.setAddress(new InetSocketAddress("127.0.0.1", 3306));
  9.       // 设置用户名
  10.       connector.setUsername("root");
  11.       // 设置密码
  12.       connector.setPassword(“your password");
  13.       // 设置默认连接到的数据库
  14.       connector.setDefaultSchema("test");
  15.       // 设置链接字符串,33表示UTF-8
  16.       connector.setCharsetNumber((byte) 33);
  17.       // ======设置网络相关参数===========
  18.       // 设置socket超时时间,默认30s,也就是发送一个请求给mysql时,如果30s没响应,则会抛出SocketTimeoutException
  19.       connector.setSoTimeout(30 * 1000);
  20.       // 设置发送缓冲区发小,默认16K
  21.       connector.setSendBufferSize(16 * 1024);// 16K
  22.       // 设置接受缓冲区大小,默认16K
  23.       connector.setReceiveBufferSize(16 * 1024);// 16k
  24.       //调用connect方法建立连接
  25.       connector.connect();
  26.       //2 ... do something....
  27.    }catch (IOException e){
  28.       e.printStackTrace();
  29.    }finally {
  30.       try {
  31.          //关闭链接
  32.          connector.disconnect();
  33.       } catch (IOException e) {
  34.          e.printStackTrace();
  35.       }
  36.    }
  37. }

 

一个MysqlConnector实例底层只能维护一个数据库链接。除了上面提到的方法,MysqlConnector还提供了reconnect()方法和fork()方法。

reconnect()方法:

reconnect()内部先调用disconnect方法关闭原有连接,然后使用connect方法创建一个新的连接

  1. mysqlConnector.reconnect();

fork()方法:

如果希望创建多个连接,可以fork出一个新的MysqlConnector实例,再调用这个新MysqlConnector实例的connect方法建立连接。

  1. MysqlConnector fork = mysqlConnector.fork();
  2. fork.connect();

1.2 MysqlQueryExecutor

    这里我们使用MysqlQueryExecutor查询数据库中的user表中的两条记录,注意canal的driver模块并没有实现jdbcref规范,因此使用起来,与我们熟悉的JDBC编程有一些区别。

案例代码:

  1. @Test
  2. public void testQuery() throws IOException {
  3.    MysqlConnector connector = new MysqlConnector(new InetSocketAddress("127.0.0.1", 3306),"root”,”your password");
  4.    try {
  5.       //1 创建数据库连接
  6.       connector.connect();
  7.       //2 构建查询执行器,并执行查询
  8.       MysqlQueryExecutor executor = new MysqlQueryExecutor(connector);
  9.       //ResultSetPacket作用类似于ResultSet
  10.       ResultSetPacket result = executor.query("select * from test.user");
  11.       //3 对查询结果进行解析
  12.       //FieldPacket中封装的字段的一些源信息,如字段的名称,类型等
  13.       List<FieldPacket> fieldDescriptors = result.getFieldDescriptors();
  14.       //字段的值使用String表示,jdbc编程中使用的getInt,getBoolean,getDate等方法,实际上都是都是字符串转换得到的
  15.       List<String> fieldValues = result.getFieldValues();
  16.       //打印字段名称
  17.       for (FieldPacket fieldDescriptor : fieldDescriptors) {
  18.          String fieldName = fieldDescriptor.getName();
  19.          System.out.print(fieldName + "  ");
  20.       }
  21.       //打印字段的值
  22.       System.out.println("\n" + fieldValues);
  23.    } finally {
  24.       connector.disconnect();
  25.    }
  26. }

控制台输出如下:

  1. id  name  password  
  2. [1, tianshozhi, xx, 2, wangxiaoxiiao, yy]

可以看出来:

    对user表中的字段信息,canal中使用FieldPacket来表示,放于一个List表示。

    对于user表中的一行记录,使用另一个List表示,这个List的大小是字段的List大小的整数倍,前者size除以后者就是查询出来的行数。

1.3 MysqlUpdateExecutor

使用案例

  1. @Test
  2. public void testUpdate() {
  3.    MysqlConnector connector = new MysqlConnector(new InetSocketAddress("127.0.0.1", 3306), "root", "xx");
  4.    try {
  5.       connector.connect();
  6.       MysqlUpdateExecutor executor = new MysqlUpdateExecutor(connector);
  7.       OKPacket okPacket = executor.update("insert into test.user(name,password) values('tianbowen','zzz')");
  8.       System.out.println(JSON.toJSONString(okPacket,true));
  9.    } catch (IOException e) {
  10.       e.printStackTrace();
  11.    } finally {
  12.       try {
  13.          connector.disconnect();
  14.       } catch (IOException e) {
  15.          e.printStackTrace();
  16.       }
  17.    }
  18. }

如果执行更新操作成功,返回的是一个OkPacket,上面把OkPacket转成JSON,控制台输出如下:

  1. {
  2.     "affectedRows":"AQ==",
  3.     "fieldCount":0,
  4.     "insertId":"AQ==",
  5.     "message":"",
  6.     "serverStatus":2,
  7.     "warningCount":0
  8. }

可以看到这里OkPacke包含的信息比较多。其中比较重要的是:sql操作影响的记录行数affectedRows,以及insert操作返回自动生成的主键insertId

    这里返回的insertId和affectedRows都是字节数组,我们需要将其转换为数字,以insertId为例,其转换方式如下; 

  1. bytes[] insertId=okPacket.getInsertId();
  2. long autoGeneratedKey = ByteHelper.readLengthCodedBinary(insertId, 0);
  3. System.out.println(autoGeneratedKey);

2 parser模块 是如何使用driver模块的?

分析canal是如何使用driver模块的,主要就是看其他模块使用driver模块执行了哪些查询和更新sql。由于canal的作用主要是解析binlog,因此执行的大多都是binlog解析过程中所需要使用的sql语句。

       显然parser模块需要依靠driver模块来获取原始的binlog二进制字节流,因此相关sql都在driver模块中。

2.1 parser模块执行的更新sql

    parser模块提供了一个MysqlConnection对driver模块的MysqlConnector进行了封装,在开始dump binlog前,会对当前链接进行一些参数设置,如下图:

 

 

com.alibaba.otter.canal.parse.inbound.mysql.MysqlConnection#updateSettings 

其中:

1 set wait_timeout=9999999

2 set net_write_timeout=1800

3 set net_read_timeout=1800 

4 set names 'binary'

    设置服务端返回结果时不做编码转化,直接按照数据库的二进制编码进行发送,由客户端自己根据需求进行编码转化

set @master_binlog_checksum= @@global.binlog_checksum

mysql5.6针对checksum支持需要设置session变量如果不设置会出现错误:  

  1. Slave can not handle replication events with the checksum that master is configured to log

但也不能乱设置,需要和mysql server的checksum配置一致,不然RotateLogEvent会出现乱码。'@@global.binlog_checksum'需要去掉单引号,在mysql 5.6.29下导致master退出

5 set @slave_uuid=uuid()

    mysql5.6需要设置slave_uuid避免被server kill链接,参考:https://github.com/alibaba/canal/issues/284

6 SET @mariadb_slave_capability='" + LogEvent.MARIA_SLAVE_CAPABILITY_MINE + "'

    mariadb针对特殊的类型,需要设置session变量 

2.2 parser模块执行的查询sql

 

 

7 show variables like 'binlog_format'

    用于查看binlog格式,值为STATEMENT,MIXED,ROW的一种,如: 

  1. mysql> show variables like 'binlog_format';
  2. +---------------+-------+
  3. | Variable_name | Value |
  4. +---------------+-------+
  5. | binlog_format | ROW   |
  6. +---------------+-------+

8 show variables like 'binlog_row_image'

    ROW模式下,即使我们只更新了一条记录的其中某个字段,也会记录每个字段变更前后的值,binlog日志就会变大,带来磁盘IO上的开销,以及网络开销。mysql提供了参数binlog_row_image,来控制是否需要记录每一行的变更,其有3个值:

  • FULL : 记录列的所有修改

  • MINIMAL :只记录修改的列。

  • NOBLOB :如果是text类型或clob字段,不记录 这些日志

如: 

  1. mysql> show variables like 'binlog_row_image';
  2. +------------------+-------+
  3. | Variable_name    | Value |
  4. +------------------+-------+
  5. | binlog_row_image | FULL  |
  6. +------------------+-------+

9 select @master_binlog_checksum

    mysql 主从复制(replication) 同步可能会出现数据不一致,mysql  5.6 版本中加入了 replication event checksum(主从复制事件校验)功能。默认开启。如果开启,每个binlog后面会多出4个字节,为CRC32校验值。目前cancal支持解析CRC32的值,但不会进行校验。如:

  1. mysql> show variables like 'binlog_checksum';
  2. +-----------------+-------+
  3. | Variable_name   | Value |
  4. +-----------------+-------+
  5. | binlog_checksum | CRC32 |
  6.  
  7. mysql> select @master_binlog_checksum;
  8. +-------------------------+
  9. | @master_binlog_checksum |
  10. +-------------------------+
  11. | NULL                    |
  12. +-------------------------+
  13. 1 row in set (0.01 sec)

10 show variables like 'server_id'

    mysql主从同步时,每个机器都要设置一个唯一的server_id,canal连接到某个mysql实例之后,会查询这个serverId。

11 show master status

    mysql binlog是多文件存储,唯一确定一个binlog位置需要通过:binlog file + binlog position。show master status可以获得当前的binlog位置,如:

  1. mysql> show master status;
  2. +--------------+----------+--------------+------------------+-------------------+
  3. | File         | Position | Binlog_Do_DB | Binlog_Ignore_DB | Executed_Gtid_Set |
  4. +--------------+----------+--------------+------------------+-------------------+
  5. | mysql.000012 |    23479 |              |                  |                   |
  6. +--------------+----------+--------------+------------------+-------------------+

 12 show binlog events limit 1

    查询最早的binlog位置。 

  1. mysql> show binlog events limit 1;
  2. +--------------+-----+-------------+-----------+-------------+---------------------------------------+
  3. | Log_name     | Pos | Event_type  | Server_id | End_log_pos | Info                                  |
  4. +--------------+-----+-------------+-----------+-------------+---------------------------------------+
  5. | mysql.000001 |   4 | Format_desc |         1 |         123 | Server ver: 5.7.18-log, Binlog ver: 4 |
  6. +--------------+-----+-------------+-----------+-------------+---------------------------------------+

mysql binlog文件默认从mysql.000001开始,前四个字节是魔法字节,是固定的。因此真正的binlog事件总是从第4个字节之后才开始的。

binlog文件可能会清空,官方的mysql版支持设置参数expire_logs_days来控制binlog保存时间,一些分支如percona,支持指定报文binlog文件个数。主要是避免binlog过多导致磁盘空间不足。

13 show slave status

    主要用于判断MySQL复制同步状态,这个命令的内容比较多,这里不演示。主要是关注两个线程的状态:

  • Slave_IO_Running线程:负责把主库的bin日志(Master_Log)内容,投递到从库的中继日志上(Relay_Log)

  • Slave_SQL_Running线程:负责把中继日志上的语句在从库上执行一遍

    以及Seconds_Behind_Master的值,其表示从库落后主库的时间,如果为0则表示没有延迟。

show global variables  like 'rds\_%'

    这个命令没懂,猜测应该是判断是否数据库是否是是否是阿里云上提供的rds。

"desc " + fullname

    查看库表的字段定义,如:

  1. mysql> desc test.user;
  2. +----------+-------------+------+-----+---------+----------------+
  3. | Field    | Type        | Null | Key | Default | Extra          |
  4. +----------+-------------+------+-----+---------+----------------+
  5. | id       | int(11)     | NO   | PRI | NULL    | auto_increment |
  6. | name     | varchar(18) | NO   |     | NULL    |                |
  7. | password | varchar(15) | NO   |     | NULL    |                |
  8. +----------+-------------+------+-----+---------+----------------+

原始的binlog二进制流中,并不包含字段的名称,而canal提供个client订阅的event中包含了字段名称,实际上就是通过这个命令来获得的。parser模块的TableMetaCache类就是用于缓存表字段信息。当表结构变更后,也会跟着自动跟新。

3 Driver模块实现原理

canal的driver模块实际上就是一个手功编写的一个mysql客户端。要编写这样的一个客户端并不容易,需要参考Mysql client/server通信协议,以下是地址:

    https://dev.mysql.com/doc/internals/en/client-server-protocol.html

笔者也尝试自己写了一些功能,最终的体会是,要实现一个完整的客户端,太多细节要考虑,没有足够的时间。另外一点,也建议读者可以阅读一下这个通信协议即可,以便对driver模块有更深的理解。建议不要花太多时间。

事实上canal的driver客户端也没有实现完整的通信协议,只是满足了简单的查询和更新功能。不过从binlog解析的角度,这已经足够了。

 

8.0 异地多活场景下的数据同步之道

 2019-03-29 03:16:21  9,915  11

在当今互联网行业,大多数人互联网从业者对"单元化"、"异地多活"这些词汇已经耳熟能详。而数据同步是异地多活的基础,所有具备数据存储能力的组件如:数据库、缓存、MQ等,数据都可以进行同步,形成一个庞大而复杂的数据同步拓扑。

 本文将先从概念上介绍单元化、异地多活、就近访问等基本概念。之后,将以数据库为例,讲解在数据同步的情况下,如何解决数据回环、数据冲突、数据重复等典型问题。

1 什么是单元化

如果仅仅从"单元化”这个词汇的角度来说,我们可以理解为将数据划分到多个单元进行存储。"单元"是一个抽象的概念,通常与数据中心(IDC)概念相关,一个单元可以包含多个IDC,也可以只包含一个IDC。本文假设一个单元只对应一个IDC。

考虑一开始只有一个IDC的情况,所有用户的数据都会写入同一份底层存储中,如下图所示:

 

 

      这种架构是大多数据中小型互联网公司采用的方案,存在以下几个问题:

      1 不同地区的用户体验不同。一个IDC必然只能部署在一个地区,例如部署在北京,那么北京的用户访问将会得到快速响应;但是对于上海的用户,访问延迟一般就会大一点,上海到北京的一个RTT可能有20ms左右。

      2 容灾问题。这里容灾不是单台机器故障,而是指机房断电,自然灾害,或者光纤被挖断等重大灾害。一旦出现这种问题,将无法正常为用户提供访问,甚至出现数据丢失的情况。这并不是不可能,例如:2015年,支付宝杭州某数据中心的光缆就被挖断过;2018年9月,云栖大会上,蚂蚁金服当场把杭州两个数据中心的网线剪断。      

     为了解决这些问题,我们可以将服务部署到多个不同的IDC中,不同IDC之间的数据互相进行同步。如下图:

 

 

      通过这种方式,我们可以解决单机房遇到的问题:

      1 用户体验。不同的用户可以选择离自己最近的机房进行访问

      2 容灾问题。当一个机房挂了之后,我们可以将这个机房用户的流量调度到另外一个正常的机房,由于不同机房之间的数据是实时同步的,用户流量调度过去后,也可以正常访问数据 (故障发生那一刻的少部分数据可能会丢失)。

         需要注意的是,关于容灾,存在一个容灾级别的划分,例如:单机故障,机架(rack)故障,机房故障,城市级故障等。我们这里只讨论机房故障和城市故障。

  • 机房容灾 : 上面的案例中,我们使用了2个IDC,但是2个IDC并不能具备机房容灾能力。至少需要3个IDC,例如,一些基于多数派协议的一致性组件,如zookeeper,redis、etcd、consul等,需要得到大部分节点的同意。例如我们部署了3个节点,在只有2个机房的情况下, 必然是一个机房部署2个节点,一个机房部署一个节点。当部署了2个节点的机房挂了之后,只剩下一个节点,无法形成多数派。在3机房的情况下,每个机房部署一个节点,任意一个机房挂了,还剩2个节点,还是可以形成多数派。这也就是我们常说的"两地三中心”。

  • 城市级容灾:在发生重大自然灾害的情况下,可能整个城市的机房都无法访问。一些组件,例如蚂蚁的ocean base,为了达到城市级容灾的能力,使用的是"三地五中心"的方案。这种情况下,3个城市分别拥有2、2、1个机房。当整个城市发生灾难时,其他两个城市依然至少可以保证有3个机房依然是存活的,同样可以形成多数派。

 

小结:如果仅仅是考虑不同地区的用户数据就近写入距离最近的IDC,这是纯粹意义上的”单元化”。不同单元的之间数据实时进行同步,相互备份对方的数据,才能做到真正意义上"异地多活”。

实现单元化,技术层面我们要解决的事情很多,例如:流量调度,即如何让用户就近访问附近的IDC;数据互通,如何实现不同机房之间数据的相互同步。流量调度不在本文的讨论范畴内,数据同步是本文讲解的重点。

 

2 如何进行数据同步

需要同步的组件有很多,例如数据库,缓存等,这里以多个Mysql集群之间的数据同步为例进行讲解,实际上缓存的同步思路也是类似。

2.1 基础知识

为了了解如何对不同mysql的数据相互进行同步,我们先了解一下mysql主从复制的基本架构,如下图所示:

 

 

通常一个mysql集群有一主多从构成。用户的数据都是写入主库Master,Master将数据写入到本地二进制日志binary log中。从库Slave启动一个IO线程(I/O Thread)从主从同步binlog,写入到本地的relay log中,同时slave还会启动一个SQL Thread,读取本地的relay log,写入到本地,从而实现数据同步。

基于这个背景知识,我们就可以考虑自己编写一个组件,其作用类似与mysql slave,也是去主库上拉取binlog,只不过binlog不是保存到本地,而是将binlog转换成sql插入到目标mysql集群中,实现数据的同步。

        这并非是一件不可能完成的事,MySQL官网上已经提供好所有你自己编写一个mysql slave 同步binlog所需的相关背景知识,访问这个链接:https://dev.mysql.com/doc/internals/en/client-server-protocol.html,你将可以看到mysql 客户端与服务端的通信协议。下图红色框中展示了Mysql主从复制的相关协议:

        

 

 

        当然,笔者的目的并不是希望读者真正的按照这里的介绍尝试编写一个mysql 的slave,只是想告诉读者,模拟mysql slave拉取binlog并非是一件很神奇的事,只要你的网络基础知识够扎实,完全可以做到。然而,这是一个庞大而复杂的工作。以一人之力,要完成这个工作,需要占用你大量的时间。好在,现在已经有很多开源的组件,已经实现了按照这个协议可以模拟成一个mysql的slave,拉取binlog。例如:

  • 阿里巴巴开源的canal

  • 美团开源的puma

  • linkedin开源的databus

       ...

      你可以利用这些组件来完成数据同步,而不必重复造轮子。 假设你采用了上面某个开源组件进行同步,需要明白的是这个组件都要完成最基本的2件事:从源库拉取binlog并进行解析,我们把这部分功能称之为binlog syncer;将获取到的binlog转换成SQL插入目标库,这个功能称之为sql writer。

      为什么划分成两块独立的功能?因为binlog订阅解析的实际应用场景并不仅仅是数据同步,如下图:

 

 

        如图所示,我们可以通过binlog来:

  • 实时更新搜索引擎,如es中的索引信息

  • 实时更新redis中的缓存

  • 发送到kafka供下游消费,由业务方自定义业务逻辑处理等

  • ...

        因此,通常我们把binlog syncer单独作为一个模块,其只负责解析从数据库中拉取并解析binlog,并在内存中缓存(或持久化存储)。另外,binlog syncer另外提一个sdk,业务方通过这个sdk从binlog syncer中获取解析后的binlog信息,然后完成自己的特定业务逻辑处理。

        显然,在数据同步的场景下,我们可以基于这个sdk,编写一个组件专门用于将binlog转换为sql,插入目标库,实现数据同步,如下图所示:

 

 

        北京用户的数据不断写入离自己最近的机房的DB,通过binlog syncer订阅这个库binlog,然后下游的binlog writer将binlog转换成SQL,插入到目标库。上海用户类似,只不过方向相反,不再赘述。通过这种方式,我们可以实时的将两个库的数据同步到对端。当然事情并非这么简单,我们有一些重要的事情需要考虑。

2.1 如何获取全量+增量的历史数据?

        通常,mysql不会保存所有的历史binlog。原因在于,对于一条记录,可能我们会更新多次,这依然是一条记录,但是针对每一次更新操作,都会产生一条binlog记录,这样就会存在大量的binlog,很快会将磁盘占满。因此DBA通常会通过一些配置项,来定时清理binlog,只保留最近一段时间内的binlog。

       例如,官方版的mysql提供了expire_logs_days配置项,可以设置保存binlog的天数,笔者这里设置为0,表示默认不清空,如果将这个值设置大于0,则只会保存指定的天数。

      另外一些mysql 的分支,如percona server,还可以指定保留binlog文件的个数。我们可以通过show binary logs来查看当前mysql存在多少个binlog文件,如下图:

          

    

        通常,如果binlog如果从来没被清理过,那么binlog文件名字后缀通常是000001,如果不是这个值,则说明可能已经被清理过。当然,这也不是绝对,例如执行"reset master”命令,可以将所有的binlog清空,然后从000001重新开始计数。

       Whatever! 我们知道了,binlog可能不会一直保留,所以直接同步binlog,可能只能获取到部分数据。因此,通常的策略是,由DBA先dump一份源库的完整数据快照,增量部分,再通过binlog订阅解析进行同步。

2.2 如何解决重复插入?

考虑以下情况下,源库中的一条记录没有唯一索引。对于这个记录的binlog,通过sql writer将binlog转换成sql插入目标库时,抛出了异常,此时我们并不知道知道是否插入成功了,则需要进行重试。如果之前已经是插入目标库成功,只是目标库响应时网络超时(socket timeout)了,导致的异常,这个时候重试插入,就会存在多条记录,造成数据不一致。

因此,通常,在数据同步时,通常会限制记录必须有要有主键或者唯一索引。

2.3 如何解决唯一索引冲突?

 由于两边的库都存在数据插入,如果都使用了同一个唯一索引,那么在同步到对端时,将会产生唯一索引冲突。对于这种情况,通常建议是使用一个全局唯一的分布式ID生成器来生成唯一索引,保证不会产生冲突。

另外,如果真的产生冲突了,同步组件应该将冲突的记录保存下来,以便之后的问题排查。

2.4 对于DDL语句如何处理?

如果数据库表中已经有大量数据,例如千万级别、或者上亿,这个时候对于这个表的DDL变更,将会变得非常慢,可能会需要几分钟甚至更长时间,而DDL操作是会锁表的,这必然会对业务造成极大的影响。

因此,同步组件通常会对DDL语句进行过滤,不进行同步。DBA在不同的数据库集群上,通过一些在线DDL工具(如gh-ost),进行表结构变更。

2.5 如何解决数据回环问题?

数据回环问题,是数据同步过程中,最重要的问题。我们针对INSERT、UPDATE、DELETE三个操作来分别进行说明:

INSERT操作

假设在A库插入数据,A库产生binlog,之后同步到B库,B库同样也会产生binlog。由于是双向同步,这条记录,又会被重新同步回A库。由于A库应存在这条记录了,产生冲突。

UPDATE操作

先考虑针对A库某条记录R只有一次更新的情况,将R更新成R1,之后R1这个binlog会被同步到B库,B库又将R1同步会A库。对于这种情况下,A库将不会产生binlog。因为A库记录当前是R1,B库同步回来的还是R1,意味着值没有变。

在一个更新操作并没有改变某条记录值的情况下,mysql是不会产生binlog,相当于同步终止。下图演示了当更新的值没有变时,mysql实际上不会做任何操作:

    

 

 

        上图演示了,数据中原本有一条记录(1,"tianshouzhi”),之后执行一个update语句,将id=1的记录的name值再次更新为”tianshouzhi”,意味着值并没有变更。这个时候,我们看到mysql 返回的影响的记录函数为0,也就是说,并不会产生真是的更新操作。

         然而,这并不意味UPDATE 操作没有问题,事实上,其比INSERT更加危险。

         考虑A库的记录R被连续更新了2次,第一次更新成R1,第二次被更新成R2;这两条记录变更信息都被同步到B库,B也产生了R1和R2。由于B的数据也在往A同步,B的R1会被先同步到A,而A现在的值是R2,由于值不一样,将会被更新成R1,并产生新的binlog;此时B的R2再同步会A,发现A的值是R1,又更新成R2,也产生binlog。由于B同步回A的操作,让A又产生了新的binlog,A又要同步到B,如此反复,陷入无限循环中。

DELETE操作

        同样存在先后顺序问题。例如先插入一条记录,再删除。B在A删除后,又将插入的数据同步回A,接着再将A的删除操作也同步回A,每次都会产生binlog,陷入无限回环。

        关于数据回环问题,笔者有着血的教训,曾经因为笔者的误操作,将一个库的数据同步到了自身,最终也导致无限循环,原因分析与上述提到的UPDATE、DELETE操作类似,读者可自行思考。

        针对上述数据同步到过程中可能会存在的数据回环问题,最终会导致数据无限循环,因此我们必须要解决这个问题。由于存在多种解决方案,我们将在稍后统一进行讲解。

 2.6 数据同步架构设计

        现在,让我们先把思路先从解决数据同步的具体细节问题转回来,从更高的层面讲解数据同步的架构应该如何设计。稍后的内容中,我们将讲解各种避免数据回环的各种解决方案。

        前面的架构中,只涉及到2个DB的数据同步,如果有多个DB数据需要相互同步的情况下,架构将会变得非常复杂。例如:

 

 

     这个图演示的是四个DB之间数据需要相互同步,这种拓扑结构非常复杂。为了解决这种问题,我们可以将数据写入到一个数据中转站,例如MQ中进行保存,如下:

 

 

我们在不同的机房各部署一套MQ集群,这个机房的binlog syncer将需要同步的DB binlog数据写入MQ对应的Topic中。对端机房如果需要同步这个数据,只需要通过binlog writer订阅这个topic,消费topic中的binlog数据,插入到目标库中即可。一些MQ支持consumer group的概念,不同的consumer group的消费位置offset相互隔离,从而达到一份数据,同时供多个消费者进行订阅的能力。

当然,一些binlog订阅解析组件,可能实现了类似于MQ的功能,此时,则不需要独立部署MQ。    

 

3 数据同步回环问题解决方案

        数据回环问题有多种解决方案,通过排除法,一一进行讲解。

3.1 往目标库插入不生成binlog

        在mysql中,我们可以设置session变量,来控制当前会话上的更新操作,不产生binlog。这样当往目标库插入数据时,由于不产生binlog,也就不会被同步会源库了。为了演示这个效果,笔者清空了本机上的所有binlog(执行reset master),现在如下图所示:

 

 

    忽略这两个binlog event,binlog文件格式最开始就是这两个event。

        接着,笔者执行set sql_log_bin=0,然后插入一条语句,最后可以看到的确没有产生新的binlog事件:

 

 

        通过这种方式,貌似可以解决数据回环问题。目标库不产生binlog,就不会被同步会源库。

        但是,答案是否定的。我们是往目标库的master插入数据,如果不产生binlog,目标库的slave也无法同步数据,主从数据不一致。所以,需要排除这种方案。

        提示:如果恢复set sql_log_bin=1,插入语句是会产生binlog,读者可以自行模拟。

3.2 控制binlog同步方向

        既然不产生binlog不能解决问题。那么换一种思路,可以产生binlog。当把一个binlog转换成sql时,插入某个库之前,我们先判断这条记录是不是原本就是这个库产生的,如果是,那么就抛弃,也可以避免回环问题。

        现在问题就变为,如何给binlog加个标记,表示其实那个mysql集群产生的。这也有几种方案,下面一一讲述。

3.2.1 ROW模式下记录sql

        mysql主从同步,binlog复制一般有3种模式。STATEMENT,ROW,MIXED。默认情况下,STATEMENT模式只记录SQL语句,ROW模式只记录字段变更前后的值,MIXED模式是二者混合。 binlog同步一般使用的都是ROW模式,高版本Mysql主从同步默认也是ROW模式。

        我们想采取的方案是,在执行的SQL之前加上一段特殊标记,表示这个SQL的来源。例如

  1. /*IDC1:DB1*/insert into users(name) values("tianbowen")

        其中/*IDC1:DB1*/是一个注释,表示这个SQL原始在是IDC1的DB1中产生的。之后,在同步的时候,解析出SQL中的IDC信息,就能判断出是不是自己产生的数据。

        然而,ROW模式下,默认只记录变更前后的值,不记录SQL。所以,我们要通过一个开关,让Mysql在ROW模式下也记录INSERT、UPDATE、DELETE的SQL语句。具体做法是,在mysql的配置文件中,添加以下配置:

  1. binlog_rows_query_log_events =1

        这个配置可以让mysql在binlog中产生ROWS_QUERY_LOG_EVENT类型的binlog事件,其记录的就是执行的SQL。

        通过这种方式,我们就记录下的一个binlog最初是由哪一个集群产生的,之后在同步的时候,sql writer判断目标机房和当前binlog中包含的机房相同,则抛弃这条数据,从而避免回环。

        这种思路,功能上没问题,但是在实践中,确非常麻烦。首先,让业务对执行的每条sql都加上一个这样的标识,几乎不可能。另外,如果忘记加了,就不知道数据的来源了。如果采用这种方案,可以考虑在数据库访问层中间件层面添加支持在sql之前增加/*..*/的功能,统一对业务屏蔽。即使这样,也不完美,不能保证所有的sql都通过中间件来来写入,例如DBA的一些日常运维操作,或者手工通过mysql命令行来操作数据库时,肯定会存在没有添加机房信息的情况。

        总的来说,这个方案不是那么完美。

3.2.2 通过附加表记录binlog产生源集群信息

        这种方案目前很多公司使用。大致思路是,在db中都加一张额外的表,例如叫direction,记录一个binlog产生的源集群的信息。例如

  1. CREATE TABLE `direction` (
  2.   `idc` varchar(255) not null,
  3.   `db_cluster` varchar(255) not null,
  4. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4

      idc字段用于记录某条记录原始产生的IDC,db_cluster用于记录原始产生的数据库集群(注意这里要使用集群的名称,不能是server_id,因为可能会发生主从切换)。

      假设用户在IDC1的库A插入的一条记录(也可以在事务中插入多条记录,单条记录,即使不开启事务,mysql默认也会开启事务):

  1. BEGIN;
  2. insert into users(name) values("tianshouzhi”);
  3. COMMIT;

       那么A库数据binlog通过sql writer同步到目标库B时,sql writer可以提前对事务中的信息可以进行一些修改,,如下所示:

  1. BEGIN;
  2. #往目标库同步时,首先额外插入一条记录,表示这个事务中的数据都是A产生的。
  3. insert into direction(idc,db_cluster) values("IDC1”,"DB_A”)
  4. #插入原来的记录信息
  5. insert into users(name) values("tianshouzhi”);
  6. COMMIT;

        之后B库的数据往A同步时,就可以根据binlog中的第一条记录的信息,判断这个记录原本就是A产生的,进行抛弃,通过这种方式来避免回环。这种方案已经已经过很多的公司的实际验证。

3.2.3 通过GTID

Mysql 5.6引入了GTID(全局事务id)的概念,极大的简化的DBA的运维。在数据同步的场景下,GTID依然也可以发挥极大的威力。

GTID 由2个部分组成:

  1. server_uuid:transaction_id

其中server_uuid是mysql随机生成的,全局唯一。transaction_id事务id,默认情况下每次插入一个事务,transaction_id自增1。注意,这里并不会对GTID进行全面的介绍,仅说明其在数据同步的场景下,如何避免回环、数据重复插入的问题。

GTID提供了一个会话级变量gtid_next,指示如何产生下一个GTID。可能的取值如下:

  • AUTOMATIC: 自动生成下一个GTID,实现上是分配一个当前实例上尚未执行过的序号最小的GTID。

  • ANONYMOUS: 设置后执行事务不会产生GTID,显式指定的GTID。

    默认情况下,是AUTOMATIC,也就是自动生成的,例如我们执行sql:

  1. insert into users(name) values("tianbowen”);

    产生的binlog信息如下:

20BBA7C4-FAB0-4C2A-B1BB-D84ABFC5DB39.png

可以看到,GTID会在每个事务(Query->...->Xid)之前,设置这个事务下一次要使用到的GTID。

从源库订阅binlog的时候,由于这个GTID也可以被解析到,之后在往目标库同步数据的时候,我们可以显示的的指定这个GTID,不让目标自动生成。也就是说,往目标库,同步数据时,变成了2条SQL:

  1. SET GTID_NEXT= '09530823-4f7d-11e9-b569-00163e121964:1’
  2. insert into users(name) values("tianbowen")

由于我们显示指定了GTID,目标库就会使用这个GTID当做当前事务ID,不会自动生成。同样,这个操作也会在目标库产生binlog信息,需要同步回源库。再往源库同步时,我们按照相同的方式,先设置GTID,在执行解析binlog后得到的SQL,还是上面的内容

SET GTID_NEXT= '09530823-4f7d-11e9-b569-00163e121964:1'

insert into users(name) values("tianbowen")

        由于这个GTID在源库中已经存在了,插入记录将会被忽略,演示如下:

  1. mysql> SET GTID_NEXT= '09530823-4f7d-11e9-b569-00163e121964:1';
  2. Query OK, 0 rows affected (0.00 sec)
  3. mysql> insert into users(name) values("tianbowen");
  4. Query OK, 0 rows affected (0.01 sec) #注意这里,影响的记录行数为0

注意这里,对于一条insert语句,其影响的记录函数居然为0,也就会插入并没有产生记录,也就不会产生binlog,避免了循环问题。

如何做到的呢?mysql会记录自己执行过的所有GTID,当判断一个GTID已经执行过,就会忽略。通过如下sql查看:

  1. mysql> show global variables like "gtid_executed";
  2. +---------------+------------------------------------------+
  3. | Variable_name | Value                                    |
  4. +---------------+------------------------------------------+
  5. | gtid_executed | 09530823-4f7d-11e9-b569-00163e121964:1-5 |
  6. +---------------+------------------------------------------+

        上述value部分,冒号":"前面的是server_uuid,冒号后面的1-5,是一个范围,表示已经执行过1,2,3,4,5这个几个transaction_id。这里就能解释了,在GTID模式的情况下,为什么前面的插入语句影响的记录函数为0了。

        显然,GTID除了可以帮助我们避免数据回环问题,还可以帮助我们解决数据重复插入的问题,对于一条没有主键或者唯一索引的记录,即使重复插入也没有,只要GTID已经执行过,之后的重复插入都会忽略。

        当然,我们还可以做得更加细致,不需要每次都往目标库设置GTID_NEXT,这毕竟是一次网络通信。sql writer在往目标库插入数据之前,先判断目标库的server_uuid是不是和当前binlog事务信息携带的server_uuid相同,如果相同,则可以直接丢弃。查看目标库的gtid,可以通过以下sql执行:

  1. mysql> show variables like "server_uuid";
  2. +---------------+--------------------------------------+
  3. | Variable_name | Value                                |
  4. +---------------+--------------------------------------+
  5. | server_uuid   | 09530823-4f7d-11e9-b569-00163e121964 |
  6. +---------------+--------------------------------------+

        GTID应该算是一个终极的数据回环解决方案,mysql原生自带,比添加一个辅助表的方式更轻量,开销也更低。需要注意的是,这倒并不是一定说GTID的方案就比辅助表好,因为辅助表可以添加机房等额外信息。在一些场景下,如果下游需要知道这条记录原始产生的机房,还是需要使用辅助表。

4 开源组件介绍canal/otter

前面深入讲解了单元化场景下数据同步的基础知识。读者可能比较感兴趣的是,哪些开源组件在这些方面做的比较好。笔者建议的首选,是canal/otter组合。

canal的作用就是类似于前面所述的binlog syncer,拉取解析binlog。otter是canal的客户端,专门用于进行数据同步,类似于前文所讲解的sql writer。并且,canal的最新版本已经实现了GTID。

posted @ 2022-02-17 12:26  hanease  阅读(372)  评论(0编辑  收藏  举报