1 主备简介
客户端的读写都直接访问节点 A,而节点 B 是 A 的备库,只是将 A 的更新都同步过来,到本地执行。这样可以保持节点 B 和 A 的数据是相同的。(M-S 主备架构)
在状态 1 中,虽然节点 B 没有被直接访问,但是我依然建议你把节点 B(也就是备库)设置成只读(readonly)模式。这样做,有以下几个考虑:
- 有时候一些运营类的查询语句会被放到备库上去查,设置为只读可以防止误操作;
- 防止切换逻辑有 bug,比如切换过程中出现双写,造成主备不一致;
- 可以用 readonly 状态,来判断节点的角色。
注意 readonly 设置对超级 (super) 权限用户是无效的,而用于同步更新的线程,就拥有超级权限。所以,主备间的同步更新不会收到备库只读模式的影响
2 主备数据同步流程
图中画出的就是一个 update 语句在节点 A 执行,然后同步到节点 B 的完整流程图。可以看到:主库接收到客户端的更新请求后,执行内部事务的更新逻辑,同时写 binlog。备库 B 跟主库 A 之间维持了一个长连接。主库 A 内部有一个线程,专门用于服务备库 B 的这个长连接。
一个事务日志同步的完整过程是这样的:
-
在备库 B 上通过
change master命令,设置主库 A 的 IP、端口、用户名、密码,以及要从哪个位置开始请求 binlog,这个位置包含文件名和日志偏移量。 -
在备库 B 上执行
start slave命令,这时候备库会启动两个线程,就是图中的io_thread和sql_thread。io_thread 负责与主库建立连接。主库 A 校验完用户名、密码后,开始按照备库 B 传过来的位置,从本地读取 binlog,发给 B。备库 B 拿到 binlog 后,写到本地文件,称为中转日志relay log);sql_thread 读取中转日志,解析出日志里的命令,并执行。
3 主备延迟
3.1 简介
与数据同步有关的时间点主要包括以下三个:
- 主库 A 执行完成一个事务,写入 binlog,我们把这个时刻记为 T1;
- 之后传给备库 B,我们把备库 B 接收完这个 binlog 的时刻记为 T2;
- 备库 B 执行完成这个事务,我们把这个时刻记为 T3
所谓主备延迟,就是同一个事务,在备库执行完成的时间和主库执行完成的时间之间的差值,也就是 T3-T1。
在网络正常的时候,日志从主库传给备库所需的时间是很短的,即 T2-T1 的值是非常小的。也就是说,网络正常情况下,主备延迟的主要来源是备库接收完 binlog 和执行完这个事务之间的时间差。
3.2 延迟时长检查
在备库上执行show slave status命令,它的返回结果里面会显示 seconds_behind_master,用于表示当前备库延迟了多少秒。seconds_behind_master 的计算方法是这样的:
- 每个事务的 binlog 里面都有一个时间字段,用于记录主库上写入的时间;
- 备库取出当前正在执行的事务的时间字段的值,计算它与当前系统时间的差值,得到 seconds_behind_master。这个值的时间精度是秒。
3.3 原因
- 备库所在机器的性能要比主库所在的机器性能差
- 备库的压力大。备库上的查询耗费了大量的 CPU 资源,影响了同步速度,造成主备延迟。
- 大事务。因为主库上必须等事务执行完成才会写入 binlog,再传给备库。所以,如果一个主库上的语句执行 10 分钟,那这个事务很可能就会导致从库延迟 10 分钟。(大表DDL)
4 数据复制
4.1 演进之路
在官方的 5.6 版本之前,MySQL 只支持单线程复制,由此在主库并发高、TPS 高时就会出现严重的主备延迟问题。从单线程复制到最新版本的多线程复制,中间的演化经历了好几个版本。接下来,我就跟你说说 MySQL 多线程复制的演进过程。其实说到底,所有的多线程复制机制,都是要把只有一个线程的 sql_thread,拆成多个线程,也就是都符合下面的这个模型:
coordinator 就是原来的 sql_thread, 不过现在它不再直接更新数据了,只负责读取中转日志和分发事务。真正更新日志的,变成了 worker 线程。而 work 线程的个数,就是由参数 slave_parallel_workers决定的。coordinator 在分发的时候,需要满足以下这两个基本要求:
- 不能造成更新覆盖。这就要求更新同一行的两个事务,必须被分发到同一个 worker 中。
- 同一个事务不能被拆开,必须放到同一个 worker 中。
各个版本的多线程复制,都遵循了这两条基本原则。
4.2 按照数据表并行
按表分发事务的基本思路是,如果两个事务更新不同的表,它们就可以并行。因为数据是存储在表里的,所以按表分发,可以保证两个 worker 不会更新同一行。当然,如果有跨表的事务,还是要把两张表放在一起考虑的。按表分发的规则如下图所示:
每个 worker 线程对应一个 hash 表,用于保存当前正在这个 worker 的“执行队列”里的事务所涉及的表。hash 表的 key 是“库名. 表名”,value 是一个数字,表示队列中有多少个事务修改这个表。在有事务分配给 worker 时,事务里面涉及的表会被加到对应的 hash 表中。worker 执行完成后,这个表会被从 hash 表中去掉。
图中,hash_table_1 表示,现在 worker_1 的“待执行事务队列”里,有 4 个事务涉及到 db1.t1 表,有 1 个事务涉及到 db2.t2 表;hash_table_2 表示,现在 worker_2 中有一个事务会更新到表 t3 的数据。
4.2.1 跨表事务
针对跨表事务如何处理呢?假设在图中的情况下,coordinator 从中转日志中读入一个新事务 T,这个事务修改的行涉及到表 t1 和 t3。现在我们用事务 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 继续读下一个中转日志,继续分配事务。
4.2.2 事务与worker的冲突关系
- 如果跟所有 worker 都不冲突,coordinator 线程就会把这个事务分配给最空闲的 woker;
- 如果跟多于一个 worker 冲突,coordinator 线程就进入等待状态,直到和这个事务存在冲突关系的 worker 只剩下 1 个;
- 如果只跟一个 worker 冲突,coordinator 线程就会把这个事务分配给这个存在冲突关系的 worker。
4.2.3 缺点
如果碰到热点表,比如所有的更新事务都会涉及到某一个表的时候,所有事务都会被分配到同一个 worker 中,就变成单线程复制了。
4.3 按照数据库并行
官方 MySQL5.6 版本,支持了并行复制,只是支持的粒度是按库并行。用于决定分发策略的 hash 表里,key 就是数据库名。这个策略的并行效果,取决于压力模型。如果在主库上有多个 DB,并且各个 DB 的压力均衡,使用这个策略的效果会很好。但是,如果你的主库上的表都放在同一个 DB 里面,这个策略就没有效果了。
4.4 按照commit并行
4.4.1 MariaDB并行模式
MariaDB 的并行复制策略利用的是redo log 组提交 (group commit) 优化的特性:
- 能够在同一组里提交的事务,一定不会修改同一行;
- 主库上可以并行执行的事务,备库上也一定是可以并行执行的。
在实现上,MariaDB 是这么做的:
- 在一组里面一起提交的事务,有一个相同的 commit_id,下一组就是 commit_id+1;
- commit_id 直接写到 binlog 里面;
- 传到备库应用的时候,相同 commit_id 的事务分发到多个 worker 执行;
- 这一组全部执行完成后,coordinator 再去取下一批。
这个策略出来的时候是相当惊艳的。因为,之前业界的思路都是在“分析 binlog,并拆分到 worker”上。而 MariaDB 的这个策略,目标是模拟主库的并行模式。
4.4.2 缺点
这个策略有一个问题,它并没有实现“真正的模拟主库并发度”这个目标。在主库上,一组事务在 commit 的时候,下一组事务是同时处于“执行中”状态的。
如下图所示,假设了三组事务在主库的执行情况,你可以看到在 trx1、trx2 和 trx3 提交的时候,trx4、trx5 和 trx6 是在执行的。这样,在第一组事务提交完成的时候,下一组事务很快就会进入 commit 状态。
而按照 MariaDB 的并行复制策略,备库上的执行效果如下图所示。
可以看到,在备库上执行的时候,要等第一组事务完全执行完成后,第二组事务才能开始执行,这样系统的吞吐量就不够。
4.4.3 MySQL并行模式优化
在 MariaDB 并行复制实现之后,官方的 MySQL5.7 版本也提供了类似的功能,由参数slave-parallel-type来控制并行复制策略:
- 配置为
DATABASE,表示使用 MySQL 5.6 版本的按库并行策略; - 配置为
LOGICAL_CLOCK,表示的就是类似 MariaDB 的策略。不过,MySQL 5.7 这个策略,针对并行度做了优化。
MariaDB 这个策略的核心,是“所有处于 commit”状态的事务可以并行。事务处于 commit 状态,表示已经通过了锁冲突的检验了。
其实,不用等到 commit 阶段,只要能够到达 redo log prepare 阶段,就表示事务已经通过锁冲突的检验了。因此,MySQL 5.7 并行复制策略的思想是:
- 同时处于 prepare 状态的事务,在备库执行时是可以并行的;
- 处于 prepare 状态的事务,与处于 commit 状态的事务之间,在备库执行时也是可以并行的。
4.5 WRITESET并行复制
在 2018 年 4 月份发布的 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 值。
5 主备切换
5.1 简介
虚线箭头表示的是主备关系,也就是 A 和 A’互为主备, 从库 B、C、D 指向的是主库 A。一主多从的设置,一般用于读写分离,主库负责所有的写入和一部分读,其他的读请求则由从库分担。今天讨论的就是,在一主多从架构下,主库故障后的主备切换问题。
相比于一主一备的切换流程,一主多从结构在切换完成后,A’会成为新的主库,从库 B、C、D 也要改接到 A’。正是由于多了从库 B、C、D 重新指向的这个过程,所以主备切换的复杂性也相应增加了。
5.2 GTID
GTID 的全称是 Global Transaction Identifier,也就是全局事务 ID,是一个事务在提交的时候生成的,是这个事务的唯一标识。它由两部分组成,格式是:
GTID=server_uuid:gno
- server_uuid 是一个实例第一次启动时自动生成的,是一个全局唯一的值;
- gno 是一个整数,初始值是 1,每次提交事务的时候分配给这个事务,并加 1。
GTID 模式的启动也很简单,我们只需要在启动一个 MySQL 实例的时候,加上参数gtid_mode=on和 enforce_gtid_consistency=on就可以了。在 GTID 模式下,每个事务都会跟一个 GTID 一一对应。这个 GTID 有两种生成方式,而使用哪种方式取决于 session 变量 gtid_next 的值.
- 如果
gtid_next=automatic,代表使用默认值。这时,MySQL 就会把 server_uuid:gno 分配给这个事务。记录 binlog 的时候,先记录一行 SET @@SESSION.GTID_NEXT=‘server_uuid:gno’; 把这个 GTID 加入本实例的 GTID 集合。 - 如果 gtid_next 是一个指定的 GTID 的值,比如通过 set gtid_next='current_gtid’指定为 current_gtid,那么就有两种可能:如果 current_gtid 已经存在于实例的 GTID 集合中,接下来执行的这个事务会直接被系统忽略;如果 current_gtid 没有存在于实例的 GTID 集合中,就将这个 current_gtid 分配给接下来要执行的事务,也就是说系统不需要给这个事务生成新的 GTID,因此 gno 也不用加 1。
注意 一个 current_gtid 只能给一个事务使用。这个事务提交后,如果要执行下一个事务,就要执行 set 命令,把 gtid_next 设置成另外一个 gtid 或者 automatic。这样,每个 MySQL 实例都维护了一个 GTID 集合,用来对应“这个实例执行过的所有事务”。
5.3 基于位点的主备切换
当我们把节点 B 设置成节点 A’的从库的时候,需要执行一条change master命令:
CHANGE MASTER TO
MASTER_HOST=$host_name #主库 A’的 IP
MASTER_PORT=$port #端口
MASTER_USER=$user_name #用户名
MASTER_PASSWORD=$password #密码
MASTER_LOG_FILE=$master_log_name
#要从主库的 master_log_name 文件的 master_log_pos 这个位置的日志继续同步
MASTER_LOG_POS=$master_log_pos
考虑到切换过程中不能丢数据,所以我们找位点的时候,总是要找一个“稍微往前”的,然后再通过判断跳过那些在从库 B 上已经执行过的事务。操作繁琐,容易出错。
5.4 基于 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 的逻辑是这样的:
-
实例 B 指定主库 A’,基于主备协议建立连接。
-
实例 B 把 set_b 发给主库 A’。实例 A’算出
set_a 与 set_b 的差集,也就是所有存在于 set_a,但是不存在于 set_b 的 GTID 的集合,判断 A’本地是否包含了这个差集需要的所有 binlog 事务。如果不包含,表示 A’已经把实例 B 需要的 binlog 给删掉了,直接返回错误;如果确认全部包含,A’从自己的 binlog 文件里面,找出第一个不在 set_b 的事务,发给 B;之后就从这个事务开始,往后读文件,按顺序取 binlog 发给 B 去执行。
其实,这个逻辑里面包含了一个设计思想:在基于 GTID 的主备关系里,系统认为只要建立主备关系,就必须保证主库发给备库的日志是完整的。因此,如果实例 B 需要的日志已经不存在,A’就拒绝把日志发给 B。
5.5 GTID/位点切换异同点
这跟基于位点的主备协议不同。基于位点的协议,是由备库决定的,备库指定哪个位点,主库就发哪个位点,不做日志的完整性判断。
6 过期读
6.1 等主库位点方案
6.1.1 简介
select master_pos_wait(file, pos[, timeout]);
这条命令的逻辑如下:
- 它是在从库执行的;
- 参数 file 和 pos 指的是主库上的文件名和位置;
- timeout 可选,设置为正整数 N 表示这个函数最多等待 N 秒。
这个命令正常返回的结果是一个正整数 M,表示从命令开始执行,到应用完 file 和 pos 表示的 binlog 位置,执行了多少事务。
除了正常返回一个正整数 M 外,这条命令还会返回一些其他结果,包括:
- 如果执行期间,备库同步线程发生异常,则返回 NULL;
- 如果等待超过 N 秒,就返回 -1;
- 如果刚开始执行的时候,就发现已经执行过这个位置了,则返回 0。
6.1.2 案例分析
对于上图中先执行 trx1,再执行一个查询请求的逻辑,要保证能够查到正确的数据,我们可以使用这个逻辑:
- trx1 事务更新完成后,马上执行
show master status得到当前主库执行到的File和Position; - 选定一个从库执行查询语句;在从库上执行
select master_pos_wait(File, Position, 1); - 如果返回值是 >=0 的正整数,则在这个从库执行查询语句;否则,到主库执行查询语句。
因为从库的延迟时间不可控,不能无限等待,所以如果等待超时,就应该放弃,然后到主库去查。你可能会说,如果所有的从库都延迟超过 1 秒了,那查询压力不就都跑到主库上了吗?确实是这样。
6.2 GTID 方案
6.2.1 简介
select wait_for_executed_gtid_set(gtid_set, 1);
这条命令的逻辑是:
- 等待,直到这个库执行的事务中包含传入的 gtid_set,返回 0;
- 超时返回 1。
在前面等位点的方案中,我们执行完事务后,还要主动去主库执行 show master status。而MySQL 5.7.6版本开始,允许在执行完更新类事务后,把这个事务的 GTID 返回给客户端,这样等 GTID 的方案就可以减少一次查询。这时,等 GTID 的执行流程就变成了:
- trx1 事务更新完成后,从返回包直接获取这个事务的 GTID,记为 gtid1;
- 选定一个从库执行查询语句;在从库上执行
select wait_for_executed_gtid_set(gtid1, 1);如果返回值是 0,则在这个从库执行查询语句;否则,到主库执行查询语句。 跟等主库位点的方案一样,等待超时后是否直接到主库查询
FAQ
1 循环复制
业务逻辑在节点 A 上更新了一条语句,然后再把生成的 binlog 发给节点 B,节点 B 执行完这条更新语句后也会生成 binlog。那么,如果节点 A 同时是节点 B 的备库,相当于又把节点 B 新生成的 binlog 拿过来执行了一次,然后节点 A 和 B 间,会不断地循环执行这个更新语句,也就是循环复制了。这个要怎么解决呢?
- 规定两个库的 server id 必须不同,如果相同,则它们之间不能设定为主备关系;
- 一个备库接到 binlog 并在重放的过程中,生成与原 binlog 的 server id 相同的新的 binlog;
- 每个库在收到从自己的主库发过来的日志后,先判断 server id,如果跟自己的相同,表示这个日志是自己生成的,就直接丢弃这个日志。
按照这个逻辑,如果我们设置了双 M 结构,日志的执行流就会变成这样:
从节点 A 更新的事务,binlog 里面记的都是 A 的 server id;传到节点 B 执行一次以后,节点 B 生成的 binlog 的 server id 也是 A 的 server id;再传回给节点 A,A 判断到这个 server id 与自己的相同,就不会再处理这个日志。所以,死循环在这里就断掉了。
2 如果主备库机器的系统时间设置不一致,会不会导致主备延迟的值不准?
其实不会的。因为,备库连接到主库的时候,会通过执行SELECT UNIX_TIMESTAMP()函数来获得当前主库的系统时间。如果这时候发现主库的系统时间与自己不一致,备库在执行 seconds_behind_master 计算的时候会自动扣掉这个差值。