一组 SQL 操作的集合,不可分割的工作单位。也就是说,事务会把所有操作作为一个整体向系统进行提交或撤销,即这些操作要么同时成功,要么同时失败。
MYSQL 的事务默认是自动提交的,即当执行一条 DML 语句时,MYSQL 会立即隐式地提交事务。
查看/设置事务提交方式:
- 查看自动提交状态:
SELECT @@autocommit;
(1 为自动提交,0 为手动提交) - 设置手动提交:
SET @@autocommit = 0;
事务的回滚以转账为例:
开启事务 | |
---|---|
查询发起人账户余额 | |
发起人账户余额 -1000 | |
如果抛异常,回滚事务 | |
接收人账户余额 +1000 | |
结束事务 |
事务四大特性
- 原子性(Atomicity):事务是不可分割的最小操作单元,全部成功或全部失败。
- 一致性(Consistency):事务中必须保持所有数据处于一致状态,即从一个合法状态转向另一合法状态。
- 隔离性(Isolation):数据库系统提供的隔离机制,保证事务在不受外部并发操作影响的独立环境下运行,即并发执行的事务是彼此隔离的。
- 持久性(Durability):事务一旦提交或回滚,对数据库中的数据的改变是永久的(通过数据库的恢复和日志机制来实现)。
事务分类
从事务理论的角度来说,可以把事务分为以下几种类型:
- 扁平事务 (Flat Transactions)
- 带有保存点的扁平事务 (Flat Transactions with Savepoints)
- 链事务 (Chained Transactions)
- 嵌套事务 (Nested Transactions)
- 分布式事务 (Distributed Transactions)
扁平事务是事务类型中最简单的一种,但在实际生产环境中,这可能是使用最为频繁的事务。在扁平事务中,所有操作都处于同一层次,其由 BEGIN 开始,由 COMMIT 或 ROLLBACK 结束,其间的操作是原子的,要么都执行,要么都回滚。因此扁平事务是应用程序成为原子操作的基本组成模块。下图显示了扁平事务的三种不同结果。
扁平事务的主要限制是不能提交或者回滚事务的某一部分,或分几个步骤提交。
例如用户在旅行网站上进行自己的旅行度假计划。用户想从南昌到洛杉矶的圣莫妮卡,这两个城市之间没有直达的班机,需要用户预订并转乘航班,或者需要搭火车等待。用户预订旅行度假的事务为:
1 | BEGIN |
但是当用户执行到 S3 时,发现由于飞机到达洛杉矶的时间太晚,打不到 Uber 了。这时用户希望在 LAX 附近住一晚,第二天出发去圣莫妮卡。这时如果事务为扁平事务,则需要回滚之前 S1、S2、S3 的三个操作,这个代价就显得有点大。因为当再次进行该事务时,S1、S2 的执行计划是不变的。也就是说,如果支持有计划的回滚操作,那么就不需要终止整个事务。因此就出现了带有保存点的扁平事务。
带有保存点的扁平事务除了支持扁平事务支持的操作外,允许在事务执行过程中回滚到同一事务中较早的一个状态。这是因为某些事务可能在执行过程中出现的错误并不会导致所有的操作都无效,放弃整个事务不合乎平衡,也开销太大。保存点(Savepoint)用来通知系统应该记住事务当前的状态,以便当之后发生错误时,事务能回到保存点当时的状态。
对于扁平的事务来说,其隐式地设置了一个保存点。然而在整个事务中,只有这一个保存点,因此,回滚只能回滚到事务开始时的状态。保存点用 SAVE WORK 函数来建立,通知系统记录当前的处理状态。当出现问题时,保存点能用作内部的重启动点,根据应用逻辑,决定是回到最近一个保存点还是其他更早的保存点。下图显示了在事务中使用保存点。
上图中,灰色背景部分的操作表示由 ROLLBACK WORK 而导致部分回滚,实际上并没有执行的操作。当用 BEGIN WORK 开启一个事务时,隐式地包含了一个保存点;当事务通过 ROLLBACK WORK : 2 发出部分回滚命令时,事务回滚到保存点 2,接着依次执行,并再次执行到 ROLLBACK WORK : 7,直到最后的 COMMIT WORK 操作,这时表示事务结束,除灰色阴影部分的操作外,其余操作都已经执行,并且提交。
另一点需要注意的是,保存点在事务内部是递增的,这从上图中也能看出。有人可能会想,返回保存点 2 以后,下一个保存点可以为 3,因为之前的工作终止了。然而新的保存点编号为 5,这意味着 ROLLBACK 不影响保存点的计数,并且单调递增的编号能保持事务执行的整个历史过程,包括在执行过程中想法的改变。
此外,当事务通过 ROLLBACK WORK : 2 命令发出部分回滚命令时,要记住事务并没有完全被回滚,只是回滚到了保存点 2 而已。这代表当前事务还是活跃的,如果想要完全回滚事务,还需要再执行命令 ROLLBACK WORK。
链事务可视为保存点模式的一种变种。带有保存点的扁平事务,当发生系统崩溃时,所有的保存点都将消失,因为其保存点是易失的 (volatile),而非持久的 (persistent)。这意味着当进行恢复时,事务需要从头开始重新执行,而不能从最近的一个保存点继续执行。
链事务的思想是:在提交一个事务时,释放不需要的数据对象,将必要的处理上下文隐式地传给下一个要开始的事务。注意,提交事务操作和开始下一个事务操作将合并为一个原子操作。这意味着下一个事务将看到上一个事务的结果,就好像在一个事务中进行的一样。下图显示了链事务的工作方式:
上图中,第一个事务提交触发第二个事务的开始。
链事务与带有保存点的扁平事务不同的是,带有保存点的扁平事务能回滚到任意正确的保存点。而链事务中的回滚仅限于当前事务,即只能恢复到最近一个的保存点,也就是说,每个子事务刚开始时,系统会“隐式地”在那个点打一个恢复点,但你没有机会在同一个子事务里再自己开第二个、第三个保存点。
对于锁的处理,两者也不相同。带保存点的扁平事务开始后,一直占有它所申请的所有行锁、表锁(或者更高级别的锁),即便你中途用 ROLLBACK TO SAVEPOINT 回滚到某个保存点,也只是撤销了回滚点之后的逻辑修改,但不会释放任何锁。而链事务中,每当一个子事务执行 COMMIT,它所持有的锁就立即释放,数据库中其它会话或下一个子事务都可以并发访问那些之前被锁定的资源。
嵌套事务将一个大事务组织成“事务树”——顶层事务(parent)控制若干子事务(child),子事务内又可有更深层的子事务,直到叶子节点。叶子节点的事务是真正向数据库发起操作的“扁平事务”,上层事务只负责逻辑控制与协调。
提交与生效
- 子事务提交:仅把该子事务状态标记为“准备就绪”,真正对外生效要等到其所有祖先(尤其是顶层事务)都提交后才算完成。
- 父事务提交:向下统一提交整棵事务树,所有子事务的修改才真正持久化。
回滚某一事务节点,会递归地回滚其所有子孙事务。因此,只有顶层事务具备全局回滚能力,子事务无法独立保留已提交的修改。
嵌套事务可以选择性地继承父事务的锁,也可以通过“反向继承”让父事务获得子事务锁。不同子事务可对同一资源持有不同级别的锁;上层事务在其自身提交前,锁不会被释放。
保存点可模拟嵌套回滚的灵活性(任意回到某个保存点),但无法在锁继承、并行执行等方面还原嵌套事务的精细控制。若需要子事务并行执行或精确的锁传递,就必须由系统原生支持嵌套事务。
分布式事务通常是一个在分布式环境下运行的扁平事务,因此需要根据数据库所在位置访问网络中的不同节点。
假设一个用户在 ATM 机进行银行的转账操作,例如持卡人从招商银行的储蓄卡转账 10 000 元到工商银行的储蓄卡。在这种情况下,可以将 ATM 机视为节点 A,招商银行的后台数据库视为节点 B,工商银行的后台数据库视为节点 C,这个转账的操作可分解为以下步骤:
- 节点 A 发出转账命令。
- 节点 B 执行储蓄卡中的余额值减去 10 000。
- 节点 C 执行储蓄卡中的余额值加上 10 000。
- 节点 A 通知用户操作完成或者通知用户操作失败。
这里需要使用分布式事务,因为节点 A 不能通过调用一台数据库就完成任务,需要访问网络中多个节点的数据库,而在每个节点的数据库上执行的事务操作又都是扁平事务。分布式事务同样需要满足 ACID 特性,要么都发生,要么都失效。对于上述例子,如果步骤 2 或 3 中的任何一个操作失败,都必须回滚整个分布式事务,否则结果将非常不可控。
对于 InnoDB 存储引擎来说,支持扁平事务、带有保存点的事务、链事务和分布式事务。但原生并不支持嵌套事务,因此对于有并行事务需求的用户来说,MySQL 或 InnoDB 在这方面能力有限。不过,用户仍可通过带有保存点的事务来模拟串行的嵌套事务。
并发场景下的事务问题
脏读:一个事务读到另一个事务未提交的数据,这些数据可能在之后被回滚,从而导致读取到的数据无效。数据库系统在较低的隔离级别下允许其他事务读取尚未提交的数据,可以减少数据锁定的时间,从而提升系统的吞吐量和响应时间。
不可重复读:一个事务先后读取同一条事务,但两次读取的数据不同。具体来说,事务 A 在读取数据后,事务 B 更新了该数据并提交,事务 A 再次读取时,数据已经被改变,导致了前后读取的数据不一致。
幻读:一个事务按照条件查询数据时,没有对应的数据行,但之后又查询数据时,又发现这行数据已存在,可能是别的事务在这两次查询之间插入了新的数据。
事务的隔离级别
读未提交:这是最低的隔离级别,对普通一致性读不加锁,允许读取其他事务未提交的更改,因此可能出现脏读、不可重复读和幻读。InnoDB 在该级别下仍使用 MVCC 快照(Read View)进行一致性读,但允许直接读取缓冲区中的最新行版本,无需检查事务是否已提交。
读已提交:在该级别下,每条普通 SELECT 都会生成一个新的 Read View,只读取已提交的行版本,从而避免脏读,但仍可能出现不可重复读和幻读。一致性读不加行锁,只有在显式锁定读(SELECT … FOR UPDATE/SHARE
)或 DML 操作时才会加锁。该级别只能工作在二进制日志为 ROW 的格式下。但即使不使用 READ COMMITTED 的事务隔离级别,也应该考虑将二进制日志的格式更换成 ROW,因为这个格式记录的是行的变更,而不是简单的 SQL 语句,所以可以避免一些不同步现象的产生,进一步保证数据的同步。
可重复读:这是 InnoDB 的默认隔离级别,事务在第一次读取时创建一个 Read View,后续所有普通读都使用相同快照,保证事务内多次读取相同记录的结果一致,从而避免脏读和不可重复读。同时,InnoDB 在该隔离级别下启用 Next-Key 锁(记录锁 + 间隙锁)阻止其他事务在范围内插入新行,以防止幻读。
串行化:这是最高隔离级别,所有普通 SELECT 都作为共享锁定读执行(LOCK IN SHARE MODE),对每条检索到的记录及其所在间隙加 Shared Next-Key 锁,彻底消除脏读、不可重复读和幻读。写操作继续使用 Exclusive Next-Key 锁,锁粒度保持行级;同时在表层加意向锁(IS/IX)以协调多粒度锁,但并不加表级锁。
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
Read uncommitted | ✅ | ✅ | ✅ |
Read committed | ❌ | ✅ | ✅ |
Repeatable read(默认) | ❌ | ❌ | ❌ |
Serializable | ❌ | ❌ | ❌ |
✅:会发生
❌:不会发生
查看事务隔离级别:SELECT @@TRANSACTION_ISOLATION;
设置事务隔离级别:SET [SESSION | GLOBAL] TRANSACTION_ISOLATION LEVEL [READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE];
隔离级别越高,数据越安全,但是性能越低。
长事务
长事务就是执行时间较长的事务。
长事务的典型场景如下:
- 开发错误:开启事务后忘记提交或回滚。
- 业务逻辑复杂:一些业务逻辑需要执行多个步骤或设计的数据量大,耗时较长,可能导致事务一直未提交。
例如,对于银行系统的数据库,每过一个阶段可能需要更新对应账户的利息。如果对应账户的数量非常大,例如对有 1 亿用户的表 account,需要执行下列语句:
1 | UPDATE account SET account_total = account_total + (1 + interest_rate); |
这时这个事务可能需要非常长的时间来完成。可能需要 1 个小时,也可能需要 4、5 个小时,这取决于数据库的硬件配置。同时由于事务 ACID 的特性,这个操作被封装在一个事务中完成。这就产生了一个问题:在执行过程中,当数据库或操作系统、硬件等发生故障时,重新开始事务的代价变得不可接受。数据库需要回滚所有已经发生的变化,而这个过程可能比产生这些变化的时间还要长。
因此,对于长事务的问题,有时可以通过转化为小批量(mini batch)的事务来进行处理。事务发生错误时,只需要回滚一部分数据,然后接着上次已完成的小事务继续进行。例如,对于前面讨论的银行利息计算问题,我们可以将一个需要处理 1 亿用户的大事务,分解为每次处理 10 万用户的小事务。既可以在应用层通过循环和分页来完成,也可以写成存储过程。将大事务拆分成小事务后:
- 每一次只更新一小批用户,某一批出错时只需回滚该批,前边已成功的批次不受影响;
- 事务时间大幅缩短,锁竞争和系统压力随之下降;
- 用户或监控系统能够更清晰地看到进度,例如已更新到第几批。
长事务的危害
- 长事务会长时间占用行锁或表锁,导致其他事务无法访问相关资源,可能引发大量阻塞甚至死锁;
- 事务未提交前,MySQL 需要保留 Undo 日志以支持事务的回滚操作,这会增加存储空间和内存压力,并且还会造成长时间回滚;
- 长时间未提交的事务可能会影响数据库备份、复制和其他管理操作;
- 并发情况下,数据库连接池中的可用连接会被耗尽。
长事务的优化
- 将查询等数据准备操作放到事务外;
- 事务中避免远程调用,如果有的话,要设置超时时间,防止事务等待时间太久;
- 事务中避免一次性处理太多数据,可拆分成多个事务分次处理;
- 更新等涉及加锁的操作尽量放在事务靠后的位置;
- 尽量使用异步处理;
- 极端情况下,可在应用侧(业务代码)保证数据一致性,放弃事务。
事务控制语句
在 MySQL 命令行的默认设置下,事务都是自动提交(auto commit)的,即执行 SQL 语句后就会马上执行 COMMIT
操作。因此要显式地开启一个事务需使用命令 BEGIN
、START TRANSACTION
,或者执行命令 SET AUTOCOMMIT=0
,禁用当前会话的自动提交。在具体介绍其含义之前,先来看看用户可以使用哪些事务控制语句。
START TRANSACTION | BEGIN
:显式地开启一个事务。COMMIT
:要想使用这个语句的最简形式,只需发出COMMIT
。也可以更详细一些,写为COMMIT WORK
,不过二者几乎是等价的。COMMIT
会提交事务,并使得已对数据库做的所有修改成为永久性的。ROLLBACK
:要想使用这个语句的最简形式,只需发出ROLLBACK
。同样地,也可以写为ROLLBACK WORK
,但是二者几乎是等价的。回滚会结束用户的事务,并撤销正在进行的所有未提交的修改。SAVEPOINT identifier
:SAVEPOINT 允许在事务中创建一个保存点,一个事务中可以有多个 SAVEPOINT。RELEASE SAVEPOINT identifier
:删除一个事务的保存点,当没有一个保存点时执行这句话,会抛出一个异常。ROLLBACK TO [SAVEPOINT] identifier
:这个语句与 SAVEPOINT 命令一起使用。可以把事务回滚到指定的保存点,而不回滚此保存点之前的任何工作。例如可以发出两条 UPDATE 语句,后面跟一个 SAVEPOINT,然后又是两条 DELETE 语句。如果执行 DELETE 语句期间出现了某种异常情况,并且捕获到这个异常,同时发出了ROLLBACK TO SAVEPOINT
命令,事务就会回滚到指定的 SAVEPOINT,撤销 DELETE 完成的所有工作,而 UPDATE 语句完成的工作不受影响。SET TRANSACTION
:这个语句用来设置事务的隔离级别。InnoDB 存储引擎提供的事务隔离级别有:READ UNCOMMITTED、READ COMMITTED、REPEATABLE READ、SERIALIZABLE。
START TRANSACTION
、BEGIN
语句都可以在 MySQL 命令行下显式地开启一个事务。但是在存储过程中,MySQL 数据库的解析器会自动将 BEGIN
识别为 BEGIN … END
,因此在存储过程中只能使用 START TRANSACTION
语句来开启一个事务。
COMMIT
和 COMMIT WORK
语句基本一致,都是用来提交事务。不同之处在于 COMMIT WORK
用来控制事务结束后的行为是 CHAIN
还是 RELEASE
。如果是 CHAIN
方式,那么事务就变成了链事务。
用户可以通过参数 completion_type
来进行控制,该参数默认为 0,表示没有任何操作。在这种设置下 COMMIT
和 COMMIT WORK
是完全等价的。当参数 completion_type
的值为 1 时,COMMIT WORK
等同于 COMMIT AND CHAIN
,表示马上自动开启一个相同隔离级别的事务,如:
1 | mysql> CREATE TABLE t ( a INT, PRIMARY KEY (a) ) ENGINE=INNODB; |
在这个示例中我们设置 completion_type
为 1,第一次通过 COMMIT WORK
来插入 1 这一个记录。之后插入记录 2 时我们并没有用 BEGIN
(或者 START TRANSACTION
)来显式地开启一个事务,后续再插入一条重复的记录 2 就会抛出异常。接着执行 ROLLBACK
操作,最后发现只有 1 这一个记录,2 并没有被插入。因为 completion_type
为 1 时,COMMIT WORK
会自动开启一个链事务,第二条 INSERT INTO t SELECT 2
语句是在同一个事务内的,因此回滚后 2 这条记录并没有被插入表 t 中。
参数 completion_type
为 2 时,COMMIT WORK
等同于 COMMIT AND RELEASE
。在事务提交后会自动断开与服务器的连接,如:
1 | mysql> SET @@completion_type=2; |
在这个例子中,设置了 completion_type=2
后,执行 COMMIT WORK
不仅提交了事务,而且释放(关闭)了当前连接。重新执行任何查询时,客户端都会发现与服务器的连接已断开,并尝试重新连接。
SAVEPOINT
记录了一个保存点,可以通过 ROLLBACK TO SAVEPOINT
来回滚到某个保存点,但是如果回滚到一个不存在的保存点,会抛出异常:
1 | mysql> BEGIN; |
InnoDB 存储引擎中的事务都是原子的,这说明下述两种情况:构成事务的每条语句要么都提交(持久化),要么所有语句都回滚。这种保护还延伸到单个语句——一条语句要么完全成功,要么完全回滚(注意,这里说的是语句级回滚)。因此当一条语句失败并抛出异常时,并不会导致先前已经执行的语句自动回滚。所有已执行的操作都将保留,必须由用户自己决定是否对其进行提交或回滚。示例如下:
1 | mysql> CREATE TABLE t (a INT, PRIMARY KEY(a)) ENGINE=INNODB; |
可以看到,当插入第二条记录 1 时,由于主键重复抛出了 1062 错误,但数据库并没有自动回滚事务;此时事务仍处于活动状态,必须由用户显式地执行 COMMIT
或 ROLLBACK
命令来结束事务。
另一个容易混淆的地方是 ROLLBACK TO SAVEPOINT
——虽然它包含 “ROLLBACK”,但并不能真正结束整个事务。因此,即使执行了 ROLLBACK TO SAVEPOINT
,之后仍需显式地运行 COMMIT
或 ROLLBACK
才能完成事务。具体示例如下:
1 | mysql> CREATE TABLE t ( a INT, PRIMARY KEY(a) ) ENGINE=INNODB; |
可以看到,在上面的例子中,虽然在发生重复错误后用户通过 ROLLBACK TO SAVEPOINT t2
命令回滚到了保存点 t2,但是事务此时并没有结束。再运行命令 ROLLBACK
后,事务才会完整地回滚。这里再次提醒,ROLLBACK TO SAVEPOINT
命令并不真正地结束事务。
隐式提交的 SQL 语句
以下这些 SQL 语句会产生一个隐式的提交操作,即执行完这些语句后,会有一个隐式的 COMMIT 操作。
DDL 语句:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21ALTER DATABASE … UPGRADE DATA DIRECTORY NAME
ALTER EVENT
ALTER PROCEDURE
ALTER TABLE
ALTER VIEW
CREATE DATABASE
CREATE EVENT
CREATE INDEX
CREATE PROCEDURE
CREATE TABLE
CREATE TRIGGER
CREATE VIEW
DROP DATABASE
DROP EVENT
DROP INDEX
DROP PROCEDURE
DROP TABLE
DROP TRIGGER
DROP VIEW
RENAME TABLE
TRUNCATE TABLE用来隐式地修改 MySQL 架构的操作:
1
2
3
4
5
6CREATE USER
DROP USER
GRANT
RENAME USER
REVOKE
SET PASSWORD管理语句:
1
2
3
4
5
6ANALYZE TABLE
CACHE INDEX
CHECK TABLE
LOAD INDEX INTO CACHE
OPTIMIZE TABLE
REPAIR TABLE
需要注意的是,TRUNCATE TABLE
语句是 DDL,因此虽然和对整张表执行 DELETE 的结果是一样的,但它是不能被回滚的。
隐式提交
隐式提交(implicit commit)并不是某个存储引擎偷偷在后台提交,而是 MySQL 服务器在执行这些语句时,在语法分析/执行阶段主动调用了事务提交。其原理可以概括为以下几点:
元数据一致性要求
DDL(Data Definition Language,定义性语言)操作例如 CREATE TABLE
、ALTER TABLE
等,会修改数据库的元数据(数据字典)——表结构、索引信息、权限信息……这些修改必须在干净的事务边界上进行,否则如果与正在进行的事务混合,元数据和事务日志就可能出现不一致。因此,服务器在处理 DDL 之前,会先隐式地提交(COMMIT)当前事务,确保所有已做的数据修改都已持久化且可见;DDL 执行完毕后,又会隐式提交,以将新的元数据生效并释放所有表级锁。
存储引擎锁与全局锁
DDL 通常需要获取表级甚至元数据级的全局锁(例如 metadata lock),而 InnoDB 行事务的 MVCC、锁粒度等是在行和页级别管理的。为了避免长事务与元数据锁冲突,MySQL 设计为:
- DDL 前自动提交,这样旧事务内的锁都释放掉,DDL 能顺利抢到元数据锁;
- DDL 后自动提交,这样新事务再去访问时就能看到最新结构。
隐式提交的实现机制
在 MySQL 源码里,SQL 解析器(SQL layer)会为特定的语句类型(DDL、某些管理语句)打上 autocommit 标志或直接在执行函数里调用 thd->commit():
1 | // 伪代码,执行 DDL 时 |
对于 TRUNCATE TABLE
这类内部实现成 DROP + CREATE 的操作,同样也在前后各一次 commit()
,所以即使在显式事务中执行,也马上结束当前事务。
与普通 DML 的区别
普通的 DML 如 INSERT/UPDATE/DELETE,在显式事务中只会被存入 redo/undo 日志缓冲,真正刷盘并提交则取决于 innodb_flush_log_at_trx_commit
、COMMIT 语句或自动提交设置。它们不会在执行时强制触发存储引擎层面的全局提交。
对于事务操作的统计
由于 InnoDB 存储引擎是支持事务的,因此 InnoDB 存储引擎的应用需要在考虑每秒请求数(Questions Per Second, QPS)的同时,也应该关注每秒事务处理的能力(Transactions Per Second, TPS)。计算 TPS 的方法是:(com_commit + com_rollback) / 时间。
但要使用这种方法,前提是所有的事务都必须是显式提交的;如果存在隐式提交或隐式回滚(默认 autocommit=1
),这些操作不会计入 com_commit
和 com_rollback
这两个状态变量中。例如:
1 | mysql> SHOW GLOBAL STATUS LIKE 'com_commit'\G |
可以看到,尽管插入了第三条记录,但 com_commit
的值仍然保持 5,没有增加。这是因为默认情况下每条 INSERT 都在隐式事务中执行(autocommit=1
),而隐式提交不计入 com_commit
。
事务原理
事务隔离性由锁来实现。原子性、持久性通过数据库的 redo log 和 undo log 来完成,一致性则由其他三种特性的共同实现来保证。redo log 称为重做日志,用来保证事务的原子性和持久性。undo log 用来保证事务的一致性。
有人或许会认为 undo 是 redo 的逆过程,其实不然。redo 和 undo 的作用都可以视为是一种恢复操作;redo 恢复提交事务修改的页操作,而 undo 回滚行记录到某个特定版本。因此两者记录的内容不同,redo 通常是物理日志,记录的是页的物理修改操作。undo 是逻辑日志,根据行记录进行记录。
redo log => 持久性
重做日志用来实现事务的持久性,即事务 ACID 中的D。其由两部分组成:一是内存中的重做日志缓冲(redo log buffer),其是易失的;二是重做日志文件(redo log file),其是持久的。InnoDB 是事务的存储引擎,其通过 Force Log at Commit 机制实现事务的持久性,即当事务提交(COMMIT)时,必须先将该事务的所有日志写入到重做日志文件进行持久化,待事务的 COMMIT 操作完成才算完成。这里的日志是指重做日志,在 InnoDB 存储引擎中,由 redo log 和 undo log 两部分组成。redo log 用来保证事务的持久性,undo log 用来帮助事务回滚及MVCC的功能。redo log 基本上都是顺序写的,在数据库运行时不需要对 redo log 文件进行读取操作;undo log 是需要进行随机读写的。
为了确保每次日志都写入重做日志文件,在每次将重做日志缓冲写入重做日志文件后,InnoDB 存储引擎都需要调用一次 fsync 操作。由于重做日志文件打开并没有使用 O_DIRECT
选项,因此重做日志先写入文件系统缓存;为了确保重做日志写入磁盘,必须进行一次 fsync 操作。由于 fsync 的效率取决于磁盘的性能,因此磁盘的性能决定了事务提交的性能,也就是数据库的性能。
InnoDB 存储引擎允许用户手工设置非持久化的情况发生,以此提高数据库的性能。即当事务提交时,日志不写入重做日志文件,而是等待一个时间周期后再执行 fsync 操作。由于并非强制在事务提交时进行一次 fsync 操作,这可以显著提高数据库的性能,但是当数据库发生宕机时,由于部分日志未刷新到磁盘,因此会丢失最后一段时间的事务。
参数 innodb_flush_log_at_trx_commit
用来控制重做日志刷新到磁盘的策略。该参数的默认值为 1,表示事务提交时必须调用一次 fsync 操作。还可以设置该参数的值为 0 和 2。0 表示事务提交时不进行写入重做日志操作,这个操作仅在 master thread 中完成,而 master thread 中每 1 秒会进行一次重做日志文件的 fsync 操作;2 表示事务提交时将重做日志写入文件系统缓存,不进行 fsync 操作。在这个设置下,当 MySQL 数据库发生宕机而操作系统不发生宕机时,并不会导致事务的丢失;而当操作系统宕机时,重启数据库后会丢失未从文件系统缓存刷新到重做日志文件的那部分事务。
在 MySQL 数据库中还有一种二进制日志(binlog),其用来进行 POINT-IN-TIME(PIT)的恢复及主从复制(Replication)环境的建立。从表面上看其和重做日志非常相似,都是记录了对于数据库操作的日志。然而,从本质上来看,两者有着非常大的不同。首先,重做日志是在 InnoDB 存储引擎层产生,而二进制日志是在 MySQL 数据库的上层产生的,并且二进制日志不仅仅针对 InnoDB 存储引擎,MySQL 数据库中的任何存储引擎对于数据库的更改都会产生二进制日志。
其次,两种日志记录的内容形式不同。MySQL 数据库上层的二进制日志是一种逻辑日志,其记录的是对应的 SQL 语句。而 InnoDB 存储引擎层面的重做日志是物理格式日志,其记录的是对于每个页的修改。
此外,两种日志记录写入磁盘的时间点不同,如下图所示。二进制日志只在事务提交完成后进行一次写入。而 InnoDB 存储引擎的重做日志在事务进行中不断地被写入,这表现为日志并不是随事务提交的顺序进行写入的。
从上图中可以看到,二进制日志仅在事务提交时记录,并且对于每一个事务,仅包含对应事务的一个日志。而对于 InnoDB 存储引擎的重做日志,由于其记录的是物理操作日志,因此每个事务对应多个日志条目,并且事务的重做日志写入是并发的,并非在事务提交时写入,故其在文件中记录的顺序并非事务开始的顺序。*T1、*T2、*T3 表示的是事务提交时的日志。
log block
在 InnoDB 存储引擎中,重做日志都是以 512 字节进行存储的。这意味着重做日志缓冲、重做日志文件都是以块的方式进行保存的,称之为重做日志块,每块的大小为 512 字节。
若一个页中产生的重做日志数量大于 512 字节,那么需要分割为多个重做日志块进行存储。此外,由于重做日志块的大小和磁盘扇区大小一样,都是 512 字节,因此重做日志的写入可以保证原子性,不需要 doublewrite 技术。
重做日志除了日志本身之外,还由日志块头及日志块尾两部分组成。重做日志块头一共占用 12 字节,重做日志块尾占用 8 字节。故每个重做日志块实际可以存储的大小为 492 字节(512 – 12 – 8)。下图显示了重做日志块缓冲的结构。
log block header 由 4 部分组成,如下表所示:
名称 | 占用字节 |
---|---|
LOG_BLOCK_HDR_NO | 4 |
LOG_BLOCK_HDR_DATA_LEN | 2 |
LOG_BLOCK_FIRST_REC_GROUP | 2 |
LOG_BLOCK_CHECKPOINT_NO | 4 |
log buffer 是由 log block 组成,在内部 log buffer 就好似一个数组,因此 LOG_BLOCK_HDR_NO
用来标记这个数组中的位置。其是递增并且循环使用的,占用 4 个字节,但是由于第一位用来判断是否是 flush bit,所以最大值为 2 G。
LOG_BLOCK_HDR_DATA_LEN
(2 字节)表示 log block 所占用的大小。当 log block 被写满时,该值为 0x200,表示使用全部 log block 空间,即占用 512 字节。
LOG_BLOCK_FIRST_REC_GROUP
(2 字节)表示 log block 中第一个日志所在的偏移量。如果该值的大小和 LOG_BLOCK_HDR_DATA_LEN
相同,则表示当前 log block 不包含新的日志。
例如:事务 T1 的重做日志 占用 762 字节,事务 T2 的重做日志占用 100 字节。由于每个 log block 实际只能保存 492 字节(512 − 12 − 8),其在 log buffer 中的情况如下图所示。
可以观察到,由于事务 T1 的重做日志占用 792 字节,因此需要占用两个 log block。
在第一个 log block 中,LOG_BLOCK_FIRST_REC_GROUP
为 12,即此 block 中第一个日志的起始位置。
在第二个 log block 中,由于它包含了之前事务 T1 的剩余日志,事务 T2 的日志才是该 block 中第一个日志,因此该 log block 的 LOG_BLOCK_FIRST_REC_GROUP
为 282(270 + 12)。
LOG_BLOCK_CHECKPOINT_NO
(4 字节)表示该 log block 最后被写入时的检查点(checkpoint)编号。
log block tailer 只由 1 个字段组成(如下表所示),其值与 LOG_BLOCK_HDR_NO
相同,并在函数 log_block_init
中被初始化。
名称 | 大小(字节) |
---|---|
LOG_BLOCK_TRL_NO | 4 |
log group
log group 为重做日志组,其中有多个重做日志文件。虽然源码里支持镜像多组 log group 的功能,但官方屏蔽了镜像功能,实际上只有一组文件集合。
log group 是一个逻辑上的概念,并没有一个实际存储的物理文件来表示 log group 信息。log group 由多个重做日志文件组成,每个 log group 中的日志文件大小是相同的,且在 InnoDB 1.2 版本之前,重做日志文件的总大小要小于 4 GB(不能等于 4 GB)。从 InnoDB 1.2 版本开始,重做日志文件总大小的限制提高为了 512 GB。
重做日志文件中存储的就是之前在 log buffer 中保存的 log block,因此其也是根据块的方式进行物理存储管理,每个块的大小与 log block 一样,同样为 512 字节。在 InnoDB 存储引擎运行过程中,log buffer 会根据一定的规则将内存中的 log block 刷新到磁盘。这个规则具体是:
- 事务提交时
- 当 log buffer 中有一半的内存空间已经被使用时
- log checkpoint 时
对于 log block 的写入追加(append)在 redo log file 的最后部分,当一个 redo log file 被写满时,会接着写入下一个 redo log file,其使用方式为 round-robin。
虽然 log block 总是在 redo log file 的最后部分进行写入,有的读者可能认为对 redo log file 的写入都是顺序的。其实不然,因为 redo log file 除了保存 log buffer 刷新到磁盘的 log block,还保存了一些其他的信息,这些信息一共占用 2 KB 大小,即每个 redo log file 的前 2 KB 部分不保存 log block 的信息。对于 log group 中的第一个 redo log file,其前 2 KB 的部分保存 4 个 512 字节大小的块,其中存放的内容如下表所示。
名称 | 大小(字节) |
---|---|
log file header | 512 |
checkpoint1 | 512 |
空 | 512 |
checkpoint2 | 512 |
需要特别注意的是,上述信息仅在每个 log group 的第一个 redo log file 中进行存储。
log group 中的其余 redo log file 仅保留这些空间,但不保存上述信息。正因为保存了这些信息,就意味着对 redo log file 的写入并不是完全顺序的。因为其除了 log block 的写入操作,还需要更新前 2 KB 部分的信息,这些信息对于 InnoDB 存储引擎的恢复操作来说非常关键和重要。
故 log group 与 redo log file 之间的关系如下图所示。
在 log file header 后面的部分为 InnoDB 存储引擎保存的 checkpoint(检查点)值,其设计是交替写入,这样的设计避免了因介质失败导致无法找到可用的 checkpoint 的情况。
具象一点就是,第一次写 checkpoint 时,写入 Slot A;下一次写 checkpoint 时,写入 Slot B;再下一次,又回到写 Slot A……如此循环。
为什么要这样做?
- 避免写一半后丢失:如果只有一个槽,刚写入还没刷完,突然停电或磁盘故障,这个槽可能只写了一半,里面的值就坏了。
- 保证总有一个有效副本:交替写入意味着每次只有一个槽被覆盖,另一个槽保存的是上一次写入的、完整且可靠的检查点值。即便一个槽在写入时被破坏,另外一个槽里还有上一次的有效值。
恢复时的处理
在数据库启动恢复阶段,InnoDB 会读取这两个槽的内容,选择最新且完整的那个作为真正的 checkpoint,从那里开始重做日志的回放,保证不会因为写损坏而找不到有效的检查点。
重做日志格式
不同的数据库操作会有对应的重做日志格式。此外,由于 InnoDB 存储引擎的存储管理是基于页的,故其重做日志格式也是基于页的。虽然有着不同的重做日志格式,但它们具有通用的头部格式,如下图所示:
通用头部格式由以下 3 部分组成:
redo_log_type
:重做日志的类型。space
:表空间的 ID。page_no
:页的偏移量。
之后的 redo log body 的部分,根据重做日志类型的不同,会有不同的存储内容。例如,对于页上记录的插入和删除操作,分别对应下图所示的格式:
LSN
LSN 是 Log Sequence Number 的缩写,代表日志序列号。在 InnoDB 存储引擎中,LSN 占用 8 字节,并且单调递增。LSN 的含义包括:
- 重做日志已写入的总字节数;
- checkpoint 所在位置;
- 页的版本。
LSN 记录了事务写入重做日志的累计字节数。例如:
- 如果当前 LSN 为 1000,事务 T1 又写入了 100 字节的重做日志,则 LSN 更新为 1100;
- 接着事务 T2 写入 200 字节,则 LSN 更新为 1300。
除了记录在重做日志文件中,每个数据页的页头也保存了一个 FIL_PAGE_LSN
值,它表示该页最后一次刷新的 LSN。恢复时,InnoDB 会比较重做日志中记录的 LSN 与页头中的 FIL_PAGE_LSN
:
- 如果页头 LSN 小于重做日志中的 LSN,就需要应用重做日志;
- 否则说明该页已经被刷新到最新,无需重做。
查看当前 LSN:
1 | mysql> SHOW ENGINE INNODB STATUS\G |
以上结果的参数如下:
Log sequence number:当前的 LSN
Log flushed up to:已刷新到重做日志文件的 LSN
Last checkpoint at:已同步到磁盘的 LSN
虽然在上面的例子中,Log sequence number 和 Log flushed up to 的值是相同的,但是在实际生产环境中,该值有可能是不同的。因为在一个事务中从日志缓冲刷新到重做日志文件并不只是在事务提交时发生,每秒都会有从日志缓冲刷新到重做日志文件的动作。下面是在生产环境下重做日志的信息的示例。
1 | mysql> SHOW ENGINE INNODB STATUS\G |
所以,redo log 的刷盘时机有以下 4 种:
- 提交时:保证事务持久性;
- 超时周期:(默认 1s)定时写入,防止长时间积压;
- 缓冲区半满:防止 buffer 溢出;
- 检查点:保证恢复一致性。
恢复
InnoDB 存储引擎在启动时,不管上次数据库运行时是否正常关闭,都会尝试进行恢复操作。因为重做日志记录的是物理日志,因此恢复的速度比逻辑日志(如二进制日志)要快很多。与此同时,InnoDB 存储引擎自身也对恢复进行了优化,例如顺序读取和并行应用重做日志,这样可以进一步提高数据库恢复的速度。
由于 checkpoint 表示已刷新到磁盘页上的 LSN,因此在恢复过程中仅需应用从 checkpoint 开始的日志部分。
假设数据库崩溃时,checkpoint 的 LSN 为 10000;重做日志当前已写到 LSN 13000;那么恢复时只需重做 LSN 10000 到 13000 范围内的日志。
InnoDB 存储引擎的重做日志是物理日志,因此其恢复速度较之二进制日志恢复快得多。例如对于 INSERT 操作,其记录的是每个页上的变化。对于下面的表:
1 | CREATE TABLE t ( |
若执行 SQL 语句:
1 | INSERT INTO t SELECT 1, 2; |
由于需要对聚集索引页和辅助索引页进行操作,其记录的重做日志大致为:
1 | page(2,3), offset 32, value 1,2 # 聚集索引 |
上图中的 page(x,y)
代表表空间 x 中的页 y。
可以看到记录的是页的物理修改操作,若插入涉及 B+ 树的 split,可能会有更多的页需要记录日志。此外,由于重做日志是物理日志,因此其是幂等的。幂等的概念如下:
$$f(f(x)) = f(x)$$
有的人错误地认为只要将二进制日志的格式设置为 ROW,那么二进制日志也是幂等的。这显然是错误的。举个简单的例子,INSERT 操作在二进制日志中就不是幂等的:重复执行可能会插入多条重复的记录。而上述 INSERT 操作的重做日志是幂等的。
undo log => 原子性
重做日志记录了事务的行为,可以很好地通过其对页进行“重做”操作。但是事务有时还需要进行回滚操作,这时就需要 undo。因此在对数据库进行修改时,InnoDB 存储引擎不但会产生 redo,还会产生一定量的 undo。这样如果用户执行的事务或语句由于某种原因失败了,又或者用户用一条 ROLLBACK 语句请求回滚,就可以利用这些 undo 信息将数据回滚到修改之前的样子。
redo 存放在重做日志文件中,与 redo 不同,undo 存放在数据库内部的一个特殊段中,这个段称为 undo 段(undo segment)。undo 段位于共享表空间内。也就是说,一个共享表空间 ibdata1
里可以包含多个 undo segment,每个 segment 又由一系列 undo log page 组成。
很多人以为 undo 是把数据页整个地回滚到某个时刻的样子,就像把一整张纸翻到以前的版本。实际上并不是这样!
undo 记录的是对每条记录所做修改的逆操作:比如把一个 INSERT 反做成 DELETE,把一个 UPDATE 反做成把旧值写回去。它并不保存整页的“图片”或快照,而是保存每个操作的前后值,对操作本身做逆向处理。
为什么不能物理地整页回滚?
在真正的数据库中,常常有 大量并发事务 同时在操作:
事务 A 在这一页上修改了第 3 行和第 7 行;
同时事务 B 也在这一页上修改了第 10 行和第 15 行;如果把整页恢复到事务 A 开始时的版本,那么事务 B 对第 10、15 行的修改就一并被移除了,破坏了 B 的工作。
因此,InnoDB 在回滚时,不会把页面还原成某个历史时刻的完全镜像,而是针对事务 A 做的那些修改,逐条执行逆操作,只撤销 A 做的改动,保留其他事务(如 B)在同一页上的修改。
此外,假设用户执行了一个 INSERT 10W 条记录的事务,这个事务会导致分配一个新的段,即表空间会增大。在用户执行 ROLLBACK
时,会将插入的事务进行回滚,但是表空间的大小并不会因此而收缩。在内部把这些插入的行逻辑删除。回滚不会缩小表空间文件,它只是把数据逻辑地撤销,保留了原先为这些数据分配的物理页,用于后续的存储需求。这样避免了频繁的文件尺寸变动,也利于性能和空间重用。
因此,当 InnoDB 存储引擎回滚时,它实际上做的是与之前相反的工作。对于每个 INSERT,InnoDB 存储引擎会完成一个 DELETE;对于每个 DELETE,InnoDB 存储引擎会执行一个 INSERT;对于每个 UPDATE,InnoDB 存储引擎会执行一个相反的 UPDATE,将修改前的行放回去。
除了回滚操作,undo 的另一个作用是 MVCC,即在 InnoDB 存储引擎中 MVCC 的实现是通过 undo 来完成。当用户读取一行记录时,若该记录已经被其他事务占用,当前事务可以通过 undo 读取之前的行版本信息,以此实现非锁定读取。
最为重要的一点是,undo log 会产生 redo log,也就是 undo log 的产生会伴随着 redo log 的产生,这是因为 undo log 也需要持久性的保护。
undo 日志存储管理
InnoDB 存储引擎对 undo 的管理同样采用段的方式。但是这个段和之前介绍的段有所不同。首先 InnoDB 存储引擎有 rollback segment,每个 rollback segment 记录了 1024 个 undo log segment,而在每个 undo log segment 段中进行 undo 页的申请。共享表空间偏移量为 5 的页 (0, 5)
记录了所有 rollback segment header 所在的页,这个页的类型为 FIL_PAGE_TYPE_SYS
。
在 InnoDB1.1 版本之前(不包括 1.1 版本),只有一个 rollback segment,因此支持同时在线的事务限制为 1024。虽然对绝大多数的应用来说都已经够用,但不管怎么说这是一个瓶颈。从 1.1 版本开始 InnoDB 支持最大 128 个 rollback segment,故其支持同时在线的事务限制提高到了 128 × 1024。
虽然 InnoDB1.1 版本支持了 128 个 rollback segment,但是这些 rollback segment 都存储于共享表空间中。从 InnoDB1.2 版本开始,可通过参数对 rollback segment 做进一步的设置。这些参数包括:
innodb_undo_directory
;innodb_undo_logs
;innodb_undo_tablespaces
。
参数 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。下图显示了由 3 个文件组成的 rollback segment。
1 | mysql> SHOW VARIABLES LIKE 'innodb_undo%'; |
需要特别注意的是,事务在 undo log segment 分配页并写入 undo log 的这个过程同样需要写入重做日志。当事务提交时,InnoDB 存储引擎会做以下两件事情:
- 将 undo log 放入列表中,以供之后的 purge 操作;
- 判断 undo log 所在的页是否可以重用,若可以则分配给下个事务使用。
事务提交后并不能马上删除 undo log 及 undo log 所在的页。这是因为可能还有其他事务需要通过 undo log 来得到行记录之前的版本。故事务提交时将 undo log 放入一个链表中,是否可以最终删除 undo log 及其所在页由 purge 线程来判断。
此外,若为每一个事务分配一个单独的 undo 页会非常浪费存储空间,特别是对于 OLTP 的应用类型。因为在事务提交时,可能并不能马上释放页。假设某应用的删除和更新操作的 TPS(transactions per second)为 1000,为每个事务分配一个 undo 页,那么一分钟就需要 1000*60 页,大约需要的存储空间为 1 GB。若每秒的 purge 页的数量为 20,这样的设计对磁盘空间有着相当高的要求。
因此,在 InnoDB 存储引擎的设计中对 undo 页可以进行重用。具体来说,当事务提交时,首先将 undo log 放入链表中,然后判断该 undo 页的使用空间是否小于 3/4,若是则表示该 undo 页可以被重用,之后新的 undo log 记录就在该页的后面。由于存放 undo log 的列表是以记录组织的,而 undo 页可能存放着不同事务的 undo log,因此 purge 操作需要涉及磁盘的离散读取,是一个相对较慢的过程。
具象一点就是,InnoDB 里维护了一条 undo log 链表,链表里的每个节点对应一条 undo 记录(即某次对某行的前镜像)。这些节点并不是按事务或者按页顺序排列,而是按操作顺序串成一串——也就是一条操作一条记录地组织。因此,一个物理的 undo page(4KB 或 16KB)上,可能混杂了多个事务在不同时间写入的 undo 记录。
当后台 purge 线程要清理某个事务的 undo 记录(即判断哪些 undo 可以安全丢弃),它会按链表顺序走:
- 找到这条事务的第一个 undo 记录在链表里的位置。
- 沿着链表往下走,一条一条检查:这条记录是不是该事务的?如果是,就删掉;如果不是,就跳过。
- 对应的,每读一条 undo 记录,就要去读它所在的那页(undo page)——而这些记录分散在不同的页面上。
undo log 格式
在 InnoDB 存储引擎中,undo log 分为:
- insert undo log;
- update undo log。
insert undo log 是指在 insert 操作中产生的 undo log。因为 insert 操作的记录,只对事务本身可见,对其他事务不可见(这是事务隔离性的要求),故该 undo log 可以在事务提交后直接删除。不需要进行 purge 操作。
下图显示了 insert undo log 的格式,其中 *
表示对存储的字段进行了压缩。insert undo log 开始的前两个字节 next 记录的是下一个 undo log 的位置,通过该 next 字节可以知道一个 undo log 所占的空间字节数。类似地,尾部的两个字节记录的是 undo log 的开始位置。type_cmpl
占用一个字节,记录的是 undo 的类型,也就是在回滚或 MVCC 回查时应该执行怎样的逆向操作。对于 insert undo log,该值总是为 11。undo_no
记录事务的 ID,table_id 记录 undo log 所对应的表对象。这两个值都是在压缩后保存的。接着的部分记录了所有主键的列和值。在进行 rollback 操作时,根据这些值可以定位到具体的记录,然后进行删除即可。
update undo log 记录的是对 delete 和 update 操作产生的 undo log。该 undo log 可能需要提供 MVCC 机制,因此不能在事务提交时就进行删除。提交时放入 undo log 链表,等待 purge 线程进行最后的删除。update undo log 的结构如下图所示。
update undo log 相对于之前介绍的 insert undo log,记录的内容更多,所需占用的空间也更大。next、start、undo_no、table_id 与之前介绍的 insert undo log 部分相同。这里的 type_cmpl
,由于 update undo log 本身还有分类,故其可能的值如下:
- 12 对应
TRX_UNDO_UPD_EXIST_REC
,更新 non-delete-mark 的记录,用于 UPDATE 操作的时候生成该类型 undo 日志; - 13 对应
TRX_UNDO_UPD_DEL_REC
,将 delete 的记录标记为 not delete,用于回滚 DELETE 操作的时候生成该类型 undo 日志; - 14 对应
TRX_UNDO_DEL_MARK_REC
,将记录标记为 delete,在 DELETE 操作的时候生成该类型 undo 日志。
这里梳理一下:
- 初次执行 DELETE时,InnoDB 会打上删除标记,这一步会生成
type_cmpl = 14
(TRX_UNDO_DEL_MARK_REC
) 的 undo 记录,用于在回滚时撤销“打标记”操作。 - 当真正执行回滚(或构建 MVCC 快照读需要“回到删除前”)时,InnoDB 必须将该行的删除标记“更新”回可见状态,这时才会写入
type_cmpl = 13
(TRX_UNDO_UPD_DEL_REC
) 的 undo 记录,用于撤销之前的删除标记 。
为什么需要 type_cmpl = 13
这个状态?
当执行 ROLLBACK
或需要 MVCC 快照读回到删除前状态时,InnoDB 必须把 delete‐mark 更新回“未删除”,这一步在引擎层面是一次新的 UPDATE 操作。
接着的部分记录 update_vector
信息,update_vector
表示 update 操作导致发生改变的列。每个修改的列信息都要记录到 undo log 中。对于不同的 undo log 类型,可能还需要记录对索引列所做的修改。
如果回滚操作也失败了,怎么办?
在 MySQL(尤其是 InnoDB)中,ROLLBACK 失败主要出现在两种情况:一是客户端或连接异常导致回滚命令未能到达服务器,二是服务器在执行回滚期间遇到严重内部错误(例如表空间或日志损坏),无法完成回滚动作。针对这两类问题,通常需要先检查错误日志、确认失败原因;若是客户端侧问题,可重连重试或在应用代码中捕获并安全终止事务;若是服务器侧问题,则可能需要使用 innodb_force_recovery
模式跳过故障步骤以启动实例,甚至导出数据、重建表空间或从备份+二进制日志恢复。
查看 undo 日志
InnoSQL 对 information_schema
进行了扩展,添加了两张数据字典表,这样用户可以非常方便和快捷地查看 undo 的信息。
首先增加的数据字典表为 INNODB_TRX_ROLLBACK_SEGMENT
。顾名思义,这个数据字典表用来查看 rollback segment,其表结构如下表所示。
Field | Type | Null | Key | Default | Extra |
---|---|---|---|---|---|
Segment_id | bigint(21) unsigned | NO | 0 | ||
space | bigint(21) unsigned | NO | 0 | ||
page_no | bigint(21) unsigned | NO | 0 | ||
last_page_no | bigint(21) unsigned | YES | NULL | ||
last_offset | bigint(21) unsigned | NO | 0 | ||
last_trx_no | varchar(18) | NO | 0 | ||
update_undo_list | bigint(21) unsigned | NO | 0 | ||
update_undo_cached | bigint(21) unsigned | NO | 0 | ||
insert_undo_list | bigint(21) unsigned | NO | 0 | ||
insert_undo_cached | bigint(21) unsigned | NO | 0 |
例如,可以通过如下命令来查看 rollback segment 所在的页:
1 | mysql> SELECT segment_id, space, page_no |
另一张数据字典表为 INNODB_TRX_UNDO
,用来记录事务对应的 undo log,方便开发人员详细了解每个事务产生的 undo 量。下面将演示如何使用 INNODB_TRX_UNDO
表。首先根据如下代码创建测试表。
1 | CREATE TABLE t ( |
接着插入一条记录,并尝试通过 INNODB_TRX_UNDO 观察该事务的 undo log 情况:
1 | mysql> BEGIN; |
通过数据字典表可以看到,事务 ID 为 3001,rollback segment 的 ID 为 2,因为是该条事务的第一个操作,故 undo_rec_no
为 0。之后可以看到插入的类型为 TRX_UNDO_INSERT_REC
,表示 insert undo log 的大小,占用 12 字节。最后的 space, page_no, offset 表示 undo log 开始的位置。打开文件 ibdata1,定位到页(334,272)
,并读取 12 字节,可得到如下内容:
1 | 01 1c 0b 00 16 04 80 00 01 01 10 |
上述就是 undo log 实际的内容,根据上一小节对 undo log 格式的介绍,可以整理得到:
1 | 01 1c # 下一个 undo log 的偏移 272+12=0x011c |
此外,由于知道该 undo log 所在的 rollback segment 的 ID 为 2,用户还可以通过数据字典表 INNODB_TRX_ROLLBACK_SEGMENT
来查看当前 rollback segment 的信息,如:
1 | mysql> SELECT segment_id, insert_undo_list, insert_undo_cached |
可以看到 insert_undo_list
为 1。若这时进行事务的 COMMIT 操作,再查看该数据字典表:
1 | mysql> COMMIT; |
可以发现,insert_undo_list
变为 0,而 insert_undo_cached
增加为 1。这就是前面所介绍的 undo 页重用。下次再有事务需要向该 rollback segment 申请 undo 页时,可以直接使用该页。
接着再来观察 delete 操作产生的 undo log。进行如下操作:
1 | mysql> BEGIN; |
用上述同样的方法定位到页 326,偏移量为 620 的位置,得到如下结果:
1 | 0518260 00 00 00 00 00 00 00 00 00 00 00 02 91 0e 00 |
接着开始整理:
1 | 02 91 # 下一个 undo log 开始位置的偏移量 |
观察 rollback segment 信息,可以看到:
1 | mysql> SELECT segment_id, update_undo_list, update_undo_cached |
同样的,在事务提交后,undo 页会放入 cache 列表以供下次重用:
1 | mysql> COMMIT; |
通过上面的例子可以看到,delete 操作并不直接删除记录,只是将记录标记为已删除,也就是将记录的 delete flag 设置为 1,而记录最终的删除是在 purge 操作中完成的。
最后来看 update 操作产生的 undo log 情况。首先再次插入记录 (1, '1')
,然后进行 update 操作,同时通过数据字典表 INNODB_TRX_UNDO
观察 undo log 的情况:
1 | mysql> INSERT INTO t SELECT 1,'1'; |
用上述同样的方法定位到页 318,偏移量为 724 的位置,得到如下结果:
1 | 04f82d0 00 00 00 00 02 fd 0c 00 16 00 00 00 00 32 04 e0 |
整理后得到:
1 | 02 fd # 下一个 undo log 开始位置 |
上述的例子是更新一个非主键值,若更新的对象是一个主键值,那么其产生的 undo log 完全不同,如:
1 | mysql> ROLLBACK; |
可以看到,update 主键的操作其实分两步完成。首先将原主键记录标记为已删除,因此需要产生一个类型为 TRX_UNDO_DEL_MARK_REC
的 undo log,之后插入一条新的记录,因此需要产生一个类型为 TRX_UNDO_INSERT_REC
的 undo log。undo_rec_no
显示了产生日志的步数。对 undo log 不再详细进行分析,相关内容和之前介绍并无不同。
为什么主键值更新需要两阶段操作?
在 InnoDB 中,主键不仅是逻辑上的唯一标识,还决定了行在聚簇索引中的物理位置。当主键值发生改变时,MySQL 无法就地修改这一物理位置,也无法仅更新聚簇索引条目的关键字而保留其余数据和二级索引结构。因此,InnoDB 对主键的更新会被分成两阶段:先对旧记录打删除标记,再插入一条带新主键的记录,这样既能保证数据一致性,又能正确维护所有关联的索引。
但是,辅助索引的更新,虽然在逻辑上也等同于删除旧的索引条目+插入新的索引条目,但 InnoDB 在内部并不会把它拆成两个分离的 undo/redo 阶段,而是当作一次“更新”来处理,并由同一条 update undo log(TRX_UNDO_UPD_EXIST_REC
)和对应的 redo 记录一次性完成。
purge
delete 和 update 操作可能并不直接删除原有的数据。例如,对之前的表 t 执行如下的 SQL 语句:
1 | DELETE FROM t WHERE a=1; |
表 t 上列 a 有聚簇索引,列 b 上有辅助索引。对于上述的 delete 操作,通过前面关于 undo log 的介绍已经知道仅是将主键列等于 1 的记录 delete flag 设置为 1,记录并没有被删除,即记录还是存在于 B+ 树中。其次,对辅助索引上 a 等于 1、b 等于 1 的记录同样没有做任何处理,甚至没有产生 undo log。而真正删除这行记录的操作其实被延时了,最终在 purge 操作中完成。
purge 用于最终完成 delete 和 update 操作。这样设计是因为 InnoDB 存储引擎支持 MVCC,所以记录不能在事务提交时立即进行处理。这时其他事务可能正在引用该行,故 InnoDB 存储引擎需要保存记录之前的版本,而是否可以删除该记录通过 purge 来进行判断。若该记录已不被任何其他事务引用,那么就可以进行真正的 delete 操作。可见,purge 操作是清理之前的 delete 和 update 操作,将上述操作最终完成。而实际上执行的操作为 delete 操作,清理之前记录的版本。
之前已经介绍过,为了节省存储空间,InnoDB 存储引擎的 undo log 设计是这样的:一个页上允许多个事务的 undo log 存在。虽然这不代表事务在全局过程中提交的顺序,但是后面的事务产生的 undo log 总在最后。此外,InnoDB 存储引擎还有一个 history 列表,它根据事务提交的顺序,将 undo log 进行链接。
下图中,history list 表示按照事务提交的顺序将 undo log 进行组织。在 InnoDB 存储引擎的设计中,先提交的事务总在尾端。undo page 存放了 undo log,由于可以重用,因此一个 undo page 中可能存放了多个不同事务的 undo log。trx5 的灰色阴影表示该 undo log 还被其他事务引用。
在执行 purge 的过程中,InnoDB 存储引擎首先从 history list 中找到第一个需要被清理的记录,这里为 trx1,清理之后 InnoDB 存储引擎会在 trx1 的 undo log 所在的页中继续寻找是否存在可以被清理的记录,这里会找到事务 trx3,接着找到 trx5,但是发现 trx5 被其他事务引用而不能清理,故会再次去 history list 中查找,发现此时最尾端的记录为 trx2,接着找到 trx2 所在的页,然后依次再把事务 trx6、trx4 的记录进行清理。
由于 undo page2 中所有的页都被清理了,因此该 undo page 可以被重用。
InnoDB 存储引擎这种先从 history list 中找 undo log,然后再从 undo page 中找 undo log 的设计模式是为了避免大量的随机读取操作,从而提高 purge 的效率。
全局动态参数 innodb_purge_batch_size
用来设置每次 purge 操作需要清理的 undo page 数量。在 InnoDB 1.2 之前,该参数的默认值为 20。而从 1.2 版本开始,该参数的默认值为 300。通常来说,该参数设置得越大,每次回收的 undo page 也就越多,这样可供重用的 undo page 就越多,减少了磁盘存储空间与分配的开销。不过,若该参数设置得太大,则每次需要 purge 处理更多的 undo page,从而导致 CPU 和磁盘 I/O 过于集中于对 undo log 的处理,使性能下降。因此对该参数的调整需要由有经验的 DBA 来操作,并且需要长期观察数据库的运行状态。正如官方的 MySQL 数据库手册所说的,普通用户不需要调整该参数。
当 InnoDB 存储引擎的压力非常大时,并不能高效地进行 purge 操作。那么 history list 的长度会变得越来越长。全局动态参数 innodb_max_purge_lag
用来控制 history list 的长度,若长度大于该参数时,其会延缓 DML 的操作。该参数默认值为 0,表示不对 history list 做任何限制。当大于 0 时,就会延缓 DML 的操作,其延缓的算法为:
$$delay = ((length(history_list) – innodb_max_purge_lag) * 10) – 5$$
delay 的单位是毫秒。此外,需要特别注意的是,delay 的对象是行,而不是一个 DML 操作。例如当一个 update 操作需要更新 5 行数据时,每行数据的操作都会被 delay,故总的延时时间为 5 * delay
。而 delay 的统计会在每一次 purge 操作完成后,重新进行计算。
InnoDB 1.2 版本引入了新的全局动态参数 innodb_max_purge_lag_delay
,其用来控制 delay 的最大毫秒数。也就是说当上述计算得到的 delay 值大于该参数时,将 delay 设置为 innodb_max_purge_lag_delay
,避免由于 purge 操作缓慢导致其他 SQL 线程出现无限制的等待。
group commit
若事务为非只读事务,则每次事务提交时需要进行一次 fsync 操作,以此保证重做日志都已写入磁盘。当数据库发生宕机时,可以通过重做日志进行恢复。虽然固态硬盘的出现提高了磁盘的性能,然而磁盘的 fsync 性能是有限的。为了提高磁盘 fsync 的效率,目前数据库都提供了 group commit 的功能,即一次 fsync 可以刷新确保多个事务日志被写入文件。对于 InnoDB 存储引擎来说,事务提交时会进行两个阶段的操作:
- 修改内存中事务对应的信息,并且将日志写入重做日志缓冲。
- 调用 fsync 将确保日志都从重做日志缓冲冲写入磁盘。
步骤 2)相对于步骤 1)是一个较慢的过程,这是因为存储引擎需要与磁盘打交道。但当有事务进行这个过程时,其他事务可以进行步骤 1)的操作,正在提交的事务完成提交操作后,再次进行步骤 2)时,可以将多个事务的重做日志通过一次 fsync 刷新到磁盘,这样就大大地减少了磁盘的压力,从而提高了数据库的整体性能。对于写入或更新较为频繁的操作,group commit 的效果尤为明显。
然而在 InnoDB 1.2 版本之前,在开启二进制日志后,InnoDB 存储引擎的 group commit 功能会失效,从而导致性能的下降。并且在在线环境多使用 replication 环境,因此二进制日志的选项基本都为开启状态,因此这个问题尤为显著。
导致这个问题的原因是在开启二进制日志后,为了保证存储引擎层中的事务和二进制日志的一致性,二者之间使用了两阶段事务,其步骤如下:
- 当事务提交时 InnoDB 会将事务的 prepare 记录写入 redo 日志缓冲并持久化到磁盘。
- MySQL 数据库上层写入二进制日志。
- InnoDB 存储引擎层将日志写入重做日志文件。
- 修改内存中事务对应的信息,并且将日志写入重做日志缓冲。
- 调用 fsync 将确保日志都从重做日志缓冲冲写入磁盘。
一旦步骤 2)中的操作完成,就确保了事务的提交,即使在执行步骤 3)时数据库发生了宕机。此外需要注意的是,每个步骤都需要进行一次 fsync 操作才能保证上下两层数据的一致性。步骤 2)的 fsync 由参数 sync_binlog
控制,步骤 3)的 fsync 由参数 innodb_flush_log_at_trx_commit
控制。因此上述整个过程如下图所示。
为了保证 MySQL 数据库上层二进制日志的写入顺序和 InnoDB 层的事务提交顺序一致,MySQL 数据库内部使用了 prepare_commit_mutex
这个锁。但是在启用这个锁之后,步骤 3)中的步骤 a)不可在其他事务执行步骤 b)时进行,从而导致了 group commit 失效。
然而,为什么需要保证 MySQL 数据库上层二进制日志的写入顺序和 InnoDB 层的事务提交顺序一致?
崩溃恢复一致性
如果 Binlog 中记录的提交顺序与 InnoDB 实际提交顺序不一致:
- 在异常崩溃后,InnoDB 会回滚那些 prepare 但未 commit 的事务;
- 但 Binlog 里可能已记录这些事务,重放时会尝试执行已回滚的事务,导致副本或恢复数据与源实例不吻合。
物理备份兼容性
工具如 Percona XtraBackup、InnoDB Hot Backup 在 prepare 阶段会汇总所有未完成事务,并依赖 Binlog 决定哪些事务在恢复时执行提交:
- 若写日志与提交顺序错乱,就无法正确匹配,可能把原本应回滚的事务提交,或把应提交的事务丢弃。
主从复制一致性
MySQL 单线程或多线程复制都遵循“在主库上的提交顺序即 Binlog 写入顺序”,备库严格按此顺序重放事务:
- 顺序错乱会让备库先执行后提交的事务,再执行前提交的事务,破坏数据因果关系;
- 按顺序保证了“复制是严格顺序一致”的模型。
这里以备份及恢复为例,例如通过工具 xtrabackup 或者 ibbackup 进行备份,并用来建立 replication。如下图所示:
因此通过锁 prepare_commit_mutex
以串行的方式来保证顺序性,然而这会使 group commit 无法生效,如下图所示。
上图中启用了 prepare_commit_mutex
,因此只有当上一个事务 commit 后释放锁,下一个事务才可以进行 prepare 操作,并且在每个事务过程中 bin log 没有 fsync()
的调用。因此,事务的启动顺序对应写入 binlog 中的顺序。此外,由于内存数据写入磁盘的开销很大,如果频繁 fsync()
把日志数据永久写入磁盘,数据库的性能将会急剧下降。此时 MySQL 提供 sync_binlog 参数来设置在产生多少个 binlog 日志后调用一次 fsync()
,将二进制日志刷新到磁盘,以提高整体性能。
这个问题最早在 2010 年的 MySQL 数据库大会中提出,Facebook MySQL 技术组、Percona 公司都提出过解决方案。最后由 MariaDB 数据库的开发人员 Kristian Nielsen 完成了最终的“完美”解决方案。在这种情况下,不但 MySQL 数据库上层的二进制日志写入是 group commit 的,InnoDB 存储引擎层也是 group commit 的。此外还移除了原先的锁 prepare_commit_mutex
,从而大大提高了数据库的整体性。MySQL 5.6 采用了类似的实现方式,并将其称为 Binary Log Group Commit(BLGC)。
MySQL 5.6 BLGC 的实现方式是将事务提交的过程分为几个步骤来完成,如下图所示。
三阶段提交与队列模型
BLGC 将单次事务提交拆分为三阶段,每阶段各自维护一个队列和对应的处理流程:
- Flush 阶段(Flush queue):事务先将各自的 Binlog 缓存(内存 I/O cache)写入到文件页缓存中,不立即调用 fsync,仅做写操作;
- Sync 阶段(Sync queue):队列领导者统一调用一次 fsync,根据 sync_binlog 设置决定是否持久化到存储介质;如果
sync_binlog=1
,每批次都强制同步; - Commit 阶段(Commit queue):在 InnoDB 层真正执行 group commit,写入 Redo Log 的 commit 标记,并通知各事务完成提交。
Leader / Follower 模式:队列中的第一个事务称为 leader,其他事务称为 follower,leader 控制着 follower 的行为。
具体流程
Flush 阶段
每个事务将自己的 Binlog 事件追加到线程本地的缓存区;
Leader 在此阶段结束时,将所有 follower 的缓存统一写入到 Binlog 文件的页缓存(内存)中,仅触发一次文件页写操作,无 fsync。
Sync 阶段
所有线程进入 Sync 队列,并由 Leader 负责后续操作;
Leader 按 sync_binlog 配置调用 fsync。若 sync_binlog=1,保证每批次提交后强制持久化;若设置大于 1,则每隔 N 次才 fsync;
同步完成后,Leader 释放文件锁并唤醒所有 Follower,确保它们能看到持久化的数据。
Commit 阶段
InnoDB Group Commit:Leader 调用 InnoDB 的 group commit 接口,将事务在存储引擎层的提交日志(commit 重做日志)一次性刷入 Redo Log 文件;
释放事务锁;
所有参与事务的线程被唤醒,返回提交成功状态。
为什么 Binary Log Group Commit 能保证 Binlog 写入顺序与 InnoDB 提交顺序严格一致?
inary Log Group Commit 通过在 Flush、Sync、Commit 三个阶段均使用 FIFO 队列,并由队首的 Leader 顺序处理,保证了先进入队列的事务先被 Flush、先被 Sync、先被 Commit 以及在当前阶段完成后按顺序先被传给下一阶段,从而使 Binlog 的写入顺序与 InnoDB 的提交顺序严格一致。
当有一组事务在进行 Commit 阶段时,其他新事务可以进行 Flush 阶段,从而使 group commit 不断生效。当然 group commit 的效果由队列中事务的数量决定,若每次队列中仅有一个事务,那么可能效果和之前差不多,甚至会更差。但当提交的事务越多时,group commit 的效果越明显,数据库性能的提升也就越大。
参数 binlog_max_flush_queue_time
用来控制 Flush 阶段中等待的时间,即使之前的一组事务完成提交,当前一组的事务也不马上进入 Sync 阶段,而是至少需要等待一段时间。这样做的好处是 group commit 的事务数量更多,然而这也可能会导致事务的响应时间变慢。该参数的默认值为 0,且推荐设置依然为 0。除非用户的 MySQL 数据库系统中有着大量的连接(如 100 个连接),并且不断地在进行事务的写入或更新操作。
锁 和 MVCC => 隔离性
锁的内容在其他部分会提到,这里不赘述。
读操作的类型
当前读:读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。
select ... lock in share mode
, select ... for update
, insert
, delete
都是一种当前读。
快照读:读取的是记录数据的可见版本(可能是任一历史版本),不加锁,非阻塞读。简单的 select(不加锁)就是快照读。
- read committed:每次 select 都生成一个快照读。
- repeatable read:开启事务后执行第一次 select 语句时快照读(产生快照),之后的查询都是读取之前产生的快照。
- serializable:通过快照隔离的读视图被放弃,取而代之的是对最新提交数据的锁定式读取。
MVCC
多版本并发控制:通过一定机制生成一个数据请求时间点的一致性数据快照,并用这个快照来提供一定级别(语句级或事务级)的一致性读取。记录的是某个时间点上的数据快照,用来实现不同事务之间数据的隔离性。
它维护一个数据的多个版本,使得读写操作没有冲突,快照读为 MySQL 实现 MVCC 提供了一个非阻塞读功能。
MVCC 的实现依赖于数据库的每条记录中的三个隐式字段:
DB_TRX_ID
:记录最后一次插入或修改该记录的事务 ID。DB_ROLL_PTR
(回滚指针):指向 undo log 中这条记录的上一个版本。DB_ROW_ID
(隐藏主键):如果表结构未指定主键,将会生成该隐藏字段。
undo log(回滚日志):记录未提交事务,用于事务回滚。
- insert 时产生的回滚日志只在回滚时需要,可在事务提交后被立即删除。
- update 或 delete 时产生的回滚日志不仅在回滚时需要,在快照读时也需要,所以不会被立即删除。
**undo log 版本链:**不同事务或相同事务对同一条记录进行修改,会导致该记录 undo log 生成一条记录版本的链表,链表头部是最新的旧记录,链表尾部是最早的旧记录。
undo log 确实会被删除,但只有在所有可能依赖它的事务都结束之后才会删除。因此,在事务活跃期间,版本链仍然存在,不会立即消失。
ReadView 是快照读 SQL 执行时 MVCC 提取数据的依据,用于确定在特定事务中哪些版本的行记录是可见的,它记录并维护系统当前活跃的未提交事务的 id。ReadView 主要用来处理隔离级别为可重复读和读已提交的情况。因为在这两个隔离级别下,事务在读取数据时,需要保证读取到的数据是一致的,即读取到的数据是在事务开始时的一个快照。包含四个核心字段:
字段 | 含义 |
---|---|
m_ids | 当前活跃的事务 ID 集合 |
min_trx_id | 最小活跃事务 ID |
max_trx_id | 预分配事务 ID(当前最大事务 ID + 1) |
creator_trx_id | ReadView 创建者的事务 ID |
版本链访问机制
trx_id
:特定事务 ID
trx_id == creator_trx_id
:可以访问该版本。trx_id < min_trx_id
:可以访问该版本。trx_id > max_trx_id
:不可以访问该版本。min_trx_id <= trx_id <= max_trx_id
:如果 trx_id 不在 m_ids 中则可以访问该版本。
假设读事务开启了一个 ReadView,这个 ReadView 里面记录了当前活跃事务的 ID 列表(444、555、665),以及最小事务 ID(444)和最大事务 ID(666)。当然还有自己的事务 ID 520,也就是 creator_trx_id
。它要读的这行数据的写事务 ID 是 x,也就是 DB_TRX_ID
。
- 如果 x = 110,显然在 ReadView 生成之前就提交了,所以这行数据是可见的。
- 如果 x = 667,显然是未知世界,所以这行数据对读操作是不可见的。
- 如果 x = 519,虽然 519 大于 444 小于 666,但是 519 不在活跃事务列表里,所以这行数据是可见的。因为 519 是在 520 生成 ReadView 之前就提交了。
- 如果 x = 555,虽然 555 大于 444 小于 666,但是 555 在活跃事务列表里,所以这行数据是不可见的。因为 555 不确定有没有提交。
需要注意的是,不同隔离级别生成 ReadView 的时机不同:
- 可重复读:在第一次读取数据时生成一个 ReadView,这个 ReadView 会一直保持到事务结束,这样可以保证在事务中多次读取同一行数据时,读取到的数据是一致的。
- 读已提交:每次读取数据前都生成一个 ReadView,这样就能保证每次读取的数据都是最新的。
多个事务同时操作同一行会发生什么?
多个事务确实可同时操作同一行,但 MVCC 提供了以下机制来处理并发情况,确保数据一致性:
- 读写并发:当一个事务在对某一行数据进行读取时(读操作),即使其他事务正在对该行数据进行写操作(更新或删除),MVCC 仍然允许该事务读取该行的历史版本数据,而不会与写操作发生冲突。读操作不会阻塞写操作,写操作也不会阻塞读操作,这正是 MVCC 并发控制的核心优势之一。
- 写写并发:当多个事务同时对同一行数据进行写操作时,InnoDB 使用行级锁来控制并发写操作。这意味着,虽然多个事务可以并发读取同一行数据的不同版本,但同一时刻只能有一个事务对该行进行写操作。当一个事务对某一行数据加了排他锁(X 锁),其他事务尝试写入同一行时,会被阻塞,直到该排他锁释放。
- 事务冲突与回滚:如果多个事务在同一行上发生冲突(例如,事务 A 在修改一行数据时,事务 B 也试图修改同一行数据),事务 B 会等待事务 A 完成。当事务 A 提交后,事务 B 才能继续进行。如果事务 A 回滚,那么事务 B 仍然可以继续修改该行,因为回滚后数据恢复到了事务 A 之前的状态。
如果其他三个特性都能够得到保证,那一致性也就能得到保证了。例如:
- 如果一个事务回滚,原子性确保数据库回到之前的状态;
- 隔离性确保事务之间的干扰最小化,避免不一致;
- 持久性确保事务提交后修改不会丢失。
分布式事务
在 MySQL 数据库中,InnoDB 存储引擎提供了对 XA 事务的支持,并通过 XA 事务来实现分布式事务。分布式事务指的是允许多个独立的事务资源(transactional resources)参与到一个全局的事务中。事务资源通常是关系型数据库系统,但也可以是其他类型的资源。全局事务要求其中的所有参与事务要么都提交,要么都回滚,这对事务原有的 ACID 要求提出了更高的要求。此外,在使用分布式事务时,InnoDB 存储引擎的事务隔离级别必须设置为 SERIALIZABLE。
XA 事务允许在不同数据库之间进行分布式事务。比如,一台服务器上运行 MySQL 数据库,另一台服务器上运行 Oracle 数据库,甚至还可能有一台运行 SQL Server 数据库,只要所有参与全局事务的节点都支持 XA 事务即可。分布式事务在银行系统的转账场景中比较常见,例如用户 David 需要从上海的账户转账 10 000 元到北京用户 Mariah 的银行卡中:
1 | # Bank@Shanghai: |
在这种情况下,一定需要使用分布式事务来保证数据的安全性。如果操作不能全部提交或全部回滚,那么任何一个节点出现问题都会导致严重后果:要么 David 的账户被扣款但 Mariah 没收到钱,要么 David 的账户没有扣款但 Mariah 收到钱。
XA 事务由以下三部分组成:
- 资源管理器(Resource Manager):提供访问事务资源的方法。通常一个数据库就是一个资源管理器。
- 事务管理器(Transaction Manager):协调参与全局事务的各个资源管理器,需要与所有参与的资源管理器通信。
- 应用程序(Application Program):定义事务的边界,指定全局事务中的操作。
在 MySQL 的分布式事务中,资源管理器就是 MySQL 数据库,事务管理器通常是连接 MySQL 服务器的客户端。下图展示了一个分布式事务的模型。
分布式事务采用两段式提交(two-phase commit)方法:
- **准备阶段(PREPARE):**所有参与全局事务的节点开始准备,告诉事务管理器它们已经准备好提交。
- **提交/回滚阶段(COMMIT or ROLLBACK):**事务管理器根据各节点的准备情况,通知资源管理器执行 COMMIT 还是 ROLLBACK。如果任何一个节点无法提交,则所有节点都被告知回滚。
与本地事务相比,分布式事务多了一次 PREPARE 操作:只有在收到所有节点同意准备的信息后,才执行最终的 COMMIT 或 ROLLBACK。
MySQL 数据库中 XA 事务的 SQL 语法如下:
1 | XA {START|BEGIN} xid [JOIN|RESUME] |
在单个节点上运行分布式事务意义不大;通常需要通过编程语言来驱动多个节点的分布式事务操作。
内部 XA 事务
之前讨论的分布式事务是外部事务,即资源管理器是 MySQL 数据库本身。在 MySQL 数据库中还存在另外一种分布式事务,其在存储引擎与插件之间,又或者在存储引擎与存储引擎之间,称之为内部 XA 事务。
最常见的内部 XA 事务存在于 binlog 与 InnoDB 存储引擎之间。由于复制的需要,因此目前绝大多数的数据库都开启了 binlog 功能。在事务提交时,先写二进制日志,再写 InnoDB 存储引擎的重做日志。对上述两个操作的要求也是原子的,即二进制日志和重做日志必须同时写入。若二进制日志先写了,而在写入 InnoDB 存储引擎时发生了宕机,那么 slave 可能会接收到 master 传过去的二进制日志并执行,最终导致主从不一致的情况。如下图所示。
在上图中,如果执行完 ①、② 后在步骤 ③ 之前 MySQL 数据库发生了宕机,则会发生主从不一致的情况。为了解决这个问题,MySQL 数据库在 binlog 与 InnoDB 存储引擎之间采用 XA 事务。当事务提交时,InnoDB 存储引擎会先做一个 PREPARE 操作,将事务的 xid 写入,接着进行二进制日志的写入,如下图所示。如果在 InnoDB 存储引擎提交前,MySQL 数据库宕机了,那么 MySQL 数据库在重启后会先检查准备的 UXID 事务是否已经提交,若没有,则在存储引擎层再进行一次提交操作。
不好的事务习惯
在循环中提交
开发人员可能会在循环中进行事务的提交,如下(可想象成 Java 中的某个方法):
1 | CREATE PROCEDURE load1(count INT UNSIGNED) |
其实,在上述的例子中,是否加上提交命令 COMMIT 并不关键。因为 InnoDB 存储引擎默认是自动提交,所以在上述的存储过程中去掉 COMMIT,结果其实是完全一样的。
上面的存储过程存在一个问题,当发生错误时,数据库会停留在一个未知的位置。例如,用户需要插入 10000 条记录,但是在插入 5000 条时,发生了错误,此时前 5000 条记录已经存放在数据库中,那应该怎么处理呢?另一个问题是性能问题,上面两个存储过程都不会比下面的存储过程 load2 快,因为下面的存储过程将所有的 INSERT 都放在一个事务中:
1 | CREATE PROCEDURE load3(count INT UNSIGNED) |
也就是说,因为每一次提交都要写一次重做日志,存储过程 load1 和 load2 实际写了 10000 次重做日志文件,而对于存储过程 load3 来说,实际上只写了 1 次。
使用自动提交
MySQL 中默认启用 autocommit 模式,意味着每条 DML 语句会被视作单独的事务并在执行后立即提交。这虽然降低了使用门槛,但也容易在批量操作或存储过程中导致不可控的中间状态,并引发性能和一致性问题。通过关闭 autocommit(SET autocommit=0
)或显式使用 START TRANSACTION/BEGIN 来管理事务,可以将多条操作放入同一个事务,从而在发生错误时统一回滚,并显著减少重做日志的写入次数。不同客户端 API(如 C API、Python API)对 autocommit 的默认行为各不相同,应用开发时需特别留意并在程序端明确控制事务边界。
使用自动回滚
在存储过程中,通过 DECLARE EXIT HANDLER FOR SQLEXCEPTION ROLLBACK;
定义了一个针对任意 SQL 异常的退出型处理器,一旦发生错误便自动执行 ROLLBACK,无需显式再调用回滚语句。虽然自动回滚保证了数据一致性,但存储过程本身并不会向调用者返回错误信息。所以我们应将事务控制下放到应用程序一侧,既能保证回滚,也能捕获数据库抛出的错误编码与描述。