1、引子
现在的系统大都是7 * 24不间断运行的,对高可用的要求很高。除此之外,系统需要保证不同城市,甚至于不同国家的可用性。
MySQL数据库一般来说是主从结构,通过跨地域部署主从复制查询可以保证跨地域的可用性。但是写操作一旦跨机房,网络的延迟和不可靠可能会造成执行时间超长或者执行失败。而这对于那些对高可用系统有跨地域的要求的系统来说,就是不能接受的了。
MySQL的多主复制
mysql本身具备多主复制的能力,但是我们不能在多个mysql上进行毫无顾忌的写,一旦两个mysql实例在同一个表的同一行记录都进行了写操作就会造成复制问题,或者是实例之间数据的不一致。
MySQL Group Replication
从MySQL 5.7.17开始,发布了MySQL Group Replication,Group Replication具备了多主的能力,它是一种强一致的实现,多个MySQL实例能够实时保证数据的一致性,但是它对网络延时要求高,而且比价适合于金融业务场景。一旦跨地域性能非常低,网络的延迟和抖动甚至于会造成整个系统的不可用。
2、跨IDC业务常见的模式
大多数配置了多主复制的mysql集群,在业务上我们也是写一个点,或者严格按照主键把请求哈希路由到不同的实例上来写。
当然大多数业务都是选择单点写,这样业务上好控制。当业务跨IDC部署时,跨IDC的写操作延时就会很高,影响业务性能。目前多IDC的业务部署有以下几种:
对称部署
这种模式能写性能非常高,但是对业务程序的要求更高,不仅增加了开发难度,而且部署成本会偏高,需要上层有对应的路由功能。而且一旦写请求对应的数据不属于本IDC,一样需要跨机房路由到其他机房去写。
而且这种模式一旦扩展到3个IDC的时候,往往需要在MySQL上部署环形复制,这种模式极易造成数据的不一致。
非对称部署
这种模式下,只有一个真正的主库,所有的写操作必须落到这个主库上,是最常见的模式。它不需要有路由来根据不同的key路由到不同的服务程序上,但是对于不在主库所在的IDC的服务程序需要实现读写分离。
这种模式顾名思义非对称,就是说不同IDC的服务程序部署有一定的差异性,一旦我们需要切换主库时很多情况下就需要手工操作,并且容易造成切换时的数据不一致。
同时,由于IDC2中的写操作是跨机房的,由于跨机房的网络延时和丢包影响,会对应用程序的服务性能产生巨大的影响。写操作过多,就不可取。
代理层提供的折中方案
从图中可以看出,虽然采用代理层能够解决对称部署,但是无法解决跨机房写延时的问题。代理层是一个支持多套MySQL集群的中间件平台,详细了解请参考:(http://www.jianshu.com/p/bc50221972ca)。
3、改造MySQL支持多主多写模式
目前MySQL的主从复制是一种强一致的数据库部署方案,我们公司内部有很多业务都是没有强一致性需求,而更亲赖于高可用性,需要容忍机房故障,在任何一个机房出现故障时,也不能影响服务的正常进行。所以这种场景,我们一般采用最终一致性数据库模型。
MyShard是一种最终一致性数据库,它一个支持多主多写模式的存储,是一个跨机房的对等部署的典范,但是MyShard的问题在于系统过重,部署和运维都是一件非常头痛的事情,目前我们数据库团队已经不再对外推广MyShard了,只是对线上已有的MyShard还是提供技术支持和维护。
但是目前我们公司还是有很多业务是对于这种多机房同时写有强需求,如何解决这个问题呢?我在去年就提出要开发一套基于MySQL的多写方案,最近花时间突击研究了一下终于有了突破,基本上实现了想要的轻量级的多主MySQL集群。
MySQL5.7的多源复制
从MySQL5.7开始,有了通信渠道的概念,每一个通信渠道都是一个从服务器从主服务器获得二进制日志的链接。这意味着每个通信渠道都得有一个IO_THREAD .我们需要运行不同的 “CHANGE MASTER” 命令, 对于每一个主服务器。我们需要用到 “FOR CHANNEL”这个参数来提供通信链接的名字。
举个例子:
MySQL > CHANGE MASTER to MASTER_HOST='127.0.0.1',MASTER_PORT=6301 , MASTER_USER='repl', MASTER_PASSWORD='repl',MASTER_AUTO_POSITION = 1 FOR CHANNEL="idc1";
MySQL > start slave for channel='idc1' ;
改造后多主存储的架构
多主MySQL是基于MySQL 5.7.19改造,充分利用了多源复制功能,每个MySQL实例都会成为集群中的其他的实例的从库。
改造后的MySQL是通用和多写的混合版本,对于不同的表采用不同的策略,支持多写的表有一定的限制:
1、存储在__mm 数据库下
2、必须添加一个 __version字段,并且是在表的第一个字段__version字段用来处理版本冲突,其中最后一个bit用来描述是否该记录以删除。
最后一位为0:有效数据
最后一位为1:删除数据
在该模式下,各个IDC的应用程序,都可以往机房内的MySQL实例进行读写操作。
改造点
- 给MySQL添加新的语法 这里借用了MyShard的语法,给MySQL添加了SET语义的两个语法
写:HASET table set col1 = ? AND col2=? WHERE K1=? AND k2=?
如果对应主键数据不存在,就插入数据,存在就更新数据
删:HADELETE FROM table where k1=? AND k2=?
如果对应主键数据不存在,插入一条删除记录,存在,就改为带删除标记的数据
- 给MySQL添加新的函数
mmversion(seed,delete_flag):函数代两个参数,第一个参数是版本生成的种子,如果为0,采用系统时间自动生成种子;第二个参数是删除标记。
mysql> select mmversion(0,0) ;
+--------------------------------+
| mmversion(0,0) |
+--------------------------------+
| 3232157937589817394 |
+--------------------------------+
1 row in set (0.00 sec)
mmversion_is_local(version):该函数用来返回是否是在本实例产生
mysql> select mmversion_is_local(3232157937589817394) ;
+-----------------------------------------+
| mmversion_is_local(3232157937589817394) |
+-----------------------------------------+
| 1 |
+-----------------------------------------+
注意:该版本没有对MySQL基本的语法做任何逻辑上的变更,所以基本的查询语法、DML语法都是可以使用的,并且也可以用在多写的表上面。
4、如何使用多主MySQL
首先创建一个表:
CREATE TABLE `b` (
`__version` bigint(20) unsigned NOT NULL,
`id` bigint(20) auto_increment NOT NULL,
`name` varchar(200) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
表的第一个字段必须是__version字段,bigint类型。
下面我们同过DML语法在这种多主表中的写法:
插入数据语法,可以采用自动生成id的功能:
mysql> insert into b ( __version, id, name) values ( mmversion(0, 0 ), mmuuid(), '123') ;
Query OK, 1 row affected (0.04 sec)
mysql> select last_insert_id() ;
+---------------------+
| last_insert_id() |
+---------------------+
| 1617051614604954626 |
+---------------------+
1 row in set (0.00 sec)
mysql> select * from b where id= 1617051614604954626 ;
+---------------------+---------------------+------+
| __version | id | name |
+---------------------+---------------------+------+
| 3234103229209909252 | 1617051614604954626 | 123 |
+---------------------+---------------------+------+
1 row in set (0.00 sec)
更新数据
按照最终一致性的原理,版本号大的会胜出,更新语法中一定要判断版本号,并更新版本号:
mysql> update b set name='234',__version=mmversion(0,0) where id=1 and __version < mmversion(0,0) ;
Query OK, 1 row affected (0.03 sec)
Rows matched: 1 Changed: 1 Warnings: 0
也可以支持复杂的update
mysql> update b set name='4444', __version=mmversion(0,0) where name like '1%' and __version < mmversion(0,0) ;
Query OK, 1 row affected (0.03 sec)
Rows matched: 1 Changed: 1 Warnings: 0
删除数据(其实是给数据加删除标记)
mysql> update b set __version=mmversion(0,1) where name like '44%' and __version < mmversion(0,1) ;
Query OK, 1 row affected (0.07 sec)
Rows matched: 1 Changed: 1 Warnings: 0
采用SET语法操作数据:
- 在没有数据的情况下, 执行haset会插入数据,有数据的情况,haset会更新数据:
mysql> haset b set name='123' where id=1 ;
Query OK, 1 row affected (0.03 sec)
mysql> haset b set name='234' where id=2 ;
Query OK, 1 row affected (0.03 sec)
mysql> select * from b ;
+--------------------------------+----+--------+
| __version | id | name |
+--------------------------------+----+--------+
| 3232159101525960754 | 1 | 123 |
| 3232159110115897394 | 2 | 234 |
+--------------------------------+----+--------+
2 rows in set (0.00 sec)
- HADELETE删除数据
mysql> hadelete from b where id=1 ;
Query OK, 2 rows affected (0.23 sec)
mysql> select * from b ;
+--------------------------------+----+--------+
| __version | id | name |
+--------------------------------+----+--------+
| 3232159183130343475 | 1 | 123 |
| 3232159110115897394 | 2 | 234 |
+--------------------------------+----+--------+
2 rows in set (0.01 sec)
hadelete不会实际的删除一条记录,而是把__version字段的删除标记为标位1。
查询数据:
mysql> select * from b where id=1 and __version % 2 = 0 ;
Empty set (0.00 sec)
mysql> select * from b where __version %2 = 0 ;
+--------------------------+----+--------+
| __version | id | name |
+--------------------------+----+--------+
| 3232159110115897394 | 2 | 234 |
+--------------------------+----+--------+
1 row in set (0.00 sec)
真正的删除数据,
这种语法不建议开发人员使用,可能造成数据不一致,一般由dba清除数据用
delete from b where id=1 ;
5、一些实战技巧
使用自增字段
我们仍然可以使用自增id,这要求在集群中的不同MySQL实例要设置不同的偏移,例如:
实例1:
auto-increment-increment = 2
auto-increment-offset = 1
实例2:
auto-increment-increment = 2
auto-increment-offset = 2
插入数据:
mysql> insert into c ( __version, name ) values ( mmversion(-1,0), '444') ;
Query OK, 1 row affected (0.03 sec)
mysql> select last_insert_id() ;
+------------------+
| last_insert_id() |
+------------------+
| 1 |
+------------------+
1 row in set (0.00 sec)
mysql> select * from c where id=1 ;
+---------------------+----+------+
| __version | id | name |
+---------------------+----+------+
| 3234105709553524740 | 1 | 444 |
+---------------------+----+------+
1 row in set (0.00 sec)
利用事物逻辑实现自己想要的SET功能
MySQL原生的语法和功能都没有改变,所以我们可以利用事物来处理一些复杂事情。
start transaction;
select * from a where id=1 and __version < new_version for update ;
if ( has key )
update a set xxxx = ffff, __version = new_version where id=1;
else
insert into a ( id, xxxx , __version) values ( 1, 'ffff' ,new_version);
end
commit transaction ;