前言
谈论一切之前,使用的数据库一定要支持事务,本文以MySQL InnoDB存储引擎为例,从数据库事务基本原理出发,简要说明事务相关的主要知识,以及如何在Spring项目中使用,对于细节的描述少且模糊,需要不断完善。
相关内容:InnoDB事务实现、Spring事务传播机制等。
什么是事务(Transaction)
事务是逻辑上的一组操作,要么都执行,要么都不执行。事务是一种用于维护数据一致性的机制,它确保了数据库在多个并发操作下仍然保持一致性。
事务(最小操作单元)存在的主要意图:
- 在最小操作单元中保持稳定的操作,即使在故障时也能恢复到操作之前的状态保持数据一致性。
- 保持各个最小操作单元之间互相隔离,以防止互相交互产生的覆盖性错误。
事务结束的两种可能方式:
commit
:提交最小操作单元中的所有操作。terminate
:操作终止,最小操作单元中所有修改无效。
数据库操作的环境:
- 共享-多用户并发访问
- 不稳定-潜在的硬件/软件故障
事务所需环境:
- 不共享 - 一个事务内的操作不受其他事务影响
- 稳定 - 即使面对系统故障,当前事务的操作也能保留现场
一个事务一旦开始,则必须确保:
- 所有操作必须可回溯
- 所有操作对后续操作的影响必须是可见的
一个事务开始的过程中必须确保:在该事务结束之前其他事务看不到它的结果。如果事务中止,必须确保当前事务所有可能影响数据一致性的操作都会被清理。如果系统出现故障,必须确保重新启动时所有未提交的事务都会被清理。
关系型数据库大多遵循事务的四大特性:
- 原子性(Atomicity):事务是最小的执行单位,事务中的所有操作要么全部成功执行,要么全部失败回滚。如果其中任何一个操作失败,那么整个事务都会被回滚到初始状态。
- 一致性(Consistency):事务在执行前后,数据库必须保持一致性状态。这意味着事务执行后,数据库的完整性约束仍然得以维护,以转账业务为例,双方存款总额应不变。
- 隔离性(Isolation):事务的执行应该与其他事务相互隔离,即一个事务的执行不应该影响其他事务的执行。这确保了并发事务之间的数据不会互相干扰。
- 持久性(Durability):一旦事务提交成功,对数据库的修改应该永久保存,即使数据库发生故障也不应该对其有影响。
关于四大特性的一些理解:
原子性,隔离性和持久性是数据库的属性,而一致性(在 ACID 意义上)是应用程序的属性。应用可能依赖数据库的原子性和隔离属性来实现一致性,但这并不仅取决于数据库。只有保证了事务的持久性、原子性、隔离性之后,一致性才可能得到保障。
- 只有满足一致性,事务的执行结果才是正确的。
- 在无并发的情况下,事务串行执行,隔离性一定能够满足。此时只要能满足原子性,就一定能满足一致性。 在并发的情况下多个事务并行执行,事务不仅要满足原子性,还需要满足隔离性,才能满足一致性。
- 事务满足持久性是为了能应对数据库崩溃的情况。
InnoDB事务实现
MySQL提供插件式存储引擎,这些存储引擎是基于表的,而不是数据库。
InnoDB存储引擎支持事务,其设计目标主要面向在线事务处理(OLTP)的应用。它的特点是行锁设计,支持外键,并支持非锁定读,即默认读取操作不会产生锁。从MySQL数据库5.5.8版本开始,InnoDB存储引擎是MySQL默认的存储引擎。
在InnoDB引擎中实现事务最重要的东西就是日志系统,保证事务的四大特性主要依靠这两大日志:
- redo log:保证事务持久性
- undo log:回滚日志,保证事务原子性
两大日志系统分别保证了持久性和原子性,隔离性则是通过MVCC机制和锁机制来控制实现。
Logical logs & Physical logs
逻辑日志(Logical Logs):
- 记录内容:逻辑日志记录的是数据库操作的逻辑信息,例如SQL语句、表和列的名称、数据的逻辑结构等。它不关心底层数据的物理存储方式。
- 用途:逻辑日志主要用于数据导入、导出、备份和恢复等高层次的数据操作。它允许将数据从一个数据库复制到另一个数据库,而不必考虑底层数据的物理结构。
- 示例:MySQL的二进制日志(binlog)是一种逻辑日志,记录了SQL语句的执行顺序,以便在复制数据或进行数据备份时使用。
物理日志(Physical Logs):
- 记录内容:物理日志记录的是数据库操作对底层物理数据的实际修改,包括数据页的读写、磁盘块的分配和释放等。它关注数据的物理存储细节。
- 用途:物理日志主要用于确保事务的持久性和恢复能力。它允许在系统崩溃或故障后恢复未提交的事务,以及将事务的修改应用到数据库中。
- 示例:MySQL的重做日志(redo log)是一种物理日志,记录了事务对数据页的修改,以便在事务提交后将这些修改应用到数据文件中,或者在系统故障时恢复数据一致性。
以上只是简要的概念解释,更多信息可以参见[6]。
Binlog
在介绍InnoDB的两大核心日志前,先简单聊一下MySQL的二进制日志,对理解Redo log的作用有帮助。
binlog = binary log,二进制日志,它记录了除了 select 之外所有的 DDL 和 DML 语句。以事件形式记录,还包含语句所执行的消耗的时间,MySQL 的二进制日志是事务安全型的。binlog是MySQL的逻辑日志,并且由Server层进行记录,使用任何存储引擎的MySQL都会记录binlog日志。
binlog日志有两个最重要的使用场景:
- 主从复制:mysql replication 在 master 端开启 binlog,master 把它的二进制日志传递给 slaves 来达到 master-slave 数据一致的目的。
- 数据恢复:通过 mysqlbinlog 工具来恢复数据。
binlog 日志包括两类文件:
- 二进制日志索引文件(文件名后缀为 .index)用于记录所有的二进制文件。
- 二进制日志文件(文件名后缀为 .00000*)记录数据库所有的 DDL 和 DML 语句事件。
binlog文件是通过追加的方式写入的,可通过配置参数max_binlog_size
设置每个 binlog 文件的大小,当文件大小大于给定值后,日志会发生滚动,之后的日志记录到新的文件上。
binlog日志有三种格式,分别为STATMENT、ROW和MIXED。在 MySQL 5.7.7之前,默认的格式是STATEMENT,MySQL 5.7.7之后,默认值是ROW。日志格式通过binlog-format
指定。
我们假设数据库只有 binlog,那么数据文件的更新和写入 binlog 只有两种情况:
- 先更新数据文件,再写入 binlog。
- 先写入 binlog,再更新数据文件。
如果先更新数据文件,接着服务器宕机,则导致 binlog 中缺少最后的更新信息;如果先写 binlog 再更新数据则可能导致数据文件未被更新。所以在只有 binlog 的环境中的 MySQL 是不具备 crash-safe 的能力。
PS:这里关于binlog的写入机制不做过多展开,但也是分write和fsync两个步骤,时机由参数sync_binlog
控制。
Write-Ahead Logging
Write-Ahead Logging策略是一种用于确保数据一致性和恢复能力的重要技术,为了保证恢复时可以从日志中看到最新的数据库状态,要求日志先于数据内容落盘。其核心思想是在修改数据之前,首先将这些修改操作记录到一个持久性的日志文件中,然后再将这些操作应用到实际的数据文件。注意这里的日志是比binlog更细粒度的日志。
除此之外,事务完成提交前还需要在日志中记录对应的Commit标记,以供恢复时了解当前的事务状态,因此还需要关注Commit标记和事务中数据内容的落盘顺序。根据日志中记录的内容可以分为三类:Undo-Only,Redo-Only,Redo-Undo。
Undo-Only Logging
Undo-Only Logging的Log记录可以表示为<T, X, v>
,事务$T$修改了$X$的值,$X$的旧值是v。事务提交时,需要通过强制Flush保证Commit标记落盘前,对应事务的所有数据落盘,即落盘顺序为Log记录->Data->Commit标记。恢复时可以根据Commit标记判断事务的状态,并通过Undo Log中记录的旧值将未提交事务的修改回滚。我们来审视一下Undo-Only对Durability及Atomic的保证:
- Durability of Updates:Data强制刷盘保证,已经Commit的事务由于其所有Data都已经在Commit标记之前落盘,因此会一直存在;
- Failure Atomic:Undo Log内容保证,失败事务的已刷盘的修改会在恢复阶段通过Undo日志回滚,不再可见。
然而Undo-Only依然有不能Page内并发的问题,如果两个事务的修改落到一个Page中,一个事务提交前需要的强制Flush操作,会导致同Page所有事务的Data落盘,可能会早于对应的Log项从而损害WAL。同时,也会导致关键路径上过于频繁的磁盘随机访问。
Redo-Only Logging
不同于Undo-Only,采用Redo-Only的Log中记录的是修改后的新值。对应地,Commit时需要保证,Log中的Commit标记在事务的任何数据之前落盘,即落盘顺序为Log记录->Commit标记->Data。恢复时同样根据Commit标记判断事务状态,并通过Redo Log中记录的新值将已经Commit,但数据没有落盘的事务修改重放。
- Durability of Updates:Redo Log内容保证,已提交事务的未刷盘的修改,利用Redo Log中的内容重放,之后可见;
- Failure Atomic:阻止Commit前Data落盘保证,失败事务的修改不会出现在磁盘上,自然不可见。
Redo-Only同样有不能Page内并发的问题,Page中的多个不同事务,只要有一个未提交就不能刷盘,这些数据全部都需要维护在内存中,造成较大的内存压力。
Redo-Undo Logging
可以看出的只有Undo或Redo的问题,主要来自于对Commit标记及Data落盘顺序的限制,而这种限制归根结底来源于Log信息中对新值或旧值的缺失。因此Redo-Undo采用同时记录新值和旧值的方式,来消除Commit和Data之间刷盘顺序的限制。
- Durability of Updates:Redo 内容保证,已提交事务的未刷盘的修改,利用Redo Log中的内容重放,之后可见;
- Failure Atomic:Undo内容保证,失败事务的已刷盘的修改会在恢复阶段通过Undo日志回滚,不再可见。
如此一来,同Page的不同事务提交就变得非常简单。同时可以将连续的数据攒着进行批量的刷盘已利用磁盘较高的顺序写性能。
脏页
当内存数据页跟磁盘数据页内容不一致的时候,我们称这个内存页为“脏页”。内存数据写入磁盘后,内存和磁盘上的数据页内容就一致了,称为“干净页”。刷脏页,即把脏页(内存中的修改过的数据页)刷新(flush)到磁盘上。
对于 InnoDB 存储引擎,缓冲池(buffer pool)是内存中的一个重要组成部分。当查询需要读取数据时,数据库首先查看缓冲池中是否已经有相应的数据页。如果数据页在缓冲池中,查询可以立即从内存中获取数据,而不必进行磁盘读取,这大大提高了性能。如果数据页不在缓冲池中(缓冲池未命中),数据库系统将从磁盘读取该数据页,并将其放入缓冲池中,以便将来的查询可以更快地访问。缓冲池的大小通常是可以配置的,数据库管理员可以根据系统的内存和性能需求来调整缓冲池的大小。
对于事务处理,数据的修改通常首先在内存中进行,然后等待事务提交。在事务提交之前,数据在内存中被认为是脏的,因为它们还未被写入到磁盘。下面有一段文字可以也可以帮助引出Redo log的作用。
InnoDB 有 缓冲池(buffer pool)。缓冲池是物理页的缓存,对 InnoDB 的任何修改操作都会首先在缓冲池的 page 上进行,然后这样的页面将被标记为 dirty 并被放到专门的 flush list 上,后续将由专门的刷脏线程阶段性的将这些页面写入磁盘。这样的好处是避免每次写操作都操作磁盘导致大量的随机IO,阶段性的刷脏可以将多次对页面的修改 merge 成一次IO操作,同时异步写入也降低了访问的时延。
然而,如果在 dirty page 还未刷入磁盘时,server非正常关闭,这些修改操作将会丢失,如果写入操作正在进行,甚至会由于损坏数据文件导致数据库不可用。为了避免上述问题的发生,Innodb 将所有对页面的修改操作写入一个专门的文件,并在数据库启动时从此文件进行恢复操作,这个文件就是 redo log file。这样的技术推迟了缓冲池页面的刷新,从而提升了数据库的吞吐,有效的降低了访问时延。带来的问题是额外的写 redo log 操作的开销(顺序 IO,比随机 IO 快很多),以及数据库启动时恢复操作所需的时间。
Redo log (Durability)
Redo log包括两部分:一个是内存中的日志缓冲(redo log buffer),另一个是磁盘上的日志文件(redo log file)。
在计算机操作系统中,用户空间(user space)下的缓冲区数据一般情况下是无法直接写入磁盘的,中间必须经过操作系统内核空间(kernel space)缓冲区(OS Buffer)。因此,redo log buffer写入redo log file实际上是先写入文件系统page cache,然后再通过系统调用fsync将其刷到redo log file中。由此可以得到redo log的三种状态:
- 存在 redo log buffer 中,物理上是在 MySQL 进程内存中。
- 写到磁盘 (write),但是没有持久化,物理上是在文件系统的 page cache 里面。
- 调用fsync,持久化到磁盘。
事务在执行过程中,对内存中数据页进行修改将生成redo log,生成的 redo log 是先写到 redo log buffer 中,然后通过某些方式刷入磁盘。这里所指的方式,个人理解主要有五种:
- 后台线程每秒一次执行刷盘,并行轮询。
- 每个事务提交时依据策略刷盘。
- 当redo log buffer缓存可用空间小于一半的时候刷盘,整体空间受
innodb_log_buffer_size
控制。这个情况的刷盘仅指write,然后可能被后台线程刷盘,这也是未提交redo log刷盘的可能情况之一。 - 数据库服务器正常关闭时。
- 检查点,checkpoints。
PS:另外两种情况是:1、事务执行过程中的 redo log 也是直接写在 redo log buffer 中的,即将事务的修改暂时保存于内存中,这些 redo log 也会被后台线程一起持久化到磁盘。即一个没有提交的事务的 redo log,也是可能已经持久化到磁盘的。2、事务并行,先提交的事务把其他事务的redo log buffer刷盘。
Redo log的在事务提交时的写入策略由参数innodb_flush_log_at_trx_commit
控制,有以下三种取值选项:
- 设置为
0
的时候,表示每次事务提交时都只是把 redo log 留在 redo log buffer 中 ; - 设置为
1
的时候,表示每次事务提交时都将 redo log 直接持久化到磁盘(默认); - 设置为
2
的时候,表示每次事务提交时都只是把 redo log 写到 page cache。
(TODO:这一部分应该有问题,内存中的数据也会写入,不过这时候落盘的脏数据就靠undo了)那么了解了redo log的写入机制后,它到底是如何实现保证数据库持久性的呢?下面我尝试从一个事务的开始进行分析。
1 | Chain1: 事务开始 -> 数据页读取 -> 事务执行(数据页修改 & redo log生成)-> 事务提交 -> 事务结束 |
我把执行流程分为了两个链条,chain1是事务执行的流程,chain2是redo log部分InnoDB在对应位置可能采取的操作。下面我们在chain1的每个部分模拟掉电关机,看会发生什么事情。由于这一块内容实际上是崩溃恢复相关内容,我们先定义正常状态,正常状态指的是 MySQL 崩溃之前,数据页最后一次正确的刷新到磁盘的状态。
1、数据页读取:对数据库完整性无影响。
2、事务执行(redo log生成未落盘):无影响,事务未提交。
3、事务执行(redo log生成已落盘):无影响,事务未提交。
4、事务提交(redo log写入策略=1):任意时间崩溃,都可以通过redo log执行恢复,损坏的数据页可以通过double write修复后再执行恢复。
5、事务提交(redo log写入策略=2):如果在redo log刚写入未刷盘时断电,则会丢失上一秒的数据,仅mysqld崩溃不会丢失数据。
6、事务提交(redo log写入策略=0):与上一情况相同,但如果崩溃情况降级,例如mysqld崩溃,也会丢失数据。
注意以上分析仅是简化版,更多细节如Checkpoints、Double write、LSN、Mini-Transaction等…需要多读书实践才能通透了,需要时再学习吧。
Undo log (Atomicity)
Undo Log是InnoDB十分重要的组成部分,它的作用横贯InnoDB中两个最主要的部分,并发控制(Concurrency Control)和故障恢复(Crash Recovery),InnoDB中undo log的实现亦日志亦数据。
为保证原子性,InnoDB会在正常事务进行中,就不断的连续写入undo log,来记录本次修改之前的历史值。当故障真正发生时,可以在recovery过程中通过回放undo log将未提交事务的修改抹掉。此外,undo log也可以用来支持死锁处理或用户请求的事务回滚。
在并发控制中,主流数据库采用多版本并发控制,为每条记录保存多份历史数据供读事务访问,新的写入只需要添加新的版本即可,InnoDB利用undo log提供此功能。
在设计方面,undo log需要的是事务之间的并发,以及方便的多版本数据维护,其重放逻辑不希望因数据库物理存储变化而变化,因此InnoDB中的undo log采用逻辑日志。同时,InnoDB是把undo log当做一种数据来维护和使用的,其本身也像其他的数据库数据一样,会写自己对应的redo log,以此保证自己不出错。
Undo Record的内容
每当InnoDB中需要修改某条记录时,都会将其历史版本写入一个undo log中,对应的undo record是Update类型。当插入新的记录时,还没有一个历史版本,但为了方便事务回滚时做逆向(Delete)操作,还是会写入一个Insert类型的undo record。
对于Insert类型的undo record,它仅仅是为了可能的事务回滚准备的,并不在MVCC功能中承担作用,因此只需要用Key Fields记录对应数据库记录的主键,供回滚时查找记录位置即可。
其中Undo Number是Undo的一个递增编号,Table ID用来表示是哪张表的修改。下面一组Key Fields的长度不定,因为对应表的主键可能由多个field组成,这里需要存储数据库记录完整的主键信息,回滚的时候可以通过这个信息在索引中定位到对应的记录。除此之外,在Undo Record的头尾还各留了两个字节用户记录其前序和后继Undo Record的位置。
对于Update类型的undo record,情况稍微复杂一些,由于MVCC需要保留记录的多个历史版本,当某个记录的历史版本还在被使用时,这个记录是不能被真正的删除的。因此,当需要删除时,其实只是修改对应记录的Delete Mark标记。对应的,如果这时这个记录又重新插入,其实也只是修改一下Delete Mark标记,也就是将这两种情况的删除和插入转变成了更新操作。再加上常规的更新记录,这种类型的undo record存在三种类型:TRX_UNDO_UPD_EXIST_REC, TRX_UNDO_DEL_MARK_REC, TRX_UNDO_UPD_DEL_REC
。
除了Key Fields外,Update类型的undo record增加了以下内容:
- Transaction Id,记录了产生这个历史版本事务ID,用作后续MVCC中的版本可见性判断。
- RollPtr,指向的是该记录的上一个版本的位置,沿着RollPtr可以找到一个记录的所有历史版本。
- Update Fields,其中记录的就是当前这个记录版本相对于其之后的一次修改的Delta信息,包括所有被修改的Field的编号,长度和历史值。
Undo Record的组织方式
每一次的修改都会产生至少一个Undo Record,现在考虑大量Undo Record如何组织起来支持高效访问与管理。首先是在不考虑物理存储的情况下的逻辑组织方式;之后,物理组织方式介绍如何将其存储到到实际16KB物理块中;然后文件组织方式介绍整体的文件结构;最后再介绍其在内存中的组织方式。
逻辑组织方式 - Undo Log
每个事务会修改一组数据库记录,对应的会产生一组Undo Record,这些Undo Record首尾相连组成了这个事务的Undo Log。除了一个个的Undo Record之外,还在开头增加了一个Undo Log Header来记录一些必要的控制信息,因此,一个Undo Log的结构如下所示:
Undo Log Header中记录了产生这个Undo Log的事务的Trx ID;Trx No是事务的提交顺序,也会用这个来判断是否能Purge,这个在后面会详细介绍;Delete Mark标明该Undo Log中有没有TRX_UNDO_DEL_MARK_REC
类型的Undo Record,避免Purge时不必要的扫描;Log Start Offset中记录Undo Log Header的结束位置,方便之后Header中增加内容时的兼容;之后是一些Flag信息;Next Undo Log及Prev Undo Log标记前后两个Undo Log,这个会在接下来介绍;最后通过History List Node将自己挂载到为Purge准备的History List中。
索引中的同一个数据库记录被不同事务修改,会产生不同的历史版本,这些历史版本又通过Rollptr穿成一个链表,供MVCC使用。如下图所示:
示例中有三个事务操作了表$t$上,主键id是1的记录,首先事务$I$插入了这条记录并且设置字段$a$的值为A,之后事务$J$和事务$K$分别将这条id为1的记录中的字段$a$的值修改为了B和C。$I$,$J$,$K$三个事务分别有自己的逻辑上连续的三条Undo Log,每条Undo Log有自己的Undo Log Header。从索引中的这条数据库记录沿着Rollptr可以依次找到这三个事务Undo Log中关于这条记录的历史版本。同时可以看出,Insert类型Undo Record中只记录了对应的主键值:id=1
,而Update类型的Undo Record中还记录了对应的历史版本的生成事务Trx_id,以及被修改的字段$a$的历史值。
物理组织格式 - Undo Segment
一个事务会产生多大的Undo Log本身是不可控的,而最终写入磁盘却是按照固定的块大小为单位的,InnoDB中默认是16KB,因此需要考虑如何用固定的块大小承载不定长的Undo Log,以实现高效的空间分配、复用,避免空间浪费。InnoDB的基本思路是让多个较小的Undo Log紧凑存在一个Undo Page中,而对较大的Undo Log则随着不断的写入,按需分配足够多的Undo Page分散承载。下面来看这部分的物理存储方式:
每个写事务开始写操作之前都需要持有一个Undo Segment,一个Undo Segment中的所有磁盘空间的分配和释放,也就是16KB Page的申请和释放,都是由一个FSP Segment管理的。
FSP(File Space Page)是InnoDB存储引擎中的一个概念,表示文件空间段(Segment)。每个FSP Segment对应于一个表空间(tablespace),用于存储数据和索引。
在InnoDB中,数据和索引被组织为一个个页面(Page),而这些页面又按照一定的方式来组织并保存在FSP Segment中。FSP Segment是InnoDB存储引擎管理存储空间的基本单位。
FSP Segment包含多个连续的文件空间页(File Space Pages),这些页面可以是数据页、索引页或其他类型的页,以满足不同的存储需求。每个FSP Segment都有自己的FSP ID(File Space Page ID),用于唯一标识它。
Undo Segment会持有至少一个Undo Page,每个Undo Page会在开头38字节到56字节记录Undo Page Header,其中记录Undo Page的类型、最后一条Undo Record的位置,当前Page还空闲部分的开头,也就是下一条Undo Record要写入的位置。Undo Segment中的第一个Undo Page还会在56字节到86字节记录Undo Segment Header,这就是这个Undo Segment中磁盘空间管理的Handle,其中记录的是这个Undo Segment的状态(State),包括TRX_UNDO_CACHED、TRX_UNDO_TO_PURGE等,还记录了这个Undo Segment中最后一条Undo Record的位置、这个FSP Segment的Header以及当前分配出来的所有Undo Page的链表。
Undo Page剩余的空间都是用来存放Undo Log的,对于像上图Undo Log 1,Undo Log 2这种较短的Undo Log,为了避免Page内的空间浪费,InnoDB会复用Undo Page来存放多个Undo Log,而对于像Undo Log 3这种比较长的Undo Log可能会分配多个Undo Page来存放。需要注意的是Undo Page的复用只会发生在第一个Page。
文件组织方式 - Undo Tablespace
每一时刻一个Undo Segment都是被一个事务独占的。每个写事务都会持有至少一个Undo Segment,当有大量写事务并发运行时,就需要存在多个Undo Segment。InnoDB中的Undo文件中准备了大量的Undo Segment的槽位,按照1024一组划分为Rollback Segment。每个Undo Tablespace最多会包含128个Rollback Segment,Undo Tablespace文件中的第三个Page会固定作为这128个Rollback Segment的目录,也就是Rollback Segment Arrary Header,其中最多会有128个指针指向各个Rollback Segment Header所在的Page。Rollback Segment Header是按需分配的,其中包含1024个Slot,每个Slot占四个字节,指向一个Undo Segment的First Page。除此之前还会记录该Rollback Segment中已提交事务的History List,后续的Purge过程会顺序从这里开始回收工作。
可以看出Rollback Segment的个数会直接影响InnoDB支持的最大事务并发数。MySQL 8.0由于支持了最多127个独立的Undo Tablespace,一方面避免了ibdata1的膨胀,方便undo空间回收,另一方面也大大增加了最大的Rollback Segment的个数,增加了可支持的最大并发写事务数。如下图所示:
内存组织结构
上面介绍的都是Undo数据在磁盘上的组织结构,除此之外,在内存中也会维护对应的数据结构来管理Undo Log,如下图所示:
对应每个磁盘Undo Tablespace会有一个undo::Tablespace的内存结构,其中最主要的就是一组trx_rseg_t的集合,trx_rseg_t对应的就是上面介绍过的一个Rollback Segment Header(目录),除了一些基本的元信息之外,trx_rseg_t中维护了四个trx_undo_t的链表,Update List中是正在被使用的用于写入Update类型Undo的Undo Segment;Update Cache List中是空闲空间比较多,可以被后续事务复用的Update类型Undo Segment;对应的,Insert List和Insert Cache List分别是正在使用中的Insert类型Undo Segment,和空间空间较多,可以被后续复用的Insert类型Undo Segment。因此trx_undo_t对应的就是上面介绍过的Undo Segment。接下来,我们就从Undo的写入、Undo用于Rollback、MVCC、Crash Recovery以及如何清理Undo等方面来介绍InnoDB中Undo的角色和功能。
Undo Log的写入
当写事务开始时,会先通过trx_assign_rseg_durable操作分配一个Rollback Segment,该事务的内存结构trx_t也会通过rsegs指针指向对应的trx_rseg_t内存结构,这里的分配策略很简单,就是依次尝试下一个Active的Rollback Segment。之后当第一次真正产生修改需要写Undo Record的时,会调用trx_undo_assign_undo操作来获得一个Undo Segment。这里会优先复用trx_rseg_t上Cached List中的trx_undo_t,也就是已经分配出来但没有被正在使用的Undo Segment,如果没有才调用trx_undo_create操作创建新的Undo Segment,trx_undo_create中会轮询选择当前Rollback Segment中可用的Slot,也是就值FIL_NUL的Slot,申请新的Undo Page,初始化Undo Page Header,Undo Segment Header等信息,创建新的trx_undo_t内存结构并挂到trx_rseg_t的对应List中。
获得了可用的Undo Segment之后,该事务会在合适的位置初始化自己的Undo Log Header,之后,其所有修改产生的Undo Record都会顺序的通过trx_undo_report_row_operation操作顺序的写入当前的Undo Log,其中会根据是insert还是update类型,分别调用trx_undo_page_report_insert或者trx_undo_page_report_modify。本文开始已经介绍过了具体的Undo Record内容。简单的讲,insert类型会记录插入Record的主键,update类型除了记录主键以外还会有一个update fileds记录这个历史值跟索引值的diff。之后指向当前Undo Record位置的Rollptr会返回写入索引的Record上。
当一个Page写满后,会调用trx_undo_add_page来在当前的Undo Segment上添加新的Page,新Page写入Undo Page Header之后继续供事务写入Undo Record,为了方便维护,这里有一个限制就是单条Undo Record不跨page,如果当前Page放不下,会将整个Undo Record写入下一个Page。
当事务结束(commit或者rollback)之后,如果只占用了一个Undo Page,且当前Undo Page使用空间小于page的3/4,这个Undo Segment会保留并加入到对应的insert/update cached list中。否则,insert类型的Undo Segment会直接回收,而update类型的Undo Segment会等待后台的Purge做完后回收。根据不同的情况,Undo Segment Header中的State会被从TRX_UNDO_ACTIVE
改成TRX_UNDO_TO_FREE
,TRX_UNDO_TO_PURGE
或TRX_UNDO_CACHED
,这个修改其实就是InnoDB的事务结束的标志,无论是Rollback还是Commit,在这个修改对应的Redo落盘之后,就可以返回用户结果,并且Crash Recovery之后也不会再做回滚处理。
Undo Log之回滚
InnoDB中的事务可能会由用户主动触发Rollback;也可能因为遇到死锁异常Rollback;或者发生Crash,重启后对未提交的事务回滚。在Undo层面来看,这些回滚的操作是一致的,基本的过程就是从该事务的Undo Log中,从后向前依次读取Undo Record,并根据其中内容做逆向操作,恢复索引记录。
回滚的入口是函数row_undo,其中会先调用trx_roll_pop_top_rec_of_trx获取并删除该事务的最后一条Undo Record。如下图例子中的Undo Log包括三条Undo Records,其中Record 1在Undo Page 1中,Record 2,3在Undo Page 2中,先通过从Undo Segment Header中记录的Page List找到当前事务的最后一个Undo Page的Header,并根据Undo Page 2的Header上记录的Free Space Offset定位最后一条Undo Record结束的位置,当然实际运行时,这两个值是缓存在trx_undo_t的top_page_no和top_offset中的。利用Prev Record Offset可以找到Undo Record 3,做完对应的回滚操作之后,再通过前序指针Prev Record Offset找到前一个Undo Record,依次进行处理。处理完当前Page中的所有Undo Records后,再沿着Undo Page Header中的List找到前一个Undo Page,重复前面的过程,完成一个事务所有Page上的所有Undo Records的回滚。
拿到一个Undo Record之后,自然地,就是对其中内容的解析,这里会调用row_undo_ins_parse_undo_rec,从Undo Record中获取修改行的table,解析出其中记录的主键信息,如果是update类型,还会拿到一个update vector记录其相对于更新的一个版本的变化。
TRX_UNDO_INSERT_REC
类型的Undo回滚在row_undo_ins中进行,insert的逆向操作当然就是delete,根据从Undo Record中解析出来的主键,用row_undo_search_clust_to_pcur定位到对应的ROW, 分别调用row_undo_ins_remove_sec_rec和row_undo_ins_remove_clust_rec在二级索引和主索引上将当前行删除。
update类型的undo包括TRX_UNDO_UPD_EXIST_REC
,TRX_UNDO_DEL_MARK_REC
和TRX_UNDO_UPD_DEL_REC
三种情况,他们的Undo回滚都是在row_undo_mod中进行,首先会调用row_undo_mod_del_unmark_sec_and_undo_update,其中根据从Undo Record中解析出的update vector来回退这次操作在所有二级索引上的影响,可能包括重新插入被删除的二级索引记录、去除其中的Delete Mark标记,或者用update vector中的diff信息将二级索引记录修改之前的值。之后调用row_undo_mod_clust同样利用update vector中记录的diff信息将主索引记录修改回之前的值。
完成回滚的Undo Log部分,会调用trx_roll_try_truncate进行回收,对不再使用的page调用trx_undo_free_last_page将磁盘空间交还给Undo Segment,这个是写入过程中trx_undo_add_page的逆操作。
Undo Log之故障恢复
Crash Recovery时,需要利用Undo中的信息将未提交的事务的所有影响回滚,以保证数据库的Failure Atomic。前面提到过,InnoDB中的Undo其实是像数据一样处理的,也从上面的组织结构中可以看出来,Undo本身有着比Redo Log复杂得多、按事务分配而不是顺序写入的组织结构,其本身的Durability像InnoDB中其他的数据一样,需要靠Redo来保证。除了通用的一些MLOG_2BYTES、MLOG_4BYTES类型之外,Undo本身也有自己对应的Redo Log类型:MLOG_UNDO_INIT类型在Undo Page舒适化的时候记录初始化;在分配Undo Log的时候,需要重用Undo Log Header或需要创建新的Undo Log Header的时候,会分别记录MLOG_UNDO_HDR_REUSE和MLOG_UNDO_HDR_CREATE类型的Redo Record;MLOG_UNDO_INSERT是最常见的,在Undo Log里写入新的Undo Record都对应的写这个日志记录写入Undo中的所有内容;最后,MLOG_UNDO_ERASE_END对应Undo Log跨Undo Page时抹除最后一个不完整的Undo
ARIES(Algorithms for Recovery and Isolation Exploiting Semantics)是一种事务恢复协议,用于数据库系统中的崩溃恢复和并发控制。它是一个经典的恢复算法,并被广泛应用于许多关系型数据库管理系统(RDBMS)。
ARIES本质是一种Redo-Undo的WAL实现。其正常运行过程为:修改数据之前先追加Log记录,Log内容同时包括Redo和Undo信息,每个日志记录产生一个标记其在日志中位置的递增LSN(Log Sequence Number);数据页中记录最后修改的日志项LSN,以此来判断Page中的内容的新旧程度,实现幂等。故障恢复阶段需要通过Log中的内容恢复数据库状态,为了减少恢复时需要处理的日志量,ARIES会在正常运行期间周期性的生成Checkpoint,Checkpoint中除了当前的日志LSN之外,还需要记录当前活跃事务的最新LSN,以及所有脏页,供恢复时决定重放Redo的开始位置。需要注意的是,由于生成Checkpoint时数据库还在正常提供服务(Fuzzy Checkpoint),其中记录的活跃事务及脏页信息并不一定准确,因此需要Recovery阶段通过Log内容进行修正。
Recover过程:故障恢复包含三个阶段:Analysis,Redo和Undo。Analysis阶段的任务主要是利用Checkpoint及Log中的信息确认后续Redo和Undo阶段的操作范围,通过Log修正Checkpoint中记录的脏页集合信息,并用其中涉及最小的LSN位置作为下一步Redo的开始位置RedoLSN。同时修正Checkpoint中记录的活跃事务集合(未提交事务),作为Undo过程的回滚对象;Redo阶段从Analysis获得的RedoLSN出发,重放所有的Log中的Redo内容,注意这里也包含了未Commit事务;最后Undo阶段对所有未提交事务利用Undo信息进行回滚,通过Log的PrevLSN可以顺序找到事务所有需要回滚的修改。
以ARIES过程为例,Crash Recovery的过程中会先重放所有的Redo Log,整个Undo的磁盘组织结构,也会作为一种数据类型也会通过上面讲到的这些Redo类型的重放恢复出来。之后在trx_sys_init_at_db_start操作中会扫描Undo的磁盘结构,遍历所有的Rollback Segment和其中所有的Undo Segment,通过读取Undo Segment Header中的State,可以知道在Crash前,最后持有这个Undo Segment的事务状态。如果是TRX_UNDO_ACTIVE
,说明当时事务需要回滚,否则说明事务已经结束,可以继续清理Undo Segment的逻辑。之后,就可以恢复出Undo Log的内存组织模式,包括活跃事务的内存结构trx_t,Rollback Segment的内存结构trx_rseg_t,以及其中的trx_undo_t的四个链表。
Crash Recovery完成之前,会启动在srv_dict_recover_on_restart中启动异步回滚线程trx_recovery_rollback_thread,其中对Crash前还活跃的事务,通过trx_rollback_active进行回滚,这个过程与上面提到的Undo回滚是一致的。
Undo Log的清理
InnoDB在Undo Log中保存了多份历史版本来实现MVCC,当某个历史版本已经确认不会被任何现有的和未来的事务看到的时候,就应该被清理掉。因此就需要有办法判断哪些Undo Log不会再被看到。InnoDB中每个写事务结束时都会拿一个递增的编号trx_no作为事务的提交序号,而每个读事务会在自己的ReadView中记录自己开始的时候看到的最大的trx_no为m_low_limit_no。那么,如果一个事务的trx_no小于当前所有活跃的读事务Readview中的这个m_low_limit_no,说明这个事务在所有的读开始之前已经提交了,其修改的新版本是可见的, 因此不再需要通过undo构建之前的版本,这个事务的Undo Log也就可以被清理了。
这里不多深入,暂时用处不大,相关内容:Undo Purge、Undo Truncate、Undo Tablespace Truncate。
MVCC和锁 (Isolation)
多版本的目的是为了避免写事务和读事务的互相等待,那么每个读事务都需要在不对数据库记录加锁的情况下,找到对应的应该看到的历史版本。所谓历史版本就是假设在该只读事务开始的时候对整个DB打一个快照,之后该事务的所有读请求都从这个快照上获取。当然实现上不能真正去为每个事务打一个快照,这个时间空间都太高了。InnoDB的做法,是在读事务第一次读取的时候获取一份ReadView,并一直持有,其中记录所有当前活跃的写事务ID,由于写事务的ID是自增分配的,通过这个ReadView我们可以知道在这一瞬间,哪些事务已经提交哪些还在运行,根据Read Committed的要求,未提交的事务的修改就是不应该被看见的,对应地,已经提交的事务的修改应该被看到。
作为存储历史版本的Undo Record,其中记录的trx_id就是做这个可见性判断的,对应的数据库主索引的记录上也有这个值。当一个读事务拿着自己的ReadView访问某个表索引上的记录时,会通过比较记录上的trx_id确定是否是可见的版本,如果不可见就沿着Record或Undo Record中记录的rollptr一路找更老的历史版本。如下图所示,事务$R$开始需要查询表$t$上的id为1的记录,$R$开始时事务$I$已经提交,事务$J$还在运行,事务$K$还没开始,这些信息都被记录在了事务$R$的ReadView中。事务$R$从索引中找到对应的这条$Record_{1,C}$,对应的trx_id是$K$,不可见。沿着rollptr找到Undo中的前一版本$Record_{1,B}$,对应的trx_id是$J$,不可见。继续沿着rollptr找到$Record_{1,A}$,trx_id是$I$可见,返回结果。
前面提到过,作为逻辑日志,Undo中记录的其实是前后两个版本的diff信息,而读操作最终是要获得完整的Record内容的,也就是说这个沿着rollptr指针一路查找的过程中需要用Undo Record中的diff内容依次构造出对应的历史版本,这个过程在函数row_search_mvcc中,其中trx_undo_prev_version_build会根据当前的rollptr找到对应的Undo Record位置,这里如果是rollptr指向的是insert类型,或者找到了已经Purge了的位置,说明到头了,会直接返回失败。否则,就会解析对应的Undo Record,恢复出trx_id、指向下一条Undo Record的rollptr、主键信息和diff信息update vector等信息。之后通过row_upd_rec_in_place,用update vector修改当前持有的Record拷贝中的信息,获得Record的这个历史版本。之后调用自己ReadView的changes_visible判断可见性,如果可见则返回用户。完成这个历史版本的读取。
TODO:补充关于锁的内容和MVCC的相关内容,并发事务问题->事务隔离级别->(隔离级别设置)->隔离级别的实现->锁(表、行、页)->MVCC(隐藏字段、undo、read view)。[3]
Spring事务使用
先介绍Spring支持的两种事务管理方式,然后介绍Spring提供的主要事务管理接口。在实际开发中,我们通常使用@Transactional
注解来开启事务,于是我们介绍这个注解中包含的事务属性参数(包括隔离级别、传播行为等等重要内容),并介绍如何使用该注解。
两种事务管理方式
编程式事务管理
通过 TransactionTemplate
或者 TransactionManager
手动管理事务,实际应用中很少使用。使用 TransactionTemplate
进行编程式事务管理的示例代码如下:
1 |
|
使用 TransactionManager
进行编程式事务管理的示例代码如下:
1 |
|
声明式事务管理(常用)
代码侵入性最小,实际是通过 AOP 实现(基于@Transactional
的全注解方式使用最多)。
使用@Transactional
注解进行事务管理的示例代码如下:
1 |
|
Spring 事务管理接口
Spring 框架中,事务管理相关最重要的 3 个接口如下:PlatformTransactionManager
,(平台)事务管理器,Spring 事务策略的核心;TransactionDefinition
,事务定义信息(事务隔离级别、传播行为、超时、只读、回滚规则);TransactionStatus
,事务运行状态。我们可以把 PlatformTransactionManager
接口可以被看作是事务上层的管理者,而 TransactionDefinition
和 TransactionStatus
这两个接口可以看作是事务的描述。PlatformTransactionManager
会根据 TransactionDefinition
的定义比如事务超时时间、隔离级别、传播行为等来进行事务管理 ,而 TransactionStatus
接口则提供了一些方法来获取事务相应的状态比如是否新事务、是否可以回滚等等。
PlatformTransactionManager
- 事务管理器接口
Spring 并不直接管理事务,而是提供了多种事务管理器,通过这个接口,Spring 为各个平台如:JDBC(DataSourceTransactionManager
)、Hibernate(HibernateTransactionManager
)、JPA(JpaTransactionManager
)等都提供了对应的事务管理器,但是具体的实现就是各个平台自己的事情了。将事务管理行为抽象出来方便程序扩展。
PlatformTransactionManager
接口中定义了三个方法:
1 | package org.springframework.transaction; |
TransactionDefinition
- 事务属性定义
事务管理器接口 PlatformTransactionManager
通过 getTransaction(TransactionDefinition definition)
方法来得到一个事务,这个方法里面的参数是 `TransactionDefinition
事务属性可以理解成事务的一些基本配置,描述了事务策略如何应用到方法上。
事务属性包含了 5 个方面:
- 隔离级别
- 传播行为
- 回滚规则
- 是否只读
- 事务超时
TransactionDefinition
接口中定义了 5 个方法以及一些表示事务属性的常量比如隔离级别、传播行为等等。
1 | package org.springframework.transaction; |
TransactionStatus
- 事务状态
TransactionStatus
接口用来记录事务的状态,该接口定义了一组方法,用来获取或判断事务的相应状态信息。PlatformTransactionManager.getTransaction()
方法返回一个 TransactionStatus
对象。TransactionStatus
接口内容如下:
1 | public interface TransactionStatus{ |
事务属性详解
@Transactional
中包含的事务属性参数。
事务传播行为
事务传播行为是为了解决业务层方法之间互相调用的事务问题。
当事务方法被另一个事务方法调用时,必须指定事务应该如何传播。例如:方法可能继续在现有事务中运行,也可能开启一个新事务,并在自己的事务中运行。
举例来说,在 A 类的aMethod()
中调用了 B 类的bMethod()
。这个时候就涉及到业务层方法之间互相调用的事务问题。如果bMethod()
发生异常需要回滚,如何配置事务传播行为才能让aMethod()
也跟着回滚呢?下面来看一下。
1 |
|
在TransactionDefinition
定义中包括了如下几个表示传播行为的常量:
1 | public interface TransactionDefinition { |
为了方便使用,Spring 相应地定义了一个枚举类:Propagation
。
1 | package org.springframework.transaction.annotation; |
事务传播行为可能的值如下:
1.TransactionDefinition.PROPAGATION_REQUIRED
@Transactional
注解默认使用的事务传播行为。如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新的事务。也就是说:
- 如果外部方法没有开启事务的话,
Propagation.REQUIRED
修饰的内部方法会新开启自己的事务,且开启的事务相互独立,互不干扰。 - 如果外部方法开启事务并且被
Propagation.REQUIRED
的话,所有Propagation.REQUIRED
修饰的内部方法和外部方法均属于同一事务,只要一个方法回滚,整个事务均回滚。
1 |
|
举例来说,如果我们上面的aMethod()
和bMethod()
使用的都是PROPAGATION_REQUIRED
传播行为的话,两者使用的就是同一个事务,只要其中一个方法回滚,整个事务均回滚。
2.TransactionDefinition.PROPAGATION_REQUIRES_NEW
创建一个新的事务,如果当前存在事务,则把当前事务挂起。也就是说不管外部方法是否开启事务,Propagation.REQUIRES_NEW
修饰的内部方法会新开启自己的事务,且开启的事务相互独立,互不干扰。
1 |
|
举例来说,如果我们上面的bMethod()
使用PROPAGATION_REQUIRES_NEW
事务传播行为修饰,aMethod()
还是用PROPAGATION_REQUIRED
修饰的话。如果aMethod()
发生异常回滚,bMethod()
不会跟着回滚,因为bMethod()
开启了独立的事务。但是,如果bMethod()
抛出了未被捕获的异常并且这个异常满足事务回滚规则的话,aMethod()
同样也会回滚,因为这个异常被aMethod()
的事务管理机制检测到了。
3.TransactionDefinition.PROPAGATION_NESTED
如果当前存在事务,就在嵌套事务内执行;如果当前没有事务,就执行与TransactionDefinition.PROPAGATION_REQUIRED
类似的操作。嵌套事务回滚不影响外部事务。也就是说:
- 在外部方法开启事务的情况下,在内部开启一个新的事务,作为嵌套事务存在。
- 如果外部方法无事务,则单独开启一个事务。
1 |
|
举例来说,如果 bMethod()
回滚的话,aMethod()
不会回滚。如果aMethod()
回滚的话,bMethod()
会回滚。
4.TransactionDefinition.PROPAGATION_MANDATORY
如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常。这种方式还是能保证全部回滚的,下面的三种则不一定了,需要看情况使用。
5.TransactionDefinition.PROPAGATION_SUPPORTS
如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行。
6.TransactionDefinition.PROPAGATION_NOT_SUPPORTED
以非事务方式运行,如果当前存在事务,则把当前事务挂起。
7.TransactionDefinition.PROPAGATION_NEVER
以非事务方式运行,如果当前存在事务,则抛出异常。
事务隔离级别
TransactionDefinition
接口中定义了五个表示隔离级别的常量:
1 | public interface TransactionDefinition { |
和事务传播行为一样,为了方便使用,Spring 也相应地定义了一个枚举类:Isolation
:
1 | public enum Isolation { |
依次对每一种事务隔离级别进行介绍:
TransactionDefinition.ISOLATION_DEFAULT
:使用后端数据库默认的隔离级别,MySQL 默认采用的REPEATABLE_READ
隔离级别,Oracle 默认采用的READ_COMMITTED
隔离级别。TransactionDefinition.ISOLATION_READ_UNCOMMITTED
:最低的隔离级别,使用这个隔离级别很少,因为它允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读。TransactionDefinition.ISOLATION_READ_COMMITTED
:允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生。TransactionDefinition.ISOLATION_REPEATABLE_READ
:对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。TransactionDefinition.ISOLATION_SERIALIZABLE
:最高的隔离级别,完全服从 ACID 的隔离级别。所有的事务串行执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。但是这将严重影响程序的性能。通常情况下也不会用到该级别。
事务超时属性
所谓事务超时,就是指一个事务所允许执行的最长时间,如果超过该时间限制但事务还没有完成,则自动回滚事务。在 TransactionDefinition
中以整型的值来表示超时时间,其单位是秒,默认值为-1
,这表示事务的超时时间取决于底层事务系统或者没有超时时间。
事务只读属性
1 | package org.springframework.transaction; |
对于只有读取数据查询的事务,可以指定事务类型为 readonly
,即只读事务。只读事务不涉及数据的修改,数据库会提供一些优化手段,适合用在有多条数据库查询操作的方法中。
为什么数据查询操作还要启用事务支持呢?拿Innodb举例子,根据官网描述:
MySQL 默认对每一个新建立的连接都启用了
autocommit
模式。在该模式下,每一个发送到 MySQL 服务器的sql语句都会在一个单独的事务中进行处理,执行结束后会自动提交事务,并开启一个新的事务。
但是,如果你给方法加上了@Transactional
注解的话,这个方法执行的所有sql会被放在一个事务中。如果声明了只读事务的话,数据库就会去优化它的执行,并不会带来其他的什么收益。
如果不加@Transactional
,每条sql会开启一个单独的事务,中间被其它事务改了数据,都会实时读取到最新值。
分享一下关于事务只读属性,其他人的解答:
- 如果你一次执行单条查询语句,则没有必要启用事务支持,数据库默认支持 SQL 执行期间的读一致性;
- 如果你一次执行多条查询语句,例如统计查询,报表查询,在这种场景下,多条查询 SQL 必须保证整体的读一致性,否则,在前条 SQL 查询之后,后条 SQL 查询之前,数据被其他用户改变,则该次整体的统计查询将会出现读数据不一致的状态,此时,应该启用事务支持。
事务回滚规则
这些规则定义了哪些异常会导致事务回滚而哪些不会。默认情况下,事务只有遇到运行期异常(RuntimeException
的子类)时才会回滚,Error
也会导致事务回滚,但是,在遇到检查型(Checked)异常时不会回滚。
可以通过下面的方式回滚特定的异常类型:
1 |
@Transactional
注解使用详解
@Transactional
的作用范围
- 方法:推荐将注解使用于方法上,不过需要注意的是,该注解只能应用到
public
方法上,否则不生效。 - 类:如果这个注解使用在类上的话,表明该注解对该类中所有的
public
方法都生效。 - 接口:不推荐在接口上使用。
@Transactional
的常用配置参数
@Transactional
注解源码如下,里面包含了基本事务属性的配置:
1 |
|
常用配置参数即:propagation、isolation、timeout、readOnly、rollbackFor,具体内容上节已列出。
参考
[1] https://javaguide.cn/system-design/framework/spring/spring-transaction.html
[2] https://blog.csdn.net/ITcreater000/article/details/115338657
[3] https://www.cnblogs.com/rickiyang/p/13652664.html
[4] https://dl.acm.org/doi/10.1145/289.291
[5] https://juejin.cn/post/6860252224930070536
[6] https://spongecaptain.cool/post/database/logicalandphicallog/
[7] https://www.jianshu.com/p/646961b93c7e
[8] https://zhuanlan.zhihu.com/p/394388285
[9] https://blog.csdn.net/qq_24854607/article/details/114639318
[10] https://blog.csdn.net/m0_71777195/article/details/130842268
[11] https://www.cnblogs.com/f66666/articles/10993873.html
[12] http://catkang.github.io/2021/10/30/mysql-undo.html
[13] http://catkang.github.io/2020/02/27/mysql-redo.html
[14] http://catkang.github.io/2018/09/19/concurrency-control.html
[15] https://catkang.github.io/2023/08/08/mysql-buffer-pool.html
[16] https://mariadb.com/kb/en/innodb-undo-log
[17] https://javaguide.cn/system-design/framework/spring/spring-transaction.html