MySQL之Redo Log 与 BinLog
Redo Log的意义
持久性有两层含义:
- 已提交的事务的所有修改一定都是没有丢失的,持久化的;
- 未提交的事务的所有修改一定都是失效的,未持久化的
其中1是通过Redo Log保证的,2是通过undo log保证的。当然undo log的正确性也是Redo Log保证的。因此我们说,redo log 保证了事务的持久性,undo log 保证了事务的一致性,redo log 和 undo log 共同保证了事务的原子性。Redo Log只在MySQL崩溃后使用,如果MySQL没有发生崩溃,是正常关闭的,那用不到Redo Log
Write Ahead Log && Force Log at Commit
由于事务一般会修改很多地方的数据,为了保证持久性,最简单的办法是事务每次提交都将数据的修改更新到磁盘,由于事务很可能会修改不连续Page的内容,这样事务提交时会产生大量的随机IO,影响事务提交的性能。为了取得更好的读写性能,InnoDB会将热点磁盘数据缓存在内存中(InnoDB Buffer Pool),事务读时优先从Buffer Pool中读,如果Buffer Pool没有便将Page从磁盘加载到Buffer Pool,事务修改Page之前需要先将修改的内容write(write指Linux API)到Redo Log中(Write Ahead Log),然后修改Buffer Pool中的Page,事务提交时不用将自己修改过的Buffer Pool中的脏页更新到磁盘,而是fsync(fsync指Linux API) Redo Log到磁盘(Force Log at Commit),由于Redo Log是顺序写的,这样在保证已提交事务持久性的前提下,提高了数据库的读写性能。Redo Log将事务提交时对磁盘的随机IO变成顺序IO,并通过group commit进一步减少刷盘次数,提高事务commit时的性能。同时innoDB会通过一个后台周期线程将 Buffer Pool中的脏块更新到磁盘,这个后台线程会对脏块进行组合批量更新,使得脏块刷盘更加顺序化。在持久化数据文件前,需要保证之前的redo日志已经写到磁盘。
为什么Redo Log需要保证幂等性
除了checkpoint会将Buffer Pool中的脏页写到硬盘外,当Buffer Pool内存不够时,也可能会将脏页写到硬盘以腾出内存空间。当MySQL崩溃后,用户的标准SOP是基于innoDB最新的checkpoint去重放Redo Log文件(Redo Log文件是一整段连续的Redo Log),在恢复过程中,可能出现Redo Log文件中的某些Redo Log的修改其实已经落盘,因此innoDB的恢复程序需要确保这些Redo Log即便出现在Redo Log文件中,也不会有任何问题。要么innoDB能找到办法不应用这些已经落盘的Redo Log(目前是这么做的,通过Page Header中的FIL_PAGE_LSN),要么保证即便应用这些Redo Log也不会有问题。
Redo Log格式
innoDB中所有的修改操作都需要记录Redo Log,比如创建page会生成一条类型为MLOG_COMP_PAGE_CREATE的redo log记录, 对各种类型Page的修改会产生Index Page REDO,Undo Page REDO等。对Space表空间文件的修改,如MLOG_FILE_CREATE,MLOG_FILE_DELETE,MLOG_FILE_RENAME分别记录对一个Space文件的创建,删除以及重命名。还有少数的几个REDO类型不涉及具体的数据修改,只是为了记录一些额外的逻辑信息,比如后面介绍的MLOG_MULTI_REC_END就是为了标识一个MTR的Redo Log组的结束。
虽然redo log有很多类型的记录,其记录格式均是统一的,记录中包括记录的类型、表空间ID、页号和日志内容。通过表空间ID+页号即可唯一定位到发生修改的页。
Physical Logging VS Logical Logging
Undo log 采用的是Logical Logging,记录的是行记录的修改。Undo log中不会记录修改发生的物理位置信息(没有表空间ID和Page Number),只会记录逻辑位置信息(行记录的主键),需要通过聚簇索引去定位某个行记录,找到修改发生的位置。 https://www.cnblogs.com/zoo-keeper/articles/16110267.html
与Logical Logging相对的是Physical Logging,记录的是page中的修改。数据量小是Logical Logging的优点,而幂等以及基于Page的并发恢复带来的恢复速度快是Physical Logging的优点。 对于一些复杂的操作,InnoDB的Redo Log采取了一种称为Physiological Logging的方式,来兼得二者的优势。以Page为单位,但在Page内以Logical的方式记录,比如后面的MLOG_REC_UPDATE_IN_PLACE类型的REDO,通过表空间ID + Page Number + Record Offset 可直接定位到发生修改的物理位置,然后在页内采用Logical Logging的方式记录需要修改的Field以及修改后的Value。
为什么Redo Log不能像undo log,用来回滚事务?
不管是Physical Logging,还是采用Physiological Logging格式(比如MLOG_REC_UPDATE_IN_PLACE类型)的 Redo Log大部分只记录修改后的值,不像undo log采用逻辑日志的方式,记录字段修改前和修改后的值。如果没有记录修改前的值,就没法将记录回滚到修改之前的状态,因此Redo Log不能做回滚。
简单类型-Physical Logging
有时候我们仅仅只是修改某个页中的若干个字节,且该修改并不会影响到其它页中的数据。这个时候即可通过简单类型记录来记录所做的修改。即在日志内容中记录页面偏移量来确定修改页内位置和修改的数据内容即可,如下图所示
具体地,根据修改的数据内容的字节数可细分为以下类型的日志记录
MLOG_1BYTE、MLOG_2BYTE、MLOG_4BYTE、MLOG_8BYTE:分别表示在某页面指定偏移量的位置写入1、2、4、8个字节的数据
MLOG_WRITE_STRING:在某页面指定偏移量的位置写入不定长的数据,数据的长度(字节数)记录在页面偏移量后
可以看到,采用Physical Logging方式的简单类型的Redo记录天然就是幂等的,比如MLOG_WRITE_STRING 类似于调用memset,多次调用memset与调用一次效果一样,并且不要求整个Page的数据都是完整正确的,即便Page中的数据是不正确的,调用memset后,memset修改部分的数据就是正确的。
复杂类型-Physiological Logging
对于有些数据库操作而言,例如向数据表插入一条新的用户记录。一方面,其可能导致页分裂并通过调整来维护该B+树;另一方面,对于新记录所在的数据页而言,其修改操作也远不止仅仅添加一条新的用户记录那么简单(还需维护Page Header的统计信息、Page Directory的槽信息、页内记录间的单向链表)。一个数据库操作对Page的修改可能会非常多。在此种情况下,如果是将该操作的多处修改均使用上面提到的简单类型的redo log日志进行记录,那么很可能多条redo log记录占用的空间比一个页都大;而如果将对该页中 需修改的第一个字节 到 需修改的最后一个字节 视为一个整体的话,使用一条redo log记录进行记录的话。也是比较浪费空间的,因为这中间还有大部分数据其实是无需修改的。故目前主流数据库采用了"physical to a page, logical within a page"的思路, 尽可能将Logocal logging 和 Physical logging 的优点结合在一起。针对不同的数据库操作,InnoDB提供了各自类型的redo log记录,即所谓的复杂类型。常见地有:
MLOG_REC_INSERT、MLOG_REC_UPDATE_IN_PLACE、MLOG_REC_DELETE:分别表示Index Page中插入、修改、删除一条非紧凑行格式(Redundant)的记录
MLOG_REC_CLUST_DELETE_MARK = 10 /** Mark clustered index record deleted,逻辑删除,不是物理删除 /
MLOG_REC_SEC_DELETE_MARK = 11 /* Mark secondary index record deleted */
MLOG_COMP_REC_INSERT、MLOG_COMP_REC_DELETE:分别表示 插入、删除一条紧凑行格式(Compact、Dynamic、Compressed)的记录
MLOG_COMP_LIST_START_DELETE、MLOG_COMP_LIST_END_DELETE:分别用于表示批量删除紧凑行格式的记录时的起始记录、结束记录
MLOG_PAGE_REORGANIZE : 表示对Page进行合并和整理,比如某个Page中删除的行记录过多,可以和相邻的Page合并
对于这些复杂类型的redo log记录而言,其只会在Redo Log中记录进行相关的数据库操作时必要的数据。当MySQL在崩溃恢复时会将该redo log记录的日志内容作为参数,通过调用相关函数实现插入、删除等数据库操作。至于页内数据(例如Page Header的统计信息、Page Directory的槽信息等等)将会在相关函数执行过程中进行调整、修改。因此,可以将复杂类型的redo log视为逻辑日志
这里以MLOG_REC_UPDATE_IN_PLACE为例来看看其中具体的内容:
其中,Type就是MLOG_REC_UPDATE_IN_PLACE类型,Space ID表示表空间ID,Page Number表示页号,可唯一标识一个Page页。前三项是所有Redo记录通用的头信息。后面的是MLOG_REC_UPDATE_IN_PLACE类型独有的,其中Record Offset表示修改的行记录在Page中的位置偏移,Update Field Count说明行记录里有几个Field要修改,紧接着对每个Field给出了Field编号(Field Number),数据长度(Field Data Length)以及数据(Filed Data)。
MLOG_COMP_REC_INSERT 的 redo 格式,注意记录的offset是逻辑上,上一条记录在页内的偏移
MLOG_REC_DELETE 里面只有一个offset信息,没有保存每个列的值
由于Physiological Logging的方式采用了物理Page中的逻辑记法,这会导致两个问题:
-
需要基于正确的Page状态上重放REDO。
对于Physiological Logging的Redo Log,其重放恢复是通过调用相关函数实现的,函数执行过程中可能会用到Page中的其他数据(比如insert类型的Redo Log需要修改Page的directory),如果Page的其他数据有问题,那函数执行后的结果将是未知的。因此重放REDO必须基于正确的Page状态。然而InnoDB默认的Page大小是16KB,是大于文件系统能保证原子的4KB大小的,因此可能出现Page内容成功一半的情况,导致整个Page处于不完整的状态。InnoDB中采用了Double Write Buffer的方式来通过写两次的方式保证恢复出来的Page一定处于正确的状态。这部分会在之后介绍Buffer Pool的时候详细介绍。 -
需要保证REDO重放的幂等。
Physiological Logging的方式由于采用了Logical Logging,不像Physical Logging天然地具备幂等性,具体表现为如果恢复出来的Page已经应用过某些Redo了,如果再重新应用这些Redo可能Page的状态就错误了,比如多次执行同一个insert类型的Redo可能会插入多条重复数据而不是只插入一条,因此需要额外的机制来实现幂等。为此,InnoDB给每个REDO记录一个全局唯一递增的标号LSN。Page在修改时,会将对应的REDO记录的LSN记录在Page上(FIL_PAGE_LSN字段),这样恢复重放REDO时,如果Redo的LSN小于等于FIL_PAGE_LSN,说明这个redo的修改已经落盘可以跳过,从而实现Redo Log的幂等。由于只应用需要应用的Redo,再加上Redo Log可以基于page并发恢复,可以提高Redo Log重放恢复的速度
从MTR到Redo Log Buffer
Redo Log的产生:MTR(Mini-Transaction)
事务中的一条SQL语句可能会产生多条redo log记录。比如插入操作在发生页分裂时会产生多条redo log记录,如果恢复过程只恢复了部分,会导致B+树的性质被破坏掉。我们将对底层页中的一次原子操作称为Mini-Transaction(MTR) 。一条SQL语句的执行过程可能被划分为多个MTR,每个MTR可以含有若干条redo log记录。对于一个MTR中的redo日志,要么全部进行恢复,要么一条也不恢复。一个MTR包含的一组redo log记录,是恢复时的最小执行单元。
为了实现将MTR内的多条redo log记录作为一个整体进行恢复。MTR内的多条redo记录在Redo Log文件中必须物理连续,且会在MTR内的最后一条redo log记录后追加一条类型为MLOG_MULTI_REC_END的redo log记录。与我们前面所介绍的各种类型的redo log记录不同的是,该记录只含有type字段,无其他组成部分。在恢复过程中,直到解析了该类型的redo log记录时,才认为解析了完整的一组 MTR的redo log记录;否则将直接丢弃之前解析的redo log记录。特别地,如果某个需要保证原子性操作只生成了一条redo log记录,即该MTR内只含一条redo log记录时,其不是通过在后面追加类型为MLOG_MULTI_REC_END的redo log记录作为组标识的。因为type字段虽然占用1个字节的空间,但其只使用了7个比特位用于表示redo log记录的类型。故其可以通过type字段中未使用的1个比特位作为组标识,即当该比特位为1时,表示该记录是组内唯一的一条redo log记录。
http://mysql.taobao.org/monthly/2015/05/01/
在修改或读数据时,一般是通过mtr来控制对索引树和page的加锁,比如当修改索引下的某个Page时,MTR需要持有索引的X锁,PAGE上的X锁。事务需要原子操作时,调用mtr_start生成一个mtr,mtr中会维护一个动态增长的m_log,这是一个动态分配的内存空间,可以看作MTR的buf,将这个原子操作产生的所有REDO先写到这个m_log中,当原子操作结束后,调用mtr_commit将m_log中的数据整体拷贝到InnoDB的 Redo Log Buffer,将修改的脏页加到flush list上,同时更新脏页控制块中的LSN信息。从MySQL 8.0开始,设计了一套无锁的写Redo Log Buffer机制,其核心思路是允许不同的mtr,同时并发地写Log Buffer的不同位置。不同的mtr会首先调用log_buffer_reserve函数,这个函数里会用自己的REDO长度,原子地对Log Buffer的全局偏移log.sn做fetch_add,得到自己在Log Buffer中独享的空间。之后不同mtr并行的将自己的m_log中的数据拷贝到各自独享的空间内。
Redo Log Buffer
MySQL服务在启动后会向OS申请一块连续的内存空间将其作为Redo Log Buffer,并将其分为若干个连续的Redo Log Block。磁盘是块设备,读写一个扇区(Block)是原子操作,InnoDB中也用Block的概念来读写数据。一个Redo Log Block的长度OS_FILE_LOG_BLOCK_SIZE等于磁盘扇区的大小512B,由于log block大小等于磁盘扇区大小,因此重做日志的写入可以保证原子性,不需要double write buffer。
Log Block中除了存放REDO数据外还需要一些额外的信息,包括12字节的Block Header:前4字节中Flush Flag占用最高位bit,标识一次IO的第一个Block,剩下的31个个bit是Block的唯一编号;之后是2字节的数据长度,取值在[12,508];紧接着2字节的First Record Offset用来指向Block中第一个MTR起始处的偏移量(LSN),如果一个MTR的日志横跨多个block,只设置最后一个block,这个值的存在使得我们对任何一个Block都可以找到一个合法的REDO开始位置;最后的4字节Checkpoint Number记录写Block时的next_checkpoint_number,用来发现文件的循环使用,这个会在文件层详细讲解。Block末尾是4字节的Block Tailer,记录当前Block的Checksum,通过这个值,读取Log时可以检查Block数据有没有被完整写完。
Block中剩余的中间498个字节用于存放MTR中的Redo Log记录。当一个事务的一个MTR生成了该组全部的redo log记录后,需要将该组全部的redo log记录整体拷贝到Redo Log Buffer中。MTR写入Redo Log Buffer时,是顺序使用Redo Log Buffer中各Block的,即先使用前面的Block再使用后面的Block。由于Log Block的长度固定,而REDO长度不定,因此可能一个Block中有多个REDO,也可能一个REDO被拆分到多个Block中;另外,由于存在多个事务的并发执行,因此可能会出现如下图所示的交替写入的情况,并且如果事务2先提交,会将事务1的Redo Log也一起刷盘
当MTR拷贝到Redo Log Buffer时,会为每个Redo Log生成LSN(Log sequence number)。LSN表示已经写入到Redo Log Buffer中的日志总量。由于MTR中的一组redo log只是写到Log Block的body当中。在计算LSN的增量时,不仅需要包含MTR中的redo log量(即redo log的字节数),还需要包含实际使用的log block header、log block trailer部分的字节数。
Redo Log日志文件
最终REDO会被写入到REDO日志文件中,以ib_logfile0、ib_logfile1...命名,这些日志文件又被称为log group,为了避免创建文件及初始化空间带来的开销,InooDB的Redo文件会顺序循环使用。多个文件首尾相连顺序写入REDO内容,形成了一个环形写入的结构,但是覆盖写入的前提是要确定哪个位置点是可以覆盖写的,哪些位置是不能覆盖写的,这个就是check point的工作了。每个文件以Block为单位划分,长度512字节,与Redo Log Buffer中Block长度一致。每个文件的开头固定预留4个Block来记录一些额外的信息,其中第一个Block称为Header Block,之后的3个Block在0号文件上用来存储Checkpoint信息,而在其他文件上留空,剩下的Block存储Redo Log Buffer中的Log Block。
对于Header Block(共512字节),4字节的LOG_HEADER_FORMAT字段记录Redo Log的版本,不同版本的 Redo Log会有REDO类型的增减,这个信息是8.0开始才加入的;LOG_HEADER_PAD1:该属性使用4个字节,用于填充字节、无实际用途。 8字节的LOG_HEADER_START_LSN标识该日志文件开始的LSN值,即文件内偏移量为2048字节(4个Block)处对应的LSN值,通过这个信息可以将文件的offset与对应的lsn对应起来;32字节的LOG_HEADER_CREATOR 标识该日志文件的创建者信息,正常情况下会记录MySQL的版本。Not Used区域未使用,占用460个字节。LOG_BLOCK_CHECKSUM使用4个字节,标识该Block的校验和。
对于checkpoint 1、checkpoint 2这两个Block,每个block 512字节,其内部结构完全一样,这两个Block会在打Checkpiont的时候交替使用,避免写Checkpoint过程中的崩溃导致没有可用的Checkpoint。
LOG_CHECKPOINT_NO:该属性使用8个字节,标识checkpoint操作的序号,通过比较这个值可以判断哪个是最新的Checkpiont记录
LOG_CHECKPOINT_LSN:该属性使用8个字节,标识checkpoint操作打的checkpoint_lsn。恢复时从这个位置开始重放后边的Redo Log
LOG_CHECKPOINT_OFFSET:该属性使用8个字节,标识LOG_CHECKPOINT_LSN对应的Redo Log在日志文件组中的偏移量,恢复时可以通过这个offset在日志文件组中直接找到第一个需要恢复的Redo Log
LOG_CHECKPOINT_LOG_BUF_SIZE:该属性使用8个字节,用于标识在checkpoint操作中对应的Redo Log Buffer的大小,这个值目前在恢复过程并没有使用。
Not Used:未使用,占用476个字节
LOG_BLOCK_CHECKSUM:该属性使用4个字节,标识该Block的校验和
从Redo Log Buffer到Redo Log文件
从Redo Log Buffer写到Redo Log文件是按Redo Log Buffer的顺序来写的,且写完后不会再删除redo log文件中已经写的内容。
下图中,逻辑REDO是MTR产生的原始Redo Log,逻辑REDO按固定大小的Block组织,并添加Block的头尾信息形成物理REDO,在Redo Log Buffer中以lsn索引,这些Block又会放到循环使用的磁盘文件空间中的某一位置:
当MTR拷贝到Redo Log Buffer时,如果Redo Log Buffer使用率高,可能需要用户线程同步将Redo Log Buffer写入到磁盘,如果Redo Log Buffer使用率不高,则由后台线程将将Redo Log Buffer写入到磁盘。Log Buffer中的REDO数据需要先写入操作系统的Page Cache(页缓存),InnoDB中有单独的log_writer来做这件事情。这里有个问题,由于Log Buffer中的数据是不同mtr并发写入的,这个过程中Log Buffer是有空洞的,因此log_writer需要感知当前Log Buffer中连续日志的末尾,将连续日志通过pwrite系统调用写入操作系统Page Cache。log_writer完成write操作后会通知log_flusher线程,log_flusher线程会调用fsync将REDO刷盘,至此完成了REDO完整的写入过程。
具体可以参考 https://jinglingwang.cn/archives/mysql-redolog
http://mysql.taobao.org/monthly/2015/05/01/
Redo Log Buffer刷新到磁盘的规则是:
- 事务提交时(由innodb_flush_log_at_trx_commit控制)
- Redo Log Buffer使用率超过50%时(只write,不fsync)
- 后台线程每秒刷新一次(write + fsync)
- checkpoint 时(刷脏需要确保这些脏页对应的Redo Log已经fsync到磁盘)
- MySQL服务正常关闭时(write + fsync)
这里需要注意的是,即便事务没有提交,其redo log可能已经持久化到磁盘,数据库崩溃恢复时需要通过undo log来回滚未提交事务的修改
innodb_flush_log_at_trx_commit
由于fsync是阻塞的,innodb_flush_log_at_trx_commit控制事务提交时刷新缓冲的策略:
- 0:事务提交时不write,也不fsync。由mysql的main_thread每隔一秒将重做日志缓存write and fsync到日志文件。
该模式下MySQL性能最好,但安全性最差, MySQL崩溃时可能导致一秒内事务的Redo Log丢失; - 1:事务提交时write and fsync。该模式为系统默认。
该模式下MySQL性能最差,但安全性最好。 - 2:事务提交时只write,不fsync。由mysql的main_thread每隔一秒调用fsync。
该模式下MySQL性能和安全性都介于中间,与参数0不同的是:如果MySQL崩溃但操作系统没有崩溃,并不会导致事务丢失。只有当操作系统崩溃时(操作系统崩溃时MySQL一定也崩溃),才可能导致一秒内事务的Redo Log丢失;
LSN
LSN值的变化趋势是单调递增的,在 innodb 中有以下几种 LSN:
-
Log Sequence Number
已经写入到Redo Log Buffer中的日志总量。任意一个redo log都有唯一的LSN值与之对应。由于MTR中的一组redo log只是写到Redo Log Buffer的log block body当中。在计算LSN的增长量时,不仅需要包含MTR产生的redo log量(即redo log的字节数),还需要包含在写入该MTR时实际使用的log block header、log block trailer部分的字节数 -
Log flushed up to
已经刷新到磁盘的 redo log 的总量 -
FIL_PAGE_LSN
含义和Page控制块的newest_modification类似,存在于每个数据页文件头(File Header),表示当前数据页最新被修改的LSN(log serial number of page's latest log record),可以看作数据页的版本号,redo log幂等性依赖此字段。事务修改page之前需要先生成Redo Log,这样每个Redo Log会获得LSN,然后将Redo Log拷贝到redo log buffer中,然后修改page时将对应Redo Log的LSN更新到FIL_PAGE_LEN(为了保证FIL_PAGE_LEN是递增的,个人感觉多个事务修改相同page时需要加锁)。刷脏时,脏页的FIL_PAGE_LSN也会刷新到磁盘对应的数据页中。 -
Last checkpoint at
类似checkpoint_lsn,上次checkpoint结束时,Flush链表中最后一个页所对应的oldest_modification属性值。这个参数的意思是,页 LSN 小于该 LSN 的数据页都已经刷入了磁盘(但不代表大于该 LSN 的页都没有刷入,redo log 的幂等性确保了重复恢复的一致性),该参数会保存两份交替写入,避免了因介质失败而导致无法找到可用的 checkpoint。当数据恢复时,只需要应用 checkpoint 之后的Redo Log(只需应用 LSN大于 Last checkpoint at的Redo Log),且对于某一页,只需要应用 FIL_PAGE_LSN 之后的Redo Log,这样加快了恢复的速度。而且 redo log 中小于 checkpoint_lsn 的部分可以写入新的数据,循环利用,节省空间。 -
Pages flushed up to
当前Flush链表中最后一个页(即最早被修改)所对应的oldest_modification属性值。这个值不会小于 Last checkpoint at,checkpoint操作刚结束时,这个值等于Last checkpoint at。如果这个值比Last checkpoint at大,说明Buffer pool有刷脏,但还没发生checkpoint。
LSN的计算示例图(最开始的LSN是8704):

Innodb Buffer Pool
http://mysql.taobao.org/monthly/2017/05/01/
MySQL操作任何一个数据页面都需要读到Buffer pool进行才会进行操作。所以任何一个读写请求都需要从Buffer pool来获取所需页面。Buffer Pool中存储了热点索引数据,Buffer Pool是以Page(默认16K)为单位通过下面的LRU链表进行管理的。Buffer Pool的大小是由参数 innodb_buffer_pool_size确定的,一般建议设置成可用物理内存的60%~80%。一个稳定服务的线上系统,要保证响应时间符合要求的话,Buffer Pool命中率要在99%以上。
Free Page、Clean Page、Dirty Page
-
Free Page(空闲页)
此Page 未被使用,位于 Free 链表 -
Clean Page(干净页)
此Page 已被使用,但是页面未发生修改,只位于LRU 链表。 -
Dirty Page(脏页)
- 此Page的页面发生修改,内存中的数据和磁盘上的数据已经不一致。脏页同时存在于LRU 链表和Flush 链表。
- 脏页刷盘的前提条件是该脏页对应的 redo log 已经刷盘完成,判断准则是该脏页newest_modification 之前的redo log已经落盘。理论上未提交事务的Redo log如果已经落盘,其修改的脏页也可以刷盘。当一个事务最后回滚时,并不会把它之前已经写到Redo Log buffer中(可能还已经落盘)的Redo Log删除,而是通过undo log回滚事务之前所有得修改操作,这一步会产生很多Redo Log,并最终提交(因为需要调用fsync)
- 当脏页刷盘后,对应的Redo Log就没有用了
为什么脏页刷盘的前提条件是该脏页对应的 redo log 已经刷盘完成?
对于未提交事务,如果其Redo Log只是在Redo Log Buffer中,还没有刷盘,但其脏页已经刷盘,那崩溃恢复时由于没有这个事务的Redo Log,也就没有这个事务的undo log,那就不能回滚这个事务的修改,但这个事务的修改却已经在数据页了,等于是这个事务已经部分提交并持久化了。
LRU 链表、Flush 链表、Free 链表
- Free 链表存放的是空闲页面,Buffer Pool初始化时会预先申请整个空间的内存,初始化后Buffer Pool中的Page都在Free链表中。
- Buffer Pool有一个page_hash的hash table链表,通过它,使用space_id和page_no就能快速找到LRU List中的Page,而不用线性遍历LRU List去查找,提升Page的访问效率。
- 在执行SQL的过程中,事务首先从LRU链表中查找需要的Page,如果没有找到,则从Free 链表获取 Free Page,然后从磁盘读取数据到Free Page中,此时这个Free Page变成Clean Page,然后加入到LRU链表中。如果从Free 链表获取失败,则从LRU链表中淘汰末尾的Page并删除(淘汰有几轮,第一轮是淘汰Clean Page,如果没有找到,第二轮是淘汰Dirty Page),如果淘汰的Page是脏页,需要同步刷盘,并从Flush链表中删除(这个过程刷盘的Page一般很少,不会推进checkpoint_lsn)。从LRU链表淘汰出去的Page会变成Free page重新加入Free 链表,然后从Free链表重新获取进行上述过程。
- 事务从LRU链表找到需要的Page后,如果对Page做了修改,该Page会变成Dirty Page,同时将该脏页的控制块加入Flush链表,控制块中有两个属性:oldest_modification、newest_modification,分别表示该页被缓存到Buffer Pool后第一次修改该脏页的MTR开始的LSN值、被缓存到Buffer Pool中每次修改该页面的MTR结束的LSN。具体地,如果该脏页的控制块不在flush链表中则从头部插入到flush链表中,并向该控制块的oldest_modification、newest_modification属性写入该修改操作MTR开始、结束时对应的LSN;如果该脏页的控制块已经在flush链表中,只需更新newest_modification为每次修改的MTR结束时的LSN。可以看到,Flush链表是按照控制块的oldest_modification(第一次修改该Page的LSN)进行降序排序的,值大的在头部(最近修改),值小的在尾部(最早修改)。
LRU 算法
LRU List采用非常精细的LRU淘汰策略来管理Page,LRU List被划分为Young和Old两个部分,Young区保存的是“真正的热数据”,Old区保存的是冷数据和刚从数据文件中读取出来的数据。
- 用户SQL和预取操作(read-ahead operation)可以产生read操作,会从磁盘数据文件“read” Page到LRU List( 如果未能在page_hash找到),read操作读取的Page会先添加到Old区的头部(midpoint insertion strategy)
- 只有用户SQL会产生Access操作,Access操作读取的Page此时一定在LRU list:
- 如果该Page在Old区,使用完后会把它添加到Young区的链表头部
- 如果该Page在Young区,只有Page处于Young区总长度大约1/4的位置之后,才会将其添加到Young区的链表头部,否则不会修改其在Young区的位置,这个策略避免频繁修改LRU链表,否则每访问一个数据页就要修改链表一次,效率会很低,因为LRU List的根本目的是保证经常被访问的数据页不会被驱逐出去,因此只需要保证这些热点数据页在头部一个可控的范围内即可
上述策略是为了防止不准确预读的数据页污染buffer pool,以及类似全表扫描这种需要load大量Page到内存中的SQL驱逐真正的热数据
- 预取会预先从磁盘读取page到LRU list中,此时先放入Old区头部,等到用户SQL使用到时(说明预取是准确的),这些page才会被移动到Young区头部成为真正的热数据。如果预取操作从磁盘读取的page的后续没有被用户SQL使用,那这些Page很快会从Old区被清除,这就保证了在预取操作在不准的情况下也不会驱逐真正的热数据,因为真正的热数据在Young区,LRU list空间不够时是先驱逐Old区的Page。
- 对于用户SQL,如果执行时发现需要的Page不在LRU list中(没有被预取操作预先读入),会先从磁盘读入,放到LRU Old区的头部,但这些Page由于会立即被用户SQL使用到,这些Page又会立即被放入到Young区头部。
LRU list管理的难点是,类似全表扫描这种一次性load大量Page到内存中,但这些Page只被用户SQL使用一两次,由于这些Page会被放入Young区,会驱逐Young区中真正的热数据。参数 innodb_old_blocks_time 增大了 Page从Old 链表转移到Young链表的难度。这个参数表示Old 链表中的Page必须在Old链表中停留超过innodb_old_blocks_time(默认1秒) 时间,Page在刚被读入LRU链表的innodb_old_blocks_time 时间内如果被访问不会被移动到Young链表,只有超过这个时间后被访问,才会移动到Young 链表。这样可以避免Young 链表被那些只在innodb_old_blocks_time时间间隔内频繁访问,之后就不被访问的页面塞满,从而有效的保护Young 链表中的热点Page不被驱逐。在全表扫描或者全索引扫描的时候,Innodb会将大量的页面读入LRU 链表的Mid Point位置,并且只在短时间内访问几次之后就不再访问了,通过这个参数可以避免全表扫描驱逐Young 链表中的热点Page。
https://dev.mysql.com/doc/refman/8.0/en/innodb-buffer-pool.html
innoDB刷脏页的时机
- redo log写满了,需要做同步checkpoint。此时系统会停止所有更新操作,从Flush 链表尾部开始往头部大量刷脏(刷脏之前需要将Redo Log Buffer刷到磁盘,保证脏页对应的Redo Log已经落盘),推进checkpoint_lsn,为redo log留出空间可以继续写
- Buffer Pool不够用,需要淘汰LRU 链表尾部的数据页。如果淘汰的是“脏页”,需要用户线程同步刷脏,这一步一般不会推进checkpoint_lsn。
- 后台线程周期性清理LRU链表和Flush链表,避免用户线程同步刷脏。
- mysql正常关闭时全量刷脏,类似checkpoint,会推进checkpoint_lsn
刷脏会导致MySQL服务时延发生抖动, 特别是以下情况会明显影响性能:
- 一个查询要淘汰的脏页个数太多,会导致该查询的响应时间明显变长;
- Redo Log写满,更新全部堵住,写性能跌为0,这种情况对敏感业务来说,是不能接受的。
预读和预写
从空间局部性来讲,如果一个数据页被读入Buffer Pool,其周围的数据页也有很大的概率被读入内存,与其分开多次读取,还不如一次都读入内存,从而减少磁盘寻道时间。在官方的InnoDB中,预读分两种,随机预读和线性预读。
- 随机预读
这种预读发生在一个数据页成功读入Buffer Pool的时候。在一个Extent范围(1M,如果数据页大小为16KB,则为连续的64个数据页)内,如果热点数据页(LRU young list前1/4的数据页才算是热点数据页)大于一定数量(默认13),就把整个Extend的其他所有数据页(依据page_no从低到高遍历读入)读入Buffer Pool。 - 线性预读
这种预读只发生在一个边界的数据页(Extend中第一个数据页或者最后一个数据页)上。在一个Extend范围内,如果大于一定数量(默认56)的数据页是被严格顺序访问,则把下一个Extend的所有数据页都读入Buffer Pool。线性预读触发的条件比较苛刻,主要是为了解决全表扫描时的性能问题。
InnoDB刷脏页的时候,也能进行预写。当一个数据页需要被写入磁盘的时候,查找其前面或者后面邻居数据页是否也是脏页且可以被刷盘(没有被IOFix且在LRU链表的old list中),如果是的话,一起刷入磁盘,减少磁盘寻道时间。预写功能在SSD磁盘下建议关闭。
Change Buffer
http://mysql.taobao.org/monthly/2015/07/01/
https://dev.mysql.com/doc/refman/8.0/en/innodb-change-buffer.html
change buffer 位于Buffer Pool中,同时也是需要持久化的数据,在数据存储文件的系统表空间中有区域和其对应。change buffer是一种重要的数据变更日志,主要目的是在修改时,如果二级索引的Page不在buffer pool中,将二级索引的修改先缓存下来,避免通过随机IO将二级索引Page读入Buffer Pool,减少Buffer Pool内存占用,提高修改操作的执行速度,并在后续查询将Page读入Buffer Pool时应用缓存中的修改,同时通过合并同一Page上的多次修改操作,提高对page的修改效率。
InnoDB change buffer可以对三种类型的操作进行缓存:INSERT、DELETE-MARK(软删) 、physical DELETE(物理删除)操作,前两种对应用户线程操作,第三种则由purge操作触发。二级索引的update对应 insert + delete-mark两个操作。
缓存条件
- 对二级索引的叶子节点的修改。因为实际中对二级索引的修改经常是无序的,会产生大量随机IO,所以这里只针对二级索引。
- 该Page不在buffer pool中,如果在Buffer Pool中直接修改即可。
- 对于唯一索引(unique key)上的插入操作,由于需要先将page读到buffer pool中判断该insert操作是否合法(是否满足唯一约束),因此后续insert时会直接修改buffer pool中已有的page,不会将insert操作缓存到change buffer。但唯一索引上的删除操作(update是先delete再insert)可以通过change buffer优化;
- 表上没有flush 操作,例如执行flush table for export时,不允许缓存到change buffer中
- 未超出change buffer最大size限制
往change buffer中添加缓存条目需要记录Redo Log,由Redo Log保证崩溃恢复后能恢复change buffer中的内容。
触发merge(将change buffer中的缓存内容应用到Buffer Pool中的Page,然后从change buffer删除对应的缓存内容,可能需要主动从磁盘读入需要修改的Page到Buffer Pool)的条件:
- 用户线程选择通过二级索引进行数据查询,在从磁盘读入二级索引页时,将change buffer中的缓存操作应用到读入的Page上
- 当change buffer的大小超过阈值,从磁盘读入Page到Buffer Pool,执行merge释放change buffer空间
- 后台master线程周期性发起merge
- 对某个表执行flush table 操作时,会触发对该表的强制 merge
- MySQL shutdown时会进行full merge
merge操作会产生修改数据页和删除change buffer的Redo Log。
在一个数据页做merge之前,change buffer缓存的变更操作越多(也就是这个页面上的更新次数越多),收益就越大。在写多读少的场景,页面在写完以后马上被访问到的概率比较小,change buffer收益较高。但如果写入后会立即读这个page,则收益不明显。如果是使用固态硬盘,或者二级索引体积较小能全部放入Buffer Pool,则可以关闭change buffer。
Double Write Buffer
InnoDB的redolog日志不是完全的物理日志,有部分是逻辑日志,这部分逻辑日志在恢复的时候必须在正确一致的Page上重放,另外,Page Header中的FIL_PAGE_LSN 用于决定恢复时需要应用的Redo Log,这个值也需要整个Page的数据时完整的。然而InnoDB默认的Page大小是16KB,是大于文件系统能保证原子写的4KB大小的(Linux文件系统页OS Page的大小一般是4KB),将一个Page的数据刷盘需要对4个OS Page进行写,因此可能出现Page内容成功一半的情况,导致整个Page处于不完整的状态。
Double Write Buffer主要是解决数据页半写的问题,如果文件系统能保证写数据页是一个原子操作,那么可以把这个功能关闭,这个时候每个写请求直接写到对应的表空间中。 Double Write Buffer大小默认为2M,即128个数据页。假设我们要进行刷脏操作,我们会首先memcopy到内存中的Double Write Buffer(DWB),然后将DWB中的数据先写(顺序写,一次fsync,速度较快)到系统表空间指定位置(系统表空间有个区域和DWB对应),注意这里是同步IO操作,在确保写入成功后,然后使用异步IO把DWB中的数据页写回自己的表空间(随机写),由于是异步操作,所有请求下发后,函数就返回,表示写成功了。不过这个时候后续对DWB的写请求依然会阻塞,直到这些异步IO操作都成功,然后清空系统表空间上DWB的内容,释放DWB的空间,把数据页从Flush List删除,后续的写请求才能被继续执行。
最开始磁盘中每个Page都处于完整正确的状态:
- 如果在第一步同步写系统表空间时发生断电或故障,那就不会写用户表空间,用户表空间中的page还是处于之前的完整一致的状态。
- 如果在异步写回数据页的时候,系统断电,发生了数据页半写,此时Buffer Pool中的Page已经全部丢失,磁盘中用户表空间有部分Page会处于不完整的状态。恢复的时候通过page中的checksum会发现哪些page不完整,此时系统表空间DWB对应区域一定存在对应的完整的Page,只需从系统表空间拷贝正确的Page过来覆盖用户表空间中不完整的Page,用户表空间的这些page就会处于正确状态。
事务执行过程中对Redo Log的写入顺序
1、事务开启
2、undo log’s redo log 写入Redo Log Buffer
3、undo log page写入,undo log page加入Flush List
4、数据页的redo log 写入Redo Log Buffer
5、数据页写入,数据页加入Flush List
6、如果Redo Log Buffer满了,redo log 刷盘(redo log 刷盘可能由用户线程同步完成,也可能由后台线程完成)
7、2-6 重复若干
8、事务提交,redo log 刷盘
9、某个时间脏页刷盘
Checkpoint
后台线程和用户线程(同步checkpoint,用户线程时延会增大)都可以触发checkpoint操作,checkpoint操作发生时,会从Flush链表的尾部开始向头部,计算和选择本次将要刷到磁盘的脏页,然后将这些脏页对应的Redo Log从Redo Log Buffer fsync到磁盘,之后再将选择的脏页刷新到磁盘,之后LRU链表中的对应脏页将变成Clean Page(或者这些Page也从LRU链表删除,释放内存空间)。checkpoint操作结束后,更新全局变量checkpoint_lsn为Flush链表中剩下脏页的最小LSN(Flush链表尾部最后一个控制块的oldest_modification),然后将本次checkpoint的信息(重点是checkpoint_lsn)保存到第一个redo log file(即ib_logfile0文件)中,交替使用file header中的checkpoint 1(LOG_CHECKPOINT_NO为偶数时)、checkpoint 2这两个block(LOG_CHECKPOINT_NO为奇数时)。
推进checkpoint_lsn的好处:
- MySQL崩溃恢复时,oldest_modification小于checkpoint_lsn的脏页已经刷盘了,只需要回放checkpoint_lsn后边的REDO,因此减少了数据恢复的工作量,缩短了数据恢复的时间
- 与1类似,小于checkpoint_lsn的REDO没用了,这部分的空间可以释放出来,供新事务的Redo Log使用。(覆盖Redo Log之前,需要保证对应的脏页已经刷到了磁盘)
- checkpoint操作会将大量脏页刷盘,脏页刷盘后,LRU链表中的对应脏页会变成Clean Page,后续LRU链表逐出这些Page时将不用再刷盘(或者这些Page也从LRU链表删除,释放内存空间)。
实际中如果出现以下情况,是很容易影响MySQL的性能:
一个SQL查询的数据页需要淘汰的页面过多,需要从磁盘读取大量的page,需要用户线程同步刷脏。
对于写多型的MySQL,checkpoint跟不上日志产生量,会导致用户线程同步做checkpoint,写事务发生阻塞。
恢复数据
恢复数据的起点
checkpoint操作结束后,会将此次checkpoint的信息保存到第一个redo log file(即ib_logfile0文件)中。而其内部有两个存储checkpoint信息的Block是交替使用的——checkpoint 1、checkpoint 2。我们需要从最近一次的checkpoint操作开始恢复数据。通过比较checkpoint 1、checkpoint 2这两个Block的checkpoint_no,较大的即为最近一次的checkpoint信息。然后利用该Block中的checkpoint_lsn、checkpoint_offset信息,即可进一步确定日志文件组中恢复数据所需redo log的起点
恢复数据的终点
前面我们提到Block中有一个LOG_BLOCK_HDR_DATA_LEN属性,用于表示该Block已使用的字节数。当该Block的空间全部使用完毕,则该值为512。则若某个Block该属性值不为512,则其即为恢复数据时所需redo log的终点
优化恢复--跳过已经同步到硬盘的页
对于任何LSN小于checkpoint_lsn的redo log而言,其修改操作的脏页已被同步到硬盘中,这部分Redo Log可以全部跳过。但是对于LSN大于checkpoint_lsn的Redo Log而言,其修改操作的脏页是否同步到硬盘中是不确定的,即可能在一次checkpoint后,存在少部分的脏页已经被同步到硬盘中了(除了checkpoint会将脏页刷到磁盘外,如果buffer pool满了,也需要将淘汰的脏页刷盘),这时我们在恢复数据时,对于这些页是无需使用全部的redo log进行恢复的
我们知道在页的File Header部分有一个FIL_PAGE_LSN属性,其记录的是最近一次修改该页结束时的LSN值(即flush链表中控制块的newest_modification值)。故如果在checkpoint操作后该脏页被同步到硬盘了,则该页的FIL_PAGE_LSN属性必然是大于checkpoint_lsn的。故此时在数据恢复的过程中,如果发现redo log对应的LSN值小于FIL_PAGE_LSN属性,则可直接跳过该redo log,无需进行重复的修改,显然此举可以进一步提高数据恢复的效率
优化恢复--基于哈希表的并发恢复
确定了恢复数据所需的redo log后,即可顺序使用这些redo log将相关页面加载到内存进行相应的修改操作。但是为了减少页面加载的次数,减少数据恢复过程中的时间,InnoDB利用哈希表进行了优化。具体地,将redo log中的表空间ID、页号作为哈希表的键,而哈希表的值则为一个链表,用于存放对同一个页进行修改的所有redo log。其中链表中的redo log则是按生成时间的顺序由远到近进行排序的。这里链表中各redo log顺序不能错乱,否则在利用这些redo log进行数据恢复时即可能会出现错误。例如当我们先向表中插入一条数据,然后又删除了该数据。而如果我们在恢复数据时,按先删除该数据再插入该数据 的顺序进行恢复,显然是不符的
当哈希表建立完成后,我们就可以不用顺序遍历所有的redo log进行数据恢复了。而是通过遍历哈希表进行恢复,因为此时对某个页进行所有修改的redo log均在该键所对应的链表中。这样即可一次性完成对该页的所有修改和恢复,减少了随机IO的次数,同时哈希表不同槽位的链表可以并发恢复。
innoDB在崩溃恢复时如何回滚未提交的事务
http://mysql.taobao.org/monthly/2015/04/01/
每个事务的undo log中有字段记录事务的状态,活跃事务的状态是ACTIVE或PREPARE,且活跃事务的undo log一定不会被purge线程清理。当崩溃恢复后,可以通过这个状态字段找到未提交的活跃事务,恢复出读写事务链表,然后通过恢复出来的undo log回滚这些未提交事务的所有修改。
崩溃恢复全过程
http://mysql.taobao.org/monthly/2015/06/01/
- 启动开始时检测是否发生崩溃
当正常shutdown实例时,会将所有的脏页都刷到磁盘,并做一次完全同步的checkpoint;同时将最后的lsn写到系统表ibdata的第一个page中。在重启时,可以根据该lsn和ib_logfile0号文件中的checkpoint_lsn是否相同,来判断这是不是一次正常的shutdown,如果不是就需要去做崩溃恢复逻辑。 - 从ib_logfile0号文件中定位到最近的一个 checkpoint,读出checkpoint_lsn和checkpoint_offset,跳过LSN小于checkpoint_lsn的Redo Log,通过checkpoint_offset定位到第一个需要被应用的Redo Log。
- 读出磁盘存储的数据页,检查 checksum。如果不正确,说明这个页在上次写入是不完整的,从 doublewrite buffer 对应的系统表空间文件中把正确的页读出来,更新不完整的数据页。
- 顺序执行 redo,执行过程中跳过LSN小于页FIL_PAGE_LSN的Redo Log(通过 redo log 也可以恢复 undo log),在恢复数据页的过程中不产生新的redo 日志;
- Redo Log应用完成后,会将活跃事务链表从undo中恢复出来,对于链表中ACTIVE状态的事务直接回滚,对于Prepare状态(开启binlog才会有这个状态)的事务,如果该事务的XID在binlog中存在,则提交,否则回滚事务。
如果 innodb_flush_log_at_trx_commit != 1,主库宕机可能会导致事务在binlog中已经写入,但在innoDB的Redo Log中没有prepare,此时从库已经应用binlog并提交了该事务,但主库重启后该事务会被回滚(Redo Log中只有prepare状态的事务才会和binlog做XA,ACTIVE状态的事务直接回滚),导致主库丢事务。
如果sync_binlog != 1,主库宕机可能会导致事务在innoDB的Redo Log中已经提交,但却没有写入binlog,主库重启innoDB会提交该事务,但从库却没有执行该事务,会导致从库丢事务
半同步-semisync
http://mysql.taobao.org/monthly/2015/06/01/
https://dev.mysql.com/doc/refman/5.7/en/replication-semisync.html
https://www.cnblogs.com/gaogao67/p/11152935.html
即使我们将参数设置成innodb_flush_log_at_trx_commit =1 和 sync_binlog = 1,也还会面临这样一种情况:主库crash时还有部分binlog没传递到备库,如果我们直接提升备库为主库,新主库会缺少旧主库中某些已经提交的事务,会破坏事务的持久性(当然,最理想的办法是把旧主库的binlog拷贝出来,让新主库完全应用,这样就不会有一致性问题,但可能此时旧主库的磁盘都损坏了,从旧主库拷贝binlog会花费很多时间,线上MySQL的故障恢复必须很快)。另外,旧主库重启恢复后,会成为新主库的从库,新从库(旧主库)会有新主库没有的事务,出现主从不一致。针对这种场景,我们可以通过开启semisync的方式来解决
semisync让MySQL具备像ZK类似的能力,分为 AFTER_COMMIT与AFTER_SYNC方式,不管是哪种方式,事务在写入binlog后,在让存储引擎提交前,会将binlog发送给slave。
- AFTER_COMMIT:事务在master的存储引擎层面提交后,完成2PC所有步骤后,等待slave的ACK,然后将结果发给client。
- AFTER_SYNC:事务在写入binlog后,等待slave的ACK,然后才在存储引擎中提交,然后将结果发给client。
对于这两种方式,当client收到server的正确响应后,可以保证事务在master的binlog和slave的relaylog中存在。semisync中收到slave的ACK只是表示slave已经收到binlog并fsync到自己的relaylog中,但slave可能并没有应用。fullsync要求slave commit事务后才能返回ACK。可以增大rpl_semi_sync_master_wait_for_slave_count的值来保证多个从库均包含最新事务
这两种方式的区别是AFTER_COMMIT可能存在不可重复读问题而AFTER_SYNC不会。对于AFTER_COMMIT,另一个客户端在事务在master上commit后读到了该事务,master挂了以后,因为slave可能没有收到这个binlog,这个事务可能不在slave的relaylog中,slave没有执行该事务,slave切换成新master后,这个客户端再读新master时发现事务不见了。对于AFTER_SYNC,一个客户端如果在master上读到了该事务,那这个事务在master上一定已经提交,这个事务一定存在于slave的relaylog中(slave最终会应用),这样主从切换后,这个客户端读新master时也会读到这个事务。从严格意义来说,这两种方式还是可能存在master上写入binlog中的事务在master宕机后,slave(新master)中不存在,这个问题是无法避免的。因此主从同步中,如果master挂了,一般会对旧master的binlog截断,或者让旧master重启恢复时直接使用新master的binlog,保证旧master成为新master的slave后主从的最终一致性。但是在AFTER_SYNC下,从client的使用角度出发不会有主从一致问题,如果client收到正确响应或者client在master读到了某个事务,那master挂了,新的master也会有这个事务。如果client没有收到正确响应,那client应该去检查自己的事务是否成功执行还是被回滚,这个时候事务的执行结果是未知的。
一种通过semisync保证主从事务一致性的方案描述如下:
- master设置双1强持久化配置;
- 将semisync的超时时间设到极大值,同时使用AFTER_SYNC模式,即用户线程在写入binlog后,引擎层提交前等待备库ACK;
- 基于步骤1和2的配置,我们可以保证在主库crash时,所有老主库binlog中比备库多出来的事务都处于prepare状态(包括binlog中有这个事务或者没有这个事务);因为老主库中处于commit状态的事务一定在slave的relay log中存在。
- 备库完全应用relay log后,记下其执行到的relay log对应的位点,然后将备库提升为新主库;
备库没有应用完relaylog之前,不能接收写请求(否则会出现数据不一致),也不能接收读请求(客户端可能发现之前在主库读到的事务在备库读不到了) - 将老主库的最后一个binlog进行截断,截断的位点即为步骤4记录的位点;
如果不进行截断,对于老主库中处于prepare状态且已经记录在binlog的事务,如果这些事务不在新主库的relaylog中,老主库重启恢复后这些事务会在老主库提交,但新主库却没有,出现主从不一致。
这一步截断的目的是为了保证最后一步中新主库没有的事务旧主库都会回滚,所以必须使用AFTER_SYNC,如果使用AFTER_COMMIT,可能出现某个事务在旧主库已经提交了,但新主库却没有,此时即使对旧主库的binlog进行截断,旧主库重启后这个事务还是处于提交的状态。 - 启动老主库,新主库已经应用的事务都会提交,未应用的事务都会回滚,实现老主库与新主库事务的一致性。
BinLog
MySQL为了兼容其它非事务引擎的复制,在server层面引入了 binlog, 它可以记录所有引擎中的修改操作,因而可以对所有的引擎使用主从复制功能。基于最近的一次全量备份,通过binlog的时间戳(理论上Redo Log也可以),可以将mysql恢复到一段时间内的任意一秒的状态。一般崩溃恢复或者扩容从库是使用的binlog。
binlog 中有三种格式:
-
STATEMENT:记录的是每一条会修改数据的 SQL 语句。优点是体积小(比如一个delete语句删掉10万行数据,用statement的话binlog中就只有一个SQL语句);缺点是会造成主从不一致,无法记录特定函数,比如 UUID()、USER() 等。STATEMENT和MIXED格式的binlog会记录临时表的操作
- statement格式无法保证主从一致,比如delete from t where a >= 4 and t_modified<='2018-11-10' limit 1; 这条SQL甚至会产生warning提示主从不一致,因为如果用的是索引a,那么会根据索引a找到第一个满足条件的行并删除;如果用的是索引t_modified,那找到的第一个满足条件的行可能不一样。这条SQL执行的结果和使用的索引有关系。
- binlog使用STATEMENT格式,在READ COMMITTED隔离级别下,主从不一致会更加严重。常见的先delete 再 insert语句都会产生主从不一致。在可重复读隔离级别下,因为有间隙锁会block产生冲突的并发事务,主从不一致现象会少很多。
-
ROW:记录的是每一条被修改的记录行的修改情况。优点是没有主从不一致现象,不会再有无法记录特定函数的问题;缺点是体积大。ROW格式的binlog会忽略所有临时表的操作
即便采用row格式,truncate/drop table这种DDL语句在binlog中记录的仍然是SQL语句。 -
MIXED:此格式下默认采用 STATEMENT 格式进行记录,如果这条SQL语句可能引起主备不一致,则采用 ROW 的格式记录。
目前版本不推荐使用statement格式,如果想节约空间至少使用MIXED格式,默认使用 ROW 格式来记录二进制日志。
binlog日志格式参考 MySQL实战45讲 -- MySQL是怎么保证主备一致的?
sync_binlog
sync_binlog控制事务提交时,binglog刷新到磁盘的策略:
0:默认值。事务提交时只write,不fsync。如果MySQL崩溃但操作系统没有崩溃,不会导致数据丢失。只有当操作系统崩溃时才会导致数据丢失;这个模式最不安全,同时主从一致性最差,但性能最好,。
1:事务提交时write + fsync。这个模式最安全,但性能最差。
N:事务提交时write,每写N次fsync。从服务器相比主服务器最多落后N个事务。
主从复制的步骤
binlog 目前主要用于实现 MySQL 主从服务器之间的复制功能,具体有以下几步:
- 在主服务器上创建一个具有复制权限的用户。
- 依次在主从服务器配置唯一的 serverid。
- 从服务器设置连接主服务器的信息,然后执行start slave,开启主从复制开关。
- 从服务器会创建两个线程:IO 线程和 SQL 线程。
IO 线程会通过主服务器上授权的有复制权限的用户请求连接主服务器,并请求从指定 binlog 日志文件的指定位置之后发送 binlog 日志内容。(日志文件名和位置在上一步设置连接信息时已指定) - 主服务器接收到来自从服务器的请求后,会创建一个专门的 IO 线程,此 IO 线程会根据从服务器的 IO 线程请求中的位置信息,读取指定 binlog 日志文件指定位置之后的 binlog 日志信息,然后返回给从端的 IO 线程(之后的binlog发送是主库主动推送的)。返回的信息中除了 binlog 日志内容外,还有本次返回日志内容后在主服务器端的新的 binlog 文件名以及在 binlog 中的下一个指定更新位置。
- 当从服务器的 IO 线程获取来自主服务器上 IO 线程发送的日志内容及日志文件和位置点后,将 binlog 日志内容依次写入到从端自身的 relay log(即中继日志)文件的最末端,并将新的 binlog 文件名和位置记录到 master-info 文件中,以便下一次读取主端新 binlog 日志时,能告诉主服务器需要从新 binlog 日志的哪个文件哪个位置开始请求新的 binlog 日志内容。
- 从服务器端的 SQL 线程会实时检测本地 relay log 中新增加的日志内容,然后根据日志内容更新从库的数据。从库更新数据时也会记录binlog。到此一轮复制操作就完成了。
Slave并发应用BinLog
MySQL实战45讲/27 26 | 备库为什么会延迟好几个小时?
在官方的5.6版本之前,MySQL只支持单线程复制,在主库并发高、TPS高时就会导致备库应用binlog日志不够快,出现主备延迟问题。
MySQL的多线程复制模型如下,coordinator就是原来的sql_thread, 不过现在它不再直接更新数据了,只负责读取中转日志和分发事务。真正更新日志的,变成了worker线程。

coordinator分发事务需要满足以下两个基本要求:
- 不能因为乱序造成更新覆盖。要求更新同一行的两个事务,必须被分发到同一个worker中,保证从库的事务执行顺序一致。
- 同一个事务的binlog不能被拆开执行,必须放到同一个worker中(放到一个事务中),保证同一个事务内语句的执行顺序一致,且是一起提交生效的。
- 举个例子,一个事务更新了表t1和表t2中的各一行,如果这两条更新语句被分到不同worker的话,虽然最终的结果是主备一致的,但如果表t1执行完成的瞬间,备库上有一个查询,就会看到这个事务“更新了一半的结果”,破坏了事务逻辑的隔离性。
- 大部分的应用其实没有要求事务的完整性,要求的是最终一致性,因此这个条件可以放开
一种按行并发应用的策略
slave按行复制的核心思路是:如果两个事务没有更新相同的行,它们在备库上可以并行执行。显然,这个模式要求binlog格式必须是row。
- 1、每个worker都有一个hash表。key是“库名+表名+主键/唯一键的值”,用于保存当前正在这个worker的“执行队列”里的事务所修改的行,如果这个事务修改主键或唯一键,则会插入2个key,分别对应修改前后的值。否则插入一个key
- 2、事务分配给worker时,事务里面修改的行会被加到对应的hash表中。事务执行完后,这个事务的记录会从hash表中去掉。
- 3、coordinator从中转日志中读入一个新事务T,解析出其修改的所有行,生成key,然后遍历每个worker,如果某个worker的hash表中存在相同的key,说明事务T和这个worker冲突。
- 4、如果事务T跟多于一个worker冲突,coordinator线程就进入等待,直到和这个事务存在冲突关系的worker只剩下1个。如果只跟一个worker冲突,coordinator线程就会把这个事务分配给这个存在冲突关系的worker。如果跟所有worker都不冲突,就把这个事务分配给最空闲的woker;
需要注意,hash表key中的“唯一键”不只有主键id,还需要包含修改的唯一键。
考虑下面的场景,表t1中除了主键,还有唯一索引a:
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);
假设,接下来我们要在主库执行这两个事务:
可以看到,这两个事务要更新的行的主键值不同,但是如果它们被分到不同的worker,有可能session B的语句先执行, session B的语句执行时id=1的行的a的值还是1,就会报唯一键冲突。因此,事务hash表中还需要包含唯一键,key应该是“库名+表名+索引a的名字+a的值”。
比如,在上面这个例子中,对于sessionA的 update t1 set a=6 where id=1语句,coordinator在解析这个语句时,会往事务的hash表添加三个项:
- key=hash_func(db1+t1+“PRIMARY”+1), 如果修改了主键,需要添加修改前和修改后的主键共两项记录。
- key=hash_func(db1+t1+“a”+1), 辅助索引的修改是先删除后插入
- key=hash_func(db1+t1+“a”+6)
这个方案有一些约束条件:
- 要能够从binlog里面解析出表名、主键值和唯一索引的值。也就是说,主库的binlog格式必须是row;
- 表必须有主键;
- 不能有外键。表上如果有外键,还需要新增约束条件。
MySQL 5.7版本的并行复制策略
MySQL的并行复制策略利用了组提交的特性:
能够在同一组里一起提交的事务,一定没有发生锁冲突,可以并行执行;
主库上可以并行执行的事务,备库上也一定是可以并行执行的。
MySQL的具体做法是:
在一组里面一起提交的事务,有一个相同的commit_id,下一组就是commit_id+1;
commit_id直接写到binlog里面;
传到备库应用的时候,相同commit_id的事务分发到多个worker执行;
这一组全部执行完成后,coordinator再去取下一批。
binlog与Redo Log的区别
- 适用的范围不同:Redo Log和undo log是在innoDB引擎层产生。binlog在MySQL服务器层产生,对所有数据库引擎的修改操作都生效。
- Redo Log是循环写,binlog是追加写append,不会覆盖之前的日志。
- 日志格式不同:Redo Log采用物理日志格式,记录的是对page的修改,redo log是幂等的。binlog是逻辑日志,采用的row-based或statement-based的格式,binlog 不是幂等的,尽管 binlog也可以按行记录,但这种记录是逻辑的,逻辑的记录不像物理记录那样天然具有幂等性。比如对于插入操作而言,它的记录可能是:在xx行插入一条xxx的数据。对这条记录而言,重复执行就会插入多条重复数据,因为这个原因,binlog无法提供crash-safe能力,无法用于崩溃恢复,Redo Log具有crash-safe能力。如果只有binlog而没有Redo Log,当MySQL挂了以后,首先,未提交事务一定没有binlog,因为binlog是在提交时刷盘的,那么未提交事务的脏页一定不能刷盘,所以MySQL崩溃重启后,未提交事务的所有修改都没有,这是OK的。但是对于已提交事务,其有binlog,但已提交事务的脏页是否刷盘是未知,但是由于binlog不具备幂等性,如果已提交事务的脏页已经刷盘,再次应用其binlog时会出现数据重复问题。
- 日志写入时间点不同:每个事务会为binlog日志分配一个缓冲区,缓冲区中的binlog日志只在事务提交时一次性刷入磁盘,因此binlog是按事务提交的顺序记录日志的,且一个事务的binlog是连续的。 而redo log的缓冲区由所有事务共享,且在事务进行过程中是不断写入的,一个事务的Redo Log可能分散在不同地方,假如T2先于T1提交,T2提交时会把 T1 已经生成的Redo Log一起刷到磁盘。
可以只要Redo Log不要binlog吗?
理论上只要Redo Log可以保证crash-safe,但binlog有其他Redo Log不具备的作用。
一个是binlog具备归档的能力。redo log是循环写,写到末尾是要回到开头继续写的。这样历史日志没法保留,也就起不到归档的作用。
二是binlog作为MySQL Server层的标准通用功能被用在了很多地方。其中,主从复制和databus实时监听依赖binlog。
2PC 与 Redo和Binlog的顺序一致性问题
https://www.shangmayuan.com/a/c654087cdeb94106954d4409.html
MySQL 主从复制之间依赖 binlog,而 binlog 文件的写入在 commit 之前,如果主库在写完 binlog 文件后宕机,重启后主库会回滚事务。由于从库通过binlog重放主库已提交的事务,此时从库可能已经执行这个事务,则会造成主备数据不一致。所以在开启 binlog 后,如何保证 binlog 和 redo log 的一致性呢?为此,MySQL 引入二阶段提交(two phase commit or 2pc),MySQL 会自动将普通事务当做一个内部XA 事务(内部分布式事务,单台MySQL服务器中的跨库事务)来处理:
- 自动为每个事务分配一个唯一的ID(XID),Redo Log和binlog的事务都会记录XID。
- COMMIT 会被自动的分成 Prepare 和 Commit 两个阶段。
- 内部XA中,MySQL Server层作为事务协调者,Binlog日志会被当做协调者日志。由协调者来通知各个存储引擎(包括 InnoDB 引擎)来执行 prepare,commit 或者 rollback 的步骤
2PC理论上还是会有一致性问题,因此才有3PC,可以参考 https://zhuanlan.zhihu.com/p/35616810
事务提交的整个过程如下:
以上的图片中可以看到,事务的提交主要分为两个阶段,三个步骤:
-
第一阶段:准备阶段(Prepare)
步骤1:此时事务的所有SQL已经成功执行,并已经生成 xid 信息,持有prepare_commit_mutex锁,将事务undo log中的事务状态从ACTIVE 修改为 TRX_PREPARED,innoDB引擎将redo log write/fsync到磁盘。需要fsync的原因是,如果在第2和第3步之间发生崩溃,MySQL崩溃恢复时会提交这个事务,因此需要第一步结束后Redo Log中有这个事务的全部Redo Log,因此需要在第一步就fsync。 -
第二阶段:提交阶段( commit)
-
步骤2 :记录协调者日志,即 Binlog 日志
- 如果事务涉及的所有存储引擎的 prepare 都执行成功,write/fsync Binlog
- 如果有存储引擎prepare失败,则事务内存中的binlog日志会被丢弃,不会写入磁盘。
-
步骤3:
- 如果事务涉及的所有存储引擎的 prepare 都执行成功,则调用所有存储引擎的 commit,会将事务状态修改为 TRX_NOT_STARTED 状态(COMMIT状态),innoDB会write Redo Log到磁盘(可以不fsync,因为binlog已经fsync,恢复时这个事务一定是commit的),释放prepare_commit_mutex锁。
- 如果有存储引擎prepare失败,让所有存储引擎回滚事务,释放prepare_commit_mutex锁。
-
可以看到,一次事务的commit需要至少两次fsync,前两个步骤都需要调用一次 fsync 操作才能保证上下两层数据的一致性。步骤 1 的 fsync 由参数 innodb_flush_log_at_trx_commit=1 控制,步骤 2 的 fsync 参数由 sync_binlog=1 控制,俗称“双1”,是保证日志一致性的根本。由于binlog写成功为事务提交成功的标志,所以通常情况下第三个步骤的fsync可以省略。
数据库崩溃后,崩溃恢复会恢复出读写事务链表,对于链表中状态为 prepare 的事务,在innoDB看来是未提交的事务,innoDB会去查询该事务的XID是否也存在于 binlog 中,如果存在,innoDB会提交该事务(因为此时从库可能已经获取了对应的 binlog 内容,从库执行并提交了对应的事务),如果 binlog 中没有该事务,innoDB回滚该事务。
binlog与Redo Log一致性分析
如果崩溃发生在第一步和第二步之间,处于 prepare 状态的事务还没来得及写入到 binlog 中,崩溃恢复时该事务会在存储引擎内部进行回滚,这样该事务在存储引擎 和 binlog 中都不会存在;当崩溃发生在第二步和第三步之间时,处于 prepare 状态的事务存在于 binlog 中,那么该事务会在存储引擎内部进行提交,这样该事务就同时存在于存储引擎和 binlog 中。由上面的二阶段提交流程可以看出,在进行恢复时,在innoDB引擎中事务要提交还是回滚,是由Binlog来决定的。一旦步骤 2 binlog写成功,该事务最终就会是已提交状态,即使在执行步骤 3 时数据库发送了宕机(即使还没有调用存储引擎的commit)。事务的两阶段提交协议保证了无论在任何情况下,事务要么同时存在于存储引擎和 binlog 中,要么两个里面都不存在,这就保证了主库与从库之间数据的一致性。
binlog与Redo Log顺序一致性保证
在并发事务情况下,上述2PC中使用的 prepare_commit_mutex锁 以串行的方式来保证binlog日志的事务提交写入顺序 和 InnoDB存储引擎层事务提交顺序一致(因为备份及恢复需要,这两者的提交顺序也得一致)。但这样会无法实现组提交(group commit),同时高并发场景下事务的提交性能很差。
主从一致性分析
由于不能保证强一致性,只是最终一致性,一般的情况是存在主从延时,就会导致一个客户端先读主库发现某个事务已提交,但读从库发现事务未提交。
但由于引入2PC,可能还有一种相反的情况,事务在主库写入binlog后,从库就收到该binlog并应用了,但主库此时还未在innoDB引擎中commit,因此客户端会发现事务在从库已经生效但在主库却没有生效,这个问题目前无法避免,只能要求client尽量读同一台机器。
组提交(Group Commit)
上面介绍事务的两阶段提交过程是5.6之前版本中的实现,有严重的缺陷。当sync_binlog=1时,很明显上述的第二阶段中的 write/sync binlog会成为瓶颈,而且还是持有全局大锁(prepare_commit_mutex: prepare 和 commit共用一把锁),这会导致并发事务提交性能急剧下降。解决办法就是在MySQL5.6中引进的binlog组提交。此时,不但binlog日志写入是group commit的,InnoDB的redo log的写入也是group commit的,还移除了原先的prepare_commit_mutex锁,从而大大提升了数据库的总体性能。
组提交的原理是:对于一组事务的提交,只执行一次耗时的 I/O 操作,而不是每个事务的提交都write 和 fsync。
MySQL 5.6 将事务的提交分为三个阶段:
在 MySQL 中每个阶段都有一个队列,每个队列都有一把锁保护,第一个进入队列的事务会成为 leader,leader提交时会一次性把整队所有事务的日志都write 和 fsync,完成后通知队内其它事务他们的日志已经刷盘,。
- flush 阶段:支持 redo log 的组提交
- 将 redo log 中处于 prepare 阶段的数据刷盘
- 将 binlog 数据写入文件,但此时只是写入文件系统的缓冲,不能保证数据库崩溃时 binlog 不丢失
- sync 阶段:支持 binlog 的组提交
- 将 binlog 刷盘,若队列中有多个事务,那么仅一次 fsync 操作就可以完成二进制日志的刷盘操作,这在 MySQL 5.6 中称为 BLGC(binary log group commit)
- 如果在这步完成后数据库崩溃,由于 binlog 中已经存在事务记录,MySQL 会通过 flush 阶段中已经刷盘的 redo log 继续进行事务的提交
- commit 阶段
- 将 redo log 中处于 prepare 状态的事务在引擎层提交,commit 阶段不用刷盘
http://timd.cn/mysql-redo-and-undo-log/
https://www.cnblogs.com/mao3714/p/8734558.html
为了支持组提交,事务提交时2PC的3个阶段细化成5个阶段,这样binlog和Redo Log都可以组提交
第一个提交的事务会被选为这组的 leader,由于write是写到磁盘的Page Cache中,相比fsync还是挺快的,组内的每个事务write到磁盘后,由leader负责将整组的日志fsync到磁盘,组内多个事务的提交只调用一次fsync。leader事务write完Redo Log 和 binlog后,接下来的fsync越晚调用,组员会越多,节约IOPS的效果就越好。为了让一次fsync带的组员更多,MySQL的优化是拖时间。如果想提升binlog的组提交的效果,可以设置 :
binlog_group_commit_sync_delay参数,表示延迟多少微秒后才调用fsync;
binlog_group_commit_sync_no_delay_count参数,表示累积多少次以后才调用fsync。
这两个条件是或的关系,也就是说只要有一个满足条件就会调用fsync。
参考 MySQL实战45讲 -- MySQL是怎么保证数据不丢的?
参考
庖丁解InnoDB之REDO LOG https://jinglingwang.cn/archives/mysql-redolog
MySQL之InnoDB存储引擎:浅谈Redo Log重做日志 https://www.modb.pro/db/124753
Innodb Buffer Pool的三种Page和链表 https://www.modb.pro/db/13076
Redo log,Undo log 和 Binlog http://huzb.me/2019/04/24/redo-undo和binlog/
MySQL 中Redo与Binlog顺序一致性问题 https://www.shangmayuan.com/a/c654087cdeb94106954d4409.html
MySQL binlog 组提交与 XA(分布式事务、两阶段提交) https://www.cnblogs.com/mao3714/p/8734558.html
MySQL · 引擎特性 · InnoDB Buffer Pool http://mysql.taobao.org/monthly/2017/05/01/
MySQL · 源码分析 · InnoDB LRU List刷脏改进之路 http://mysql.taobao.org/monthly/2017/11/05/
MySQL · 引擎特性 · InnoDB Buffer Pool 浅析 http://mysql.taobao.org/monthly/2020/02/02/
MySQL · 引擎特性 · InnoDB undo log 漫游 http://mysql.taobao.org/monthly/2015/04/01/
MySQL · 引擎特性 · InnoDB redo log漫游 http://mysql.taobao.org/monthly/2015/05/01/
MySQL · 引擎特性 · InnoDB 崩溃恢复过程 http://mysql.taobao.org/monthly/2015/06/01/
MySQL · 引擎特性 · 8.0 Innodb redo log record 源码分析 http://mysql.taobao.org/monthly/2019/08/03/

浙公网安备 33010602011771号