📖 本篇简介:事务(Transaction)是 MySQL 区别于普通脚本文件系统的核心能力,也是面试与生产事故中出现频率最高的主题之一。本篇假设你已经会建库建表、写基本的 INSERT/UPDATE/SELECT,能看懂单表 CRUD。学完之后,你将能写出要么全成功、要么全回滚的业务 SQL,理解脏读、不可重复读、幻读三大并发问题,并能根据业务场景正确选择四种隔离级别。本示例基于 MySQL 8.0(InnoDB 引擎)。
目录
- 1 什么是事务
- 2 ACID 四大特性
- 3 事务的基本用法 BEGIN COMMIT ROLLBACK
- 4 自动提交与保存点 SAVEPOINT
- 5 四种隔离级别总览
- 6 脏读 不可重复读 幻读
- 7 查看与设置隔离级别
- 8 隔离级别速查表
- 9 事务实战演示
- 10 常见坑与注意事项
- 11 练习题
1 什么是事务
事务(Transaction)就是把一组 SQL 语句打包成一个不可分割的执行单元:它们要么全部成功生效,要么全部撤销,数据库永远不会停留在"做了一半"的中间状态。
最经典的例子是银行转账:A 给 B 转 100 元,需要两步——A 的余额减 100,B 的余额加 100。如果第一步执行成功、第二步执行到一半数据库崩溃,A 的钱凭空少了、B 的钱却没多,这就是事故。事务保证这两步要么都发生,要么都不发生。
在 MySQL 中,只有支持事务的存储引擎才能使用事务。MySQL 8.0 的默认引擎是 InnoDB,它完整支持事务;MyISAM 不支持事务,所以现在几乎不会再用它做业务库。
-- 本示例基于 MySQL 8.0(InnoDB)
-- 查看当前表使用的存储引擎
SHOW TABLE STATUS LIKE 'account';
-- 建一张账户表,显式指定 InnoDB 引擎
CREATE TABLE account (
id INT PRIMARY KEY AUTO_INCREMENT, -- 账户编号
name VARCHAR(32) NOT NULL, -- 户主姓名
balance DECIMAL(12,2) NOT NULL DEFAULT 0 -- 账户余额
) ENGINE = InnoDB;
INSERT INTO account(name, balance) VALUES
('张三', 1000.00),
('李四', 1000.00);
2 ACID 四大特性
事务的正确性由四个性质保证,合称 ACID,是面试必考题:
| 特性 | 英文 | 含义 | InnoDB 如何实现 |
|---|---|---|---|
| 原子性 | Atomicity | 事务要么全做,要么全不做 | undo log(回滚日志) |
| 一致性 | Consistency | 事务前后数据库从一个合法状态变到另一个合法状态 | 由其余三者共同保证 |
| 隔离性 | Isolation | 并发事务之间互不干扰 | 锁 + MVCC(多版本并发控制) |
| 持久性 | Durability | 事务一旦提交,永久生效 | redo log(重做日志) |
- 原子性:靠 undo log。每条修改在执行前都会记录反向操作,回滚时照着 undo log 反向执行即可。
- 一致性:是最终目标。数据库的约束(主键、外键、唯一索引、CHECK)在事务前后都不能被破坏。
- 隔离性:靠锁和 MVCC。后面讲隔离级别时会展开。
- 持久性:靠 redo log。提交时先把修改顺序写入 redo log 并刷盘,即使立刻宕机,重启后也能重放 redo log 恢复数据。
-- 一致性的直观体现:主键约束不能被事务"绕过去"
BEGIN;
INSERT INTO account(id, name, balance) VALUES (1, '重复主键', 500); -- 主键冲突,报错
COMMIT; -- 即使执行了 COMMIT,前面失败的插入也不会生效
SELECT * FROM account; -- 数据保持事务开始前的一致状态
3 事务的基本用法 BEGIN COMMIT ROLLBACK
事务的三个核心命令:
BEGIN或START TRANSACTION:开启事务;COMMIT:提交,把所有修改永久落盘;ROLLBACK:回滚,撤销事务开始后的所有修改。
-- 张三给李四转 100 元的完整事务
BEGIN; -- 1. 开启事务
UPDATE account SET balance = balance - 100 WHERE name = '张三'; -- 2. 张三扣钱
UPDATE account SET balance = balance + 100 WHERE name = '李四'; -- 3. 李四加钱
-- 此时两个会话里都能看到中间状态,但其他未提交事务看不到
SELECT name, balance FROM account; -- 4. 自检余额
COMMIT; -- 5. 确认无误,提交;如果中途出错则改成 ROLLBACK
如果把 COMMIT 换成 ROLLBACK:
BEGIN;
UPDATE account SET balance = balance - 100 WHERE name = '张三';
-- 突然发现李四账户写错了,不想转了
ROLLBACK; -- 张三的扣款被撤销,余额回到事务开始前
SELECT name, balance FROM account; -- 张三仍是 1000,李四仍是 1000
关键点:事务里的修改在 COMMIT 之前,对其他会话是不可见的(默认隔离级别下)。这就是"未提交数据"与"已提交数据"的区别。
4 自动提交与保存点 SAVEPOINT
4.1 autocommit
MySQL 默认开启了 autocommit(自动提交),意味着你每执行一条单独的 DML,它都会自动包成一个事务并立刻提交。这就是为什么你不写 BEGIN 也能看到数据落盘。
-- 查看自动提交状态:1 表示开启
SHOW VARIABLES LIKE 'autocommit';
-- 在显式 BEGIN 之后,autocommit 暂时失效,直到 COMMIT/ROLLBACK 结束
-- 临时关闭自动提交(仅当前会话)
SET autocommit = 0;
UPDATE account SET balance = balance - 50 WHERE name = '张三'; -- 这条不会自动提交
ROLLBACK; -- 手动回滚
SET autocommit = 1; -- 恢复默认
4.2 SAVEPOINT
保存点可以在一个事务内部"打标记",只回滚到某个标记,而不必把整个事务全部撤销。
BEGIN;
UPDATE account SET balance = balance - 100 WHERE name = '张三'; -- 第一步扣款
SAVEPOINT sp1; -- 打第一个保存点
UPDATE account SET balance = balance + 100 WHERE name = '李四'; -- 第二步加款
SAVEPOINT sp2; -- 打第二个保存点
-- 发现第二步不该加给李四,回退到 sp1 之后、sp2 之前
ROLLBACK TO SAVEPOINT sp2; -- 也可以是 ROLLBACK TO sp2
RELEASE SAVEPOINT sp2; -- 不需要了可以手动释放保存点
COMMIT; -- 最终提交:只保留了第一步扣款(按业务需要再补正确的对方账户)
注意:ROLLBACK TO SAVEPOINT 不会结束整个事务,事务仍然处于打开状态,最后还是要 COMMIT 或 ROLLBACK。
5 四种隔离级别总览
隔离级别(Isolation Level)定义了多个并发事务之间"能看见彼此多少未提交的数据"。SQL 标准定义了四种,MySQL InnoDB 全部支持:
| 隔离级别 | 英文名 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|---|
| 读未提交 | READ UNCOMMITTED | 可能 | 可能 | 可能 |
| 读已提交 | READ COMMITTED | 不可能 | 可能 | 可能 |
| 可重复读 | REPEATABLE READ | 不可能 | 不可能 | 基本不可能* |
| 串行化 | SERIALIZABLE | 不可能 | 不可能 | 不可能 |
\* MySQL InnoDB 在默认的 REPEATABLE READ 级别下,通过 Next-Key Lock(临键锁)很大程度上阻止了幻读,这一点是 MySQL 比 SQL 标准做得更严格的地方,后面会再提到。
MySQL 8.0 默认隔离级别是 REPEATABLE READ(可重复读)。
6 脏读 不可重复读 幻读
这三个概念必须分清,它们是递进关系:
6.1 脏读(Dirty Read)
一个事务读到了另一个事务还没提交的修改。如果对方回滚,你读到的就是从来不存在的"脏数据"。
场景:事务 A 把张三余额改成 0 但还没提交,事务 B 此时读到 0,并据此给张三判了"破产"。随后 A 回滚,张三其实还是 1000。B 的判断建立在脏数据上。
6.2 不可重复读(Non-Repeatable Read)
同一个事务内,两次读同一行,结果不一样——因为中间被别的事务 UPDATE 并提交了。重点在"同一行被改"。
场景:事务 A 第一次查张三余额是 1000;事务 B 把张三改成 800 并提交;事务 A 再查,变成 800。对 A 来说,"重复读"失败了。
6.3 幻读(Phantom Read)
同一个事务内,两次按相同条件查询,结果集的"行数"变了——因为中间被别的事务 INSERT/DELETE 并提交了。重点在"行数变化",像出现了幻影行。
场景:事务 A 查询余额大于 500 的账户有 2 条;事务 B 插入一条余额 800 的账户并提交;事务 A 再查,变成 3 条。
一句话区分:脏读读别人没提交的;不可重复读是同一行被 UPDATE;幻读是结果集行数因 INSERT/DELETE 变化。
7 查看与设置隔离级别
-- 查看全局和当前会话的隔离级别(MySQL 8.0 默认输出 REPEATABLE-READ)
SELECT @@GLOBAL.transaction_isolation, @@SESSION.transaction_isolation;
-- 设置当前会话的隔离级别为"读已提交"
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 设置全局隔离级别(对之后新建立的连接生效,已存在连接不受影响)
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 也可以写成带下划线的形式
SET SESSION TRANSACTION ISOLATION LEVEL
READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE;
生产中想永久生效,要写到配置文件 my.cnf / my.ini 的 [mysqld] 段:
[mysqld]
# 永久设置默认隔离级别为读已提交(互联网公司常见配置)
transaction_isolation = READ-COMMITTED
建议:OLTP 互联网业务常用 READ COMMITTED(减少锁冲突、配合 RC 下的半一致性读提升并发),金融核心账务保持 REPEATABLE READ。不要轻易用 SERIALIZABLE,它会把所有普通 SELECT 变成加锁读,并发性能急剧下降。
8 隔离级别速查表
| 需求场景 | 推荐隔离级别 | 说明 |
|---|---|---|
| 默认、账务类、要求强一致 | REPEATABLE READ | MySQL 默认,MVCC + 临键锁 |
| 高并发互联网业务、可接受短暂不一致 | READ COMMITTED | 锁冲突小,语句级快照 |
| 几乎不用,仅极端调试 | READ UNCOMMITTED | 会脏读,生产禁用 |
| 严格串行、报表对账极敏感 | SERIALIZABLE | 全表加锁,性能差 |
| 命令 | 作用 |
|---|---|
| BEGIN; / START TRANSACTION; | 开启事务 |
| COMMIT; | 提交事务 |
| ROLLBACK; | 回滚整个事务 |
| SAVEPOINT sp1; | 打保存点 |
| ROLLBACK TO SAVEPOINT sp1; | 回退到保存点 |
| SELECT @@transaction_isolation; | 查看当前隔离级别 |
9 事务实战演示
下面用一个完整的"下单扣库存"流程串起来。表结构先建好:
-- 商品表与库存表(基于 MySQL 8.0 InnoDB)
CREATE TABLE product (
id INT PRIMARY KEY,
name VARCHAR(64),
stock INT NOT NULL DEFAULT 0 -- 库存数量
) ENGINE = InnoDB;
CREATE TABLE orders (
id INT PRIMARY KEY AUTO_INCREMENT,
product_id INT,
qty INT,
status VARCHAR(16) DEFAULT 'paid'
) ENGINE = InnoDB;
INSERT INTO product(id, name, stock) VALUES (1, '机械键盘', 10);
下单事务:先扣库存、再写订单,两步必须同生共死。
BEGIN;
-- 1. 扣减库存(带条件:库存必须足够,否则影响行数为 0)
UPDATE product SET stock = stock - 1 WHERE id = 1 AND stock > 0;
-- 判断上一行受影响行数:为 0 说明库存不足,主动回滚
-- 在程序里通常用 cursor.rowcount 判断,这里用 SQL 模拟:
-- IF ROW_COUNT() = 0 THEN ROLLBACK; END IF;
-- 2. 写订单
INSERT INTO orders(product_id, qty) VALUES (1, 1);
-- 3. 关键:先查再决定提交,避免扣了库存却写订单失败
SELECT stock FROM product WHERE id = 1;
COMMIT; -- 一切正常才提交;任何一步异常都 ROLLBACK
下面用 Python + sqlite3 演示"事务要么全成功要么全回滚"的真实效果(逻辑与 InnoDB 一致),把真实输出贴出来:
# 用 Python 标准库 sqlite3 演示事务回滚效果(与 MySQL InnoDB 行为一致)
import sqlite3
conn = sqlite3.connect(":memory:") # 内存库,便于演示
cur = conn.cursor()
cur.execute("CREATE TABLE account(name TEXT, balance REAL)")
cur.execute("INSERT INTO account VALUES ('张三', 1000), ('李四', 1000)")
# —— 场景一:正常提交 ——
conn.execute("BEGIN")
conn.execute("UPDATE account SET balance = balance - 100 WHERE name='张三'")
conn.execute("UPDATE account SET balance = balance + 100 WHERE name='李四'")
conn.commit()
print("提交后:", cur.execute("SELECT name, balance FROM account").fetchall())
# —— 场景二:中途出错,回滚 ——
try:
conn.execute("BEGIN")
conn.execute("UPDATE account SET balance = balance - 500 WHERE name='张三'")
raise RuntimeError("模拟数据库崩溃") # 主动制造异常
conn.execute("UPDATE account SET balance = balance + 500 WHERE name='李四'")
except RuntimeError:
conn.rollback() # 捕获异常后回滚
print("回滚后:", cur.execute("SELECT name, balance FROM account").fetchall())
conn.close()
真实运行输出(已在 Python 3.14 实跑):
提交后: [('张三', 900.0), ('李四', 1100.0)]
回滚后: [('张三', 900.0), ('李四', 1100.0)]
可以看到:第一次提交后张三变成 900、李四变成 1100;第二次扣款 500 因异常回滚,余额保持不变——这正是原子性。
9.1 程序里的事务范式
真实业务里事务是写在代码里的,下面是 Python(pymysql,仅示范,本机无 MySQL 服务器故未实跑)的标准范式:
# pymysql 事务范式(本机无 MySQL,仅作示范)
import pymysql
conn = pymysql.connect(host="127.0.0.1", user="root",
password="***", database="shop", autocommit=False)
try:
with conn.cursor() as cur:
# 扣库存
cur.execute("UPDATE product SET stock=stock-1 WHERE id=1 AND stock>0")
if cur.rowcount == 0:
raise RuntimeError("库存不足")
# 写订单
cur.execute("INSERT INTO orders(product_id, qty) VALUES (1,1)")
conn.commit() # 全部成功才提交
except Exception:
conn.rollback() # 任何异常都回滚
raise
finally:
conn.close()
要点:autocommit=False 后必须手动 commit;异常必须 rollback;不要把远程调用塞进事务里。
9.2 快照读与当前读
InnoDB 在 RR 级别下有两种读:
| 读类型 | 写法 | 可见性 |
|---|---|---|
| 快照读 | 普通 SELECT | 读事务开始时的快照,看不到别人新提交 |
| 当前读 | SELECT ... FOR UPDATE / LOCK IN SHARE MODE、UPDATE、DELETE | 读最新已提交版本,并加锁 |
-- 快照读:看到的是事务启动时的快照
SELECT * FROM account WHERE name = '张三';
-- 当前读:强制读最新数据并加排他锁
SELECT * FROM account WHERE name = '张三' FOR UPDATE;
理解这两者的区别,是后面"幻读为什么在 RR 下基本被解决"的关键:普通快照读根本看不到新插入的行,自然不会幻读;当前读则靠临键锁把插入路径堵住。
9.3 长事务的监控
长事务(执行很久不提交的事务)是线上事故常客。MySQL 8.0 可以从 information_schema.innodb_trx 查到:
-- 查看当前 InnoDB 里所有正在运行的事务
SELECT trx_id, trx_started, trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;
-- 找到跑太久的事务后,用 KILL 结束它的连接
-- KILL <trx_mysql_thread_id>;
发现一个事务跑了几十秒还没结束,就要警惕:是不是事务里夹了慢查询、是不是忘了 commit。
10 常见坑与注意事项
坑 1:把 DDL 写进事务里导致隐式提交。
CREATE TABLE、ALTER TABLE、TRUNCATE 等 DDL 语句会隐式提交当前事务,前面 BEGIN 的修改被强行提交,ROLLBACK 失效。所以事务里只放 DML(INSERT/UPDATE/DELETE/SELECT),不要混 DDL。
BEGIN;
UPDATE account SET balance = balance - 100 WHERE name = '张三';
CREATE TABLE t(id INT); -- 这条 DDL 会隐式 COMMIT 上面的 UPDATE!
ROLLBACK; -- 想撤销 UPDATE?已经晚了,已被提交
坑 2:长事务不提交也不回滚。
事务一直开着不 COMMIT/ROLLBACK,会一直持有 undo log 和行锁,导致 undo 不能 purge、其他事务被锁阻塞、主从延迟变大。规范做法:事务尽量短小,把 RPC、发邮件、调第三方接口放到事务外面。
坑 3:以为默认隔离级别能挡住一切幻读。
REPEATABLE READ 下,普通快照读(普通 SELECT)看不到别的事务新插入的行;但当前读(SELECT ... FOR UPDATE)仍可能遇到幻读,需要靠临键锁或显式加范围锁来防。
坑 4:异常了却忘了 rollback。
程序里 catch 到异常后必须显式 rollback,否则连接池把这条还开着事务的连接还回去,下一个使用者会"继承"到一个悬挂事务。
# 正确范式:try 里 commit,except 里 rollback
try:
cur.execute("BEGIN")
do_business()
conn.commit()
except Exception:
conn.rollback() # 一定要回滚
raise
坑 5:一条 SQL 自动提交 + 多条 SQL 手控事务混用。
因为 autocommit 默认开,单条 UPDATE 本身就是一个事务;只有显式 BEGIN 之后的多条语句才是同一个事务。不要误以为"我没写 BEGIN,多条 UPDATE 也是一个事务"。
11 练习题
练习 1:为什么说 InnoDB 的 REPEATABLE READ 比 SQL 标准定义得更严格?它额外解决了什么问题?
<details>
<summary>参考答案</summary>
SQL 标准里 REPEATABLE READ 只保证不可重复读,不保证不幻读;而 MySQL InnoDB 在 RR 级别下通过 MVCC(快照读)+ Next-Key Lock(临键锁,对当前读加范围锁),在很大程度上同时阻止了幻读。这是 InnoDB 做得比标准更严的地方,也是很多业务敢直接用默认 RR 的原因。
</details>
练习 2:写出一条 SQL,查看当前会话的隔离级别,并把它改成 READ COMMITTED。
<details>
<summary>参考答案</summary>
-- 查看当前会话隔离级别
SELECT @@SESSION.transaction_isolation;
-- 修改为读已提交
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
</details>
练习 3:脏读、不可重复读、幻读三者的区别是什么?各对应哪个隔离级别才能彻底避免?
<details>
<summary>参考答案</summary>
脏读:读到别人未提交的数据,READ COMMITTED 及以上即可避免;
不可重复读:同一事务内同一行两次读结果不同(被 UPDATE),REPEATABLE READ 及以上避免;
幻读:同一事务内相同条件两次查询行数不同(被 INSERT/DELETE),SERIALIZABLE 可彻底避免,InnoDB 的 RR 通过临键锁已基本解决。
</details>
练习 4:在事务里写了一句 ALTER TABLE,之后想 ROLLBACK 撤销前面的 UPDATE,为什么失败?
<details>
<summary>参考答案</summary>
因为 ALTER TABLE 属于 DDL,MySQL 执行 DDL 前会隐式 COMMIT 当前事务,前面的 UPDATE 已经被永久提交,再 ROLLBACK 也无法撤销。事务里只应放 DML。
</details>
练习 5:用一段 Python(sqlite3 即可)模拟:转账 200 元,转账过程中故意抛异常,验证余额回滚。
<details>
<summary>参考答案</summary>
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE a(name TEXT, bal REAL)")
conn.execute("INSERT INTO a VALUES ('A',1000),('B',1000)")
try:
conn.execute("BEGIN")
conn.execute("UPDATE a SET bal=bal-200 WHERE name='A'")
raise RuntimeError("crash") # 模拟异常
conn.execute("UPDATE a SET bal=bal+200 WHERE name='B'")
conn.commit()
except RuntimeError:
conn.rollback()
print(conn.execute("SELECT * FROM a").fetchall()) # 余额不变
输出为 [('A', 1000.0), ('B', 1000.0)],证明扣款被回滚。
</details>
下一篇预告:第 15 章 用户与权限管理
文章回复
0 条公开回复