事务
在 MySQL
中事务提供三个关键字:BEGIN
(开始事务)、COMMIT
(提交事务)、ROLLBACK
(回滚事务)。
事务以 BEGIN
开始,以 COMMIT
或 ROLLBACK
结束,如果事务提交那么正常结束,如果事务回滚它会回到 BEGIN
的状态。事务的作用在于,它将整个事务视为一个原子操作,必须全部完成,否则回滚。例如,我们在做一笔交易时,扣款和提交订单必须绑定完成,如果流程中出现任何故障它必须回滚。
1 2 3 4
| BEGIN; INSERT INTO test VALUES(2, "Jhon", 20); SELECT * FROM test; COMMIT;
|
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 2 3 4
| BEGIN; INSERT INTO test VALUES(3, "Mike", 20); SELECT * FROM test; ROLLBACK;
|
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 2 3
| BEGIN; TRUNCATE TABLE test; ROLLBACK;
|
1 2 3 4 5 6
| +----+-----------+------+ | id | name | age | +----+-----------+------+ | 1 | JamhusTao | 19 | | 2 | Mike | 20 | +----+-----------+------+
|
不是说好的会回滚嘛???
只有 INSERT
/ DELETE
/ UPDATE
/ SELECT
是 DML
语句。
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-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 UPDATE
MySQL 似乎并不会出现幻读现象,这一点我也不是很明确。
假设我们已经将隔离级别修改为 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
与该树形结构无关,指向创建该记录的事务。
ReadView
又 SnapShot
(快照),就是我前面在可重复读中提到的快照,用来判断一个事务是否可见另一事务,同时它与 Undo Log
结合用于查找回退链上的第一条可见记录。ReadView
的结构和可见性判断如下:
1 2 3 4 5 6 7
| struct ReadView { size_t creator_trx_id; size_t low_limit_id; size_t up_limit_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) return true; if (trx_id >= low_limit_id) return false; return not m_ids.count(trx_id); }
|
区别 Lock 与 Latch
讲到锁,必须区分 Lock 和 Latch 的概念。在并发编程中,我们经常将 Lock 和 Latch 混为一谈,在数据库中 Lock 和 Latch 必须严格区分:Latch 用于保证内存访问的互斥性,其生命周期到原子操作完成,可以分为 Mutex
和 RWLock
是我们熟悉的并发编程的锁;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 发现并自动回滚了。