RSS
菜单
全部文章快讯开发科技深度热点

第 14 章 事务与隔离级别

内容摘要

事务(Transaction)是 MySQL 区别于普通脚本文件系统的核心能力,也是面试与生产事故中出现频率最高的主题之一。本篇假设你已经会建库建表、写基本的 INSERT/UPDATE/SELECT,能看懂单表 CRUD。学完之后,你将能写出要么全成功、要么全回滚的业务 SQL,理解脏读、不可重复读、幻读三大并发问题,并能根据业务场景正确选择四种隔离级别。本示例基于 MySQL 8.0(InnoDB 引擎)。

📖 本篇简介:事务(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 章 用户与权限管理

— 全文完 —回到顶部 ↑
下载推广海报

文章推广海报

《第 14 章 事务与隔离级别》完整推广海报
DISCUSSION

文章回复

0 条公开回复
未登录回复需要审核后公开
还没有回复,欢迎参与讨论。