MySQL的数据持久化过程
事务的支持是数据库区分文件系统的重要特征之一
MySQL 持久化的本质,是把内存中数据的修改安全、可靠地写入磁盘,即使遇到数据库崩溃或者服务器断电这种服务宕机的情况,也能恢复到崩溃前的一致状态。
三大日志
MySQL 日志 主要包括错误日志、查询日志、慢查询日志、事务日志、二进制日志几大类。
其中,跟 MySQL 持久化流程相关的日志主要是,Redo Log 重做日志,Binlog 二进制日志,Undo Log 回滚日志,它们的架构是这样的
- Redo Log:是 InnoDB 存储引擎独有的,保证已提交事务不丢失
- Binlog:保证数据恢复,而且主从复制也涉及到这个日志
- Undo Log:是 InnoDB 特有的日志,记录数据修改前的状态,实现 MVCC 多版本并发控制,而且通过 Undo Log 在事务回滚时,恢复数据到修改前的状态
MySQL数据的持久化过程简述
过程简述
MySQL数据的存储总体上可以分为两部分,内存中的存储过程以及硬盘的持久化存储,这里就涉及到了内存中的缓冲区和 Redo Log 的操作,以及磁盘上的 Undo Log 和 表结构
- buffer poll:可以理解成内存缓冲区,是 InnoDB 引擎缓存池的一部分,我们这里可以简单理解为数据库从磁盘读进内存的内存块的缓存;
- Redo Log:通过先把数据修改记录到 Redo Log,再在合适的时机批量刷到磁盘,保证已提交事务不丢失
- Undo Log:回滚日志记录数据修改前的状态
- 表结构:存储数据的结构
整个过程就是内存临时改→日志顺序写→磁盘最终落盘
内存中的操作
在内存中,buffer poll中有对于读入内存的数据的缓存,在查询/修改命令执行时,MySQL
会先看 Buffer Pool
中查看是否能命中目标数据,这样命中的话就能直接操作内存数据,不用读磁盘,未命中就会从磁盘中将需要的数据读进
Buffer Pool 再操作,缓存的管理使用的是改良的LRU算法
所以说,当一条修改指令运行的时候,首先进行的是对于buffer poll中缓存的修改,被修改后的数据会被标记为脏页,同时,修改的操作也会记录在redo log中,它用于保证已提交的修改不丢失
- 所以说脏页就是内存里被修改过、但还没刷到磁盘的数据页
内存是非持久的,所以肯定要在某一时刻把脏页中的内容刷到磁盘里,而脏页不是立刻落到磁盘的,而是有可以设置的刷盘控制机制,例如,一个事务执行结算后立刻落盘,按照一定时间定期落盘等等,如果没刷盘呢此时宕机了,Buffer Pool 里的脏页会全部丢失,所以磁盘上的原始数据是完整的,不会对数据库造成破坏性的影响。
磁盘的持久化
InnoDB在磁盘的持久化分为两步,第一步是逻辑日志的存储,之后再将日志中的数据刷进磁盘空间。
事务日志的作用是用顺序 IO 代替随机 IO,提升效率,由于日志文件在磁盘上是连续的,相比于分布在各处的数据表信息,IO效率能高出很多。
随机 IO:修改数据表时,数据页在磁盘上的位置是分散的(比如表 t 的数据页可能在磁盘的 100 号、2000 号、5000 号位置),修改时磁头要来回移动找位置,速度极慢;
顺序 IO:日志文件(比如 Redo Log)在磁盘上是连续的文件,写日志时磁头只需要顺着文件往后写,不用来回移动,速度是随机 IO 的几十倍。
所以 MySQL 的设计逻辑是
- 不直接把脏页刷到数据表,因为这个过程是随机 IO ,慢,而是先把修改操作记录到日志文件,这部分是顺序 IO,快,只要日志写成功,就认为事务持久化成功
只要我们在事务日志中完整更新了操作,那么这个事务就已经持久化成功了,后续会有专门负责的线程将日志信息存储到表结构中。
然后,日志信息存储到表结构的过程是分为两步进行的,这两步本质上都是校验,保证数据存储的强一致性,防止在刷入磁盘的过程中,数据库宕机导致数据不完整。
首先,会在表头的缓存区域内进行数据更新,更新表头缓存区域,把日志里的修改先写到数据表文件的表头缓存区,可以理解成数据表的临时写入区,表头缓存区更新完成后,再把数据刷到数据表的实际数据块中
如果 表头缓存区完整,数据块不完整(比如刷数据块时宕机):直接用表头缓存区的完整数据重新刷到数据块;
如果表头缓存区不完整(比如从日志刷表头时宕机):直接重新从日志里读取修改内容,再刷一遍就可以了
这个设计的目的是:把一次性刷完整数据表拆成先写临时区→再写正式区,用校验码保证每一步的完整性,避免数据刷到一半宕机导致数据表损坏。
Redo Log
Redo Log 是做什么的
Redo Log 是 InnoDB 存储引擎独有的,用于记录事务执行的过程中对数据页的修改,它让 MySQL 拥有了崩溃恢复能力。
比如 MySQL 实例挂了或宕机了,重启时,InnoDB 存储引擎会使用 redo log 恢复数据,保证数据的持久性与完整性。
MySQL
中数据是以页为单位,一页默认16KB,你查询一条记录,会从硬盘把一页的数据加载出来,上面也提到了,加载出来的数据叫数据页,会放入到
Buffer Pool 中。
然后后续的查询都是先从 Buffer Pool
中找,没有命中再去硬盘加载,减少硬盘 IO
开销,提升性能。更新表数据的时候,也是如此,发现
Buffer Pool 里存在要更新的数据,就直接在
Buffer Pool 里更新。这些都是上面持久化过程中提到的
那么,关键是在某个数据页上做了什么修改,把他记录到重做日志缓存,也就是redo log buffer里,接着刷盘到
redo log 文件里。
理想情况下,事务一提交就会进行刷盘操作,但实际上,刷盘的时机是根据策略来进行的。
现在我们来思考一个问题:只要每次把修改后的数据页直接刷盘不就好了,还有 redo log 什么事?
它们不都是刷盘么?差别在哪里?
因为 MySQL 的数据页大小是
16KB,刷盘比较耗时,可能就修改了数据页里的几Byte数据,有必要把完整的数据页刷盘吗?而且数据页刷盘是随机写,它是随机IO,因为一个数据页对应的位置可能在硬盘文件的随机位置,所以性能是很差。
如果是写 redo log,一行记录可能就占几十
Byte,只包含表空间号、数据页号、磁盘文件偏移 量、更新值,再加上是顺序写,所以刷盘速度很快。所以用 redo log 形式记录修改内容,性能会远远超过刷数据页的方式,这也让数据库的并发能力更强。
刷盘时机
在 InnoDB 存储引擎中,redo log buffer(重做日志缓冲区)是一块用于暂存 redo log 的内存区域。
为了确保事务的持久性和数据的一致性,InnoDB
会在特定时机将redo log buffer缓冲区中的日志数据刷新到磁盘上的
redo log 文件中。
这些时机可以归纳为以下六种:
事务提交时:当事务提交时,log buffer 里的 redo log 会被刷新到磁盘,可以通过
innodb_flush_log_at_trx_commit参数控制redo log buffer缓冲区空间不足:这是 InnoDB 的一种主动容量管理策略,旨在避免因缓冲区写满而导致用户线程阻塞。- 当
redo log buffer的已用空间超过其总容量的一半 (50%) 时,后台线程会主动将这部分日志刷新到磁盘上的 redo log 文件中,为后续的日志写入腾出空间,这是一种未雨绸缪的优化。 - 如果因为大事务或 I/O 繁忙导致 buffer
被完全写满,那么所有试图写入新日志的用户线程都会被阻塞,并强制进行一次同步刷盘,强行把
redo log buffer中的空间刷到磁盘上的 redo log 文件中,直到有可用空间为止。这种情况会很大的影响数据库性能,应尽量避免。
- 当
触发检查点 (Checkpoint) 时:Checkpoint 是 InnoDB 为了缩短崩溃恢复时间而设计的核心机制。当 Checkpoint 被触发时,InnoDB 需要将在此检查点之前的所有脏页刷写到磁盘。根据 Write-Ahead Logging (WAL) 原则,脏页刷入磁盘前,其对应的 redo log 必须先落盘。因此,执行 Checkpoint 操作必然会确保相关的 redo log 也已经被刷新到了磁盘。
WAL原则:对数据的修改,必须先把 修改记录(在这里是 redo log)写入磁盘,再把 数据本身(在这里是脏页)写入磁盘。
也就是先记账,再修改数据,这样那怕改数据的时候出问题,还有账本可以对照,而若记录账本时候宕机,数据并没有被修改,是安全的
旧 redo log 对应的所有脏页,已经安全刷到磁盘,且这些 redo log 本身已经落盘,才能覆盖旧 redo log
后台线程周期性刷新:InnoDB 有一个后台的 master thread,它会大约每秒执行一次例行任务,其中它的任务就包括将 redo log buffer 中的日志刷新到磁盘。这个机制是
innodb_flush_log_at_trx_commit设置为 0 或 2 时的主要持久化保障。正常关闭服务器:在 MySQL 服务器正常关闭的过程中,为了确保所有已提交事务的数据都被完整保存,InnoDB 会执行一次最终的刷盘操作,将 redo log buffer 中剩余的全部日志都清空并写入磁盘文件来保证正常关机。
binlog 切换时:当开启 binlog 后,在 MySQL 采用
innodb_flush_log_at_trx_commit=1和sync_binlog=1的 双一配置下,为了保证 redo log 和 binlog 之间状态的一致性(用于崩溃恢复或主从复制),在 binlog 文件写满或者手动执行 flush logs 进行切换时,会触发 redo log 的刷盘动作。
总之,InnoDB 在多种情况下会刷新重做日志,以保证数据的持久性和一致性。
参数 innodb_flush_log_at_trx_commit
我们要注意设置正确的刷盘策略,刷盘策略的关键参数是innodb_flush_log_at_trx_commit
。根据 MySQL 配置的刷盘策略的不同,MySQL
宕机之后可能会存在轻微的数据丢失问题。
innodb_flush_log_at_trx_commit 的值有 3 种,也就是共有 3
种刷盘策略:
0:设置为 0 的时候,表示每次事务提交时不进行刷盘操作。这种方式性能最高,但是也最不安全,因为如果 MySQL 挂了或宕机了,可能会丢失最近 1 秒内的事务。
1:设置为 1 的时候,表示每次事务提交时都将进行刷盘操作。而且, 只要事务提交成功,redo log 记录就一定在硬盘里,不会有任何数据丢失。这种方式性能最低,但是也最安全,因为只要事务提交成功,redo log 记录就一定在磁盘里,不会有任何数据丢失。
2:设置为 2 的时候,表示每次事务提交时都只把 redo log buffer 里的 redo log 内容写入 page cache,也就是文件系统缓存。page cache 是专门用来缓存文件的,这里被缓存的文件就是 redo log 文件。这种方式的性能和安全性都介于前两者中间。此时依赖后台线程每秒刷到磁盘,如果仅仅只是 MySQL 挂了不会有任何数据丢失,但是服务器宕机了可能会有
1秒数据的丢失。redo log 的存储分为三层:
redo log buffer(内存)→page cache(文件系统缓存)→redo log file(磁盘)
刷盘策略innodb_flush_log_at_trx_commit 的默认值为
1,设置为 1
的时候才不会丢失任何数据。为了保证事务的持久性,我们必须将其设置为
1。
InnoDB 的 Log Thread 后台线程
nnoDB 存储引擎有一个(准确来说是两个)后台线程 Log
Thread,每隔1 秒,就会把 redo log buffer
中的内容写到文件系统缓存(page cache),然后调用
fsync 刷盘。
它作为 redo log 持久化的保障,如果事务未提交,或者
innodb_flush_log_at_trx_commit 未配置为 1,也能保证 redo
log buffer 中的内容每隔 1
秒被刷到磁盘,避免因内存数据丢失导致大量已执行的修改无法恢复。
它与事务提交的自动刷盘是分开的,如果
innodb_flush_log_at_trx_commit=1,触发了事务
Commit,会立即触发进行该事务的 redo log 刷到磁盘, Log Thread 的每秒自动
redo log buffer 刷到磁盘 redo log
文件是他自己的策略,如果没有这个线程,redo log buffer
中的数据只有事务提交或 buffer 写满了,而且如果每次生成 redo log
都立即刷盘,会导致大量随机的 fsync 调用,IO 压力极大
也就是说,一个没有提交事务的 redo log 记录,也可能会刷盘。
别忘了,redo log 只是记录的是数据的修改记录,由此实现的事务确认
因为在事务执行过程中,redo log
记录是会把事务写入redo log buffer 中,这些 redo log
记录只要是未刷盘的,都会在每秒扫描后被后台线程刷盘,其中肯定包括未提交事务的redo
log
提前刷不影响什么,因为事务提交时,只需要确保该事务的 redo log 已经刷盘(如果还没刷,此时立即触发刷盘)在 redo log 中标记修改一下事务状态就可以了
一个事务的 redo log 可能被拆分成多次刷盘,这正好还缓解了 IO 压力和避免 Buffer 溢出的风险
未提交的 redo log 刷到磁盘,重启后会不会把这些未提交的修改应用到数据中?
别忘了每个事务的 redo log 都有自己的状态标记,这涉及到下面提到的两阶段提交了
数据库重启时,InnoDB 会扫描 redo log,只应用 状态为 Commit 的事务记录
日志文件组
redo log 是环形文件,它采用的是环形数组形式,从头开始写,写到末尾又回到头循环写,如下图所示。
硬盘上存储的 redo log
日志文件不只一个,而是以一个日志文件组的形式出现的,每个的redo日志文件大小都是一样的。
比如可以配置为一组4个文件,每个文件的大小是
1GB,这样整个 redo log
日志文件组可以记录4G的内容。
在这个日志文件组中还有两个重要的属性,分别是
write pos和checkpoint
- write pos 是当前记录的位置,一边写一边后移
- checkpoint 是当前要擦除的位置,也就是标记可覆盖的 redo log 位置,往后推移,标记该位置之前的 redo log 可被覆盖,让环形的 redo log 文件组能循环使用
每次刷盘 redo log
记录到日志文件组中,write pos
位置就会后移更新。
每次 MySQL 加载日志文件组恢复数据时,会清空加载过的
redo log 记录,并把 checkpoint 后移更新,通过
Checkpoint 标记可覆盖的旧日志
这样,write pos 和 checkpoint
之间的还空着的部分可以用来写入新的 redo log 记录。
如果 write pos 追上 checkpoint
,没有地方往前写了,而因为没有刷脏页或者其他情况,标记可覆盖的位置也不能继续往前推进了,这样就代表日志文件已满,这时候不能再写入新的
redo log 记录,MySQL 得停下来,清空一些记录,把 checkpoint
推进一下。
checkpoint 的执行步骤是这样的
graph TD
A[触发 Checkpoint] --> B[确定「待刷盘的脏页范围」:checkpoint 之前的所有脏页]
B --> C[遵循 WAL 原则:先把这些脏页对应的 redo log 刷到磁盘(确保日志已落盘)]
C --> D[将这些脏页从 Buffer Pool 刷到磁盘的数据文件(.ibd)]
D --> E[推进 checkpoint 位置到「已刷盘脏页的最大 LSN」]
E --> F[标记 checkpoint 之前的 redo log 为「可覆盖」,更新日志文件组的元信息]
然后,我们把「后台线程刷 redo log」「Checkpoint 刷脏页」「日志文件组」串成一个完整流程
- 事务执行时,修改了内存中 Buffer Pool
中的数据,产生了脏页,同时把修改步骤写入到
redo log buffer; - 后台线程每秒把
redo log buffer中的步骤刷到 redo log 文件组,使得write pos后移 - 随着
write pos后移,日志文件组剩余空间越来越少,在某个时刻触发了 Checkpoint,这就触发了上面提到的刷盘时机3 - Checkpoint 先根据 WAL 原则,确认
checkpoint之前的 redo log 已被全部刷到磁盘,把这些 redo log 对应的脏页刷到磁盘的数据文件中,推进checkpoint位置,标记旧 redo log 可覆盖 - 如果
write pos追上checkpoint,MySQL 暂停修改,强制执行 Checkpoint,直到checkpoint推进,write pos有空间继续写。
MySQL 8.0.30 之前可以通过 innodb_log_files_in_group 和
innodb_log_file_size 配置日志文件组的文件数和文件大小,但在
MySQL 8.0.30 及之后的版本中,这两个变量已被废弃,即使被指定也是用来计算
innodb_redo_log_capacity 的值。而日志文件组的文件数则固定为
32,文件大小则为 innodb_redo_log_capacity / 32 。
所以在使用 MySQL 8.0.30 及之后的版本时,推荐使用
innodb_redo_log_capacity 变量配置日志文件组
Binlog
Binlog是做什么的
先回顾 Redo Log,Redo Log 记录的是 数据在某个数据页上发生了什么样的修改,它是 InnoDB 存储引擎的日志,记录的是修改的过程,因为它记录的是数据的修改的结果 和 物理变化,所以它记录的是 物理日志,它的作用是崩溃恢复,而且保证事务提交后不丢数据
而 Binlog 是逻辑日志,记录内容是语句的原始逻辑,类似于 “给 ID=2
这一行的 c 字段加 1” 这种执行了什么 SQL
的操作流水账,因为它属于MySQL Server
层,所有引擎都能用,它负责数据备份、主从同步、数据恢复等,它有点像是
Redis 的 AOF 持久化
不管用什么存储引擎,只要发生了表数据更新,都会产生 binlog 日志。而且可以说 MySQL 数据库的数据备份、主备、主主、主从都离不开 binlog,需要依靠 binlog 来同步数据,保证数据一致性。
binlog 会记录所有涉及更新数据的逻辑操作,并且是顺序写。
Binlog 的记录格式
binlog 日志有三种格式,可以通过binlog_format参数指定
- statement:基于 SQL 语句
- row:基于行
- mixed:混合
statement
指定statement,记录的内容是SQL语句原文,类似
Redis 的 AOF
持久化,比如执行一条update T set update_time=now() where id=1,binlog
里记录的内容就是这些 SQL 语句,如下。
同步数据时,会执行记录的SQL语句,但是有个问题,update_time=now()这里会获取当前系统时间,直接执行会导致与原库的数据不一致。因此这样,NOW()、UUID()、RAND()、存储过程、触发器、自定义函数等等,如果主从执行时间不同,结果就不一样,很容易导致主从数据的不一致
它虽然体积小,可读性好,但是几乎不再推荐,核心缺陷让它几乎被淘汰,但是看看隔壁,AOF 是 Redis 主流持久化方案之一,憋憋
ROW
ROW 模式不记录 SQL,只记录 行的变化,类似 Redis 的 RDB 模式,它记录哪一行修改前是什么,修改后是什么
面对上面 statement
的问题,为了解决它,我们需要指定为row,记录的内容不再是简单的SQL语句了,还包含操作的具体数据,记录内容如下。
它最准确,主从绝对一致,不受函数、存储过程影响,能看到每一行的变化,数据恢复非常方便
但是它日志量大,需要更大的容量来记录,比较占用空间,恢复与同步时会更消耗 IO 资源,影响执行速度,而且如果不解析几乎没有可读性,但它现在官方推荐,MySQL 5.7+、8.0 默认都是 ROW 模式
MIXED
这个就完全不类似 Redis 的混合持久化了,请注意,在 MIXED 模式下,MySQL 自动判断:
- 安全的 SQL → 用 STATEMENT
- 不安全的(含 NOW ()、UUID () 等)→ 自动切 ROW
它是选择一种模式进行记录,是一种折中的方案,但是它正因如此,行为虽然可预测但是不可控,不如直接用 ROW 稳定
最后对比一下
| 特性 | STATEMENT | ROW | MIXED |
|---|---|---|---|
| 记录内容 | SQL 语句 | 行的变化(前后值) | 自动切换 |
| 主从一致性 | 差,可能不一致 | 极好,完全一致 | 较好 |
| 日志大小 | 小 | 大 | 中等 |
| 可读性 | 好 | 差(二进制) | 中等 |
| 不确定函数 | 不支持 | 完美支持 | 自动处理 |
| 推荐程度 | 不推荐 | 强烈推荐 | 一般 |
| 默认版本 | 5.1 之前 | 5.7、8.0 默认 | 5.1–5.6 常用 |
1 | # 查看当前 binlog 格式 |
几乎都是 ROW
Binlog 的写入机制
Binlog 是 MySQL Server 层的日志,所有存储引擎(InnoDB/MyISAM 等)都会触发 Binlog 写入,其写入机制的核心是:
事务提交时一次性写入 Binlog(先缓存,再刷盘),且写入逻辑和 InnoDB 的 Redo Log 两阶段提交强绑定,保证主从一致。
所以说,binlog
的写入时机也非常简单,事务执行过程中,先把日志写到binlog cache,事务提交的时候,再把binlog cache写到
binlog 文件中。
用一条 UPDATE 语句的执行过程,拆解一下
当你执行 UPDATE user SET name='张三' WHERE id=1; 时
- MySQL 解析 SQL,开启隐式或者显式事务
- InnoDB 执行数据修改,同时将修改操作记录到 Redo Log Buffer 中
- MySQL Server 层会将这条 SQL 的 逻辑变更情况 暂存到 binlog cache(每个事务独立的内存缓存,避免多事务冲突)
- 然后,当执行
COMMIT;时,触发 Binlog 写入,且和 Redo Log 两阶段提交联动- Prepare 阶段,InnoDB 将 Redo Log Buffer 中的内容刷到 Redo Log 文件并标记为 Prepare 状态,此时 Redo Log 已落盘,但事务未真正提交。
- 然后 MySQL 将当前事务的 Binlog Cache 中的内容追加写入到 BinLog 缓存中,它是全局的 Binlog Cache,区别于事务级 BinLog 缓存
- 然后再将上述的全局 BinLog 缓存中从内存写到操作系统页缓存(page cache),然后再根据刷盘策略,刷到上述内容到 BinLog 文件
- 然后 InnoDB 收到 Binlog 写入成功的确认后,将 Redo Log 标记为 Commit 状态,事务正式提交;若 Binlog 写入失败,InnoDB 会回滚事务,保证 Redo Log 和 Binlog 一致。
为什么 BinLog 要有两种内存缓冲区呢?
因为一个事务的 binlog
不能被拆开,无论这个事务多大,也要确保一次性写入,所以系统会给每个线程分配一个块内存作为binlog cache。
我们可以通过binlog_cache_size参数控制单个线程 binlog
cache
大小,如果存储内容超过了这个参数,就要暂存到磁盘(Swap)。
binlog 日志刷盘流程如下
- 上图的 write,就是上面提到的把日志写入到文件系统的 page cache,并没有把数据持久化到磁盘,所以速度比较快
- 上图的 fsync,才是将数据持久化到磁盘的操作
write和fsync的时机,可以由参数sync_binlog控制,默认是1。
0:默认,MySQL 不主动刷盘,由操作系统决定何时将 page cache 中的 Binlog 刷到磁盘,性能高但是数据安全性低,机器宕机,
page cache里面的 binlog 会丢失。
1:代表每次事务提交时,都调用
fsync()将 Binlog 从 page cache 刷到磁盘,性能低但是数据安全性高N:N>1,每累计 N 个事务提交时,调用
fsync()刷盘,服务器宕机可能丢失最后 N 个事务的 Binlog
在出现 IO
瓶颈的场景里,将sync_binlog设置成一个比较大的值,可以提升性能。但是核心业务必须设置
sync_binlog=1,虽然牺牲一点性能,但能保证 Binlog
绝对不丢,避免主从不一致。
还有一些控制 Binlog 写入行为的其他参数
| 参数名 | 作用 | 常用值 |
|---|---|---|
binlog_format |
控制 Binlog 记录格式 | ROW(生产标配) |
max_binlog_size |
单个 Binlog 文件的最大大小 | 1G |
binlog_cache_size |
单个事务的 Binlog Cache 初始大小 | 32K/64K(自动扩容) |
max_binlog_cache_size |
单个事务的 Binlog Cache 最大大小(防止大事务撑爆内存) | 1G |
expire_logs_days |
Binlog 文件自动删除的天数(避免磁盘占满) | 7/15 天 |
Undo Log
Undo Log 是做什么的
undo log 是 InnoDB 引擎层的一种日志,没错三大日志 InnoDB 占了俩
Undo意为撤销或取消,以撤销操作为目的,返回某个状态的操作。
数据库事务开始之前,每一个事务对数据的修改都会被记录到 undo log ,在事务的修改记录之前,undo log 会把该记录的原值(before image)先保存到 undo log 然后再做修改,当执行事务过程中出现错误或者需要执行回滚操作的话,MySQL 可以利用 undo log 将数据恢复到事务开始之前的状态。
不难看出undo log的两个作用:
- 保证事务回滚原子性: Undo Log
是事务原子性的保证。在事务中更新数据的前置操作其实是要先写入一
个Undo
Log,事务处理的过程中,如果出现了错误或者用户执行
ROLLBACK语句,MySQL可以利用 undo log 中的备份将数据恢复到事务开始之前的状态。 - MVCC:undo log 在 MySQL InnoDB 储存引擎中用来实现多版本并发控制也就是 MVCC,因为它本质上是生成一致性读快照,事务未提交之前,当读取的某一行被其他事务锁定时,它可以从 undo log 中分析出该行记录以前的数据是什么,从而提供该行版本信息,让用户实现非锁定一致性读取,也就是 RR 可重复读。
- 辅助崩溃恢复事务:当 MySQL 宕机时,可能存在 已写入 Redo Log 但未提交的事务,重启后,InnoDB 会用 Redo Log 恢复这些数据到宕机之前的状态,包括未提交的脏数据,再通过 Undo Log 回滚这些未提交的事务,保证数据一致性。
在事务中,进行以下四种操作,都会创建undo log:
insert用户定义的表update或者delete用户定义的表insert用户定义的临时表update或者delete用户定义的临时表
由于查询操作(SELECT)并不会修改任何用户记录,所以在杳询操作行时,并不需要记录相应的Undo日志
所以说,在 InnoDB 存储引擎中,Undo Log 分为:
- insert Undo Log:
insert Undo Log是指在insert操作中产生的 Undo Log。因为insert操作的记录,只对事务本身可见,对其他事务不可见(这是事务隔离性的要求),故该 Undo Log 可以在事务提交后直接删除。不需要进行 purge 操作。 - update Undo
Log:
update Undo Log记录的是对delete和update操作产生的 Undo Log。该 Undo Log 可能需要提供 MVCC 机制,因此不能在事务提交时就进行删除。提交时放入 Undo Log 链表,等待 purge 线程进行最后的删除。
Undo Log 也是逻辑日志,什么意思呢?它记录的也是 SQL 语句,只不过比如说事务执行一条 DELETE 语句,那 Undo Log 就会记录一条相对应的 INSERT 语句。这样反向记录,就达成了据此撤销的目的。
同时,Undo Log 的信息也会被记录到 Redo Log 中,因为 Undo Log 本身也要实现持久性保护。
并且,Undo Log 本身是会被删除清理的,例如 INSERT 操作,在事务 Commit 之后,它的使命就完成了,就可以被清除掉了;UPDATE/DELETE 操作在事务提交不会立即删除,会加入 history list,由后台线程 purge 进行清理。
Undo Log的存储结构
那么,InnoDB 对 Undo Log 的管理采用段的方式,也就是回滚段(rollback segment)。每个回滚段记录了 1024 个 Undo Log Segement,而在每个 Undo Log segment 段中进行 Undo页 的申请。
整体的层级关系是这样的
Undo 表空间(也就是 Undo Log 落地的物理文件)–> 回滚段(Rollback Segment)–> 每个 Rollback Segment 包含1024个 Undo Log Segment(Undo 日志段)–> 每个 Undo Log Segment 管理多个 Undo 页 –> 页内存储具体的 Undo Log 记录
在InnoDB1.1版本之前(不包括1.1版本),只有一个rollback segment,因此支持同时在线的事务限制为
1024。虽然对绝大多数的应用来说都已经够用。从1.1版本开始InnoDB支持最大128个rollback segment,故其支持同时在线的事务限制提高到了128 x 1024。
虽然 InnoDB1.1
版本支持了128个rollback segment,但是这些rollback segment都存储于共享表空间
ibdata 中。从 lnnoDB1.2
版本开始,可通过参数对rollback segment做进一步的设置,比较重要的就是可通过参数配置独立
Undo 表空间,脱离 ibdata,支持自动收缩。这些参数包括:
innodb_undo_directory:设置rollback segment文件所在的路径。这意味着rollback segment可以存放在共享表空间以外的位置,即可以设置为独立表空间。该参数的默认值为./,表示当前 InnoDB 存储引擎的目录。innodb_undo_logs:设置rollback segment的个数,默认值为128。在 InnoDB1.2 版本中,该参数用来替换之前版本的参数innodb_rollback_segments。innodb_undo_tablespaces:设置构成 rollback segment 文件的数量,这样 rollback segment 可以较为平均地分布在多个文件中。设置该参数后,会在路径innodb_undo_directory看到 undo 为前缀的文件,该文件就代表rollback segment文件。innodb_undo_log_truncate:开启 Undo 表空间自动收缩
对于回滚段和事务的说明
每个事务只会使用一个 Rollback Segment 回滚段,但是一个回滚段在同一时刻可能会服务于多个事务。
在回滚段中,事务会不断填充盘区,直到事务结束或所有的空间被用完。如果当前的盘区不够用,事务会在段中请求扩展下一个盘区,如果所有已分配的盘区都被用完,事务会覆盖最初的盘区或者在回滚段允许的情况下扩展新的盘区来使用。
回滚段存在 Undo 表空间中,在数据库中可以存在多个Undo表空间,但同一时刻只能使用一个 Undo 表空间。
当事务提交时,InnoDB 存储引擎会做以下两件事情:
- 将 Undo Log 放入列表中,以供之后的 purge 操作
- 判断 Undo Log 所在的页是否可以重用,若可以分配给下个事务使用
事务开始时分配回滚段,结束后释放绑定,但回滚段本身不销毁
Undo Log 所在的 Undo 页是循环复用的(类似 Redo Log),而且所有回滚段都存储在 Undo 表空间中,不会无限膨胀;
对于如何清理,就是后台有专门的 Purge
线程,定期清理事务使命已经完成,可以被安全清理掉的 Undo Log 版本
Undo 页也并不特殊,默认大小也是和 InnoDB 数据页大小一致, 16KB,而且一个页可存储多个事务的 Undo Log,因为它们都是离散分布的
回滚段中的数据分类
回滚段中的数据分成三类,本质就是 Undo Log 在不同阶段的状态,决定了 是否可被覆盖
- 未提交的回滚数据(
uncommitted undo information):该数据所关联的事务并未提交,用于实现读一致性,所以该数据不能被其他事务的数据覆盖。 - 已经提交但未过期的回滚数据(
committed undo information):该数据关联的事务已经提交,但是仍受到 undo retention 参数的保持时间的影响。 - 事务已经提交并过期的数据(
expired undo information):事务已经提交,而且数据保存时间已经超过 undo retention 参数指定的时间,属于已经过期的数据。当回滚段满了之后,会优先覆盖。
这里涉及到一个关键参数:innodb_undo_log_retention
- 作用:设置已提交 Undo Log 的最小保留时间(单位:秒),默认 900 秒(15 分钟);
- 如果有长事务(如统计报表),需调大该值(如 3600 秒),避免长事务读取时 Undo Log 已被清理,导致 快照过旧 错误。
Undo页的重用
当我们开启一个事务需要写 Undo Log
的时候,就得先去Undo Log segment中去找到一个空闲的位置,当有空位的时候,就去申请
Undo 页,在这个申请到的 Undo 页中进行 Undo Log 的写入。我们知道 MySQL
默认一页的大小是16k。
为每一个事务分配一个页,是非常浪费的(除非你的事务非常长),假设你的应用的TPS(每秒处理的事务数目)为1000,那么1s就需要1000个页,大概需要16M的存储,1分钟大概需要1G的存储。如果照这样下去除非 MySQL 清理的非常勤快,否则随着时间的推移,磁盘空间会增长的非常快,而且很多空间都是浪费的。
于是Undo页就被设计的可以重用了,当事务提交时,并不会立刻删除 Undo 页。因为 Undo 页被设计成重用的了,所以这个 Undo 页可能混杂着其他事务的 Undo Log,当然不能立刻删除
Undo Log 在对应的事务 Commit 后,它会被放到一个链表中,然后判断 Undo 页的使用空间是否小于3/4,如果小于3/4的话,则表示当前的 Undo 页可以被重用,那么它就不会被回收,其他事务的 Undo Log 可以记录在当前 Undo 页的后面。由于 Undo Log 是离散的,所以清理对应的磁盘空间时,效率不高。
事务提交后页不删除,仅标记为可复用;
Undo Log 的工作
在更新数据之前,MySQL会提前生成 Undo Log 日志,当事务提交的时候,并不会立即删除 Undo Log,因为后面可能需要根据 Undo Log 进行回滚操作,
Undo Log 日志的删除是通过通过后台 purge 线程进行回收处理的,和 Redo Log 的 Log Thread 后台线程一样都是 InnoDB 的后台线程
事务A执行 UPDATE 操作,此时事务还没提交,在修改 test 表数据之前,InnoDB 会先把修改前的旧数据备份到对应的 Undo Buffer 中
然后由 Undo Buffer 持久化到磁盘中的 Undo Log 文件中,这部分和 Redo Log 是很类似的,此时 Undo Log 保存了未提交之前的数据,可以继续事务了,接着修改 test 表的数据页,这时候就是 Redo Log 的事了,后续由后台线程将内存中修改后的数据页,持久化到磁盘上的 test.id 文件。
此时事务 B 进行 SELECT 操作,读取同一条数据,我们设定这时事务A还没提交事务,事务 B 无法直接读取事务 A 修改后的数据,它会通过 Undo Log 找到这条数据修改前的旧版本来返回给用户,这样就实现了读不阻塞写,写不阻塞读的MVCC
如果 Undo Log 还在 Undo Buffer 中,就直接从内存读取;如果已经被刷到磁盘,就从磁盘的 Undo Log 文件中读取。
如果要回滚(ROLLBACK)事务,InnoDB 会读取 Undo Log 中备份的旧数据,将 test 表中的数据恢复到修改前的状态,如果可以的话这个过程是不读磁盘的,先直接从 Undo Buffer 缓存读取,没有就没办法了
来看看 Undo Log 的结构,Undo Log日志里面不仅存放着数据更新前的记录,还记录着 RowID行标识,事务XID,回滚指针。
这张图展示了 Undo Log 如何形成版本链,支撑 MVCC。
事务 A 执行 INSERT,新插入的行会生成一条 Undo Log 记录。然后事务 B 执行 UPDATE,事务 B 在修改数据前,会先生成一条新的 Undo Log 记录,新记录的 回滚指针(ROL_PTR)会指向事务 A 生成的那条旧记录。如果事务 B 回滚,就通过回滚指针找到上一个版本,恢复数据。以此类推,就会形成一条 Undo Log 的回滚链,方便找到该条记录的历史版本。
如果有一个在事务 A 之后、事务 B 之前开始的查询,它会根据自己的事务
ID,在版本链中找到对自己可见的那个版本(即
name='KK'),从而实现 “可重复读”。
三大日志整体协作
关键在于这张图,我有点懒得整理举例的详细流程了说实话
两阶段提交
事务的特性决定了什么
当功能函数中有批量增删改操作时,用事务包裹这一系列操作,保证要么全部成功,要么全部回滚,避免出现脏数据,让整体数据可控。
对MySQL来说你可以通过下面的命令显示的开启、提交、回滚事务
1 | # 开启事务 |
但是日常开发中大家普遍使用编程语言操作数据库。在使用这种具体编程语言持久层的框架时,它们一般都支持事务操作,比如,在
Spring
中你可以对一个方法添加注解@Transctional显示的开启事务。Golang
的 beego 中也提供了让你可以显示的开启事务的函数。
有一张数据表
1 | CopyCREATE TABLE `test_backup` ( |
然后我往这个表中insert几条数据,再去查看
binlog,可以发现,即使没有显式执行begin和commit,Binlog
中依然记录了这条insert对应的事务的开启和提交
因为 MySQL 的autocommit参数默认值为ON,每条
SQL
会自动开启一个事务,并在执行完成后自动提交,当使用框架的事务注解或手动开启事务时,框架会向
MySQL
发送begin/start transaction,阻止自动提交,保证一组
SQL 在同一个事务中执行。
两阶段提交的流程
redo log(重做日志)让 InnoDB 存储引擎拥有了崩溃恢复能力。
binlog(归档日志)保证了 MySQL 集群架构的数据一致性。
虽然它们都属于持久化的保证,但是侧重点不同。
在执行更新语句过程,会记录 redo log 与 binlog 两块日志,以基本的事务为单位,redo log 在事务执行过程中可以不断写入,而 binlog 只有在提交事务时才写入,所以 redo log 与 binlog 的写入时机不一样。
那么,两阶段提交解决的核心问题是避免 Redo Log 和 Binlog 数据不一致。
redo log 与 binlog 两份日志之间的逻辑不一致,会出现什么问题?
考虑一下没有两阶段提交,那么两者的同步会这样
事务提交时,先刷 Redo Log 到磁盘,再刷 Binlog 到磁盘
如果,Redo Log 刷盘成功,但 Binlog 刷盘失败,重启后 InnoDB 会通过 Redo Log 恢复数据,此时数据是修改之后的,但是 Binlog 没有相关修改,Binlog 是逻辑日志,主从同步或者 数据恢复时就会出现问题
事务提交时,先刷 Binlog 到磁盘,再刷 Redo Log 到磁盘
如果Binlog 刷盘成功,但 Redo Log 刷盘失败,重启后 InnoDB 没有 Redo Log 可恢复,此时数据是修改前的,但 Binlog 里有这条修改,主从同步或者数据恢复时就会多一条修改,出现问题
因此,MySQL 引入两阶段提交,把事务提交拆成 Prepare 阶段 和 Commit 阶段,强制保证:要么 Redo Log 和 Binlog 都刷盘成功,要么都失败。
为了解决两份日志之间的逻辑一致问题,InnoDB 存储引擎使用两阶段提交方案。
其实所谓的两阶段就是把一个事务分成两个阶段来提交,实则是指 Redo Log
分两次写入,将 redo log
的写入拆成了两个步骤prepare和commit
从图中可看出,事务的提交过程有两个阶段,就是将 redo log 的写入拆成了两个步骤:prepare 和 commit,中间再穿插写入binlog,具体如下:
- prepare 阶段:将 XID(内部 XA 事务的 ID) 写入到 redo log,同时将
redo log 对应的事务状态设置为 prepare,然后将 redo log
持久化到磁盘(
innodb_flush_log_at_trx_commit = 1的作用); - commit 阶段:把 XID 写入到 binlog,然后将 binlog
持久化到磁盘(
sync_binlog = 1的作用),接着调用引擎的提交事务接口,将 redo log 中该事务的状态设置为 commit,此时该状态并不需要持久化到磁盘,只需要 write 到文件系统的 page cache 中就够了
使用两阶段提交后,写入 binlog
时发生异常也不会有影响,因为 MySQL 根据 redo log 日志恢复数据时,发现
redo log 还处于prepare阶段,并且没有对应 binlog
日志,就会回滚该事务。
那么如果 redo log
设置commit阶段发生异常,那会不会回滚事务呢?
并不会回滚事务,此时 redo log 设置成 commit 虽然失败了,处于 prepare
阶段,但是能通过事务id找到对应的 binlog 日志,MySQL
会认为它是一个完整的事务提交流程,无伤事务大雅。
我们以 UPDATE t SET a=20 WHERE id=1; COMMIT;
为例,拆解每一步
事务执行
事务执行阶段还没到提交
- 客户端发送 UPDATE 语句,MySQL 先检查权限、加行锁
- 然后从 Buffer Pool 读取 id=1 的数据页,未命中就磁盘读,生成 Undo Log,记录原始值
- 然后修改 Buffer Pool 读取 id=1 的数据,产生 Redo Logo,写入 Redo Logo Buffer
- 然后生成 Binlog 内容,记录执行语句,写入 Binlog Buffer
Prepare 阶段
这是两阶段提交的第一个步骤,也就是准备提交事务,确保 Redo Log 刷盘,且标记为准备状态
- MySQL 向 InnoDB 发送 Prepare 指令;
- InnoDB 将该事务的 Redo Log 从 Buffer 刷到磁盘中的 Redo Log 文件;
- InnoDB 在 Redo Log 中记录 该事务的状态为 Prepare(准备完成),并刷盘;
- InnoDB 向 MySQL 服务器层返回 Prepare 成功 的确认。
此时 Redo Log 已经落盘,但事务还没真正提交,Redo Log 里的事务状态是 Prepare,而不是 Commit
Commit 阶段
这个阶段正式提交,刷 Binlog 到磁盘,再标记 Redo Log 为提交状态
- MySQL 服务器层收到 InnoDB 的 Prepare 成功确认后,将该事务的 Binlog 从 Buffer 刷到磁盘中的 Binlog 文件中
- MySQL 向 InnoDB 发送 Commit 指令
- InnoDB 在 Redo Log 中记录 该事务的状态为 Commit(提交完成),并刷盘;
- InnoDB 释放该事务持有的锁(行锁 / 表锁);
- InnoDB 向 MySQL 服务器层返回 Commit 成功,MySQL 向客户端返回事务提交成功。
MySQL中的 binlog 默认都是不开启的状态,只有在特定场景下(如配置为主从复制的主库)才需要手动启用,所以,如果你根本不需要 binlog 带给你的特性,那你根本就用不着让MySQL写binlog,也用不着什么两阶段提交,一个 Redo Log 足够。
无论你的数据库如何宕掉了,Redo Log 中记录的内容总能让你 MySQL 内存中的数据恢复成宕掉之前的状态。
总结
MySQL InnoDB 引擎使用 redo log(重做日志) 保证事务的持久性,使用 undo log(回滚日志) 来保证事务的原子性。
MySQL 数据库的数据备份、主备、主主、主从都离不开 binlog,需要依靠 binlog 来同步数据,保证数据一致性
MySQL 的持久化并非直接写磁盘数据文件(随机 IO),而是通过 WAL(Write-Ahead Logging) 机制,将随机写转换为 Redo Log 的顺序写,在保证性能的同时实现了崩溃后的数据重做。
Undo Log 记录了数据的“前世”,不仅是事务回滚(原子性)的依赖,更是 MVCC(多版本并发控制) 的核心,实现了读写不冲突。
两阶段提交(2PC) 是连接 Redo Log 和 Binlog
的桥梁。它通过 Prepare 和 Commit
两个阶段,强制保证了引擎层日志与 Server
层日志的逻辑一致,解决了主从架构下数据同步的可靠性问题。







