之前的文章我们提到过,主备数据库是通过binlog实现的数据同步:
主库在接到客户端更新请求时,执行内部事务的更新逻辑,同时写binlog。 r
1)edo log commit后,才会回复客户端ack;
2)binlog写成功后就可以同步备库,因为binlog写盘成功后,就算后续commit失败,数据库也可以根据redo log+binlog重新恢复commit状态;
备库与主库之间维护一个长链接,有专门的线程来发送或者接收请求。
果冻布丁兔,公众号:陆队长MySQL:为什么所有实例可以保证数据一致性
无论是主备还是主从,实际上都是为了保证MySQL集群的高可用性:
无论是主备还是主从架构,实际上就是为了系统的高可用性实现的一个策略,防止主机因为某些故障导致异常下线,这时候备份或者从实例就会通过选择或者其他策略成为主服务实例,对外继续提供服务。
果冻布丁兔,公众号:陆队长MySQL:从MySQL看主从架构高可用性实现
但是如果在一个压力持续比较久(比如双十一或者大促期间)的主从系统内,主服务器需要应对庞大的数据读写压力,如果备库执行日志的速度低于主库生成日志的速度,那么主从的主备延迟时间越来越长,导致备库可能一直无法追上主库。这时候就需要本节引入的备库并行复制能力。
图片
如图所示的两个黑色箭头是我们比较关注的,一个是客户端写入主库,一个是备库上sql_thread执行中转日志(relay log)。
主库上影响并发主要是各种锁,在备库上的执行,如果 从sql_thread更新数据使用单线程就很大可能导致主备延迟,这也是MySQL5.6版本前在主库并发高或者TPS高时导致严重主备延迟问题的原因。
图片
上图有些类似netty的线程模型,没错,如果是好的技术模型,那么在很多的技术栈中都会使用。
coordinator只负责读取中转日志和分发事务,真正更新日志的逻辑由各个worker线程处理,worker的线程数由参数slave_parallel_workers决定。如果是32核的服务器,这个值可以设置为8~16.
虽然文章中很多人说为了保证备库的读服务,线程数为核数1/4~1/2,实际上我是不认同的,应该是主要看核数和读写压力,如果即使是64核的机器,并且写压力不大,还是可以继续保持当前的配置;如果是读写比例在10:1,那么这个线程数可以超过1/2。
为了保证事务的幂等性和原子性,我们需要做如下的要求:
1.幂等性:不能造成更新覆盖。幂等性要求同一行的两个事务必须分发到同一个worker。这里主要是为了防止由于客户端的重试导致的事务重复或者是两个事务之间的上下文依赖导致的数据不一致。
2.原子性:用一个事务必须由一个worker负责。相同事务的语句必须使用一个worker处理,否则可能导致一个worker失败,另一个worker成功引入的数据不一致问题。
注意,这部分是作者丁奇自己写的并行复制策略,非官方实现策略。
按表分发事务的基本思想是:如果两个事务更新不同的表,他们就可以并行。因为数据是存储在表里,所以按表分发,可以保证两个worker不会更新同一行。
如果有跨表的事务,那么就需要把两张表放在一起考虑。
图片
每个worker对应一个hash表,用于保存当前正在这个worker的“执行队列”里的事务所涉及的表。hash表的key是“库名.表名”,value是一个数字,表示队列中有多少事务修改这个表。
在有事务分配给 worker 时,事务里面涉及的表会被加到对应的 hash 表中。worker 执行完成后,这个表会被从 hash 表中去掉。
图 3 中,hash_table_1 表示,现在 worker_1 的“待执行事务队列”里,有 4 个事务涉及到 db1.t1 表,有 1 个事务涉及到 db1.t2 表;hash_table_2 表示,现在 worker_2 中有一个事务会更新到表 t3 的数据。
假设在图中的情况下,coordinator 从中转日志中读入一个新事务 T,这个事务修改的行涉及到表 t1 和 t3。
现在我们用事务 T 的分配流程,来看一下分配规则:
也就是说,每个事务在分发的时候,跟所有 worker 的冲突关系包括以下三种情况:
这个按表分发的方案,在多个表负载均匀的场景里应用效果很好。但是,如果碰到热点表,比如所有的更新事务都会涉及到某一个表的时候,所有事务都会被分配到同一个 worker 中,就变成单线程复制了。
要解决热点表的并行复制问题,需要使用按行并行复制的方法。按行并行复制的核心思路就是:如果两个事务没有更新相同的行,在备库上可以并行执行,这时候就要求binlog的格式必须是row。这时候,我们判定事务T和worker冲突的规则是“修改同一行”。
按行复制和按表复制也是为每个worker分配一个hash表,只是按行复制时,在考虑主键的同时还要考虑唯一索引的冲突。
CREATE TABLE `t1` (
`id` int(11) NOT NULL,
`a` int(11) DEFAULT NULL,
`b` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `a` (`a`)
) ENGINE=InnoDB;
insert into t1 values(1,1,1),(2,2,2),(3,3,3),(4,4,4),(5,5,5);
这两个事务的主键不一致,但是如果分到不同worker,有可能出现sessionB先行,这时候id=1对应的a值还是1,就会出现唯一键冲突的问题。因此,基于行的策略,需要考虑唯一键,即key为:“库名+表名+索引a的名字+a的值”;
因此,上表例子中,表t1执行sessionB语句,在binlog记录了数据行修改前后各个字段的值,coordinator解析语句时,这个事务的hash表有三个项:
相比于按表并行分发策略,按行并行策略在决定线程分发的时候:
对比按表分发和按行分发这两个方案的话,按行分发策略的并行度更高。不过,如果是要操作很多行的大事务的话,按行分发的策略有两个问题:
所以,我在实现这个策略的时候会设置一个阈值,单个事务如果超过设置的行数阈值(比如,如果单个事务更新的行数超过 10 万行),就暂时退化为单线程模式,退化过程的逻辑大概是这样的:
5.6版本开始支持按库并行复制的策略,由于是按库,自然粒度比较粗。这个策略的并行效果,取决于压力模型,如果主库上有多个DB,并且各个DB的压力均衡,这个策略还好:
但是问题也比较明显,比如大促项目的数据库和运营后台的数据库一定不是均衡的,因此,策略的应用性有些差。
MariaDB是基于redo log的组提交(group commit)特性实现:
在实现上:
MariaDB的目标就是“模拟主库的并行执行”,但是在具体实现上有些差距,毕竟主库在一组事务commit时,下一组事务同时处于“执行中”状态。如图所示:
图片
MariaDB的执行过程为:
图片
在备库上执行的时候,要等第一组事务完全执行完成后,第二组事务才能开始执行,这样系统的吞吐量就不够。
另外,这个方案很容易被大事务拖后腿。假设 trx2 是一个超大事务,那么在备库应用的时候,trx1 和 trx3 执行完成后,就只能等 trx2 完全执行完成,下一组才能开始执行。这段时间,只有一个 worker 线程在工作,是对资源的浪费。
5.7版本提供了类似于MariaDB策略,并增加参数slave-parallel-type控制并行策略:
优化点在于,把阶段进行了提前,执行中的事务可能会存在冲突,commit状态的事务可能又有些延迟,MySQL5.7允许同时处于prepare状态的事务执行并行操作,因为已经prepare状态的事务一定也已经通过锁冲突的检测:
binlog 的组提交的时候,介绍过两个参数:
这两个参数是用于故意拉长 binlog 从 write 到 fsync 的时间,以此减少 binlog 的写盘次数。在 MySQL 5.7 的并行复制策略里,它们可以用来制造更多的“同时处于 prepare 阶段的事务”。这样就增加了备库复制的并行度。
也就是说,这两个参数,既可以“故意”让主库提交得慢些,又可以让备库执行得快些。在 MySQL 5.7 处理备库延迟的时候,可以考虑调整这两个参数值,来达到提升备库复制并发度的目的。
MySQL 5.7.22 版本里,MySQL 增加了一个新的并行复制策略,基于 WRITESET 的并行复制,新增了一个参数 binlog-transaction-dependency-tracking,用来控制是否启用这个新策略。这个参数的可选值有以下三种。
当然为了唯一标识,这个 hash 值是通过“库名 + 表名 + 索引名 + 值”计算出来的。如果一个表上除了有主键索引外,还有其他唯一索引,那么对于每个唯一索引,insert 语句对应的 writeset 就要多增加一个 hash 值。
这跟前面介绍的基于 MySQL 5.5 版本的按行分发的策略是差不多的。不过,MySQL 官方的这个实现还是有很大的优势:
因此,MySQL 5.7.22 的并行复制策略在通用性上还是有保证的。当然,对于“表上没主键”和“外键约束”的场景,WRITESET 策略也是没法并行的,也会暂时退化为单线程模型。