MySQL的事务

3,728 阅读11分钟

MySQL的事务

提到事务首先想到的当然是事务的四个特性:原子性、一致性、隔离性、持久性。事务的实现是由引擎层面来实现的,因此不同的存储引擎可能对事务有不同的实现方案。比如 MySQL 的 MyISAM 引擎就没有实现事务,这也是其被 InnoDB 所替代的原因之一。

事务的四个特性

原子性: 事务的所有操作在数据库中要么全部正确的反映出来,要么完全不反映。

一致性: 事务执行前后数据库的数据保持一致。

隔离性: 多个事务并发执行时,对于任何一对事务Ti和Tj,在Ti看来,Tj 要么在 Ti 之前已经完成执行,或者在Ti完成之后开始执行。因此,每个事务都感觉不到系统中有其他事务在并发执行。

持久性: 一个事务成功完成后,它对数据库的改变必须是永久的,即使事务刚提交机器就宕机了数据也不能丢。

事务的原子性和持久性比较好理解,但是一致性会更加抽象一些。对于一致性经常有个转账的例子,A 给 B 转账,转账前后 A 和 B 的账户总和不变就是一致的。这个例子咋一看好像很清楚,但转念一想原子性是不是也能达到这个目的呢?答案是:不能,原子性可以保证 A 账户扣减和 B 账户增加同时成功或者同时失败,但是并不能保证 A 扣减的数量等于 B 增加的数量。实际上是为了达到一致性所以要同时满足其他三个条件。

还有一个事务的隔离性比较复杂,因为 MySQL 的事务可以有多种隔离级别,接下里一起看看。

事务的隔离级别

当多个事务并发执行时可能存在脏读(dirty read),不可重复读(non-repeatable read)和幻读(phantom read),为了解决这些问题因此引入了不同的隔离级别。

脏读: 事务 A 和事务 B 并发执行时,事务 B 可以读到事务 A 未提交的数据,就发生了脏读。脏读的本质在于事务 B 读了事务 A 未提交的数据,如果事务 A 发生了回滚,那么事务 B 读到的数据实际上是无效的。如下面案例所示:事务 B 查询到 value 的结果为100,但是因为事务 A 发生了回滚,因此 value 的值不一定是 100。

事务 A 事务 B
begin begin
update t set value = 100
select value from t
rollback
commit commit

不可重复读: 在一个事务中,多次查询同一个数据会得到不同的结果,就叫不可重复读。如下面案例所示:事务 B 两次查询 value 的结果不一致。

事务 A 事务 B
begin begin
update t set value = 100
select value from t ( value = 100 )
update t set value = 200
select value from t ( value = 200 )
commit commit

幻读: 在一个事务中进行范围查询,查询到了一定条数的数据,但是这个时候又有新的数据插入就导致数据库中数据多了一行,这就是幻读。如下面案例所示:事务 B 两次查询到的数据行数不一样。

事务 A 事务 B
begin begin
select * from t
insert into t ...
commit
select * from t
commit

MySQL 的事务隔离级别包括:读未提交(read uncommitted)、读提交(read committed)可重复读(repeatable read)和串行化(serializable)。

未提交读: 一个事务还未提交,其造成的更新就可以被其他事务看到。这就造成了脏读。

读提交: 一个事务提交后,其更改才能被其他事务所看到。读提交解决了脏读的问题。

可重复读: 在一个事务中,多次读取同一个数据得到的结果总是相同的,即使有其他事务更新了这个数据并提交成功了。可重复读解决了不可重复读的问题。但是还是会出现幻读。InnoDB 引擎通过多版本并发控制(Multiversion concurrency control,MVCC)解决了幻读的问题。

串行化: 串行话是最严格的隔离级别,在事务中对读操作加读锁,对写操作加写锁,所以可能会出现大量锁争用的场景。

从上到下,隔离级别越来越高,效率相应也会随之降低,对于不同的隔离级别需要根据业务场景进行合理选择。

查询和修改事务的隔离级别

下面的命令可以查询 InnoDB 引擎全局的隔离级别和当前会话的隔离级别

mysql> select @@global.tx_isolation,@@tx_isolation;
+-----------------------+-----------------+
| @@global.tx_isolation | @@tx_isolation  |
+-----------------------+-----------------+
| REPEATABLE-READ       | REPEATABLE-READ |
+-----------------------+-----------------+

设置innodb的事务级别方法是:

set 作用域 transaction isolation level 事务隔离级别

SET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL {READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE}

mysql> set global transaction isolation level read committed; // 设定全局的隔离级别为读提交

mysql> set session transaction isolation level read committed; // 设定当前会话的隔离级别为读提交

事务的启动方式

  • MySQL 里可以通过 begin 命令或 start transaction 来显示启动一个事务。显示开启的事务,需要使用 commit 命令进行提交。

  • MySQL 里如果没有显示执行命令开启事务,MySQL 也会在执行第一条命令的时候自动开启事务。如果自动提交 autocommit 处于开启状态,那么自动开启的事务也会被自动提交。那么执行一条 select 语句时,MySQL 首先会自动开启一个事务,并且在 select 语句执行完后自动提交。因此,在 MySQL 里执行一条语句时也是一个完整的事务。

  • 在 MySQL 里执行命令 set autocommit=0 可以关闭事务的自动提交。如果 autocommit 处于关闭状态,那么执行一条 select 语句时仍然会开启一个事务,并且在执行完成后不会自动提交。

  • begin 和 start transaction 命令并不是执行后立即开启一个事务,而是在执行第一条语句时才开启事务。start transaction with consistent snapshot 命令才是执行后就立即开启事务。

举例说明不同隔离级别的影响

接下来我们用一个案例来看不同隔离级别下会有怎样不同的结果。

create table t (k int) ENGINE=InnoDB;
insert into t values (1);
事务 A 事务 B
begin
1: select k from t
begin; update t set k = k + 1
2: select k from t
commit
3: select k from t
commit
4: select k from t

隔离级别为未提交读时:对于事务 A,第1条查询语句的结果是1,第2条查询语句的结果是2,第3条和第4条查询语句的结果也都是2。

隔离级别为读提交时:对于事务 A,第1条查询语句的结果是1,第2条查询语句的结果是1,第3条查询语句的结果是2,第4条查询语句的结果也是2。

隔离级别为可重复读时:对于事务 A,第1条、第2条和第3条查询语句的结果都是1,第4条查询语句的结果是2。

隔离级别为串行化时:对于事务 A,第1条查询语句的结果是1。这时事务 B 执行更新语句时会被阻塞,因为事务 A 在这条数据上加上了读锁,事务 B 要更新这个数据就必须加写锁,由于读锁和写锁冲突,因此事务 B 只能等到事务 A 提交后释放读锁才能进行更新。因此,事务 A 的第2条和第3条查询语句的结果也是1,第4条查询语句的结果是2。

事务隔离性的实现

事务的隔离性通过 undo log 日志来实现,对于同一条数据,InnoDB 会存储其多个版本,多个版本则是通过 undo log 日志来实现,将当前值回滚不同的次数就可以得到不同低版本的数据,这就是数据库的多版本并发控制(MVCC)。当然只有 undo log 日志还不行,为了支持提交读和可重复读两种隔离级别,一个事务 Ti 如何知道自己应该使用哪个版本的数据呢?InnoDB 的做法是维护一个一致性视图来现实。

InnoDB 给每一个事务维护一个唯一的事务 ID,事务 ID 是严格递增分配的,也就是后开启的事务的事务 ID 一定比先开启的事务的事务 ID 要大。因为通过 undo log 日志可以得到多个版本的数据,可以假想在数据库中每个数据有多个版本。每个事务更新一个数据时,就会生成一个新版本数据并且将自己的事务 ID 贴在这个版本的数据上,用来标识这个数据的版本。

当开启一个新的事务时,InnoDB 会为每一个事务维护一个数组,这个数组中保存了当前活跃的事务的事务ID,所谓活跃的事务指事务已经开始,但是还未提交的事务。在这个数组中最小的事务 ID 将其称为低水位,最大的事务 ID 加1称为高水位。当某个事务读取某条数据时,从该数据的最高版本开始,如果读得起那么就取这个数据,如果读不起就取更低一个版本的数据,如此循环,直到能读取有效数据。

在判断读得起和读不起时就只有以下几种情况:

  1. 数据版本号大于等于事务的高水位,说明是后面的事务创建的,读不起;

  2. 数据版本号小于等于低水位,说明是事务开启前就已经提交的,或者是本事务自己修改的,读得起;

  3. 数据版本号介于高水位和低水位之间,如果该版本号在数组里,说明是未提交的,读不起。

  4. 数据版本号介于高水位和低水位之间,如果该版本号不在数组里,说明是已经提交的,读的起。

提交读和可重复读的区别在于,提交读每次执行语句前更新这个数组,这样已经提交的数据就不在数组里,就会被看到,可重复读就是始终使用事务开启时生成的数组。

快照读和当前读

InnoDB 给每一个事务生成一个唯一事务 ID 的方法称为生成快照,因此这种场景称为快照读。但是对于更新数据不能使用快照读,因为更新数据时如果使用快照读会可能会覆盖其他事务的更改。另外查询时如果加锁也会采用当前读的方式。当前读就是读这个数据最新的提交数据。InnoDB 的多版本并发控制实现了在串行化的隔离级别下读不加锁,提高了并发性能。

下面通过一个例子来理解快照读和当前读:

首先建一个表 t,并插入一条数据。

mysql-> create table t(k int)ENGINE=InnoDB;
mysql-> insert into t(k) values (1);

然后将事务的隔离级别设置为 REPEATABLE-READ,接着开启三个事务,并按照下面的顺序进行执行。

事务 A 事务 B 事务 C
start transaction with consistent snapshot
start transaction with consistent snapshot
select k fromt t;
select k from t;
update t set k = k + 1;
update t set k = k + 1;
select k from t; commit;
select k from t; commit;

结果是:事务 A 两次读取的结果都是1,事务 B 第一次读取的结果是1,第二次读取的结果是 3。事务 A 两次都是快照读,在可重复读的隔离级别下,因此两次读到的结果相同。事务 B 第一次是快照读,但是 update 语句进行了一次当前读将 k 的值更新为事务 C 已经提交的结果 2,并且在此基础上再加1得到3。执行了 update 操作时会创建一个新版本的数据,并且将自己的事务 ID 作为该数据的版本号,因此在该事务内可以读到自己更新的数据。因此事务 B 最后一次查询的结果是 3。

最近在学习 MySQL 的原理,一篇文章做个笔记。

参考

[1] 数据库系统概念(第6版)

[2] MySQL实战45讲,林晓斌

[3] 高性能MySQL(第3版)

[4] 事务的隔离级别和mysql事务隔离级别修改

[5] MySQL 加锁处理分析, 何登成