吹什么轩头像

MySQL 事务管理:从 ACID 到 MVCC,一篇讲透

前六篇,我们讲的都是「单条 SQL 怎么写得对、写得快」。

但真实业务从来不是单条 SQL —— 它是一串必须「同生共死」的操作: 买票要扣库存 + 扣钱 + 生成订单,三步里任何一步失败,前面做的都得撤回去。

这就是事务。

而这一篇,我会从一个买票的截图开始,一路讲到 MySQL 面试最硬的那块骨头:MVCC。


目录


0. 开篇:四个灵魂拷问

课件第一页就抛出了两个问题,我觉得是整章最好的开场:

「CURD 不加控制,会有什么问题?CURD 满足什么属性,能解决上述问题?」

然后它给了四个「买票」的具体拷问:

1. 买票的过程得是原子的吧
2. 买票互相应该不能影响吧
3. 买完票应该要永久有效吧
4. 买前,和买后都要是确定的状态吧

这四个问题,恰好就是 ACID 四属性的「人话版」。

拷问属性英文
买票的过程得是原子的原子性Atomicity
买票互相不能影响隔离性Isolation
买完票要永久有效持久性Durability
买前买后都是确定的状态一致性Consistency

记住这个对应关系。 后面所有理论,都只是给这四句人话做「技术注解」。


1. 什么是事务

1.1 定义

事务就是一组 DML 语句组成,这些语句在逻辑上存在相关性,这一组 DML 语句要么全部成功,要么全部失败,是一个整体。

MySQL 提供一种机制,保证我们达到这样的效果。事务还规定不同的客户端看到的数据是不相同的。

这句话里其实说了两个东西,很多人只记住第一个:

半句说的是什么
「要么全部成功,要么全部失败」事务的原子性 + 持久性(A + D)
「不同的客户端看到的数据是不相同的」事务的隔离性(I)

第二个半句极其重要——它是后面「四大隔离级别」和「MVCC」的入口。很多人学完事务只会背 ACID,却不知道「隔离性」到底在隔离什么。

1.2 课件那个「教务系统」的例子

假设一种场景:你毕业了,学校的教务系统后台 MySQL 中,不再需要你的数据,要删除你的所有信息(一般不会 :)),那么要删除你的基本信息(姓名、电话、籍贯等)的同时,也删除和你有关的其他信息,比如:你的各科成绩、你在校表现,甚至你在论坛发过的文章等。

这样,就需要多条 MySQL 语句构成,那么所有这些操作合起来,就构成了一个事务。

这个例子的精髓在于「多条语句必须一起生效或一起失效」:

-- 如果没有事务,中间挂了会怎样?
delete from student_basic where id = 1001;   -- ✅ 成功
delete from student_score where sid = 1001;  -- ✅ 成功
delete from student_article where sid = 1001;-- ❌ 断电了!

-- 结果:人的基本信息没了,但他的成绩和文章还在
-- 数据库进入了「不一致状态」

这就是事务要解决的第一类问题:中途失败。

1.3 课件最重要的一句话:事务是「为应用层服务」的

事务被 MySQL 编写者设计出来,本质是为了当应用程序访问数据库的时候,事务能够简化我们的编程模型,不需要我们去考虑各种各样的潜在错误和并发问题。

可以想一下当我们使用事务时,要么提交,要么回滚,我们不会去考虑网络异常了、服务器宕机了、同时更改一个数据怎么办对吧?

因此事务本质上是为了应用层服务的,而不是伴随着数据库系统天生就有的。

这句话含金量极高,我给它加粗:

事务不是数据库的「天然属性」,是数据库「送给应用层的一份礼物」。

为什么这么说? 因为如果没有事务,你这个「扣钱 + 加钱」的逻辑得自己写:

// 没有事务,你得自己处理所有异常
try {
    updateBalance(from, -100);
} catch (Exception e) {
    // 怎么办?网络超时了,到底扣没扣成功?不知道!
    // 你得去查、去重试、去补偿……
}

有了事务:

begin;
update account set balance = balance - 100 where id = 1;
update account set balance = balance + 100 where id = 2;
commit;   // 要么都成,要么 rollback 都撤

「要么 commit,要么 rollback」——两选一,中间的一切崩溃、超时、宕机,数据库都替你兜住。

这就是「简化编程模型」。

🔗 前后照应:这和第 05 篇讲的「约束是把业务规则交给数据库」是同一个哲学。

  • 约束:把「数据合法性」托管给数据库
  • 事务:把「操作原子性」托管给数据库

数据库的价值,就是替你兜住那些你不想写也写不好的东西。


2. ACID:四个属性各管一件事

课件逐条解释得很清楚,我抄下来并配上「人话版」:

2.1 原子性(Atomicity)

一个事务(transaction)中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。

人话:转账要么都成,要么都没发生,绝不会有「扣了钱没加钱」的中间态。

实现手段:undo log(记录反向操作,回滚时按相反顺序执行)。

2.2 一致性(Consistency)

在事务开始之前和事务结束以后,数据库的完整性没有被破坏。这表示写入的资料必须完全符合所有的预设规则,这包含资料的精确度、串联性以及后续数据库可以自发性地完成预定的工作。

人话:转账前后,两个账户的总额必须相等(1000 + 0 = 500 + 500)。

⚠️ 这一条最特殊——课件在后面专门澄清了一件事(第 7 节会展开):

一致性和用户的业务逻辑强相关。MySQL 提供技术支持,但一致性还是要用户业务逻辑做支撑。也就是说,一致性,是由用户决定的。而技术上,通过 AID 保证 C。

记住这个结论:

C(一致性)是「目的」,A、I、D 是「手段」。 数据库能保证「总额不变」这种规则吗?不能——它不知道你的业务规则是什么。它只能通过原子性、隔离性、持久性,为你的业务规则「创造不出错的环境」。

2.3 隔离性(Isolation)

数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致的数据的不一致。

事务隔离分为不同级别,包括读未提交(Read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(Serializable)。

人话:两个人在同一时刻转账,不会互相看到对方的中间状态。

实现手段:锁 + MVCC。

2.4 持久性(Durability)

事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。

人话:commit 成功后,即使下一秒服务器被雷劈了,这笔转账也一定还在。

实现手段:redo log(先写日志再刷盘,WAL)。

2.5 一张表记住四个属性

属性一句话实现手段出问题会怎样
A 原子性要么全成,要么全不undo log扣了钱没加钱
C 一致性数据符合业务规则由 A + I + D 保证总额对不上
I 隔离性并发事务互不干扰锁 + MVCC脏读/幻读/丢更新
D 持久性提交了就永不丢redo log宕机后数据丢失

⭐ 一句话串起来:

「原子性管回滚,持久性管提交,隔离性管并发,一致性是它们共同的目标。」


3. 谁支持事务?怎么提交?

3.1 只有 InnoDB 支持事务

在 MySQL 中,只有使用了 InnoDB 数据库引擎的数据库或表才支持事务,MyISAM 不支持。

查看所有引擎:

mysql> show engines \G
*************************** 1. row ***************************
      Engine: InnoDB                    -- 引擎名称
     Support: DEFAULT                   -- 默认引擎
     Comment: Supports transactions, row-level locking, and foreign keys
Transactions: YES                       -- 支持事务 ✅
          XA: YES
  Savepoints: YES                       -- 支持事务保存点 ✅
*************************** 2. row ***************************
      Engine: MRG_MYISAM
Transactions: NO
*************************** 3. row ***************************
      Engine: MEMORY                    -- 内存引擎
Transactions: NO
*************************** 4. row ***************************
      Engine: BLACKHOLE
Transactions: NO
*************************** 5. row ***************************
      Engine: MyISAM
Transactions: NO                        -- MyISAM 不支持事务 ❌
*************************** 6. row ***************************
      Engine: CSV
Transactions: NO
...
9 rows in set (0.00 sec)

⚠️ 这里有个「埋了很多年的坑」:

如果你的表是 MyISAM,你写 begin; ... rollback; 不会报错,但也不会回滚——语句已经生效了。

create table t_bad (id int primary key) engine=MyISAM;
begin;
insert into t_bad values (1);
rollback;
select * from t_bad;   -- ⚠️ 数据还在!因为 MyISAM 根本不支持事务

🔗 前后照应:第 05 篇讲约束时说过「MyISAM 不支持外键」;第 10 篇讲索引时说「MyISAM 是非聚簇索引」。这里补上第三条:MyISAM 不支持事务。

所以:任何需要事务的表,引擎必须是 InnoDB。 建表时别手滑写 engine=MyISAM。

3.2 两种提交方式

事务的提交方式常见的有两种:自动提交、手动提交。

mysql> show variables like 'autocommit';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit    | ON    |     -- 默认开启自动提交
+---------------+-------+
1 row in set (0.41 sec)

用 SET 改变自动提交模式:

mysql> SET AUTOCOMMIT=0;   # 禁止自动提交
Query OK, 0 rows affected

mysql> show variables like 'autocommit';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit    | OFF   |
+---------------+-------+

mysql> SET AUTOCOMMIT=1;   # 开启自动提交
Query OK, 0 rows affected

mysql> show variables like 'autocommit';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit    | ON    |
+---------------+-------+

autocommit 到底是什么?

值行为
ON(默认)每条 SQL 都「自成事务」,执行完自动 commit
OFF每条 SQL 都要手动 commit 才生效(可以 rollback)

⚠️ 一个必须知道的坑:

set autocommit = 0;
insert into t values (1);       -- 没 commit,数据只在当前会话可见
-- 一直不 commit,也不 rollback

这时候这行数据:

  • 别人看不到(隔离性挡住了)
  • 但锁还拿着(阻止别人改这一行)
  • undo log 还在增长
  • 如果你连 commit 都忘了,这就是「长事务」

所以:autocommit = 0 是个危险设置。 生产环境(尤其是连接池)一般保持 ON,需要事务时用 begin ... commit 显式开启。别全局关掉 autocommit,否则忘了 commit 的会话会一直持锁。


4. 实验环境:一张银行账户表

create database if not exists trans_demo default character set utf8mb4;
use trans_demo;

-- 简单银行用户表
create table if not exists account (
    id     int primary key,
    name   varchar(50) not null default '',
    blance decimal(10,2) not null default 0.0     -- 课件原文就拼成了 blance(balance 的笔误),照抄方便对照
) engine=InnoDB default charset=utf8mb4;

insert into account values (1, '张三', 100.00), (2, '李四', 10000.00);

💡 顺手纠正课件一个拼写:blance 应该是 balance。这种笔误在真实项目里非常常见,也真的非常难改(改名要动代码、ORM、报表)——所以建表时把字段名拼对,是一项被严重低估的能力。

为了演示方便,课件把隔离级别设成了「读未提交」:

mysql> set global transaction isolation level READ UNCOMMITTED;
Query OK, 0 rows affected (0.00 sec)

mysql> quit
Bye

-- ⚠️ 必须重连才会生效(global 只影响新建会话)
mysql> select @@tx_isolation;
+------------------+
| @@tx_isolation   |
+------------------+
| READ-UNCOMMITTED |
+------------------+

⚠️ 这里必须补一刀(课件用的是 MySQL 5.7):

MySQL 8.0 起 @@tx_isolation 已被移除,要改用 @@transaction_isolation。 (5.7.20 起 tx_isolation 就被标记为 deprecated)

-- 5.7 及以前
select @@tx_isolation;

-- 8.0(旧写法会报 ERROR 1193: Unknown system variable 'tx_isolation')
select @@transaction_isolation;

后面所有涉及 @@tx_isolation 的地方,请自行替换成 @@transaction_isolation。


5. 五个演示,把事务「演」给你看

课件这一节用了两个终端(会话 A / 会话 B),这是理解事务的最佳方式——因为「隔离性」只有在两个会话里才看得见。

5.1 演示一:正常流程(保存点 + 回滚 + 提交)

mysql> show variables like 'autocommit';   -- 我们故意保持自动提交,看看它是否影响 begin
+---------------+-------+
| autocommit    | ON    |
+---------------+-------+

mysql> start transaction;                  -- 开始事务(也可以写 begin,推荐 begin)
Query OK, 0 rows affected

mysql> savepoint save1;                    -- 保存点 save1
Query OK, 0 rows affected

mysql> insert into account values (1, '张三', 100);
Query OK, 1 row affected

mysql> savepoint save2;                    -- 保存点 save2
Query OK, 0 rows affected

mysql> insert into account values (2, '李四', 10000);
Query OK, 1 row affected

mysql> select * from account;              -- 两条记录都在了
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |   100.00 |
|  2 | 李四   | 10000.00 |
+----+--------+----------+

mysql> rollback to save2;                  -- 回滚到保存点 save2
Query OK, 0 rows affected

mysql> select * from account;              -- 李四那条没了
+----+--------+--------+
| id | name   | blance |
+----+--------+--------+
|  1 | 张三   | 100.00 |
+----+--------+--------+

mysql> rollback;                           -- 直接 rollback,回到事务最开始
Query OK, 0 rows affected

mysql> select * from account;              -- 全空了
Empty set

这个演示里有三个关键点:

① savepoint 是「事务内的书签」

begin ──┬── insert 张三 ──┬── insert 李四 ──┬── rollback to save2 → 只撤掉李四
        │  save1          │  save2          │
        │                 │                 └── rollback → 全撤(回到 begin)
        └─────────────────┴────────────────────────────────────────

② rollback to savepoint 和 rollback 的区别

命令回滚到哪
rollback to save2回到保存点 save2(之前的工作保留)
rollback回到事务开始(全部作废)

③ 就算 autocommit = ON,begin 期间也不会自动提交

这一点很多人误解,课件专门用下面两个演示证明。

5.2 演示二:没 commit 就崩溃 → 自动回滚

-- ===== 终端 A =====
mysql> begin;                              -- 开启事务
mysql> insert into account values (1, '张三', 100);
mysql> select * from account;
+----+--------+--------+
| id | name   | blance |
+----+--------+--------+
|  1 | 张三   | 100.00 |
+----+--------+--------+
-- 数据在 A 里已经存在,但【没有 commit】

mysql> Aborted                             -- ctrl + \ 异常终止 MySQL

-- ===== 终端 B =====
mysql> select * from account;              -- A 崩溃前
+----+--------+--------+
| id | name   | blance |
+----+--------+--------+
|  1 | 张三   | 100.00 |
+----+--------+--------+

mysql> select * from account;              -- A 崩溃后,数据自动回滚了
Empty set

⭐ 结论:未提交的事务,在客户端崩溃时会被 MySQL 自动回滚。

为什么? 因为 A 断开连接时,MySQL 发现「这个会话有一个未提交的事务」,就把它当成一次失败的事务,主动回滚(这也叫「回滚未提交事务」)。

这就是原子性的体现:

没 commit 的事务,等于没发生。

🔗 前后照应:这也是为什么**「长事务」是灾难**——如果一个事务开了 10 分钟没提交,然后客户端断开,这 10 分钟的操作全部白做,而且期间它一直持着锁。

5.3 演示三:commit 后崩溃 → 数据持久化

-- ===== 终端 A =====
mysql> begin;
mysql> insert into account values (1, '张三', 100);
mysql> commit;                             -- 关键:提交了!
Query OK, 0 rows affected

mysql> Aborted                             -- 然后再崩溃

-- ===== 终端 B =====
mysql> select * from account;              -- 数据还在!
+----+--------+--------+
| id | name   | blance |
+----+--------+--------+
|  1 | 张三   | 100.00 |
+----+--------+--------+

⭐ 结论:commit 的作用是把数据持久化到 MySQL,崩溃也不会丢。

这就是持久性(D)。

5.4 演示四:begin 会「接管」提交方式

-- ===== 终端 A =====
mysql> show variables like 'autocommit';   -- 先关掉自动提交
mysql> set autocommit = 0;

mysql> show variables like 'autocommit';
+---------------+-------+
| autocommit    | OFF   |
+---------------+-------+

mysql> begin;
mysql> insert into account values (2, '李四', 10000);
mysql> select * from account;              -- 两条都在
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |   100.00 |
|  2 | 李四   | 10000.00 |
+----+--------+----------+

mysql> Aborted                             -- 异常终止

-- ===== 终端 B =====
mysql> select * from account;              -- A 崩溃后,自动回滚
+----+--------+--------+
| id | name   | blance |
+----+--------+--------+
|  1 | 张三   | 100.00 |
+----+--------+--------+

⭐ 结论:autocommit 是 ON 还是 OFF,都不影响 begin 的行为——只要你写了 begin,就必须 commit 才算数。

5.5 演示五:单条 SQL 和事务的关系(本节最精华)

实验一:autocommit = 0 时,单条 SQL 崩溃会回滚

-- ===== 终端 A =====
mysql> set autocommit = 0;                 -- 关掉自动提交
mysql> insert into account values (2, '李四', 10000);   -- 没写 begin,就一条 SQL
mysql> select * from account;              -- 已经插入了
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |   100.00 |
|  2 | 李四   | 10000.00 |
+----+--------+----------+

mysql> ^D Bye                              -- ctrl + d 终止终端

-- ===== 终端 B =====
mysql> select * from account;              -- 崩溃后,数据回滚了!
+----+--------+--------+
| id | name   | blance |
+----+--------+--------+
|  1 | 张三   | 100.00 |
+----+--------+--------+

实验二:autocommit = 1 时,单条 SQL 崩溃不影响(已持久化)

-- ===== 终端 A =====
mysql> show variables like 'autocommit';   -- 默认就是 ON
mysql> insert into account values (2, '李四', 10000);
mysql> select * from account;              -- 数据已经插入
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |   100.00 |
|  2 | 李四   | 10000.00 |
+----+--------+----------+

mysql> Aborted                             -- 异常终止

-- ===== 终端 B =====
mysql> select * from account;              -- 崩溃后,数据还在!已经持久化
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |   100.00 |
|  2 | 李四   | 10000.00 |
+----+--------+----------+

同样是「一条没写 begin 的 SQL」,为什么结果相反?

autocommit一条普通 SQL 的身份崩溃后
OFF一个「已开启但未提交」的事务的一部分回滚
ON一个「自己提交自己」的完整事务保留

⭐ 这就是课件那句结论的技术含义:

对 InnoDB 每一条 SQL 语言都默认封装成事务,自动提交。(select 有特殊情况,因为 MySQL 有 MVCC)

「每一条 SQL 都是一个事务」这句话,只有在 autocommit = ON 时严格成立。

5.6 五个演示的结论汇总(课件原文)

结论: ① 只要输入 begin 或者 start transaction,事务便必须要通过 commit 提交,才会持久化,与是否设置 set autocommit 无关。 ② 事务可以手动回滚,同时,当操作异常,MySQL 会自动回滚。 ③ 对于 InnoDB,每一条 SQL 语言都默认封装成事务,自动提交。(select 有特殊情况,因为 MySQL 有 MVCC)

5.7 事务操作注意事项(课件原文 + 我的补充)

① 如果没有设置保存点,也可以回滚,只能回滚到事务的开始。直接使用 rollback(前提是事务还没有提交)。 ② 如果一个事务被提交了(commit),则不可以回退(rollback)。 ③ 可以选择回退到哪个保存点。 ④ InnoDB 支持事务,MyISAM 不支持事务。 ⑤ 开始事务可以使用 start transaction 或者 begin。

⭐ 我补充三条极其实用的:

⑥ start transaction 和 begin 的区别(课件没说,但面试会问)

写法是否等价额外能力
begin / begin work等价无修饰符
start transaction等价支持修饰符:READ ONLY、READ WRITE、WITH CONSISTENT SNAPSHOT
-- 只读事务(能优化性能,明确告诉 MySQL 不用记录 undo)
start transaction read only;

-- 开启事务时「立刻」建立一致性快照(RR 下第一次快照读才建 Read View,这个能提前)
start transaction with consistent snapshot;

⑦ commit 之后,只有当前会话能看到已提交数据吗?—— 不,submit 后所有会话都能看到(只要隔离级别不是 RR 且对方还没建快照)。这是第 6 节的重点。

⑧ 保存点在 Spring 里叫「嵌套事务」

@Transactional(propagation = Propagation.NESTED)   // 对应 savepoint

6. 四大隔离级别实测

6.1 先理解「隔离性」到底在隔离什么

课件有一段特别好的比喻:

MySQL 服务可能会同时被多个客户端进程(线程)访问,访问的方式以事务方式进行。一个事务可能由多条 SQL 构成,也就意味着,任何一个事务,都有执行前、执行中、执行后的阶段。

而所谓的原子性,其实就是让用户层,要么看到执行前,要么看到执行后。执行中出现问题,可以随时回滚。

但,毕竟所有事务都要有个执行过程,那么在多个事务各自执行多个 SQL 的时候,就还是有可能会出现互相影响的情况。比如:多个事务同时访问同一张表,甚至同一行数据。

就如同你妈妈给你说:你要么别学,要学就学到最好。至于你怎么学,中间有什么困难,你妈妈不关心。那么你的学习,对你妈妈来讲,就是原子的。

那么你学习过程中,很容易受别人干扰,此时,就需要将你的学习隔离开,保证你的学习环境是健康的。

这个比喻把 A 和 I 的关系讲透了:

原子性:从「外部视角」看,事务是「一瞬间」完成的(要么前,要么后)
隔离性:从「内部视角」看,事务执行时会互相打架,所以要隔离

然后课件给了关键定义:

数据库中,允许事务受不同程度的干扰,就有了一种重要特征:隔离级别。

⭐ 所以:

隔离级别 = 「你允许事务之间互相干扰到什么程度」的档位。

6.2 四种隔离级别

级别英文能否看到别人未提交的数据能否看到别人已提交的数据并发性能
读未提交Read Uncommitted (RU)✅ 能✅ 能🚀 最高
读提交Read Committed (RC)❌ 不能✅ 能高
可重复读Repeatable Read (RR)❌❌ 同一事务内读不到中
串行化Serializable❌❌🐢 最低

课件原话(我加了标注):

读未提交(Read Uncommitted):在该隔离级别,所有的事务都可以看到其他事务没有提交的执行结果。(实际生产中不可能使用这种隔离级别的),相当于没有任何隔离性,也会有很多并发问题,如脏读、幻读、不可重复读等。我们上面为了做实验方便,用的就是这个隔离性。

读提交(Read Committed):该隔离级别是大多数数据库的默认隔离级别(不是 MySQL 默认的)。它满足了隔离的简单定义:一个事务只能看到其他已经提交的事务所做的改变。这种隔离级别会引起不可重复读,即一个事务执行时,如果多次 select,可能得到不同的结果。

可重复读(Repeatable Read):这是 MySQL 默认的隔离级别,它确保同一个事务,在执行中,多次读取操作数据时,会看到同样的数据行。但是会有幻读问题。

串行化(Serializable):这是事务的最高隔离级别,它通过强制事务排序,使之不可能相互冲突,从而解决了幻读的问题。它在每个读的数据行上面加上共享锁。但是可能会导致超时和锁竞争(这种隔离级别太极端,实际生产基本不使用)。

⭐ 补全:各数据库的默认隔离级别(这题面试常考)

数据库默认隔离级别
MySQL(InnoDB)Repeatable Read(RR)
OracleRead Committed(RC)
PostgreSQLRead Committed(RC)
SQL ServerRead Committed(RC)

为什么 MySQL 偏偏是 RR? 历史原因——早期 MySQL 的 binlog 只有 STATEMENT 格式,RC 级别下「先 update 后 insert」的顺序在主从上可能不一致,导致主从数据错乱。所以 MySQL 选了更「安全」的 RR。

但现在(5.1+)默认 binlog_format = ROW,这个问题已经不存在了。 所以《阿里巴巴 Java 开发手册》里明确提出:

【推荐】推荐使用 RC(读提交)隔离级别。 理由是 RR 有间隙锁(Gap Lock),容易造成死锁,且并发度低。

这是个「书本答案」和「工程实践」不一致的经典案例,面试时能说出来会加分很多。

6.3 怎么查看与设置隔离级别

-- ===== 查看 =====
SELECT @@global.tx_isolation;    -- 全局(8.0 用 @@global.transaction_isolation)
SELECT @@session.tx_isolation;   -- 会话(当前)
SELECT @@tx_isolation;           -- 默认同 session(8.0 用 @@transaction_isolation)

-- ===== 设置(语法)=====
SET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL
    {READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE}

⚠️ global 和 session 的作用范围,是这节最容易搞混的:

设置方式影响谁何时生效
set global ...之后新建的所有连接当前连接不受影响!必须重连
set session ...只有当前连接立即生效
set transaction ...(不带 global/session)下一个事务下一个事务生效,之后恢复

课件实测:

-- 设置当前会话为串行化
mysql> set session transaction isolation level serializable;
mysql> SELECT @@global.tx_isolation;    -- 全局还是 REPEATABLE-READ
+-----------------------+
| @@global.tx_isolation |
+-----------------------+
| REPEATABLE-READ       |
+-----------------------+
mysql> SELECT @@session.tx_isolation;   -- 会话变成 SERIALIZABLE
+------------------------+
| @@session.tx_isolation |
+------------------------+
| SERIALIZABLE           |
+------------------------+

-- 设置全局为读未提交,另起会话会被影响
mysql> set global transaction isolation level READ UNCOMMITTED;
mysql> SELECT @@global.tx_isolation;    -- READ-UNCOMMITTED
mysql> SELECT @@session.tx_isolation;   -- 当前会话也变了(因为新会话继承 global)

⚠️ 课件提醒过一句很重要的话:「注意,如果没有现象,关闭 mysql 客户端,重新连接。」 因为 global 只影响「之后新建的会话」,当前会话还是老值。

6.4 实测一:读未提交 → 脏读(Dirty Read)

-- ===== 终端 A =====
mysql> set global transaction isolation level read uncommitted;
mysql> -- 重启客户端
mysql> select @@tx_isolation;
+------------------+
| @@tx_isolation   |
+------------------+
| READ-UNCOMMITTED |
+------------------+

mysql> begin;
mysql> update account set blance = 123.0 where id = 1;   -- 改张三的钱
Query OK, 1 row affected
-- ⚠️ 注意:没有 commit!!!

-- ===== 终端 B =====
mysql> begin;
mysql> select * from account;
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |   123.00 |   ← 读到了 A 未提交的数据!
|  2 | 李四   | 10000.00 |
+----+--------+----------+

⭐ 这就是「脏读(dirty read)」:

一个事务在执行中,读到另一个执行中事务的更新(或其他操作)但是未 commit 的数据。

为什么叫「脏」? 因为 A 可能随时 rollback:

-- 如果 A 回滚了
mysql> rollback;
-- 那 B 刚才读到的 123.00 是「从未存在过」的脏数据

课件评价:

几乎没有加锁,虽然效率高,但是问题太多,严重不建议采用。

6.5 实测二:读提交 → 不可重复读(Non-repeatable Read)

-- ===== 终端 A =====
mysql> set global transaction isolation level read committed;
mysql> -- 重启客户端
mysql> begin;
mysql> update account set blance = 321.0 where id = 1;
-- 依然没有 commit

-- ===== 终端 B =====
mysql> begin;
mysql> select * from account;
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |   123.00 |   ← A 未提交,读不到(比 RU 好)
|  2 | 李四   | 10000.00 |
+----+--------+----------+

-- ===== 终端 A =====
mysql> commit;                              -- 提交!

-- ===== 终端 B(注意:B 的事务还没结束!)=====
mysql> select * from account;
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |   321.00 |   ← 同一个事务内,第二次读到了不同的值!
|  2 | 李四   | 10000.00 |
+----+--------+----------+

⭐ 这就是「不可重复读」:

同一个事务内,同样的读取,在不同的时间段,读取到了不同的值。

注意 B 的事务没有结束(没 commit),但它第二次读到了新值。因为它看到了 A 已经提交的修改。

课件这句吐槽很有意思:

(这个是问题吗??)

它当然是个问题——比如你在一个事务里算了两次总额,一次是 100,一次是 321,你的报表该怎么写?

6.6 实测三:可重复读 → 幻读也被解决了

第一回合(update 场景):

-- ===== 终端 A =====
mysql> set global transaction isolation level repeatable read;
mysql> -- 关闭终端重启
mysql> select @@tx_isolation;
+-----------------+
| @@tx_isolation  |
+-----------------+
| REPEATABLE-READ |
+-----------------+

mysql> begin;
mysql> update account set blance = 4321.0 where id = 1;
mysql> commit;                              -- 提交了

-- ===== 终端 B =====
mysql> begin;
mysql> select * from account;               -- A commit 之前
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |   321.00 |
|  2 | 李四   | 10000.00 |
+----+--------+----------+

mysql> select * from account;               -- A commit 之后,仍然读到老值!
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |   321.00 |   ← 没有变成 4321
|  2 | 李四   | 10000.00 |
+----+--------+----------+

mysql> commit;                              -- B 结束事务
mysql> select * from account;               -- 现在才看到新值
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |  4321.00 |
|  2 | 李四   | 10000.00 |
+----+--------+----------+

⭐ 「可重复读」的含义:

在事务 B 的整个生命周期内,无论查多少次,看到的都是同一份快照。

第二回合(insert 场景 —— 幻读):

-- ===== 终端 A =====
mysql> begin;
mysql> insert into account (id, name, blance) values (3, '王五', 5432.0);
mysql> commit;

-- ===== 终端 B(事务已开启)=====
mysql> begin;
mysql> select * from account;               -- A commit 前
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |  4321.00 |
|  2 | 李四   | 10000.00 |
+----+--------+----------+

mysql> select * from account;               -- A commit 后,还是 2 行!
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |  4321.00 |
|  2 | 李四   | 10000.00 |
+----+--------+----------+

mysql> select * from account;               -- 多次查,都是 2 行
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |  4321.00 |
|  2 | 李四   | 10000.00 |
+----+--------+----------+

课件在这里给了一段极其重要的理论说明,我原样引用:

但是,一般的数据库在可重复读情况的时候,无法屏蔽其他事务 insert 的数据(为什么?因为隔离性实现是对数据加锁完成的,而 insert 待插入的数据因为并不存在,那么一般加锁无法屏蔽这类问题),会造成虽然大部分内容是可重复读的,但是 insert 的数据在可重复读情况被读取出来,导致多次查找时,会多查找出来新的记录,就如同产生了幻觉。这种现象,叫做幻读(phantom read)。

很明显,MySQL 在 RR 级别的时候,是解决了幻读问题的(解决的方式是用 Next-Key 锁(GAP + 行锁)解决的。这块比较难,有兴趣同学了解一下)。

⭐ 这是 MySQL 和其他数据库最大的区别之一:

数据库RR 级别下能解决幻读吗
SQL 标准❌ 不能(RR 只防不可重复读)
MySQL InnoDB✅ 能(用 Next-Key Lock 解决)

⚠️ 但这是一个「有名的争议点」,我要补一刀:

MySQL 的 RR 只在「快照读」下解决了幻读,在「当前读」下并没有完全解决。

-- 终端 A
begin;
select * from account where id > 10;          -- 快照读,返回空

-- 终端 B
begin;
insert into account values (11, '赵六', 100);
commit;

-- 终端 A(同一个事务内)
select * from account where id > 10;          -- 快照读,仍然返回空 ✅
update account set blance = 1 where id > 10;  -- 当前读!
select * from account where id > 10;          -- 快照读... 看到了赵六?!

这个「快照读和当前读混用导致幻读复现」的现象,是面试里的高级题。 简单结论:

MySQL RR 的幻读防护,依赖于「全程只用快照读」。一旦你用了 update / select ... for update(当前读), 而你的事务又不是在「第一个快照读之前」就拿到了间隙锁,就可能出现幻读。

6.7 实测四:串行化 → 直接阻塞

-- ===== 终端 A =====
mysql> set global transaction isolation level serializable;

mysql> begin;
mysql> select * from account;               -- 读,加共享锁(读和读不冲突)
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |  4321.00 |
|  2 | 李四   | 10000.00 |
|  3 | 王五   |  5432.00 |
+----+--------+----------+

mysql> update account set blance = 1.00 where id = 1;
Query OK, 1 row affected (18.19 sec)        -- ⚠️ 阻塞了 18.19 秒!
Rows matched: 1  Changed: 1  Warnings: 0

-- ===== 终端 B =====
mysql> begin;
mysql> select * from account;               -- 两个「读」不会串行化(共享锁)
+----+--------+----------+
...
mysql> commit;                              -- B 提交后,A 的 update 才继续

⭐ 注意那个 18.19 sec——这就是串行化的代价。

为什么阻塞? 因为 A 的 update 和 B 的读锁冲突了。串行化给每个读都加共享锁(S 锁),update 需要排他锁(X 锁),S 和 X 不兼容 → 只能等。

课件的评价:

对所有操作全部加锁,进行串行化,不会有问题,但是只要串行化,效率很低,几乎完全不会被采用。

6.8 隔离级别总结表(划重点)

隔离级别脏读不可重复读幻读并发谁在用
读未提交 RU✅ 会✅ 会✅ 会🚀 最高❌ 没人用
读提交 RC❌ 不会✅ 会✅ 会高Oracle / PG / SQLServer 默认
可重复读 RR❌❌MySQL 下不会中MySQL 默认
串行化❌❌❌🐢 最低❌ 几乎不用

⭐ 两个易混概念的区别(课件原文,务必记牢):

不可重复读的重点是「修改和删除」:同样的条件,你读取过的数据,再次读取出来发现值不一样了。

幻读的重点在于「新增」:同样的条件,第 1 次和第 2 次读出来的记录数不一样。

用一张图记住:

不可重复读:行数没变,某一行的「值」变了
    select → [张三 100]
    (别人 update + commit)
    select → [张三 321]     ← 还是 1 行,但值变了

幻读:值没变,但「行数」多了/少了
    select → [张三, 李四](2 行)
    (别人 insert + commit)
    select → [张三, 李四, 王五](3 行)   ← 冒出「幻觉」的一行

7. 一致性到底是什么

课件专门用一段讲了 C,我觉得这段比很多教材都讲得透:

事务执行的结果,必须使数据库从一个一致性状态,变到另一个一致性状态。

当数据库只包含事务成功提交的结果时,数据库处于一致性状态。如果系统运行发生中断,某个事务尚未完成而被迫中断,而该未完成的事务对数据库所做的修改已被写入数据库,此时数据库就处于一种不正确(不一致)的状态。因此,一致性是通过原子性来保证的。

其实一致性和用户的业务逻辑强相关,一般 MySQL 提供技术支持,但是一致性还是要用户业务逻辑做支撑,也就是,一致性,是由用户决定的。而技术上,通过 AID 保证 C。

这段话的核心,我用三句话总结:

① 一致性是「目标」,原子性/隔离性/持久性是「手段」。 ② 数据库不知道你的业务规则(比如「总额不变」),所以它没法直接保证 C。 ③ 它能做的是:保证「要么全成要么全败」(A)+「并发不串」(I)+「提交不丢」(D),从而让你的 C 有可能成立。

举个具体的例子:

-- 你的业务规则:转账后两人总额不变
begin;
update account set blance = blance - 100 where id = 1;   -- A
update account set blance = blance + 100 where id = 2;   -- B
commit;
出问题的环节谁救了你
A 执行后服务器断电原子性:回滚,A 白做
A 提交后断电持久性:A 一定在
别人同时也在改这两个账户隔离性:加锁/MVCC 不让你看到中间态
但「必须先扣 A 再加 B」这个规则⚠️ 只能靠你的业务代码保证

⭐ 所以:

别指望数据库帮你保证业务一致性。它只负责「不出技术层面的错」,业务层面的对错,是你的事。

🔗 前后照应:第 05 篇讲约束时说「约束是最后一道防线」。这里可以再补一句: 事务是「过程」的防线,约束是「结果」的防线。

  • 约束(CHECK / 外键 / 唯一键):挡住不合法的数据落库
  • 事务(ACID):挡住「半途而废」的操作

两者配合,才是完整的数据一致性保障。


8. MVCC:MySQL 并发性能的真正底牌

课件这一节标了「试学内容」,但它恰恰是面试最常问、也最难讲清的部分。我尽量讲透。

8.1 数据库并发的三种场景

数据库并发的场景有三种:

  • 读-读:不存在任何问题,也不需要并发控制
  • 读-写:有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读、幻读、不可重复读
  • 写-写:有线程安全问题,可能会存在更新丢失问题,比如第一类更新丢失、第二类更新丢失
场景有问题吗解决手段
读-读❌ 没问题不用管
读-写✅ 有问题MVCC(无锁)
写-写✅ 有问题锁(加锁串行化)

⭐ 这句话是本节的钥匙:MVCC 只解决「读-写」冲突,解决不了「写-写」。

8.2 MVCC 是什么

多版本并发控制(MVCC)是一种用来解决读-写冲突的无锁并发控制。

为事务分配单向增长的事务 ID,为每个修改保存一个版本,版本与事务 ID 关联,读操作只读该事务开始前的数据库的快照。

课件列了 MVCC 能做的和不能做的:

所以 MVCC 可以为数据库解决以下问题:

  • 在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能
  • 同时还可以解决脏读、幻读、不可重复读等事务隔离问题
  • 但不能解决更新丢失问题

⭐ 一句话理解 MVCC 的价值:

它让「读」不用等「写」,也让「写」不用等「读」——读和写各自看自己的版本,互不打扰。

如果没有 MVCC,RC/RR 级别的读就得靠加锁实现,那读和写就得互相等待,并发度直接掉到串行化的水平。

8.3 MVCC 的三个前提知识

理解 MVCC 需要知道三个前提知识: ① 3 个记录隐藏字段 ② undo 日志 ③ Read View

前提一:三个隐藏字段

InnoDB 给每一行记录都偷偷加了三个字段(你看不见,但它们一直在):

隐藏字段大小含义
DB_TRX_ID6 byte最近修改(修改/插入)这条记录的事务 ID
DB_ROLL_PTR7 byte回滚指针,指向这条记录的上一个版本(这些版本在 undo log 里)
DB_ROW_ID6 byte隐含的自增 ID(隐藏主键)。如果表没有主键,InnoDB 会自动用它生成聚簇索引

课件还补了一个:

补充:实际还有一个「删除 flag」隐藏字段。记录被更新或删除并不代表真的删除,而是「删除 flag」变了。

⭐ 最后这句是重磅:

InnoDB 里的「删除」,只是把 flag 改了一下,数据并没有真的消失。

🔗 前后照应:第 06 篇讲 DELETE 时我说过「DELETE 不释放磁盘空间」。这里解释了原因的第二层——不只是「空间被标记为可复用」,而是这一行的历史版本还躺在 undo log 里,被版本链引用着,purge 线程没清理之前,谁也动不了它。

而 TRUNCATE 是 DDL,直接重建表,所以「真正删掉」——这也呼应了第 06 篇「TRUNCATE 不可回滚」。

DB_ROW_ID 的意义,顺带呼应第 10 篇:

🔗 前后照应:第 10 篇讲聚簇索引时说「主键就是聚簇索引」。那如果表没有主键呢? InnoDB 会用 DB_ROW_ID 生成一个隐藏的聚簇索引。 所以「没有主键的表」并不是「没有索引」——它只是有一个你看不见的主键。 但这也意味着:你无法通过 SQL 用它,且它是全局自增的(可能耗尽)。所以——建表一定要显式给主键。

前提二:undo 日志

这里不想细讲,但是有一件事情得说清楚:MySQL 将来是以服务进程的方式,在内存中运行。我们之前所讲的所有机制:索引、事务、隔离性、日志等,都是在内存中完成的……然后在合适的时候,将相关数据刷新到磁盘当中的。

所以,我们这里理解 undo log,简单理解成就是 MySQL 中的一段内存缓冲区,用来保存日志数据的就行。

⭐ 用一句话记住 undo log 的作用:

它保存「旧版本」,用于两件事:① 回滚(原子性);② MVCC 的版本链(隔离性)。

8.4 模拟 MVCC:版本链是怎么长出来的

初始状态:表里有一条记录

create table if not exists student (
    name varchar(11) not null,
    age  int not null
);
insert into student (name, age) values ('张三', 28);
select * from student;
+--------+-----+
| name   | age |
+--------+-----+
| 张三   |  28 |
+--------+-----+

加上隐藏字段后,这一行实际长这样:

nameageDB_TRX_IDDB_ROW_IDDB_ROLL_PTR
张三28null1null

课件原话:「我们目前并不知道创建该记录的事务 ID,隐式主键,我们就默认设置成 null、1。第一条记录也没有其他版本,我们设置回滚指针为 null。」

现在事务 10 来了,把 name 从「张三」改成「李四」:

① 给这行加行锁
② 拷贝当前行到 undo log(写时拷贝,Copy-on-Write)
③ 修改原行的 name 为「李四」
④ 原行的 DB_TRX_ID 改成 10
⑤ 原行的 DB_ROLL_PTR 指向 undo log 里的副本
⑥ 事务 10 提交,释放锁

画成图:

     当前记录(最新版本)                    undo log(历史版本)
   ┌──────────────────────────┐
   │ name: 李四               │
   │ age:  28                 │
   │ DB_TRX_ID: 10            │
   │ DB_ROLL_PTR: ──────────────→  ┌──────────────────┐
   └──────────────────────────┘     │ name: 张三       │
                                    │ age:  28         │
                                    │ DB_TRX_ID: null  │
                                    │ DB_ROLL_PTR: null│
                                    └──────────────────┘

事务 11 又来了,把 age 从 28 改成 38:

① 加行锁
② 把当前行(李四/28)拷贝到 undo log,采用【头插法】插到版本链最前面
③ 修改原行的 age 为 38
④ DB_TRX_ID 改成 11
⑤ DB_ROLL_PTR 指向新的副本

版本链变成了三个节点:

   当前记录
 ┌──────────────────┐
 │ 李四 / 38        │
 │ DB_TRX_ID: 11    │
 │ DB_ROLL_PTR: ──────→  副本1(李四/28,trx=10)
 └──────────────────┘         │ DB_ROLL_PTR ──→ 副本2(张三/28,trx=null)
                              │                      │
                              └──────────────────────┘

⭐ 课件那句总结极好:

这样,我们就有了一个基于链表记录的历史版本链。所谓的回滚,无非就是用历史数据,覆盖当前数据。 上面的一个一个版本,我们可以称之为一个一个的快照。

⭐ 另一个重要补充(课件提到,我展开):

如果是 delete 呢?一样的,别忘了,删数据不是清空,而是设置 flag 为删除即可,也可以形成版本。

如果是 insert 呢?因为 insert 是插入,也就是之前没有数据,那么 insert 也就没有历史版本。但是一般为了回滚操作,insert 的数据也是要被放入 undo log 中。

三个操作对版本链的影响:

操作是否产生新版本说明
insert❌ 无历史版本但为了能 rollback,也会进 undo log
update✅ 新增一个版本版本链的核心
delete✅ 只改 flag也能形成版本

⭐ 所以课件说:「update 和 delete 可以形成版本链,insert 暂时不考虑。」

8.5 当前读 vs 快照读

这是 MVCC 里最关键的一组概念。

当前读:读取最新的记录,就是当前读。增删改,都叫做当前读。select 也有可能当前读,比如 select ... lock in share mode(共享锁)、select ... for update。

快照读:读取历史版本(一般而言),就叫做快照读。

类型读什么加锁吗典型语句
当前读最新版本✅ 要加锁update / delete / insert / select ... for update / select ... lock in share mode
快照读历史版本(Read View 决定的)❌ 不加锁普通 select

⭐ 这就是 MySQL 并发高的秘密:

如果所有 select 都要读最新值 → 就得加锁 → 和写互斥 → 并发度掉到串行化
如果 select 允许读历史版本 → 不用加锁 → 读和写可以并行 → 并发度暴涨

课件这段话说得非常到位:

我们可以看到,在多个事务同时删改查的时候,都是当前读,是要加锁的。那同时有 select 过来,如果也要读取最新版(当前读),那么也就需要加锁,这就是串行化。但如果是快照读,读取历史版本的话,是不受加锁限制的。也就是可以并行执行!换言之,提高了效率,即 MVCC 的意义所在。

那么,是什么决定了 select 是当前读,还是快照读呢?—— 隔离级别!

⭐ 记住这个链条:

隔离级别 → 决定 Read View 的生成时机 → 决定快照读能看到哪个版本 → 决定了「能不能重复读」

8.6 Read View:可见性判断的「裁判」

Read View 就是事务进行快照读操作的时候生产的读视图(Read View),在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的 ID。

Read View 在 MySQL 源码中就是一个类,本质是用来进行可见性判断的。

课件的简化版结构(四个关键字段):

字段含义
m_ids活跃事务 ID 列表(生成 Read View 时,系统里还没提交的事务)
up_limit_id低水位:m_ids 里最小的 ID(小于它的事务都可见)
low_limit_id高水位:系统尚未分配的下一个事务 ID(≥ 它的事务都不可见)
creator_trx_id创建这个 Read View 的事务 ID(自己的修改总是可见)

⭐ 三个水位的关系(务必理解):

    可见区                要判断的灰区                不可见区
  ─────────┬──────────────────────────┬──────────────────→ 事务 ID 递增
           │                          │
      up_limit_id              low_limit_id
     (m_ids 最小值)          (下一个未分配的 ID)

8.7 可见性判断的三步走(课件原文)

拿到一条记录的 DB_TRX_ID 后,跟 Read View 一比:

① DB_TRX_ID < up_limit_id ?
     小于 → 这个版本在 Read View 生成之前就已提交 → ✅ 可见
     不小于 → 下一步

② DB_TRX_ID >= low_limit_id ?
     大于等于 → 这个版本是 Read View 生成之后才出现的 → ❌ 不可见
     不大于 → 下一步

③ m_ids 中包含 DB_TRX_ID 吗?
     包含   → 说明这个事务在生成 Read View 时还活着(未提交) → ❌ 不可见
     不包含 → 说明它的提交发生在 Read View 生成之前 → ✅ 可见

课件给的实测例子:

//事务2的 Read View
m_ids;            // 1, 3
up_limit_id;      // 1
low_limit_id;     // 4 + 1 = 5,原因:ReadView 生成时刻,系统尚未分配的下一个事务 ID
creator_trx_id    // 2

//事务4提交的记录对应的事务ID
DB_TRX_ID = 4

//比较步骤
DB_TRX_ID(4) < up_limit_id(1)?      不小于,下一步
DB_TRX_ID(4) >= low_limit_id(5)?      不大于,下一步
m_ids.contains(DB_TRX_ID)?              不包含,说明事务4不在当前的活跃事务中
→ 结论:事务4的更改,应该看到。

一个具体的场景:

事务1 [id=1]  ────── 事务开始 ────── 进行中
事务2 [id=2]  ────── 事务开始 ────── 快照读 ← 此刻生成 Read View
事务3 [id=3]  ────── 事务开始 ────── 进行中
事务4 [id=4]  ────── 修改且已提交

事务2 在快照读时生成的 Read View:

  • m_ids = [1, 3](事务1、3 还在跑)
  • up_limit_id = 1
  • low_limit_id = 5(下一个要分配的是 5)
  • creator_trx_id = 2

看事务4 修改的那条记录(DB_TRX_ID = 4):

  • 4 < 1?否
  • 4 >= 5?否
  • m_ids 含 4 吗?不含 → 可见 ✅

所以事务2 能看到事务4 的修改。

如果查到不应该看到当前版本,接下来就是遍历下一个版本(沿着 DB_ROLL_PTR 往版本链深处走),直到符合条件,即可以看到。

8.8 RR 与 RC 的本质区别(本节结论)

正是 Read View 生成时机的不同,从而造成 RC、RR 级别下快照读的结果的不同。

在 RR 级别下的某个事务的对某条记录的第一次快照读会创建一个快照及 Read View,将当前系统活跃的其他事务记录起来。此后在调用快照读的时候,还是使用的是同一个 Read View。 所以只要当前事务在其他事务提交更新之前使用过快照读,那么之后的快照读使用的都是同一个 Read View,所以对之后的修改不可见。

而在 RC 级别下,事务中,每次快照读都会新生成一个快照和 Read View,这就是我们在 RC 级别下的事务中可以看到别的事务提交的更新的原因。

⭐ 一句话记住:

RC:每次 select 都新建一个 Read View(所以能看到别人刚提交的)。 RR:整个事务只在第一次 select 时建一个 Read View,之后一直复用(所以永远看到同一份快照)。

画成图:

RR(同一个 Read View 贯穿始终)
begin ──► select① 建 ReadView ──► select② 复用 ──► select③ 复用 ──► commit
                                        ↑
                                  别人提交了,也看不见

RC(每次 select 都新建)
begin ──► select① 建 RV₁ ──► select② 建 RV₂ ──► select③ 建 RV₃ ──► commit
                                 ↑               ↑
                          别人提交了,能看见   又提交了,又能看见

课件的两个测试用例,正是验证这一点:

用例1 和 用例2 唯一区别仅仅是:表1 的事务B 在事务A 修改 age 前,快照读过一次 age 数据;而表2 的事务B 在事务A 修改前没有进行过快照读。

结论:事务中快照读的结果是非常依赖该事务首次出现快照读的地方,即某个事务中首次出现快照读,决定该事务后续快照读结果的能力。delete 同样如此。

用例1(B 先快照读过):

事务A事务B
beginbegin
select * from user(快照读,age=15)← 此刻建 Read View
update user set age=18 where id=1
commit
select * from user → 读到 age=15(旧值)
select ... lock in share mode(当前读)→ 读到 age=18

用例2(B 没有先快照读):

事务A事务B
beginbegin
update user set age=28 where id=1
commit
select * from user → 读到 age=28(因为 Read View 是此刻才建的,A 已提交)

⭐ 这个现象非常重要:

在 RR 下,「你什么时候第一次 select」,决定了你后面能看到什么。 这也是为什么「start transaction with consistent snapshot」是有意义的——它让快照在事务开始时就建好,而不是等第一次 select。

🔗 前后照应:第 07 篇讲 now() 时说「一条语句内 now() 恒定」。这里是同一类设计的另一面:一个事务内「快照」恒定。 MySQL 很喜欢用「某个时间点定格」的思路来保证一致性——now() 定格语句开始,Read View 定格事务的第一次快照读。


9. 补全:课件没讲、但工作中必须知道的十件事

课件已经很全了,但下面这些是「真上生产」才会踩到的。

9.1 隐式提交:DDL 会悄悄 commit 你的事务

⚠️ 这是事务第一大坑,比脏读幻读更常坑人。

begin;
update account set blance = 1 where id = 1;    -- 想改钱
create table tmp_x (id int);                   -- 顺手建了个表(DDL)
rollback;                                      -- 想回滚?
-- ❌ 回滚不了!上面那条 update 已经被提交了

原因:MySQL 规定,DDL 语句会隐式提交当前事务。

会隐式提交的语句清单(记住就好):

类别语句
DDLCREATE / ALTER / DROP / TRUNCATE / RENAME
权限GRANT / REVOKE / SET PASSWORD
锁表LOCK TABLES / UNLOCK TABLES
管理ANALYZE TABLE / OPTIMIZE TABLE / FLUSH
事务控制SET AUTOCOMMIT = 1(从 0 改成 1)
其他START TRANSACTION(会先提交上一个事务)

🔗 前后照应:第 06 篇讲 DELETE vs TRUNCATE 时说「TRUNCATE 不可回滚」。这里补上原因——它是 DDL,会隐式提交,根本没有「回滚」这回事。

⭐ 工程建议:

事务里不要夹 DDL。 需要改表结构,单独跑;需要「数据 + 结构」一起改,用「先结构、后数据」的两步走。

9.2 长事务的危害

什么算长事务? 事务开启后长时间不 commit。常见来源:autocommit = 0 忘提交、@Transactional 包了一个 RPC 调用、事务里做了文件上传/发邮件。

危害(五条,每条都致命):

危害原因
undo log 无法清理别的 Read View 还引用着旧版本,purge 线程不敢删 → undo 表空间暴涨
版本链越来越长每次快照读都要沿链遍历 → 查询变慢
锁持有时间长别人一直等锁 → 超时、死锁
主从延迟大事务在从库重放要很久
Buffer Pool 污染大量历史版本占用内存

怎么查长事务:

-- 当前正在跑的事务
select trx_id, trx_state, trx_started,
       timestampdiff(second, trx_started, now()) as running_seconds,
       trx_mysql_thread_id, trx_query
from information_schema.innodb_trx
order by trx_started;

-- 超过 60 秒的事务(重点关注)
select * from information_schema.innodb_trx
where timestampdiff(second, trx_started, now()) > 60;

⭐ 工程建议:

事务要「短小快」——只包住「必须原子」的那几条 SQL。 RPC 调用、发消息、写文件、生成报表,一律放到事务外面。 监控里给「事务执行超过 1 秒」报警。

9.3 更新丢失:课件提了,但没展开

课件提到「写-写……可能会存在更新丢失问题,比如第一类更新丢失、第二类更新丢失(后面补充)」

我补上:

第一类更新丢失(回滚丢失)

事务A: 读 balance=100
事务B: 读 balance=100
事务A: balance = 100 - 10 = 90,commit
事务B: rollback(它基于 100 算的)
       → B 的回滚把 A 的修改也"盖"掉了

第二类更新丢失(覆盖丢失)

事务A: 读 balance=100
事务B: 读 balance=100
事务A: balance = 90,commit
事务B: balance = 90,commit   ← B 基于旧值 100 算的,把 A 的 -10 覆盖了
最终 balance = 90(本该是 80)

MySQL 怎么防?

  • 第一类:现代数据库基本不会出现(因为回滚只撤销自己的修改,不会覆盖别人已提交的)
  • 第二类:用「当前读」的加锁更新
-- ❌ 危险:先查再算再写(应用层的经典错误)
select balance from account where id = 1;      -- 读到 100
-- 应用层算 100 - 10 = 90
update account set balance = 90 where id = 1;  -- 覆盖

-- ✅ 正确:把计算放进 SQL(原子操作)
update account set balance = balance - 10 where id = 1;

-- ✅ 或者用悲观锁(当前读 + 行锁)
select balance from account where id = 1 for update;
update account set balance = balance - 10 where id = 1;

-- ✅ 或者用乐观锁(版本号)
update account set balance = balance - 10, version = version + 1
where id = 1 and version = 5;

⭐ 一句话:

MVCC 解决不了「写-写」冲突。要防更新丢失,要么把计算推进 SQL,要么加锁,要么用乐观锁版本号。

9.4 间隙锁只在 RR 下才加(这是 RC vs RR 的实操差异)

第 10 篇讲索引时说过「索引让查询变快」;这里补一句更狠的:

间隙锁是「加在索引上」的。如果查询条件用不上索引,InnoDB 会退化成「锁全表的所有间隙」,等于把所有并发写都堵死。

-- RR 级别
begin;
select * from account where id > 10 for update;   -- 锁住 (10, +∞) 这个间隙
-- 别人想 insert id=11 → 阻塞!

RC 级别下(除唯一性检查外)不加间隙锁,所以:

对比RCRR
行锁✅✅
间隙锁(Gap Lock)❌ 不加✅ 加
Next-Key Lock❌✅
幻读✅ 会发生✅ 快照读下被解决
死锁概率低高(间隙锁容易互相等)
并发度高中

这就是阿里规约推荐 RC 的真正原因。

🔗 前后照应:第 10 篇说「索引是性能地基」,这里是它的第二层意义——索引不只是「查得快」,还决定「锁得少」。没索引的表,在 RR 下随便一个 for update 都可能锁全表。

9.5 select for update 到底锁什么?

-- 锁住查到的那几行(X 锁)
select * from account where id = 1 for update;

-- 锁住行 + 间隙(RR 下)
select * from account where id > 1 for update;

-- 共享锁(别人能读,不能写)
select * from account where id = 1 lock in share mode;
语句锁类型允许别人读允许别人写
普通 select无锁(快照读)✅✅
select ... lock in share modeS 锁✅❌
select ... for updateX 锁❌❌

⚠️ for update 的三大坑:

  1. 必须在事务里,否则语句结束锁就释放了(等于没用)
  2. 条件必须走索引,否则会锁全表
  3. 别在 RC/RR 混用,行为不一样

9.6 null 与唯一索引:INSERT ... ON DUPLICATE KEY 的并发坑

🔗 第 05 篇讲过 ON DUPLICATE KEY UPDATE。这里补一个事务视角:

它内部是「先加锁查、再插入或更新」,高并发下多个事务抢同一批唯一键,很容易死锁(ERROR 1213)。

解法:① 减少唯一键冲突的可能(先查后插在应用层串行化);② 捕获 1213 后重试;③ 用「先 insert ignore 再 update」的两步法。

9.7 Spring @Transactional 的五个失效场景

这是 Java 后端面试的必问项,和事务知识直接挂钩:

场景为什么失效
同类内部调用(this.methodB())没走代理,注解不生效
方法不是 publicSpring AOP 只代理 public
异常被自己 catch 了框架看不到异常,不会回滚
抛的是受检异常(Exception)默认只回滚 RuntimeException 和 Error,要加 @Transactional(rollbackFor = Exception.class)
多线程里调用事务绑定在 ThreadLocal 上,新线程拿不到
表是 MyISAM引擎不支持事务(呼应第 3 节)
事务方法里用了 DDL隐式提交(呼应 9.1)

9.8 保存点与「嵌套事务」

begin;
insert into account values (10, 'A', 100);
savepoint sp1;
insert into account values (11, 'B', 200);
rollback to sp1;        -- 只撤掉 B,A 还在
commit;                 -- A 提交

Spring 里对应的是:

@Transactional(propagation = Propagation.NESTED)

⚠️ NESTED 和 REQUIRES_NEW 的区别(面试常考):

传播行为底层回滚影响
NESTEDsavepoint内层回滚不影响外层
REQUIRES_NEW新事务(新连接)内层回滚完全独立,外层回滚也不影响内层

9.9 事务与「主从复制」:RC/RR 与 binlog 的历史包袱

课件的时代(MySQL 5.7)默认 binlog_format = ROW。但在更早的版本里:

  • STATEMENT 格式下,RC 级别可能导致主从数据不一致(因为「先 update 后 insert」的顺序在主从上可能不同)
  • 所以 MySQL 历史上选择 RR 作为默认,用间隙锁把并发「压」下去,换来主从一致
  • 现在(5.1+ ROW 格式成为默认)这个问题已经不存在了,所以 RC 变得安全且并发更高

⭐ 面试加分点:

「MySQL 默认 RR 是历史原因(STATEMENT binlog 的一致性),不是技术上的最优选择。现在主流互联网公司都用 RC,因为 ROW 格式已经解决了主从一致问题,而 RC 没有间隙锁、死锁少、并发高。」

9.10 一致性快照读的两种「立即建快照」方式

-- 方式一:事务启动时立即建快照(RR 下有意义)
start transaction with consistent snapshot;

-- 方式二:事务开始后马上做一次快照读(效果类似,但不完全等价)
begin;
select 1;   -- 也会建立 Read View

区别:with consistent snapshot 在「事务开始」那一刻建 Read View,而 select 1 要到语句执行时才建。 在毫秒级并发场景下,这个差别可能决定你看到看不到某个事务的提交。


10. 报错与坑速查

现象 / 报错原因解决
ERROR 1193: Unknown system variable 'tx_isolation'8.0 已移除改 @@transaction_isolation
ERROR 1213: Deadlock found死锁(间隙锁/多表加锁顺序不一致)按固定顺序加锁;重试;降级到 RC
ERROR 1205: Lock wait timeout exceeded等锁超时查 innodb_trx 找长事务;缩短事务
ERROR 1062: Duplicate entry并发 upsert 冲突重试,或改用 insert ignore
begin 后 rollback 没生效表是 MyISAM改成 engine=InnoDB
rollback 只撤了一部分中间执行了 DDL(隐式提交)把 DDL 移出事务
隔离级别设了但没生效用了 set global必须重连,或改用 set session
RR 下看不到别人刚提交的数据Read View 已冻结这是预期行为;需要最新值用 for update
长事务导致 undo 暴涨事务没及时提交缩短事务;purge 清理
@Transactional 不回滚受检异常 / 同类调用 / 非 public加 rollbackFor;改调用方式
从库延迟越来越大大事务拆小事务;监控延迟

11. 面试高频 15 问

Q1:什么是事务?ACID 分别是什么? 一组「要么全成功、要么全失败」的 DML 操作。A 原子性(undo log)、C 一致性(由 AID 保证)、I 隔离性(锁 + MVCC)、D 持久性(redo log)。

Q2:事务的隔离级别有哪几种?MySQL 默认哪个? RU / RC / RR / Serializable。MySQL 默认 RR,Oracle / PG / SQLServer 默认 RC。

Q3:脏读、不可重复读、幻读的区别? 脏读:读到别人未提交的数据;不可重复读:同一事务内两次读,同一行的值变了;幻读:同一事务内两次读,行数变了(别人 insert/delete)。

Q4:MySQL 的 RR 到底解决幻读了吗? 快照读下解决了(Read View 冻结快照 + Next-Key Lock 防插入);当前读和快照读混用时可能复现。

Q5:MVCC 是什么?解决什么问题? 多版本并发控制。为每行保存多个版本(undo log 版本链),读操作读快照、不加锁。解决读-写冲突(读不阻塞写、写不阻塞读),解决不了写-写冲突(更新丢失)。

Q6:MVCC 的三个前提是什么? ① 三个隐藏字段(DB_TRX_ID / DB_ROLL_PTR / DB_ROW_ID + 删除 flag);② undo log 版本链;③ Read View。

Q7:Read View 的四个字段是什么? m_ids(活跃事务 ID 列表)、up_limit_id(低水位,最小值)、low_limit_id(高水位,下一个待分配 ID)、creator_trx_id(自己)。

Q8:RR 和 RC 的本质区别? Read View 的生成时机。RC 每次快照读都新建 Read View;RR 只在事务第一次快照读时建一次,之后复用。

Q9:当前读和快照读的区别? 当前读读最新版本且加锁(update / delete / insert / for update / lock in share mode);快照读读历史版本且不加锁(普通 select)。

Q10:undo log 和 redo log 分别干什么? undo log:存旧版本,用于回滚(原子性)和 MVCC 版本链(隔离性);redo log:存新值,用于崩溃恢复(持久性)。

Q11:为什么 delete 之后磁盘空间没变小? ① 空间被标记为「可复用」不还给系统;② 更重要的——历史版本还在 undo log 里被版本链引用着,purge 线程没清理前不能真正回收。

Q12:什么是长事务?有什么危害? 开很久不提交的事务。危害:undo 无法清理、版本链变长、锁持有久、主从延迟、Buffer Pool 污染。

Q13:DDL 在事务里执行会怎样? 隐式提交当前事务,之前的操作无法再回滚。所以事务里不要夹 DDL。

Q14:@Transactional 为什么会失效? 同类内部调用(不走代理)、非 public 方法、异常被 catch、抛受检异常(默认不回滚)、多线程、表是 MyISAM、事务内有 DDL。

Q15:MySQL 为什么默认 RR 而不是 RC? 历史原因:早期只有 STATEMENT binlog,RC 下可能主从数据不一致,所以选 RR + 间隙锁。现在 ROW 格式成为默认,主流实践推荐 RC(无间隙锁、死锁少、并发高)。


12. 总结:一张图 + 一句口诀

                        ┌──────────────────────────────┐
                        │           事务 ACID           │
                        └──────────────────────────────┘
                                      │
        ┌─────────────────┬───────────┴───────────┬─────────────────┐
        │                 │                       │                 │
    【A 原子性】      【C 一致性】            【I 隔离性】      【D 持久性】
        │                 │                       │                 │
   undo log          由 AID 保证             锁 + MVCC          redo log
   要么全成               │                       │             commit 即永久
   要么全不          业务规则定义           ┌─────┴─────┐
                                            │           │
                                         【锁】      【MVCC】
                                            │           │
                                       写-写冲突    读-写冲突
                                            │           │
                                     间隙锁/行锁   版本链+ReadView
                                            │           │
                                      (RR 下生效)   RC/RR 差异
                                                    ← 快照读

一句口诀:

「A 管回滚,D 管提交,I 管并发,C 是目的;读-写靠 MVCC,写-写靠加锁,RC 每次建快照,RR 只建第一次。」

展开:

半句含义
A 管回滚undo log,要么全成要么全不
D 管提交redo log,commit 后崩溃也不丢
I 管并发锁 + MVCC,四个隔离级别
C 是目的一致性是目标,由 AID 保证
读-写靠 MVCC快照读 + 版本链,读不阻塞写
写-写靠加锁行锁 / 间隙锁,防更新丢失
RC 每次建快照每次 select 都新建 Read View
RR 只建第一次第一次快照读建一次,全程复用

最后,把整个系列串起来:

篇主题一句话
05表的约束约束是门禁,脏数据进不来
06增删改查执行顺序决定一切
07内置函数函数别碰字段,索引就活了
08复合查询多表 = 笛卡尔积 + 筛选
09内外连接内连接求交集,外连接保一侧
10索引特性B+ 树 = 减少 IO 次数
11事务管理A 管回滚、D 管提交、I 管并发、C 是目的

而这一篇,又埋下了下一个坑:

我们反复说「写-写冲突靠加锁」、「间隙锁加在索引上」、「死锁要按固定顺序加锁」……

但锁到底是什么?行锁、表锁、间隙锁、Next-Key 锁、意向锁有什么区别? 为什么我的 update 会锁住一整张表?

——这就是「MySQL 锁机制」要解决的问题。 如果后面还有课件,我们接着聊。


附:一键可执行完整脚本

-- ============================================================
-- MySQL 事务管理 · 全功能演示脚本
-- 环境:MySQL 5.7 / 8.0 均可(8.0 请把 @@tx_isolation 换成 @@transaction_isolation)
-- ⚠️ 事务演示强烈建议开【两个终端】对照观察
-- ============================================================
drop database if exists trans_demo;
create database trans_demo default character set utf8mb4;
use trans_demo;

-- ---------- 3. 引擎与提交方式 ----------
show engines \G
show variables like 'autocommit';

-- 关闭 / 开启自动提交
set autocommit = 0;
show variables like 'autocommit';
set autocommit = 1;
show variables like 'autocommit';

-- ---------- 4. 实验表 ----------
create table if not exists account (
    id     int primary key,
    name   varchar(50) not null default '',
    blance decimal(10,2) not null default 0.0
) engine=InnoDB default charset=utf8mb4;

insert into account values (1, '张三', 100.00), (2, '李四', 10000.00);

-- 设置隔离级别(global 需要重连才生效)
set global transaction isolation level READ UNCOMMITTED;
-- 5.7
select @@tx_isolation;
-- 8.0
-- select @@transaction_isolation;

-- ---------- 5.1 正常演示:保存点 + 回滚 + 提交 ----------
start transaction;
savepoint save1;
insert into account values (1, '张三', 100);
savepoint save2;
insert into account values (2, '李四', 10000);
select * from account;
rollback to save2;          -- 只撤掉李四
select * from account;
rollback;                   -- 全撤
select * from account;

-- ---------- 5.2 未 commit 崩溃 → 自动回滚 ----------
-- 终端A: begin; insert ...; 然后 Ctrl+\ 异常退出
-- 终端B: select * from account;   → 数据回滚,看不到

-- ---------- 5.3 commit 后崩溃 → 持久化 ----------
-- 终端A: begin; insert ...; commit; Ctrl+\
-- 终端B: select * from account;   → 数据还在

-- ---------- 5.4 begin 接管提交方式 ----------
set autocommit = 0;
begin;
insert into account values (2, '李四', 10000);
-- Ctrl+\ 退出后,终端B 查不到 → 回滚

-- ---------- 5.5 单条 SQL 与事务 ----------
-- 实验一:autocommit=0
set autocommit = 0;
insert into account values (2, '李四', 10000);   -- 未 commit
-- 崩溃后 → 回滚

-- 实验二:autocommit=1
set autocommit = 1;
insert into account values (2, '李四', 10000);   -- 自动提交
-- 崩溃后 → 数据仍在

-- ---------- 6. 四大隔离级别实测 ----------
-- 6.1 读未提交 → 脏读
set global transaction isolation level read uncommitted;
-- 终端A: begin; update account set blance=123.0 where id=1;  (不提交)
-- 终端B: begin; select * from account;  → 读到 123.00(脏读)

-- 6.2 读提交 → 不可重复读
set global transaction isolation level read committed;
-- 终端A: begin; update ...; commit;
-- 终端B: begin; select(旧值); 等A提交; select(新值)→ 不可重复读

-- 6.3 可重复读 → 可重复 + 幻读被解决
set global transaction isolation level repeatable read;
-- 终端A: begin; update ...; commit;
-- 终端B: begin; select(旧值); A提交后 select(仍旧值); commit 后 select(新值)
-- insert 场景:A insert + commit,B 事务内多次 select 都看不到新行

-- 6.4 串行化 → 阻塞
set global transaction isolation level serializable;
-- 终端A: begin; select; update ...(会被终端B的读锁阻塞)

-- 恢复默认
set global transaction isolation level repeatable read;

-- ---------- 8. MVCC 版本链演示 ----------
create table if not exists student (
    name varchar(11) not null,
    age  int not null
);
insert into student (name, age) values ('张三', 28);

-- 用 3 个隐藏字段理解版本链(这些字段查不到,但一直在)
-- 可以用这个技巧"看到"隐藏字段(非官方,仅供理解)
-- select name, age, DB_TRX_ID, DB_ROLL_PTR from student;

-- 事务10 改 name
-- begin; update student set name='李四'; commit;
-- 事务11 改 age
-- begin; update student set age=38; commit;
-- 此时版本链:李四/38 → 李四/28 → 张三/28

-- ---------- 6.3 补:RR 下「快照读 vs 当前读」 ----------
-- 终端A: begin; select ...; (建 Read View)
-- 终端B: begin; update ...; commit;
-- 终端A: select ...              → 旧值(快照读)
-- 终端A: select ... lock in share mode   → 新值(当前读!)

-- ---------- 9.1 隐式提交演示 ----------
begin;
insert into account values (99, '测试', 1);
create table tmp_x (id int);       -- DDL,隐式提交
rollback;                          -- ⚠️ 上面那条 insert 已经提交,撤不掉了
select * from account where id = 99;

-- ---------- 9.2 查长事务 ----------
select trx_id, trx_state, trx_started,
       timestampdiff(second, trx_started, now()) as running_seconds,
       trx_mysql_thread_id, trx_query
from information_schema.innodb_trx
order by trx_started;

-- ---------- 9.3 更新丢失的三种防法 ----------
-- ❌ 危险:应用层先查再算再写
-- select blance from account where id = 1;

-- ✅ 方式一:把计算推进 SQL(原子)
update account set blance = blance - 10 where id = 1;

-- ✅ 方式二:悲观锁(当前读 + 行锁,必须在事务里)
begin;
select blance from account where id = 1 for update;
update account set blance = blance - 10 where id = 1;
commit;

-- ✅ 方式三:乐观锁(版本号)
-- alter table account add column version int not null default 0;
-- update account set blance = blance - 10, version = version + 1
-- where id = 1 and version = 0;

如果这篇博客帮你理清了某个点,欢迎点赞收藏;有任何疑问,评论区见,我会一条条回。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2601_94861874/article/details/169564324

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--