MySQL系列(6)— 事务基础

1,687 阅读20分钟

专栏系列文章:MySQL系列专栏

事务

事务(Transaction)是数据库系统执行过程中的一个逻辑处理单元,可由一条简单的SQL语句组成,也可以由一组复杂的SQL语句组成。在事务中的操作,要么都做修改,要么都不做,这就是事务的目的。

先准备下面一张账户表来供后面测试使用:

CREATE TABLE `account` (
  `id` bigint(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `card` varchar(60) NOT NULL COMMENT '卡号',
  `balance` int(11) NOT NULL DEFAULT '0' COMMENT '余额',
  PRIMARY KEY (`id`),
  UNIQUE KEY `account_u1` (`card`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账户表';

事务特性

标准上,事务必须同时满足四个特性,也就是事务的ACID特性。正是这些特性,才保证了数据库事务的安全性。不过数据库厂商出于各种目的,可能并不会严格满足事务的ACID标准。

  • 原子性(Atomicity)
  • 一致性(Consistency)
  • 隔离性(Isolation)
  • 持久性(Durability)

在MySQL中,MyISAM 存储引擎不支持事务,InnoDB 存储引擎在默认的READ REPEATABLE(RR)隔离级别下,完全遵循和满足事务的ACID特性,所以后续对事务的研究是基于 InnoDB 存储引擎的。

原子性(Atomicity)

原子性 指一个数据库事务中的所有操作是不可分割的单元,只有事务中所有的数据库操作都执行成功,才算整个事务成功。事务中任何一个SQL语句执行失败,已经执行成功的SQL语句也必须撤销,数据库状态应该退回到执行事务前的状态。

比如有下面一个转账操作,A 向 B 转账 100

BEGIN;
UPDATE account SET balance = balance - 100 WHERE card = "A";
UPDATE account SET balance = balance + 100 WHERE card = "B";
COMMIT;

这个转账操作就必须是一个原子操作,A 减去 100,B 加上 100,要么都成功,要么都回滚,不能有中间状态,任何一个SQL失败,都要回滚到执行事务前的状态。

在我们看来,就是两条SQL更新语句,其实在数据库层面,这两条SQL语句会涉及很多操作。在前面学习Buffer Pool的时候,就知道了首先需要将所在的数据页从磁盘加载到 Buffer Pool,然后更新内存中的页,再把页加入Flush链表,然后在某个时刻将脏页刷盘,其中任何一个步骤失败(比如数据库宕机)都需要回滚。

所以原子性是要保证数据库的整个操作过程都是原子的,其中任何一步失败都要撤销。InnoDB底层有一套复杂的机制来保证数据库操作的原子性,把已经做了的操作恢复成执行之前的样子,这块我们后面会用一篇文章来说明。

一致性(Consistency)

一致性 指事务将数据库从一种状态转变为下一种一致的状态。在事务开始之前和事务结束以后,数据库的完整性约束没有被破坏。

例如,account 表中 card 字段是唯一的,不管如何修改这个字段,在事务提交或事务回滚后,card 字段的数据都还是唯一。如果变得非唯一了,这就破坏了事务的一致性要求。

mysql> INSERT INTO account(card, balance) values ("A", 1000);
1062 - Duplicate entry 'A' for key 'account_u1'

要保证数据库中数据的一致性,主要有两个方面:

  • 数据库本身保证一部分一致性:MySQL数据库本身可以建立一些约束,例如为表建立主键、唯一索引、外键、声明某个列为NOT NULL等。

  • 业务层保证一致性:更多的情况下,具体业务场景中的约束会比较复杂,而且数据库建立约束会对数据库性能有一定损耗。所以往往我们会在业务代码层面来对数据做一致性校验。

隔离性(Isolation)

隔离性还有其他的称呼,如并发控制、可串行化、锁等。事务的隔离性要求每个读写事务的对象对其他事务的操作对象能相互分离,即该事务提交前对其他事务都不可见,通常这使用锁来实现。

当数据库上有多个事务同时执行的时候,就可能出现脏读、不可重复读、幻读的问题,这块我们后面会再细说。

持久性(Durability)

持久性要求事务一旦提交,其结果就是永久性的。即使发生宕机等故障,数据库也能将数据恢复。

需要注意的是,持久性是保证事务系统的高可靠性,而不是高可用性。事务本身能保证结果的永久性,在事务提交后,所有的变化都是永久的。但对于一些外部因素,如磁盘损坏、自然灾害等原因导致数据库发生问题,那么所有提交的数据可能都会丢失。对于高可用性的实现,事务本身并不能保证,需要一些系统共同配合来完成。

事务分类

从事务理论的角度来说,可以把事务分为以下几种类型:

  • 扁平事务(Flat Transactions)
  • 带有保存点的扁平事务(Flat Transactions with Savepoints)
  • 链事务(Chained Transactions)
  • 嵌套事务(Nested Transactions)
  • 分布式事务(Distributed Transactions)

对于InnoDB存储引擎来说,其支持扁平事务、带有保存点的事务、链事务、分布式事务。对于嵌套事务,其并不原生支持。

1、扁平事务

扁平事务是事务类型中最简单的一种,也是使用最为频繁的事务。在扁平事务中,所有操作都处于同一层次,由 BEGIN/START TRANSACTION 开始,由 COMMITROLLBACK 结束,其间的操作是原子的。

2、带有保存点的扁平事务

带有保存点的扁平事务允许在事务执行过程中回滚到同一事务中较早的一个状态。我们可以在事务过程中设置一些保存点(Savepoint),保存点用来通知系统应该记住事务当前的状态,以便当之后发生错误时,事务能回到保存点当时的状态。

对于扁平事务来说,其在事务开始的时候隐式地设置了一个保存点,扁平事务就只有这一个保存点,因此,回滚只能回滚到事务开始时的状态。

可以通过 SAVEPOINT 创建一个保存点,ROLLBACK TO SAVEPOINT 回滚到某个保存点。

3、链事务

链事务就是一个事务在提交的时候自动将上下文传给下一个事务,也就是说一个事务的提交和下一个事务的开始是原子性的,下一个事务可以看到上一个事务的结果,就好像在一个事务中进行的一样。

链事务可视为保存点模式的一种变种,不同的是,带有保存点的扁平事务能回滚到任意正确的保存点,而链事务中的回滚仅限于当前事务。

MySQL 的链式事务可以通过 SET completion_type = 1 来打开,后面会举例说明。

4、嵌套事务

嵌套事务是一个层次结构框架,由一个顶层事务控制着各个层次的事务。顶层事务之下嵌套的事务被称为子事务,其控制每一个局部的变换。子事务提交后不会真的提交,而是等到父事务提交才真正的提交,父事务回滚了,会回滚所有子事务。

MySQL 不支持嵌套事务,不过我们可以通过带有保存点的事务来模拟串行的嵌套事务。

5、分布式事务

分布式事务通常是一个在分布式环境下运行的扁平事务,需要根据数据所在位置访问网络中的不同节点。后面会有一个专门的系列来学习分布式事务。

事务控制语句

开启事务

可以使用 BEGIN [WORK] 或者 START TRANSACTION; 显示的开启一个事务。

在存储过程中,MySQL数据库的分析器会自动将BEGIN识别为BEGIN…END,因此在存储过程中只能使用START TRANSACTION语句来开启一个事务。

START TRANSACTION后边可以跟随几个修饰符:

  • READ ONLY:标识当前事务是一个只读事务,该事务中的数据库操作只能读取数据,不能修改数据。
  • READ WRITE:标识当前事务是一个读写事务,该事务中的数据库操作可以读取数据,也可以修改数据。
  • WITH CONSISTENT SNAPSHOT:启动一致性读。

如果不显式指定事务的访问模式,该事务的访问模式默认就是读写模式(READ WRITE)。

例如开启只读事务后,就不能修改数据了:

mysql> START TRANSACTION READ ONLY;
Query OK, 0 rows affected (0.00 sec)

mysql> UPDATE account SET balance = balance - 100 WHERE id = 100;
1792 - Cannot execute statement in a READ ONLY transaction.

提交事务

可以使用 COMMIT [WORK] 来显示提交事务,WORK有没有都可以。

1、自动提交事务

在MySQL命令行的默认设置下,事务都是自动提交的,即执行一条SQL语句后就会自动执行COMMIT操作。因此要显式地开启一个事务需使用命令BEGINSTART TRANSACTION,或者执行命令SET autocommit=O来禁用自动提交。

2、隐式提交事务

使用START TRANSACTIONBEGIN开启了一个事务,或者把系统变量autocommit的设置为OFF时,事务就不会进行自动提交。但某些数据库操作会自动隐式的提交事务,也不需要开始一个事务。

常见的隐式提交事务的语句包括:

  • 定义或修改数据库对象的数据定义语言(DDL),CREATE、ALTER、DROP、ALTER 等等。
  • START TRANSACTIONBEGIN 开启事务时,会自动提交上一个事务。
  • 其它的如 ANALYZE TABLE、FLUSH、OPTIMIZE TABLE、REPAIR TABLE 等语句也会隐式提交事务。

3、事务提交类型

我们可以通过参数completion_type来控制COMMIT后的行为,有三个值:

  • 0/NO_CHAIN:默认为NO_CHAIN,表示 COMMIT 后没有任何操作。
  • 1/CHAIN:设置为1CHAIN时,COMMIT等同于COMMIT AND CHAIN,表示在事务提交后马上自动开启一个相同隔离级别的新事务。
  • 2/RELEASE:设置为2RELEASE时,COMMIT等同于COMMIT AND RELEASE,表示在事务提交后会自动断开与服务器的连接。

可以看到completion_type默认值为 NO_CHAIN(0)

mysql> SHOW VARIABLES LIKE 'completion_type';
+-----------------+----------+
| Variable_name   | Value    |
+-----------------+----------+
| completion_type | NO_CHAIN |
+-----------------+----------+

completion_type参数只会影响BEGINSTART TRANSACTION开头,COMMITROLLBACK结尾的事务,不会影响自动提交的事务(AUTOCOMMIT=1)的事务。

  • completion_type设置为1

此时就变成了前面说的链事务。例如下面的操作,COMMIT之后立马开启了一个新的事务,所以"B"这条数据才可以被回滚。设置为0的情况下是不会回滚的。最后查询就只插入了"A"这条数据。

如果要开启链事务,可以直接使用 COMMIT [WORK] AND CHAIN; 来实现,而无需设置 completion_type=1

mysql> TRUNCATE account;
Query OK, 0 rows affected (0.01 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 account(card) VALUES ("A");
Query OK, 1 row affected (0.01 sec)

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

mysql> INSERT INTO account(card) VALUES ("B");
Query OK, 1 row affected (0.00 sec)

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

mysql> SELECT * FROM account;
+----+------+---------+
| id | card | balance |
+----+------+---------+
|  1 | A    |       0 |
+----+------+---------+
  • completion_type设置为2

此时 COMMIT 之后就会断开连接,再操作就会报连接断开的错误,有些客户端会自动尝试重新连接。

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 account(card) VALUES ("C");
Query OK, 1 row affected (0.00 sec)

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

mysql> SELECT * FROM account WHERE card = "C";
ERROR 2006 (HY000): MySQL server has gone away
No connection. Trying to reconnect...
Connection id:    3
Current database: test

+----+------+---------+
| id | card | balance |
+----+------+---------+
|  1 | C    |       0 |
+----+------+---------+
1 row in set (0.00 sec)

终止事务

可以使用 ROLLBACK [WORK] 来终止事务,撤销正在进行的未提交的修改。

要注意的是,ROLLBACK 语句是手动的回滚事务时才去使用的,如果事务在执行过程中遇到了某些错误而无法继续执行的话,事务自身会自动的回滚。

保存点

保存点的操作方法如下:

  • 创建保存点:SAVEPOINT <identifier>
  • 删除保存点:RELEASE SAVEPOINT <identifier>
  • 回滚到保存点:ROLLBACK TO [SAVEPOINT] <identifier>

需要注意的是,ROLLBACK TO SAVEPOINT,只是回滚到了指定的保存点,其并不是真正地结束一个事务,最后还需要显式地运行COMMITROLLBACK命令。

例如下面的操作,

mysql> TRUNCATE account;
Query OK, 0 rows affected (0.01 sec)

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

mysql> INSERT INTO account(card) VALUES("A");
Query OK, 1 row affected (0.00 sec)

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

mysql> INSERT INTO account(card) VALUES("B");
Query OK, 1 row affected (0.00 sec)

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

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

mysql> SELECT * FROM account;
+----+------+---------+
| id | card | balance |
+----+------+---------+
|  1 | A    |       0 |
+----+------+---------+
1 row in set (0.00 sec)

并发事务

并发事务带来的问题

我们的业务系统往往都是多线程并发执行多个事务,数据库层面也会多个事务并发执行,那么就可能会对同一条数据查询和修改。既然是并发,跟Java中的多线程一样,就会有线程安全问题。

并发事务涉及到四个问题:脏写、脏读、不可重复读、幻读。按问题的严重程度排个序就是:脏写 > 脏读 > 不可重复读 > 幻读

脏读

如果一个事务A读到了另一个事务B修改过的未提交的数据,那事务A的读取就是脏读,因为事务A读取的数据是非持久性的数据。

例如按下面的时间线执行:事务B更新了数据,事务A读取了事务B未提交的数据,但是在t5时刻事务B回滚了此次操作,事务A查询到的数据就是脏数据,如果继续用这个脏数据做业务就会有问题。

TimelineSession ASession B
t1BEGIN;BEGIN;
t2查询余额为100;查询余额为100;
t3余额增加100;
t4查询约为200;
t5ROLLBACK;
t6COMMIT;
t7A事务读取到B事务未提交的数据,导致读取到的是脏数据

不可重复读

在没有脏读的情况下,如果一个事务多次读取同一个数据不一致,那说明发生了不可重复读的问题,也就是同一个数据无法重复读取,违反了数据库事务一致性的要求。

例如按下面的时间线执行:在事务A中,第一次查询为100,此时事务B修改了余额并且提交了事务,事务A再次查询就读取到事务B已提交的数据,在同一个事务中,两次查询的结果不一致,就是不可重复读取。

TimelineSession ASession B
t1BEGIN;BEGIN;
t2查询余额为100;查询余额为100;
t3余额增加100;
t4COMMIT;
t5查询余额为200;
t6COMMIT;
t7A事务读取到B事务已提交的数据,多次读取同一条数据不一致

其实不可重复读在一些场景下也可以认为不是问题,比如我就希望在一个事务中,别的事务修改了数据,我立马也能读到,那就是不可重复读的。如果希望在一个事务中多次读取是一样的,就是可重复读。

幻读

幻读就是一个事务用同样的条件查询,由于另一个事务新增了数据,导致看到了之前没有的数据。

例如按下面的时间线执行,事务A将所有账户余额都改为100了,然后事务B新增了一个账户,结果事务A再次查询发现还有一个账户的余额为0,之前更新的数据中是没有这条记录的,这就是幻读。

TimelineSession ASession B
t1BEGIN;BEGIN;
t2更新所有账户余额为100;
t3新增一个账户,余额为0;
t4COMMIT;
t5查询发现还有一个账户余额为0;
t6COMMIT;
t7由于B事务新增数据,导致A事务操作之后还有之前没看到过的数据

脏写

脏写也称为数据丢失、更新丢失,简单来说就是一个事务的更新操作会被另一个事务的更新操作所覆盖,从而导致数据的不一致。

有两类情况会导致脏写:

1、事务A回滚把事务B已提交的修改给覆盖了,就会造成事务B的修改丢失。

例如按下面的时间线执行,事务A、B开始时查询余额都为0,事务B修改余额增加了200,事务A回滚将余额变为0,事务B看起来就是修改的数据丢失了。

TimelineSession ASession B
t1BEGIN;BEGIN;
t2查询余额为0;查询余额为0;
t3余额增加100;
t4余额增加200;
t5COMMIT;
t6ROLLBACK;
t7查询余额为0;查询余额为0;
t8由于事务A回滚,导致事务B更新的数据没了

不过InnoDB存储引擎不会发生这个问题,因为InnoDB在更新数据时加了排他锁, 这样在事务A在未完成的时候, 其他事务是无法对事务A涉及到的数据做修改并提交的。例如上面的示意图中,事务B在执行余额增加200的时候,由于事务A修改了同一条数据且未提交,这时这条数据已经加了排它锁了,因此事务B修改时会阻塞住,等待加锁后才能修改。

2、事务A覆盖了事务B已提交的修改,造成事务B的修改丢失。

例如按下面的时间线执行,事务A、B一开始查询余额都为0,事务B先增加了200,并提交了事务。接着事务A在余额为0的基础上增加100,然后提交事务。最后就是余额只有100,事务B的修改丢失了。

TimelineSession ASession B
t1BEGIN;BEGIN;
t2查询余额为0;查询余额为0;
t3余额增加200;
t4COMMIT;
t5余额增加100;
t6COMMIT;
t7查询余额为100;查询余额为100;
t8事务A将事务B提交的修改覆盖掉,导致事务B的修改丢失

这种情况有两种方式可以避免脏写发生:

一种是基于数据库悲观锁,在查询时使用 for update 实现一个排它锁,保证在该事务结束时其他事务无法更新该数据。不过这样就会导致并发更新的性能降低。

SELECT * FROM account WHERE id = 1 FOR UPDATE;

另一种是基于乐观锁,可以在表中增加一个版本号字段,查询时将版本号查出来,更新时带上版本号作为条件,更新成功则是同一条记录,否则就时更新失败。更新失败就可以返回“记录不存在或版本不一致”这样的错误,让用户可以重新查询再更新一次。

UPDATE account SET balance=balance+100, version=version+1 where id = 1 and version = 1

事务隔离级别

SQL标准事务隔离级别

前面说到并发事务有四个问题:脏写、脏读、不可重复度、幻读。其中,脏写可以通过乐观锁或悲观锁的方式来解决,剩下的3个问题,其实是数据库读一致性造成的,需要数据库提供一定的事务隔离机制来解决,也就是事务的隔离性

SQL标准定义了四个隔离级别:

  • READ UNCOMMITTED:读未提交,简称 RU
  • READ COMMITTED:读已提交,简称 RC
  • REPEATABLE READ:可重复读,简称 RR
  • SERIALIZABLE:可串行化。

不同的隔离级别,分别能解决一部分事务问题,具体情况可查看下面的表格。

  • READ UNCOMMITTED:会发生脏读、不可重复读、幻读的问题。
  • READ COMMITTED:会发生不可重复读、幻读的问题,不会发生脏读的问题。
  • REPEATABLE READ:会发生幻读的问题,不会发生脏读、不可重复读的问题。
  • SERIALIZABLE:脏读、不可重复读、幻读的问题都不会发生。

image.png

隔离级别越低,可能产生的问题越严重;隔离级别越高,并发的性能也会越低。不过很少有数据库厂商遵循这些SQL标准,比如Oracle数据库就不支持READ UNCOMMITTEDREPEATABLE READ的事务隔离级别。

MySQL事务隔离级别

InnoDB存储引擎支持SQL标准的四种隔离级别,不过InnoDB在REPEATABLE READ隔离级别下就能避免幻读问题的发生。

MySQL的默认隔离级别为REPEATABLE READ,可以通过下面的语句修改事务的隔离级别:

SET [GLOBAL|SESSION] TRANSACTION ISOLATION LEVEL <level>;

level 可选值:
    READ UNCOMMITTED
    READ COMMITTED
    REPEATABLE READ
    SERIALIZABLE

如果想在服务器启动时修改事务的默认隔离级别,可以在[mysqld]下添加参数transaction-isolation

[mysqld]
transaction-isolation = READ-COMMITTED

查看当前会话的事务隔离级别:

mysql> SELECT @@tx_isolation;
+-----------------+
| @@tx_isolation  |
+-----------------+
| REPEATABLE-READ |
+-----------------+

查看全局的事务隔离级别:

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

事务特性实现

数据库底层有一套复杂的机制来实现事务的ACID特性,这节做个简单说明,接下来会用几篇单独的文章来介绍。

1、持久性(D)

事务的持久性通过数据库的redo log来实现,redo log称为重做日志。在更新Buffer Pool中的数据页时,会同时记录对应的 redo log,这样就算脏页没有刷盘,在MySQL宕机重启时,也可以通过 redo log 来恢复数据。

2、原子性(A)

事务的原子性通过数据库的undo log来实现,undo log称为撤销日志或回滚日志。在一个事务中进行增删改操作时,都会记录对应的 undo log。

  • 当 delete 一条数据的时候,就记录这条数据的信息,回滚的时候,insert 这条旧数据
  • 当 update 一条数据的时候,就记录之前的旧值,回滚的时候,根据旧值执行 update 操作
  • 当 insert 一条数据的时候,就这条记录的主键,回滚的时候,根据主键执行 delete 操作

而且 undo log 形成的版本链还用于实现多版本并发控制(MVCC),InnoDB的RCRR隔离级别就是是基于MVCC来实现高性能事务,而且通过MVCC来避免幻读的发生。

3、隔离性(I)

事务的隔离性由来实现,不同的加锁方式,可以实现不同的事务隔离机制。

4、一致性(C)

事务的一致性需要两个层面来保证:

  • 数据库层面:数据库必须要实现AID三大特性,才有可能实现一致性。例如,原子性无法保证,显然一致性也无法保证。
  • 应用层面:如果在事务里故意写出违反约束的代码,一致性还是无法保证的。应用层面应该通过代码判断数据库数据是否有效,然后决定回滚还是提交数据。

可以看到,原子性、持久性、隔离性是数据库层面保证持久性的手段。因此,我们后面会分别针对原子性、持久性、隔离性单独用一篇文章来学习。