MySQL 数据同步

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

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

1 什么是单元化

  若是仅仅从"单元化”这个词汇的角度来讲,咱们能够理解为将数据划分到多个单元进行存储。"单元"是一个抽象的概念,一般与数据中心(IDC)概念相关,一个单元能够包含多个IDC,也能够只包含一个IDC。本文假设一个单元只对应一个IDC。redis

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

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

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

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

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

    

  经过这种方式,咱们能够解决单机房遇到的问题:架构

  一、用户体验。不一样的用户能够选择离本身最近的机房进行访问运维

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

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

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

  • 城市级容灾:在发生重大天然灾害的状况下,可能整个城市的机房都没法访问。一些组件,例如蚂蚁的ocean base,为了达到城市级容灾的能力,使用的是"三地五中心"的方案。这种状况下,3个城市分别拥有二、二、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.2 如何获取全量+增量数据?

        一般,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的来源。例如

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

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

        然而,ROW模式下,默认只记录变动先后的值,不记录SQL。因此,咱们要经过一个开关,让Mysql在ROW模式下也记录INSERT、UPDATE、DELETE的SQL语句。具体作法是,在mysql的配置文件中,添加如下配置:

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 经过附加表

        这种方案目前不少知名互联网公司在使用。大体思路是,在db中都加一张额外的表,例如叫direction,记录一个binlog产生的源集群的信息。例如

CREATE TABLE `direction` (  `idc` varchar(255) not null,  `db_cluster` varchar(255) not null,) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4

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

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

BEGIN;insert into users(name) values("tianshouzhi”);COMMIT;

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

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

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

3.2.3 经过GTID

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

  GTID 由2个部分组成:

  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:

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

   产生的binlog信息以下:

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

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

SET GTID_NEXT= '09530823-4f7d-11e9-b569-00163e121964:1’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在源库中已经存在了,插入记录将会被忽略,演示以下:

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

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

  如何作到的呢?mysql会记录本身执行过的全部GTID,当判断一个GTID已经执行过,就会忽略。经过以下sql查看:

mysql> show global variables like "gtid_executed"; +---------------+------------------------------------------+

| Variable_name | Value                                    |

+---------------+------------------------------------------+

| gtid_executed | 09530823-4f7d-11e9-b569-00163e121964:1-5 |

+---------------+------------------------------------------+

        上述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执行:

mysql> show variables like "server_uuid"; +---------------+--------------------------------------+

| Variable_name | Value                                |

+---------------+--------------------------------------+

| server_uuid   | 09530823-4f7d-11e9-b569-00163e121964 |

+---------------+--------------------------------------+

        GTID应该算是一个终极的数据回环解决方案,mysql原生自带,比添加一个辅助表的方式更轻量,开销也更低。须要注意的是,这倒并非必定说GTID的方案就比辅助表好,由于辅助表能够添加机房等额外信息。在一些场景下,若是下游须要知道这条记录原始产生的机房,仍是须要使用辅助表。

4 开源组件介绍canal/otter

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

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