Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

26 | 备库为什么会延迟好几个小时? #37

Open
git-zjx opened this issue Sep 19, 2019 · 0 comments

Comments

@git-zjx
Copy link
Owner

commented Sep 19, 2019

不论是偶发性的查询压力,还是备份,对备库延迟的影响一般是分钟级的,而且在备库恢复正常以后都能够追上来。但是,如果备库执行日志的速度持续低于主库生成日志的速度,那这个延迟就有可能成了小时级别。而且对于一个压力持续比较高的主库来说,备库很可能永远都追不上主库的节奏。
1a85a3bac30a32438bfd8862e5a34eef
谈到主备的并行复制能力,我们要关注的是图中黑色的两个箭头。一个箭头代表了客户端写入主库,另一箭头代表的是备库上 sql_thread 执行中转日志(relay log)
在主库上,影响并发度的原因就是各种锁了。由于 InnoDB 引擎支持行锁,除了所有并发事务都在更新同一行(热点行)这种极端场景外,它对业务并发度的支持还是很友好的
而日志在备库上的执行,就是图中备库上 sql_thread 更新数据 (DATA) 的逻辑。如果是用单线程的话,就会导致备库应用日志不够快,造成主备延迟。

在官方的 5.6 版本之前,MySQL 只支持单线程复制,由此在主库并发高、TPS 高时就会出现严重的主备延迟问题。而所有的多线程复制机制,都是要把 sql_thread 拆成多个线程,也就是都符合下面的这个模型
bcf75aa3b0f496699fd7885426bc6245
coordinator 就是原来的 sql_thread, 不过现在它不再直接更新数据了,只负责读取中转日志和分发事务。真正更新日志的,变成了 worker 线程。而 work 线程的个数,就是由参数 slave_parallel_workers 决定的

事务分发会遇到两个问题:

  1. 事务能不能按照轮询的方式分发给各个 worker,也就是第一个事务分给 worker_1,第二个事务发给 worker_2 呢?
    不行,因为,事务被分发给 worker 以后,不同的 worker 就独立执行了。但是,由于 CPU 的调度策略,很可能第二个事务最终比第一个事务先执行。而如果这时候刚好这两个事务更新的是同一行,也就意味着,同一行上的两个事务,在主库和备库上的执行顺序相反,会导致主备不一致的问题
  2. 同一个事务的多个更新语句,能不能分给不同的 worker 来执行呢?
    不行,例如,一个事务更新了表 t1 和表 t2 中的各一行,如果这两条更新语句被分到不同 worker 的话,虽然最终的结果是主备一致的,但如果表 t1 执行完成的瞬间,备库上有一个查询,就会看到这个事务“更新了一半的结果”,破坏了事务逻辑的隔离性

所以,coordinator 在分发的时候,需要满足以下这两个基本要求:

  1. 不能造成更新覆盖。这就要求更新同一行的两个事务,必须被分发到同一个 worker 中。
  2. 同一个事务不能被拆开,必须放到同一个 worker 中。

丁奇在 MySQL 5.5 版本的使用的并行复制策略

官方 MySQL 5.5 版本是不支持并行复制的,但是丁奇为了解决主备延迟问题,先后写了两个版本的并行策略

  1. 按表分发策略

按表分发事务的基本思路是,如果两个事务更新不同的表,它们就可以并行。如果有跨表的事务,还是要把两张表放在一起考虑。
8b6976fedd6e644022d4026581fb8d76
可以看到,每个 worker 线程对应一个 hash 表,用于保存当前正在这个 worker 的“执行队列”里的事务所涉及的表。hash 表的 key 是“库名. 表名”,value 是一个数字,表示队列中有多少个事务修改这个表。
在有事务分配给 worker 时,事务里面涉及的表会被加到对应的 hash 表中。worker 执行完成后,这个表会被从 hash 表中去掉。
现在我们用事务 T 的分配流程,来看一下分配规则:

Ⅰ. 由于事务 T 中涉及修改表 t1,而 worker_1 队列中有事务在修改表 t1,事务 T 和队列中的某个事务要修改同一个表的数据,这种情况我们说事务 T 和 worker_1 是冲突的。
Ⅱ. 按照这个逻辑,顺序判断事务 T 和每个 worker 队列的冲突关系,会发现事务 T 跟 worker_2 也冲突。
Ⅲ. 事务 T 跟多于一个 worker 冲突,coordinator 线程就进入等待。
Ⅳ. 每个 worker 继续执行,同时修改 hash_table。假设 hash_table_2 里面涉及到修改表 t3 的事务先执行完成,就会从 hash_table_2 中把 db1.t3 这一项去掉。
Ⅴ. 这样 coordinator 会发现跟事务 T 冲突的 worker 只有 worker_1 了,因此就把它分配给 worker_1。
Ⅵ. coordinator 继续读下一个中转日志,继续分配事务。
也就是说,每个事务在分发的时候,跟所有 worker 的冲突关系包括以下三种情况:

Ⅰ. 如果跟所有 worker 都不冲突,coordinator 线程就会把这个事务分配给最空闲的 woker;
Ⅱ. 如果跟多于一个 worker 冲突,coordinator 线程就进入等待状态,直到和这个事务存在冲突关系的 worker 只剩下 1 个;
Ⅲ. 如果只跟一个 worker 冲突,coordinator 线程就会把这个事务分配给这个存在冲突关系的 worker。
这个按表分发的方案,在多个表负载均匀的场景里应用效果很好。但是,如果碰到热点表,比如所有的更新事务都会涉及到某一个表的时候,所有事务都会被分配到同一个 worker 中,就变成单线程复制了

  1. 按行分发策略

要解决热点表的并行复制问题,就需要一个按行并行复制的方案。按行复制的核心思路是:如果两个事务没有更新相同的行,它们在备库上可以并行执行。显然,这个模式要求 binlog 格式必须是 row。
按行复制和按表复制的数据结构差不多,也是为每个 worker,分配一个 hash 表。只是要实现按行分发,这时候的 key,就必须是“库名 + 表名 + 唯一键的值”,但是,这个“唯一键”只有主键 id 还是不够的,如果表中还存在唯一索引,还需要考虑唯一索引的键值

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);

f19916e27b8ff28e87ed3ad9f5473378
可以看到,这两个事务要更新的行的主键值不同,但是如果它们被分到不同的 worker,就有可能 session B 的语句先执行。这时候 id=1 的行的 a 的值还是 1,就会报唯一键冲突。所以当前 key 应该是“库名 + 表名 + 索引 a 的名字 +a 的值”。
比如,在上面这个例子中,我要在表 t1 上执行 update t1 set a=1 where id=2 语句,在 binlog 里面记录了整行的数据修改前各个字段的值,和修改后各个字段的值。
因此,coordinator 在解析这个语句的 binlog 的时候,这个事务的 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 的行。
相比于按表并行分发策略,按行并行策略在决定线程分发的时候,需要消耗更多的计算资源

  1. 两个方案的约束条件

Ⅰ. 要能够从 binlog 里面解析出表名、主键值和唯一索引的值。也就是说,主库的 binlog 格式必须是 row;
Ⅱ. 表必须有主键;
Ⅲ. 不能有外键。表上如果有外键,级联更新的行不会记录在 binlog 中,这样冲突检测就不准确。

  1. 按行分发的问题及退化过程

主要问题,发生于处理大事务时:
Ⅰ. 耗费内存。比如一个语句要删除 100 万行数据,这时候 hash 表就要记录 100 万个项。
Ⅱ. 耗费 CPU。解析 binlog,然后计算 hash 值,对于大事务,这个成本还是很高的。
所以实现这个策略的时候会设置一个阈值,单个事务如果超过设置的行数阈值(比如,如果单个事务更新的行数超过 10 万行),就暂时退化为单线程模式,退化过程的逻辑大概是这样的:
Ⅰ. coordinator 暂时先 hold 住这个事务;
Ⅱ. 等待所有 worker 都执行完成,变成空队列;
Ⅲ. coordinator 直接执行这个事务;
Ⅳ. 恢复并行模式。

MySQL 5.6 版本的并行复制策略

官方 MySQL5.6 版本,支持了并行复制,只是支持的粒度是按库并行,用于决定分发策略的 hash 表里,key 就是数据库名

这个策略的并行效果,取决于压力模型。如果在主库上有多个 DB,并且各个 DB 的压力均衡,使用这个策略的效果会很好。

相比于按表和按行分发,这个策略有两个优势:

  1. 构造 hash 值的时候很快,只需要库名;而且一个实例上 DB 数也不会很多,不会出现需要构造 100 万个项这种情况。
  2. 不要求 binlog 的格式。因为 statement 格式的 binlog 也可以很容易拿到库名。

MariaDB 的并行复制策略

MariaDB 的并行复制策略利用了 redo log 组提交 (group commit) 优化特性:

  1. 能够在同一组里提交的事务,一定不会修改同一行;
  2. 主库上可以并行执行的事务,备库上也一定是可以并行执行的。

处理过程:

  1. 在一组里面一起提交的事务,有一个相同的 commit_id,下一组就是 commit_id+1;
  2. commit_id 直接写到 binlog 里面;
  3. 传到备库应用的时候,相同 commit_id 的事务分发到多个 worker 执行;
  4. 这一组全部执行完成后,coordinator 再去取下一批。

MariaDB 的这个策略,目标是“模拟主库的并行模式”,但它并没有实现“真正的模拟主库并发度”这个目标,因为在主库上,一组事务在 commit 的时候,下一组事务是同时处于“执行中”状态的。

如图所示,假设了三组事务在主库的执行情况,你可以看到在 trx1、trx2 和 trx3 提交的时候,trx4、trx5 和 trx6 是在执行的。这样,在第一组事务提交完成的时候,下一组事务很快就会进入 commit 状态。
8fec5fb48d6095aecc80016826efbfc3

而按照 MariaDB 的并行复制策略,备库上的执行效果如图所示:
8ac3799c1ff2f9833619a1624ca3e622

可以看到,在备库上执行的时候,要等第一组事务完全执行完成后,第二组事务才能开始执行,这样系统的吞吐量就不够。另外,这个方案很容易被大事务拖后腿,需要等待大事务执行完成之后,下一组才会继续执行

MySQL 5.7 的并行复制策略
在 MariaDB 并行复制实现之后,官方的 MySQL5.7 版本也提供了类似的功能,由参数 slave-parallel-type 来控制并行复制策略:

  1. 配置为 DATABASE,表示使用 MySQL 5.6 版本的按库并行策略;
  2. 配置为 LOGICAL_CLOCK,表示的就是类似 MariaDB 的策略。不过,MySQL 5.7 这个策略,针对并行度做了优化。
    你可以先考虑这样一个问题:同时处于“执行状态”的所有事务,是不是可以并行?
    答案是,不能。因为,这里面可能有由于锁冲突而处于锁等待状态的事务。如果这些事务在备库上被分配到不同的 worker,就会出现备库跟主库不一致的情况
    MariaDB 策略的核心,是“所有处于 commit”状态的事务可以并行。事务处于 commit 状态,表示已经通过了锁冲突的检验了
    所以,也就是说通过锁冲突检验的事务是可以并行的。

5ae7d074c34bc5bd55c82781de670c28 (1)

而通过两阶段提交细化过程可以知道,只要能够到达 redo log prepare 阶段,就表示事务已经通过锁冲突的检验了。

因此,MySQL 5.7 并行复制策略的思想是:

  1. 同时处于 prepare 状态的事务,在备库执行时是可以并行的;
  2. 处于 prepare 状态的事务,与处于 commit 状态的事务之间,在备库执行时也是可以并行的。

binlog 的组提交的时候,有两个参数:

  1. binlog_group_commit_sync_delay 参数,表示延迟多少微秒后才调用 fsync;
  2. binlog_group_commit_sync_no_delay_count 参数,表示累积多少次以后才调用 fsync。

这两个参数是用于故意拉长 binlog 从 write 到 fsync 的时间,以此减少 binlog 的写盘次数。在 MySQL 5.7 的并行复制策略里,它们可以用来制造更多的“同时处于 prepare 阶段的事务”。这样就增加了备库复制的并行度。

也就是说,这两个参数,既可以“故意”让主库提交得慢些,又可以让备库执行得快些。在 MySQL 5.7 处理备库延迟的时候,可以考虑调整这两个参数值,来达到提升备库复制并发度的目的。

MySQL 5.7.22 的并行复制策略

在 MySQL 5.7.22 版本里,MySQL 增加了一个新的并行复制策略,基于 WRITESET 的并行复制。
相应地,新增了一个参数 binlog-transaction-dependency-tracking,用来控制是否启用这个新策略。这个参数的可选值有以下三种:

  1. COMMIT_ORDER,表示的就是前面介绍的,根据同时进入 prepare 和 commit 来判断是否可以并行的策略。
  2. WRITESET,表示的是对于事务涉及更新的每一行,计算出这一行的 hash 值,组成集合 writeset。如果两个事务没有操作相同的行,也就是说它们的 writeset 没有交集,就可以并行。
  3. WRITESET_SESSION,是在 WRITESET 的基础上多了一个约束,即在主库上同一个线程先后执行的两个事务,在备库执行的时候,要保证相同的先后顺序。

当然为了唯一标识,这个 hash 值是通过“库名 + 表名 + 索引名 + 值”计算出来的。如果一个表上除了有主键索引外,还有其他唯一索引,那么对于每个唯一索引,insert 语句对应的 writeset 就要多增加一个 hash 值。

这跟我们前面介绍的按行分发的策略是差不多的。不过,MySQL 官方的这个实现还是有很大的优势(毕竟是官方实现):

  1. writeset 是在主库生成后直接写入到 binlog 里面的,这样在备库执行的时候,不需要解析 binlog 内容(event 里的行数据),节省了很多计算量;
  2. 不需要把整个事务的 binlog 都扫一遍才能决定分发到哪个 worker,更省内存;
  3. 由于备库的分发策略不依赖于 binlog 内容,所以 binlog 是 statement 格式也是可以的。

因此,MySQL 5.7.22 的并行复制策略在通用性上还是有保证的。

当然,对于“表上没主键”和“外键约束”的场景,WRITESET 策略也是没法并行的,也会暂时退化为单线程模型。

@git-zjx git-zjx added this to MySQL实战45讲 in MySQL Oct 8, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
MySQL
MySQL实战45讲
1 participant
You can’t perform that action at this time.