0%

一组 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 结束,其间的操作是原子的,要么都执行,要么都回滚。因此扁平事务是应用程序成为原子操作的基本组成模块。下图显示了扁平事务的三种不同结果。

img

扁平事务的主要限制是不能提交或者回滚事务的某一部分,或分几个步骤提交。

例如用户在旅行网站上进行自己的旅行度假计划。用户想从南昌到洛杉矶的圣莫妮卡,这两个城市之间没有直达的班机,需要用户预订并转乘航班,或者需要搭火车等待。用户预订旅行度假的事务为:

1
2
3
4
5
6
7
8
9
BEGIN 

S1:预订南昌到上海的高铁。

S2:上海浦东国际机场坐飞机,预订去洛杉矶的航班。

S3:在洛杉矶打 Uber 前往圣莫妮卡。

COMMIT

但是当用户执行到 S3 时,发现由于飞机到达洛杉矶的时间太晚,打不到 Uber 了。这时用户希望在 LAX 附近住一晚,第二天出发去圣莫妮卡。这时如果事务为扁平事务,则需要回滚之前 S1、S2、S3 的三个操作,这个代价就显得有点大。因为当再次进行该事务时,S1、S2 的执行计划是不变的。也就是说,如果支持有计划的回滚操作,那么就不需要终止整个事务。因此就出现了带有保存点的扁平事务。

带有保存点的扁平事务除了支持扁平事务支持的操作外,允许在事务执行过程中回滚到同一事务中较早的一个状态。这是因为某些事务可能在执行过程中出现的错误并不会导致所有的操作都无效,放弃整个事务不合乎平衡,也开销太大。保存点(Savepoint)用来通知系统应该记住事务当前的状态,以便当之后发生错误时,事务能回到保存点当时的状态。

对于扁平的事务来说,其隐式地设置了一个保存点。然而在整个事务中,只有这一个保存点,因此,回滚只能回滚到事务开始时的状态。保存点用 SAVE WORK 函数来建立,通知系统记录当前的处理状态。当出现问题时,保存点能用作内部的重启动点,根据应用逻辑,决定是回到最近一个保存点还是其他更早的保存点。下图显示了在事务中使用保存点。

img

上图中,灰色背景部分的操作表示由 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)。这意味着当进行恢复时,事务需要从头开始重新执行,而不能从最近的一个保存点继续执行。

链事务的思想是:在提交一个事务时,释放不需要的数据对象,将必要的处理上下文隐式地传给下一个要开始的事务。注意,提交事务操作和开始下一个事务操作将合并为一个原子操作。这意味着下一个事务将看到上一个事务的结果,就好像在一个事务中进行的一样。下图显示了链事务的工作方式:

img

上图中,第一个事务提交触发第二个事务的开始。

链事务与带有保存点的扁平事务不同的是,带有保存点的扁平事务能回滚到任意正确的保存点。而链事务中的回滚仅限于当前事务,即只能恢复到最近一个的保存点,也就是说,每个子事务刚开始时,系统会“隐式地”在那个点打一个恢复点,但你没有机会在同一个子事务里再自己开第二个、第三个保存点。

对于锁的处理,两者也不相同。带保存点的扁平事务开始后,一直占有它所申请的所有行锁、表锁(或者更高级别的锁),即便你中途用 ROLLBACK TO SAVEPOINT 回滚到某个保存点,也只是撤销了回滚点之后的逻辑修改,但不会释放任何锁。而链事务中,每当一个子事务执行 COMMIT,它所持有的锁就立即释放,数据库中其它会话或下一个子事务都可以并发访问那些之前被锁定的资源。

嵌套事务将一个大事务组织成“事务树”——顶层事务(parent)控制若干子事务(child),子事务内又可有更深层的子事务,直到叶子节点。叶子节点的事务是真正向数据库发起操作的“扁平事务”,上层事务只负责逻辑控制与协调。

提交与生效

  • 子事务提交:仅把该子事务状态标记为“准备就绪”,真正对外生效要等到其所有祖先(尤其是顶层事务)都提交后才算完成。
  • 父事务提交:向下统一提交整棵事务树,所有子事务的修改才真正持久化。

回滚某一事务节点,会递归地回滚其所有子孙事务。因此,只有顶层事务具备全局回滚能力,子事务无法独立保留已提交的修改。

嵌套事务可以选择性地继承父事务的锁,也可以通过“反向继承”让父事务获得子事务锁。不同子事务可对同一资源持有不同级别的锁;上层事务在其自身提交前,锁不会被释放。

保存点可模拟嵌套回滚的灵活性(任意回到某个保存点),但无法在锁继承、并行执行等方面还原嵌套事务的精细控制。若需要子事务并行执行或精确的锁传递,就必须由系统原生支持嵌套事务。

分布式事务通常是一个在分布式环境下运行的扁平事务,因此需要根据数据库所在位置访问网络中的不同节点。

假设一个用户在 ATM 机进行银行的转账操作,例如持卡人从招商银行的储蓄卡转账 10 000 元到工商银行的储蓄卡。在这种情况下,可以将 ATM 机视为节点 A,招商银行的后台数据库视为节点 B,工商银行的后台数据库视为节点 C,这个转账的操作可分解为以下步骤:

  1. 节点 A 发出转账命令。
  2. 节点 B 执行储蓄卡中的余额值减去 10 000。
  3. 节点 C 执行储蓄卡中的余额值加上 10 000。
  4. 节点 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 操作。因此要显式地开启一个事务需使用命令 BEGINSTART 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 TRANSACTIONBEGIN 语句都可以在 MySQL 命令行下显式地开启一个事务。但是在存储过程中,MySQL 数据库的解析器会自动将 BEGIN 识别为 BEGIN … END,因此在存储过程中只能使用 START TRANSACTION 语句来开启一个事务。

COMMITCOMMIT WORK 语句基本一致,都是用来提交事务。不同之处在于 COMMIT WORK 用来控制事务结束后的行为是 CHAIN 还是 RELEASE。如果是 CHAIN 方式,那么事务就变成了链事务。

用户可以通过参数 completion_type 来进行控制,该参数默认为 0,表示没有任何操作。在这种设置下 COMMITCOMMIT WORK 是完全等价的。当参数 completion_type 的值为 1 时,COMMIT WORK 等同于 COMMIT AND CHAIN,表示马上自动开启一个相同隔离级别的事务,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
mysql> CREATE TABLE t ( a INT, PRIMARY KEY (a) ) ENGINE=INNODB;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT @@autocommit\G;
*************************** 1. row ***************************
@@autocommit: 1
1 row in set (0.00 sec)

mysql> SET @@completion_type=1;
Query OK, 0 rows affected (0.00 sec)

mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)

mysql> INSERT INTO t SELECT 1;
Query OK, 1 row affected (0.00 sec)
Records: 1 Duplicates: 0 Warnings: 0

mysql> COMMIT WORK;
Query OK, 0 rows affected (0.01 sec)

mysql> INSERT INTO t SELECT 2;
Query OK, 1 row affected (0.00 sec)
Records: 1 Duplicates: 0 Warnings: 0

mysql> INSERT INTO t SELECT 2;
ERROR 1062 (23000): Duplicate entry '2' for key 'PRIMARY'

mysql> ROLLBACK;
Query OK, 0 rows affected (0.00 sec)

# 注意回滚之后只有 1 这一个记录,而没有 2 这两个记录

mysql> SELECT * FROM t\G;
*************************** 1. row ***************************
a: 1
1 row in set (0.00 sec)

在这个示例中我们设置 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
mysql> SET @@completion_type=2;
Query OK, 0 rows affected (0.00 sec)

mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)

mysql> INSERT INTO t SELECT 3;
Query OK, 1 row affected (0.00 sec)
Records: 1 Duplicates: 0 Warnings: 0

mysql> COMMIT WORK;
Query OK, 0 rows affected (0.01 sec)

mysql> SELECT @@version\G;
ERROR 2006 (HY000): MySQL server has gone away
No connection. Trying to reconnect...
Connection id: 54
Current database: test
*************************** 1. row ***************************
@@version: 5.1.45-log
1 row in set (0.00 sec)

在这个例子中,设置了 completion_type=2 后,执行 COMMIT WORK 不仅提交了事务,而且释放(关闭)了当前连接。重新执行任何查询时,客户端都会发现与服务器的连接已断开,并尝试重新连接。

SAVEPOINT 记录了一个保存点,可以通过 ROLLBACK TO SAVEPOINT 来回滚到某个保存点,但是如果回滚到一个不存在的保存点,会抛出异常:

1
2
3
4
5
mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)

mysql> ROLLBACK TO SAVEPOINT t1;
ERROR 1305 (42000): SAVEPOINT t1 does not exist

InnoDB 存储引擎中的事务都是原子的,这说明下述两种情况:构成事务的每条语句要么都提交(持久化),要么所有语句都回滚。这种保护还延伸到单个语句——一条语句要么完全成功,要么完全回滚(注意,这里说的是语句级回滚)。因此当一条语句失败并抛出异常时,并不会导致先前已经执行的语句自动回滚。所有已执行的操作都将保留,必须由用户自己决定是否对其进行提交或回滚。示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
mysql> CREATE TABLE t (a INT, PRIMARY KEY(a)) ENGINE=INNODB;
Query OK, 0 rows affected (0.00 sec)

mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)

mysql> INSERT INTO t SELECT 1;
Query OK, 1 row affected (0.00 sec)
Records: 1 Duplicates: 0 Warnings: 0

mysql> INSERT INTO t SELECT 1;
ERROR 1062 (23000): Duplicate entry '1' for key 'PRIMARY'

mysql> SELECT * FROM t\G;
*************************** 1. row ***************************
a: 1
1 row in set (0.00 sec)

可以看到,当插入第二条记录 1 时,由于主键重复抛出了 1062 错误,但数据库并没有自动回滚事务;此时事务仍处于活动状态,必须由用户显式地执行 COMMITROLLBACK 命令来结束事务。

另一个容易混淆的地方是 ROLLBACK TO SAVEPOINT——虽然它包含 “ROLLBACK”,但并不能真正结束整个事务。因此,即使执行了 ROLLBACK TO SAVEPOINT,之后仍需显式地运行 COMMITROLLBACK 才能完成事务。具体示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
mysql> CREATE TABLE t ( a INT, PRIMARY KEY(a) ) ENGINE=INNODB;
Query OK, 0 rows affected (0.00 sec)

mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)

mysql> INSERT INTO t SELECT 1;
Query OK, 1 row affected (0.00 sec)
Records: 1 Duplicates: 0 Warnings: 0

mysql> SAVEPOINT t1;
Query OK, 0 rows affected (0.00 sec)

mysql> INSERT INTO t SELECT 2;
Query OK, 1 row affected (0.00 sec)
Records: 1 Duplicates: 0 Warnings: 0

mysql> SAVEPOINT t2;
Query OK, 0 rows affected (0.00 sec)

mysql> RELEASE SAVEPOINT t1;
Query OK, 0 rows affected (0.00 sec)

mysql> INSERT INTO t SELECT 2;
ERROR 1062 (23000): Duplicate entry '2' for key 'PRIMARY'

mysql> ROLLBACK TO SAVEPOINT t2;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT * FROM t;
+---+
| a |
+---+
| 1 |
| 2 |
+---+
2 rows in set (0.00 sec)

mysql> ROLLBACK;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT * FROM t;
Empty set (0.00 sec)

可以看到,在上面的例子中,虽然在发生重复错误后用户通过 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
    21
    ALTER 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
    6
    CREATE USER
    DROP USER
    GRANT
    RENAME USER
    REVOKE
    SET PASSWORD
  • 管理语句:

    1
    2
    3
    4
    5
    6
    ANALYZE 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 TABLEALTER TABLE 等,会修改数据库的元数据(数据字典)——表结构、索引信息、权限信息……这些修改必须在干净的事务边界上进行,否则如果与正在进行的事务混合,元数据和事务日志就可能出现不一致。因此,服务器在处理 DDL 之前,会先隐式地提交(COMMIT)当前事务,确保所有已做的数据修改都已持久化且可见;DDL 执行完毕后,又会隐式提交,以将新的元数据生效并释放所有表级锁。

存储引擎锁与全局锁

DDL 通常需要获取表级甚至元数据级的全局锁(例如 metadata lock),而 InnoDB 行事务的 MVCC、锁粒度等是在行和页级别管理的。为了避免长事务与元数据锁冲突,MySQL 设计为:

  • DDL 前自动提交,这样旧事务内的锁都释放掉,DDL 能顺利抢到元数据锁;
  • DDL 后自动提交,这样新事务再去访问时就能看到最新结构。

隐式提交的实现机制

在 MySQL 源码里,SQL 解析器(SQL layer)会为特定的语句类型(DDL、某些管理语句)打上 autocommit 标志或直接在执行函数里调用 thd->commit():

1
2
3
4
5
6
7
8
9
// 伪代码,执行 DDL 时 

if (statement_is_ddl(stmt)) {

thd->commit(); // 隐式提交前一个事务
execute_ddl(stmt); // 改变元数据
thd->commit(); // 隐式提交以生效并释放锁

}

对于 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_commitcom_rollback 这两个状态变量中。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
mysql> SHOW GLOBAL STATUS LIKE 'com_commit'\G

*************************** 1. row ***************************
Variable_name: Com_commit
Value: 5
1 row in set (0.00 sec)

mysql> INSERT INTO t SELECT 3;
Query OK, 1 row affected (0.00 sec)
Records: 1 Duplicates: 0 Warnings: 0

mysql> SELECT * FROM t\G
*************************** 1. row ***************************
a: 1
*************************** 2. row ***************************
a: 2
*************************** 3. row ***************************
a: 3
3 rows in set (0.00 sec)

mysql> SHOW GLOBAL STATUS LIKE 'com_commit'\G
*************************** 1. row ***************************
Variable_name: Com_commit
Value: 5
1 row in set (0.00 sec)

可以看到,尽管插入了第三条记录,但 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 数据库发生宕机而操作系统不发生宕机时,并不会导致事务的丢失;而当操作系统宕机时,重启数据库后会丢失未从文件系统缓存刷新到重做日志文件的那部分事务。

img

在 MySQL 数据库中还有一种二进制日志(binlog),其用来进行 POINT-IN-TIME(PIT)的恢复及主从复制(Replication)环境的建立。从表面上看其和重做日志非常相似,都是记录了对于数据库操作的日志。然而,从本质上来看,两者有着非常大的不同。首先,重做日志是在 InnoDB 存储引擎层产生,而二进制日志是在 MySQL 数据库的上层产生的,并且二进制日志不仅仅针对 InnoDB 存储引擎,MySQL 数据库中的任何存储引擎对于数据库的更改都会产生二进制日志。

其次,两种日志记录的内容形式不同。MySQL 数据库上层的二进制日志是一种逻辑日志,其记录的是对应的 SQL 语句。而 InnoDB 存储引擎层面的重做日志是物理格式日志,其记录的是对于每个页的修改。

此外,两种日志记录写入磁盘的时间点不同,如下图所示。二进制日志只在事务提交完成后进行一次写入。而 InnoDB 存储引擎的重做日志在事务进行中不断地被写入,这表现为日志并不是随事务提交的顺序进行写入的。

img

从上图中可以看到,二进制日志仅在事务提交时记录,并且对于每一个事务,仅包含对应事务的一个日志。而对于 InnoDB 存储引擎的重做日志,由于其记录的是物理操作日志,因此每个事务对应多个日志条目,并且事务的重做日志写入是并发的,并非在事务提交时写入,故其在文件中记录的顺序并非事务开始的顺序。*T1、*T2、*T3 表示的是事务提交时的日志。

log block

在 InnoDB 存储引擎中,重做日志都是以 512 字节进行存储的。这意味着重做日志缓冲、重做日志文件都是以块的方式进行保存的,称之为重做日志块,每块的大小为 512 字节。

若一个页中产生的重做日志数量大于 512 字节,那么需要分割为多个重做日志块进行存储。此外,由于重做日志块的大小和磁盘扇区大小一样,都是 512 字节,因此重做日志的写入可以保证原子性,不需要 doublewrite 技术。

重做日志除了日志本身之外,还由日志块头及日志块尾两部分组成。重做日志块头一共占用 12 字节,重做日志块尾占用 8 字节。故每个重做日志块实际可以存储的大小为 492 字节(512 – 12 – 8)。下图显示了重做日志块缓冲的结构。

img

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 中的情况如下图所示。
img

可以观察到,由于事务 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 之间的关系如下图所示。

img

在 log file header 后面的部分为 InnoDB 存储引擎保存的 checkpoint(检查点)值,其设计是交替写入,这样的设计避免了因介质失败导致无法找到可用的 checkpoint 的情况。

具象一点就是,第一次写 checkpoint 时,写入 Slot A;下一次写 checkpoint 时,写入 Slot B;再下一次,又回到写 Slot A……如此循环。

为什么要这样做?

  • 避免写一半后丢失:如果只有一个槽,刚写入还没刷完,突然停电或磁盘故障,这个槽可能只写了一半,里面的值就坏了。
  • 保证总有一个有效副本:交替写入意味着每次只有一个槽被覆盖,另一个槽保存的是上一次写入的、完整且可靠的检查点值。即便一个槽在写入时被破坏,另外一个槽里还有上一次的有效值。

恢复时的处理
在数据库启动恢复阶段,InnoDB 会读取这两个槽的内容,选择最新且完整的那个作为真正的 checkpoint,从那里开始重做日志的回放,保证不会因为写损坏而找不到有效的检查点。

重做日志格式

不同的数据库操作会有对应的重做日志格式。此外,由于 InnoDB 存储引擎的存储管理是基于页的,故其重做日志格式也是基于页的。虽然有着不同的重做日志格式,但它们具有通用的头部格式,如下图所示:

img

通用头部格式由以下 3 部分组成:

  1. redo_log_type:重做日志的类型。
  2. space:表空间的 ID。
  3. page_no:页的偏移量。

之后的 redo log body 的部分,根据重做日志类型的不同,会有不同的存储内容。例如,对于页上记录的插入和删除操作,分别对应下图所示的格式:

img

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
2
3
4
5
6
7
8
9
10
mysql> SHOW ENGINE INNODB STATUS\G

--- LOG ---
Log sequence number 113047174608
Log flushed up to 113047174608
Last checkpoint at 113047174608
0 pending log writes, 0 pending chkp writes
142 log i/o's done, 0.00 log i/o's/second

1 row in set (0.00 sec)

以上结果的参数如下:

Log sequence number:当前的 LSN

Log flushed up to:已刷新到重做日志文件的 LSN

Last checkpoint at:已同步到磁盘的 LSN

虽然在上面的例子中,Log sequence number 和 Log flushed up to 的值是相同的,但是在实际生产环境中,该值有可能是不同的。因为在一个事务中从日志缓冲刷新到重做日志文件并不只是在事务提交时发生,每秒都会有从日志缓冲刷新到重做日志文件的动作。下面是在生产环境下重做日志的信息的示例。

1
2
3
4
5
6
7
8
9
10
mysql> SHOW ENGINE INNODB STATUS\G

--- LOG ---
Log sequence number 203318213447
Log flushed up to 203318213326
Last checkpoint at 203252831194
1 pending log writes, 0 pending chkp writes
103447 log i/o's done, 7.00 log i/o's/second

1 row in set (0.00 sec)

所以,redo log 的刷盘时机有以下 4 种:

  1. 提交时:保证事务持久性;
  2. 超时周期:(默认 1s)定时写入,防止长时间积压;
  3. 缓冲区半满:防止 buffer 溢出;
  4. 检查点:保证恢复一致性。

恢复

InnoDB 存储引擎在启动时,不管上次数据库运行时是否正常关闭,都会尝试进行恢复操作。因为重做日志记录的是物理日志,因此恢复的速度比逻辑日志(如二进制日志)要快很多。与此同时,InnoDB 存储引擎自身也对恢复进行了优化,例如顺序读取和并行应用重做日志,这样可以进一步提高数据库恢复的速度。

由于 checkpoint 表示已刷新到磁盘页上的 LSN,因此在恢复过程中仅需应用从 checkpoint 开始的日志部分。

假设数据库崩溃时,checkpoint 的 LSN 为 10000;重做日志当前已写到 LSN 13000;那么恢复时只需重做 LSN 10000 到 13000 范围内的日志。

img

InnoDB 存储引擎的重做日志是物理日志,因此其恢复速度较之二进制日志恢复快得多。例如对于 INSERT 操作,其记录的是每个页上的变化。对于下面的表:

1
2
3
4
5
6
CREATE TABLE t (
a INT,
b INT,
PRIMARY KEY(a),
KEY(b)
);

若执行 SQL 语句:

1
INSERT INTO t SELECT 1, 2;

由于需要对聚集索引页和辅助索引页进行操作,其记录的重做日志大致为:

1
2
3
page(2,3), offset 32, value 1,2  # 聚集索引

page(2,4), offset 64, value 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 做进一步的设置。这些参数包括:

  1. innodb_undo_directory
  2. innodb_undo_logs
  3. 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
mysql> SHOW VARIABLES LIKE 'innodb_undo%';
+-------------------------+----------------+
| Variable_name | Value |
+-------------------------+----------------+
| innodb_undo_directory | . |
| innodb_undo_logs | 128 |
| innodb_undo_tablespaces | 3 |
+-------------------------+----------------+
3 rows in set (0.00 sec)

mysql> SHOW VARIABLES LIKE 'datadir';
+---------------+---------------------------------+
| Variable_name | Value |
+---------------+---------------------------------+
| datadir | /Users/david/mysql_data/data/ |
+---------------+---------------------------------+
1 row in set (0.00 sec)

mysql> system ls -lh /Users/david/mysql_data/data/undo*
-rw-rw---- 1 david staff 10M 11 22 16:55 /Users/david/mysql_data/data/undo001
-rw-rw---- 1 david staff 10M 11 22 16:51 /Users/david/mysql_data/data/undo002
-rw-rw---- 1 david staff 10M 11 22 16:51 /Users/david/mysql_data/data/undo003

需要特别注意的是,事务在 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 可以安全丢弃),它会按链表顺序走:

  1. 找到这条事务的第一个 undo 记录在链表里的位置。
  2. 沿着链表往下走,一条一条检查:这条记录是不是该事务的?如果是,就删掉;如果不是,就跳过。
  3. 对应的,每读一条 undo 记录,就要去读它所在的那页(undo page)——而这些记录分散在不同的页面上。

undo log 格式

在 InnoDB 存储引擎中,undo log 分为:

  1. insert undo log;
  2. 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 操作时,根据这些值可以定位到具体的记录,然后进行删除即可。

img

update undo log 记录的是对 delete 和 update 操作产生的 undo log。该 undo log 可能需要提供 MVCC 机制,因此不能在事务提交时就进行删除。提交时放入 undo log 链表,等待 purge 线程进行最后的删除。update undo log 的结构如下图所示。

img

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
2
3
4
5
6
7
8
9
mysql> SELECT segment_id, space, page_no
-> FROM INNODB_TRX_ROLLBACK_SEGMENT;
| segment_id | space | page_no |
|------------|-------|---------|
| 0 | 0 | 6 |
| 1 | 0 | 45 |
| 2 | 0 | 46 |
| ... | ... | ... |
128 rows in set (0.00 sec)

另一张数据字典表为 INNODB_TRX_UNDO,用来记录事务对应的 undo log,方便开发人员详细了解每个事务产生的 undo 量。下面将演示如何使用 INNODB_TRX_UNDO 表。首先根据如下代码创建测试表。

1
2
3
4
5
6
CREATE TABLE t (
a INT,
b VARCHAR(32),
PRIMARY KEY(a),
KEY(b)
) ENGINE=InnoDB;

接着插入一条记录,并尝试通过 INNODB_TRX_UNDO 观察该事务的 undo log 情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)

mysql> INSERT INTO t SELECT 1,'1';
Query OK, 1 row affected (0.00 sec)
Records: 1 Duplicates: 0 Warnings: 0

mysql> SELECT * FROM information_schema.INNODB_TRX_UNDO\G
*************************** 1. row ***************************
trx_id: 3001
rseg_id: 2
undo_rec_no: 0
undo_rec_type: TRX_UNDO_INSERT_REC
size: 12
space: 0
page_no: 334
offset: 272
1 row in set (0.00 sec)

通过数据字典表可以看到,事务 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
2
3
4
5
6
7
8
9
10
11
01 1c   # 下一个 undo log 的偏移 272+12=0x011c 

0b # undo log 的类型,TRX_UNDO_INSERT_REC 为 11

00 # undo log 的记录,等同于 undo_rec_no

16 04 # 表的 ID

80 00 00 01 # 主键的长度

01 01 10 # 主键的内容

此外,由于知道该 undo log 所在的 rollback segment 的 ID 为 2,用户还可以通过数据字典表 INNODB_TRX_ROLLBACK_SEGMENT 来查看当前 rollback segment 的信息,如:

1
2
3
4
5
6
7
8
mysql> SELECT segment_id, insert_undo_list, insert_undo_cached
-> FROM information_schema.INNODB_TRX_ROLLBACK_SEGMENT
-> WHERE segment_id=2\G
*************************** 1. row ***************************
segment_id: 2
insert_undo_list: 1
insert_undo_cached: 0
1 row in set (0.00 sec)

可以看到 insert_undo_list 为 1。若这时进行事务的 COMMIT 操作,再查看该数据字典表:

1
2
3
4
5
6
7
8
9
10
11
mysql> COMMIT;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT segment_id, insert_undo_list, insert_undo_cached
-> FROM information_schema.INNODB_TRX_ROLLBACK_SEGMENT
-> WHERE segment_id=2\G
*************************** 1. row ***************************
segment_id: 2
insert_undo_list: 0
insert_undo_cached: 1
1 row in set (0.00 sec)

可以发现,insert_undo_list 变为 0,而 insert_undo_cached 增加为 1。这就是前面所介绍的 undo 页重用。下次再有事务需要向该 rollback segment 申请 undo 页时,可以直接使用该页。

接着再来观察 delete 操作产生的 undo log。进行如下操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)

mysql> DELETE FROM t WHERE a=1;
Query OK, 1 row affected (0.00 sec)
Records: 1 Duplicates: 0 Warnings: 0

mysql> SELECT * FROM information_schema.INNODB_TRX_UNDO\G
*************************** 1. row ***************************
trx_id: 3201
rseg_id: 2
undo_rec_no: 0
undo_rec_type: TRX_UNDO_DEL_MARK_REC
size: 37
space: 0
page_no: 326
offset: 620
1 row in set (0.00 sec)

用上述同样的方法定位到页 326,偏移量为 620 的位置,得到如下结果:

1
2
3
4
5
6
7
0518260 00 00 00 00 00 00 00 00 00 00 00 02 91 0e 00 

0518270 16 00 00 00 30 01 e0 82 00 00 01 4e 01 10 04

0518280 80 00 00 01 0c 31 01 02 01 03 01 31 02 6c 00

0518290 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00

接着开始整理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
02 91   # 下一个 undo log 开始位置的偏移量 

0e # undo log 类型,TRX_UNDO_DEL_MARK_REC 为 14

16 00 # undo no

00 # table id

00 00 30 01 # info bits

e0 # rec 事务 id

82 00 00 01 # rec 回滚指针

04 # 主键长度

80 00 00 01 # 主键值

0b # 之后部分的长度

04 # 列的位置

80 00 00 01 # 列的值

03 # 列的位置,前 00~02 为系统列

01 # 列的长度

31 # 列 b 插入的字符 ‘1’ 的十六进制

02 6c # 开始位置的偏移量

观察 rollback segment 信息,可以看到:

1
2
3
4
5
6
7
8
mysql> SELECT segment_id, update_undo_list, update_undo_cached
-> FROM information_schema.INNODB_TRX_ROLLBACK_SEGMENT
-> WHERE segment_id=2\G
*************************** 1. row ***************************
segment_id: 2
update_undo_list: 1
update_undo_cached: 0
1 row in set (0.00 sec)

同样的,在事务提交后,undo 页会放入 cache 列表以供下次重用:

1
2
3
4
5
6
7
8
9
10
11
mysql> COMMIT;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT segment_id, update_undo_list, update_undo_cached
-> FROM information_schema.INNODB_TRX_ROLLBACK_SEGMENT
-> WHERE segment_id=2\G
*************************** 1. row ***************************
segment_id: 2
update_undo_list: 0
update_undo_cached: 1
1 row in set (0.00 sec)

通过上面的例子可以看到,delete 操作并不直接删除记录,只是将记录标记为已删除,也就是将记录的 delete flag 设置为 1,而记录最终的删除是在 purge 操作中完成的。

最后来看 update 操作产生的 undo log 情况。首先再次插入记录 (1, '1'),然后进行 update 操作,同时通过数据字典表 INNODB_TRX_UNDO 观察 undo log 的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
mysql> INSERT INTO t SELECT 1,'1';
Query OK, 1 row affected (0.00 sec)

mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)

mysql> UPDATE t SET b='2' WHERE a=1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0

mysql> SELECT * FROM information_schema.INNODB_TRX_UNDO\G
*************************** 1. row ***************************
trx_id: 3205
rseg_id: 5
undo_rec_no: 0
undo_rec_type: TRX_UNDO_UPD_EXIST_REC
size: 41
space: 0
page_no: 318
offset: 724
1 row in set (0.00 sec)

用上述同样的方法定位到页 318,偏移量为 724 的位置,得到如下结果:

1
2
3
4
5
04f82d0 00 00 00 00 02 fd 0c 00 16 00 00 00 00 32 04 e0 

04f82e0 84 00 00 01 48 01 10 04 80 00 00 01 01 03 01 31

04f82f0 00 0b 00 04 80 00 00 01 03 01 31 02 d4 00 00 00

整理后得到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
02 fd   # 下一个 undo log 开始位置 

0c # undo log 类型,TRX_UNDO_UPD_EXIST_REC 为 12

00 # undo no

16 00 # table id

00 00 00 32 # info bits

04 e0 # rec trx id

84 00 00 01 # rec 回滚指针

48 01 10 00 # 主键长度及主键值

01 03 01 31 # update_vector 的数目及各列编号和值

0b # 接下去部分占用的字节

00 … # 后续其他列的 old values(略)

上述的例子是更新一个非主键值,若更新的对象是一个主键值,那么其产生的 undo log 完全不同,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
mysql> ROLLBACK;
Query OK, 1 row affected (0.00 sec)

mysql> UPDATE t SET a=2 WHERE a=1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0

mysql> SELECT * FROM information_schema.INNODB_TRX_UNDO
-> ORDER BY undo_rec_no\G
*************************** 1. row ***************************
trx_id: 320F
rseg_id: 11
undo_rec_no: 0
undo_rec_type: TRX_UNDO_DEL_MARK_REC
size: 37
space: 0
page_no: 324
offset: 492
*************************** 2. row ***************************
trx_id: 320F
rseg_id: 11
undo_rec_no: 1
undo_rec_type: TRX_UNDO_INSERT_REC
size: 12
space: 0
page_no: 336
offset: 272
2 rows in set (0.00 sec)

可以看到,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 logTRX_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 还被其他事务引用。

img

在执行 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 存储引擎来说,事务提交时会进行两个阶段的操作:

  1. 修改内存中事务对应的信息,并且将日志写入重做日志缓冲。
  2. 调用 fsync 将确保日志都从重做日志缓冲冲写入磁盘。

步骤 2)相对于步骤 1)是一个较慢的过程,这是因为存储引擎需要与磁盘打交道。但当有事务进行这个过程时,其他事务可以进行步骤 1)的操作,正在提交的事务完成提交操作后,再次进行步骤 2)时,可以将多个事务的重做日志通过一次 fsync 刷新到磁盘,这样就大大地减少了磁盘的压力,从而提高了数据库的整体性能。对于写入或更新较为频繁的操作,group commit 的效果尤为明显。

然而在 InnoDB 1.2 版本之前,在开启二进制日志后,InnoDB 存储引擎的 group commit 功能会失效,从而导致性能的下降。并且在在线环境多使用 replication 环境,因此二进制日志的选项基本都为开启状态,因此这个问题尤为显著。

导致这个问题的原因是在开启二进制日志后,为了保证存储引擎层中的事务和二进制日志的一致性,二者之间使用了两阶段事务,其步骤如下:

  1. 当事务提交时 InnoDB 会将事务的 prepare 记录写入 redo 日志缓冲并持久化到磁盘。
  2. MySQL 数据库上层写入二进制日志。
  3. InnoDB 存储引擎层将日志写入重做日志文件。
    1. 修改内存中事务对应的信息,并且将日志写入重做日志缓冲。
    2. 调用 fsync 将确保日志都从重做日志缓冲冲写入磁盘。

一旦步骤 2)中的操作完成,就确保了事务的提交,即使在执行步骤 3)时数据库发生了宕机。此外需要注意的是,每个步骤都需要进行一次 fsync 操作才能保证上下两层数据的一致性。步骤 2)的 fsync 由参数 sync_binlog 控制,步骤 3)的 fsync 由参数 innodb_flush_log_at_trx_commit 控制。因此上述整个过程如下图所示。

img

为了保证 MySQL 数据库上层二进制日志的写入顺序和 InnoDB 层的事务提交顺序一致,MySQL 数据库内部使用了 prepare_commit_mutex 这个锁。但是在启用这个锁之后,步骤 3)中的步骤 a)不可在其他事务执行步骤 b)时进行,从而导致了 group commit 失效。

然而,为什么需要保证 MySQL 数据库上层二进制日志的写入顺序和 InnoDB 层的事务提交顺序一致?

  1. 崩溃恢复一致性

    如果 Binlog 中记录的提交顺序与 InnoDB 实际提交顺序不一致:

    • 在异常崩溃后,InnoDB 会回滚那些 prepare 但未 commit 的事务;
    • 但 Binlog 里可能已记录这些事务,重放时会尝试执行已回滚的事务,导致副本或恢复数据与源实例不吻合。
  2. 物理备份兼容性

    工具如 Percona XtraBackup、InnoDB Hot Backup 在 prepare 阶段会汇总所有未完成事务,并依赖 Binlog 决定哪些事务在恢复时执行提交:

    • 若写日志与提交顺序错乱,就无法正确匹配,可能把原本应回滚的事务提交,或把应提交的事务丢弃。
  3. 主从复制一致性

    MySQL 单线程或多线程复制都遵循“在主库上的提交顺序即 Binlog 写入顺序”,备库严格按此顺序重放事务:

    • 顺序错乱会让备库先执行后提交的事务,再执行前提交的事务,破坏数据因果关系;
    • 按顺序保证了“复制是严格顺序一致”的模型。

这里以备份及恢复为例,例如通过工具 xtrabackup 或者 ibbackup 进行备份,并用来建立 replication。如下图所示:

img

因此通过锁 prepare_commit_mutex 以串行的方式来保证顺序性,然而这会使 group commit 无法生效,如下图所示。

img

上图中启用了 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 的实现方式是将事务提交的过程分为几个步骤来完成,如下图所示。

img

三阶段提交与队列模型

BLGC 将单次事务提交拆分为三阶段,每阶段各自维护一个队列和对应的处理流程:

  1. Flush 阶段(Flush queue):事务先将各自的 Binlog 缓存(内存 I/O cache)写入到文件页缓存中,不立即调用 fsync,仅做写操作;
  2. Sync 阶段(Sync queue):队列领导者统一调用一次 fsync,根据 sync_binlog 设置决定是否持久化到存储介质;如果 sync_binlog=1,每批次都强制同步;
  3. Commit 阶段(Commit queue):在 InnoDB 层真正执行 group commit,写入 Redo Log 的 commit 标记,并通知各事务完成提交。

Leader / Follower 模式:队列中的第一个事务称为 leader,其他事务称为 follower,leader 控制着 follower 的行为。

具体流程

  1. Flush 阶段

    • 每个事务将自己的 Binlog 事件追加到线程本地的缓存区;

    • Leader 在此阶段结束时,将所有 follower 的缓存统一写入到 Binlog 文件的页缓存(内存)中,仅触发一次文件页写操作,无 fsync。

  2. Sync 阶段

    • 所有线程进入 Sync 队列,并由 Leader 负责后续操作;

    • Leader 按 sync_binlog 配置调用 fsync。若 sync_binlog=1,保证每批次提交后强制持久化;若设置大于 1,则每隔 N 次才 fsync;

    • 同步完成后,Leader 释放文件锁并唤醒所有 Follower,确保它们能看到持久化的数据。

  3. 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 的实现依赖于数据库的每条记录中的三个隐式字段:

  1. DB_TRX_ID:记录最后一次插入或修改该记录的事务 ID。
  2. DB_ROLL_PTR(回滚指针):指向 undo log 中这条记录的上一个版本。
  3. DB_ROW_ID(隐藏主键):如果表结构未指定主键,将会生成该隐藏字段。

img

undo log(回滚日志):记录未提交事务,用于事务回滚。

  • insert 时产生的回滚日志只在回滚时需要,可在事务提交后被立即删除
  • update 或 delete 时产生的回滚日志不仅在回滚时需要,在快照读时也需要,所以不会被立即删除。

**undo log 版本链:**不同事务或相同事务对同一条记录进行修改,会导致该记录 undo log 生成一条记录版本的链表,链表头部是最新的旧记录,链表尾部是最早的旧记录。

undo log 确实会被删除,但只有在所有可能依赖它的事务都结束之后才会删除。因此,在事务活跃期间,版本链仍然存在,不会立即消失。

img

ReadView 是快照读 SQL 执行时 MVCC 提取数据的依据,用于确定在特定事务中哪些版本的行记录是可见的,它记录并维护系统当前活跃的未提交事务的 id。ReadView 主要用来处理隔离级别为可重复读和读已提交的情况。因为在这两个隔离级别下,事务在读取数据时,需要保证读取到的数据是一致的,即读取到的数据是在事务开始时的一个快照。包含四个核心字段

字段 含义
m_ids 当前活跃的事务 ID 集合
min_trx_id 最小活跃事务 ID
max_trx_id 预分配事务 ID(当前最大事务 ID + 1)
creator_trx_id ReadView 创建者的事务 ID

版本链访问机制

img

trx_id:特定事务 ID

  1. trx_id == creator_trx_id:可以访问该版本。
  2. trx_id < min_trx_id:可以访问该版本。
  3. trx_id > max_trx_id:不可以访问该版本。
  4. 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 的时机不同:

  1. 可重复读:在第一次读取数据时生成一个 ReadView,这个 ReadView 会一直保持到事务结束,这样可以保证在事务中多次读取同一行数据时,读取到的数据是一致的。
  2. 读已提交:每次读取数据前都生成一个 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
2
3
4
5
# Bank@Shanghai:
UPDATE account SET money = money - 10000 WHERE user='David';

# Bank@Beijing:
UPDATE account SET money = money + 10000 WHERE user='Mariah';

在这种情况下,一定需要使用分布式事务来保证数据的安全性。如果操作不能全部提交或全部回滚,那么任何一个节点出现问题都会导致严重后果:要么 David 的账户被扣款但 Mariah 没收到钱,要么 David 的账户没有扣款但 Mariah 收到钱。

XA 事务由以下三部分组成:

  • 资源管理器(Resource Manager):提供访问事务资源的方法。通常一个数据库就是一个资源管理器。
  • 事务管理器(Transaction Manager):协调参与全局事务的各个资源管理器,需要与所有参与的资源管理器通信。
  • 应用程序(Application Program):定义事务的边界,指定全局事务中的操作。

在 MySQL 的分布式事务中,资源管理器就是 MySQL 数据库,事务管理器通常是连接 MySQL 服务器的客户端。下图展示了一个分布式事务的模型。

img

分布式事务采用两段式提交(two-phase commit)方法:

  1. **准备阶段(PREPARE):**所有参与全局事务的节点开始准备,告诉事务管理器它们已经准备好提交。
  2. **提交/回滚阶段(COMMIT or ROLLBACK):**事务管理器根据各节点的准备情况,通知资源管理器执行 COMMIT 还是 ROLLBACK。如果任何一个节点无法提交,则所有节点都被告知回滚。

与本地事务相比,分布式事务多了一次 PREPARE 操作:只有在收到所有节点同意准备的信息后,才执行最终的 COMMIT 或 ROLLBACK。

MySQL 数据库中 XA 事务的 SQL 语法如下:

1
2
3
4
5
6
7
8
9
10
11
XA {START|BEGIN} xid [JOIN|RESUME]

XA END xid [SUSPEND [FOR MIGRATE]]

XA PREPARE xid

XA COMMIT xid [ONE PHASE]

XA ROLLBACK xid

XA RECOVER

在单个节点上运行分布式事务意义不大;通常需要通过编程语言来驱动多个节点的分布式事务操作。

内部 XA 事务

之前讨论的分布式事务是外部事务,即资源管理器是 MySQL 数据库本身。在 MySQL 数据库中还存在另外一种分布式事务,其在存储引擎与插件之间,又或者在存储引擎与存储引擎之间,称之为内部 XA 事务。

最常见的内部 XA 事务存在于 binlog 与 InnoDB 存储引擎之间。由于复制的需要,因此目前绝大多数的数据库都开启了 binlog 功能。在事务提交时,先写二进制日志,再写 InnoDB 存储引擎的重做日志。对上述两个操作的要求也是原子的,即二进制日志和重做日志必须同时写入。若二进制日志先写了,而在写入 InnoDB 存储引擎时发生了宕机,那么 slave 可能会接收到 master 传过去的二进制日志并执行,最终导致主从不一致的情况。如下图所示。

img

在上图中,如果执行完 ①、② 后在步骤 ③ 之前 MySQL 数据库发生了宕机,则会发生主从不一致的情况。为了解决这个问题,MySQL 数据库在 binlog 与 InnoDB 存储引擎之间采用 XA 事务。当事务提交时,InnoDB 存储引擎会先做一个 PREPARE 操作,将事务的 xid 写入,接着进行二进制日志的写入,如下图所示。如果在 InnoDB 存储引擎提交前,MySQL 数据库宕机了,那么 MySQL 数据库在重启后会先检查准备的 UXID 事务是否已经提交,若没有,则在存储引擎层再进行一次提交操作。

img

不好的事务习惯

在循环中提交

开发人员可能会在循环中进行事务的提交,如下(可想象成 Java 中的某个方法):

1
2
3
4
5
6
7
8
9
10
CREATE PROCEDURE load1(count INT UNSIGNED)
BEGIN
DECLARE s INT UNSIGNED DEFAULT 1;
DECLARE c CHAR(80) DEFAULT REPEAT('a',80);
WHILE s <= count DO
INSERT INTO t1 SELECT NULL, c;
COMMIT;
SET s = s + 1;
END WHILE;
END;

其实,在上述的例子中,是否加上提交命令 COMMIT 并不关键。因为 InnoDB 存储引擎默认是自动提交,所以在上述的存储过程中去掉 COMMIT,结果其实是完全一样的。

上面的存储过程存在一个问题,当发生错误时,数据库会停留在一个未知的位置。例如,用户需要插入 10000 条记录,但是在插入 5000 条时,发生了错误,此时前 5000 条记录已经存放在数据库中,那应该怎么处理呢?另一个问题是性能问题,上面两个存储过程都不会比下面的存储过程 load2 快,因为下面的存储过程将所有的 INSERT 都放在一个事务中:

1
2
3
4
5
6
7
8
9
10
11
CREATE PROCEDURE load3(count INT UNSIGNED)
BEGIN
DECLARE s INT UNSIGNED DEFAULT 1;
DECLARE c CHAR(80) DEFAULT REPEAT('a',80);
START TRANSACTION;
WHILE s <= count DO
INSERT INTO t1 SELECT NULL, c;
SET s = s + 1;
END WHILE;
COMMIT;
END;

也就是说,因为每一次提交都要写一次重做日志,存储过程 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,无需显式再调用回滚语句。虽然自动回滚保证了数据一致性,但存储过程本身并不会向调用者返回错误信息。所以我们应将事务控制下放到应用程序一侧,既能保证回滚,也能捕获数据库抛出的错误编码与描述。

Intro

FoundationDB的研究意义在于,它成功地将NoSQL的灵活性与ACID事务的强大功能结合在一起,提供了一种模块化的架构,使得各个子系统可以独立配置和扩展。这种设计不仅提高了系统的可扩展性和可用性,还增强了故障容忍能力。此外,FoundationDB采用了严格的模拟测试框架,确保了系统的稳定性和高效性,使得开发者能够快速引入和发布新特性。FoundationDB的快速恢复机制显著提高了系统的可用性,简化了软件升级和配置变更的过程,通常在几秒钟内完成。

The main design principles are:

  1. Divide-and-Conquer (or separation of concerns). FDB decouples the transaction management system (write path) from the distributed storage (read path) and scales them independently. Within the transaction management system, processes are assigned various roles representing different aspects of transaction management. Furthermore, cluster-wide orchestrating tasks, such as overload control and load balancing are also divided and serviced by additional heterogeneous roles.
  2. Make failure a common case. For distributed systems, failure is a norm rather than an exception. To cope with failures in the transaction management system of FDB, we handle all failures through the recovery path: the transaction system proactively shuts down when it detects a failure. Thus, all failure handling is reduced to a single recovery operation, which becomes a common and well-tested code path. To improve availability, FDB strives to minimize Mean-Time-To-Recovery (MTTR). In our production clusters, the total time is usually less than five seconds.
  3. Simulation testing. FDB relies on a randomized, deterministic simulation framework for testing the correctness of its distributed database. Simulation tests not only expose deep bugs, but also boost developer productivity and the code quality of FDB.

Architecture

img

  • The control plane is responsible for persisting critical system metadata, that is, the configuration of transaction systems, on Coordinators.

    • These Coordinators form a Paxos group and elect a ClusterController.

    • The ClusterController monitors all servers in the cluster and recruits three processes, Sequencer, DataDistributor, and Ratekeeper, which are re-recruited if they fail or crash.

    • The DataDistributor is responsible for monitoring failures and balancing data among StorageServers.

    • Ratekeeper provides overload protection for the cluster.

  • The data plane is responsible for transaction processing and data storage. FDB chooses an unbundled architecture:

    • A distributed transaction management system (TS) consists of a Sequencer, Proxies, and Resolvers, all of which are stateless processes.

      • The Sequencer assigns a read and a commit version to each transaction.

      • Proxies offer MVCC read versions to clients and orchestrate transaction commits.

      • Resolvers check for conflicts among transactions.

    • A log system (LS) stores Write-Ahead-Log (WAL) for TS, and a separate distributed storage system (SS) is used for storing data and servicing reads. The LS contains a set of LogServers and the SS has a number of StorageServers. LogServers act as replicated, sharded, distributed persistent queues, each queue storing WAL data for a StorageServer.

Clients read from sharded StorageServers, so reads scale linearly with the number of StorageServers.

Writes are scaled by adding more Proxies, Resolvers, and LogServers.

The control plane’s singleton processes (e.g., ClusterController and Sequencer) and Coordinators are not performance bottlenecks; they only perform limited metadata operations. 因为元数据操作少且简单,且与两者无关的数据读写是并行扩展的(如上面两行加粗字体所述)。

Bootstrapping

FDB has no dependency on external coordination services. All user data and most system metadata (keys that start with 0xFF prefix) are stored in StorageServers. The metadata about StorageServers is persisted in LogServers, and the LogServers configuration data is stored in all Coordinators.

  1. The Coordinators are a disk Paxos group; servers attempt to become the ClusterController if one does not exist.
  2. A newly elected ClusterController reads the old LS configuration from the Coordinators and spawns a new TS and LS.
  3. Proxies recover system metadata from the old LS, including information about all StorageServers.
  4. The Sequencer waits until the new TS finishes recovery, then writes the new LS configuration to all Coordinators. The new transaction system is then ready to accept client transactions.

Reconfiguration

The Sequencer process monitors the health of Proxies, Resolvers, and LogServers. Whenever there is a failure in the TS or LS, or the database configuration changes, the Sequencer terminates. The ClusterController detects the Sequencer failure, then recruits and bootstraps a new TS and LS. In this way, transaction processing is divided into epochs, where each epoch represents a generation of the transaction management system with its own Sequencer.

End-to-end transaction processing

  1. Transaction Start and Read Operations:

    • A client starts a transaction by contacting a Proxy to obtain a read version (timestamp).

    • The Proxy requests a read version from the Sequencer that is greater than all previously issued commit versions and sends it to the client.

    • The client then reads from StorageServers at this specific read version.

  2. Buffered Write Operations:

    • Client writes are buffered locally and not sent to the cluster immediately.

    • Read-your-write semantics are preserved by combining the database lookups with the client’s uncommitted writes.

  3. Transaction Commit:

    • When the client commits, it sends the transaction data (read and write sets) to a Proxy, waiting for either a commit or abort response.

    • The Proxy commits a transaction in three steps:

      1. Obtain Commit Version: The Proxy requests a commit version from the Sequencer that is larger than all current read or commit versions.

      2. Conflict Check: The Proxy sends transaction data to the partitioned Resolvers, which check for read-write conflicts. If no conflicts are found, the transaction proceeds; otherwise, it is aborted.

      3. Persist to Log Servers: The transaction is sent to LogServers for persistence, and after all LogServers acknowledge, the transaction is considered committed. The Proxy then reports the committed version to the Sequencer and sends the response back to the client.

  4. Applying Writes:

    • StorageServers continuously pull mutation logs from LogServers and apply the committed changes to disk.
  5. Read-Only Transactions and Snapshot Reads:

    • Read-only transactions are serializable (at the read version) and high-performance (thanks to MVCC), allowing the client to commit locally without contacting the database, which is particularly important since most transactions are read-only.

    • Snapshot reads relax the isolation property of a transaction, reducing conflicts by allowing concurrent writes without conflicting with snapshot reads.

FoundationDB (FDB) using Serializable Snapshot Isolation (SSI) by combining Optimistic Concurrency Control (OCC) with Multi-Version Concurrency Control (MVCC).

Transaction Versions

  • Each transaction receives a read version and a commit version from the Sequencer.
  • The read version ensures that the transaction observes the results of all previously committed transactions, and the commit version is greater than all current read or commit versions, establishing a serial order for transactions.

Log Sequence Number (LSN)

  • The commit version serves as the LSN, defining a serial history of transactions.
  • To ensure no gaps between LSNs, the Sequencer also returns the previous LSN with each commit. Both the LSN and previous LSN are sent to Resolvers and LogServers to enforce serial processing of transactions.

Conflict Detection

  • FDB uses a lock-free conflict detection algorithm similar to write-snapshot isolation, but the commit version is chosen before conflict detection, enabling efficient batch processing of version assignments and conflict detection.
  • The key space is divided among multiple Resolvers, allowing conflict detection to be parallelized. A transaction can commit only if all Resolvers confirm no conflicts.

Handling Aborted Transactions

  • If a transaction is aborted, some Resolvers may have already updated their history, leading to possible “false positive” conflicts for other transactions. However, this is rare because most transactions’ key ranges fall within one Resolver, and the effects of false positives are limited to a short MVCC window (5 seconds).

Efficiency of OCC

  • The OCC design avoids the complexity of acquiring and releasing locks, simplifying interactions between the Transaction System (TS) and Storage Servers (SS).
  • While OCC may result in some wasted work due to aborted transactions, FDB’s conflict rate in production is low (less than 1%), and clients can simply restart aborted transactions.

Logging protocol

Commit Logging:

  • Once a Proxy decides to commit a transaction, it sends the transaction’s changes (mutations) to the LogServers responsible for the modified key ranges. Other LogServers receive an empty message.
  • The log message includes the current and previous Log Sequence Number (LSN) from the Sequencer and the largest known committed version (KCV) of the Proxy.
  • The LogServers reply to the Proxy once the log data is durably stored. The Proxy updates its KCV if all replica LogServers acknowledge and the LSN is larger than the current KCV.

Shipping Redo Logs:

  • Shipping the redo log from LogServers to StorageServers happens in the background and is not part of the commit path, improving performance.

Applying Redo Logs:

  • StorageServers apply non-durable redo logs from LogServers to an in-memory index. In most cases, this happens before any client reads are processed, ensuring low-latency multi-version reads.
  • If the requested data is not yet available on a StorageServer, the client either waits or retries at another replica. If both reads time out, the client can restart the transaction.

I/O Efficiency:

  • Since log data is already durable on LogServers, StorageServers can buffer updates in memory and write batches to disk periodically, improving input/output (I/O) efficiency.

What if a StorageServer is lagging behind on applying the redo logs and a client requests a version of a key pair it does not have?

  1. Wait for a threshold for when known-committed-version is greater than or equal to the read version
  2. If timeout, the client asks another StorageServer that stores the key
  3. Return error “request for a future version” (FDB error code 1009)

What if there is no further transaction logs to redo?

  • Without new transactions issued from the client, proxies still generate empty transactions to advance the known-committed-version
  • Known-committed-version and LSN of each transaction are sent to all LogServers (limit scalability on writes)

Transaction system recovery

Simplified Recovery

  • Unlike traditional databases that require undo log processing, FoundationDB avoids this step by making the redo log processing the same as the normal log forward path. StorageServers pull logs from LogServers and apply them in the background.

Failure Detection and New Transaction System (TS)

  • Upon detecting a failure, a new TS is recruited. The new TS can start accepting transactions even before all old logs are fully processed. Recovery focuses on finding the end of the redo log, allowing StorageServers to asynchronously replay the logs from that point.

Epoch-based Recovery

  • The recovery process is handled per epoch. The ClusterController locks the old TS configuration, stops old LogServers from accepting new transactions, recruits a new set of transaction components (Sequencer, Proxies, Resolvers, and LogServers), and writes the new TS configuration to the Coordinators.
  • Stateless components like Proxies and Resolvers don’t require special recovery, but LogServers, which store committed transaction logs, must ensure all data is durable and retrievable by StorageServers.

Recovery Version (RV)

  • The recovery focuses on determining the Recovery Version (RV), which is essentially the end of the redo log. The Sequencer collects data from the old LogServers, specifically the Durable Version (DV) (maximum LSN persisted) and KCV (maximum committed version) from each.
  • Once enough LogServers have responded, the Previous Epoch Version (PEV) is established (the maximum of all KCVs). The start version of the new epoch is PEV + 1, and the minimum DV becomes the RV.

Log Copying and Healing

  • Logs between PEV + 1 and RV are copied from old LogServers to the new ones to restore replication in case of LogServer failures. This copying process is lightweight since it only covers a few seconds of logs.

Rollback and Transaction Processing

  • The first transaction after recovery is a special recovery transaction that informs StorageServers of the RV, so they can discard in-memory multi-versioned data beyond the RV. StorageServers then pull data larger than the PEV from the new LogServers.
  • The rollback process simply discards in-memory multi-versioned data, as persistent data is only written to disk once it leaves the MVCC window.

Replication

  1. Metadata Replication:

    • System metadata related to the control plane is stored on Coordinators using the Active Disk Paxos protocol. As long as a majority (quorum) of Coordinators are operational, the metadata can be recovered in case of failure.
  2. Log Replication:

    • When a Proxy writes logs to LogServers, each log record is replicated synchronously across k = f + 1 LogServers (where f is the number of allowed failures). The Proxy only sends a commit response to the client after all k LogServers have successfully persisted the log. If a LogServer fails, a transaction system recovery is triggered.
  3. Storage Replication:

    • Each key range (shard) is asynchronously replicated across k = f + 1 StorageServers. These StorageServers form a team. A StorageServer typically hosts multiple shards, distributing its data across several teams. If a StorageServer fails, the DataDistributor moves the data from teams with the failed server to other healthy teams.

To prevent data loss in case of simultaneous failures, FoundationDB ensures that no more than one process in a replica group is placed within the same fault domain (e.g., a host, rack, or availability zone). As long as one process in each team is operational, no data is lost, provided at least one fault domain remains available.

Simulation testing

  1. Deterministic Simulation:

    • FoundationDB uses deterministic discrete-event simulation to test its distributed system. This simulation runs real database code along with randomized synthetic workloads and fault injection to uncover bugs.

    • Determinism ensures that bugs are reproducible and can be investigated thoroughly.

  2. Fault Injection:

    • The simulation tests system resilience by injecting various faults, such as machine, rack, or data center failures, network issues, disk corruption, and delays.

    • Randomization of these faults increases the diversity of tested states, allowing for a wide range of potential issues to be examined.

    • “Buggification” is a technique used to deliberately introduce rare or unusual behaviors (e.g., unnecessary delays, errors) in the system to stress-test its handling of non-standard conditions.

  3. Swarm Testing:

    • Swarm testing increases simulation diversity by using random cluster sizes, configurations, workloads, and fault injection parameters.

    • This ensures that a broad range of scenarios is covered in testing, allowing for the discovery of rare bugs.

  4. Test Oracles:

    • Test oracles are built into the system to verify key properties like transaction atomicity, isolation, and recoverability. Assertions check these properties to detect failures during simulation.

    • They help confirm that the system’s expected behaviors are maintained, even under stressful conditions.

  5. Bug Detection Efficiency:

    • The simulation runs faster than real-time, allowing FoundationDB to quickly discover and trace bugs. The parallel nature of testing accelerates the process of finding bugs, particularly before major releases.

    • This approach uncovers bugs that may not appear during real-time testing, especially for issues that require long-running operations.

  6. Limitations:

    • Simulation cannot reliably detect performance issues (like imperfect load balancing).

    • It cannot test third-party libraries or external dependencies, focusing mainly on FoundationDB’s internal code and behaviors.

Lessons learned

  1. Architecture Design

    • Divide-and-Conquer Principle: Separating the transaction system from the storage layer allows for independent scaling and deployment of resources, enhancing both flexibility and performance.

    • LogServers as Witness Replicas: In multi-region deployments, LogServers reduce the need for full StorageServer replicas while maintaining high availability.

    • Role Specialization: The design enables the creation of specialized roles, like separating DataDistributor and Ratekeeper from the Sequencer, and separating Proxies into Get-Read-Version and Commit Proxies, which improves performance and makes the system extensible.

    • Decoupling Enhances Extensibility: This design pattern allows features like replacing SQLite with RocksDB and adding new roles or functions without overhauling the entire system.

  2. Simulation Testing

    • High Productivity: FDB’s deterministic simulation testing enables bugs to be found and reproduced quickly. This approach has improved developer productivity and system reliability by reducing debugging time and improving test coverage.

    • Reliability: FDB has operated without any data corruption over several years of deployment (e.g., CloudKit), thanks to rigorous simulation testing. Simulation has allowed ambitious rewrites and improvements to be made safely.

    • Eliminating Dependencies: Simulation testing helped find bugs in external dependencies, leading to FDB replacing Apache Zookeeper with its own Paxos implementation. This change resulted in no further production bugs.

  3. Fast Recovery

    • Simplifies Upgrades: FDB allows fast recovery by restarting all processes simultaneously, typically within seconds, simplifying software upgrades and configuration changes. This method has been extensively tested and used in Apple’s production clusters.

    • Bug Healing: Fast recovery can automatically resolve certain latent bugs, similar to software rejuvenation, by resetting system states.

  4. 5-Second MVCC Window

    • Memory Efficiency: FDB uses a 5-second MVCC (Multi-Version Concurrency Control) window to limit memory usage in transaction systems and storage servers. This time window is long enough for most OLTP workloads, exposing inefficiencies if the transaction exceeds 5 seconds.

    • TaskBucket Abstraction: Long-running processes, like backups, are broken into smaller transactions that fit within the 5-second window. FDB implements this through an abstraction called TaskBucket, which simplifies splitting large transactions into manageable jobs.

Questions

With FDB, what operations does a transaction commit perform when the transaction only reads the value of data items?

  • Read Version Retrieval: The client requests a read version from a Proxy via the Sequencer, which guarantees the read version is greater than or equal to any committed version.
  • Read Operation: The client reads the requested data at this specific read version from the StorageServers. The reads are served by the StorageServers, which are guaranteed to provide data consistent with the requested version.
  • No Writes or Conflicts: Since the transaction is read-only, there is no write set or conflicts to check. The transaction simply ends, and no data is written or modified, meaning it does not interact with LogServers or commit any changes.
  • Commit: Even though no actual commit occurs (because there’s no data change), the transaction is marked as successfully completed after the reads are done.

With FDB, is it possible for multiple resolvers to participate in the decision whether to commit or abort a write transaction?

Yes, multiple Resolvers can participate in the decision to commit or abort a write transaction in FDB. Here’s how it works:

  • Conflict Detection: When a transaction writes data, the write set (the keys it wants to write) is sent to a set of Resolvers. Each Resolver is responsible for a specific portion of the key space. Multiple Resolvers can be involved in checking the transaction’s read and write sets to detect conflicts (read-write conflicts or write-write conflicts).
  • Parallel Conflict Checking: Since the key space is partitioned, different Resolvers check different key ranges in parallel. A transaction can only commit if all Resolvers agree that there are no conflicts.

With FDB, what if a StorageServer is lagging behind on applying the redo logs and a client requests a version of a key pair it does not have?

  • Client Waits: The client can choose to wait for the StorageServer to catch up by applying the redo logs. Once the StorageServer finishes replaying the logs and reaches the required version, it can serve the requested data.
  • Retry at Another Replica: If the StorageServer does not have the requested version yet, the client can try to read from another replica of the key. FDB typically stores multiple replicas of data across different StorageServers, so the client can retry the request from a replica that is up to date.
  • Transaction Restart: If neither replica has the requested version or the delay is too long, the client may restart the transaction. Since FoundationDB uses MVCC (Multi-Version Concurrency Control), restarting the transaction allows it to obtain a fresh version of the key from an up-to-date StorageServer.

Consider a database for students enrolling in courses and professors teaching those courses. Provide a SDM model of this database?

Students: base concrete object class.

member property: student_id, name, age, email, department_id.

identifier: student_id.

Professors: base concrete object class.

member property: professor_id, name, age, email, department_id.

identifier: professor_id.

Courses: base concrete object class

member property: course_id, name, location, start_time, end_time, department_id.

derived member property: professor as Professors.professor_id.

identifier: course_id.

Enrollment: base duration event class.

member property: enrollment_id, date_of_enrollment.

member participant: student in Students, course in Courses.

identifier: enrollment_id.

Departments: abstract Students and Professors on common value of department_id.

derived member property: department_id as distinct value of (Students.department_id union Professors.department_id).

What is the difference between a monolithic database management system and a disaggregated database management system?

Feature Monolithic DBMS Disaggregated DBMS
Architecture All components tightly integrated into a single system Components like storage, computation, and query processing are separated
Scalability Scales through vertical scaling (adding resources to the single server) Scales through horizontal scaling (independent scaling of storage and compute)
Performance Bottlenecks May face bottlenecks as the system grows Components are independently optimized, reducing bottlenecks
Resource Management Storage and compute resources are tightly coupled, hard to manage separately Storage and compute resources can be managed independently, offering flexibility
Complexity Easier to deploy and manage initially, but complexity increases with scale More complex to manage and coordinate different components
Cost Pay for all resources, even if they are not fully utilized Can optimize resource usage and costs by scaling components independently
Consistency Strong data consistency due to tight integration Requires additional mechanisms to ensure consistency across components

With Gamma and its data flow execution paradigm, how does the system know when the execution of a parallel query involving multiple operators is complete?

Data Dependency Graph: The query execution is modeled as a directed acyclic graph (DAG), where each node represents an operator (e.g., selection, join). Data flows between operators, and the system tracks the completion of each operator based on this graph.

Completion Signals: Each parallel operator sends a “done” signal once it finishes processing its data partition. The system monitors these signals to determine when all operators have finished.

Coordinator: A central coordinator tracks the progress of parallel tasks. When all tasks report completion, the system declares the query execution as complete.

Reference: https://sigmodrecord.org/publications/sigmodRecord/2203/pdfs/08_fdb-zhou.pdf

多表关系

  • 一对多:在多的一方建立外键,指向一的一方的主键。如:部门-员工。
  • 多对多:建立第三张中间表,其中至少包含两个外键,分别关联两方主键。如:学生-课程。
  • 一对一:用于单表拆分,将一张表的基础字段放在一张表中,其他详情字段放在另一张表中,以提升操作效率。在任意一方加入外键,关联另一方的主键,并且设置外键为唯一(UNIQUE) 。

[!NOTE]

在多表查询时,需要消除无效的笛卡尔积。

连接查询

内连接

相当于查询两张表交集部分数据。

  • 隐式内连接:SELECT 字段列表 FROM 表1,表2 WHERE 条件;
  • 显式内连接 :SELECT 字段列表 FROM 表1 [INNER] JOIN 表2 ON 连接条件;

外连接

左外连接:查询左表所有数据,以及两张表交集部分数据。

1
SELECT 字段列表 FROM1 LEFT [OUTER] JOIN2 ON 条件;

右外连接:查询右表所有数据,以及两张表交集部分数据。

1
SELECT 字段列表 FROM1 RIGHT [OUTER] JOIN2 ON 条件;

自连接:当前表与自身的连接查询,必须使用表别名。可以是内连接,也可以是外连接。

联合查询:把多次查询的结果合并以形成一个新的查询结果集。不使用 ALL 的时候,有去重效果。联合查询的多张表之间的列数和字段类型需要保持一致

1
SELECT 字段列表 FROM1 UNION [ALL] SELECT 字段列表 FROM2;

子查询

SQL语句中嵌套 SELECT 语句,外部语句可以是 INSERT/UPDATE/DELETE/SELECT 中任何一个。

1
SELECT * FROM 表名 WHERE col = (SELECT col FROM2);

子查询种类

根据结果

  • 标量子查询:返回结果是单个值。
  • 列子查询:返回结果是一列。
  • 行子查询:返回结果是一行(可以是多列)。
  • 表子查询:返回结果是多行多列。

根据位置:WHERE 之后,FROM 之后,SELECT 之后。

Intro

LSM-Tree(Log-Structured Merge Tree)的核心思想是将大量的随机写入转换为更高效的顺序写入。简单来说,它通过以下方式来实现:

  1. 写入内存:当有新的数据写入时,LSM-Tree首先将这些数据存储在内存中的缓冲区(称为MemTable)。这是一个有序的结构,数据按键排序。
  2. 批量写入磁盘:当内存中的数据积累到一定程度时,整个MemTable会被一次性地写入磁盘,这个过程是顺序写入,非常高效。写入磁盘后,这个数据成为一个不可修改的文件,称为SSTable(Sorted String Table)。
  3. 合并和压缩:随着时间的推移,磁盘上会产生多个SSTable。为了优化读取性能,系统会周期性地将这些SSTable进行合并和压缩,使得数据保持有序并减少冗余。

这样,LSM-Tree通过将频繁的随机写操作缓存在内存中,最后批量顺序写入磁盘,大大提高了写入性能。这种方式适合写入密集型的工作负载,同时还能保证数据查询的效率。

LSM-Tree的基础结构,特别是数据如何从内存(memtable)移动到磁盘,并经过多级的归并排序(compaction)过程来进行存储。

img

  1. MemTable(内存表)

    • 数据的写入首先进入到内存中的memtable,通常是一个有序的数据结构(比如跳表或B+树),这使得数据在内存中是有序的,便于快速写入和查询。

    • 当memtable满了或者系统需要将数据持久化时,memtable中的数据会被flush(刷新)到磁盘,形成第一层的SSTable。

  2. Level-0(磁盘上的第一层)

    • 数据从内存写入磁盘后,存储在Level-0层的SSTable中。此时,SSTable的数据顺序与memtable一致,但可能存在多个SSTable,且它们之间的键值范围可能重叠。

    • Level-0的SSTable是逐渐积累的,并不会自动排序或整理,直到执行compaction(归并操作)。

  3. Compaction(归并操作)

    • 当Level-0层的数据达到一定量时,系统会执行归并操作,将Level-0层的多个SSTable合并,并将合并后的有序数据移到Level-1层。

    • Level-1开始,所有的SSTable都是有序且互不重叠的。也就是说,每个SSTable都有自己独立的键值范围,不会与其他SSTable的键值范围重叠,这使得查询时能够快速定位到目标SSTable。

  4. 逐级沉降

    • 数据会随着系统运行,从Level-0层逐步沉降到更深的层级(如Level-1、Level-2等)。在每一层,数据都通过归并操作变得更加有序且结构紧凑。

    • 每次合并后,数据被重新整理,分配到新的不重叠的SSTable中,从而保持物理上的键值有序性。

LSM-Tree查询

基于LSM-Tree的查询可分为点查与范围查询两大类,对应的执行方式如下:

  • 点查(point lookup):从上往下进行查询,先查memtable,再到L0层、L1层。因为上层的数据永远比下层版本新,所以在第一次发生匹配后就会停止查询。
  • 范围查询(range lookup):每一层都会找到一个匹配数据项的范围,再将该范围进行多路归并,归并过程中同一key只会保留最新版本。

LSM-Tree性能的衡量主要考虑三个因素:空间放大、读放大和写放大。

一是空间放大(space amplification)。LSM-Tree的所有写操作都是顺序追加写,对数据的更新并不会立即反映到数据既有的值里,而是通过分配新的空间来存储新的值,即out-place update。因此冗余的数据或数据的多版本,仍会在LSM-Tree系统里存在一定时间。这种实际的占用空间大于数据本身的现象我们称之为空间放大。因为空间有限,为了减少空间放大,LSM-Tree会从L1往L2、L3、L4不断做compaction,以此来清理过期的数据以及不同数据的旧版本,从而将空间释放出来。

二是读放大(read amplification)。假设数据本身的大小为1k,由于存储结构的设计,它所读到的值会触发多次IO操作,一次IO意味着一条读请求,这时它所读取到的则是在后端所需要做大的磁盘读的实际量,已经远大于目标数据本身的大小,从而影响到了读性能。这种现象我们称之为读放大。为了减轻读放大,LSM-Tree采用布隆过滤器来避免读取不包括查询键值的SST文件。

三是写放大(write amplification)。在每层进行compaction时,我们会对多个SST文件进行反复读取再进行归并排序,在删掉数据的旧版本后,再写入新的SST文件。从效果上看,每条key在存储系统里可能会被多次写入,相当于一条key在每层都会写入一次,由此带来的IO性能损失即写放大。

LSM-Tree最初的理念是用空间放大和读放大来换取写放大的降低,从而实现较好的写性能,但也需要做好三者的平衡。以下是两种假设的极端情况。

第一种极端情况是:如果完全不做compaction,LSM-Tree基本等同于log文件,当memtable不断刷下来时,由于不做compaction,只做L0层的文件,这时如果要读一条key,读性能会非常差。因为如果在memtable里找不到该条key,就要去扫描所有的SST文件,但与此同时写放大现象也将不存在。

第二种极端情况是:如果compaction操作做到极致,实现所有数据全局有序,此时读性能最优。因为只需要读一个文件且该文件处于有序状态,在读取时可以很快找到对应的key。但要达到这种效果,需要做非常多的compaction操作,要不断地把需要删的SST文件读取合并再来写入,这会导致非常严重的写放大。

Nova-LSM架构设计

img

第一部分是写日志的组件,将WAL写成功后再往LSM-Tree的memtable中查询新的数据。

第二部分是本身处理LSM-Tree写入的线程,其缩写为LTC(LSM-Tree Component),代表着将该线程单独组件化。

第三部分则是底层的存储,负责把接收到的上层LTC组件下发下来,并提供标准的文件接口。

Nova-LSM所解决的核心问题

第一个核心问题是:基于LSM-Tree结构的存储系统,例如LevelDB、RocksDB等,都会不可避免地遇到缓写或者停写的问题。比如内存里的memtable,在配置时最多可以写8个,因为写入多,需要全部flush到磁盘上。与此同时,当前L0层的SST文件非常多,L0层即将开始做compaction。但compaction会涉及到磁盘IO,在还没做完时,就会阻塞内存中的memtable对L0层SST进行flush的过程。当flush无法进行时,就会发生缓写,随着阈值的推进,在实在写不进时甚至会停写,这种现象体现在客户端就是请求掉零。

为了解决LSM-Tree结构存储系统中的缓写、停写问题,该文章提出了两个解决办法:

  • 第一种方法是设计了存算分离的架构体系,具体如上图所示。该架构的重要作用之一,是把处理写入和处理磁盘IO的两大主力模块拆分,计算存储分离,哪个部分慢就为哪个部分增加节点以此来提高该部分的能力,这是比较亮眼的突破。
  • 第二种方法是引入了动态分区,即Drange机制。该机制的目的是为了让业务的写入压力,在LTC即计算层的memtable上进行区间划分,每个range都有自己的memtable,通过区间划分,从而实现多个range之间进行并行compaction。以L0层为例,我们可以把L0层变成没有互相重叠的状态,这时我们就可以对L0层进行并行的compaction,可以加快L0层的文件的消化,从而减轻对memtable flush到磁盘上的过程的影响。

第二个核心问题是:在这种方式下需要划分很多不同的Drange,每个range都会增加一定的memtable数量,memtable数量的增加会影响scan和get的性能。假设有一个主请求,在原来所有数据都写在一个memtable里的情况下,在读取时,索引只需要面向这个memtable,再根据跳表进行get,如果get到则可以马上返回。现在划分成不同的Drange,memtable数量增加,因此需要查找的memtable以及L0层的SST也会变多。解决办法是:实现了一个索引,可以查询到一个key在memtable或L0 SST中的最新值(若存在)。

Nova-LSM 中的重要设计

LTC和StoCs之间的写数据流程

第一个比较重要的设计是LTC和StoCs之间的写数据流程。该流程展示的是:当在客户端发起写请求时,计算节点和存储节点是以怎样的方式将数据写进去的过程。

首先是计算节点的客户端发起一个新的写请求操作。存储节点在接收到该请求后,基于RDMA交互,它会在buffer区域分配一个内存区域,并且为这块内存和偏移量(当前哪块内存可以写)分配一个id,告知客户端。客户端接到响应后就会开始写数据,完成后会通知存储节点。存储节点接收到信号后,将数据持久化并且再告知客户端。

上述流程是写一个数据文件即SSTable。写完后,我们要以同样的流程将元数据文件更新。因为底层是分布式架构,需要知道哪些文件写在哪里以及每个SST的范围、版本号。

img

动态区间划分

第二个比较重要的设计是动态区间划分。假设业务的请求范围为0-1万,当前有10个计算节点,将这10个计算节点的区间划分为10等份,比如第一个key的空间范围为0-1000。在负责0-1000的计算节点里,它会再进行划分,这一层划分业务无感知。这就叫动态区间划分,简称Drange。其作用主要有以下几点:

首先,每个range都是一棵LSM-Tree,按照数据区间,不同的Drange都有自己的memtables。比如0-1000区间又可以划分为10个Drange,10个Drange之间的memtable相互独立。这样做的好处是这些Drange之间的key互不重叠,例如0-100、100-200、200-300。

其次,在Dranges下还有一层Tranges。如果发现Drange里的部分range比如890-895存在热点现象,而旁边的range并非热点,则可以用Tranges进行细粒度的复杂重均衡,实现动态均衡负载。

最后,在此基础上,因为Drange的key范围互不相交,当memtable变成immutable,不可再写后,它们需要独立地flush到磁盘上。这时,在L0层的SSTable来自不同的Drange,它们之间的key完全不相交,我们就可以进行并行的compaction。

img

文章还将没有Drange划分和有Drange划分两种情况进行了对比:

  • 在没有Drange划分的情况下,L0的compaction无法很好并行。在这种情况下,如果遇到最坏的情况,L0层的某一个SST有可能覆盖了整个key空间,假设key范围为0-600,L0层的SST文件的范围是0-1000,当发生compaction时,它必须要跟其他4个SST做归并,这时不但要把L0层的其他SST全部读取比较一遍,还要把L1层所有的SST都读一遍再做归并排序。这时写放大会较为严重,意味着L0层到L1层的compaction会变慢,flush也会变慢,甚至flush不了时,前端就会出现缓写、停写现象。
  • 有Drange划分后,相当于compaction可以分开区间,如下方的示意图所示。在0-100区间,L0到L1可以独立去compaction,100-200区间也可以独立去compaction,可以较好地实现并行compaction。而在原生的RocksDB里,只有从L1开始compaction,才能进行并行compaction操作。

索引查找以及Scan操作

因为划分了很多不同的动态区间,memtable的数量也会增加,意味着查询操作的耗时也会增加。所以要如何在原来的基础上维护好读性能?这篇文章提出了以下解决思路:

每个LTC维护了一个lookup index。如果这些数据存在于memtable和L0层的SST上,通过lookup index我们就可以快速查找到想要的数据。当某一个L0层SST被compaction到L1层时,索引上就会移除掉对应的key。

LTC同时还维护了一个范围索引即range index。因为知道每个Drange的范围,所以当一个scan请求所涉及到的key都可以在memtable和L0层SST中找到时,该范围索引就能快速响应scan操作。

img

SSTable的分布

最后一个比较重要的设计涉及到存储层。当某个SST文件要写到存储节点时,分布式系统首先要保证负载均衡,要保证数据避免单点故障不可恢复的场景。

该文章提出根据一定策略,将数据文件即SST打散写入到多个存储节点里。考虑到存储成本,每个SSTable采用纠删码(Erasure Coding)的方式进行编码然后分布式存放。默认情况下对每个 SSTable 采用 “3+1”的 EC 配置,将一个SSTable切分为3个数据块,根据一定算法,在这3个数据块里去计算出一个校验块,变成了“3+1”的形式。这种方式比传统的多副本可以节省更多空间。假设一个SSTable是3M,这种“3+1”的方式最终所占空间为4M,并且能容忍一个节点的丢失,与占用6M空间的双副本方案拥有同样的故障容忍等级。而元数据文件因为体积比较小,所以直接采用多副本存储的方式,比如1个元数据文件可以写3个副本。

Challenges and Solutions

  1. Write Stalls, the solutions are:

    1. Vertical scaling: use large memory.

    2. Horizontal scaling: use the bandwidth of many StoCs.

  2. Scans are slowed down, the solutions are:

    1. Construct Dranges at runtime based on workload. Drange faciliates parallel compaction.

    2. Construct range index dynamically.

  3. Gets are slowed down, the solution is: Use lookup index.

  4. Temporary Bottlenecks, the solution is:

    1. Scatter blocks of a SSTable across multiple StoCs.

    2. Power-of-d: power-of-d is applied in Nova-LSM to help with load balancing during SSTable placement. When writing data to storage components (StoCs), Nova-LSM doesn’t randomly select just one StoC. Instead, it chooses d StoCs at random and writes to the one with the shortest queue. This method helps avoid bottlenecks and improves throughput, ensuring that data is distributed evenly across storage nodes without overwhelming any individual node.

  5. Logging, the solution is: Replicating Log records in the memory of StoCs to provide high availability.

  6. Skewed Access Pattern, the solution is: Dranges enable LTC to write 65% less data to StoCs with skewed data access.

Questions

Why do modern database systems disaggregate compute from storage?

Modern database systems disaggregate compute from storage to improve scalability, resource utilization, and fault isolation. By separating compute (processing) and storage, the system can independently scale each based on demand. Compute nodes handle processing, while storage nodes handle data access, optimizing resources and ensuring that failures in one component don’t impact the other. This separation also benefits cloud environments, where elastic scaling of resources is crucial.

How does Nova-LSM provide superior performance than monolithic data stores?

Nova-LSM improves performance by using a component-based architecture that disaggregates processing (LTC) and storage (StoC). It allows components to scale independently and uses RDMA for fast communication. Nova-LSM also introduces dynamic range partitioning (Dranges), allowing parallel compaction and reducing write stalls, which significantly enhances throughput. This architecture minimizes bottlenecks seen in monolithic stores like LevelDB and RocksDB, especially under skewed workloads.

Why does the standard cost-based optimizer produce sub-optimal query plans? How does Kepler improve both the query planning time and query execution time?

The standard cost-based optimizer can produce sub-optimal plans because it relies on simplified and static cost models that don’t always capture real execution costs, especially in dynamic environments. It also may lack up-to-date statistics, leading to inaccurate decisions. Kepler, on the other hand, uses machine learning to learn from past executions and adapts to current data distributions, improving query plan selection. By pruning the search space efficiently and using real-time data, it reduces both planning time and execution time while optimizing performance.

References:

作用于表中字段上的规则,用于限制存储在表中的数据,保证表中数据的正确性、有效性和完整性。

  • NOT NULL 非空约束
  • UNIQUE 唯一约束
  • PRIMARY KEY 主键约束
  • DEFAULT 默认约束
  • CHECK 检查约束
  • FOREIGN KEY 外键约束:让两张表的数据之间建立连接,具有外键的表是子表。

声明外键:

1
2
3
4
5
6
CREATE TABLE 表名 (
字段名 数据类型,
[CONSTRAINT] [外键名称] FOREIGN KEY (外键字段名) REFERENCES 主表 (主表列名)
);

ALTER TABLE 表名 ADD CONSTRAINT 外键名称 FOREIGN KEY (外键字段名) REFERENCES 主表 (主表列名);

删除外键:ALTER TABLE 表名 DROP FOREIGN KEY 外键名称;

删除/更新行为:

  • NO ACTION 在父表中删除/更新对应记录时,首先检查该记录是否有对应外键,如果有则不允许删除/更新。(与RESTRICT一致)
  • RESTRICT
  • CASCADE 在父表中删除/更新对应记录时,首先检查该记录是否有对应外键:如果有,也删除/更新外键在子表中的记录。
  • SET NULL 在父表中删除对应记录时,首先检查该记录是否有对应外键:如果有,设置子表中该外键值为 null (要求外键允许取 null)。
  • SET DEFAULT 父表数据变更时,子表将外键列设置成一个默认的值(InnoDB不支持)。
1
ALTER TABLE 表名 ADD CONSTRAINT 外键名称 FOREIGN KEY (外键字段) REFERENCES 主表名 (主表字段名) ON UPDATE [更新行为] ON DELETE [删除行为];