本文所述的事务都是指在 MySQL 的 InnoDB 引擎下。
事务(transaction)是数据处理的最小操作单元,是一组不可在分割的操作集合,这个操作单元里的一系列操作要么都成功,要么都失败。在数据库事务操作过程中必须要遵循事务的4个特性:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。(简称:ACID)
隐式事务&显式事务
MySQL默认采用自动提交(AUTOCOMMIT)模式。也就是说,如果不是显式地开始一个事务,则每个 SQL 操作都会被当做一个事务执行提交操作,这种事务被称为隐式事务。当然我们也可以通过设置 AUTOCOMMIT
参数来启用或禁用自动提交模式:
-- 查看 AUTOCOMMIT 参数配置
SHOW VARIABLES LIKE 'AUTOCOMMIT';
-- 设置 AUTOCOMMIT 参数为禁用状态,1或ON表示启用,0或OFF表示禁用(该修改操作只对当前会话session有效)
SET AUTOCOMMIT = 0;
当 AUTOCOMMIT = 0
时,所有的SQL操作都在一个事务中,直到显式地执行 COMMIT
提交或 ROLLBACK
回滚。此外,如果我们想通过多个SQL语句作为一个事务执行时,还可以通过 BEGIN
开启一个事务,然后通过执行 COMMIT
提交或 ROLLBACK
回滚一个事务。这种通过手动方式完成的事务操作被称为显式事务。
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT; -- 当然也可以使用 ROLLBACK 回滚整个事务,事务执行失败
事务的四个特性
一个完整的数据库事务操作必须要具备 ACID 这4个特性:
-
原子性(Atomicity)
原子性是指事务是一个不可分割的工作单位,事务中的操作要么都成功(commit),要么都失败(rollback)。 -
一致性(Consistency)
事务前后数据的完整性必须保持一致。数据一致性是基础,也是最终目的,其他三个特性(原子性、隔离性和持久性)都是为了保证数据的一致性。 -
隔离性(Isolation)
事务的隔离性是指多个用户并发访问数据库时,数据库为每一个用户开启的事务,不能被其他事务的操作数据所干扰,多个并发事务之间要相互隔离。与原子性、持久性侧重于研究事务本身不同,隔离性研究的是不同事务之间的相互影响。如果不考虑事务的隔离性问题,可能会导致脏读、幻读和不可重复读的问题,从而导致在并发情况下出现数据一致性的问题。
-
持久性(Durability)
持久性是指一个事务一旦被提交,它对数据库中数据的改变就是永久性的,接下来即使数据库发生故障也不应该对其有任何影响。
什么是脏读、不可重复读、幻读
脏读、不可重复读、幻读这三种情况的出现和数据库并发事务有密切的关系。为了解决这些问题,SQL 标准定义了四种隔离级别,通过隔离级别解决了这些问题。在研究隔离级别前我们先了解一下这些问题的概念。
-
脏读
事务A读取了事务B中没有提交的数据,若事务B中的更新操作回滚,那么事务A中读取的数据是不存在的,这种现象就是脏读。注意:只有事务隔离级别为读未提交
时才会出现脏读问题。 -
不可重复读
在同一个事务中,多次读取相同的数据,但是读取的结果不一样,称之为不可重复读。注意:当事务隔离级别为读未提交
和读已提交
时都可能会出现不可重复读问题。 -
幻读
在同一个事务中,第一次查询某条记录,发现没有,但是再次读取同一条记录,它就神奇地出现了,称之为幻读。注意:当事务隔离级别为读未提交
、读已提交
、可重复读
时都可能会出现幻读问题。
脏读、不可重复读、幻读用事务操作表示如下:
事务的隔离级别
为了保证数据一致性,SQL 标准定义了四种隔离级别,这四种隔离级别分别如下:
-
读未提交(
READ UNCOMMITTED
):
在该隔离级别,所有事务都可以看到其他未提交事务的执行结果。本隔离级别是最低的隔离级别,很少用于实际应用中。隔离级别为读未提交
时,可能会存在脏读
、不可重复读
、幻读
的问题。 -
读已提交(
READ COMMITTED
):
它是大多数数据库系统的默认隔离级别(但不是 MySQL 默认的)。它满足了隔离的简单定义:一个事务只能读到已提交事务所做的改变。隔离级别为读已提交
时,可以防止脏读
问题,但会出现不可重复读
、幻读
的问题。 -
可重复读(
REPEATABLE READ
):
它可以确保在同一个事务内的查询结果是一致的。隔离级别为可重复读
时,可以防止脏读
和不可重复读
的问题,但可能发生幻读
问题。
注意,MySQL 的 InnoDB 存储引擎,在可重复读
隔离级别中解决了幻读问题,但是通过 UPDATE 更新不存在的记录时,竟然能成功。后面会进行示例说明。 -
串行化(
SERIALIZABLE
):
串行化
是最严格的隔离级别,同样性能也是最差的。隔离级别为串行化
时,所有事务按照次序依次执行,因此,脏读
、不可重复读
、幻读
都不会出现。
从上往下,隔离强度逐渐增强,性能逐渐变差,采用哪种隔离级别要根据系统需求权衡决定。总的来说,隔离级别和脏读、不可重复读以及幻读的对应关系如下:
事务隔离级别实操演示
实践出真知,单纯的理论背诵,很可能会让你晕头转向。为了更好的理解事务隔离级别,下面我们通过几个实际的 SQL 操作来进行一下演示。
查看与设置MySQL的事务隔离级别
我们可以通过以下语句来查看并设置当前数据库的隔离级别。
-
查看事务隔离级别
-- MySQL 5.6及以下版本 | 查看数据库版本: select version(); SELECT @@GLOBAL.tx_isolation as '全局-隔离级别', @@tx_isolation as '会话-隔离级别'; -- MySQL 5.6以上版本 SELECT @@GLOBAL.transaction_isolation AS '全局-隔离级别', @@transaction_isolation AS '会话-隔离级别';
-
设置事务隔离级别
SET {作用域} TRANSACTION ISOLATION LEVEL {事务隔离级别}; -- 作用域:SESSION | GLOBAL -- 事务隔离级别:READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE
注意:作用域 GLOBAL
是全局有效,而 SESSION
只针对当前会话窗口生效,所以在测试时只修改当前 SESSION
的隔离级别就可以。另外,如果你使用的是 Navicat 的话,不同的查询窗口会连接不同的 SESSION
会话;如果你使用的是 SQLyog,那么不同查询窗口使用的是同一个 SESSION
会话,所以需要你再开启一个新的连接。
隔离级别实操演示
在开始之前我们先创建一张数据表,表结构即数据内容如下:
CREATE TABLE `db_user` (
`id` INT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(20) DEFAULT NULL COMMENT '姓名',
`account` INT DEFAULT NULL COMMENT '账户余额',
PRIMARY KEY (`id`)
) ENGINE=INNODB DEFAULT CHARSET=utf8;
INSERT INTO `db_user` (`id`, `name`, `account`) VALUES('1','李四','1000');
-
读未提交
在该事务隔离级别下,一个事务会读到另一个事务更新后但未提交的数据,如果另一个事务回滚,那么当前事务读到的数据就是脏数据,这也就是脏读问题。我们来看看下面这个例子:Time 事务A 事务B T1 SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; T2 START TRANSACTION; START TRANSACTION; T3 UPDATE db_user SET account = account + 1000 WHERE NAME = ‘李四’; T4 SELECT account FROM db_user WHERE NAME = ‘李四’; T5 ROLLBACK; T6 SELECT account FROM db_user WHERE NAME = ‘李四’; T7 COMMIT; 当事务A在 T3 时,它更新了 李四 的记录,但并未提交,而事务B在 T4 时,读取到了事务A还未提交的数据。但是,事务A在 T5 时进行了事务回滚操作,那么此时事务B再次读取数据发现和上一次读到的数据不一致,也就是产生了脏读。 读未提交
隔离级别没办法解决脏数据问题,更别提不可重复读和幻读的问题了。 -
读已提交
在该事务隔离级别下,一个事务可能会遇到不可重复读的问题。我们来看看下面这个例子:Time 事务A 事务B T1 SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; T2 START TRANSACTION; START TRANSACTION; T3 SELECT account FROM db_user WHERE NAME = ‘李四’; T4 UPDATE db_user SET account = account + 1000 WHERE NAME = ‘李四’; T5 COMMIT; T6 SELECT account FROM db_user WHERE NAME = ‘李四’; T7 COMMIT; 当事务B在 T3 时,查询到的结果是 1000,随后,由于事务A在 T4 更新了这条记录并完成提交,所以,事务B在 T6 时查询到的结果是 2000。因此,在 读已提交
隔离级别下,事务不可重复读同一条记录,因为很可能读到的结构不一致。读已提交
隔离级别解决了脏读的问题,但是无法做到可重复读,也没办法解决幻读。 -
可重复读
这是 MySQL 的默认事务隔离级别,它可以确保在同一个事务内的查询结果是一致的。在标准 SQL 定义中的可重复读隔离级别下,一个事务可能会遇到幻读问题。而 MySQL 的 InnoDB 存储引擎在一定程度上解决了幻读问题。但是在更新操作中却可以操作成功。具体我们看看下面这个例子:Time 事务A 事务B T1 SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; T2 START TRANSACTION; START TRANSACTION; T3 SELECT account FROM db_user WHERE NAME = ‘张三’; T4 INSERT INTO db_user (name, account) VALUES(‘张三’,‘1000’); T5 COMMIT; T6 SELECT account FROM db_user WHERE NAME = ‘张三’; T7 UPDATE db_user SET account = account + 1000 WHERE NAME = ‘张三’; T8 SELECT account FROM db_user WHERE NAME = ‘张三’; T9 COMMIT; 事务B在 T3 时,读到的记录为空,说明此时 张三 的记录还不存在。随后,事务A在 T4 插入了一条 张三 的记录并提交。然后,事务B在 T6 时查询记录仍然为空,由此可见,InnoDB 存储引擎在 可重复读
隔离级别下,不存在幻读问题。但是,事务B在 T7 时,试图更新这条不存在的记录时,竟然成功了,并且,事务B在 T8 时,再次读取该记录,发现记录存在了。可重复读
隔离级别解决了脏读问题,并且做到了可重复读,同时在 InnoDB 存储引擎中也一定程度的避免了幻读问题的出现。 -
串行化
在该事务隔离级别下,所有的事务操作都按照次序依次执行。因此,脏读、不可重复读、幻读这些问题都不会出现。所以,串行化是最严格的隔离级别,但这也会大大降低系统的性能。如果没有特别重要的情景,一般都不会使用Serializable
隔离级别。
MVCC(Multi-Version Concurrency Control,即多版本并发控制)
数据库系统基于提升并发性能的考虑,它们一般都会实现多版本并发控制。我们可以认为 MVCC 是行级锁的一个变种,它是通过保存数据在某个时间点的快照来实现的。也就是说,不管事务执行多久,在该事务中看到的数据都是一致的,并且根据事务开始时间的不同,每个事务对同一张表,同一时刻看到的数据可能会是不一样的。
MVCC 没有统一的实现标准,不同存储引擎对 MVCC 的实现方式可能不同。在 InnoDB 存储引擎中,MVCC 是通过在每行记录后面保存两个隐藏列来实现的。这两个列,一个用来记录数据行的创建时间,另一个用来记录数据行的过期时间(删除时间),但是在实际操作中,存储的并不是时间,而是系统版本号。每开始一个新的事务,系统版本号都会自动递增。事务开始时的系统版本号会作为事务的版本号,用来和查询到的每一行记录的版本号进行比较。下面我们看看在可重复读(REPEATABLE READ
)隔离级别下,MVCC具体是如何操作的:
-
SELECT
InnoDB 会检查每行数据记录是否符合以下两个条件:
a. InnoDB 只查找版本早于当前事务版本的数据行(也就是,行的系统版本号 <= 当前事务的系统版本号),这样可以确保事务读取的行,要么是在事务开始前已经存在的,要么是事务自身插入或者修改过的。b. 行的删除版本要么未定义,要么大于当前事务版本号。这可以确保事务读取到的行,在事务开始之前未被删除。
-
INSERT
InnoDB 为新插入的每一行保存当前系统版本号作为行版本号。 -
DELETE
InnoDB 为删除的每一行保存当前系统版本号作为行删除标识。 -
UPDATE
InnoDB 会插入一行新记录,保存当前系统版本号作为行版本号,同时保存当前系统版本号到原来的行作为行删除标识。
MVCC 只在 读已提交
和 可重复读
两个隔离级别下工作,其他两个隔离级别都和MVCC不兼容。读已提交
隔离级别时,数据快照会在每一次进行普通 SELECT 操作前生成;而 可重复读
隔离级别时,数据快照只在第一次进行普通 SELECT 操作前生成,之后的查询操作都重复使用这个快照。
怎么解决脏读、不可重复读、幻读这些问题呢?
要解决脏读、不可重复读、幻读这些问题,其实有两种可选的解决方案:
-
读操作利用 MVCC 机制,写操作进行加锁
所谓的 MVCC 我们在前面已经有过描述,就是通过生成一个数据快照,然后通过数据快照找到符合条件的记录版本,在生成数据快照之前未提交的事务或者之后才开启的事务所做的更改是看不到的。而写操作肯定针对的是最新版本的记录,读记录的历史版本和变更记录的最新版本本身并不冲突,也就是采用 MVCC 时,读-写
操作并不冲突。在
读已提交
隔离级别下,数据快照会在每一次进行普通 SELECT 操作前生成,数据快照的存在本身就保证了事务不可以读取到未提交的事务所做的更改,也就是避免了脏读现象;可重复读
隔离级别下,数据快照只在第一次进行普通 SELECT 操作前生成,之后的查询操作都重复使用这个快照,这样也就避免了不可重复读和幻读的问题。 -
读、写操作都采用加锁的方式
如果我们的一些业务场景不允许读取记录的旧版本,而是每次都必须读取记录的最新版本。也就是说读取记录的时候也需要对其进行加锁
操作,这也就意味着读
操作和写
操作也像写-写
操作那样排队执行(即,串行化隔离级别)。
很明显,采用 MVCC 方式的话,读-写
操作彼此并不冲突,性能更高,采用 加锁
方式的话,读-写
操作彼此需要排队执行,影响性能。一般情况下我们当然愿意采用 MVCC 来解决 读-写
操作并发执行的问题。
参考文档:
《高性能MySQL-第三版》
《MySQL是怎么运行的》
https://www.liaoxuefeng.com/wiki/1177760294764384/1179611198786848
https://www.cnblogs.com/fengzheng/p/12557762.html
欢迎关注我的个人公众号:小瓦匠学编程,优质好文不错过!扫描二维码或微信搜索 “小瓦匠学编程” 即可关注。
(本文完)