常见的主从架构模式有四种:
接下来的讨论都是针对最常见的一主多从架构。
主从架构中必须有一个主节点,以及一个或多个从节点,所有的数据都会先写入到主,接着其他从节点会复制主节点上的增量数据,从而保证数据的最终一致性,使用主从复制方案,可以进一步提升数据库的可用性和性能:
在主节点宕机或故障的情况下,从节点能自动切换成主节点的身份,从而继续对外提供服务。
提供数据备份的功能,当主节点的数据发生损坏时,从节点中依旧保存着完整数据。
可以基于主从实现读写分离,主节点负责处理写请求,从节点处理读请求,进一步提升性能。
但无论任何技术栈的主从架构,都会存在致命硬伤,同时也会存在些许问题需要解决:
硬伤:木桶效应,一个主从集群中所有节点的容量,受限于存储容量最低的哪台服务器。
数据一致性问题:由于同步复制数据的过程是基于网络传输完成的,所以存储延迟性。
脑裂问题:从节点会通过心跳机制,发送网络包来判断主机是否存活,网络故障情况下会产生多主。
MySQL 集群的主从复制过程梳理成 3 个阶段:
具体详细过程如下:
1.MySQL 主库在收到客户端提交事务的请求之后,会先更新数据;
2.数据写入完成后,再去写入 binlog,然后提交事务,返回客户端“操作成功”的响应;
3.主节点上有一条专门监听 binlog 的 log dump 线程;
4.当 log dump 线程监听到日志发生变更时,会通知从节点来拉取数据;
5.从节点有专门的 I/O 线程(io_thread)用于等待主节点的通知,当收到通知时会去请求一定范围的数据;
6.当从节点在主节点上请求到数据后,会将得到的数据写入到 relay-log 中继日志,然后返回给主库“复制成功”的响应;
7.从节点上有专门负责监听 relay-log 变更的线程(sql_thread),当日志出现变更时会开始工作;
8.中继日志出现变更后,会从中读取日志记录,然后回放 binlog 更新数据,实现主从数据一致性。
复制模式
同步复制
MySQL 主库提交事务的线程要等待所有从库的复制成功响应,才返回客户端结果。这种方式在实际项目中,基本上没法用,原因有两个:一是性能很差,因为要复制到所有节点才返回响应;二是可用性也很差,主库和所有从库任何一个数据库出问题,都会影响业务。
异步复制
是 MySQL 默认使用的复制模式,MySQL 主库提交事务的线程并不会等待 binlog 同步到各从库,就返回客户端结果。这种模式一旦主库宕机,数据就会发生丢失。
半同步复制
MySQL 5.7 版本之后增加的一种复制方式,介于两者之间,事务线程不用等待所有的从库复制成功响应,只要一部分复制成功响应回来就行,比如一主二从的集群,只要数据成功复制到任意一个从库上,主库的事务线程就可以返回给客户端。这种半同步复制的方式,兼顾了异步复制和同步复制的优点,即使出现主库宕机,至少还有一个从库有最新的数据,不存在数据丢失的风险。
增强式半同步复制
也被称为无损复制,也是 MySQL5.7 引入的。和之前传统的半同步复制区别在于:从 after-commit 变成了 after-sync ,如果将复制模式配置成半同步时,默认就会选用无损复制模式。
after-commit:主库在未收到从库的 ACK 之前,虽然不会给客户端返回写入成功,但本质上会提交事务,也就是主库中的其他事务是可以看见对应数据的,当此时出现宕机时,就会导致旧主上能查询出的数据,在新主上无法查询出来了。
after-sync:当主库未收到从库的 ACK 之前,也不会在主库上提交事务,也就是保证了主从节点的数据强一致性,解决了 after-commit 中存在的问题。但是相较于 after-commit,可能导致事务迟迟不提交,从而导致锁资源不释放和阻塞等待等性能问题。
延迟复制
当从库上的 I/O 线程,将主库的 binlog 请求回来后,从节点的 SQL 线程并不会立刻解析日志执行,而是等待一段时间后再解析日志执行,这个等待的时间可以配置。
延迟复制的好处是可以防止误删操作。缺点是会有很长一段时间的数据不一致,可能导致数据的丢失。一般用于仅作为备库的节点使用,不能进行读写分离。
并行复制
GTID复制
GTID,Global Transaction Identifier,用于唯一地标识一个事务。
GTID 由节点 UUID + 事务 ID 两部分组成,MySQL 在第一次启动时都会利用 UUID 随机生成一个 server_id,还会对每一个写事务都分配一个顺序递增的值作为事务 ID,GTID 的格式为 server_uuid:trx_id。
基于 GTID 的复制过程:
master 在更新数据时,会为每一个写事务分配一个全局的 GTID,并记录到 binlog 中。
slave 节点的 I/O 线程拉取数据时,会将读到的记录写到 relay-log 中,并设置 gtid_next 值。
slave 节点的 SQL 线程执行前,会读取 gtid_next 值得知接下来该解析哪条日志并执行。
slave 节点的 SQL 线程在执行时,会先比对自身的 binlog 日志中是否有对应的 GTID:
有:意味着该 GTID 对应的事务已经执行过了,slave 会自动忽略掉这条记录。
没有:SQL 解析该 GTID 对应的 relay-log 记录并执行,再将 GTID 记录到 binlog。
基于 GTID 的自动同步:发生主从切换时可以执行 change master to master_auto_position=1,会自动去新主库上寻找数据的同步点。
GTID 是基于事务来实现的,也就代表不支持事务的存储引擎无法使用这种机制。
组复制
GTID 复制是组复制得基础。组复制是指将一组并行执行的事务,全部放入到一个 GTID 中记录,后续从节点同步数据时,会一次性读取这一组事务解析并执行,即组提交。
组复制的 GTID 通过逗号分隔。
并行复制
并行复制是在组复制的基础上实现的。因为能够在同一时间内提交的事务,绝对是不存在锁冲突的,所以可以开启多条线程同时执行一个组中不同的事务。
并行复制能够在很大程度上提升从库复制数据的速度,也就是能够让从库的数据实时性提升。
在 MySQL5.7 中官方为这种机制命名为 enhanced multi-threaded slave,简称 MTS 机制,同时为了兼容 5.6 版本中的并行复制,又多加入了一个 slave-parallel-type 参数:
DATABASE:默认的并行复制模式,表示基于库级别来完成并行复制。
LOGICAL_CLOCK:表示基于组提交的方式来完成并行复制。
虽然 5.7 中的并行复制,在一定程度上解决了原有的从库延迟问题,但如果一个新的从节点加入集群时,因为要从头开始同步数据,这种并行复制的模式依旧存在效率问题,而到了 MySQL8.0 对于并行复制技术提出了真正的解决之道,也就是基于 writeset 的 MTS 技术。即多个事务之间,只要变更的数据记录没有重叠,也就是操作的数据没有冲突,无需在一个事务组内,也可以支持并发执行。
两阶段提交
2PC,two-phase commit protocol。
事务提交后,redo log 和 binlog 都要持久化到磁盘,但是这两个是独立的逻辑,可能出现半成功的状态,这样就造成两份日志之间的逻辑不一致。
如果在将 redo log 刷入到磁盘之后, MySQL 突然宕机了,而 binlog 还没有来得及写入。MySQL 重启后,通过 redo log 能将 Buffer Pool 中的相应数据恢复到新值,但是 binlog 里面没有记录这条更新语句,在主从架构中,binlog 会被复制到从库,由于 binlog 丢失了这条更新语句,从库的数据是旧值;
如果在将 binlog 刷入到磁盘之后, MySQL 突然宕机了,而 redo log 还没有来得及写入。由于 redo log 还没写,崩溃恢复以后这个事务无效,所以数据还是旧值;而 binlog 里面记录了这条更新语句,数据是新值;
在持久化 redo log 和 binlog 这两份日志的时候,如果出现半成功的状态,就会造成主从环境的数据不一致性。因为 redo log 影响主库的数据,binlog 影响从库的数据,所以 redo log 和 binlog 必须保持一致才能保证主从数据一致。
MySQL 为了避免出现两份日志之间的逻辑不一致的问题,使用了「两阶段提交」来解决,两阶段提交其实是分布式事务一致性协议,它可以保证多个逻辑操作要不全部成功,要不全部失败,不会出现半成功的状态。
当客户端执行 commit 语句或者在自动提交的情况下,MySQL 内部开启一个 XA 事务,分两阶段来完成 XA 事务的提交,分别是准备阶段和提交阶段。
prepare 阶段:将 XID(内部 XA 事务的 ID) 写入到 redo log,同时将 redo log 对应的事务状态设置为 prepare,然后将 redo log 持久化到磁盘(innodb_flush_log_at_trx_commit = 1 的作用);
commit 阶段:把 XID 写入到 binlog,然后将 binlog 持久化到磁盘(sync_binlog = 1 的作用),接着调用引擎的提交事务接口,将 redo log 状态设置为 commit,此时该状态并不需要持久化到磁盘,只需要 write 到文件系统的 page cache 中就够了,因为只要 binlog 写磁盘成功,就算 redo log 的状态还是 prepare 也没有关系,一样会被认为事务已经执行成功;
不管是时刻 A(redo log 已经写入磁盘, binlog 还没写入磁盘),还是时刻 B (redo log 和 binlog 都已经写入磁盘,还没写入 commit 标识)崩溃,此时的 redo log 都处于 prepare 状态。
在 MySQL 重启后会按顺序扫描 redo log 文件,碰到处于 prepare 状态的 redo log,就拿着 redo log 中的 XID 去 binlog 查看是否存在此 XID:
如果 binlog 中没有当前内部 XA 事务的 XID,说明 redolog 完成刷盘,但是 binlog 还没有刷盘,则回滚事务。对应时刻 A 崩溃恢复的情况。
如果 binlog 中有当前内部 XA 事务的 XID,说明 redolog 和 binlog 都已经完成了刷盘,则提交事务。对应时刻 B 崩溃恢复的情况。
可以看到,对于处于 prepare 阶段的 redo log,即可以提交事务,也可以回滚事务,这取决于是否能在 binlog 中查找到与 redo log 相同的 XID,如果有就提交事务,如果没有就回滚事务。这样就可以保证 redo log 和 binlog 这两份日志的一致性了。
两阶段提交是以 binlog 写成功为事务提交成功的标识。
事务没提交的时候,redo log 也是可能被持久化到磁盘。但是 binlog 必须在事务提交之后,才可以持久化到磁盘。
组提交
两阶段提交虽然保证了两个日志文件的数据一致性,但是性能很差,主要有两个方面的影响:
磁盘 I/O 次数高:对于“双1”配置,每个事务提交都会进行两次 fsync(刷盘),一次是 redo log 刷盘,另一次是 binlog 刷盘。
锁竞争激烈:两阶段提交虽然能够保证「单事务」两个日志的内容一致,但在「多事务」的情况下,却不能保证两者的提交顺序一致,因此,在两阶段提交的流程基础上,还需要加一个锁来保证提交的原子性,从而保证多事务的情况下,两个日志的提交顺序一致。
MySQL 引入了 binlog 组提交(group commit)机制,当有多个事务提交的时候,会将多个 binlog 刷盘操作合并成一个,从而减少磁盘 I/O 的次数。
MySQL 5.7 开始有 redo log 组提交。
引入了组提交机制后,prepare 阶段不变,只针对 commit 阶段,将 commit 阶段拆分为三个过程:
flush 阶段:多个事务按进入的顺序将 binlog 从 cache 写入文件(不刷盘);
sync 阶段:对 binlog 文件做 fsync 操作(多个事务的 binlog 合并一次刷盘);
commit 阶段:各个事务按顺序做 InnoDB commit 操作;
上面的每个阶段都有一个队列,每个阶段有锁进行保护,因此保证了事务写入的顺序,第一个进入队列的事务会成为 leader,领导所在队列的所有事务,全权负责整队的操作,完成后通知队内其他事务操作结束。
同一时刻只允许一组事务提交。