概述
在实际业务架构中,我们最常接触到的数据库架构模式为「主从架构」,这是一种中小型公司最为偏爱的架构模式,也是开发人员最广为熟知的模式。它有着读写速度快,易于扩展,高可用等等非常多的优点。但还有一种架构却不常被人提起,他就是本篇文章着重讨论的场景:「多主架构」
多主架构相比较主从架构而言,逻辑更为复杂,需要考虑的因素更多。数据流向从单向复制变成了双向复制,单主结构变成了双主,甚至三主,四主。
一个字的简单改变却让整体逻辑变得复杂深奥起来。原本少有了解的 数据回环问题,多库并发插入问题 直扑面门。
那么,如何解决上述的两个主要问题?我们遇到了该怎么做?在了解问题的答案之前,我们先要清楚问题的缘由与本质。
数据回环问题
数据回环问题是指在多主模式下,A 节点接收到 INSERT/UPDATE/DELETE 等操作后同步给 B,B 节点接收到 A 节点的数据又重新同步给A,形成了一个闭环,这个闭环中的数据会不断的在 A 和 B 节点之间传递,形成了一个死循环。
往目标库插入不生成 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,读者可以自行模拟。
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命令行来操作数据库时,肯定会存在没有添加机房信息的情况。
总的来说,这个方案不是那么完美。
通过标记表
这种方案目前很多公司使用。大致思路是,在db中都加一张额外的表,例如叫 direction,记录一个binlog产生的源集群的信息。例如
create table direction(
idc varchar(255) not null,
db_cluster varchar(255) not null
)
idc字段用于记录某条记录原始产生的IDCdb_cluster用于记录原始产生的数据库集群(注意这里要使用集群的名称,不能是server_id,因为可能会发生主从切换)。
假设用户在IDC1的库A插入的一条记录
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产生的,进行抛弃,通过这种方式来避免回环。这种方案已经已经过很多的公司的实际验证。
但是该方案无法解决三方同步的问题。
通过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_next,指示如何产生下一个GTID。可能的取值如下:
-
AUTOMATIC: 自动生成下一个GTID,实现上是分配一个当前实例上尚未执行过的序号最小的GTID。 -
ANONYMOUS: 设置后执行事务不会产生GTID,显式指定的GTID。
默认情况下,是 AUTOMATIC ,也就是自动生成的,例如我们执行sql:
insert into users(name) values("tianbowen”);
产生的binlog信息如下:
可以看到,GTID会在每个事务(Query->…->Xid)之前,设置这个事务下一次要使用到的GTID。
MQ 订阅 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 的方案就比辅助表好,因为辅助表可以添加机房等额外信息。在一些场景下,如果下游需要知道这条记录原始产生的机房,还是需要使用辅助表。
附加列
在非 Mysql 数据库的场景下,我们可以使用附加列的方式来解决数据回环问题。比如,我们在表中添加一个额外的列,用来标识这条数据是否被复制过。当业务操作源数据库时,该列值设为空。当该数据被 MQ 监听到后交由 sql writer 处理,在往同步库中同步该数据前,将该列值设置为 replicated。这样,当同步库被监听时,因为发现了数据的附加列值为 replicated,所以不会再次同步该数据。
但是该方式只能解决业务端,如果DBA手动操作数据库,则无法控制。
并发修改问题
多主复制的还有一个问题是可能发生写冲突。
例如两个用户同时更改标题,用户1将标题从A改为B,用户2将标题从A改为C。每个用户的更改者R顺利地提交到本地主节点,但当更改被异步复制到对方时却发现存在冲突。
对于主从复制模型,数据更新符合顺序性原则,即如果同一个字段有多个更新,则最后一个写操作将决定该字段的最终值。
对于多主节点复制模型,由于不存在这样的写入顺序,所以最终值也会变得不确定。如果每个副本都只是按照它所看到写入的顺序执行,那么数据库最终将处于不一致状态,而这是不可接受的。因此数据库必须以一种收敛趋同的方式来解决冲突,这也意味着当所有更改最终被复制、同步之后,所有副本的最终值是相同的。
以下两种方案都会存在数据丢失的问题,即丢失被遗弃的更改内容
乐观锁
为了解决该情况,我们可以在同步库中添加一个额外的列,用来标识该数据的版本号,比如 时间戳。当修改数据时,将该列的值设置为当前时间戳。这样一来,其中一方的数据同步过来时,肯定会比当前时间戳要小,所以就不会在该数据库执行。也就解决了并发问题。(这种方式被称为LWW)
缓存池
我们也可以使用缓存池的方式来解决数据不一致的问题。比如,我们可以添加一个公用缓存池,用来存放同步过来的数据。当数据同步过来时,先将数据按标识存放到缓存池中,同条数据进行覆盖,然后再将数据根据标识同步到指定的数据库中。这样一来,当数据同步到数据库中时,就不会出现并发修改的问题。
但是数据在缓存池存放的时间是一个问题,具体多少要取决于数据同步的的时间,长了会导致数据的延迟,短了的话,又会导致数据不一致的问题。