MySQL如何保证持久性?

44 阅读22分钟

事务具有四大特性ACID,分别表示原子性(atomicity)、一致性(consistency)、隔离性(isolation)和持久性(durability)。下面我们将借着InnoDB来着重探讨下MySQL是如何保证事务的持久性的。

前置知识

MySQL(以InnoDB存储引擎为例)持久性主要是通过二进制日志(BinLog)和重做日志(RedoLog)来实现,其中二进制日志(BinLog)主要作用于MySQLServer层(负责MySQL功能层面的事情),而重做日志(RedoLog,以InnoDB存储引擎为例)主要是作用于MySQL的引擎层(负责存储相关的具体事情)。

为什么会有两份日志呢?

因为最开始MySQL使用的是自带的MyISAM引擎,MyISAM引擎没有crash-safe的能力,而BinLog日志只能用于归档。后来,另一个公司以插件形式为MySQL引入了InnoDB引擎,鉴于BinLog日志没有crash-safe的能力,就加入了另外一套日志系统(RedoLog)来实现crash-safe能力。

二进制日志(BinLog)

二进制日志(BinLog)是MySQL的逻辑日志,记录了对MySQL数据库执行更改的所有操作。

二进制日志不包括SELECTSHOW等操作,因为这类操作对数据库本身没有修改。需要注意的是,即使更改操作本身没有导致数据库发生改变,该更改操作也可能会写入二进制日志。

二进制日志(BinLog)一般有以下三个作用:

  1. 恢复(recovery): 某些数据的恢复需要二进制日志
  2. 复制(replication): 通过复制和执行二进制日志使一台远程的MySQL数据库(一般称为slavestandby)与另一台MySQL数据库(一般称为masterprimary)进行实时同步
  3. 审计(audit): 用户可以通过二进制日志中的信息来进行审计,判断是否有对数据库进行注入的攻击

二进制日志(BinLog)支持以下三种格式:

  1. STATEMENT: 记录的是逻辑SQL语句,即记录的是SQL语句的原文。通常情况很少会直接设置为STATEMENT格式,因为它会导致主从数据不一致的问题。
  2. ROW: 记录的是行的更改情况。通常情况我们将参数设置ROW可以为数据库的恢复和复制带来更好的可靠性,但是这样也会让二进制文件大小增加。
  3. MIXED(混合): 默认采用STATEMENT格式进行日志记录,但是一些情况下会使用ROW格式,可能有:
    1. 表的存储引擎为NDB,这时对表的DML操作都会以ROW格式记录
    2. 使用了UUID()USER()CURRENT_USER()FOUND_ROWS()ROW_COUNT()等不确定函数
    3. 使用了INSERT DELAY语句
    4. 使用了用户定义函数(UDF)
    5. 使用了临时表(temporary table)

对于BinLog的写入,程序会在事务执行的过程中先将日志写入到BingLog Cache中,到事务提交时再将BingLog Cache中的数据写入到Binlog文件中(在完成后将BingLog Cache清空)。

┌──────────────────────────────┐
│  Server                      │
│                              │
│  ┌────────────────────────┐  │
│  │         MySQL          │  │
│  │  ┌──────────────────┐  │  │
│  │  │   BinLog Cache   │  │  │
│  └──┴─────────┬────────┴──┘  │
├───────────────┼write─────────┤
│               ▼              │
│         FS Page Cache        │
│               │              │
├───────────────┼fsync─────────┤
│               ▼              │
│           Hard Disk          │
│                              │
└──────────────────────────────┘

其中,BingLog Cache在每个线程中系统都会为其分配一块内存来使用(线程独有),其大小可由binlog_cache_size来控制(如果实际大小超过了这个参数配置的大小,它就会被暂存在磁盘中);而Binlog文件则是所有线程共用一份。

┌────────────────────────────────────────────────────────────────────────┐                   
│ Trx Running                                                            │                   
│                                                                        │                   
│   ┌──────────────────┐   ┌──────────────────┐   ┌──────────────────┐   │                   
│   │ Thread           │   │ Thread           │   │ Thread           │   │                   
│   │ ┌──────────────┐ │   │ ┌──────────────┐ │   │ ┌──────────────┐ │   │                   
│   │ │ BinLog Cache │ │   │ │ BinLog Cache │ │   │ │ BinLog Cache │ │   │                   
│   │ │┌────────────┐│ │   │ │┌────────────┐│ │   │ │┌────────────┐│ │   │                   
│   │ ││   Memory   ││ │   │ ││   Memory   ││ │   │ ││   Memory   ││ │   │                   
│   │ │└────────────┘│ │   │ │└────────────┘│ │   │ │└────────────┘│ │   │                   
│   │ └──────────────┘ │   │ └──────────────┘ │   │ └──────────────┘ │   │                   
│   └──────────────────┘   └──────────────────┘   └──────────────────┘   │                   
└───────────────────────────────────┬────────────────────────────────────┘                   
                                    │ commit                               
┌───────────────────────────────────▼────────────────────────────────────┐                   
│ Trx Commit                                                             │                   
│                                                                        │                   
│   ┌──────────────────┐   ┌──────────────────┐   ┌──────────────────┐   │                   
│   │ Thread           │   │ Thread           │   │ Thread           │   │                   
│   │ ┌──────────────┐ │   │ ┌──────────────┐ │   │ ┌──────────────┐ │   │                   
│   │ │ BinLog Cache │ │   │ │ BinLog Cache │ │   │ │ BinLog Cache │ │   │                   
│   │ │┌────────────┐│ │   │ │┌────────────┐│ │   │ │┌────────────┐│ │   │                   
│   │ ││            ││ │   │ ││            ││ │   │ ││            ││ │   │                   
│   │ │└─────┬──────┘│ │   │ │└─────┬──────┘│ │   │ │└──────┬─────┘│ │   │                   
│   │ └──────┼───────┘ │   │ └──────┼───────┘ │   │ └───────┼──────┘ │   │ 
│   └────────┼─────────┘   └────────┼─────────┘   └─────────┼────────┘   │                   
│            └──────────────────────┼───────────────────────┘            │                   
│                                   │write                               │                   
│            ┌──────────────────────┼───────────────────────┐            │                   
│            │                      ▼                       │            │                   
│            │                FS Page Cache                 │            │                   
│            │                      │                       │            │                   
│            ├──────────────────────┼fsync──────────────────┤            │                   
│            │                      ▼                       │            │                   
│            │                  Hard Disk                   │            │                   
│            │                                              │            │                   
│            └──────────────────────────────────────────────┘            │                                 
└────────────────────────────────────────────────────────────────────────┘                   

如上图所示,对Binlog文件的写入首会先通过write操作将BinLog Cache中的数据写入到文件系统中的Page Cache中,然后再通过fsync操作将文件系统中Page Cache的数据刷新到磁盘中(一般情况下,我们认为fsync操作才占磁盘的IOPS)。但考虑到不同业务对持久性和性能的侧重点不一样,MySQL并没有强制在每次写入时都执行fsync操作进行刷盘,而是提供了sync_binlog参数对fsync操作的执行时机进行控制,即:

  1. sync_binlog=0的时候,表示每次提交事务都只write,不fsync
  2. sync_binlog=1的时候,表示每次提交事务都会执行writefsync
  3. sync_binlog=N(N>1)的时候,表示每次提交事务都write,但累积N个事务后才fsync

假设将sync_binlog设置为N,对应的风险是:如果主机发生异常重启,可能会丢失最近N个事务的BinLog日志。

因此,在出现IO瓶颈的场景里,我们可以将sync_binlog设置成一个比较大的值来可以提升性能,但与此同时这也会造成BinLog日志的丢失。在实际的业务场景中,考虑到丢失日志量的可控性,一般不建议将这个参数设成0,比较常见的是将其设置为100~1000中的某个数值。

重做日志(RedoLog)

重做日志(RedoLog)是InnoDB保证事务持久性和crash-safe能力的物理日志,记录的是页的物理修改操作。它主要由两个部分组成,一是内存中的重做日志缓冲(RedoLog Buffer),其是易失的;二是重做日志文件(RedoLog File),其是持久的。对重做日志的写入,InnoDB首先会将数据写入到重做日志缓冲(RedoLog Buffer)中,然后再按照一定条件将其顺序写入到重做日志文件(RedoLog File)中,其中写入重做日志文件(RedoLog File)的条件主要有两个:

  1. 后台线程每秒会将重做日志缓冲写入重做日志文件中。
  2. 在事务提交时程序会将重做日志缓冲写入重做日志文件中。

而由于重做日志文件并没有使用O_DIRECT选项,因此在写入重做日志文件时会先将重做日志缓冲写入文件系统缓存中(即执行write操作),然后文件系统再在某些时刻将文件系统缓存中的数据刷新到磁盘中(即执行fsync操作),具体如下所示:

┌──────────────────────────────┐
│  Server                      │
│                              │
│  ┌────────────────────────┐  │
│  │         MySQL          │  │
│  │  ┌──────────────────┐  │  │
│  │  │  RedoLog Buffer  │  │  │
│  └──┴─────────┬────────┴──┘  │
├───────────────┼write─────────┤
│               ▼              │
│         FS Page Cache        │
│               │              │
├───────────────┼fsync─────────┤
│               ▼              │
│           Hard Disk          │
│                              │
└──────────────────────────────┘

为了保证事务的持久性,在每次写入重做日志文件(RedoLog File)时都需要调用一次fsync操作,确保文件系统缓存中的数据能立即写入磁盘中以保证数据不丢失。另外,fsync操作的效率取决于磁盘的性能,所以磁盘的性能决定了事务提交的性能,也就是数据库的性能。

考虑到不同业务对持久性和性能的侧重点不一样,InnoDB也没有规定在事务提交时就一定需要将文件系统缓存中的数据刷新到磁盘中(即执行fsync操作),而是提供了innodb_flush_log_at_trx_commit参数让我们可以控制在提交事务时对重做日志的处理(条件2),具体如下所示:

  1. 当将innodb_flush_log_at_trx_commit设置为0时,提交事务不会将重做日志缓冲(RedoLog Buffer)写入重做日志文件(RedoLog File),即不会执行write操作也不会执行fsync操作,而是等待主线程每秒将重做日志缓冲写入到重做日志文件。在这种情况下,如果数据库发生崩溃,可能会丢失最多 1 秒的事务数据,因为最后一秒内的重做日志缓冲还未写入重做日志文件。
  2. 当将innodb_flush_log_at_trx_commit设置为1时,提交事务会将重做日志缓冲(RedoLog Buffer)写入磁盘的重做日志文件(RedoLog File)中,即在执行write操作后在执行fsync操作进行持久化。这种设置能保证事务的持久性,即使在事务提交后(数据库所在的)服务器发生崩溃,也不会丢失已提交事务的重做日志记录,数据的一致性、持久性和完整性都能得到最大程度的保证。
  3. 当将innodb_flush_log_at_trx_commit设置为2时,提交事务会将重做日志缓冲(RedoLog Buffer)写入磁盘的重做日志文件(RedoLog File)缓冲中,即仅仅执行write操作将重做日志缓冲写入文件缓冲(Page Cache)中(没有执行fsync操作)。在这种情况下,如果操作系统崩溃,可能会丢失事务数据,因为数据可能还在操作系统的文件缓存中,尚未真正写入磁盘。

对于“主线程每秒会将重做日志缓冲写入重做日志文件”(条件1),InnoDB是会先执行write操作将重做日志缓冲写入Page Cache,然后再执行fsync操作将Page Cache持久化到磁盘。

如果要保证事务ACID中的持久性,我们必须将innodb_flush_log_at_trx_commit设置为1,这样每当有事务提交时就能确保事务已经写入到磁盘的重做日志文件中。那么当数据库因为意外发生宕机时,MySQL就可以通过重做日志进行恢复(恢复已提交的事务)。而如果将innodb_flush_log_at_trx_commit设置为02,虽然可以提高事务提交的性能,但与此同时也会丧失事务的持久性,即当数据库发生宕机可能会导致数据的丢失。不同之处在于,如果将innodb_flush_log_at_trx_commit设置为2,在MySQL数据库发生宕机而操作系统及服务器没有发生宕机的情况,由于事务日志已经从重做日志缓冲(RedoLog Buffer)提交到磁盘文件系统的缓冲(Page Cache)中,在进行数据恢复时同样能保证不丢失。

重做日志缓存和重做日志文件都是以块(Block)的方式进行保存的,每块大小为512字节,我们称之为重做日志块(RedoLog Block)。当每一页中产生的重做日志数量大于512字节,需要将其分割为多个重做日志块进行存储。此外,由于重做日志块的大小和磁盘扇区大小一样,都是512字节,因此重做日志的写入可以保证原子性,不需要Double Write技术。

另外,在InnoDB中重做日志(RedoLog)是以组的形式进行存储的,我们将这个文件组称之为重做日志组(RedoLog Group)。重做日志组(RedoLog Group)是一个逻辑概念,物理上并不会有一个文件来表示它。一般来说,每个InnoDB中至少会有一个重做日志组(RedoLog Group),每个文件组中至少有两个重做日志文件(默认名为ib_logfile0ib_logfile1),文件组中的每个重做日志文件的大小一致,并以循环写入的方式运行。

InnoDB1.2版本之前,重做日志文件的总大小要小于4GB(不能等于4GB)。而从InnoDB1.2开始重做日志文件总大小的限制提高为512GB

在重做日志组(RedoLog Group)的形式下,重做日志块(RedoLog Block)的每次写入都会追加到重做日志文件(RedoLog File)的末端,当一个重做日志文件(RedoLog File)被写满后会接着写入下一个重做日志文件(RedoLog File),即通过round-robin的方式循环写入重做日志文件0和重做日志文件1

┌────────────────┐                                    
│                │                                    
│ RedoLog Buffer │                                    
│                │                                    
└───────┬────────┘                                    
        │                                             
┌───────▼────────────────────────────────────────────┐
│                                      RedoLog Group │
│                                                    │
│      ┌────────────────┐   ┌─────────────────┐      │
│      │                │   │                 │      │
│   ┌─►│   ib_logfie0   ├──►│   ib_logfile1   ├──┐   │
│   │  │                │   │                 │  │   │
│   │  └────────────────┘   └─────────────────┘  │   │
│   └────────────────────────────────────────────┘   │
│                                                    │
└────────────────────────────────────────────────────┘

BinLogRedoLog的差别

  • 首先,它们作用的层次不同,二进制日志属于MySQLServer层日志,它会记录所有与MySQL数据库变更的日志记录,包括InnoDBMyISAMHeap等其他存储引擎的日志。而重做日志属于InnoDB存储引擎层的日志,它只会记录有关该存储引擎本身的事务日志。
  • 接着,它们记录的日志内容不同,无论是将二进制记录的格式设置为STATEMENTROW还是MIXED,实际上记录的都是关于一个事务的具体操作内容,即该日志是逻辑日志。而InnoDB存储引擎的重做日志是关于每个页(Page)物理上的更改情况。
  • 其次,它们对日志的写入时间不同,二进制日志仅在事务提交前进行提交,即只写磁盘一次(不论这时该事务有多大)。而重做日志会在事务进行的过程中不断有条目(Redo Entry)被写入到重做日志文件中。
  • 最后,它们对日志的写入方式也不同,二进制日志文件是通过“追加写”的方式写入日志记录,它会不断地将新的日志记录追加到当前日志文件的末尾,在当前文件空间用完后会切换到新的文件继续“追加写”。而重做日志是通过“循环写”的方式写入日志记录,它会不断地将新的日志记录追加到当前日志文件的末尾,在文件组内所有的文件空间都用完后会循环回到第一个日志文件继续写入(覆盖旧数据)。

Write Ahead Log(WAL)策略

如果在事务提交时每次都将发生变化的数据页(脏页)刷新到磁盘中,那这个开销是非常大的。这种情况下要是热点数据集中在某几个数据页中,那数据库性能将变得非常差。为了解决这个问题,事务数据库普遍采用了Write Ahead Log策略,即事务提交时,数据库会先写入重做日志(RedoLog),再将发生变化的数据页(脏页)保存在缓冲池中,后续再在某些时刻或者定时将缓存池中这些发生变化的数据页(脏页)刷新到磁盘中。

为什么引入重做日志(RedoLog)?

通过重做日志(RedoLog)的引入,数据库就能在保证持久性的前提下做到性能的提高。

  1. 如果没有引入重做日志(RedoLog),只是将脏页保存在缓冲池中,只在特定时刻或定时将这些脏页刷新到磁盘,这种方式会导致数据库的持久性无法被保证,例如在缓冲池将脏页刷新到磁盘前发生了宕机,那么这些数据是不可恢复的。
  2. 通过每次将发生变化的数据页(脏页)直接写入到磁盘对应的位置 改为 每次将数据页发生变化的数据写入到重做日志(RedoLog),使得数据的写入可以从随机I/O优化为顺序I/O,写入性能得到一定的提升。另一方面,如果在缓冲池中将脏页刷新到磁盘前发生了宕机而导致数据丢失,程序也可以通过重做日志(RedoLog)来完成数据的恢复。

Checkpoint机制

同时,为了保证将脏页持久化到磁盘的效率和避免RedoLog文件无止尽的增长,数据库引入了Checkpoint机制。所谓Checkpoint机制就是将缓冲池中的脏页刷新会磁盘中,具体地,Checkpoint会被用来标记重做日志(RedoLog)中要刷新到磁盘的日志偏移量,即Checkpoint之前的脏页都已经刷新到磁盘中了。

通过这种机制,程序就可以在每次刷新脏页时从Checkpoint标记的偏移量开始,而不是从头开始扫描整个日志文件。特别地,在系统崩溃后,程序只需要从Checkpoint开始恢复,而不需要从头开始扫描整个日志文件。另一方面,因为在Checkpoint偏移量之前的脏页已经刷新到磁盘中了,因此程序可以对这部分数据进行及时地清理。

什么时候会执行Checkpoint机制将脏页刷新回磁盘?

  1. Sharp Checkpoint会刷新所有脏页回磁盘,默认数据库关闭时将所有脏页都刷新会磁盘,即innodb_fast_shutdown=1
  2. Fuzzy Checkpoint会刷新部分脏页回磁盘,具体如下所示:
    1. Master Thread Checkpoint: 每秒或每十秒将缓冲池的脏页列表按一定比例刷新回磁盘。
    2. FLUSH_LRU_LIST Checkpoint: 如果缓冲池中的LRU列表没有可用的空闲页,程序就会将LRU列表尾端的页移除。如果这些页中存在脏页,那么会进行Checkpoint机制,以保证LRU列表中有空闲页可供使用。
    3. Async/Sync Flush Checkpoint: 如果重做日志文件(RedoLog)不可用,程序就会强制从脏页列表中选取一些脏页刷新回磁盘,以保证重做日志的可循环使用。
    4. Dirty Page too much Checkpoint: 如果脏页数量太多,程序就会强制执行Checkpoint机制,以保证缓冲池中有足够可用的页。

两次写(Double Write

另外,如果在将缓冲池中的脏页刷新到磁盘的过程中,数据库发生了宕机而且数据页只写了一部分,比如16KB只写了前4KB,在没使用两次写(Double Write)技术之前是有可能会出现数据丢失的,这种情况我们也称之为部分写失效(Partial Page Write)。

如果发生了部分写失效(Partial Page Write),简单地通过重做日志恢复是不行的,因为重做日志是对页进行物理操作,如偏移量800写记录‘aaa’。如果这个页本身已经发生了损坏,在对其进行重做日志是没有意义的。

而在使用两次写(Double Write)后,程序就可以在发生部分写失效(Partial Page Write)时,先通过该页的副本还原/恢复该页,再进行重做日志,这就是所谓的两次写(Double Write)。

具体地,当将缓冲池中的脏页刷新到磁盘时,程序执行流程如下:

       ┌───────────────────────────────────────────────────────────┐         
       │                                                           │         
       │    ┌────────────┐                         memory          │         
       │    │            │                                         │         
       │    │    page    ├───┐     ┌──────────────────────────┐    │         
       │    │            │   │     │                          │    │         
       │    └────────────┘   │  1  │                          │    │         
       │                     └────►│    doublewrite buffer    │    │         
       │                     │copy │                          │    │         
       │    ┌────────────┐   │     │                          │    │         
       │    │            │   │     └──────┬────────────┬──────┘    │         
       │    │    page    ├───┘            │            │           │         
       │    │            │                │            │           │         
       │    └────────────┘                │            │           │         
       └──────────────────────────────────┼────────────┼───────────┘         
                                     2    │            │     3               
                                ┌─────────┘            └───────────┐         
                                │  write                   write   │         
                                │                                  │         
┌───────────────────────────────▼──────────────┐           ┌───────▼────────┐
│                                              │           │                │
│    ┌──────────────────┬─────────────────┐    │           │                │
│    │                  │                 │    │           │                │
│    │    doublewrite   │   doublewrite   │    │           │                │
│    │       (1MB)      │      (1MB)      │    │           │ datafile(.ibd) │
│    │                  │                 │    │           │                │
│    └──────────────────┴─────────────────┘    │           │                │
│                      Shared tablespace       │           │                │
└──────────────────────────────────────────────┘           │                │
                                                           └────────────────┘
  1. 首先,程序会通过memcpy函数将缓冲池中的脏页复制到内存中的doublewrite buffer中,而不是直接将缓冲池中的脏页写入磁盘。
  2. 然后,将doublewrite buffer中的数据分两次,每次1MB写入(调用write函数)到共享表空间的物理磁盘上(doublewrite页),然后马上调用fsync函数将磁盘缓冲区中的数据刷新到磁盘中。其中,因为doublewrite页是连续的,因此这个过程是顺序写入的,开销并不是很大。
  3. 最后,再将doublewrite buffer中的数据写入各个表空间文件中,此时写入则是离散的。

而,如果在上述第三步将doublewrite buffer中的数据写入各个表文件时发生了数据库宕机而导致的部分写失效(Partial Page Write),程序就会首先从共享表空间中的doublewrite页找到该页的副本,然后将其复制到对应的表空间文件(还原/恢复),最后再应用重做日志。具体流程图如下所示:

       ┌───────────────────────────────────────────────────────────┐         
       │                                                           │         
       │    ┌────────────┐                         memory          │         
       │    │            │                                         │         
       │    │    page    │         ┌──────────────────────────┐    │         
       │    │            │         │                          │    │         
       │    └────────────┘         │                          │    │         
       │                           │    doublewrite buffer    │    │         
       │                           │                          │    │         
       │    ┌────────────┐         │                          │    │         
       │    │            │         └───────────────────┬──────┘    │         
       │    │    page    │                             │           │         
       │    │            │                             │           │         
       │    └────────────┘                             │           │         
       └───────────────────────────────────────────────┼───────────┘         
                                                       │     2               
                                                       └───────────┐         
                                                           write   │         
                                                                   │         
┌──────────────────────────────────────────────┐           ┌───────▼────────┐
│                                              │           │                │
│    ┌──────────────────┬─────────────────┐    │           │                │
│    │                  │                 │    │           │                │
│    │    doublewrite   │   doublewrite   │    │    1      │                │
│    │       (1MB)      │      (1MB)      │    ├──────────►│ datafile(.ibd) │
│    │                  │                 │    │ recovery  │                │
│    └──────────────────┴─────────────────┘    │           │                │
│                      Shared tablespace       │           │                │
└──────────────────────────────────────────────┘           │                │
                                                           └────────────────┘

二阶段提交(2PC)

最后,在MySQL的“双1”配置下,为了保证二进制日志(BinLog)与重做日志(RedoLog)这两份日志之间的一致性,在提交事务时使用了“二阶段提交”策略。具体执行步骤如下所示:

MySQL的“双1”配置指的是sync_binloginnodb_flush_log_at_trx_commit都设置成1,即一个事务完整提交前,需要等待两次刷盘,一次是RedoLog,一次是BinLog

  1. 准备阶段(Prepare Phase),存储引擎将内存中的事务对象标记为“准备提交”(RedoLog处于Prepare状态)。
  2. 提交阶段(Commit Phase):
    1. 将准备阶段及其之前产生在重做日志缓冲区(RedoLog Buffer)中尚未提交的日志记录写入重做日志文件(RedoLog File)中。在执行上会先通过write操作写入到文件系统的PageCache中,然后再通过fsync操作将PageCache中的数据写入到磁盘中。
    2. 将事务执行过程中产生的二进制日志缓冲(BingLog Cache)写入到二进制日志(Binlog)中。在执行上会先通过write操作写入到文件系统的PageCache中,然后再通过fsync操作将PageCache中的数据写入到磁盘中。
    3. 存储引擎将内存中的事务对象标记为“已提交”(RedoLog处于Commit状态)。

简单来说,在提交事务时会先将RedoLog标记为Prepare状态,然后写入RedoLog,接着再写入BinLog,最后再将RedoLog标记为Commit状态,具体如下图所示:

                      ┌───────────────────┐   ┌───────────────────┐                      
                      │ ┌───────────────┐ │   │ ┌───────────────┐ │                      
                      │ │               │ │   │ │               │ │                      
┌─────────────────┐   │ │ RedoLog write │ │   │ │ BinLog write  │ │   ┌─────────────────┐
│                 │   │ │               │ │   │ │               │ │   │                 │
│ RedoLog Prepare ├──►│ └───────────────┘ ├──►│ └───────────────┘ ├──►│ RedoLog commit  │
│                 │   │ ┌───────────────┐ │   │ ┌───────────────┐ │   │                 │
└─────────────────┘   │ │               │ │   │ │               │ │   └─────────────────┘
                      │ │ RedoLog fsync │ │   │ │ BinLog fsync  │ │                      
                      │ │               │ │   │ │               │ │                      
                      │ └───────────────┘ │   │ └───────────────┘ │                      
                      └───────────────────┘   └───────────────────┘                      

通过这种方式,MySQL就可以在数据库或者服务器发生崩溃时保证二进制日志(BinLog)与重做日志(RedoLog)的一致性了。

MySQL在进行事务崩溃恢复时会遵循以下判断规则:

  1. 如果在恢复时发现事务的重做日志(RedoLog)是完整的,即存在Commit标识,则直接提交事务。
  2. 如果在恢复时发现事务的重做日志(RedoLog) 不是完整的,则判断是否存在Prepare标识:
    1. 如果存在存在Prepare标识,则接着判断事务的对应二进制日志(BinLog)(通过XID查找出对应的BinLog)是否完整:

      1. 如果事务的对应二进制日志(BinLog)是完整的,则提交事务。
      2. 如果事务的对应二进制日志(BinLog)不是完整的,则回滚事务。
    2. 如果不存在存在Prepare标识,则回滚事务。

对于事务的二进制日志(BinLog)是否完整,可通过以下规则进行判断:

  1. 对于STATEMENT格式的二进制日志(BinLog),事务的最后是否存在COMMIT标识,如果存在则表示完整,否则表示不完整;
  2. 对于ROW格式的二进制日志(BinLog),事务的最后是否存在XID event标识,如果存在则表示完整,否则表示不完整;

另外,在MySQL 5.6.2版本以后还引入了binlog-checksum参数,用来验证二进制日志(BinLog)内容的正确性。如果由于磁盘的原因导致二进制日志(BinLog)中间出错了,MySQL可以通过校验checksum的结果来发现。

在了解“二阶段提交”策略下的事务崩溃恢复逻辑后,我们再来看看在不使用“二阶段提交”策略下,MySQL在进行事务崩溃恢复时会出现什么问题:

  1. 对于先写重做日志(RedoLog)再写二进制日志(BinLog)的策略。在一个事务提交后,如果在重做日志(RedoLog)写入后二进制日志(BinLog)写入前MySQL发生了宕机,在进行崩溃恢复时,重做日志(RedoLog)是可以将这个尚未提交的事务恢复回来的,但是由于二进制日志(BinLog)尚未完成对这个事务的写入,因此二进制日志(BinLog)是无法将这个尚未提交的事务恢复的(丢失了),这就会导致二进制日志(BinLog)与重做日志(RedoLog)的不一致。更严重的是,在进行备份日志或者主从同步时(通过二进制日志(BinLog)完成),由于这个事务尚未写入到二进制日志(BinLog)中,因此备库/从库是不会包含这个事务的,这就会导致主从/主备的数据不一致。
  2. 对于先写二进制日志(BinLog)再写重做日志(RedoLog)的策略。在一个事务提交后,如果在二进制日志(BinLog)写入后重做日志(RedoLog)写入前MySQL发生了宕机,在进行崩溃恢复时,由于重做日志(RedoLog)尚未将这个事务写入,因此这个事务时无效的(回滚)。但是由于二进制日志(BinLog)已经完成这个事务的写入,因此二进制日志(BinLog)中会多出一个事务出来,这就会导致二进制日志(BinLog)与重做日志(RedoLog)的不一致。更严重地是,在进行备份日志或者主从同步时(通过二进制日志(BinLog)完成),由于这个事务已写入二进制日志(BinLog)中,因此备库/从库是会包含这个事务的,这就会导致主从/主备的数据不一致。

总结

总的来说,MySQL通过RedoLogBinLog的协同,结合Write Ahead LogCheckpointDouble Write2PC等机制保证了事务的持久性,其中:

  • Write Ahead Log机制通过顺序IO替代随机IO,优化了事务提交时日志刷盘的效率,是平衡性能与可靠性的关键技术。
  • Checkpoint机制会定期将缓冲池中的脏页刷盘,并标记脏页的日志偏移量,以减少恢复时日志的处理范围。
  • Double Write机制会在数据页写入前先将它的副本持久化到共享表空间,以防止因部分写失效导致的数据页损坏,确保了数据完整性。
  • 2PC机制通过PrepareCommit阶段来协调RedoLogBinLog,确保了二者的数据一致性。

这些机制环环相扣:Write Ahead Log为基石,Checkpoint优化刷盘和恢复的效率,Double Write保障数据页完整,2PC解决日志一致性,共同构建高可靠的持久性体系。

参考