MySQL高可用实践
上QQ阅读APP看书,第一时间看更新

第1章 异步复制

1.1 MySQL异步复制简介

这里所说的复制,就是将来自一个MySQL数据库服务器(主库)的数据复制到一个或多个MySQL数据库服务器(从库)。传统的MySQL复制提供了一种简单的Primary-Secondary(主-从)复制方法,默认情况下,复制是单向异步的。MySQL支持两种复制方式:基于行的复制和基于语句的复制。这两种方式都是通过在主库上记录二进制日志(binlog)和在从库重放中继日志(relay-log)的方式来实现异步的数据复制。二进制日志或中继日志中的记录被称为事件。所谓异步包含两层含义:一是主库二进制日志的写入操作与将其发送到从库的操作是异步进行的;二是从库获取与重放日志事件是异步进行的。这意味着,在同一个时间点从库上的数据更新可能落后于主库,并且无法保证主库和从库之间的延迟间隔。

复制主库而增加的系统开销主要体现在启用二进制日志带来的I/O,但是增加的开销并不大,MySQL官方文档中称开启二进制日志会产生1%的性能损耗。为了保证对历史事务的备份以及从介质失败中可以恢复过来,这点系统开销是非常必要的。除此之外,每个从库也会增加主库的一些负载(即系统开销),例如网络和I/O。当从库读取主库的二进制日志时,就会产生一定的I/O开销。如果从一个主库复制到多个从库,唤醒多个复制线程发送二进制日志内容的开销就会累加。不过,所有这些复制带来的额外系统开销相对于各种应用对MySQL服务器造成的高负载来说都是微不足道的。

1.1.1 复制的用途

复制的用途主要体现在以下五个方面:

1. 横向扩展

通过复制可以将读操作指向从库来获得更好的读扩展。所有写入和更新都在主库上进行,但读取可能发生在一个或多个从库上。在这种读写分离模型中,主库专用于更新,显然比同时进行读写操作会有更好的写性能。需要注意的是,写操作并不适合通过复制来扩展。在“一主多从”架构中,写操作会被执行多次,正如“木桶效应”,整个系统的写入性能取决于写入最慢的那部分操作。

2. 负载均衡

通过MySQL复制可以将读操作分布到多个服务器上,实现对读密集型应用的优化。对于小规模的应用,可以简单地对机器名进行硬编码或者使用DNS轮询(将一个机器名指向多个IP地址)。当然也可以使用复杂的方法,例如使用LVS网络负载均衡器等,就能够很好地将负载分配到不同的MySQL服务器上。

3. 提高数据安全性

提高数据安全性可以从两方面来理解:其一,因为数据被复制到从库,并且从库可以暂停复制过程,所以可以在从库上执行备份操作而不会影响对应的主库;其二,当主库出现问题时,还有从库的数据可以被访问。但是,对备份来说,复制仅仅是一项有意义的技术补充,它既不是备份,也不能够取代备份。例如,当用户误删了一个表,而且此操作已经在从库上被复制执行,这种情况下只能用备份来恢复。

4. 提升高可用性

复制可以帮助应用程序避免MySQL单点故障,一个包含复制且设计良好的故障切换系统能够显著缩短宕机的时间。

5. 滚动升级

比较普遍的做法是,使用一个高版本的MySQL作为从库,保证在升级全部数据库实例之前,数据的查询能够在从库上按预期执行。在测试没有问题后,将高版本的MySQL切换为主库,并将应用连接至该主库,然后重新搭建高版本的从库。

1.1.2 复制如何工作

如前所述,MySQL复制依赖二进制日志(binlog),想要理解复制的工作事项,就先要了解MySQL的二进制日志。

1. 二进制日志

二进制日志包含描述数据库更改的事件,如建表操作或对表数据的更改等。开启二进制日志有两个重要目的:

  • 用于复制。主库上的二进制日志提供了要发送到从库的数据更改记录。主库将其二进制日志中包含的事件发送到从库,从库执行这些事件以对其本地数据进行相同的更改。
  • 用于恢复。当出现介质错误,如磁盘故障时,数据恢复操作需要使用二进制日志。还原备份后,重新执行备份之后记录的二进制日志中的事件,最大限度地减少数据丢失。

不难看出,MySQL二进制日志所起的作用与Oracle的归档日志类似。二进制日志只记录更新数据的事件,不记录SELECT或SHOW等语句。通过设置log-bin系统变量来开启二进制日志,MySQL 8中这个系统变量默认是开启的。

二进制日志有STATEMENT、ROW、MIXED三种格式,通过binlog-format系统变量来设置:

  • STATEMENT格式,基于SQL语句的复制(Statement-Based Replication,SBR)。每一条会修改数据的SQL语句都会被记录到binlog中。这种格式的优点是不需要记录每行的数据变化,这样二进制日志会比较少,减少了磁盘I/O,提高了性能。缺点是在某些情况下会导致主库与从库中的数据不一致,例如last_insert_id()、now()等非确定性函数,以及用户自定义函数(User-Defined Function,UDF)等易出现问题。
  • ROW格式,基于行的复制(Row-Based Replication,RBR)。该格式不记录SQL语句的上下文信息,仅记录哪条数据被修改了,修改成了什么样子,能清楚地记录每一行数据的修改细节。这种格式的优点是不会出现某些特定情况下的存储过程、函数或触发器的调用和触发无法被正确复制的问题。缺点是通常会产生大量的日志,尤其像大表上执行alter table操作时会让日志暴涨。
  • MIXED格式,混合复制(Mixed-Based Replication,MBR)。它是STATEMENT和ROW这两种格式的混合体,默认使用STATEMENT格式保存二进制日志,对于STATEMENT格式无法正确复制的操作,会自动切换到基于ROW格式的复制操作,MySQL会根据执行的SQL语句选择日志保存方式。

MySQL 8默认使用ROW格式。二进制日志的存放位置最好设置到与MySQL数据目录不同的磁盘分区,以降低磁盘I/O的竞争,提升性能,并且在数据磁盘发生故障时还可以利用备份和二进制日志来恢复数据。

2. 复制步骤

总体来说,MySQL复制有五个步骤:

步骤01 在主库上把数据更改事件记录到二进制日志中。

步骤02 从库上的I/O线程向主库询问二进制日志中的事件。

步骤03 主库上的二进制日志转储(Binlog dump)线程向I/O线程发送二进制事件。

步骤04 从库上的I/O线程将二进制日志事件复制到自己的中继日志中。

步骤05 从库上的SQL线程读取中继日志中的事件,并将其重放到从库上。

图1-1详细描述了复制的细节。

第一步,是在主库上记录二进制日志。每次准备提交事务完成数据更新前,主库将数据更新的事件记录到二进制日志中。MySQL会按事务提交的顺序而非每条语句的执行顺序来记录二进制日志。在记录二进制日志后,主库会告诉存储引擎可以提交事务了。

第二步,从库将主库的二进制日志复制到其本地的中继日志中。首先,从库会启动一个工作线程,称为I/O线程。I/O线程与主库建立一个普通的客户端连接,然后在主库上启动一个特殊的二进制日志转储(Binlog dump)线程,它会读取主库上二进制日志中的事件,但不会对事件进行轮询。如果该线程追赶上了主库,它将进入睡眠状态,直到主库发送信号通知该线程有新的事件时才会被唤醒,从库I/O线程会将接收到的事件记录到中继日志中。

图1-1 复制如何工作

从库的SQL线程执行最后一步,该线程从中继日志中读取事件并在从库上执行,从而实现从库数据的更新。当SQL线程追赶I/O线程时,中继日志通常已经在系统缓存中,所以读取中继日志的开销很低。SQL线程执行的事件也可以通过log_slave_updates系统变量来决定是否写入其自己的二进制日志中,这可以用于级联复制的场景。

这种复制架构实现了获取事件和重放事件的解耦,允许这两个过程异步进行。也就是说I/O线程的执行能够独立于SQL线程的执行。但是,这种架构也限制了复制的过程,其中最重要的一点是在主库上并发更新的查询,到从库上通常只能串行化执行了,因为系统默认只有一个SQL线程来重放中继日志中的事件。在MySQL 5.6版本以后已经可以通过配置slave_parallel_workers等系统变量进行并行复制,在第4章讨论复制性能问题时会介绍并行复制的相关细节。

现在我们已经了解了MySQL复制是以二进制日志为基础,但是像InnoDB这样的事务引擎有自己的事务日志,如ib_logfile,这些事务日志通常被称为重做日志(redo log)。作为背景知识,简单介绍一下InnoDB重做日志的作用。

对InnoDB的任何修改操作都会首先在称为缓冲池(InnoDB Buffer Pool)的内存页面上进行,然后这样的页面将被标记为脏页,并被放到专门的刷新列表上,后续将由主线程(Master Thread)或专门“刷脏页”的线程阶段性地将这些页面写入磁盘。这样做的好处是避免每次的写库操作都要操作磁盘,从而导致大量的随机I/O操作,阶段性地“刷脏页”可以将多次对页面的修改合并成一次I/O操作,同时异步写入也降低了访问时延。然而,如果在脏页还未刷入到磁盘时服务器就非正常关闭了,那么这些修改操作将会丢失,如果写入操作正在进行,甚至会由于损坏数据文件导致数据库不可用。为了避免上述问题的发生,InnoDB将所有对页面的修改写入一个专门的文件,并在数据库启动时从此文件进行实例恢复,这个文件就是重做日志文件。每当有更新操作时,在数据页变更之前将操作写入重做日志,这样当发生掉电之类的情况时,系统可以在重启后继续工作。这就是所谓的预写日志(Write-Ahead Logging,WAL)。这种技术推迟了缓冲区页面的刷新,从而提升了数据库的吞吐量。同时由于重做日志的写操作是顺序I/O,相对于写数据文件的随机I/O要快得多。大多数数据库系统都采用类似的技术实现。

聪明如你可能已经有了这样的疑问,在复制中二进制日志和重做日志如何协同工作?假设InnoDB写完重做日志后,服务异常关闭了。主库能够根据重做日志恢复数据,但由于二进制日志没写入,会导致从库同步时少了这个事务么?或者反之,二进制日志写成功,而重做日志没有写完,是否会导致从库执行事务而主库不执行?这些情况会不会造成主从数据不一致的问题呢?解决这些问题是MySQL的核心需求,让我们从MySQL基本架构说起。图1-2是MySQL的逻辑架构图。

在图1-2中,最上层用于处理客户端连接、授权认证、安全,等等。第二层架构是MySQL服务器层。大多数MySQL的核心功能都在这一层,包括查询解析、分析、优化、缓存以及全部内置函数,所有跨存储引擎的功能,如存储过程、触发器、视图等都在这一层实现。不出所料,二进制日志也在这一层实现。第三层包含了存储引擎,负责MySQL中数据的存储和提取。服务器通过API与存储引擎进行通信,存储引擎只是简单地响应上层服务器的请求。显然InnoDB的重做日志在这一层实现。

图1-2 MySQL服务器逻辑架构图

由于MySQL的事务日志包含二进制日志和重做日志,当发生崩溃恢复时,MySQL主库通过重做日志进行恢复,而在主从复制的环境下,从库依据主节点的二进制日志进行数据同步。这样的架构对两种日志有两个基本要求:第一,保证二进制日志中存在的事务一定在重做日志中存在,也就是二进制日志里不会比重做日志里的事务多(可以少,因为重做日志里面记录的事务可能有部分没有提交,这些事务最终可能会被回滚);第二,两种日志中事务的顺序一致,这也是很重要的一点。假设两者记录的事务顺序不一致,那么会出现类似于主库事务执行的顺序是ta、tb、tc和td,但是二进制日志中记录的是ta、tc、tb和td,也就是被复制到从库后主从数据不一致了。为了达到上述两个基本要求,MySQL使用内部XA来实现。XA是eXtended Architecture的缩写,是X/Open分布式事务定义的事务中间件与数据库之间的接口规范,其核心是两阶段提交(Two Phase Commit,2PC)。

1.1.3 两阶段提交

在两阶段提交协议中一般分为事务管理器(协调者)和若干事务执行器(参与者)两种角色。在MySQL内部实现的两阶段提交中,二进制日志充当了协调者角色,由它来通知InnoDB执行准备、提交或回滚。从实现角度分析,事务提交由准备阶段和提交阶段构成。提交流程和代码框架分别如图1-3和图1-4所示。

图1-3 MySQL两阶段提交流程

图1-4 commit命令的MySQL代码框架

(1)先调用binglog_hton和innobase_hton的prepare方法完成第一阶段,binlog_hton的prepare方法实际上什么也没做,InnoDB的prepare持有prepare_commit_mutex,将重做日志刷磁盘,并将事务状态设为TRX_PREPARED。

(2)如果事务涉及的所有存储引擎的prepare都执行成功,则调用TC_LOG_BINLOG::log_xid将事务(STATEMENT格式或ROW格式)写到二进制日志中,此时,事务已经肯定要提交了。否则,调用ha_rollback_trans回滚事务,而事务实际上也不会写到二进制日志中。

(3)最后,调用引擎的commit完成事务的提交。实际上binlog_hton->commit什么也不会做(上一步已经将二进制日志写入磁盘),innobase_hton->commit则会清除回滚信息,向重做日志中写入COMMIT标记,释放prepare_commit_mutex,并将事务设为TRX_NOT_STARTED状态。

如果数据库系统发生崩溃,当重启数据库时会进行崩溃恢复操作。具体到代码层面,InnoDB在恢复的时候,不同状态的事务,会进行不同的处理:

  • 对于TRX_COMMITTED_IN_MEMORY的事务,清除回滚段后,将事务设为TRX_NOT_STARTED。
  • 对于TRX_NOT_STARTED的事务,表示事务已经提交,跳过。
  • 对于TRX_PREPARED的事务,要根据二进制日志来决定事务是否提交,暂时跳过。
  • 对于TRX_ACTIVE的事务,回滚。

简单来讲,当发生崩溃恢复时,数据库根据重做日志进行数据恢复,逐个查看每条重做条目的事务状态。根据图1-3的流程,如果已进行到TRX_NOT_STARTED阶段,也就是存储引擎commit阶段,那么说明重做日志和二进制日志是一致的,正常根据重做条目进行恢复即可。如果事务状态为TRX_ACTIVE,没写到二进制日志中,就直接回滚。如果事务状态为TRX_PREPARED,要分两种情况,先检查二进制日志是否已写入成功,如果没写入成功,那么就算是TRX_PREPARED状态也要回滚。如果写入成功了,那么就进行最后一步,调用存储引擎commit,更改事务状态为TRX_NOT_STARTED,也就是真正提交状态,可以用作数据恢复。

由此可见,MySQL是以二进制日志的写入与否作为事务提交成功与否的标志,通过这种方式让InnoDB重做日志和MySQL服务器的二进制日志中的事务状态保持一致。两阶段提交很好地保持了数据一致性和事务顺序性。

了解了所有这些技术细节后,当初的疑问自然也就有了答案。假设在准备阶段(阶段1)结束之后程序异常,此时没有写入二进制日志,则从库不会同步这个事务。主库上,崩溃恢复时重做日志中这个事务没有trx_commit,因此会被回滚。逻辑上主从库都不会执行这个事务。假设在提交阶段(阶段2)结束后程序异常,此时二进制日志已经写入,则从库会同步这个事务。主库上,根据重做日志能够正常恢复此事务。也就是说,若二进制日志写入完成,那么主从库都会正常提交事务,反之则主从库都回滚事务,都不会出现主从不一致的问题。

MySQL通过innodb_support_xa系统变量控制InnoDB是否支持XA事务的2PC,默认是TRUE。如果关闭,则InnoDB在prepare阶段就什么也不做,这可能会导致二进制日志的顺序与InnoDB提交的顺序不一致,继而导致在恢复时或者从库上产生不同的数据。在MySQL 8中,innodb_support_xa系统变量已被移除,也就是始终启用InnoDB对XA事务中两阶段提交的支持,就不再交由用户来选择了。

上述的MySQL两阶段提交流程并不是天衣无缝的,主从数据是否一致还与重做日志和二进制日志的写盘方式有关。innodb_flush_log_at_trx_commit和sync_binlog系统变量分别控制两者的落盘策略。

  • innodb_flush_log_at_trx_commit:有0、1、2共三个可选值。0表示每秒进行一次刷新,但是每次事务提交不进行任何操作。每秒调用fsync使数据落到磁盘,不过这里需要注意如果底层存储有高速缓存(Cache),比如RAID Cache,那么这时也不会真正落盘。由于一般RAID卡都带有备用电源,因此一般都认为此时数据是安全的。1代表每次事务提交都会进行刷新,这是最安全的模式;2表示每秒刷新,每次事务提交时不刷新,而是调用write将重做日志缓冲区中的内容刷到操作系统页面缓存中。从数据安全性和性能角度来比较这三种策略的优劣为:策略1,因为每次事务提交都是重做日志落盘,所以最安全,但是由于fsync的次数增多会导致性能下降比较严重;策略0,表示每秒刷新,每次事务提交都不进行任何操作,所以MySQL或操作系统崩溃时最多丢失一秒的事务;策略2,相对于策略0来说多了每次事务提交时的一个write操作,此时数据虽然没有落盘,但是只要操作系统没有崩溃,即使MySQL崩溃,那么事务也不会丢失。
  • sync_binlog:MySQL在提交事务时调用MYSQL_LOG::write执行写二进制日志的操作,并根据sync_binlog决定是否进行刷新。默认值是0,即不刷新,从而把控制权交给操作系统。如果设置为1,则每次提交事务都会进行一次磁盘刷新。

这两个参数不同值的组合会带来不同的效果。两者都设置为1,数据最安全,能保证主从一致,这也是MySQL 8的默认设置。innodb_flush_log_at_trx_commit设置为非1时,假设在二进制日志写入完成后系统崩溃,则可能出现这样的情况:从库能够执行事务,但主库中trx_prepare的日志没有被写入到重做日志中,导致主库不执行事务,就出现了主从不一致的情况。同理,若sync_binlog设置为非1时,则可能导致二进制日志丢失(如操作系统异常崩溃),从而与InnoDB层面的数据不一致,体现在复制上,从库可能丢失事务。在数据一致性要求很高的场景下,建议就使用全部设置为1的默认设置。