专栏地址:
1. 概念
理论上来说,事务是指满足ACID特性的操作,可以将数据库从一个一致性状态转移到另一个一致性状态。但是数据库实现上可能并不会严格的去满足ACID标准。
对于InnoDB存储引擎而言,其默认的隔离级别可重复读,完全满足和遵循事务的ACID特性。
1.1 ACID
ACID之间的关系并不是正交的,原子性、隔离性、持久性都是为了得到最终的一致性。
Atomicity 原子性
原子性是指一个事务是不可分割的最小执行单元,要么全部执行成功,要么全部执行失败。
Consistency 一致性
事务将数据库从一个一致性状态转移到另一个一致性状态。这种一致性是语义上的,等同于数据的正确性。
Isolation 隔离性
隔离性又称并发控制,是指事务之间相互隔离,在事务提交之前其修改对其它事务不可见。
Durability 持久性
事务一旦提交,其修改就是永久性的,即使发生宕机,也能够进行故障恢复。这是一种高可靠性而不是高可用性的要求,也就是说如果硬盘发生损坏,数据仍然可能丢失。
1.2 隔离性与隔离级别
当数据库同时有多个事务执行的时候,就有可能会出现 脏读、不可重复读、幻读等问题。
1.2.1 并发问题
丢失更新
两个事务同时对一个数据进行来修改,后一个事务覆盖了前者的修改。在数据库层面,任何隔离级别下都不会导致丢失更新。因为对行的DML操作,InnoDB会利用锁进行并发控制。
但是在应用层面仍然会存在丢失更新,比如应用程序在SELECT和UPDATE之间发生了并发:
1. 事务 1 SELECT
2. 事务 2 SELECT
3. 事务 1 UPDATE
4. 事务 2 UPDATE
解决丢失更新的思路有两种:
- 使用 SELECT FOR UPDATE,对即将要修改的数据加上排他锁,以阻塞其它事务的读写。
- 使用基于版本号的乐观锁。
脏读
脏读是指读取到了未提交的数据。
比如,事务A修改了一个数据,但未提交,随后被事务B读取,若A撤销了修改,那么B读取到的为脏数据。
不可重复读
不可重复读是指对同一个数据先后读取到了不同的值。
比如,事务A先读取了数据,随后事务B修改了数据并进行了提交,此时A再次读取该数据却拿到了修改后的值。
幻读
幻读是指对同一范围的查询先后读取到了不同的数据集合,即其中一次查询读取到了另一次查询没有的记录。
比如,A事务读取了某个范围的数据,随后B事务在该范围进行了插入操作并进行了提交,此时A再次读取该范围,拿到的数据集合与前一次不同。
不可重复读的重点在于Update和Delete,而幻读在于Insert。
1.2.2 隔离级别
为了解决这些问题,SQL标准定义了4种隔离级别:
未提交读 Read Uncommitted
即使事务没有提交,其修改也对其它事务可见。
提交读 Read Committed
事务在提交之后,其修改才对其它事务可见。
提交读可以避免脏读问题。
可重复读 Repeatable Read
在同一个事务中,多次读取同样的数据,其值总是和第一次相同。
可重复读可以避免脏读和不可重复读。
与标准的SQL隔离级别不同的是,InnoDB在可重复读级别下,利用Next-Key Lock解决了幻读问题,能够完全保证事务的隔离性,达到了串行化级别。
串行化
事务串行执行,互不干扰。
可以避免脏读、不可重复读和幻读。
总结
隔离级别 | 脏读 | 不可重复读 | 幻影读 |
---|---|---|---|
未提交读 | √ | √ | √ |
提交读 | × | √ | √ |
可重复读 | × | × | √ |
可串行化 | × | × | × |
例题

在不同的隔离级别下,V值分别如下:
未提交读 | 提交读 | 可重复读 | 串行化 |
---|---|---|---|
2 | 1 | 1 | 1 |
2 | 2 | 1 | 1 |
2 | 2 | 2 | 2 |
1.3 分类
事务一般可以分为以下几类:
- 扁平事务
- 带有保存点的扁平事务
- 嵌套事务
- 链事务
- 分布式事务
InnoDB支持除嵌套事务外的所有事务类型。
1.4 事务的启动方式
事务的使用方式
- 显式启动语句,提交使用commit,回滚使用rollback:
begin、start transaction
:这两条命令并不是事务的起点,一致性视图将在第一次执行快照读时创建;start transaction with consistent snapshot
:立即创建一个一致性视图。
- autocommit:
- = 1(默认值):开启自动提交,每条语句都会被当作一个事务执行提交;
- = 0:关闭自动提交,所有语句在一个事务中执行,直到显式调用commit或者rollback。
长事务
长事务意味着系统中会存在很老的事务视图,由于这些事务可能会访问数据库的任何数据,所以可能会用到的回滚记录undo log都必须保留,占用大量的存储空间。
其次,由于两阶段锁的加锁方式,长事务会长时间占用锁资源。
最佳实践
autocommit = 1,开启自动提交,并通过显式语句开启事务。
将锁竞争最大的语句放在事务的尾部,减少锁的持有时间。
1.5 事务在InnoDB中的实现
在MySQL中,事务是在存储引擎层实现的。对于InnoDB而言:
- 原子性代表着可回滚,这一特性主要有undo log实现;
- 隔离性需要在效率上作出平衡,在不同的隔离级别下主要由MVCC和锁实现;
- 持久性主要由redo log和double write实现,redo log是一种Write Ahead Log(WAL)策略,用于对数据页进行重做;double write则用于防止脏页刷盘时部分写失效导致的数据丢失。
原子性、隔离性、持久性最终实现了一致性。
2 隔离性的实现
InnoDB实现事务隔离的机制主要有MVCC和锁。
MVCC(Multiversion concurrency control,多版本并发控制协议),是一种提高系统并发的技术,在很多情况下避免了加锁操作。MVCC通过undo log来构建数据的历史版本,通过视图来定义数据版本的可见性。并由此构建数据库在某一个时间点的全库快照(一致性视图),来实现一致性非锁定读,保障事务的隔离性和一致性。
在没有MVCC的情况下,只有读读是可以并发的,引入MVCC后只有写写之间是阻塞的,其它都可以并发。这解决读写锁造成的多个、长时间读操作饿死写操作的的问题。
在InnoDB中,只有普通查询使用的是一致性非锁定读,其它DML等操作则采用当前读。
2.1 版本
数据版本是MVCC中的一个逻辑概念,在物理上并不真实存在。InnoDB在当前行记录的基础之上,利用undo log链来构建出记录的历史版本。 当版本链很长时,可能比较耗时。
具体实现是,在InnoDB的行记录格式中,有两个隐藏列:事务ID和回滚指针。事务ID是由InnoDB在事务开始前分配的,是一个严格递增的唯一ID。行记录上的事务ID等于更新这条记录的事务ID。回滚指针指向当前记录的undo log。
当事务对记录进行更新时,会先将当前记录上更新前的值、事务ID、回滚指针一起记录在undo log中,随后更新记录的值及其事务ID,并将回滚指针指向生成的undo log。于是,通过undo log可以将当前记录恢复到上一个状态。那么,通过回滚指针串联成的undo log链表,可以将记录恢复到任意历史版本。
在MVCC中,每一行记录都有多个版本,每一次更新都会产生一个新的版本,版本的事务ID row trx_id 为生成这个版本事务的ID。

图中,V1~V4表示记录的四个版本,其中V4为最新版本,由ID为25的事务所更新,所以其 row trx_id = 25。虚线U1~U3为undo log,根据当前版本V4和U1~U3依次计算出版本V1~V3。
2.2 视图
有了历史版本,就可以构建整个库在某一时刻的数据视图。
MVCC使用一致性视图(consistent read view)来定义事务中数据的可见性,在事务中的数据访问以视图的逻辑结果为准。在不同的隔离级别下,视图的创建时机不同:
- 未提交读:直接返回记录的最新值(Buffer Pool),没有视图概念;
- 提交读:在每条SQL开始执行时创建视图;
- 可重复读:在事务启动时创建视图,在整个事务中始终使用这个视图,期间只能看到事务启动前已提交的事务结果和自己更新产生的版本;
- 串行化:直接使用锁来避免并发冲突。
在访问行记录时,都从当前最新版本开始,如果不可见就访问上个版本,直至可见或者达到最老版本。
视图数组、高/低水位
视图数组和高/低水位共同定义了事务的一致性视图。
视图数组m_ids保存了视图创建时当前活跃的事务ID,活跃事务指的是当前启动了但还未提交的事务。
低水位up_limit_id为视图数组的最小值,即当前最小活跃事务ID。
高水位low_limit_id为视图创建时,当前系统中已分配事务ID的最大值+1。
版本可见性
数据版本的可见性是基于一致性视图和版本的事务ID row trx_id对比得到的。

如果数据版本的事务ID:
- 小于低水位,则表明这个版本是由视图创建前已提交事务创建的,可见;
- 大于等于高水位,则表明这个版本是由将来启动的事务创建的,不可见;
- 位于高低水位之间,则包含两种情况:
- 位于视图数组中,则表明该版本是由视图创建前未提交事务创建的,不可见;
- 不位于视图数组中,则表明该版本是由视图创建前已提交事务创建的,可见;
- 等于当前事务ID,可见。
2.3 当前读和快照读(一致性非锁定读)
普通查询为一致性非锁定读(快照读),利用视图控制数据版本的可见性,读取当前或者历史版本,不会产生锁操作。
而对于UPDATE等DML数据变更操作,则采用当前读:读取最新版本,并对行记录进行加锁,如果与当前行锁产生冲突,则进入锁等待。
如果对查询语句进行加锁的话,那么也采用当前读:
SELECT ... IN SHARE MODE; # 读锁(S锁,共享锁)
SELECT ... FOR UPDATE; # 写锁(X锁,排他锁)
2.4 可重读读下的事务隔离
在可重复读隔离级别下,假设有以下表结构:
CREATE TABLE t (
id int(11) NOT NULL,
k int(11) DEFAULT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB;
insert into t(id, k) values(1,1),(2,2)
设置自动提交autocommit = 1,那么以下事务中读取的值分别为多少?

分析
start transaction with consistent snapshot
立即启动一个事务并创建一个一致性视图,对于事务A、B而言,id = 1这行记录快照读的可见版本为 (1, 1)。
事务C采用当前读将 (1, 1) 更新成了 (1, 2),由于没有显式地使用事务语句且设置了自动提交,所以更新完成之后立即进行了提交,并且释放了行锁。最新版本变成了(1, 2)。
事务B的UPDATE语句同样采用当前读,将 (1, 2) 更新成了 (1, 3)。普通SELECT采用的是快照读,由于最新版本 (1, 3) 是由当前事务更新的,所以该SELECT的值为3。最新版本变成了(1, 3),由于事务尚未提交,行锁不释放。
事务A的SELECT为快照读,根据事务开始前视图确认的可见版本,其值为1。
详解
假设:
- 事务A开始前当前活跃的事务ID只有99;
- 事务A、B 、C的ID分别为100,101, 102;
- 三个事务开始前,id = 1这行记录上的事务ID为90,即最新版本的事务ID为90。已提交。
于是,事务A的视图数组为 [99, 100],高低水位分别为 99/101;事务B的视图数组为 [99, 100, 101],高低水位为 99/ 102;事务C的视图数组为 [99, 100, 101, 102], 高低水位为 99/ 103。
第一个有效更新是事务C产生的,将 (1, 1) 更新成了 (1, 2),此时数据最新版本的事务ID row trx_id 为102,90变成了历史版本。
第二个有效更新是事务B,事务B在进行更新时,采用当前读,避免丢失更新,所以UPDATE读取到了 (1, 2) 并更新成了 (1, 3),该版本的事务ID row trx_id为101。
随后事务B进行查询时,最新版本的事务ID为101与自己相同,所以读取到的值为3。
在事务A进行查询时,从当前最新版本开始读取数据:
- (1, 3) 版本的事务ID为101,大于等于高水位101,位于红色区域,不可见;
- 接着访问上一个版本(利用undo log 计算)(1, 2),仍然比高水位大,不可见;
- 继续向前访问到 (1, 1),该版本的事务ID为90,比低水位99小,可见。
所以,事务A读取到的值为1。

此外,假设事务C更新完没有立即提交,在没有提交之前,事务B先发起了UPDARE操作:

由于两阶段锁的加锁方式,事务C的行锁在提交之后才会释放。所以,事务B的UPDATE会被阻塞住。

3 原子性的实现
事务是不可分割的最小执行单元,要么全部执行成功,要么全部执行失败。原子性意味着可回滚,InnoDB对事务过程中的数据变更总是记录了undo log,利用undo log可将记录恢复到历史版本。
3.1 undo log基本概念
undo log是逻辑日志,可以将数据库逻辑地而不是物理地恢复到原来的状态,撤销事务对行记录所作出的修改。undo log只作用于聚簇索引的变更。
除了回滚操作,undo log的另一个重要作用是在MVCC中用以构建数据的历史版本,以实现非锁定一致性读。
undo log 存储于共享表空间的回滚段中,与普通数据页的管理类似,也会先缓存于buffer pool之中,因此也需要进行持久性的保证——undo log的生成会随之生成对应的redo log,用于对undo log页进行重做。
3.2 undo log格式
InnoDB中的undo log主要有两种格式:
- insert undo log:insert操作产生的undo log
- update undo log:delete 和 update 操作产生的undo log
两种undo log中均有:
- n_unique_index:记录着所有主键的列及其值,用以快速定位到行记录。
- undo_no:记录生成该undo log的事务ID。
- next、start:记录下一条undo log的位置以及当前undo log的起始位置。
undate undo log记录的内容更多,有:
- DATA_TRX_ID:旧记录的事务ID,即生成旧记录的事务ID。
- DATA_ROLL_PTR:旧记录的回滚指针。
- n_undate_field:更新的记录的列及其旧值。
其中,旧记录的事务ID和回滚指针用以在MVCC中构建历史版本。

3.3 undo log的生成
当事务进行更新时,会生成能将更新操作进行回滚的undo log(redo log前),更新操作有主要有两种情况:
-
原地更新
直接在当前行记录上应用更新,一般对于非主键的字段更新采用该方式。首先先将当前记录上更新前的值、事务ID、回滚指针一起记录在undo log中,随后更新记录的值及其事务ID,并将回滚指针指向生成的undo log。
-
delete mark + insert
对于主键列的更新,采用先删除再插入的方式。先将原记录标记为删除(delete flag),然后再插入一条新纪录,同时生成两种类型的undo log。
3.4 undo log的清理
由于InnoDB支持MVCC,所以对记录的删除操作、undo log的回收操作等不能立刻进行,而是需要在这些旧版本没有被事务引用时再进行清理操作。
update undo log会按照顺序放到history list中,后台Purge Thread会在定期扫描,清理无用的undo log。另外,还会对标记为删除行记录(delete flag)进行彻底的删除,即该记录占用的空间放入PAGE_FREE链表中,以便复用。
insert undo log,由于只对当前事务生效,所以在事务提交后可以直接删除,不需要purge操作。
4 持久性的实现
InnoDB中,事务的持久性主要由redo log和double write实现。
redo log是一种Write Ahead Log(WAL)策略,用于对数据页进行重做;double write则用于防止脏页刷盘时部分写失效导致的数据丢失。
4.1 redo log
4.1.1 redo log的作用
InnoDB利用缓存池buffer pool来提高系统性能,缓存池中脏页刷新的IO成本很高,并不是实时刷新的。为了防止脏页异步刷新导致的数据丢失,InnoDB利用redo log来记录对数据页的修改,以便在故障发生后对数据页进行重做,提供Crash-Safe能力,保证事务的ACID中的D持久性。
redo log是一种WAL(Write-Ahead Logging,日志先行)策略:在对数据进行修改前,先记录修改日志,即先写日志,再将写磁盘。
为了保证事务不丢失,在默认情况下,每次事务提交前均会持久化redo log。
WAL策略应用的前提:redo log的持久化成较脏页刷新成本低
redo log需要持久化后才有意义,所以要保证redo log的持久化成本远低于脏页刷新成本,才有应用的价值。
redo log的持久化成本低主要体现在:
- redo log为顺序写,较脏页刷新的随机IO成本低很多。
- 组提交 Group Commit,redo log 和 binlog 都具有组提交特性,在刷盘时通过等待一段时间来收集多个事务日志同时进行刷盘。
PS:
Crash-Safe
指在故障恢复后:
- 已提交的事务数据不会丢失
- 未提交的事务自动回滚
4.1.2 redo log的实现
循环追加写
redo log在磁盘上以循环写的方式操作日志组,write pos表示当前日志记录的位置,边写边向后移动。checkpoint表示脏页已经刷盘的日志编号(LSN),随着脏页不断的刷新到磁盘,checkpoint也在不断地向后推进,而在checkpoint前的redo log可以被复用。

redo log 缓存
redo log先写缓存,默认在以下情况下会刷新到磁盘上:
- Master Thread 线程每秒刷新一次;
- 事务提交时,由innodb_flush_log_at_trx_commit控制,默认为1,即每次事务提交时均持久化redo log;
- 当缓存空间小于1/2时。
脏页刷新
InnoDB会在以下情况将缓存池中的脏页刷新到磁盘:
- 系统空闲时
- 缓存池空间不足时
- redo log 空间不足时
脏页刷新由Page Cleaner Thread线程负责。
4.1.3redo log和binlog的XA
在进行事务提交时,MySQL利用内部的XA事务来协调引擎层和Sercer层的一致性。XA事务是两阶段提交(2PC,Two-Phase Commit Protocol)的一种实现。
为什么要使用XA
MySQL的架构中,引擎层和Server层具有各自的日志系统,XA即为了保证这两套日志系统的逻辑一致性。
倘若没有2PC在系统崩溃时可能会造成Server层和引擎层的日志不一致,最常见的异常就是主从不一致。
假设不使用2PC,若先写redo log再写binlog,在redo log写入成功后系统崩溃,则原库可以正常恢复,而利用binlog的主从复制和数据恢复等操作则与原库不一致。
若先写binlog在写redo log,并在此期间发生崩溃,则从库和利用binlog恢复出的临时库正常,而原库则丢失了一部分更新。
事务的提交流程
具体而言,在更新操作中,InnoDB先仅在缓存中对数据页进行修改,随后事务提交时利用内部XA事务来保证引擎层日志和Server层日志的一致性,Server层作为协调者:
- Server层发起prepare,此时:
- Server层binlog什么都不做
- InnoDB将redo log设置为prepare状态,并将redo log 实时落盘
- 若所有引擎均prepare成功,则发起commit
- Server层写binlog 实时落盘
- InnoDB将redo log设置为commit

事务提交完成,脏页将在后续适时刷新,整体流程如下:

PS
控制redo log 和 binlog 持久化策略的参数:
sync_binlog:默认为1,即每次事务提交均持久化binlog;
innodb_flush_log_at_trx_commit:默认为1,即每次事务提交时均持久化redo log。
若非全1,则在极端情况下可能导致redo log和binlog不一致。
4.1.4 故障恢复流程
主库恢复流程
读取redo log和binlog,提交redo log处于commit状态和虽处于prepared状态但binlog成功落盘的事务。
具体而言,CheckPoint前的脏页已经成功落盘,主库故障恢复时从CheckPoint开始:
- 首先读取redo log:
- 对于处于commit的事务正常提交;
- 对于处于未prepared和commit的事务进行回滚;
- 对于处于prepared但尚未commit的事务暂时挂起。
- 读取binlog:
- 判断redo log中处于prepared但尚未commit的事务是否存在与binlog中,若存在则提交,否则回滚;
- 对于binlog尾部不完整的事务进行回滚。
redo log 和 binlog通过XID联系。
对于需要提交的事务,从磁盘上读取数据页到缓存池中,然后应用redo log更新页。
对于需要回滚的事务,利用undo log进行回滚。
MySQL在binlog和redo log的保存期限内可以恢复到任意一秒的状态
首先找到目标日期最近的一次binlog备份恢复出临时库,然后在临时库上重放redo log至目标时刻。
使用mysqldump和mysqlbinlog可以制作全量和增量备份。
4.1.5 redo log、binlog 和 undo log的区别
redo log是InnoDB层的物理日志,记录事务对数据页的修改,用于数据库的故障恢复时对数据页进行重做,保证事务ACID中的D持久性。
binlog是Server层的逻辑归档日志,记录事务SQL语句的原始逻辑,用于备份和主从同步。
undo log是InnoDB层的逻辑日志,记录事务对行记录修改的回滚操作(比如UPDATE前行记录的旧值),可以将数据库逻辑地而不是物理地恢复到原来的状态,用于撤销事务对行记录所作出的修改。此外,undo log还用于在MVCC中构建记录的历史版本。undo log位于共享表空间的回滚段,其持久化也需要redo log的保证。
redo log 和 binlog 都可以用于表示事务的提交状态,而两阶段提交就是让这两个状态保持逻辑上的一致。
故障恢复必须利用redo log,binlog是逻辑日志不能用于恢复数据页,临时库(从库)只能利用binlog。
4.2 double write
Double Write主要是为了提升数据页可靠性,防止部分写失效导致的数据丢失。因为InnoDB数据页一般为16K,而文件系统的页大小为4K,所以操作系统可能无法保证InnoDB数据页的原子写入。倘若在刷新页的过程中,服务器宕机了,则会导致原始数据页的损毁。重放redo log也无法解决部分写失效问题,因为重做日志记录的是对数据页的修改操作,如果这个数据页本身就是损坏的,对其应用redo log中的修改也是没有意义的。
InnoDB的解决方案是:拷贝一份数据页的副本,即Double Write。当脏页要刷新时,Doube Write的主要流程是:
- 先将Buffer Pool中的脏页拷贝到Double Write Buffer中。
- 将Double Write Buffer以顺序追加写的方式实时刷盘(系统表空间),同步IO。
- 再将Double Write Buffer中的页写到相应的表空间,此时为随机IO,异步IO。异步IO回调函数中会进行完整性校验。
Double Write Buffer不仅仅存在于内存中,是一个内存/磁盘的两层结构

当发生了部分写失效时,可以通过系统表空间的Double Write Buffer进行恢复,随后再应用redo log重做日志,完成完整的故障恢复流程。
如果文件系统能够提供页大小的原子写入,提供防范部分写失效的解决方案,那么可以关闭Double Write。
参考
MySQL · 引擎特性 · InnoDB 事务子系统介绍—淘宝数据库内核日报
MySQL · 引擎特性 · InnoDB undo log 漫游—淘宝数据库内核日报
《MySQL实战45讲》极客时间
《高性能MySQL》
《MySQL技术内幕(InnoDB存储引擎)》