# MySQL教程 - 13 事务
什么是事务?
事务是一组操作的集合,这些操作被当成一个整体进行执行,事务中的操作要么全都执行成功并提交(Commit);要么全部撤销并回滚(Rollback),从而保证数据库的一致性。
举个最常用的例子:
银行转账,张三向李四转账1000元,这里包含三个步骤:
- 张三查询余额是否大于1000
- 张三的余额
-1000
- 李四的余额
+1000
在项目中,业务逻辑代码操作上面的步骤是一步一步调用的,如果遇到代码报错,后面的步骤就无法执行。如果上面的步骤不是一次性执行成功或全部撤销,就会造成银行账户的错误,例如张三的余额减少了,李四的账户却没有增加。
所以就需要事务,保证所有的操作在一个事务内,所有操作要么都是成功的,要么全部撤销。
首先准备一下数据,准备一下银行账户表:
-- 创建银行账户表
CREATE TABLE tb_account (
id INT AUTO_INCREMENT PRIMARY KEY, -- 账户ID,自增主键
account_number VARCHAR(32), -- 账户号码
name VARCHAR(128), -- 姓名
money DECIMAL(10, 2) -- 账号余额
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 插入数据
INSERT INTO tb_account VALUES
(1, '600001', '张三', 2000),
(2, '600002', '李四', 2000);
-- 这个后面用来恢复数据,因为要重试操作
UPDATE tb_account SET money = 2000 WHERE name in ('张三', '李四');
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 13.1 事务操作
正常的操作,有以下三个步骤:
-- 步骤1:查询张三余额
SELECT * FROM tb_account WHERE name = '张三';
-- 步骤2:张三账户-1000
UPDATE tb_account SET money = money - 1000 WHERE name = '张三';
-- 步骤3:李四账户+1000
UPDATE tb_account SET money = money + 1000 WHERE name = '李四';
2
3
4
5
6
7
8
正常执行是没有问题的,但是在执行的中间出错,就会出问题了。
恢复数据后,作为整体执行如下语句:
-- 步骤1:查询张三余额
SELECT * FROM tb_account WHERE name = '张三';
-- 步骤2:张三账户-1000
UPDATE tb_account SET money = money - 1000 WHERE name = '张三';
-- 这个地方让SQL报个错
这个地方报个错;
-- 步骤3:李四账户+1000
UPDATE tb_account SET money = money + 1000 WHERE name = '李四';
2
3
4
5
6
7
8
9
10
11
上面的语句是作为整个脚本一次性执行的,使用执行脚本命令来执行:
执行的时候,会报错,最终导致账号数据发生不一致:
我的1000不见啦!
MySQL 事务默认处于自动提交模式,每条 SQL 语句执行后会自动提交,所以每条语句在一个事务里面,我们要做的是要将之前的三条语句放到一个事务中。
# 1 设置事务模式
将三条语句放在一个事务中,我们可以先关闭事务自动提交,然后执行完操作后,手动提交事务。
需要使用的事务相关的语句如下:
-- 查看当前会话的事务模式
SELECT @@AUTOCOMMIT; -- 1为自动提交,0为手动提交
-- 修改当前会话的事务模式
SET @@AUTOCOMMIT = 0;
-- 提交事务
COMMIT;
-- 回滚事务
ROLLBACK;
2
3
4
5
6
7
8
9
10
11
下面重新执行之前的转账操作,下面是没报错的情况:
-- 首先设置会话事务为手动提交
SET @@AUTOCOMMIT = 0;
-- 步骤1:查询张三余额
SELECT * FROM tb_account WHERE name = '张三';
-- 步骤2:张三账户-1000
UPDATE tb_account SET money = money - 1000 WHERE name = '张三';
-- 步骤3:李四账户+1000
UPDATE tb_account SET money = money + 1000 WHERE name = '李四';
-- 提交事务
COMMIT;
-- 将事务设为自动提交,防止执行其他SQL不自动提交事务,导致执行不生效
SET @@AUTOCOMMIT = 1;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
上面的执行没有问题,下面来试试报错的情况。
首先执行语句,关闭事务自动提交,然后执行转账操作:
-- 首先设置会话事务为手动提交
SET @@AUTOCOMMIT = 0;
-- 步骤1:查询张三余额
SELECT * FROM tb_account WHERE name = '张三';
-- 步骤2:张三账户-1000
UPDATE tb_account SET money = money - 1000 WHERE name = '张三';
-- 这个地方让SQL报个错
报个错;
-- 步骤3:李四账户+1000
UPDATE tb_account SET money = money + 1000 WHERE name = '李四';
2
3
4
5
6
7
8
9
10
11
12
13
14
上面的语句会报错,导致操作被部分执行,虽然有些操作被执行了,但是因为没有提交,所以 其他会话 默认是无法看到数据变化的,此时你在当前会话中查询用户的余额,会发现张三的金额已经减了1000,但在新窗口查询,发现并没有减少1000。
此时可以选择提交事务或回滚事务。
如果选择提交事务:
-- 提交事务
COMMIT;
2
那么就会出现和一开始一样的问题,会将执行成功的操作提交,张三的钱 -1000
,李四的钱没有 +1000
。
如果选择回滚事务
-- 回滚事务
ROLLBACK;
2
那么当前所有还没有被提交的操作都会被回滚,张三和李四的金额不会发生变化,数据一致性得到了保证。
需要注意,在执行操作的最后,一定要提交或回滚事务,如果你执行了操作,但是没有提交,后面又执行了一堆操作,最后执行提交语句会将之前没有提交的操作全部提交。
# 2 手动开启事务
上面的操作还是有点不方便,需要设置事务为手动提交,最后可能还需要重新恢复提交模式。我们可以不修改事务的提交方式,使用开启和提交事务的方式来完成。
首先查询和确保事务提交的方式为自动提交,不修改数据库的事务提交方式:
-- 查看当前会话的事务模式
SELECT @@AUTOCOMMIT; -- 1为自动提交,0为手动提交
-- 修改当前会话的事务模式为自动提交
SET @@AUTOCOMMIT = 1;
2
3
4
5
然后使用如下方式来完成。
首先开启事务,然后执行转账操作,操作会报错:
-- 首先开启事务
start transaction;
-- 开启事务,还可以使用BEGIN;
-- BEGIN;
-- 步骤1:查询张三余额
SELECT * FROM tb_account WHERE name = '张三';
-- 步骤2:张三账户-1000
UPDATE tb_account SET money = money - 1000 WHERE name = '张三';
-- 这个地方让SQL报个错
报个错;
-- 步骤3:李四账户+1000
UPDATE tb_account SET money = money + 1000 WHERE name = '李四';
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
此时报错了,依旧有两种选择,提交事务或回滚事务:
-- 提交事务
COMMIT;
2
如果提交事务,会将执行成功的 SQL 提交,失败的无法生效,数据会出现不一致。
如果选择回滚事务,那么所有的操作会被回滚,数据一致性得到保证:
-- 回滚事务
ROLLBACK;
2
所以需要保证数据一致性的时候,要在操作成功后,提交事务,操作失败后,回滚事务,才能保证数据的一致性和完整性。
# 13.2 事务的特性
事务有四大特性,通常被简称为 ACID,分别是:
原子性(Atomicity):
原子性保证了事务中的所有操作要么全部成功,要么全部失败。即使事务中的某个操作发生错误,数据库会回滚到事务开始之前的状态,保证事务操作不可分割。
举例:假设你转账操作包含两个步骤:从账户A扣款,从账户B存款。如果其中一部分失败(如从账户A扣款成功,但从账户B存款失败),那么整个事务会回滚,两个操作都不会生效,保证数据一致性。
一致性(Consistency)
一致性确保事务执行前后,数据库的完整性约束保持不变。在事务开始前和结束后,数据库必须从一个一致的状态转移到另一个一致的状态。状态始终是保持一致的。
隔离性(Isolation)
我们在操作数据的时候,可能存在并发操作,会有很多的事务同时操作相同的数据,隔离性定义了事务之间的执行隔离程度,即一个事务的执行不应受其他事务的干扰。不同的事务可以并发执行,但每个事务的中间状态对其他事务不可见。不同的数据库系统提供了不同的隔离级别来实现这一特性。待会再讲。
持久性(Durability)
持久性确保了事务一旦提交,所做的更改会永久保存在数据库中,即使系统崩溃也不会丢失。
举例:当转账事务成功提交后,账户余额的变化会被永久保存。如果数据库发生崩溃,系统恢复后,提交的事务数据不会丢失。
# 13.3 并发事务问题
并发事务指的是多个事务在同一时间段内对数据库进行操作,并且这些事务可能会访问和修改相同的数据,此时就可能存在并发事务问题。
常见的并发事务问题有:
- 脏读
- 不可能重复读
- 幻读
# 1 脏读
脏读(Dirty Read)是指一个事务读取了另一个事务尚未提交的数据。由于另一个事务可能会回滚,这意味着当前事务读取到的数据可能会是无效的,导致数据不一致。
举例:
- 事务A更新了某条记录的字段,但还没有提交。事务B读取了这条记录的更新数据,并进行了基于这些数据的操作。如果事务A最终回滚,那么事务B读取的数据就是无效的。
# 2 不可重复读
不可重复读(Non-repeatable Read)是指一个在同一个事务中,多次读取同一行数据,但是得到的结果却是不同的,这是因为其他事务对相同的数据进行了修改或删除,导致读取的数据不一致。
举例:
- 事务A读取一行数据,然后事务B修改了这行数据并提交,事务A再次读取这行数据,发现值已经被改变。
- 当事务A在读取数据后,事务B对同一行数据进行了删除操作,导致事务A在后续读取同一行数据时,发现数据已经不存在了。
# 3 幻读
关于幻读,我查看了网上的资料,发现解释有一些不同。
在B站黑马程序员讲解的MySQL视频中( 视频地址 (opens new window) ),举得例子是:事务A 查询满足某个条件的所有行;事务B插入或删除了满足这个条件的新行,并提交;但是事务A因为没有查询到满足条件的行(因为此时已经解决了不可重复读的问题),于是也插入和事务B相同的数据,发现报错,提示已存在,于是又去查询数据,发现还是没有查询到满足条件的数据,见鬼了,出现幻觉了。通过下面的讲解,我觉得关于幻读,这个地方讲的有点问题。
搜索了一些其他的帖子,对于幻读的解释是:事务A按照某个条件查询(通常是范围查询)一组数据,事务B此时插入或删除了满足该查询条件的数据,导致事务A再次执行查询,得到的结果集的行数不同。我在 Wiki
上看到的解释也是这样的( 词条地址 (opens new window)),
这样说的话,幻读和不可重复读很相似,都是多次读取数据,但结果不同;只是侧重点不同,不可重复读侧重于修改,幻读侧重于增删。
但是从数据库的处理上来讲,区别比较大,解决不可重复读
只要加行级锁就可以了。而解决幻读
则需要加表级锁,或者采用其他更复杂的技术,所以不同吧。
上面的并发事务问题是有层级的,解决高层级的问题时,会自然解决低层级的问题。也就是说解决了幻读,就不存在不可重复读和脏读的问题,解决了不可重复读的问题,就不存在脏读的问题了。
下面就来介绍如何通过数据库的隔离级别来解决上面的并发事务问题。
# 13.4 事务隔离级别
下面介绍事务的隔离级别,以及演示事务隔离级别解决的事务并发问题。
# 13.4.1 事务隔离级别
SQL 标准定义了四种事务隔离级别,不同的隔离级别可以解决不同的并发问题。
- 未提交读(Read Uncommitted)
事务可以读取其他事务未提交的数据(脏数据)存在脏读、不可重复度、幻读问题,是最低的隔离级别,对并发性能影响最小,但数据一致性无法保证。
- 已提交读(Read Committed)
只能读取已提交的数据,解决了脏读问题,但仍可能出现不可重复读和幻读。SQL Server、Oracle、PostgreSQL的数据库采用的默认隔离级别。
- 可重复读(Repeatable Read)
保证在事务内多次读取同一行数据,结果始终一致(即禁止不可重复读),通过锁定读取的行,防止其他事务更新或删除这些行。但是无法解决幻读问题。MySQL采用的默认隔离级别。
- 可序列化(Serializable)
最严格的隔离级别,所有事务按顺序执行,完全避免脏读、不可重复读和幻读。通过锁定整个查询范围,确保在事务期间,其他事务无法插入、更新或删除相关数据。该级别下,无任何并发问题,但性能最低,可能导致大量的事务等待和死锁。
该级别适合对数据一致性要求极高的场景,例如金融交易或账目结算。
总结
事务隔离级别的对比:
隔离级别 | 是否会出现脏读 | 是否会出现不可重复读 | 是否会出现幻读 | 性能影响 |
---|---|---|---|---|
未提交读(Read Uncommitted) | 是 | 是 | 是 | 最低性能开销 |
已提交读(Read Committed) | 否 | 是 | 是 | 较低性能开销 |
可重复读(Repeatable Read) | 否 | 否 | 是 | 较高性能开销 |
可序列化(Serializable) | 否 | 否 | 否 | 最高性能开销 |
# 13.4.2 查看和设置隔离级别
事务的隔离级别包括当前会话和全局的。
查询事务的隔离级别:
-- 查看当前会话的事务隔离级别
SELECT @@SESSION.transaction_isolation;
-- 查看全局的事务隔离级别
SELECT @@GLOBAL.transaction_isolation;
2
3
4
5
执行结果可以发现MySQL的默认事务隔离级别确实是 Repeatable Read
:
设置事务隔离级别,语法如下:
SET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL [READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE]
设置隔离级别,可以通过 SESSION
或 GLOBAL
指定在当前会话还是全局生效:
-- 设置当前会话的事务隔离级别为READ UNCOMMITTED
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
-- 设置全局的事务隔离级别为REPEATABLE READ
SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;
2
3
4
5
设置全局隔离级别后,查询当前会话的隔离级别,发现是不能改变当前会话的隔离级别的,仅对新创建的会话生效。
# 13.4.3 演示解决并发事务问题
并发事务问题,涉及多个事务,所以需要使用两个会话来完成。这里我在 DBeaver 中打开两个 SQL 编辑器:
下面演示的时候,注意每次都要把事务提交或回滚,然后将数据恢复。
# 1 未提交读
首先账户表初始数据如下:
下面在两个会话中,按照步骤来执行,演示存在的脏读问题。
步骤 | 会话1 | 会话2 | 说明 |
---|---|---|---|
1 | SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; | SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; | 在两个会话中设置两个会话的隔离级别为:未提交读(Read Uncommitted) |
2 | START TRANSACTION; | START TRANSACTION; | 两个会话都开启事务 |
3 | SELECT * FROM tb_account; | 在会话1中查看账户信息 | |
4 | UPDATE tb_account SET money = money - 1000 WHERE name = '张三'; | 在会话2中更改张三账户的信息 | |
5 | SELECT * FROM tb_account; | 会话1再次查看账户的信息,此时会话2没有提交,但是仍然查询到会话2执行的结果 | |
6 | COMMIT; | COMMIT; | 提交事务 |
在步骤5中,会话1可以读取会话2未提交的数据,步骤5执行结果:
# 2 已提交读
要解决上面的问题,需要一开始将两个会话的事务隔离级别设置为已提交读(Read Committed),这样在上面步骤5查询的结果就是未被修改的结果。只有等到会话2提交了事务,会话1才能查询到修改后结果。
但是将隔离级别设置为已提交读,仍然解决不了不可重复读的问题,演示一下:
步骤 | 会话1 | 会话2 | 说明 |
---|---|---|---|
1 | SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; | SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; | 在两个会话中设置两个会话的隔离级别为:已提交读(Read Committed),其实这里主要需要设置会话1即可 |
2 | START TRANSACTION; | START TRANSACTION; | 两个会话都开启事务 |
3 | SELECT * FROM tb_account; | 在会话1中查看账户信息 | |
4 | UPDATE tb_account SET money = money - 1000 WHERE name = '张三'; | 在会话2中更改张三账户的信息 | |
5 | COMMIT; | 会话2提交事务 | |
6 | SELECT * FROM tb_account; | 会话1再次查看账户的信息,此时会话2已经提交,和步骤3查询的结果是不一样的。 | |
7 | COMMIT; |
在会话1中,步骤6和步骤3的结果不一致,也就是在同一个会话中,两次相同的查询,结果是不一样的。这就是不可重复读问题。
其实不可重复读不一定是一个问题,这还是需要结合实际的业务场景进行判断。
# 3 可重复读
如果一开始将两个会话的事务隔离级别设置为可重复读(Repeatable Read),这样在上面步骤6查询的结果就和步骤3一致了。
有很多帖子说将隔离级别设置为可重复读,仍然解决不了幻读的问题,需要使用可序列化(可串行化)的隔离级别,这也是对的。
但是对于 MySQL 而言,可重复读隔离级别已经部分解决了幻读,这是因为 MySQL 通过 MVCC(多版本并发控制) 为每个事务在开始时创建一个快照(视图),该快照包含事务开始时的数据状态。事务对数据的所有读取操作,都是基于这个快照,而不是直接读取当前表中的最新数据。保证了其他事务不会影响当前事务的查询结果,从而避免读取数据时出现的幻读问题。
下面的操作和上面基本一致,只是上面会话2是对数据进行修改,下面会话2对数据进行增加,如下:
步骤 | 会话1 | 会话2 | 说明 |
---|---|---|---|
1 | SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; | SET SESSION TRANSACTION ISOLATION LEVEL READ REPEATABLE READ; | 在两个会话中设置两个会话的隔离级别为:可重复读(Repeatable Read),其实这里主要需要设置会话1即可 |
2 | START TRANSACTION; | START TRANSACTION; | 两个会话都开启事务 |
3 | SELECT * FROM tb_account; | 在会话1中查看账户信息,发现没有王五信息 | |
4 | INSERT INTO tb_account VALUES(3, '600003', '王五', 2000); | 在会话2中插入王五的账户 | |
5 | COMMIT; | 会话2提交事务 | |
6 | SELECT * FROM tb_account; | 此时查询,仍然查询不到王五的信息,没有出现幻读 | |
7 | COMMIT; |
上面执行的过程中,会话1的步骤3和步骤6执行的结果是相同的,没有出现幻读。也就是说 MySQL 可重复读隔离级别已经解决不可重复读和读取数据时候的幻读问题。
但是 MySQL 可重复读隔离级别解决不了写数据时候的幻读问题,举个栗子:
步骤 | 会话1 | 会话2 | 说明 |
---|---|---|---|
1 | SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; | SET SESSION TRANSACTION ISOLATION LEVEL READ REPEATABLE READ; | 在两个会话中设置两个会话的隔离级别为:可重复读(Repeatable Read),其实这里主要需要设置会话1即可 |
2 | START TRANSACTION; | START TRANSACTION; | 两个会话都开启事务 |
3 | SELECT * FROM tb_account; | 在会话1中查看账户信息,此时只有张三李四两个账户, | |
4 | INSERT INTO tb_account VALUES(3, '600003', '王五', 2000); | 在会话2中插入王五的账户 | |
5 | COMMIT; | 会话2提交事务 | |
6 | SELECT * FROM tb_account; | 此时查询,查询不到王五的信息,没有出现幻读 | |
7 | UPDATE tb_account SET money = 3000 WHERE money > 1000; | 此时修改,应该只修改张三和李四的账户,但是其实王五的账号也修改了 | |
8 | COMMIT; |
执行完上面的步骤,重新单独执行一下查询 SELECT * FROM tb_account;
,发现王五的账户也被修改了,所以在写数据的时候,可重复读隔离级别没有解决幻读问题。
# 4 可序列化
可以将事务隔离级别为:可序列化(Serializable)来解决幻读的问题。
演示一下:
步骤 | 会话1 | 会话2 | 说明 |
---|---|---|---|
1 | SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE; | SET SESSION TRANSACTION ISOLATION LEVEL READ REPEATABLE READ; | 在两个会话中设置两个会话的隔离级别为:可重复读(Repeatable Read),其实这里主要需要设置会话1即可 |
2 | START TRANSACTION; | START TRANSACTION; | 两个会话都开启事务 |
3 | SELECT * FROM tb_account; | 在会话1中查看账户信息,此时只有张三李四两个账户, | |
4 | INSERT INTO tb_account VALUES(3, '600003', '王五', 2000); | 在会话2中插入王五的账户,注意:此时会话2的执行被阻塞了,在等待 | |
6 | SELECT * FROM tb_account; | 此时查询,查询不到王五的信息,没有出现幻读 | |
7 | UPDATE tb_account SET money = 3000 WHERE money > 1000; | 此时修改,只修改了张三和李四的账户,因为王五的数据还没有插入成功 | |
8 | COMMIT; | 此时会话2才结束等待 | |
COMMIT; | 会话2可以提交了 |
上面会话1使用了可序列化隔离级别,所以解决了写入出现幻读的问题。
当事务的隔离级别设置为串行后,所有事务按顺序执行,当会话1执行 SELECT * FROM tb_account;
时,因为没有查询条件,会对整张表数据加锁,防止删除和修改数据,还可以添加间隙锁,防止插入数据,导致其他事务只能查询数据,无法插入、更新或删除相关数据,所以效率很低。
如果上面事务1在步骤3中,只是查询 SELECT * FROM tb_account WHERE id = 1;
的用户,那么事务2只是不能对该行数据进行修改和删除,但是可以增加 id = 3
的用户,是没有问题的,但是需要提交后,事务2才能更新数据,如下:
步骤 | 会话1 | 会话2 | 说明 |
---|---|---|---|
1 | SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE; | SET SESSION TRANSACTION ISOLATION LEVEL READ REPEATABLE READ; | 在两个会话中设置两个会话的隔离级别为:可重复读(Repeatable Read),其实这里主要需要设置会话1即可 |
2 | START TRANSACTION; | START TRANSACTION; | 两个会话都开启事务 |
3 | SELECT * FROM tb_account WHERE id = 1; | 在会话1中查看ID为1的账号信息 | |
4 | INSERT INTO tb_account VALUES(3, '600003', '王五', 2000); | 在会话2中插入王五的账户,注意:此时会话2没有阻塞,直接执行完成。 | |
5 | SELECT * FROM tb_account; | 此时查询,会话1会被阻塞 | |
6 | COMMIT; | 会话2提交,会话1结束阻塞 | |
7 | 结束阻塞 | 查询到张三、李四、王五的账号 | |
8 | UPDATE tb_account SET money = 3000 WHERE money > 1000; | 此时修改,修改张三、李四、王五的账号 | |
9 | COMMIT; | 此时会话2才结束等待 |
我们在实际的开发中,一般也不会修改数据库的事务隔离级别,采用数据库默认的隔离级别,就能解决大部分的问题了。我们处理并发事务问题,更多的是在应用层的代码中进行并发的处理,例如使用乐观锁或者悲观锁等机制。