MySQL 事务日志

发布时间 2023-12-27 16:36:34作者: 小林当

MySQL 事务日志

事务有4种特性:原子性,一致性,隔离性和持久性。那么事务的四种特性到底是基于什么机制实现呢(是通过什么来控制的呢)?

  • 事务的"隔离性"锁机制 实现(通过加锁来实现隔离)。
  • 而事务的"原子性","一致性"和"持久性"由事务的 redo 日志undo 日志 来保证
    • redo log 称为 重做日志,提供再写入操作(将缓存中修改的数据写入到文件系统中),恢复(这里的恢复指的是数据库服务崩溃之后的恢复)提交事务修改的页操作,用来保证事务的持久性
    • undo log 称为 回滚日志,回滚行记录,到某个特定版本,用来保证事务的原子性,一致性

有些 DBA 认为 undoredo 的逆过程,其实不然。undoredo 都可以视为是一种 恢复操作

  • redo log:是存储引擎层(innodb)生成的日志,记录的是 "物理级别" 上的页修改操作,比如:页号xxx,偏移量yyy,写入了zzz数据。主要为了保证数据的可靠性(持久性)。
  • undo log:是存储引擎层(innodb)生成的日志,记录的是 "逻辑操作" 日志,比如:对某张表数据进行了 insert 语句操作,那么 undo log 就记录一条与之相反的 delete 操作。主要用于 事务的回滚(undo log 记录的是每个修改操作的 逻辑逆操作)和 一致性非锁操作(undo log 回滚行记录,到某种特定的版本 -- MVCC(多版本并发控制))。

1. redo 日志

InnoDB 存储引擎是以 页为单位 来管理存储空间的。在真正访问页(记录)之前,需要把在 磁盘上 的页先缓存到内存中的 Buffer Pool (缓冲池) 中,然后才可以访问。所有的修改操作都必须 先更新缓冲池 中的数据。

然后缓冲池中的 脏页(缓冲池中未进行刷盘的页) 会以一定的频率被刷入磁盘(checkPoint机制),通过使用缓冲池来优化 cpu 和 磁盘 之间的鸿沟,这样就可以保证整体的性能下降不会太快。

1.1 为什么需要 redo 日志

答:为了保证事务的 "持久性"

首先说,缓冲池可以帮助我们消除 CPU磁盘 之间的鸿沟,checkPoint 机制可以保证数据的最终写入到磁盘中,然而由于 checkPoint 并不是每次数据变更之后就触发 的,而是由 master(主) 线程隔一段时间去处理的。所以可能会出现:当事务提交后,刚更新完缓冲池,然后数据库就宕机了,那么这个时间段(该"时间段"是指:上次触发刷盘的时间 ~ 数据库宕机的时间)内的数据就是丢失了,将无法恢复。

其次就是,事务包含 持久性 的特性,就是说对于一个已经提交的事务,在事务提交后即使系统发生了崩溃,这个事务对数据库中所做的更改也是不能丢失的

那么如何保证这个持久性呢?

一个简单的做法:就是 在事务完成之前把该事务所修改的所有页(数据也)都刷新到磁盘,但是这个简单粗暴的做法:

  • 修改量与刷新磁盘工作量严重不成比例

有时候我们只是修改了某个页面中的一个字节,但是却要刷新整个页的数据的到磁盘中

我们知道在InnoDB中是以页为单位来进行磁盘IO的,也就是说我们在该事务提交时不得不将一个完整的页面从内存中刷新到磁盘,一个页默认是16KB。如果我们只是修改了一个字节的话,那么也是要刷新16KB的数据到磁盘上的,这样显然是太小题大做了。

  • 随机 IO 刷新较慢

一个事务可能包含很多语句,一条语句也可能会修改许多张页(数据页),假如该事务修改的这些页(数据页)并不相邻,这就意味着在将某个事务修改的 Buffer Pool(缓冲池)中的页(数据页) 刷新到磁盘 时,需要进行很多的 随机IO,随机IO比顺序IO要慢,尤其是对于传统的机械硬盘来说。

另外一个解决的思路:将事务提交后,先将修改的内容写入日志(redo 日志),再写入磁盘

我们最终的目的是将在事务中修改的数据在数据库中永久生效,即使数据库崩溃,宕机,在重启后也能把丢失的修改(数据)恢复出来。所以就没有必要在每次事务提交时就把该事务在内存中修改过的页(数据页)刷新到磁盘,只需要把 修改的内容记录一下 就好。比如,某个事务将某表空间中 第10号 页(数据页)中偏移量为 100 处的那个字节值 1 改成 2。我们只需要记录一下:将某号表空间的10号页(数据页)的偏移量为100处的值更新为2

InnoDB 引擎的事务采用了 WAL 技术(Write-Ahead Logging),这种技术的思想就是先写日志(redo log),再写磁盘,只有日志写入成功,才算事务提交成功。当发生宕机且数据未刷新到磁盘的时候,可以 通过 redo log 来恢复,保证 ACID 中D (持久性) ,这就是 redo log 的作用。

1.2 redo 日志的好处,特点

1.2.1 好处

  • redo 日志降低了刷盘频率
  • redo 日志占用的空间非常小

redo 日志中只 存储表空间ID,页号,偏移量以及需要更新的值,所需的存储空间是很小的,刷盘快。

1.2.2 特点

  • redo 日志是顺序写入磁盘的(顺序IO)

在执行事务的过程中,每执行一条语句,就有可能产生若干条 redo 日志,这些日志是按照 产生的顺序写入磁盘的,也就是使用顺序IO,效率比随机IO快。

要怎么理解这里的 顺序IO 呢?

注意:这里 顺序IO 指的是:读取 redo log 文件时的 IO 的顺序

  • 随机IO:如果在 redo log 中不是按照事务中执行语句的顺序记录的话,就必须使用链表将记录关联(逻辑关联)起来,否则就会出现记录错乱的情况。而且在刷盘时,由于 redo log 中的记录不是有序的,则需要沿着链表依次遍历将记录写入磁盘中。这样就会产生 随机IO 及其耗费资源的(redo log 中有多少条记录就需要多少次 IO)。
  • 顺序IO:如果在 redo log 中是按照事务中执行语句的顺序记录的话,记录和记录之间就无需任何关联,那么在刷盘时,就只需将记录一次性读出,按照顺序(写入 redo log 的顺序)写入磁盘即可(不管 redo log 中多少条记录一次 IO 足矣)。
  • 事务执行过程中,redo log 会不断的记录

redo log 跟 bin log 的区别。redo log存储引擎层 产生的,而 bin log数据库层 产生的。假设一个事务,对表做10万行的记录插入,在这个过程中,会一直不断的往 redo log 顺序记录,而 bin log 不会记录,直到这个事务提交,才回一次写入到 bin log 文件中。(redo log 是记录过程,bin log 是记录结果

1.3 redo 日志的组成

redo log 可以简单分为以下两个部分:

  • 重做日志的缓冲区(redo log buffer),保存在内存中

在 MySQL 服务器启动时会向操作系统申请一大片称之为 redo log buffer(redo日志缓冲区)连续内存 空间。这片内存空间被划分成若干个连续的 redo log block(redo日志块)。一个 redo log block(redo日志块) 占用 512 字节大小。

设置 redo log buffer(redo日志缓冲区) 的大小:通过系统变量 innodb_log_buffer_size 控制(全局级别),默认16M,最大值是 4096 M(4G),最小值为 1M

  • 查看 redo日志缓冲区的大小
show variables like 'innodb_log_buffer_size';
# 或者
SELECT @@global.innodb_log_buffer_size;
# 或者
SELECT @@innodb_log_buffer_size;
  • 设置 redo日志缓冲区的大小
SET GLOBAL innodb_log_buffer_size = 17825792;
  • 重做日志文件(redo log file),保存在硬盘中,是持久的。

redo log 文件默认放在数据库的数据目录下(show variables like '%datadir%'),名称为 ib_logfile0ib_logfile1

1.4 redo 的整体流程

以一个更新事务为例,redo log 操作过程,如下图所示:

第一步:先来 将原始数据从磁盘中读到内存中,修改数据的内存拷贝(undo log)

第二步:生成一条重做日志并写入 redo log buffer(redo 缓冲区) 中,记录的是数据被修改后的值

第三步:当事务 提交(commit) 时,会将 redo log buffer(redo缓冲区) 中的内容刷新到 redo log file(redo 日志文件),采用 追加的方式写入

第四步:定期将内存中修改的数据刷新到磁盘中

注意:是定期将 "内存中修改的数据" 刷新到 "磁盘" 中,而不是通过 redo log 刷盘的。

体会一下(保证数据的持久性)

Write-Ahead Log(预先/提前日志持久化):在持久化一个 "数据页" 之前,先将内存中相应的 "日志页" 持久化。

1.5 redo log 的刷盘策略

注意:以下所说的刷盘策略指的是:将 redo日志缓存区(redo log buffer)中的数据刷盘到 redo日志文件(redo log file)中的策略

redo log 的写入并不是直接写入磁盘的,InnoDB 引擎在写 redo log 时,会先将修改的数据写入到 redo日志缓冲区(redo log buffer) 中,之后以 一定的频率 刷入到真正的 redo日志文件(redo log file) 中。这里的 一定的频率 就是我们要说 刷盘策略

注意:redo 日志缓冲区(redo log buffer) 刷盘到 redo日志文件(redo log file) 的过程并不是真正的刷到磁盘中去,只是刷入到 文件系统缓存(page cache)中去(这是现代操作系统为了提高文件写入效率做的一个优化),真正的写入会交给操作系统来决定。那么对于 InnoDB 来说就存在一个问题,如果交给操作系统来同步,当操作系统发生宕机时,数据同样也丢失

针对这种情况,InnoDB 给出了 innodb_flush_log_at_trx_commit全局级别)的参数,该参数用于控制事务 提交(commit) 时,如何将 redo日志缓冲区(redo log buffer) 中的日志刷盘到 redo日志文件(redo log file)中。支持的三种策略如下:

  • 设置为0:表示每次事务提交(commit)时不进行刷盘操作(什么都不做)。(默认由 InnoDB 存储引擎有一个后台线程 每隔1秒进行一次 同步,刷盘操作)
  • 设置为1:表示每次事务提交(commit)时都将进行同步,刷盘操作 (默认值)
  • 设置为2:表示每次事务提交(commit)时都只是把 redo日志缓冲区(redo log buffer) 中的日志写入到 文件系统缓存(page cache)中,不进行同步刷盘,而是 由操作系统(os)决定什么时候进行同步,刷盘操作

查看 redo log 的刷盘策略

SHOW VARIABLES LIKE '%innodb_flush_log_at_trx_commit%';
# 或者
SELECT @@innodb_flush_log_at_trx_commit;
# 或者
SELECT @@global.innodb_flush_log_at_trx_commit;

设置 redo log 的刷盘策略

# 设置 redo log 的刷盘策略为 1 秒一次。
SET GLOBAL innodb_flush_log_at_trx_commit = 0;

另外,InnoDB 存储引擎有一个后台线程,每隔 1 秒。就会把 redo日志缓冲区(redo log buffer) 中的内容写到文件系统缓存(page cache),然后调用刷盘操作。

注意:这是 InnoDB 存储引擎的默认行为,不管 redo log 的刷盘策略是什么,该行为都会执行。


也就是说,一个没有提交(commit)事务中的 redo log 记录,也可能会刷盘。因为在事务执行的过程中 redo log 会时刻写入到 redo日志缓冲区(redo log buffer) 中,那么这些 redo log 记录就会被InnoDB 的 后台线程 同步到磁盘中。

除了后台线程每秒1次的轮询操作,还有一种情况,就是当 redo日志缓冲区(redo log buffer) 占用的空间即将达到 innodb_log_buffer_size 系统变量的一半时( 也就是它自己空间的一半时),InnoDB 的 后台线程会主动刷盘

1.6 redo log 中不同刷盘策略的演示

1.6.1 流程图

# 当每次事务提交(commit)时,都会进行刷盘操作,最安全
innodb_flush_log_at_trx_commit = 1

小结:innodb_flush_log_at_trx_commit = 1

当 redo log 的 刷盘策略为 1 时,就表示只要事务提交(commit)成功,redo log 记录就一定在硬盘里,不会有任何数据丢失

如果事务执行期间 MySQL 宕机了,这部分日志丢了,但是事务并没有提交,所以日志丢就对了不会有任何损失。可以保证事务的持久性,数据绝对不会丢失,但是 效率最差 的。

建议使用 默认值(1),既然使用了事务,那么数据的安全相对来说会更重要些。

# 当每次事务提交(commit)时,都会把 redo日志缓冲区(redo log buffer) 中的日志写入到 文件系统缓存(page cache)中。由操作系统进行刷盘操作。
innodb_flush_log_at_trx_commit = 2 # 如果操作系统挂了的话,就可能会丢失 1 秒的数据 - 效率最高

小结:innodb_flush_log_at_trx_commit = 2

当 redo log 的 刷盘策略为 2 时,只要事务提交(commit)成功,redo日志缓冲区(redo log buffer)中的日志就会写入到 文件系统缓存(page cache)。(注意:此时的 redo log 记录是存放在 "操作系统" 中的

如果仅仅只是 MySQL 挂了是不会有任何数据丢失的,但是 如果 "操作系统" 挂了的话,可能就会丢失 1 秒的数据。那么这种情况下就 无法满足事务中的持久性

该刷盘策略,是一种折中的做法,但也 有丢失数据的风险,无法保证事务的持久性

# 当每次事务提交(commit)时,不进行任何操作,日志记录还在 redo日志缓冲区(redo log buffer)中。 - 最不安全
innodb_flush_log_at_trx_commit = 0 

小结:innodb_flush_log_at_trx_commit = 0

当 redo log 的 刷盘策略为 0 时,事务提交(commit)时不会有任何操作。但在 InnoDB 中默认会有一个 后台线程会每隔 1 秒钟进行一次 日志记录的同步(fsync),刷盘操作,因此可能会丢失 1 秒内的事务数据。 (InnoDB 中的后台线程是负责将 redo日志缓冲区(redo log buffer)中记录异步刷新到磁盘中的,保证数据的一致性),但是该策略 效率是最高 的。

1.6.2 举例

比较 redo log 中,不同刷盘策略(innodb_flush_log_at_trx_commit)对事务的影响

  • 创建表
CREATE TABLE IF NOT EXISTS redo_log_shua_pan_cl(
ID int UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT '主键id',
name VARCHAR(15) NOT NULL COMMENT '名称',
age TINYINT UNSIGNED DEFAULT 0 COMMENT '年龄',
INDEX idx_age(age)
)ENGINE = INNODB DEFAULT CHARSET=utf8;
  • 创建存储过程
delimiter //
CREATE PROCEDURE redo_Log_proc(num INT UNSIGNED)
BEGIN
DECLARE a TINYINT UNSIGNED DEFAULT ROUND(RAND() * 100,0); # 默认随机数
DECLARE n varchar(15) DEFAULT upper(substring(MD5(RAND()),1,10)); # 默认随机字符
DECLARE f INT UNSIGNED DEFAULT 1; # 循环标识
# 循环新增
while_xld:WHILE f <= num DO
INSERT INTO redo_log_shua_pan_cl(name,age) VALUE (n,a);
# 提交事务
COMMIT; 
SET f = f + 1; 
# 结束循环体
END WHILE while_xld;
END //
delimiter;
  • 刷盘策略 0 :耗时 20.308
# 设置刷盘策略为 0 
SET GLOBAL innodb_flush_log_at_trx_commit = 0;

# 查询 刷盘策略
SELECT @@global.innodb_flush_log_at_trx_commit;

# 调用存储函数

CALL redo_Log_proc(50000); # 总计耗时20.308s

# 查询表
SELECT COUNT(*) FROM redo_log_shua_pan_cl
  • 刷盘策略 1:耗时 36.582
# 设置刷盘策略为 1 
SET GLOBAL innodb_flush_log_at_trx_commit = 1;

# 查询 刷盘策略
SELECT @@global.innodb_flush_log_at_trx_commit;

# 调用存储函数

CALL redo_Log_proc(50000); # 总计耗时36.582s

# 查询表
SELECT COUNT(*) FROM redo_log_shua_pan_cl
  • 刷盘策略 2:耗时 24.505
# 设置刷盘策略为 2 
SET GLOBAL innodb_flush_log_at_trx_commit = 2;

# 查询 刷盘策略
SELECT @@global.innodb_flush_log_at_trx_commit;

# 调用存储函数

CALL redo_Log_proc(50000); # 总计耗时24.505s

# 查询表
SELECT COUNT(*) FROM redo_log_shua_pan_cl

结论:

  • 刷盘策略为 1 时:事务数据不会丢失,可以保证事务的持久性。安全性最高,效率最低
  • 刷盘策略为 2 时:如果 "操作系统" 挂了,可能会丢失 1 秒的事务数据,无法保证事务的持久性。折中的策略
  • 刷盘策略为 0 时:如果 MySQL 挂了,可能会丢失 1 秒的事务数据,无法保证事务的持久性。安全性最低,效率最高

注意:在实际开发中,上述的存储过程中的 commit 操作,应该在将所有的数据都插入表后,再进行一次 commit 操作的,而不是插入一条记录后就进行一次 commit 操作。

1.7 写入 redo log buffer 过程

1.7.1 Mini-Transaction 概念

MySQL 把对底层页(数据页)中的一次原子访问的过程称为一个 Mini-Transaction,简称 mtr,比如,向某个索引对应的 B+树 中插入一条记录的过程就是一个 Mini-Transaction。一个所谓的 mtr 可以包含了一组 redo 日志,在进行崩溃恢复时这一组 redo 日志是一个不可分割的整体。

一个事务包含若干条操作语句,每条操作语句包含若干个 mtr (例如:一个范围性的 update 就可能会修改底层中多条记录),每个 mtr 包含若干条 redo 日志

1.7.2 redo 日志写入到 redo日志缓冲区(redo log buffer)

在向 redo日志缓冲区(redo log buffer) 中写入 redo 日志时是有顺序的,也就是先往前边的 block(块)中写,当该 block(块)的空闲空间用完之后再往下一个 block(块)中写。

当我们每次向 redo日志缓冲区(redo log buffer) 中写入 redo 日志时,第一个遇到问题应该就是 写在那个 block(块)的那个位置,所以 InnoDB 的设计者特意提供了一个称之为 buf_free 的全局变量,该变量指明后续写入的 redo 日志应该写入到 redo日志缓冲区(redo log buffer) 中的那个位置。

一个 mtr 执行的过程中可能 产生若干条 redo 日志这些 redo日志是一个可分割的组(是原子性的),所以并不是每生成一条 redo日志,就会将其插入到 redo日志缓冲区(redo log buffer)中,而是先将其暂存起来。等到该 mtr 结束的时候,再将其所产生的 redo日志 全部复制到 redo日志缓冲区(redo log buffer)中

例如:有两个名为 T1,T2 的事务,每个事物都包含2个 mtr

  • 事务 T1 的两个 mtr 分别称为 mtr_T1_1mtr_T1_2
  • 事务 T2 的两个 mtr 分别称为 mtr_T2_1mtr_T2_2

每个 mtr 都会产生 一组 redo 日志

不同的事务可能是 并发 执行的,所以 T1,T2 之间的 mtr 可能是 交替执行。每当一个 mtr 执行完成时,随之该 mtr 产生的所有 redo 日志就需要复制到 redo日志缓冲区(redo log buffer)中,也就是说不同事务的 mtr 可能是交替写入 redo日志缓冲区(redo log buffer)的。

注意:有的 mtr 产生的 redo 日志是非常大的,就比如 mtr_T1_2 产生的 redo 日志占用了三个 block(块)

1.7.3 redo log buffer 的 block(块) 的结构图

  • 一个 block(块) 共占用 512 个字节
  • 一个 block(块)是由 日志头,日志体,日志尾 组成。"日志头" 占用 12 字节,"日志尾" 占用 8 个字节,"日志体" 占用 512 -12 - 8 = 492 字节。

为什么一个 block(块)设计成 512 个字节呢? 人家说明的很明白,但是感觉还是有些不理解!

这个和磁盘的扇区有关,机械磁盘默认的扇区就是 512 个字节,如果你要写入的数据大于 512 字节,那么要写入的扇区肯定不止一个,这时候涉及到盘片的转动,找到下一个扇区。假设现在需要写入两个扇区 A 和 B ,如果扇区 A 写入成功,而扇区 B 写入失败,那么就会出现 非原子性 的写入,而如果每次只写入和扇区的大小一样的 512 个i字节,那么每次的写入都是原子性的。

真正 redo 日志的都是存储在 日志体(log block body - 496个字节)中的,block(块)中的 日志头(log block header)日志尾(log block trailer)存储的都是一些 管理信息。我们来看看这些 管理信息 都是些什么。

  • 日志头(log block header)的属性分别如下:
    • LOG_BLOCK_HDR_NO:该 block(块)在 redo日志缓冲区(redo log buffer)中的位置,循环递增使用,占用 4 个字节。最大值为2G。
    • LOG_BLOCK_HDR_DATA_LEN:表示该 block(块)中已经使用了多个个字节,初始值为 12(因为 "日志体" 从 12 个字节处开始)。随着写入的日志越来越多,该属性会随之增长。直至该 block(块)全部写满 = 512 个字节。
    • LOG_BLOCK_FIRST_REC_GROUP:表示该 block(块)中第一个 mtr 生成的第一条 redo 日志的偏移量。如果该值的大小和 LOG_BLOCK_HDR_DATA_LEN 相同,则表示当前 block(块)不包含新的日志。
    • LOG_BLOCK_CHRCKPOINT_NO:表示该 block(块)最后被写入时的 checkpoint(校验点),占用 4 个字节。
  • 日志尾(log block trailer)的属性分别如下:
    • LOG_BLOCK_CHECHSUM:表示 block(块)的校验值,用于正确性校验(其值和 LOG_BLOCK_HDR_NO 相同)

1.8 redo log file(redo日志文件)

1.8.1 相关参数设置

  • innodb_log_group_home_dir全局级别):该系统变量可以指定 redo日志文件组所在的路径默认 ./:在数据库的数据目录下)。MySQL 的默认数据目录下默认有两个名为 ib_logfile0ib_logfile1 的文件,redo日志缓冲区(redo log buffer)中的日志默认情况下就是刷新到这个磁盘文件中的。此 redo 日志文件的位置还可以修改的。

  • innodb_log_files_in_group全局级别):该系统变量可以指定 redo日志文件(redo log file)的个数,默认 2 个,最大 100 个

  • innodb_flush_log_at_trx_commit:控制 redo log 刷盘的策略,默认为1

  • innodb_log_file_size:设置单个 redo log 文件的大小,默认值:48MB。最大值为 512G(注意:这个最大值指的是所有 redo log 文件的和:innodb_log_files_in_group * innodb_log_file_size = 512G)

在数据库实例更新比较频繁的情况下,可以适当加大 redo log 的组数和大小,但也不推荐 redo log 设置过大,因为在 MySQL 崩溃恢复时会重新执行 redo日志文件(redo log file)日志中的记录。

1.8.2 redo日志文件(redo log file)组

磁盘上的 redo 日志文件不只一个,而是以一个 日志文件组 的形式出现的。这些文件以 ib_logfile[数字](数字 可以是 0,1,2,...)的形式进行命名,每个的 redo 日志文件大小都是一样的。

在将 redo 日志写入日志文件组时,是从 ib_logfile0 开始写,如果 ib_logfile0 写满了, 就接着 ib_logfile1 写。同理,ib_logfile1 写满了就去写 ib_logfile2,依次类推。如果写到了最后一个文件,那就会重新转到 ib_logfile0 继续写。

总共的 redo日志文件大小 = innodb_log_files_in_group * innodb_log_file_size

采用循环使用的方式向 redo 日志文件组里写数据的话,会导致后写入的 redo 日志覆盖掉前边的 redo 日志。为了解决这个问题,InooDB 的设计者提出了 checkpoint 的概念。

1.8.3 checkpoint

在整个日志文件组中还有两个重要的属性,分别是:write poscheckpoint

  • checkpoint:表示 当前要擦除的位置还未恢复数据起始的位置 - 有效数据的起始位置),也是往后推移
  • write pos:表示 当前记录的位置还未恢复数据终止的位置 - 有效数据的终止位置)一边写一边后移

每次刷盘 redo log 记录到日志文件组中时,writr pos 位置就会后移更新。每次 MySQL 加载日志文件组恢复数据时,会清空在 redo日志文件组中加载过的 redo log 记录,并把 checkpoint 后移更新。

  • checkpoint 开始到 write pos 之间的部分是还未恢复的数据(有效数据)。
  • write pos 开始到 checkpoint 之间的部分可以写入新的 redo log 记录(过时的数据可以覆盖使用)。

如果 write pos 追上了 checkpoint,就表示 日志文件组满了,这时候就 不能再写入新的 redo log 记录了,MySQL 得停下来,清空一些记录,把 checkpoint 推进一下

1.9 redo log 小结

InooDB 的更新操作采用的是 Write Ahead Log(预先日志持久化)策略(先写日志,在写入磁盘)

2. undo 日志

redo log 是用于保证事务的 "持久性"undo log 是用于保证事务的 "原子性","一致性"。在事务中 更新数据前置操作 其实就是要先将 更新之前的数据 写入到 undo log 中。

2.1 如何理解 undo 日志

事务需要保证 原子性,也就是事务中的操作要么全部完成,要么什么都不做,但有时候事务执行的过程中会出现一下情况。比如:

  • 事务在执行过程中可能遇到各种错误,比如 服务器本身的错误操作系统错误,甚至是突然 断电 导致的错误。
  • 在程序中执行的过程中手动输入 rollback 语句结束当前事务的执行
  • 等....

以上情况出现,我们就需要把数据改回原先的样子,这个过程就称之为 回滚,这样就可以造成一个假象:这个事务看起来什么都没做,所以符合 原子性 的要求。

每当我们要一条记录做改动时(insert,delete,update)都需要 ”留一手“ -- 把 回滚 时所需的记录保存下来。

比如:

  • 插入(insert)一条记录 时,至少要把这条记录的主键值记下来,之后 回滚 的时候只需要把这个主键值对应的 记录删除 就好了(对于每个 insert,innodb 存储引擎会记录一个 delete
  • 删除(delete)一条记录 时,至少要把这条记录全部都记下来,之后 回滚 时再把这条记录 插入(insert) 到表中就好了(对于每个 delete,innodb 存储引擎会记录一个 insert
  • 修改(update)一条记录 时,至少要把这条记录的修改前的旧值记录下来,之后 回滚 时再把这条记录 更新回旧值 就好了(对于每个 update,innodb 存储引擎会记录一个相反 update,将修改前的值修改回去

MySQL 把这些为了 回滚 而记录的内容称之为 撤销日志 或者 回滚日志(undo log)注意:由于查询操作(select不会修改任何记录,所以不会记录到 undo log 中。

此外,undo log  也会产生 redo log,也就是说 undo log 中的操作也需要记录在 redo log 中undo log 也需要保证 持久性

2.2 undo 日志的作用

  • 回滚数据

很多人对 undo log 都有的误解就是:undo log 是用于将数据库物理地恢复到执行语句或者事务之间的样子,但事实并非如此。undo log逻辑日志,因此 只能将数据库逻辑地恢复到原来的样子。所有更新操作都被逻辑地取消了(在事务执行的过程中对物理层面的所有修改时永久性的),所以数据结构和页(数据页)本身在 回滚 之后可能打不相同。

这是因为在多用户并发系统中,可能有数十,上千个并发事务。数据库的主要任务是协调对数据记录的并发访问。

比如,一个事务在修改当前一个页(数据页)中某条记录,同时还有别的事务在对同一个页(数据页)中另几条记录进行修改。因此,不能将一个页(数据页)回滚 到事务开始的样子,因为这样会影响其他事务正在进行的工作。

例如:现在有三个事务:T1,T2,T3,三个事务同时操作一个页(数据页)中记录,该页(数据页)目前共有10条记录。
T1 事务向页(数据页)中插入了两条记录,并提交(commit)了。此时该页(数据页)中共有12条记录。
T2 事务向页(数据页)中插入了三条记录,也提交(commit)了。此时该页(数据页)中共有15条记录。
T3 事务向页(数据页)中插入了两条记录,但回滚(rollback)了。此时该页(数据页)还是15条记录,而不是 10 条记录。
注意:这三个事务开启时,在物理层面该页(数据页)中的数据共有 10 条。如果 回滚(rollback)是物理层面的话,那么在 T3 回滚时,该页(数据页)中的数据就应该返回到事务开启时的状态(就是回到共有 10 条数据的状态)。那请问 T3 回滚到你想要的状态了,可是 T1,T2 都已经提交了呀!已经持久化了,你都能改变(脏写),此时是无法保证事务的持久性的。

事务中的操作在物理层面都是一直往前走的,回滚(rollback) 只是在逻辑层面恢复到之前的状态,发生了就是发生了,(物理层面)是无法真正回退到原来的样子的。

  • MVCC(多版本控制)

undo log 的另一个作用就是 MVCC(多版本控制),即在 InnoDB 存储引擎中 MVCC(多版本控制)就是通过 undo log 来实现的。当用户读取一行记录时,若该记录已经被其他事务占用,当前事务可以通过 undo log 读取之前的行版本信息,以此实现非锁定读取

2.3 undo log 的存储结构

2.3.1 回滚段与 undo 页

InnoDB 对 undo log 的管理采用段的方式,也就是 回滚段(rollback segment),每个 回滚段 记录了 1024undo log 段(undo log segment),而在每个 undo log 段 中进行 undo 页 的申请使用。

  • 在 InnoDB 1.1 版本之前(不包括 1.1 版本),只有一个 回滚段(rollback segment),因此支持 同时在线的事务限制为 1024。对绝大数的应用来说都已经够用。
  • 从 1.1 版本开始 InnoDB 支持 最大有 120个 回滚段(rollback segment),故其支持 同时在线的事务限制提高到了 120 * 1024 = 122880

通过 innodb_undo_logs全局级别)系统变量查看 回滚段(rollback segment)个数

SHOW VARIABLES LIKE '%innodb_undo_logs%';
# 或者
SELECT @@global.innodb_undo_logs;

注意:该系统变量 MySQL 8.0 中已经不再使用

虽然 InnoDB 1.1 版本支持了 128 个 回滚段(rollback segment),但是这些 回滚段 都存储于 共享表空间 ibdata 中,从 InnoDB 1.2 版本开始,可通过系统变量(参数)对 回滚段(rollback segment) 做进一步的设置。参数如下:

  • innodb_undo_directory:设置 回滚段(rollback segment) 文件所在的路径。这就意味着 回滚段 可以存储在共享表空间以外的位置。(默认 ./:在数据库的数据目录下,undo_为前缀的文件
  • innodb_undo_logs:设置 回滚段(rollback segment) 的个数(默认值:128个)。在 MySQL 8.0 中替换为了 innodb_rollback_segments
  • innodb_undo_tablespaces:设置 回滚段(rollback segment)文件的数量(至少要配置 2 个),这样 回滚段 可以较为平均地分布在多个文件中。设置该参数后,会在路径 innodb_undo_directory 中看到 undo_为前缀的文件,该文件就是 回滚段 文件。
  • innodb_max_undo_log_size:设置单个 回滚段(rollback segment)文件的最大容量(默认:1G)。默认初始化大小为10MB。日志文件达到该阈值之后,且参数 innodb_undo_log_truncate=ON,才会触发truncate回收(收缩)动作,被truncate后的表空间文件大小缩小到undolog表空间数据文件默认的1OMB大小。否则即便是到达最大值之后,也不会自动回收 undo log的表空间。
  • innodb_undo_log_truncate:表示是否开启自动收缩 undo log 的表空间的操作。如果配置为 ON,并且配置了 2 个或 2 个以上的 undo log 表空间数据文件,当某一个日志文件大小超过设置的最大值之后,就会自动的收缩表空间数据文件。在回收表 undo log 表空间的时候,会判断这个已经超过设置的单个文件最大值 innodb_max_undo_log_size 的文件中,是否有还在活跃的事务,如果没有则可以回收该表空间,否则不能回收。对于可以回收的表空间,创建一个表示文件 undo<表空间-id>runc.log,表示正在回收该日志文件,不能向这个日志文件中写入undo日志。同时如果在回收这个日志文件的时候,数据库异常重启,也可以根据这个标识文件继续进行回收 undo表空间的操作。当回收表空间的操作结束后,就删除 undo<表空间-id>trunc.log 标识文件,此时这个被回收的日志文件可以再次被写入undo log。

undo log 相关参数一般很少会被改动

undo 页的重用

当我们开启一个事务需要写 undo log 的时候,就得先去 undo log段(undo log segment)中去找到一个空闲的位置,当有空位的时候,就去申请 undo 页,在这个申请到的 undo 页中进行 undo log 的写入(一页的大小是 16KB)。

为每一个事务分配一个页,是非常浪费的(除非事务非常长),假设我们的应用的 TPS(每秒处理的事务数量)为 1000,那么 1 秒 就需要 1000 个页,大概需要 16M 的存储空间,1 分钟大概需要 1G 的存储空间,如果照这样下去除非 MySQL 清理的非常勤快,否则随着时间的推移,磁盘空间会增长的非常快,而且很多空间都是浪费的。

于是 undo log 页就被设计成了可以 重用 的了,当事务提交(commit)时,并不会立刻删除 undo log 页。因为 重用,所以这个 undo log 页可能混杂着其他事务的 undo log。undo log 在提交(commit)后,会被放到一个 链表 中,然后判断 undo 页的使用空间是否 小于3/4,如果小于 3/4 的话,则表示当前的 undo log 页可以被 重用,那么它就不会被回收,其他事务的 undo log 可以记录在当前 undo log 页的中。由于 undo log 是 离散 的,所以清理对应的磁盘空间时,效率不高。

2.3.2 回滚段(rollback segment)与事务

  • 每个事务只会使用一个 回滚段,一个 回滚段 在同一时刻可能会服务于多个事务。
  • 当一个事务开始的时候,会制定一个 回滚段,在事务进行的过程中,当数据被修改时,原始的数据会被复制到 回滚段 中。
  • 回滚段 中,事务会不断填充盘区,直到事务结束或所有的空间被用完。如果当前盘区不够用了,事务会在 回滚段 中请求扩展下一个盘区,如果所有已分配的盘区都被用完了,事务会覆盖最初的盘区或者在 回滚段 允许的情况下扩展新的盘区来使用。
  • 回滚段 存在于 undo log 表空间中,在数据库中可以存在多个 undo 表空间,但同一时刻只能使用一个 undo log 表空间。
SHOW VARIABLES LIKE '%innodb_undo_tablespaces%';
# 或者
SELECT @@global.innodb_undo_tablespaces;
# 表示 undo log 的数量,最少为 2。undo log 的 truncate(截断) 操作有 purge 协调线程发起,在 truncate(截断)某个 undo log 表空间的过程中,必须要保证有一个可用的 undo log 供 undo log 使用。
  • 当事务提交(commit)时,InnoDB 存储引擎会做以下两件事情
    • 将 undo log 放入列表中,以方便之后的 purge(清理)操作
    • 判断 undo log 所在的页是否可以 重用,若可以分配给下个事务使用

2.3.3 回滚段(rollback segment)的数据分类

  • 未提交的回滚数据(uncommitted undo info):该数据所关联的事务并未提交(commit),用于实现读一致性,所以该数据不能被其他事务中的数据覆盖(不能覆盖)。
  • 已经提交但未过期的回滚数据(committed undo info):该数据关联的事务已经提交(commit),但是仍受到 undo retention(回滚保留) 参数的保持时间的影响(数据未过期,不优先考虑覆盖)。
  • 事务已经提交并过期的数据(expired undo info):该数据关联的事务已经提交(commit),而且数据保留的时间已经超过 undo retention(回滚保留) 参数指定的时间,属于已经过期的数据。当 回滚段 满了之后(会优先覆盖 "事务已经提交并过期的数据")。

重要理论:事务提交(commit)后并不能马上删除 undo log 以及 undo log 所在的页。这是因为可能还有其他事务需要通过 undo log 来的到行记录之前的版本。故而事务提交(commit)时会将 undo log 放入一个链表中,由专门的 purge(清除)线程来判断是否可以删除 undo log 以及 undo log 所在的页。

2.4 undo log 的类型

  • insert undo log

insert undo log 是指在 insert 操作中产生的 undo log。因为 insert 操作的记录,只对事务本身可见,对其他事务不可见(事务隔离性的要求),故 insert undo log 可以在事务提交(commit)后直接删除。

  • update undo log

update undo log 记录的是对 delete 和 update 操作产生的 undo log。由于该 undo log 可能需要提供 MVCC (多版本控制)机制,因此不能在事务提交(commit)后就进行删除。在事务提交(commit)后,会放入 undo log 链表中,等待 purge(清除)线程进行最后的删除。

2.5 undo log 的生命周期

2.5.1 简要的生成过程

已下是 undo + redo 日志在事务执行过程中生成的简化过程

假设有 2 个数值,分别为 A=1 和 B=2,然后 A 修改为 3,B 修改为 4。

# 1. BEGIN; 开启事务
# 2.记录 A = 1 到 undo log
# 3.update A = 3;
# 4.记录 A = 3 到 redo log
# 5.记录 B = 2 到 undo log
# 6.update B = 4
# 7.记录 B = 4 到redo log
# 8.将 redo log 刷盘到磁盘
# 9.COMMIT; 提交事务
  • 在 1 ~ 7 步骤的任意一步时系统宕机,事务还未提交,该事务就不会对磁盘上的数据做任何影响。
  • 如果在 8 ~ 9 之间宕机,恢复之后可以选择回滚(rollback),也可以选择继续完成事务提交(commit),以为此时 redo log 已经持久化到磁盘中。
  • 若在 9 之后系统宕机,内存映射中变更的数据还来不及刷回磁盘,那么系统恢复之后,会根据 redo log 把数据恢复到磁盘中。

只有 Buffer Pool 的流程的简图:

有了 redo log 和 undo log 之后:

有了redo-log+undo-log之后

在更新 Buffer Pool 中的数据之前,我们需要先将该数据事务开始之前的状态写入 undo log 中。假设更新到一半出错了,我们就可以通过 undo log 来回滚到事务开始前

2.5.2 详细生成过程

对于 InnoDB 引擎来说,每个行记录除了记录本省的数据之外,还有几个隐藏的列(你现在还记得吗?):

  • DB_ROW_ID:如果没有为表显式的定义主键,并且表中也没有定义唯一索引,那么 InnoDB 会自动为表添加一个 row_id 的隐藏列作为主键。
  • DB_TRX_ID:每个事务都会有一个事务ID,当某个事务正在操作(更新)该记录时,就会将这个事务的事务ID写入到该记录的(trx_id)中。
  • DB_ROLL_PTR:回滚指针,本质上就是 指向 undo log 的指针

当执行 insert 时:

begin;
insert into user(name) values('tom');

在事务中插入的数据都会生成一条 insert undo log,并且数据(刚插入的那条数据)中的 回滚指针(行格式中的:db_roll_ptr 属性) 会指向它(该数据生成的 insert undo log)。undo log 中会记录 undo log的序号,插入主键的列和值等...,那么在进行 回滚(rollback)的时候,直接通过主键把对应数据删除即可。

当执行 update 时 -(不更新主键):

在事务中对于更新的操作会产生 update undo log,并且会分 更新主键不更新主键

begin;
update user set name = 'Sun' where id = 1;

这时会 把数据修改前老的记录写入新的 undo log 中,让 回滚指针 指向 新的 undo log(undo on = 1),并且 新的 undo log 会指向老的 undo log(undo on = 0)

当再执行 update 时 -(更新主键):

begin;
update user set id = 2 where id = 1;

对于更新主键的操作,会先把原来的数据 deletemark 标识打开(1),这时并没有真正的删除数据,真正的删除会交给清理线程去判断,然后在后面插入一条新的数据,新的数据也会产生 undo log,并且 undo log 的序号会递增。

可以发现每次对数据的变更都会产生一个 undo log,当一条记录被变更多次时,那么就会产生多条 undo log,undo log 记录的是变更前的日志,并且 每个 undo log 的序号是递增的,那么当要回滚的时候,按照序号 依次向前推,就可以找到我们的原始数据了。

2.5.3 undo log 是如何回滚的

以上述的举例来说,执行 回滚(rollback)后,流程如下:

  1. 通过 undo on = 3 的 undo log 把 id = 2 的数据删除
  2. 通过 undo on = 2 的 unod log 把 id = 1 的数据中的 deletemark 还原回 0
  3. 通过 undo on = 1 的 undo log 把 id = 1 的数据的 name 还原成 Tom
  4. 通过 undo on = 0 的 undo log 把 id = 1 的数据删除

2.5.4 undo log 的删除

  • 针对于 insert undo log

因为 insert 操作的记录,只对事务本身可见,对其他事务不可见。故该 undo log 可以在事务结束后直接删除。

  • 针对于 update undo log

该 undo log 可能需要提供 MVCC(多版本控制) 机制,因此不能在事务结束后就进行删除。事务结束后会放入 undo log 链表中,等待 purge(清理)线程进行最后的删除。

补充:

purge(清理)线程有两个主要的作用:清理 undo log 页清除 page(数据页) 里面带有 Delete_Bit 标识的数据行。在 InnoDB 中,事务中的 Delete 操作实际上并不是真正的删除掉数据行,而是一种 Delete Mark 操作,在记录上标识 Delete_Bit,而不删除记录。是一种 "假删除",只是做了个标记,真正删除工作需要后台 purge(清理)去完成。

2.6 小结

undo log 是"逻辑日志",对事务回滚时,只是将数据逻辑地恢复到原来的样子。

redo log 是"物理日志",记录的是页(数据页)的物理变化,undo log 不是 redo log 的逆过程