事务

MySQL 中事务提供三个关键字:BEGIN(开始事务)、COMMIT(提交事务)、ROLLBACK(回滚事务)。

事务以 BEGIN 开始,以 COMMITROLLBACK 结束,如果事务提交那么正常结束,如果事务回滚它会回到 BEGIN 的状态。事务的作用在于,它将整个事务视为一个原子操作,必须全部完成,否则回滚。例如,我们在做一笔交易时,扣款和提交订单必须绑定完成,如果流程中出现任何故障它必须回滚。

1
SELECT * FROM test;
1
2
3
4
BEGIN;
INSERT INTO test VALUES(2, "Jhon", 20);
SELECT * FROM test;
COMMIT;
1
SELECT * FROM test;
1
2
3
4
5
+----+-----------+------+
| id | name | age |
+----+-----------+------+
| 1 | JamhusTao | 19 |
+----+-----------+------+
1
2
3
4
5
6
+----+-----------+------+
| id | name | age |
+----+-----------+------+
| 1 | JamhusTao | 19 |
| 2 | Jhon | 20 |
+----+-----------+------+
1
2
3
4
5
6
+----+-----------+------+
| id | name | age |
+----+-----------+------+
| 1 | JamhusTao | 19 |
| 2 | Jhon | 20 |
+----+-----------+------+

这里我们察觉不出和直接运行代码的区别。

1
SELECT * FROM test;
1
2
3
4
BEGIN;
INSERT INTO test VALUES(3, "Mike", 20);
SELECT * FROM test;
ROLLBACK;
1
SELECT * FROM test;
1
2
3
4
5
+----+-----------+------+
| id | name | age |
+----+-----------+------+
| 1 | JamhusTao | 19 |
+----+-----------+------+
1
2
3
4
5
6
+----+-----------+------+
| id | name | age |
+----+-----------+------+
| 1 | JamhusTao | 19 |
| 3 | Mike | 20 |
+----+-----------+------+
1
2
3
4
5
+----+-----------+------+
| id | name | age |
+----+-----------+------+
| 1 | JamhusTao | 19 |
+----+-----------+------+

可以看到操作被回滚了。

上面我们介绍了事务回滚为数据库提供了处理故障的能力。但事实上,事务被更多的赋予并发意义,在并发场景下事务的四大特性 AICD 为其提供了可靠的并发安全。

在事务中只能执行 DML 语句

请切记在事务中只能执行 DML 语句!

这一点不是本文的重点内容,但为了避免在将来实验或更重大问题的悲剧,请牢记这一点。

例如在下面的示例中,我复现了一个悲剧

1
SELECT * FROM test;
1
2
3
BEGIN;
TRUNCATE TABLE test;
ROLLBACK;
1
SELECT * FROM test;
1
2
3
4
5
6
+----+-----------+------+
| id | name | age |
+----+-----------+------+
| 1 | JamhusTao | 19 |
| 2 | Mike | 20 |
+----+-----------+------+
1
Empty set

不是说好的会回滚嘛???

只有 INSERT / DELETE / UPDATE / SELECTDML 语句。

AICD

AICD 是事务的四大特性,它们具体指:

  • Atomicity 原子性,一个事务被视为一个原子操作,事务的作用要么同时发生,要么都不发生。
  • Isolation 隔离性,不同事务的并发执行是相互隔离的,数据库有四个隔离级别,提供了不同程度的并发安全。
  • Consistency 一致性,事务令数据从一个一致性状态到另个一致性状态,这部分通过其他三个特性和业务代码逻辑实现。
  • Durability 持久性,事务对数据的改变是永久的,即使系统故障也不应改变,这部分由 redo log 实现。

隔离级别

四种隔离级别与并发问题

InnoDB(MySQL 的默认引擎)定义了四个隔离级别,提供了不同程度的并发安全。

隔离级别 脏写 脏读 不可重复读 幻读
READ-UNCOMMITTED(读未提交) NO YES YES YES
READ-COMMITTED(读已提交) NO NO YES YES
REPEATABLE-READ(可重复读) NO NO NO YES
SERIALIZABLE(可串行化) NO NO NO NO
  • 脏写:事务 A 和 B 同时修改同一记录,导致其中一个事务的修改立即被覆盖。
  • 脏读(读未提交):在事务 B 中修改但未提交的记录可以被事务 A 读取。
  • 不可重复读(读已提交):在事务 B 中修改并提交的记录可以被事务 A 读取。
  • 幻读:在事务 B 中新增或删除并提交的记录,可以被事务 A 以当前读(FOR UPDATE)读取。

读未提交

读未提交是指事务可以读取其他事务未提交的记录。

事务的实验方法

实验是最好的验证正确性的方法,结合理论在自己的 MySQL 中实验可以更好的理解问题,同时解决我在文章中未描述的各种细节。

在 MySQL 中,你无法通过一个终端创建两个事务,你需要打开两个终端分别开始两个事务。

另外,在实验中我们需要修改事务隔离级别,方法如下:

1
2
3
4
SELECT @@global.transaction_isolation;  -- 查看当前事务隔离级别
SET @@global.transaction_isolation = "{隔离级别}" -- 设置隔离级别
-- 隔离级别包括:READ-UNCOMMITTED / READ-COMMITTED / REPEATABLE-READ / SERIALIZABLE
-- 重启服务

假设我们已经将隔离级别修改为 READ-COMMITTED

事务 A 事务 B
BEGIN; BEGIN;
SELECT * FROM test;
INSERT INTO test VALUES(4, “GreatLiangpi”,19);
SELECT * FROM test;
COMMIT; COMMIT;
1
2
3
4
5
6
+----+-----------+------+
| id | name | age |
+----+-----------+------+
| 1 | JamhusTao | 19 |
| 2 | Jhon | 20 |
+----+-----------+------+
1
2
3
4
5
6
7
+----+--------------+------+
| id | name | age |
+----+--------------+------+
| 1 | JamhusTao | 19 |
| 2 | Jhon | 20 |
| 4 | GreatLiangpi | 19 |
+----+--------------+------+

这种现象称之为脏读,指读取到另一事务的中间数据,这个数据可能只是临时的。

读已提交

读已提交是指事务可以读取其他事务已提交的记录。它是 Oracle / SQL Server / PostgreSQL 的默认隔离级别。

假设我们已经将隔离级别修改为 READ-COMMITTED

事务 A 事务 B
BEGIN; BEGIN;
SELECT * FROM test;
INSERT INTO test VALUES(4, “GreatLiangpi”,19);
SELECT * FROM test;
COMMIT;
SELECT * FROM test;
COMMIT;
1
2
3
4
5
6
+----+-----------+------+
| id | name | age |
+----+-----------+------+
| 1 | JamhusTao | 19 |
| 2 | Jhon | 20 |
+----+-----------+------+
1
2
3
4
5
6
+----+-----------+------+
| id | name | age |
+----+-----------+------+
| 1 | JamhusTao | 19 |
| 2 | Jhon | 20 |
+----+-----------+------+
1
2
3
4
5
6
7
+----+--------------+------+
| id | name | age |
+----+--------------+------+
| 1 | JamhusTao | 19 |
| 2 | Jhon | 20 |
| 4 | GreatLiangpi | 19 |
+----+--------------+------+

这种现象称之为不可重复读,即在另一事务 COMMIT 前后是不可重复读的。

可重复读

可重复读存在的幻读现象是指,一个事务读取到了另一事务新增或删除的记录,就好像幻觉一样。MySQL 在可重复读下,区分快照读(默认)和当前读(FOR UPDATE),如果不使用 FOR UPDATEMySQL 似乎并不会出现幻读现象,这一点我也不是很明确。

假设我们已经将隔离级别修改为 REPEATABLE-READ

事务 A 事务 B
BEGIN; BEGIN;
SELECT * FROM test WHERE age=19;
DELETE FROM test WHERE id=4;
COMMIT;
SELECT * FROM test WHERE age=19 FOR UPDATE;
COMMIT;
1
2
3
4
5
6
+----+--------------+------+
| id | name | age |
+----+--------------+------+
| 1 | JamhusTao | 19 |
| 4 | GreatLiangpi | 19 |
+----+--------------+------+
1
2
3
4
5
+----+-----------+------+
| id | name | age |
+----+-----------+------+
| 1 | JamhusTao | 19 |
+----+-----------+------+

这种现象称之为幻读。

可串行化

可串行化对于事务的所有操作都是串行化的,读写都被加锁。可串行化提供了最高的隔离级别,同时其并发效率也是最低的。

事务 A 事务 B
BEGIN; BEGIN;
SELECT * FROM test WHERE id=1;
UPDATE test SET name=“Liangpi” WHERE id=4;
UPDATE test SET name=“Jamhus” WHERE id=1;
COMMIT; COMMIT;
1
2
3
4
5
6
7
+----+--------------+------+
| id | name | age |
+----+--------------+------+
| 1 | JamhusTao | 19 |
| 2 | Jhon | 20 |
| 4 | GreatLiangpi | 19 |
+----+--------------+------+
1
Query OK, 1 row affected
1
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

MVCC 机制与锁

MVCC 机制全称多版本并发控制(Multi-Version Concurrency Control),是 InnoDB 中保障数据一致性的机制,通过 undo 日志链实现事务的多版本控制,提供的快照机制在保障一致性的同时提供更好的效率。值得一提的是,MVCC 机制并非普遍应用于数据库产品,许多其他数据库和 MySQL 支持的其他引擎并不支持 MVCC 机制。

下面我们将依次介绍 Undo Log 与 Read View 以及四种隔离级别在 MVCC 机制和锁原理下的实现。

Undo Log 与 Read View

undo log 用于记录数据的回退链。在数据库中存储的始终是最新的数据,而当数据需要回滚时以 undo log 链式回退,即自动执行一遍反向语句。一条 undo log 记录由三部分组成:row_id / trx_id / roll_ptr,它们是行记录中的隐藏字段,存储一个返祖树形结构,其中 roll_ptr 指向上一记录;另外 trx_id 与该树形结构无关,指向创建该记录的事务。

ReadViewSnapShot(快照),就是我前面在可重复读中提到的快照,用来判断一个事务是否可见另一事务,同时它与 Undo Log 结合用于查找回退链上的第一条可见记录。ReadView 的结构和可见性判断如下:

1
2
3
4
5
6
7
struct ReadView {
size_t creator_trx_id; // 创建该 ReadView 的事务 ID
size_t low_limit_id; // 定义为进行中事务最大 ID + 1, 所有大于等于该值的一定是不可见事务
size_t up_limit_id; // 定义为进行中事务最小 ID, 所有小于该值的一定是可见事务
std::set<int> m_ids; // 进行中事务集合
// ...
};
1
2
3
4
5
6
7
8
9
10
11
12
bool isVisible(trx_id) {
if (trx_id == creator_trx_id) // 事务对自己是可见的
return true;
if (trx_id < up_limit_id)
// up_limit_id 定义为进行中事务的最小 ID, 小于该值的一定是已提交事务
// 决策优化, 已提交事务可能是相当多的, 而最后一个决策是慢的 (通常是一个对数时间的实现)
return true;
if (trx_id >= low_limit_id)
// low_limit_id 在 REPEATABLE-READ 隔离级别中定义为 cur_id + 1, 大于等于该值的一定是晚于快照的事务
return false;
return not m_ids.count(trx_id); // 是否在进行中事务集合, 如果在则是不可见的
}

区别 Lock 与 Latch

讲到锁,必须区分 Lock 和 Latch 的概念。在并发编程中,我们经常将 Lock 和 Latch 混为一谈,在数据库中 Lock 和 Latch 必须严格区分:Latch 用于保证内存访问的互斥性,其生命周期到原子操作完成,可以分为 MutexRWLock 是我们熟悉的并发编程的锁;Lock 用于保证数据库对象的互斥访问,其生命周期直至事务完成,通常比 Latch 长。可以不大确切的说,Latch 的对象是内存,而 Lock 的对象是磁盘。

InnoDB 中可以简单的将锁按两种分类:共享锁与排他锁、多粒度锁。详见:详解事务中的锁 | JamhusTao の Blog

读未提交

READ-UNCOMMITTED 实际不需要 MVCC 机制,它始终不会创建 ReadView,而是使用最近的版本,也因此存在脏读的问题。

对于脏写的问题,READ-UNCOMMITTED 对数据修改时加记录写锁,在修改时获取,事务结束时释放。事务隔离级别中的所有事务均没有脏写的问题,其解决方案都是如此。下面提供一个示例:

事务 A 事务 B
BEGIN; BEGIN;
SELECT * FROM test;
UPDATE test SET name=“Jamhus” WHERE id=1;
UPDATE test SET age=21 WHERE id=2;
UPDATE test SET age=20 WHERE id=1;
COMMIT; COMMIT;
1
2
3
4
5
6
+----+-----------+------+
| id | name | age |
+----+-----------+------+
| 1 | JamhusTao | 19 |
| 2 | Jhon | 20 |
+----+-----------+------+
1
Query OK, 1 row affected
1
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

事务 B 的第一个 UPDATE 成功执行,而第二个等待锁超时。可以看出写操作施加的时记录写锁。

读已提交

READ-COMMITTED 在执行读操作时,临时创建 ReadView,这使得所有在创建 ReadView 之前已结束的事务都是可见的。后面即将提到,REPEATABLE-READ 仅仅是创建 ReadView 的时机不同,导致了两者的可见性不同。

可重复读

REPEATABLE-READ 在事务开始时创建 ReadView,事务结束时析构,这使得只有在事务开始前已结束的事务是可见的。

使用 ReadView 判断可见性的读称为快照读,而使用 FOR UPDATE 可以在事务内进行当前读。当前读虽然为读操作,但直接对数据加写锁。在可重复读下,InnoDB 使用间隙锁来尽可能解决幻读问题。

可串行化

SERIALIZABLE 提供了事务最高的隔离级别,它不存在脏读、不可重复读、幻读,也无需 MVCC 机制支持。

与前三种事务隔离级别不同的是,可串行化在读时加读锁、写时加写锁,从而保证事务串行。但是相对于前面三种事务隔离级别,SERIALIZABLE 加更多的锁降低了事务执行效率,同时更容易产生死锁。

事务 A 事务 B
SELECT * FROM test;
BEGIN; BEGIN;
SELECT * FROM test WHERE id=1;
UPDATE test SET age=21 WHERE id=2;
UPDATE test SET age=20 WHERE id=1;
COMMIT; COMMIT;
1
2
3
4
5
6
+----+-----------+------+
| id | name | age |
+----+-----------+------+
| 1 | JamhusTao | 19 |
| 2 | Jhon | 20 |
+----+-----------+------+
1
2
3
4
5
+----+-----------+------+
| id | name | age |
+----+-----------+------+
| 1 | JamhusTao | 19 |
+----+-----------+------+
1
Query OK, 1 row affected
1
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
事务 A 事务 B
BEGIN; BEGIN;
SELECT * FROM test WHERE id=1; SELECT * FROM test WHERE id=2;
UPDATE test SET age = age + 1 WHERE id=2; UPDATE test SET age = age + 1 WHERE id=1;
1
2
3
4
5
+----+-----------+------+
| id | name | age |
+----+-----------+------+
| 1 | JamhusTao | 19 |
+----+-----------+------+
1
2
3
4
5
+----+-----------+------+
| id | name | age |
+----+-----------+------+
| 2 | Jhon | 20 |
+----+-----------+------+
1
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transactio

好在死锁被 MySQL 发现并自动回滚了。