MySQL 中事务的原子性是通过 undo log 来实现的,事务的持久性是通过 redo log 来实现的,事务的隔离性是通过读写锁 +MVCC 来实现的。事务的一致性通过原子性、隔离性、持久性来保证。也就是说 ACID 四大特 性之中,C( 一致性 ) 是目的, A( 原子性 ) 、 I( 隔离性 ) 、 D( 持久性 ) 是手段,是为了保 证一致性,数据库提供的手段。数据库必须要实现 AID 三大特性,才有可能实现
一致性。同时一致性也需要应用程序的支持,应用程序在事务里故意写出违反约 束的代码,一致性还是无法保证的,例如,转账代码里从 A 账户扣钱而不给 B账户加钱,那一致性还是无法保证。
在事务的具体实现机制上, MySQL 采用的是 WAL ( Write-ahead logging ,预写 式日志)机制来实现的。这也是是当今的主流方案。
在使用 WAL 的系统中,所有的修改都先被写入到日志中,然后再被应用到系 统中。通常包含 redo 和 undo 两部分信息。
为什么需要使用 WAL ,然后包含 redo 和 undo 信息呢?举个例子,如果一个 系统直接将变更应用到系统状态中,那么在机器掉电重启之后系统需要知道操作 是成功了,还是只有部分成功或者是失败了(为了恢复状态),如果使用了 WAL , 那么在重启之后系统可以通过比较日志和系统状态来决定是继续完成操作还是 撤销操作。
redo log 称为重做日志,每当有操作时,在数据变更之前将操作写入 redo log , 这样当发生掉电之类的情况时系统可以在重启后继续操作。
undo log 称为撤销日志,当一些变更执行到一半无法完成时,可以根据撤销日 志恢复到变更之间的状态。 前面说过,MySQL 中用 redo log 来在系统 Crash 重启之类的情况时修复数据(事务的持久性),而 undo log 来保证事务的原子性。
tips : Commit Logging 和 Shadow Paging 事务的日志类型的实现除了 WAL ( Write-ahead logging ,预写式日志)外,还 有“ Commit Logging ”(提交日志),这种方式只有在日志记录全部都安全落盘, 数据库在日志中看到代表事务成功提交的“提交记录”( Commit Record )后, 才会根据日志上的信息对真正的数据进行修改,修改完成后,再在日志中加入一 条“结束记录”( End Record )表示事务已完成持久化。两者的区别是, WAL 允 许在事务提交之前,提前写入变动数据,而 Commit Logging 则不行; WAL 中有 undo 日志, Commit Logging 没有。阿里的 OceanBase 则是使用的 Commit Logging 来实现事务。
实现事务的原子性和持久性除日志外,还有另外一种称为“ Shadow Paging ” (有中文资料翻译为“影子分页”)的事务实现机制,常用的轻量级数据库 SQLite Version 3 采用的事务机制就是 Shadow Paging 。
Shadow Paging 的大体思路是对数据的变动会写到硬盘的数据中,但并不是直 接就地修改原先的数据,而是先将数据复制一份副本,保留原数据,修改副本数 据。在事务过程中,被修改的数据会同时存在两份,一份是修改前的数据,一份 是修改后的数据,这也是“影子”( Shadow )这个名字的由来。当事务成功提 交,所有数据的修改都成功持久化之后,最后一步是去修改数据的引用指针,将 引用从原数据改为新复制出来修改后的副本,最后的“修改指针”这个操作将被 认为是原子操作,现代磁盘的写操作可以认为在硬件上保证了不会出现“改了半 个值”的现象。所以 Shadow Paging 也可以保证原子性和持久性。 Shadow Paging 实现事务要比 Commit Logging 更加简单,但涉及隔离性与并发锁时, Shadow Paging 实现的事务并发能力就相对有限,因此在高性能的数据库中应用不多。
redo 日志
redo日志是mysql在进行写操作时,为提高写入效率同时保证数据持久化而采用的日志预写技术。当mysql在进行写操作时,如果立即将修改的数据落盘,会产生一次或多次的随机IO写操作,效率较低。因此,为提高写入效率,mysql在写入数据时,并没有立即将数据进行持久化,而是将要修改的相关信息记录在了内存的日志缓存区log buffer中,再由log buffer写入到磁盘的redo log中,返回客户端修改成功。redo log的磁盘写入是一种文件追加式的顺序写操作,效率较高。
redo log buffer: 由若干个连续的内存空间redo log block组成,每个redo log block为512字节。
redo log buffer的大小由innodb_log_buffer_size 来指定 ,该启动参数的默认值为 16MB。向log buffer中写入redo日志的过程是顺序的,也就是先往前边的block中写,当该 block 的空闲空间用完之后再往下一个 block 中写。
redo log 刷盘时机
1. 如果当前写入 log buffer 的 redo 日志量已经占满了 log buffer 总容量的大约一半左右,就需要把这些日志刷新到磁盘上。
2. 事务提交时,必须要把log buffer 持久化到 磁盘上的redo 日志,以保证持久性。
3. 后台有一个线程,大约每秒都会刷新一次 log buffer 中的 redo 日志到磁盘。
4. 正常关闭服务器时等等。
redo 日志文件组
redo日志默认有两个文件ib_logfile0 和 ib_logfile1(使用 SHOW VARIABLES LIKE 'datadir'查看),在日志写入时,先从ib_logfile0 开始写,ib_logfile0 写满了,就接着 ib_logfile1 写,如此循环往复。既然 Redo log 文件是循环写入的,在覆盖写之前,总是要保证对应的脏页已经刷到了磁盘。在非常大的负载下,为避免错误的覆盖,InnoDB 会强制的 flush脏页。如果我们对默认的 redo 日志文件不满意,可以通过下边几个启动参数来调节:
innodb_log_group_home_dir,该参数指定了 redo 日志文件所在的目录,默认值就是当前的数据目录。 innodb_log_file_size,该参数指定了每个 redo 日志文件的大小,默认值为 48MB, innodb_log_files_in_group,该参数指定 redo 日志文件的个数,默认值为 2,最大值为 100。
redo 日志文件格式
我们前边说过 log buffer 本质上是一片连续的内存空间,被划分成了若干个 512 字节大小的 block 。将 log buffer 中的 redo 日志刷新到磁盘的本质就是把 block 的 镜像写入日志文件中,所以 redo 日志文件其实也是由若干个 512 字节大小的 block 组成。 redo 日志文件组中的每个文件大小都一样,格式也一样,都是由两部分组成: 前 2048 个字节,也就是前 4 个 block 是用来存储一些管理信息的。 从第 2048 字节往后是用来存储 log buffer 中的 block 镜像的。
Log Sequence Number
自系统开始运行,就不断的在修改页面,也就意味着会不断的生成 redo 日志。 redo 日志的量在不断的递增,就像人的年龄一样,自打出生起就不断递增,永 远不可能缩减了。 InnoDB 为记录已经写入的 redo 日志量,设计了一个称之为 Log Sequence Number 的全局变量,翻译过来就是:日志序列号,简称 LSN 。规定初始的 lsn 值为 8704 (也就是一条 redo 日志也没写入时, LSN 的值为 8704 )。 redo 日志都有一个唯一的 LSN 值与其对应, LSN 值越小,说明 redo 日志产生的越早。
flushed_to_disk_lsn
redo 日志是首先写到 log buffer 中,之后才会被刷新到磁盘上的 redo 日志文 件。InnoDB 中有一个称之为 buf_next_to_write 的全局变量,标记当前 log buffer 中已经有哪些日志被刷新到磁盘中了。 我们前边说 lsn 是表示当前系统中写入的 redo 日志量,这包括了写到 log buffer 而没有刷新到磁盘的日志,相应的,InnoDB 也有一个表示刷新到磁盘中的 redo 日志量的全局变量,称之为 flushed_to_disk_lsn 。系统第一次启动时,该变量的 值和初始的 lsn 值是相同的,都是 8704 。随着系统的运行, redo 日志被不断写入 log buffer,但是并不会立即刷新到磁盘, lsn 的值就和 flushed_to_disk_lsn 的值拉 开了差距。我们演示一下:
系统第一次启动后,向 log buffer 中写入了 mtr_1 、 mtr_2 、 mtr_3 这三个 redo 日志,假设这三个 mtr 开始和结束时对应的 lsn 值分别是:
mtr_1 : 8716 ~ 8916
mtr_2 : 8916 ~ 9948
mtr_3 : 9948 ~ 10000
此时的 lsn 已经增长到了 10000 ,但是由于没有刷新操作,所以此时 flushed_to_disk_lsn 的值仍为 8704 。 随后进行将 log buffer 中的 block 刷新到 redo 日志文件的操作,假设将 mtr_1 和 mtr_2 的日志刷新到磁盘,那么 flushed_to_disk_lsn 就应该增长 mtr_1 和 mtr_2 写入的日志量,所以 flushed_to_disk_lsn 的值增长到了 9948 。
综上所述,当有新的 redo 日志写入到 log buffer 时,首先 lsn 的值会增长,但flushed_to_disk_lsn 不变,随后随着不断有 log buffer 中的日志被刷新到磁盘上, flushed_to_disk_lsn 的值也跟着增长。如果两者的值相同时,说明 log buffer 中的 所有 redo 日志都已经刷新到磁盘中了。
Tips :应用程序向磁盘写入文件时其实是先写到操作系统的缓冲区中去,如果 某个写入操作要等到操作系统确认已经写到磁盘时才返回,那需要调用一下操作 系统提供的 fsync 函数。其实只有当系统执行了 fsync 函数后, flushed_to_disk_lsn 的值才会跟着增长,当仅仅把 log buffer 中的日志写入到操作系统缓冲区却没有 显式的刷新到磁盘时,另外的一个称之为 write_lsn 的值跟着增长。 当然系统的 LSN 值远不止我们前面描述的 lsn ,还有很多。
查看系统中的各种 LSN 值 我们可以使用SHOW ENGINE INNODB STATUS 命令查看当前 InnoDB 存储引擎中 的各种 LSN 值的情况,比如:
SHOW ENGINE INNODB STATUS\G
其中:
Log sequence number :代表系统中的 lsn 值,也就是当前系统已经写入的 redo 日志量,包括写入 log buffer 中的日志。
Log flushed up to :代表 flushed_to_disk_lsn 的值,也就是当前系统已经写入磁 盘的 redo 日志量。
Pages flushed up to :代表 flush 链表中被最早修改的那个页面对应的
oldest_modification 属性值。
Last checkpoint at :当前系统的 checkpoint_lsn 值。
innodb_flush_log_at_trx_commit 的用法
我们前边说为了保证事务的持久性,用户线程在事务提交时需要将该事务执 行过程中产生的所有 redo 日志都刷新到磁盘上。会很明显的降低数据库性能。 如果对事务的持久性要求不是那么强烈的话,可以选择修改一个称为 innodb_flush_log_at_trx_commit 的系统变量的值,该变量有 3 个可选的值:
0 :当该系统变量值为 0 时,表示在事务提交时不立即向磁盘中同步 redo 日志, 这个任务是交给后台线程做的。 这样很明显会加快请求处理速度,但是如果事务提交后服务器挂了,后台线 程没有及时将 redo 日志刷新到磁盘,那么该事务对页面的修改会丢失。
1 :当该系统变量值为 1 时,表示在事务提交时需要将 redo 日志同步到磁盘, 可以保证事务的持久性。1 也是 innodb_flush_log_at_trx_commit 的默认值。
2 :当该系统变量值为 2 时,表示在事务提交时需要将 redo 日志写到操作系统 的缓冲区中,但并不需要保证将日志真正的刷新到磁盘。 这种情况下如果数据库挂了,操作系统没挂的话,事务的持久性还是可以保 证的,但是操作系统也挂了的话,那就不能保证持久性了。
undo 日志
undo日志又称为撤销日志,它时用来记录的历史版本的链表,当事务在进行增删改操作时需要记录回滚日志,用于事务的回滚。undo日志
事务 id
事务可分为只读事务和读写事务,只读事务通过 START TRANSACTION READ ONLY 语句开启,只允许对临时表做增删改(CREATE TEMPORARY TABLE创建的表),不允许对其他正常的表做增删改,只读事务也不会被分配事务ID。
读写事务通过 START TRANSACTION READ WRITE或BEGIN、START TRANSACTION等语句开启,读写事务里允许对正常表的增删改,当事务第一次会表进行增删改操作时就分配一个自增的事务Id,这个id为8字节。如果事务里只有查询操作,则不分配事务Id。
事务 id 生成机制
事务ID由一个8字节的全局变量维护,每当事务需要被分配事务ID时,则将加载到内存的最大事务id变量加1,每当此变量为256的倍数时,将此变量刷到系统表空间的5号页面的一个称为Max Trx ID的属性处,此属性占用8字节。当mysql重启时,则将该事务加256后加载给内存的全局变量,用以继续分配新事务id。
undo 日志的格式
undo日志也是由16KB的页面来存储,该页面类型为FIL_PAGE_UNDO_LOG 。一个增删改操作可能会产生多条日志,每条日志都有一个编号undo no,从0开始依次递增。我们在更新数据时会同时更新聚集索引和二级索引,但对于undo日志的维护只需要考虑聚集索引,因为聚集索引和二级索引存在一一对应关系。
INSERT 操作对应的 undo 日志
insert操作的undo日志类型为TRX_UNDO_INSERT_REC,在进行新增操作时,只在undo日志里记录主键id,在回滚时,根据主键做一次删除操作,更新聚集索引和二级索引,即可完成事务回滚。
DELETE 操作对应的 undo 日志
delete操作的undo日志类型为 TRX_UNDO_DEL_MARK_REC,mysql删除记录可分为两步:
1. 在执行删除操作时,将删除记录的 delete_mask 标识位设置为 1 ,这个阶段称之为 delete mark 。
2. 在事务提交后,有一个专门的后台进程把标记为删除状态的记录从正常链表中去除,加入到垃圾链表中(页面的 PageHeader 部分的 PAGE_FREE 属性的值代表指向垃圾链表头节点的指针),此时该记录就完成正常的删除,这部分被垃圾链表占用的空间可以被再次覆盖使用。
UPDATE 操作对应的 undo 日志
在执行 UPDATE 语句时, InnoDB 对更新主键和不更新主键这两种情况有截然 不同的处理方案。
不更新主键:
不更新主键更新操作会同时记录一种类型为 TRX_UNDO_UPD_EXIST_REC的undo日志。不更新主键时也分为两种情况:
1. 如果更新是每个字段值的长度都没发生变化,则就地更新覆盖旧纪录。
2. 如果有任何一个字段长度发生变化,则在聚集索引中将旧记录存放到垃圾链表,在页面中寻找一块新的内存空间记录新记录,如果页面空间不够,则要发生页分裂后再插入新记录。
更新主键:
主键更新后记录的位置发生变化,这种情况可分为两步进行。
1. 在事务提交前记录 一条类型为 TRX_UNDO_DEL_MARK_REC 的 undo 日志,然后将记录的 delete mark 标记为1,事务提交后由后台进程对其进行purge操作,将记录添加到垃圾链表。
2. 根据新主键位置插入一条新记录,同时记录一条 类型为 TRX_UNDO_INSERT_REC 的 undo 日志,也就是说,更新主键的这种情况,会记录两条undo日志。
总结事务的流程
总的来说,事务流程分为事务的执行流程和事务恢复流程。
事务执行
我们已经知道了 MySQL 的事务主要主要是通过 Redo Log 和 Undo Log 实现的。
MySQL 事务执行流程如下图
可以看出, MySQL 在事务执行的过程中,会记录相应 SQL 语句的 UndoLog 和 Redo Log,然后在内存中更新数据并形成数据脏页。接下来 RedoLog 会根据一定 规则触发刷盘操作,Undo Log 和数据脏页则通过刷盘机制刷盘。事务提交时, 会将当前事务相关的所有 Redo Log 刷盘,只有当前事务相关的所有 Redo Log 刷 盘成功,事务才算提交成功。
事务恢复
如果一切正常,则 MySQL 事务会按照上图中的顺序执行。如果 MySQL 由于某 种原因崩溃或者宕机,当然进行数据的恢复或者回滚操作。 如果事务在执行第 8 步 , 即事务提交之前, MySQL 崩溃或者宕机 , 此时会先使用 Redo Log 恢复数据 , 然后使用 Undo Log 回滚数据。 如果在执行第8 步之后 MySQL 崩溃或者宕机,此时会使用 Redo Log 恢复数据, 大体流程如下图所示。
很明显, MySQL 崩溃恢复后,首先会获取日志检查点信息,随后根据日志检 查点信息使用 Redo Log 进行恢复。 MySQL 崩溃或者宕机时事务未提交,则接下 来使用 Undo Log 回滚数据。如果在 MySQL 崩溃或者宕机时事务已经提交,则用 Redo Log 恢复数据即可。
恢复机制
在服务器不挂的情况下, redo 日志简直就是个大累赘,不仅没用,反而让性 能变得更差。但是万一数据库挂了,就可以在重启时根据 redo 日志中的记录就 可以将页面恢复到系统崩溃前的状态。
MySQL 可以根据 redo 日志中的各种 LSN 值,来确定恢复的起点和终点。然后 将 redo 日志中的数据,以哈希表的形式,将一个页面下的放到哈希表的一个槽 中。之后就可以遍历哈希表,因为对同一个页面进行修改的 redo 日志都放在了 一个槽里,所以可以一次性将一个页面修复好(避免了很多读取页面的随机 IO )。 并且通过各种机制,避免无谓的页面修复,比如已经刷新的页面进而提升崩溃恢复的速度。
崩溃后的恢复为什么不用 binlog?
1、这两者使用方式不一样 , binlog 会记录表所有更改操作,包括更新删除数据,更改表结构等等,主要 用于人工恢复数据,而 redo log 对于我们是不可见的,它是 InnoDB 用于保证 crash-safe 能力的,也就是在事务提交后 MySQL 崩溃的话,可以保证事务的持久 性,即事务提交后其更改是永久性的。 一句话概括:binlog 是用作人工恢复数据, redo log 是 MySQL 自己使用,用
于保证在数据库崩溃时的事务持久性。
2 、 redo log 是 InnoDB 引擎特有的, binlog 是 MySQL 的 Server 层实现的 , 所有引擎都可以使用。
3 、 redo log 是物理日志,记录的是“在某个数据页上做了什么修改”,恢复 的速度更快;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给 ID=2 这的 c 字段加 1 ” ;
4 、 redo log 是“循环写”的日志文件, redo log 只会记录未刷盘的日志,已 经刷入磁盘的数据都会从 redo log 这个有限大小的日志文件里删除。 binlog 是 追加日志,保存的是全量的日志。
5 、最重要的是,当数据库 crash 后,想要恢复未刷盘但已经写入 redo log 和 binlog 的数据到内存时, binlog 是无法恢复的。虽然 binlog 拥有全量的日志, 但没有一个标志让 innoDB 判断哪些数据已经入表 ( 写入磁盘 ) ,哪些数据还没有。
比如, binlog 记录了两条日志:
给 ID=2 这一行的 c 字段加 1
给 ID=2 这一行的 c 字段加 1
在记录 1 入表后,记录 2 未入表时,数据库 crash 。重启后,只通过 binlog 数 据库无法判断这两条记录哪条已经写入磁盘,哪条没有写入磁盘,不管是两条都 恢复至内存,还是都不恢复,对 ID=2 这行数据来说,都不对。 但 redo log 不一样,只要刷入磁盘的数据,都会从 redo log 中抹掉,数据库 重启后,直接把 redo log 中的数据都恢复至内存就可以了。
Redo 日志和 Undo 日志的关系
数据库崩溃重启后,需要先从 redo log 中把未落盘的脏页数据恢复回来,重新 写入磁盘,保证用户的数据不丢失。当然,在崩溃恢复中还需要把未提交的事务 进行回滚操作。由于回滚操作需要 undo log 日志支持, undo log 日志的完整性和 可靠性需要 redo log 日志来保证,所以数据库崩溃需要先做 redo log 数据恢复, 然后做 undo log 回滚。 在事务执行过程中,除了记录 redo 一些记录,还会记录 undo log 日志。 Undo log 记录了数据每个操作前的状态,如果事务执行过程中需要回滚,就可以根据 undo log 进行回滚操作。 因为 redo log 是物理日志,记录的是数据库页的物理修改操作。所以 undo log (可以看成数据库的数据)的写入也会伴随着 redo log 的产生,这是因为 undo log 也需要持久化的保护。
事务进行过程中,每次 sql 语句执行,都会记录 undo log 和 redo log ,然后更 新数据形成脏页。事务执行 COMMIT 操作时,会将本事务相关的所有 redo log 进行落盘,只有所有的 redo log 落盘成功,才算 COMMIT 成功。然后内存中的 undo log 和脏页按照同样的规则进行落盘。如果此时发生崩溃,则只使用 redo log 恢复数据。
同时写 Redo 和 Binlog 怎么保持一致?
当我们开启了 MySQL 的 BinLog 日志,很明显需要保证 BinLog 和事务日志的一 致性,为了保证二者的一致性,使用了两阶段事务 2PC (所谓的两个阶段是指: 第一阶段:准备阶段和第二阶段:提交阶段,具体的内容请参考分布式事务的相 关的内容)。步骤如下:
1 )当事务提交时 InnoDB 存储引擎进行 prepare 操作。
2 ) MySQL 上层会将数据库、数据表和数据表中的数据的更新操作写入 BinLog 文件。
3 ) InnoDB 存储引擎将事务日志写入 Redo Log 文件中。