Loading...
墨滴

欢少の不忘初心

2021/09/28  阅读:53  主题:极简黑

主库挂了,从库谋权篡位那点事

微信搜【欢少的成长之路】

介绍

大家好,我是Leo。一个Java后端的程序员。之前我们介绍了MySQL如何保证高可用的相关技术点,比如可靠性优先策略,可用性优先策略,主从延迟,主从延迟的来源以及解决方案。今天我们继续上一篇文章遗留的问题作一个延伸,今天介绍一下从库的延迟问题!以及主库宕机,从库的抉择

思路

根据读者和用户的反馈,画了一个写作思路图。通过此图可以更好的分析出当前文章的写作知识点。可以更快的帮助读者在最短时间内判断是否为有效文章!

从库延迟

如上图所示,这个是主从库的数据同步流程图。masterA是主库,slaveB是从库。从库的延迟我们就要从他的并行复制能力说起了。上半部分的图片中是主库的流程,start便是客户端请求数据库的压力。下半部分便是从库接收到主库的binlog日志后,然后同步数据的流程图了。

从图中可以看出,sql_thread 代表着备库上的执行中转日志的关键。dump_thread 代表着主从库连接的纽带。由箭头的粗细也可以看出主从库各自的承受压力的大小。倘若从库采用单线程,那么造成的后果就是,同步数据的能力将远远跟不上主库的消费能力。如果要聊多线程的话,那我们有必要了解一下MySQL的发展历史!

在官方的 5.6 版本之前,MySQL 只支持单线程复制,由此在主库并发高、TPS 高时就会出现严重的主备延迟问题。

多线程的演变

说到底,多线程的复制机制也就是在从库的 sql_thread 这个地方做了一些多开的操作。如下图所示

coordinator 就是 sql_thread 。采用多线程之后,这里的 coordinator 不再直接更新数据了。而是专门用于读取数据,由后面的 workder 线程进行数据的同步工作。worker线程的个数由参数 slave_parallel_workers 决定的。

以前只有一个sql_thread 处理,现在被分到了多个线程上以前那套处理方案肯定不行了。下面我们采用排除法进行解释每一种可能!

是否可以用轮询的方式分发给各个worker?

这个肯定是不行的,因为在执行事务语句的时候肯定是有先后的,而分到多个线程的话,无法保证线程的先后执行顺序,由于CPU的调度策略很可能事务2比事务1先执行。

  • 如果两个事务更新一行,那么就发出现数据不一致的情况,比如修改的事务1结果是5,事务2的结果是10。有可能顺序乱了之后最后的值应该是5的,反而变成了10
  • 如果两个事务更新不同的行,不影响真实的业务。(风险极高,不建议使用,不过这种方案都是错误的!)

同一个事务的多个更新语句,能不能分给不同的 worker 来执行呢?

这个肯定也是不行的,一个事务如果更新了表vip和commodity中的各一行,如果两个更新语句被分到了两个worker线程的话,虽然主从数据是一致的。但是如果执行完一个之后有一个查询进来了,这样就破坏了事务逻辑的隔离性问题。

coordinator分发要求

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

大概的流程整理完了,是否都掌握了呢?

版本并行复制策略对比

按表分发策略5.5

这个按表分发策略上面我们提到过。就是 如果两个事务更新不同的行,不影响真实的业务。这里的不同的是,如果两个事务更新不同的表,他们可以并行,因为数据是存在表里的,可以保证worker线程不会更新同一行。如下图,就是按表分发的规则图

img
img

可以看到,按表分发的策略中。每个worker线程对应一个hash表,用于保存当前正在这个worker的执行队列。key值就代表库名+表名。value就代表当前队列中有多少个事务修改这个表。hash_table_1 表示,现在 worker_1 的“待执行事务队列”里,有 4 个事务涉及到 db1.t1 表,有 1 个事务涉及到 db2.t2 表;hash_table_2 表示,现在 worker_2 中有一个事务会更新到表 t3 的数据。

在有事务分配给 worker 时,事务里面涉及的表会被加到对应的 hash 表中。worker 执行完成后,这个表会被从 hash 表中去掉。

案例 假设 coordinator从中转日志读入一个新事物T,这个事务修改的行涉及到表1和表3。下面我们看一下这种情况的分配规则。

  • 由于事务 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 都不冲突,coordinator 线程就会把这个事务分配给最空闲的 woker;
  • 如果跟多于一个 worker 冲突,coordinator 线程就进入等待状态,直到和这个事务存在冲突关系的 worker 只剩下 1 个;
  • 如果只跟一个 worker 冲突,coordinator 线程就会把这个事务分配给这个存在冲突关系的 worker。

小结

这个按表分发的方案,在多个表负载均匀的场景里应用效果很好。但是,如果碰到热点表,比如所有的更新事务都会涉及到某一个表的时候,所有事务都会被分配到同一个 worker 中,就变成单线程复制了。

按行分发策略5.5

为了解决热点表之后单线程复制的问题,按行分发策略诞生了!

按行复制的核心思路是:如果两个事务没有更新相同的行,它们在备库上可以并行执行。显然,这个模式要求 binlog 格式必须是 row。

这时候,我们判断一个事务 T 和 worker 是否冲突,用的就规则就不是“修改同一个表”,而是“修改同一行”。这里跟按表是相同的思路。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);
img
img

执行上述事务,可以看到更新的主键值不同,但是如果他们被分到不同worker,就有可能sessionB的语句先执行。导致所以你a的值重复出现导致冲突。

综上所述:按行分发策略的key应该是 库+表+索引a的名字+a的值 这是与按表不同的地方

相比于按表并行分发策略,按行并行策略在决定线程分发的时候,需要消耗更多的计算资源。你可能也发现了,这两个方案其实都有一些约束条件:

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

对比按表分发和按行分发这两个方案的话,按行分发策略的并行度更高

如果要操作大事务的话,按行分发策略

  • 比较耗费内存,如果一个表要删除100万数据,这个hash表就要记录100万个项。
  • 耗费CPU,解析binlog,计算hash值,对于大事务成本是很高的。

MySQL老一贯的套路又出来了。这种情况往往会设置一个阈值,通过前面几篇的学习,我相信你应该很多地方的阈值把控了吧!

单个事务如果超过设置的行数阈值(比如,如果单个事务更新的行数超过 10 万行),就暂时退化为单线程模式,退化过程的逻辑大概是这样的:

  • coordinator 暂时先 hold 住这个事务;
  • 等待所有 worker 都执行完成,变成空队列;
  • coordinator 直接执行这个事务;
  • 恢复并行模式。

5.6版本

支持了并行复制,只是支持的粒度是按库并行。套用上面的公式的话就是。key值为库名称。value为执行的数量。

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

相对于按表和按行策略,这里的优势是

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

理论上你可以创建不同的 DB,把相同热度的表均匀分到这些不同的 DB 中,强行使用这个策略。不过据我所知,由于需要特地移动数据,这个策略用得并不多。

MariaDB版本

在之前我们介绍了如何保证数据不丢的文章中,介绍了redi log的组提交这个观点。而MariaDB的并行复制策略就是利用的这个特性。

特性

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

实现

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

缺点

  1. 它并没有实现“真正的模拟主库并发度”这个目标。
  2. 在主库上,一组事务在 commit 的时候,下一组事务是同时处于“执行中”状态的。这样就会发生要等第一组事务完全执行完成后,第二组事务才能开始执行,这样系统的吞吐量是不够的。
  3. 这个方案很容易被大事务拖后腿。如果一组中其中一个事务是大事务,另外两个是小事务,那么这一组的commit就要等这个大事务提交完成之后,才能切换下一组。这就造成了资源的过度浪费。

优点

不过即使如此,这个策略仍然是一个很漂亮的创新。因为,它对原系统的改造非常少,实现也很优雅。

5.7版本

介绍5.7版本的时候,我们可以介绍一个参数slave-parallel-type 。这个参数控制并行复制策略。

  1. 配置为 DATABASE,表示使用 MySQL 5.6 版本的按库并行策略;
  2. 配置为 LOGICAL_CLOCK,表示的就是类似 MariaDB 的策略。不过,MySQL 5.7 这个策略,针对并行度做了优化。这个优化的思路也很有趣儿。

5.7版本与MariaDB有着很大的不同。

  • 5.7版本中同时处于执行状态的所有事务不可以并行,因为这里面可能有由于锁冲突而处于锁等待状态的事务。如果这些事务被分到了不同的workder上,就会出现主从不一致的情况
  • MarialDB这个策略的核心是处于所有commit的事务可以并行。事务处于commit状态说明已经通过了锁冲突的检验了。
img
img

如上图所示,这是之前文章介绍的两阶段提交的流程图。不用等到 commit 阶段,只要能够到达 redo log prepare 阶段,就表示事务已经通过锁冲突的检验了。

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

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

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 处理备库延迟的时候,可以考虑调整这两个参数值,来达到提升备库复制并发度的目的。

5.7.22版本

相对5.7版本的改动中,增加了一个新的并行复制策略,基于writeset的并行复制

新增了一个参数 binlog-transaction-dependency-tracking,用来控制是否启用这个新策略。这个参数的可选值有以下三种。

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

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

优点

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

因此,MySQL 5.7.22 的并行复制策略在通用性上还是有保证的。当然,对于“表上没主键”和“外键约束”的场景,WRITESET 策略也是没法并行的,也会暂时退化为单线程模型。

一主多从

上述介绍了从库的多线程复制策略。通过对上述文章的理解。现在介绍一主多从的知识,应该就好懂很多了吧。因为这里多个从库涉及到数据同步问题。数据如何同步就是上面所介绍的了。

img
img

图中的虚线箭头就表示主从关系,也就是A和A‘。从库B,C,D指向的是主库A。一主多从的设置,一般用于读写分离,主库负责所有的写入和一部分读,其他的读请求则由从库分担。

今天我们讨论就是主库挂了,从库将何去何从!

基于位点的主从切换

当把节点 B 设置成 A’ 从库的时候,需要执行一条 change master 命令

CHANGE MASTER TO 
MASTER_HOST=$host_name
MASTER_PORT=$port 
MASTER_USER=$user_name 
MASTER_PASSWORD=$password 
MASTER_LOG_FILE=$master_log_name 
MASTER_LOG_POS=$master_log_pos  
  1. MASTER_HOST 代表IP
  2. MASTER_PORT 代表端口
  3. MASTER_USER 代表用户名
  4. MASTER_PASSWORD 代表密码
  5. MASTER_LOG_FILE 代表日志文件
  6. MASTER_LOG_POS 代表当前的日志偏移量

在进行切换的时候肯定避免不了要找点。节点 B 是 A 的从库,本地记录的也是 A 的位点。但是相同的日志,A 的位点和 A’的位点是不同的。因此,从库 B 要切换的时候,就需要先经过“找同步位点”这个逻辑。这个点很难获取到,只能取一个大概的位置,下面我们介绍一下

考虑到切换过程中不能丢数据,所以我们找位点的时候,总是要找一个“稍微往前”的,然后再通过判断跳过那些在从库 B 上已经执行过的事务。

  1. 等待新主库 A’把中转日志(relay log)全部同步完成;
  2. 在 A’上执行 show master status 命令,得到当前 A’上最新的 File 和 Position;
  3. 取原主库 A 故障的时刻 T;
  4. 用 mysqlbinlog 工具解析 A’的 File,得到 T 时刻的位点。
mysqlbinlog File --stop-datetime=T --start-datetime=T
img
img

图中,end_log_pos 后面的值“123”,表示的就是 A’这个实例,在 T 时刻写入新的 binlog 的位置。然后,我们就可以把 123 这个值作为 $master_log_pos ,用在节点 B 的 change master 命令里。但是这个值是不准确的。

  1. 首先从库B上,同步了binlog,所以数据是存在的
  2. 新主库A‘ 上,同步了binlog,数据也是存在的
  3. 在进行主从切换执行指令的时候change master 由于上文的位置是在123。就会把123位置的值放入到binlog再同步到从库B上

这时候从库B就会报错啦,id冲突!

这种情况,我们主要有两种方式 主动跳过事务 跳过指定的错误

主动跳过事务

这个就是在主从切换时候的方法。这种方法的思路就是,每次切换的时候都会提示id冲突这个错误。那么我们就直接跳过切换后id冲突这类错误。 写法如下:

set global sql_slave_skip_counter=1;
start slave;

因为切换过程中,可能会不止重复执行一个事务,所以我们需要在从库 B 刚开始接到新主库 A’时,持续观察,每次碰到这些错误就停下来,执行一次跳过命令,直到不再出现停下来的情况,以此来跳过可能涉及的所有事务。

跳过指定错误

通过设置 slave_skip_errors 参数,直接设置跳过指定的错误。

在执行主备切换时,有这么两类错误,是经常会遇到的:

  1. 1062 错误是插入数据时唯一键冲突;
  2. 1032 错误是删除数据时找不到行。

因此,我们可以把 slave_skip_errors 设置为 “1032,1062”,这样中间碰到这两个错误时就直接跳过。

这个背景是,我们很清楚在主备切换过程中,直接跳过 1032 和 1062 这两类错误是无损的,所以才可以这么设置 slave_skip_errors 参数。等到主备间的同步关系建立完成,并稳定执行一段时间之后,我们还需要把这个参数设置为空,以免之后真的出现了主从数据不一致,也跳过了。

基于 GTID 的主从切换

GTID是啥

通过 sql_slave_skip_counter 跳过事务和通过 slave_skip_errors 忽略错误的方法,虽然都最终可以建立从库 B 和新主库 A’的主备关系,但这两种操作都很复杂,而且容易出错。所以,MySQL 5.6 版本引入了 GTID,彻底解决了这个困难。

GTID 的全称是 Global Transaction Identifier,也就是全局事务 ID,是一个事务在提交的时候生成的,是这个事务的唯一标识。它由两部分组成,格式是:

GTID=server_uuid:gno
  • server_uuid 是一个实例第一次启动时自动生成的,是一个全局唯一的值;
  • gno 是一个整数,初始值是 1,每次提交事务的时候分配给这个事务,并加 1。

启动方式

我们只需要在启动一个 MySQL 实例的时候,加上参数 gtid_mode=on 和 enforce_gtid_consistency=on 就可以了。

在 GTID 模式下,每个事务都会跟一个 GTID 一一对应。这个 GTID 有两种生成方式,而使用哪种方式取决于 session 变量 gtid_next 的值。

  1. 如果 gtid_next=automatic,代表使用默认值。这时,MySQL 就会把 server_uuid:gno 分配给这个事务。a. 记录 binlog 的时候,先记录一行 SET @@SESSION.GTID_NEXT=‘server_uuid:gno’;b. 把这个 GTID 加入本实例的 GTID 集合。
  2. 如果 gtid_next 是一个指定的 GTID 的值,比如通过 set gtid_next='current_gtid’指定为 current_gtid,那么就有两种可能:a. 如果 current_gtid 已经存在于实例的 GTID 集合中,接下来执行的这个事务会直接被系统忽略;b. 如果 current_gtid 没有存在于实例的 GTID 集合中,就将这个 current_gtid 分配给接下来要执行的事务,也就是说系统不需要给这个事务生成新的 GTID,因此 gno 也不用加 1。

一个 current_gtid 只能给一个事务使用。这个事务提交后,如果要执行下一个事务,就要执行 set 命令,把 gtid_next 设置成另外一个 gtid 或者 automatic。

这样,每个 MySQL 实例都维护了一个 GTID 集合,用来对应“这个实例执行过的所有事务”。

主从切换

理解了GTID的概念,下面我们基于GTID做一些主从切换的流程介绍。

从库B要设置主库为 A’ 语法如下

CHANGE MASTER TO 
MASTER_HOST=$host_name 
MASTER_PORT=$port 
MASTER_USER=$user_name 
MASTER_PASSWORD=$password 
master_auto_position=1 

其中,master_auto_position=1 就表示这个主备关系使用的是 GTID 协议。可以看到,前面让我们头疼不已的 MASTER_LOG_FILE 和 MASTER_LOG_POS 参数,已经不需要指定了。

我们把现在这个时刻,实例 A’的 GTID 集合记为 set_a,实例 B 的 GTID 集合记为 set_b。接下来,我们就看看现在的主备切换逻辑。

我们在实例 B 上执行 start slave 命令,取 binlog 的逻辑是这样的:

  1. 实力B指定主库A‘,基于主从协议建立连接
  2. 实力把set_b发给主库A’
  3. 实例 A’算出 set_a 与 set_b 的差集,也就是所有存在于 set_a,但是不存在于 set_b 的 GTID 的集合,判断 A’本地是否包含了这个差集需要的所有 binlog 事务。a. 如果不包含,表示 A’已经把实例 B 需要的 binlog 给删掉了,直接返回错误;b. 如果确认全部包含,A’从自己的 binlog 文件里面,找出第一个不在 set_b 的事务,发给 B;
  4. 之后就从这个事务开始,往后读文件,按顺序取 binlog 发给 B 去执行。

其实,这个逻辑里面包含了一个设计思想:在基于 GTID 的主备关系里,系统认为只要建立主备关系,就必须保证主库发给备库的日志是完整的。因此,如果实例 B 需要的日志已经不存在,A’就拒绝把日志发给 B。

这跟基于位点的主备协议不同。基于位点的协议,是由备库决定的,备库指定哪个位点,主库就发哪个位点,不做日志的完整性判断。

基于上面的介绍,我们再来看看引入 GTID 后,一主多从的切换场景下,主备切换是如何实现的。

由于不需要找位点了,所以从库 B、C、D 只需要分别执行 change master 命令指向实例 A’即可。

其实,严谨地说,主备切换不是不需要找位点了,而是找位点这个工作,在实例 A’内部就已经自动完成了。但由于这个工作是自动的,所以对 HA 系统的开发人员来说,非常友好。

之后这个系统就由新主库 A’写入,主库 A’的自己生成的 binlog 中的 GTID 集合格式是:server_uuid_of_A’:1-M。

如果之前从库 B 的 GTID 集合格式是 server_uuid_of_A:1-N, 那么切换之后 GTID 集合的格式就变成了 server_uuid_of_A:1-N, server_uuid_of_A’:1-M。当然,主库 A’之前也是 A 的备库,因此主库 A’和从库 B 的 GTID 集合是一样的。这就达到了我们预期。

GTID 和 在线 DDL

这样操作的话,数据库里面是加了索引,但是 binlog 并没有记录下这一个更新,是不是会导致数据和日志不一致?

假设,这两个互为主备关系的库还是实例 X 和实例 Y,且当前主库是 X,并且都打开了 GTID 模式。这时的主备切换流程可以变成下面这样:

  1. 在实例 X 上执行 stop slave。
  2. 在实例 Y 上执行 DDL 语句。注意,这里并不需要关闭 binlog。
  3. 执行完成后,查出这个 DDL 语句对应的 GTID,并记为 server_uuid_of_Y:gno。
  4. 到实例 X 上执行以下语句序列:
set GTID_NEXT="server_uuid_of_Y:gno";
begin;
commit;
set gtid_next=automatic;
start slave;

这样做的目的在于,既可以让实例 Y 的更新有 binlog 记录,同时也可以确保不会在实例 X 上执行这条更新。

总结

今天大概介绍了MySQL的每个版本对于多线程复制策略的演变流程。通过多个版本的对比可以发现最终MySQL选择的分发策略的一步步的强大之处。

我相信通过这篇文章,你对MySQL的了解应该更加透彻了吧。比如我们的第二篇文章在讲述更新语句的时候就介绍过二阶段提交了。通过一步步提升,这里的二阶段提交应该更加健壮了吧!

这里举一个两阶段提交的文章深化吧

介绍完基础的 从库见的并行复制策略。就开始介绍一主多从的相关知识了,这里介绍了主库挂了之后从库应该何去何从。

”何去何从“: 这里的解决方案就是基于位点操作。这种方式又介绍了两种方式处理。但是这种方案不好,一个是不精确,另一点就是操作起来比较复杂。于是就引出了GTID操作。从概念到实战。

有任何不懂的地方,微信关注公众号【欢少的成长之路】 我们下期不见不散 下期介绍一下读写分离有哪些坑

- END -

欢少の不忘初心

2021/09/28  阅读:53  主题:极简黑

作者介绍

欢少の不忘初心