之前的文章我们提到过,主备数据库是通过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成功引入的数据不一致问题。
1 并行复制策略介绍
注意,这部分是作者丁奇自己写的并行复制策略,非官方实现策略。
1.1 按表分发策略
按表分发事务的基本思想是:如果两个事务更新不同的表,他们就可以并行。因为数据是存储在表里,所以按表分发,可以保证两个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 中,就变成单线程复制了。
1.2 按行分发策略
要解决热点表的并行复制问题,需要使用按行并行复制的方法。按行并行复制的核心思路就是:如果两个事务没有更新相同的行,在备库上可以并行执行,这时候就要求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表有三个项:
- key=hash_func(db1+t1+"PRIMARY"+2),value=2;这里的value=2是因为修改前后的id值不变,出现了两次;
- key=hash_func(db1+t1+"a"+2),value=1;表示会影响到表a=2的数据行;
- key=hash_func(db1+t1+"a"+1),value=1;表示会影响到表a=1的数据行;
相比于按表并行分发策略,按行并行策略在决定线程分发的时候:
- 需要消耗更多的计算资源;
- 要能够从 binlog 里面解析出表名、主键值和唯一索引的值。也就是说,主库的 binlog 格式必须是 row;
- 表必须有主键;
- 不能有外键。表上如果有外键,级联更新的行不会记录在 binlog 中,这样冲突检测就不准确。
对比按表分发和按行分发这两个方案的话,按行分发策略的并行度更高。不过,如果是要操作很多行的大事务的话,按行分发的策略有两个问题:
- 耗费内存。比如一个语句要删除 100 万行数据,这时候 hash 表就要记录 100 万个项。
- 耗费 CPU。解析 binlog,然后计算 hash 值,对于大事务,这个成本还是很高的。
所以,我在实现这个策略的时候会设置一个阈值,单个事务如果超过设置的行数阈值(比如,如果单个事务更新的行数超过 10 万行),就暂时退化为单线程模式,退化过程的逻辑大概是这样的:
- coordinator 暂时先 hold 住这个事务;
- 等待所有 worker 都执行完成,变成空队列;
- coordinator 直接执行这个事务;
- 恢复并行模式。
2 各数据库版本并行复制策略
2.1 MySQL5.6并行复制策略
5.6版本开始支持按库并行复制的策略,由于是按库,自然粒度比较粗。这个策略的并行效果,取决于压力模型,如果主库上有多个DB,并且各个DB的压力均衡,这个策略还好:
- 构建hash值只需要库名,而且一个实例上的DB数不可能会很多,不会出现构建100万个项这种情况;
- 不要求binlog格式,因为statement格式的binlog也可以很容易拿到库名。
但是问题也比较明显,比如大促项目的数据库和运营后台的数据库一定不是均衡的,因此,策略的应用性有些差。
2.2 MariaDB并行复制策略
MariaDB是基于redo log的组提交(group commit)特性实现:
- 能够在一个组内提交的事务,一定不会修改同一行;原因在于说:事务在执行数据更新或者DDL时一定会加锁,只有事务提交后才会释放锁,所以,借助于锁的互斥性,保证了事务的原子性;
- 主库上可以并行执行的事务,备库上也一定是可以并行执行的;
在实现上:
- 在一组里面一起提交的事务,有一个相同的commit_id,下一组就是commit_id+1;
- commit_id直接写入binlog中;
- 传到备库应用时,相同commit_id事务分发到多个worker执行;
- 这一组全部执行完成后,coordinator再去取下一批;
MariaDB的目标就是“模拟主库的并行执行”,但是在具体实现上有些差距,毕竟主库在一组事务commit时,下一组事务同时处于“执行中”状态。如图所示:
图片
MariaDB的执行过程为:
图片
在备库上执行的时候,要等第一组事务完全执行完成后,第二组事务才能开始执行,这样系统的吞吐量就不够。
另外,这个方案很容易被大事务拖后腿。假设 trx2 是一个超大事务,那么在备库应用的时候,trx1 和 trx3 执行完成后,就只能等 trx2 完全执行完成,下一组才能开始执行。这段时间,只有一个 worker 线程在工作,是对资源的浪费。
2.3 MySQL5.7版本并行复制策略
5.7版本提供了类似于MariaDB策略,并增加参数slave-parallel-type控制并行策略:
- 配置为 DATABASE,表示使用 MySQL 5.6 版本的按库并行策略;
- 配置为 LOGICAL_CLOCK,表示的就是类似 MariaDB 的策略。不过,MySQL 5.7 这个策略,针对并行度做了优化。
优化点在于,把阶段进行了提前,执行中的事务可能会存在冲突,commit状态的事务可能又有些延迟,MySQL5.7允许同时处于prepare状态的事务执行并行操作,因为已经prepare状态的事务一定也已经通过锁冲突的检测:
- 同时处于prepare状态的事务在备库执行时可以并行;
- 处于prepare状态的事务与commit状态的事务之间,可以并行;
binlog 的组提交的时候,介绍过两个参数:
- binlog_group_commit_sync_delay 参数,表示延迟多少微秒后才调用 fsync;
- binlog_group_commit_sync_no_delay_count 参数,表示累积多少次以后才调用 fsync。
这两个参数是用于故意拉长 binlog 从 write 到 fsync 的时间,以此减少 binlog 的写盘次数。在 MySQL 5.7 的并行复制策略里,它们可以用来制造更多的“同时处于 prepare 阶段的事务”。这样就增加了备库复制的并行度。
也就是说,这两个参数,既可以“故意”让主库提交得慢些,又可以让备库执行得快些。在 MySQL 5.7 处理备库延迟的时候,可以考虑调整这两个参数值,来达到提升备库复制并发度的目的。
2.4 MySQL5.7.22版本的并行复制策略
MySQL 5.7.22 版本里,MySQL 增加了一个新的并行复制策略,基于 WRITESET 的并行复制,新增了一个参数 binlog-transaction-dependency-tracking,用来控制是否启用这个新策略。这个参数的可选值有以下三种。
- COMMIT_ORDER,根据同时进入 prepare 和 commit 来判断是否可以并行的策略。
- WRITESET,表示的是对于事务涉及更新的每一行,计算出这一行的 hash 值,组成集合 writeset。如果两个事务没有操作相同的行,也就是说它们的 writeset 没有交集,就可以并行。
- WRITESET_SESSION,是在 WRITESET 的基础上多了一个约束,即在主库上同一个线程先后执行的两个事务,在备库执行的时候,要保证相同的先后顺序。
当然为了唯一标识,这个 hash 值是通过“库名 + 表名 + 索引名 + 值”计算出来的。如果一个表上除了有主键索引外,还有其他唯一索引,那么对于每个唯一索引,insert 语句对应的 writeset 就要多增加一个 hash 值。
这跟前面介绍的基于 MySQL 5.5 版本的按行分发的策略是差不多的。不过,MySQL 官方的这个实现还是有很大的优势:
- writeset 是在主库生成后直接写入到 binlog 里面的,这样在备库执行的时候,不需要解析 binlog 内容(event 里的行数据),节省了很多计算量;
- 不需要把整个事务的 binlog 都扫一遍才能决定分发到哪个 worker,更省内存;
- 由于备库的分发策略不依赖于 binlog 内容,所以 binlog 是 statement 格式也是可以的。
因此,MySQL 5.7.22 的并行复制策略在通用性上还是有保证的。当然,对于“表上没主键”和“外键约束”的场景,WRITESET 策略也是没法并行的,也会暂时退化为单线程模型。