上篇分享了从系统层面解决mysql主从复制的延迟问题 可先参考
今天分享根本上解决mysql主从复制的延迟问题。
一、并行复制的原理:
很多码友在自己线上的业务系统中都使用了mysql的主从复制,但是大家需要注意的是,并不是所有的场景都适合主从复制,一般情况下是读要远远多于写的应用,同时读的时效性要求不那么高的场景。如果真实场景中真的要求立马读取到更新之后的数据,那么就只能强制读取主库的数据,所以在进行实现的时候要考虑实际的应用场景,不要为了技术而技术,这是很严肃的事情。
在mysql5.6版本之后引入了一个概念,就是我们通常说的并行复制,如下图:
通过上图我们可以发现其实所谓的并行复制,就是在中间添加了一个分发的环节,也就是说原来的sql_thread变成了现在的coordinator组件,当日志来了之后,coordinator负责读取日志信息以及分发事务,真正的日志执行的过程是放在了worker线程上,由多个线程并行的去执行。
-- 查看并行的slave的线程的个数,默认是0.表示单线程
show global variables like 'slave_parallel_workers';
-- 根据实际情况保证开启多少线程
set global slave_parallel_workers = 4;
-- 设置并发复制的方式,默认是一个线程处理一个库,值为database
show global variables like '%slave_parallel_type%';
-- 停止slave
stop slave;
-- 设置属性值
set global slave_parallel_type='logical_check';
-- 开启slave
start slave
-- 查看线程数
show full processlist;
1、 通过上述的配置可以完成我们说的并行复制,但是此时你需要思考几个问题
1)、在并行操作的时候,可能会有并发的事务问题,我们的备库在执行的时候不可以按照轮训的方式发送给各个worker,原因如下:
因为事务被分发给worker以后,不同的worker就开始独立执行了,但是,由于CPU的不同调度策略,很可能第二个事务最终比第一个事务先执行,而如果刚刚好他们修改的是同一行数据,那么因为执行顺序的问题,可能导致主备的数据不一致。
2)、同一个事务的多个更新语句,不能分给不同的worker来执行,原因如下:
比如一个事务更新了表t1和表t2中的各一行,如果这两条更新语句被分到不同worker的话,虽然最终的结果是主备一致的,但如果表t1执行完成的瞬间,备库上有一个查询,就会看到这个事务更新了一半的结果,破坏了事务逻辑的隔离性。
2、我们通过讲解上述两个问题的最主要目的是为了说明一件事,就是coordinator在进行分发的时候,需要遵循特定的策略:
1)、不能造成更新覆盖。这就要求更新同一行的两个事务,必须被分发到同一个worker中。
2)、同一个事务不能被拆开,必须放到同一个worker中。
分析完上面的描述,我们来说一下具体实现的原理和过程:
如果让我们自己来设计的话,我们应该如何操作呢?这是一个值得思考的问题。其实如果按照实际的操作的话,我们可以按照粒度进行分类,分为按库分发,按表分发,按行分发。
其实不管按照什么方式进行分发,大家需要注意的就是在分发的时候必须要满足我们上面说的两条规则,所以当我们进行分发的时候要在每一个worker上定义一个hash表,用来保存当前这个work正在执行的事务所涉及到的表。hash表的key值按照不同的粒度需要存储不同的值:
按库分发:key值是数据库的名字,这个比较简单
按表分发:key值是库名+表名
按行分发:key值是库名+表名+唯一键
二、MySQL5.6版本的并行复制策略
其实从mysql的5.6版本开始就已经支持了并行复制,只是支持的粒度是按库并行,这也是为什么现在的版本中可以选择类型为database,其实说的就是支持按照库进行并行复制。
但是其实用过的码农应该都知道,这个策略的并行效果,取决于压力模型。如果在主库上有多个DB,并且各个DB的压力均衡,使用这个策略的效果会很好,但是如果主库的所有表都放在同一DB上,那么所有的操作都会分发给一个worker,变成单线程操作了,那么这个策略的效果就不好了,因此在实际的生产环境中,用的并不是特别多。
三、mariaDB的并行复制策略
在mysql5.7的时候采用的是基于组提交的并行复制,换句话说,slave服务器的回放与主机是一致的,即主库是如何并行执行的那么slave就如何怎样进行并行回放,这点其实是参考了mariaDB的并行复制,下面我们来看下其实现原理。
1、mariaDB的并行复制策略利用的就是这个特性:
1)、能够在同一组里提交的事务,一定不会修改同一行;
2)、主库上可以并行执行的事务,备库上也一定是可以并行执行的。
2 、在实现上,mariaDB是这么做的:
1)、在一组里面一起提交的事务,有一个相同的commit_id,下一组就是commit_id+1;
2)、commit_id直接写到binlog里面;
3)、传到备库应用的时候,相同commit_id的事务会分发到多个worker执行;
4)、这一组全部执行完成后,coordinator再去取下一批。
这是mariaDB的并行复制策略,大体上看起来是没有问题的,但是你仔细观察的话会发现他并没有实现“真正的模拟主库并发度”这个目标,在主库上,一组事务在commit的时候,下一组事务是同时处于“执行中”状态的。
我们真正想要达到的并行复制应该是如下的状态,也就是说当第一组事务提交的是,下一组事务是运行的状态,当第一组事务提交完成之后,下一组事务会立刻变成commit状态。
但是按照mariaDB的并行复制策略,那么备库上的执行状态会变成如下所示:
可以看到,这张图跟上面这张图的最大区别在于,备库上执行的时候必须要等第一组事务执行完成之后,第二组事务才能开始执行,这样系统的吞吐量就不够了。而且这个方案很容易被大事务拖后腿,如果trx2是一个大事务,那么在备库应用的时候,trx1和trx3执行完成之后,就只能等trx2完全执行完成,下一组才能开始执行,这段时间,只有一个worker线程在工作,是对资源的浪费。
四、mysql5.7的并行复制策略
mysql5.7版本的时候,根据mariaDB的并行复制策略,做了相应的优化调整后,提供了自己的并行复制策略,并且可以通过参数slave-parallel-type来控制并行复制的策略:
1、当配置的值为DATABASE的时候,表示使用5.6版本的按库并行策略;
2、当配置的值为LOGICAL_CLOCK的时候,表示跟mariaDB相同的策略。
此时,大家需要思考一个问题:同时处于执行状态的所有事务,是否可以并行?
答案是不行的,因为多个执行中的事务是有可能出现锁冲突的,锁冲突之后就会产生锁等待问题。
在mariaDB中,所有处于commit状态的事务是可以并行,因为如果能commit的话就说明已经没有锁的问题,但是大家回想下,我们mysql的日志提交是两阶段提交,如下图,其实只要处于prepare状态就已经表示没有锁的问题了。
2 、因此,mysql5.7的并行复制策略的思想是:
1)、同时处于prepare状态的事务,在备库执行是可以并行的。
2)、处于prepare状态的事务,与处于commit状态的事务之间,在备库上执行也是可以并行的。
3、基于这样的处理机制,我们可以将大部分的日志处于prepare状态,因此可以设置
1)、binlog_group_commit_sync_delay 参数,表示延迟多少微秒后才调用 fsync;
2)、binlog_group_commit_sync_no_delay_count 参数,表示累积多少次以后才调用 fsync。
五、基于GTID的主从复制问题
在我们之前讲解的主从复制实操中,每次想要复制,必须要在备机上执行对应的命令,如下所示:
change master to master_host='192.168.01.11',master_user='root',master_password='123456',master_port=3306,master_log_file='master-bin.000001',master_log_pos=136;
在此配置中我们必须要知道具体的binlog是哪个文件,同时在文件的哪个位置开始复制,正常情况下也没有问题,但是如果是一个主备主从集群,那么如果主机宕机,当从机开始工作的时候,那么备机就要同步从机的位置,此时位置可能跟主机的位置是不同的,因此在这种情况下,再去找位置就会比较麻烦,所以在5.6版本之后出来一个基于GTID的主从复制。
GTID(global transaction id)是对于一个已提交事务的编号,并且是一个全局唯一的编号。GTID实际上是由UUID+TID组成的,其中UUID是mysql实例的唯一标识,TID表示该实例上已经提交的事务数量,并且随着事务提交单调递增。这种方式保证事务在集群中有唯一的ID,强化了主备一致及故障恢复能力。
1、基于GTID的搭建
1)、修改mysql配置文件,添加如下配置
gtid_mode=on
enforce-gtid-consistency=true
2)、重启主从的服务
3)、从库执行如下命令
change master to master_host='192.168.01.111',master_user='root',master_password='123456'
,master_auto_position=1;
4)、主库从库插入数据测试。
2、基于GTID的并行复制
无论是什么方式的主从复制其实原理相差都不是很大,关键点在于将组提交的信息存放在GTId中。
show binlog events in 'lian-bin.000001';
previous_gtids:用于表示上一个binlog最后一个gtid的位置,每个binlog只有一个。
gtid:当开启gtid的时候,每一个操作语句执行前会添加一个gtid事件,记录当前全局事务id,组提交信息被保存在gtid事件中,有两个关键字段,last_committed,sequence_number用来标识组提交信息。
上述日志看起来可能比较麻烦,可以使用如下命令执行:
其中last_committed表示事务提交的时候,上次事务提交的编号,如果事务具有相同的last_committed值表示事务就在一个组内,在备库执行的时候可以并行执行。同时大家还要注意,每个last_committed的值都是上一个组事务的sequence_number值。
看到此处,大家可能会有疑问,如果我们不开启gtid,分组信息该如何保存呢?
其实是一样的,当没有开启的时候,数据库会有一个Anonymous_Gtid,用来保存组相关的信息。
如果大家想看并行的效果的话,可以执行如下代码:
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.Date;
public class ConCurrentInsert extends Thread{
public void run() {
String url = "jdbc:mysql://192.168.85.111/lian2";
String name = "com.mysql.jdbc.Driver";
String user = "root";
String password = "123456";
Connection conn = null;
try {
Class.forName(name);
conn = DriverManager.getConnection(url, user, password);//获取连接
conn.setAutoCommit(false);//关闭自动提交,不然conn.commit()运行到这句会报错
} catch (Exception e1) {
e1.printStackTrace();
}
// 开始时间
Long begin = new Date().getTime();
// sql前缀
String prefix = "INSERT INTO t1 (id,age) VALUES ";
try {
// 保存sql后缀
StringBuffer suffix = new StringBuffer();
// 设置事务为非自动提交
conn.setAutoCommit(false);
// 比起st,pst会更好些
PreparedStatement pst = (PreparedStatement) conn.prepareStatement("");//准备执行语句
// 外层循环,总提交事务次数
for (int i = 1; i <= 10; i++) {
suffix = new StringBuffer();
// 第j次提交步长
for (int j = 1; j <= 10; j++) {
// 构建SQL后缀
suffix.append("(" +i*j+","+i*j+"),");
}
// 构建完整SQL
String sql = prefix + suffix.substring(0, suffix.length() - 1);
// 添加执行SQL
pst.addBatch(sql);
// 执行操作
pst.executeBatch();
// 提交事务
conn.commit();
// 清空上一次添加的数据
suffix = new StringBuffer();
}
// 头等连接
pst.close();
conn.close();
} catch (SQLException e) {
e.printStackTrace();
}
// 结束时间
Long end = new Date().getTime();
// 耗时
System.out.println("100万条数据插入花费时间 : " + (end - begin) / 1000 + " s"+" 插入完成");
}
public static void main(String[] args) {
for (int i = 1; i <=10; i++) {
new ConCurrentInsert().start();
}
}
}
到此、根本上解决mysql主从复制的延迟问题分享完毕,大家一定要多思考、多联系、多测试定会很快理解!