Note
事务
记录 MySQL 事务的基本操作、ACID 特性、并发问题与隔离级别
事务
事务(Transaction)是一组需要作为一个整体执行的数据库操作。事务中的操作要么全部成功并提交,要么在发生错误时全部撤销,使数据库回到事务开始前的状态。
以张三向李四转账 1000 元为例,至少需要完成两个操作:
- 张三的余额减少
1000元。 - 李四的余额增加
1000元。
如果扣款成功后程序发生异常,而收款操作没有执行,两个账户的余额总和就会出错。将扣款和收款放在同一个事务中,可以在全部操作成功时提交;任意一步失败时回滚,避免只完成一半。
本文示例以 MySQL 8.x 的 InnoDB 存储引擎为基础。事务能否回滚以及具体的锁行为与存储引擎有关,创建业务表时应明确使用支持事务的存储引擎。
事务操作
自动提交
MySQL 默认开启自动提交。没有显式开启事务时,每条成功执行的 SQL 语句都会作为一个独立事务提交,因此后续执行 ROLLBACK 不能撤销已经自动提交的修改。
-- 查看当前会话是否开启自动提交
SELECT @@autocommit;
-- 0 表示关闭,1 表示开启
SET autocommit = 0;
SET autocommit = 1;
autocommit 是会话级设置,只影响当前连接。关闭自动提交后,COMMIT 或 ROLLBACK 会结束当前事务,下一条事务性语句又会进入一个新事务。如果连接结束时仍有未提交的事务,MySQL 会将其回滚。
实际开发中通常保留自动提交,并使用 START TRANSACTION 明确包裹需要原子执行的一组操作。事务结束后,自动提交会恢复到原来的状态,边界更容易识别。
开启、提交与回滚
| 语句 | 作用 |
|---|---|
START TRANSACTION |
开启一个事务 |
BEGIN |
START TRANSACTION 的别名 |
COMMIT |
提交当前事务,使修改生效并对其他事务可见 |
ROLLBACK |
回滚当前事务,撤销尚未提交的修改 |
START TRANSACTION;
-- 在这里执行一组需要共同成功的 DML 语句
COMMIT;
发生异常或业务条件不满足时,使用回滚结束事务:
START TRANSACTION;
-- 在这里执行一组需要共同成功的 DML 语句
ROLLBACK;
在存储过程的 BEGIN ... END 代码块中,BEGIN 表示代码块的开始,不表示开启事务。为避免歧义,本文统一使用 START TRANSACTION。
保存点
保存点用于标记事务内部的某个位置,使事务可以只撤销保存点之后的操作,而不必回滚整个事务。
START TRANSACTION;
-- 第一组操作
SAVEPOINT after_first_step;
-- 第二组操作
ROLLBACK TO SAVEPOINT after_first_step;
RELEASE SAVEPOINT after_first_step;
COMMIT;
ROLLBACK TO SAVEPOINT 不会结束事务;事务仍需通过 COMMIT 或完整的 ROLLBACK 结束。
隐式提交
并非所有语句都能被事务包裹后回滚。许多 DDL 语句会在执行前隐式提交当前事务,例如 CREATE TABLE、ALTER TABLE、DROP TABLE 和 TRUNCATE TABLE。
因此,不应把表结构变更与普通的 INSERT、UPDATE、DELETE 混在同一个事务中,并期望一次 ROLLBACK 撤销全部操作。事务示例中的建表与数据初始化也需要在业务事务开始前完成。
事务的四大特性
事务的四大特性通常简称为 ACID。
| 特性 | 英文 | 说明 |
|---|---|---|
| 原子性 | Atomicity | 事务是不可再分的工作单元,事务内的操作要么全部成功,要么全部撤销 |
| 一致性 | Consistency | 事务从一个满足约束的有效状态转移到另一个满足约束的有效状态 |
| 隔离性 | Isolation | 并发事务之间的中间状态按隔离级别受到控制,避免相互产生不符合预期的干扰 |
| 持久性 | Durability | 事务一旦成功提交,其结果应在数据库能够恢复的故障后继续存在 |
一致性是事务最终需要达到的目标,不是某一个数据库机制单独完成的结果。原子性、隔离性、持久性,以及主键、唯一约束、外键、检查约束和正确的业务逻辑共同维护数据一致性。
持久性只针对已经提交的事务。ROLLBACK 的作用是撤销未提交的修改,不能表述为“回滚后的数据修改是永久的”。
并发事务问题
多个事务同时读写相同数据时,如果没有合适的隔离级别、锁或更新方式,可能产生以下问题。
脏读
一个事务读取到另一个事务尚未提交的数据。如果写入数据的事务随后回滚,前一个事务读到的就是从未真正生效的数据。
例如,事务 A 完成扣款和收款但尚未提交,事务 B 已经看到了新余额;随后事务 A 回滚,事务 B 之前读取的余额就成为脏数据。
不可重复读
同一个事务先后读取同一行数据,两次读取之间另一个事务修改并提交了该行,导致两次读取结果不同。
例如,事务 A 第一次读取张三余额为 2000;事务 B 完成转账并提交后,事务 A 再次读取张三,余额变为 1500。
幻读
同一个事务按照相同条件执行两次范围查询,两次查询返回的记录集合不同,好像多出或少了“幻影”记录。其他事务的插入、删除,或者使记录进入或离开查询范围的更新,都可能改变结果集合。
例如,事务 A 查询余额不少于 2500 元的账户时没有结果;事务 B 转账后使李四余额达到 2500 元并提交;事务 A 再次执行相同查询时看到了李四。
丢失更新
两个事务都基于之前读取的旧值计算新值,然后依次覆盖同一行,后一次写入可能抹掉前一次写入的结果。
丢失更新不属于隔离级别表中传统的三种读异常,但对转账业务尤其重要。可以使用原子更新 balance = balance - ?、条件更新、SELECT ... FOR UPDATE 或乐观锁版本号避免“先读到应用、计算后再覆盖”的竞争问题。
事务隔离级别
事务隔离级别用于在数据正确性与并发能力之间进行权衡。隔离越严格,并发事务能够观察或修改的数据越受限制,等待和死锁的可能性通常也越高。
按照 SQL 标准对三种读异常的定义,各隔离级别可以理解为:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
READ UNCOMMITTED |
可能出现 | 可能出现 | 可能出现 |
READ COMMITTED |
避免 | 可能出现 | 可能出现 |
REPEATABLE READ |
避免 | 避免 | 标准允许出现 |
SERIALIZABLE |
避免 | 避免 | 避免 |
InnoDB 默认使用 REPEATABLE READ,其实际行为比表格中的最低标准保证更强:
- 普通
SELECT是一致性非锁定读,同一事务内会读取第一次一致性读建立的快照,因此重复执行相同范围查询通常不会看到其他事务后来提交的“幻影”记录。 SELECT ... FOR UPDATE、SELECT ... FOR SHARE、UPDATE和DELETE属于锁定读或写操作。对于范围条件,InnoDB会根据索引与查询条件使用记录锁、间隙锁或临键锁,阻止其他事务向锁定范围制造新的匹配记录。- 普通快照读与锁定读的可见性规则不同。在同一事务中混合使用时,锁定读可能看到比既有快照更新的数据,不应简单认为所有语句都固定读取同一个版本。
因此,隔离级别表用于理解标准保证;分析 MySQL 中的具体现象时,还需要结合存储引擎、查询类型、索引和锁范围。
查看与设置隔离级别
-- 查看当前会话的隔离级别
SELECT @@transaction_isolation;
-- 设置当前会话后续事务的隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
只为下一个事务设置隔离级别时,可以省略 SESSION:
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
隔离级别应在事务开始前设置。修改 SESSION 只影响当前连接,不会改变已经建立的其他连接。
隔离机制
InnoDB 主要通过多版本并发控制和锁实现隔离:
- 多版本并发控制(MVCC)让普通查询在很多情况下读取一致性快照,减少读写之间的相互阻塞。
- 行锁保护正在读取或修改的索引记录;范围操作还可能使用间隙锁或临键锁保护索引区间。
- 写锁通常会持有到事务提交或回滚。事务应尽量短,并以一致顺序访问多行,降低锁等待与死锁风险。
即使设计正确,并发事务仍可能发生死锁。应用需要捕获死锁或锁等待超时错误,并在确认操作可安全重试后重新执行整个事务。
示例
数据准备
下面创建账户表,并为张三和李四分别存入 2000 元。DECIMAL 用于保存精确金额,检查约束阻止余额小于 0。
DROP TABLE IF EXISTS account;
CREATE TABLE account (
id BIGINT UNSIGNED PRIMARY KEY COMMENT '账户编号',
name VARCHAR(50) NOT NULL UNIQUE COMMENT '账户名称',
balance DECIMAL(12, 2) NOT NULL COMMENT '账户余额',
CONSTRAINT chk_account_balance CHECK (balance >= 0)
) ENGINE = InnoDB COMMENT = '账户表';
INSERT INTO account (id, name, balance)
VALUES
(1, '张三', 2000.00),
(2, '李四', 2000.00);
每次并发实验开始前,确认两个会话都已经结束上一个事务,再恢复初始余额:
UPDATE account
SET balance = 2000.00
WHERE id IN (1, 2);
提交转账事务
张三向李四转账 1000 元:
START TRANSACTION;
UPDATE account
SET balance = balance - 1000.00
WHERE id = 1
AND balance >= 1000.00;
UPDATE account
SET balance = balance + 1000.00
WHERE id = 2;
COMMIT;
提交后查询账户余额:
SELECT id, name, balance
FROM account
ORDER BY id;
| id | name | balance |
|---|---|---|
| 1 | 张三 | 1000.00 |
| 2 | 李四 | 3000.00 |
扣款语句使用 balance >= 1000.00 防止余额不足时继续扣款。真实业务代码必须检查两条 UPDATE 的受影响行数都等于 1;任意一条不满足时,应执行 ROLLBACK,不能继续提交。
回滚异常事务
先把上一节已经提交的转账恢复为初始余额:
UPDATE account
SET balance = 2000.00
WHERE id IN (1, 2);
下面故意把收款账户写成不存在的 id = 99。第二条语句影响 0 行,应用检测到失败后回滚事务:
START TRANSACTION;
UPDATE account
SET balance = balance - 1000.00
WHERE id = 1
AND balance >= 1000.00;
UPDATE account
SET balance = balance + 1000.00
WHERE id = 99;
-- 应用检查到收款语句影响 0 行,回滚整个事务
ROLLBACK;
回滚后再次查询,张三和李四的余额仍然都是 2000.00。SQL 语句“执行成功”只代表数据库接受了语句,不代表业务操作一定成功;目标账户不存在、余额不足等情况通常不会产生 SQL 错误,必须由应用检查受影响行数。
使用两个会话演示隔离级别
下面的会话 A 和会话 B 表示两个独立的数据库连接。不要在同一个查询窗口中依次执行两组语句,否则无法形成并发事务。
每个实验都应在两个会话结束前一个事务后重新初始化余额。设置隔离级别的语句也需要分别在对应会话中执行。
READ UNCOMMITTED:读取未提交的转账
会话 A 开启事务并完成转账,但暂不提交:
-- 会话 A
START TRANSACTION;
UPDATE account
SET balance = balance - 1000.00
WHERE id = 1;
UPDATE account
SET balance = balance + 1000.00
WHERE id = 2;
-- 暂不提交或回滚
会话 B 使用 READ UNCOMMITTED 查询:
-- 会话 B
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
START TRANSACTION;
SELECT id, name, balance
FROM account
ORDER BY id;
此时会话 B 可能读到张三 1000.00、李四 3000.00,即会话 A 尚未提交的结果。
让会话 A 回滚:
-- 会话 A
ROLLBACK;
会话 B 再次查询会看到余额恢复为 2000.00,说明第一次读取的是脏数据:
-- 会话 B
SELECT id, name, balance
FROM account
ORDER BY id;
COMMIT;
READ COMMITTED:每次读取最新提交结果
会话 A 先读取初始余额:
-- 会话 A
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT id, name, balance
FROM account
ORDER BY id;
保持会话 A 的事务不结束,在会话 B 中转账 500 元并提交:
-- 会话 B
START TRANSACTION;
UPDATE account
SET balance = balance - 500.00
WHERE id = 1
AND balance >= 500.00;
UPDATE account
SET balance = balance + 500.00
WHERE id = 2;
COMMIT;
会话 A 再次执行相同查询:
-- 会话 A
SELECT id, name, balance
FROM account
ORDER BY id;
COMMIT;
第二次会看到张三 1500.00、李四 2500.00。READ COMMITTED 不会读取会话 B 未提交的修改,但每次一致性读都会建立新快照,因此同一事务的两次读取可能不同。
如果把会话 A 前后的两次余额查询都替换为以下范围查询,会话 B 的转账还会使李四进入结果集,从查询集合的角度体现幻读:
SELECT id, name, balance
FROM account
WHERE balance >= 2500.00
ORDER BY id;
REPEATABLE READ:在一致性快照中重复读取
先重新初始化余额。会话 A 使用 InnoDB 的默认隔离级别,并通过第一次普通查询建立快照:
-- 会话 A
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT id, name, balance
FROM account
ORDER BY id;
会话 B 转账 500 元并提交:
-- 会话 B
START TRANSACTION;
UPDATE account
SET balance = balance - 500.00
WHERE id = 1
AND balance >= 500.00;
UPDATE account
SET balance = balance + 500.00
WHERE id = 2;
COMMIT;
会话 A 再次执行普通查询,仍然看到第一次查询建立的快照,即两个账户都是 2000.00:
-- 会话 A
SELECT id, name, balance
FROM account
ORDER BY id;
COMMIT;
事务提交后再查询,才会看到会话 B 已提交的 1500.00 和 2500.00。该实验演示的是普通 SELECT 的一致性非锁定读;如果改用 SELECT ... FOR UPDATE,读取规则和阻塞行为会不同。
SERIALIZABLE:让并发转账等待
先重新初始化余额。会话 A 使用 SERIALIZABLE 开启事务并读取两个账户:
-- 会话 A
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
START TRANSACTION;
SELECT id, name, balance
FROM account
WHERE id IN (1, 2)
ORDER BY id;
-- 暂不提交或回滚
会话 B 尝试执行转账:
-- 会话 B
START TRANSACTION;
UPDATE account
SET balance = balance - 500.00
WHERE id = 1
AND balance >= 500.00;
UPDATE account
SET balance = balance + 500.00
WHERE id = 2;
COMMIT;
会话 B 的第一条 UPDATE 会等待会话 A 释放相关锁。让会话 A 提交后,会话 B 才能继续:
-- 会话 A
COMMIT;
SERIALIZABLE 提供最强的隔离,但也最容易让并发操作互相等待,通常只在确实需要串行效果时使用。
并发转账的实现要点
事务隔离级别不是转账正确性的全部。实现真实转账逻辑时还应注意:
- 使用
InnoDB,并显式划定事务边界。 - 使用
DECIMAL保存金额,避免浮点数精度误差。 - 扣款时把余额校验写进
UPDATE条件,并检查受影响行数。 - 使用
balance = balance + ?或balance = balance - ?进行原子更新,避免用应用读取到的旧余额直接覆盖数据库。 - 多笔转账以一致顺序锁定账户,例如总是先锁定较小的账户
id,降低死锁概率。 - 事务内只执行必要操作,不在持锁期间进行网络请求或耗时计算。
- 为死锁和锁等待超时设计安全的重试策略,重试时重新执行整个事务。