楔子
本次来聊一聊事务,首先事务一般指的是逻辑上的一组操作,或者作为单个逻辑单元执行的一系列操作。同属于一个事务的操作会作为一个整体提交给系统,这些操作要么全部执行成功,要么全部执行失败。
下面就简单地介绍一下事务的特性。
事务的特性
总体来说,事务存在四大特性,分别是原子性(Atomic)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability),因此事务的四大特性又被称为 ACID。
原子性:
事务的原子性指的是构成事务的所有操作要么全部执行成功,要么全部执行失败,不会出现部分执行成功,部分执行失败的情况。
例如在转账业务中,张三向李四转账 100 元,于是张三的账户余额减少 100 元,李四的账户余额增加 100 元。在开启事务的情况下,这两个操作要么全部执行成功,要么全部执行失败,不可能出现只将张三的账户余额减少 100 元的操作,也不可能出现只将李四的账户余额增加 100 元的操作。
一致性:
事务的一致性指的是事务在执行前和执行后,数据库中已存在的约束不会被打破。
比如余额必须大于等于 0 就是一个约束,而张三余额只有 90 元,这个时候如果转账 100 元给李四,那么之后它的余额就变成了 -10,此时就破坏了数据库的约束。所以数据库认为这个事务是不合法的,因此执行失败。
隔离性:
事务的隔离性指的是并发执行的两个事务之间互不干扰,也就是说,一个事务在执行过程中不会影响其它事务运行。
持久性:
事务的持久性指的是事务提交完成后,对数据的更改操作会被持久化到数据库中,并且不会被回滚。
例如张三向李四转账,在同一事务中执行扣减张三账户余额和增加李四账户余额操作。事务提交完成后,这种对数据的修改操作就会被持久化到数据库中,且不会被回滚,因为已经被提交了,而回滚是在事务执行之后、事务提交之前发生的。
所以数据库的事务在实现时,会将一次事务中包含的所有操作全部封装成一个不可分割的执行单元,这个单元中的所有操作必须全部执行成功,事务才算成功。只要其中任意一个操作执行失败,整个事务就会执行回滚操作,即自动回滚(当然也可以手动回滚)。但执行成功之后,就无法再回滚了,因为事务已经结束了。
如何在 MySQL 中开启事务
那么在 MySQL 中如何开启一个事务呢?语法如下:
-- 开启事务 -- begin 也可以写成 start transaction begin -- 执行一系列操作,这些操作是一个整体 insert into table(id, name) values (1, 'satori') update table set name = 'koishi' where id = 1 delete from table where id = 2 -- 执行 commit 表示提交事务 -- 执行 rollback 表示回滚事务 commit / rollback
以上这种事务也被称为本地事务,它具有如下特征:
- 一次事务过程中只能连接一个支持事务的数据库,这里的数据库一般指的是关系型数据库;
- 事务的执行结果必须满足 ACID 特性;
- 事务的执行过程会用到数据库本身的锁机制;
本地事务的执行流程如下图所示:
- 1)客户端开始事务操作之前,需要开启一个连接会话;
- 2)开始会话后,客户端发起开启事务的指令;
- 3)事务开启后,客户端发送各种 SQL 语句处理数据;
- 4)正常情况下,客户端会发起提交事务的指令,如果发生异常情况,客户端会发起回滚事务的指令;
- 5)上述流程完成后,关闭会话;
本地事务是由资源管理器在本地进行管理的,它的优缺点如下。
优点:
- 支持严格的ACID特性,这也是本地事务得以实现的基础;
- 事务可靠,一般不会出现异常情况;
- 本地事务的执行效率比较高;
- 事务的状态可以只在数据库中进行维护,上层的应用不必理会事务的具体状态;
- 应用的编程模型比较简单,不会涉及复杂的网络通信。因为在同一个节点,所以一个 transaction 里面怼几张表都行,很方便。如果是分布式事务,那么还要引入 2PC、3PC、TCC 等模型;
缺点:
- 不具备分布式事务的处理能力;
- 一次事务过程中只能连接一个支持事务的数据库,即不能用于多个事务性数据库。比如一个事务里面有两条 SQL 语句,这两条语句操作的一定是同一个数据库;
另外 MySQL 的事务还可以带有保存点,因为 MySQL 的事务一旦回滚,就会回滚到事务执行之前的状态。那么问题来了,我们能不能指定回滚的位置呢?答案是可以的,只需要设置 savepoint 即可。
-- 设置一个名为 point1 的保存点 -- 在 MySQL 中通过如下方式 savepoint point1; -- 通过如下命令将当前事务回滚到定义的保存点位置 rollback to point1; -- 通过如下命令删除保存点 release savepoint point1
从本质上讲,普通的本地事务也是有保存点的,只不过它只有一个隐式的保存点,并且会在事务启动的时候,自动设置为当前事务的开始位置。也就是说,普通的本地事务具有保存点,而且默认是事务的开始位置。
像 MySQL 的这种本地事务,一般也叫做扁平化事务。
并发事务带来的问题
数据库一般会并发执行多个事务,而多个事务可能会并发地对相同的数据进行增删改查操作,进而导致并发事务问题。并发事务带来的问题包括更新丢失(脏写)、脏读、不可重复读和幻读,如下图所示。
我们来解释一下这些问题是怎么表现的。
更新丢失(脏写)
当两个或两个以上的事务选择数据库中的同一行数据,并基于最初选定的值更新该行数据时,因为多个事务之间都无法感知彼此的存在,所以会出现最后的更新操作覆盖之前由其它事务完成的更新操作的情况。也就是说,对于同一行数据,一个事务对该行数据的更新操作覆盖了其它事务对该行数据的更新操作。
例如张三的账户余额是 100 元,当前有事务 A 和事务 B 两个事务,事务 A 负责将张三的账户余额增加 100 元,事务 B 负责将张三的账户余额增加 200 元。
起初事务 A 和事务 B 同时读取到张三的账户余额为 100 元,然后事务 A 和事务 B 分别更新张三的银行账户余额。假设事务 A 先于事务 B 提交,但最后的结果是张三的账户余额为 300 元。本来应该有 400 元的,因为 A 增加 100、B 增加 200,加上原本的 100。因此这个现象就是脏写,因为后提交的事务 B 覆盖了事务 A 的更新操作,A 的更新操作无效了。
更新丢失(脏写)本质上是写操作的冲突,解决办法是让每个事务按照串行的方式执行,按照一定的顺序依次进行写操作。
脏读
一个事务正在对数据库中的一条记录进行修改操作,但在这个事务完成并提交之前,有另一个事务来读取正在修改的这条数据记录。如果没有对这两个事务进行控制,则第二个事务就会读取到没有被提交的脏数据,并根据这些脏数据做进一步的处理,此时就会产生未提交的数据依赖。我们通常把这种现象称为脏读,也就是一个事务读取了另一个事务未提交的数据。
例如当前有事务 A 和事务 B 两个事务,事务 A 向张三的银行账户转账 100 元,事务 B 查询张三的账户余额。事务 A 执行完转账操作、但还没有提交时,事务 B 就查询到张三的银行账户多了 100 元。
后来事务 A 由于某些原因,例如服务超时、系统异常等因素进行回滚操作,张三的余额又变回去了。但事务 B 并不知道,此时我们就说事务 B 发生了脏读,因为事务 B 使用的还是在事务 A 回滚之前就读到的脏数据。
脏读本质上是读写操作的冲突,解决办法是先写后读,也就是写完之后再读。
不可重复读
一个事务读取了某些数据,在一段时间后,这个事务再次读取之前读过的数据,此时发现读取的数据发生了变化,这种现象就叫作不可重复读。即同一个事务,使用相同的查询语句,在不同时刻读取到的结果不一致。
例如当前有事务 A 和事务 B 两个事务,事务 A 是向张三的银行账户转账 100 元,事务 B 是查询张三的账户余额。事务 B 第一次查询时,事务 A 还没有转账,第二次查询时,事务 A 已经转账成功,此时就会导致事务 B 两次查询的结果不一致。
不可重复读本质上也是读写操作的冲突,解决办法是先读后写,也就是读完之后再写。
幻读
一个事务按照相同的查询条件重新读取数据,发现读到了其它事务插入的满足当前查询条件的新数据,这种现象叫作幻读。即一个事务两次读取一个范围的数据记录,两次读取到的结果不同。
比如一个事务要查询年龄大于 35 的员工数量,第一次查询的时候假设有 100 人,然后别的事务执行了 insert 操作,第二次查询的时候变成了 101 人。此时我们说发生了幻读,这多出来的 1 个就像幻影一样。
幻读本质上是读写操作的冲突,解决办法是先读后写,也就是读完之后再写。
话说很多人不清楚不可重复读和幻读到底有何区别。这里,我们简单介绍一下。
- 不可重复读的重点在于更新和删除操作,而幻读的重点在于插入操作;
- 使用锁机制实现事务隔离级别(一会说)时,在可重复读隔离级别中,SQL 语句第一次读取到数据后,会将相应的数据加锁,使得其他事务无法修改和删除这些数据,此时可以实现可重复读。但这种方法无法对新插入的数据加锁,如果事务 A 读取了数据,或者修改和删除了数据,此时事务 B 还可以进行插入操作,导致事务 A 莫名其妙地多了一条之前没有的数据,这就是幻读;
- 幻读无法通过行级锁来避免,需要使用串行化的事务隔离级别,但是这种事务隔离级别会极大降低数据库的并发能力;
- 从本质上讲,不可重复读和幻读最大的区别在于如何通过锁机制解决问题;
另外,除了可以使用悲观锁(锁的内容后续说)来避免不可重复读和幻读的问题外,我们也可以使用乐观锁来处理,例如,MySQL、Oracle 和 PostgreSQL 等数据库为了提高整体性能,就使用了基于乐观锁的MVCC(多版本并发控制)机制来避免不可重复读和幻读。
MySQL 的事务隔离级别
MySQL 中的 InnoDB 储存引擎提供 SQL 标准所描述的 4 种事务隔离级别,分别为:读未提交(Read Uncommitted)、读已提交(Read Committed)、可重复读(Repeatable Read)和串行化(Serializable)。
MySQL 默认的隔离级别为可重复读,当然我们也可以手动指定隔离级别,通过在 my.cnf 或者 my.ini 文件中的 mysqld 节点下面配置如下选项。
-- 读未提交 transaction-isolation = READ-UNCOMMITTED -- 读已提交 transaction-isolation = READ-COMMITTED -- 可重复读 transaction-isolation = REPEATABLE-READ -- 串行化 transaction-isolation = SERIALIZABLE
也可以使用 SET TRANSACTION 命令改变单个或者所有新连接的事务隔离级别,基本语法如下所示。
-- 读未提交 SET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL READ UNCOMMITTED -- 读已提交 SET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL READ COMMITTED -- 可重复读 SET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL REPEATABLE READ -- 串行化 SET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL SERIALIZABLE
如果使用 SET TRANSACTION 命令来设置事务隔离级别,需要注意以下几点。
- 不带 SESSION 或 GLOBAL 关键字设置事务隔离级别,指的是为下一个(还未开始的)事务设置隔离级别;
- 使用 GLOBAL 关键字指的是对全局设置事务隔离级别,也就是设置后的事务隔离级别对所有新产生的数据库连接生效;
- 用 SESSION 关键字指的是对当前的数据库连接设置事务隔离级别,此时的事务隔离级别只对当前连接的后续事务生效;
- 任何客户端都能自由改变当前会话的事务隔离级别,可以在事务中间改变,也可以改变下一个事务的隔离级别;
使用如下命令可以查询全局级别和会话级别的事务隔离级别。
SELECT @@global.tx_isolation; SELECT @@session.tx_isolation; SELECT @@tx_isolation;
而这些隔离级别主要是为了解决并发事务带来的问题,并且不同的隔离级别所解决的问题范围也不同。
- 读未提交允许脏读,即在读未提交的隔离级别下,可能读取到其它会话未提交事务修改的数据。这种事务隔离级别下存在脏读、不可重复读和幻读的问题;
- 读已提交只能读取到已提交事务修改的数据,Oracle 等数据库使用的默认事务隔离级别就是读已提交。这种事务隔离级别存在不可重复读和幻读的问题;
- 可重复读就是在一个事务内,同一查询无论何时执行,得到的数据都是一致的,这是 MySQL 中 InnoDB 存储引擎默认的事务隔离级别。这种事务隔离级别下存在幻读的问题;
- 串行化是指完全串行读写,每次操作数据库中的数据时,都需要获得表级别的共享锁,造成读和写都会阻塞。这种事务隔离级别解决了并发事务带来的问题,但完全的串行化操作使得数据库失去了并发特性,所以这种隔离级别往往在互联网行业中不太常用;
接下来,为了更好地理解 MySQL 的事务隔离级别,我们实际说明一下。
隔离级别代码演示
先在 MySQL 中创建一个 test 数据库,在 test 数据库中创建一个 account 表作为测试使用的账户数据表,然后写入几条数据,如下所示。
此时 account 数据表中有张三、李四和王五的账户信息,账户余额分别为 300 元、350 元和 500 元。准备工作完成了,接下来我们一起来看 MySQL 中每种事务隔离级别下数据的处理情况。
读未提交
第一步:打开第一个服务器终端,登录 MySQL,将当前终端的事务隔离级别设置为 read uncommitted,也就是读未提交,然后查询数据表。
第二步:打开服务器的另一个终端(后续就简化为终端 1、终端 2),连接 MySQL,将当前事务模式设置为 read uncommitted 并更新 account 表的数据,将张三的账户余额加 100 元。
可以看到,在终端 2 中,当前事务未提交时,张三的账户余额变为更新后的值,即 400 元。
第三步:在终端 1 查看 account 数据表的数据。
可以看到,虽然终端 2 的事务并未提交,但是终端 1 可以查询到终端 2 已经更新的数据。
第四步:如果终端 2 的事务由于某种原因执行了回滚操作,那么终端 2 中执行的所有操作都会被撤销。也就是说,终端 1 查询到的数据其实就是脏数据,下面我们执行回滚。
在终端 2 执行了事务的回滚操作后,张三的账户余额重新变为 300 元,那么终端 1 查询返回的是不是也是 300 元呢?
第五步:在终端 1 查询张三的账户余额,发现也变成了 300 元(图就不贴了)。
以上便是脏读的问题,因为你不知道另一个事务的操作最终是提交还是回滚。就好比原来张三的余额有 300,但是事务 1 发现变成了 400,因为事务 2 给它加了 100,于是准备减去 100 只留 300。
但问题是事务 2 的操作还没提交呢?如果事务 2 回滚了自己的操作,那么事务 1 再减去 100 的话,张三的余额就变成 200 了。所以万恶之源还是「读未提交」这个隔离级别允许一个事务读取另一个事务在执行过程中所做的变更,因此「读未提交」基本不会在生产环境上使用。
读已提交
第一步:在终端 1 中将事务隔离级别设置为 read committed,也就是读已提交。
和之前一样,张三、李四和王五的账户余额分别为 300 元、350 元和 500 元。
第二步:打开终端 2,将事务隔离级别设置为 read committed,开启事务并更新 account 数据表中的数据,将张三的账户余额增加 100 元。
可以看到,在终端 2 的查询结果中,张三的账户余额已经由原来的 300 元变成 400 元。
第三步:在终端 2 的事务提交之前,在终端 1 中查询 account 数据表中的数据,如下所示。
可以看到,在终端 1 查询出来的张三的账户余额仍为 300 元,说明此时已经解决了脏读的问题。
第四步:在终端 2 提交事务,如下所示。
mysql> commit; Query OK, 0 rows affected (0.03 sec)
第五步:在终端 2 提交事务后,在终端 1 再次查询 account 数据表中的数据,如下所示。
可以看到,此时就不会出现脏读的问题了,在「读已提交」隔离级别下,一个事务必须提交之后,所做的修改才能被另一个事务读取。
但此时又产生了一个问题,终端 1 在终端 2 的事务提交前和提交后读取到的数据不一致,产生了不可重复读的问题。而我们希望在一个事务内,不管什么时候读取,读到的数据是不变的,要想解决这个问题,就需要使用可重复读的事务隔离级别。
可重复读
第一步:在终端 1 中将事务隔离级别设置为 repeatable read,也就是可重复读。
可以看到,此时张三、李四、王五的账户余额分别为 400 元、350 元、500 元。
第二步:打开终端 2,将当前终端的事务隔离级别设置为可重复读。开启事务,将张三的账户余额增加 100 元,随后提交事务,如下所示。
可以看到,在终端 2 查询的结果中,张三的账户余额已经由原来的 400 元变成 500 元。
第三步:在终端 1 查询 account 数据表中的数据,如下所示。
可以看到在终端 1 查询的结果中,张三的账户余额仍为 400 元,并没有出现不可重复读的问题,说明可重复读的事务隔离级别解决了不可重复读的问题。
第四步:在终端 1 为张三的账户增加 100元,如下所示。
事务 1 查询的结果是 400 元,然后增加 100 元,但此时张三的账户余额却变成 600 元,而不是 500 元,说明数据的一致性没有遭到破坏。这是因为在终端 1 为张三的账户余额增加 100 元之前,终端 2 已经为张三的账户余额增加了 100 元,共计增加了 200 元,所以最终张三的账户余额是 600 元。
可重复读的隔离级别使用了MVCC(Multi-Version Concurrency Control,多版本并发控制)机制,数据库中的查询(select)操作不会更新版本号,是快照读,而操作数据表中的数据(insert、update、delete)则会更新版本号,是当前读。关于 MVCC 后续详细说。
第五步:在终端 2 开启事务,插入一条数据后提交事务,如下所示。
在终端 2 查询的结果中,已经显示出新插入的赵六的账户信息了。
第六步:在终端 1 查询 account 数据表的数据,如下所示。
在终端 1 查询的数据中,并没有赵六的账户信息,说明没有出现幻读。
第七步:在终端 1 为 id = 4 的账户增加 100 元,按理说由于该事务没有查到 id = 4 的记录,所以应该什么也不会发生。
可以看到,在终端 1 执行完数据更新操作后,查询到赵六的账户信息,出现了幻读的问题。如何解决该问题呢?答案是使用串行化的事务隔离级别或者间隙锁和临键锁。
目前出现了很多的锁的概念,关于锁我们后续会说。
串行化
第一步:在终端 1 中将事务隔离级别设置为 serializable,也就是串行化。
第二步:打开终端 2,将当前终端的事务隔离级别设置为 serializable,开启事务,修改 account 数据表中 id 为 1 的数据,如下所示。
可以看到,在终端 2 中对 id 为 1 的数据执行更新操作时,会发生阻塞。因为所有事务操作都是串行的,终端 1 的事务执行完毕之前,终端 2 的事务是无法执行的。
MySQL 背后的做法是通过锁来保证串行的,因此终端 2 的事务想要执行必须获取锁,但锁此时被终端 1 的事务持有,因此终端 2 的事务只能陷入等待。如果锁超时,会抛出 "ERROR 1205(HY000): Lock wait timeout exceeded: try restarting transaction" 错误。因此采用串行化的方式可以避免幻读,但它是最高的隔离级别,此时完全丧失了并发性,生产环境也很少使用。
另外,在可重复读的事务隔离级别下,如果终端 1 执行的是一个范围查询,那么该范围内的所有行都会被加锁。比如查询 id 为 2 到 10 的数据,那么整个范围都会被加锁,即使存在间隙(比如不存在 id = 4 的记录,那么也会被加锁,这就是间隙锁,后续会详细讲解)。此时终端 2 在此范围内插入数据(比如 id = 4),就会被阻塞,从而也可以避免幻读。
小结
MySQL 在并发处理事务时,会面临一系列问题,为了解决这些问题,MySQL 也提供了一系列隔离级别。不同的隔离级别,解决问题的程度不同,MySQL 的默认隔离级别是可重复读。