前言
上一篇介绍了 MySQL 的索引,这一篇将介绍事务相关的内容。
在 MySQL 的使用场景中,经常会有一个操作包含多个 SQL 语句,比如转账这个操作,至少包含从甲的账户中扣除金额和给乙的账户中增加金额这两个更新语句。那假如 MySQL 在执行途中发生了崩溃,就可能导致第一个操作成功而第二个操作失败,从而就会导致系统出错。为了解决这一类问题,MySQL 实现了事务,事务通常是由一组 SQL 组成的,这些 SQL 语句要么全部执行成功,要么全部执行失败,不会发生部分成功的情况。
实现事务(transaction)必须要遵守四个特性(ACID):
- 原子性(Atomicity):一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节,而且事务在执行过程中发生错误,会被回滚到事务开始前的状态,就像这个事务从来没有执行过一样。
- 一致性(Consistency):是指事务操作前和操作后,数据满足完整性约束,数据库保持一致性状态。以转账为例,事务操作的前后,系统中的总金额保持一致。
- 隔离性(Isolation):数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致,因为多个事务同时使用相同的数据时,不会相互干扰,每个事务都有一个完整的数据空间,对其他并发事务是隔离的。
- 持久性(Durability):事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。
并发问题
如果不对并发操作做任何限制,则会存在许多问题,常见的有脏读、不可重复读和幻读等问题。
脏读
如果一个事务「读到」了另一个「未提交事务修改过的数据」,就意味着发生了「脏读」现象。
不可重复读
在一个事务内多次读取同一个数据,如果出现前后两次读到的数据不一样的情况,就意味着发生了「不可重复读」现象。
幻读
幻读指的是一个事务在前后两次查询同一个范围的时候,后一次查询看到了前一次查询没有看到的行(即存在插入操作)。
隔离级别
SQL 标准提出了四种隔离级别来规避这些现象,隔离级别越高,性能效率就越低,这四个隔离级别按照隔离水平从低到高如下。
不同的事务隔离级别,其实是基于不同类型和粒度的锁实现的,后续会再详细介绍。
未提交读
Read Uncommitted,RU。是基于写互斥锁实现的,但是由于读操作并没有被阻塞,所以一个事务还没提交时,它做的变更就能被其他事务看到。因此可能存在脏读、不可重复读、幻读问题。
在该隔离级别下,执行完 SQL 语句后就会释放锁。
提交读
Read Committed,RC。在写互斥锁的基础上,使用了 MVCC 技术,当一个事务修改数据时,其他事务读取到的是旧数据的值,但是修改事务提交后,变更就能被其他事务读取到,所以可能存在不可重复读、幻读问题。
在提交读级别中,锁是在事务结束后才释放的。
可重复读
Repeatable Read,RR。和提交读一样使用了 MVCC,但是不是在每次查询时都创建新的 ReadView,而是在事务开始时创建,所以一个事务中的多次读取都是相同的,不会读取到其他事务做的变更。是 InnoDB 的默认隔离级别。仍然可能存在幻读问题。
在可重复读级别中,锁是在事务结束后才释放的。
MySQL 读取数据有两种读取模式:快照读和当前读。
- 快照读(普通 select 语句,又称为一致性读):读的是快照生成时候的数据。是通过 MVCC 实现的。
- 当前读(select … for update、select…lock in share mode、update、insert、delete):每次读取的都是当前最新的数据,但是读的时候不允许写,写的时候也不允许读。是通过临键锁实现的。
可重复读隔离级别能在很大程度上避免幻读现象:
- 针对快照读,是通过 MVCC 方式解决了幻读,因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,是查询不出来这条数据的,所以就很好了避免幻读问题。
- 针对当前读,如果是读取行数据,则加记录锁;如果是读取范围行数据,则通过 next-key lock(记录锁+间隙锁)方式解决了幻读,加上 next-key lock 后,如果有其他事务在锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好了避免幻读问题。
即使把所有的记录都加上锁,还是阻止不了新插入的记录,这也是为什么“幻读”会被单独拿出来解决的原因。也是间隙锁存在的意义。
但是仍然有可能发生幻读:
- 对于快照读:MVCC 并不能完全避免幻读现象。因为当事务 A 更新了一条事务 B 插入的记录,那么事务 A 前后两次查询的记录条目就不一样了,所以就发生幻读。
- 对于当前读,如果事务开启后,并没有执行当前读,而是先快照读,然后这期间如果其他事务插入了一条记录,那么事务后续使用当前读进行查询的时候,就会发现两次查询的记录条目就不一样了,所以就发生幻读。
串行化
Serializable。使用读写锁,将并发执行改为顺序执行,能从根源上避免并发问题,但是性能很差。
MVCC
Multiversion concurrency control,多版本并发控制。通过「版本链」来控制并发事务访问同一个记录时的行为。MVCC 主要通过隐藏字段、undo log、ReadView 来实现。
隐藏字段
在第一篇文章中介绍数据行结构时就提到过,记录的真实数据除了列数据以外,还会为每个记录默认添加一些列(也称为隐藏列),其中有两个就是用于实现 MVCC 的:
- DB_TRX_ID(transaction_id):事务 id,占 6 字节。当一个事务对某条记录进行改动时,就会把该事务的事务 id 记录在 trx_id 隐藏列里;
- DB_ROLL_PTR(roll_pointer):回滚指针,占 7 字节。每次对某条记录进行改动时,都会把旧版本的记录写入到 undo 日志中,然后这个隐藏列是个指针,指向每一个旧版本记录,于是就可以通过它找到修改前的记录。
Undo Log
MySQL 对数据进行修改操作时,并不是直接覆盖数据,而是通过 undo log 形成一个版本链。关于 undo log 后面在日志部分会进行详细介绍。
ReadView
ReadView 相当于数据快照。「读提交」隔离级别是在「每个语句执行前」都会重新生成一个 ReadView,而「可重复读」隔离级别是「启动事务时」生成一个 ReadView,然后整个事务期间都在用这个 ReadView。
ReadView 有四个字段:
- m_ids :指的是在创建 ReadView 时,当前数据库中「活跃事务」的事务 id 列表,“活跃事务”指的就是,启动了但还没提交的事务。
- min_trx_id :指的是在创建 ReadView 时,当前数据库中「活跃事务」中事务 id 最小的事务,也就是 m_ids 的最小值。
- max_trx_id :这个并不是 m_ids 的最大值,而是创建 ReadView 时当前数据库中应该给下一个事务的 id 值,也就是全局事务中最大的事务 id 值 + 1;
- creator_trx_id :指的是创建该 ReadView 的事务的事务 id。
一个事务去访问记录的时候,除了自己的更新记录总是可见之外,还有这几种情况:
- 如果记录的 trx_id 值小于 ReadView 中的 min_trx_id 值,表示这个版本的记录是在创建 ReadView 前已经提交的事务生成的,所以该版本的记录对当前事务可见。
- 如果记录的 trx_id 值大于等于 ReadView 中的 max_trx_id 值,表示这个版本的记录是在创建 ReadView 后才启动的事务生成的,所以该版本的记录对当前事务不可见。
- 如果记录的 trx_id 值在 ReadView 的 min_trx_id 和 max_trx_id 之间,需要判断 trx_id 是否在 m_ids 列表中:
- 如果记录的 trx_id 在 m_ids 列表中,表示生成该版本记录的活跃事务依然活跃着(还没提交事务),所以该版本的记录对当前事务不可见。
- 如果记录的 trx_id 不在 m_ids列表中,表示生成该版本记录的活跃事务已经被提交,所以该版本的记录对当前事务可见。
这种通过「版本链」来控制并发事务访问同一个记录时的行为就叫多版本并发控制。
嵌套事务
MySQL 是不支持嵌套事务的,开启了一个事务的情况下,再开启一个事务,会隐式的提交上一个事务。
但是可以用 savepoint 和 rollback to 实现嵌套事务的功能。
示例:
START TRANSACTION;
SAVEPOINT a;
UPDATE `user` SET stock = 1 WHERE id = 1;
SAVEPOINT b;
UPDATE `user` SET stock = 2 WHERE id = 1;
ROLLBACK TO b;
commit;
最后
本文介绍了 MySQL 事务相关的内容,包括常见的并发问题:脏读、不可重复读和幻读,以及四个事务的隔离级别,其中最重要的是 InnoDB 默认的隔离级别:可重复读,在这种隔离级别下,通过 MVCC 极大的避免了快照读时的幻读问题。
下一节将介绍 MySQL 的锁。