先问你一个问题:如果你的增删改查(CURD)不加任何保护,会出什么乱子?

想象一个在线购票系统。你买一张票,虽然是"一张票",但背后往往是要完成好几步操作:先扣你的钱、再锁定余票、然后把票登记到你的名下。这三步不是一锤子买卖,而是三条 SQL。假如系统同时有一百个人在买票,三条 SQL 你一步我一步地交叉着执行,会怎样?可能你的钱扣了票却没锁定、也可能票锁了钱没扣完,最后数据库里到处是"半成品"。

再比如,你毕业了,学校的教务系统后台 MySQL 里再也不需要你的数据了。系统要删除你的全部信息——不仅要删你的基本信息(姓名、电话、籍贯),还要删掉和你有关系的所有东西:你的各科成绩、在校表现、甚至你在学院论坛发过的帖子和评论。这又是一堆 SQL 组合在一起。如果删到一半服务器宕机了,你的基本资料没了,成绩却还挂在库里,你以为你毕业了,成绩单上你还是个在册学生——这显然是一团乱麻。

所以你看,"不加控制地增删改查"会有大问题。那增删改查到底满足什么样的属性,才能把这些问题解决掉?带着这个问题,我们进入今天的主题:MySQL 事务管理。

你应该有的知识准备

在读这篇之前,我默认你至少对下面这些东西有概念,不用精通,有个印象就行:

  • 增删改查(CURD):对数据库中数据的增(Create)、查(Read)、改(Update)、删(Delete)操作。前三种会改动数据,查询一般只读。
  • 存储引擎:MySQL 真正负责存取数据的底层组件。ENGINE=InnoDB 就是在建表时指定用哪个引擎来存储这张表。事务和存储引擎强相关,我们后面细讲。
  • DML:Data Manipulation Language,数据操纵语言,指 insert、update、delete 这类会改动数据的语句。

如果你用 Navicat 或命令行 mysql -uroot -p 敲过几条 SQL,以上就都齐活了。

什么是事务

先回到那个问题:为什么单条 SQL 不行?因为现实世界的业务从来不是"一条 SQL 能搞定"的。一个完整的业务动作,几乎总是一组逻辑上相关的 SQL 拼起来的。

所谓事务(Transaction),就是一组 DML 语句组成的整体,这些语句在逻辑上存在相关性。这一组语句要么全部成功,要么全部失败,绝不能停在中间某个状态。你可以把事务理解成"一捆捆着的箭":要么整捆射出去,要么整捆留着,绝不会射一半、留一半。

MySQL 提供了一套机制,帮我们保证能达到这个效果。除此之外,事务还规定:不同的客户端看到的数据可以不相同——这听起来好像有点玄乎,其实是为了解决并发访问时的相互干扰,我们后面会花大篇幅讲它。

再往深一点问:MySQL 为什么要设计出"事务"这个东西?本质是为了简化应用程序的编程模型。你想想,如果不谈事务,一个应用去访问数据库,它要自己操心网络突然断了怎么办、数据库服务器宕机了怎么办、两个订单同时改同一个账户余额怎么办……这一大堆乱七八糟的边角情况,工程上根本没法写。但有了事务,你的代码就只剩两个动作:要么提交(commit),要么回滚(rollback)——其余乱七八糟的情况,交给数据库去兜底。

所以要记住一句话:事务本质上是为应用层服务的,而不是数据库与生俱来的奢侈品。它是数据库设计者为了让上层应用"写得简单、活得安心"而特意造的工具。

下面正式命名四大特性。一个完整的事务,绝不只是简单的 SQL 集合,还必须同时满足下面四个属性,简称 ACID:

  • 原子性(Atomicity,不可分割性):一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务执行过程中一旦出错,会被**回滚(Rollback)**到事务开始前的状态,就像这个事务从来没执行过一样。
  • 一致性(Consistency):在事务开始之前和事务结束之后,数据库的完整性都没有被破坏。这表示写入的数据必须完全符合所有预设规则,包括数据的精确度、串联性,以及后续数据库能自发地完成预设的工作。
  • 隔离性(Isolation,又称独立性):数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性防止多个事务并发执行时因交叉执行而导致数据不一致。
  • 持久性(Durability):事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。

这四个英文首字母刚好拼成 ACID。下面我们一个个掰开揉碎地讲。

原子性:要么全有,要么全无

回忆买票的例子。扣钱、锁票、登记三步,你希望它对用户表现为"一步":这步成,钱票都到位;这步败,什么都不发生,回到买票前。这就是原子性。

为什么用户层面看起来就像"一步"?因为这背后的实现是:任何事务都有"执行前、执行中、执行后"三个阶段。原子性说白了,就是让用户要么看到事务执行前的状态,要么看到事务执行后的状态,绝不让你看到"执行中"那种残缺的中间态。执行中一旦出问题,就随时回滚到执行前。

这里我打了个比方,你更容易记住:你妈妈跟你说,"你要么别学,要学就学到最好。至于你怎么学、中间遇到什么困难,她不管,只认结果。"那么对你的妈妈来讲,你"学习"这件事就是原子的——她只看最终来没来报告成绩,不关心过程里你有没有摔一跤。数据库的原子性和这个逻辑一模一样。

隔离性:让并发事务互不打扰

上面说了,原子性是"单个事务"给自己定的规矩。可现实是,MySQL 服务端同一时刻可能被几百上千个客户端(连接)同时访问,而且每个客户端都是"以事务的方式"在操作数据。事务 A 在进行的时程里,事务 B 可能也在动同一张表、甚至同一行记录。

这就好比在你的"学习"过程里,别人总来打扰你:你正背单词,舍友放歌;你正刷题,隔壁寝室聊天。这时候,光有"原子性"救不了你——学习这件事对妈妈是原子的,但学习过程中很容易受外界干扰。于是数据库为了保证"你学习的过程尽量不被打扰",就有了一个重要特性:隔离性。反过来,数据库允许事务"受不同程度的干扰",就引出了另一个重要概念:隔离级别。

说白了,隔离级别就是"允许事务在多大程度上互相干扰"的档位开关。越严格越安全,但并发性能越低;越宽松效率越高,但越容易出并发问题。实际生产环境就在两者之间找平衡点。

原子性与隔离性,谁先谁后是一种错觉

注意一个容易误解的地方:很多人以为数据库里是先有"事务 A 跑完"、再有"事务 B 跑"——串行的一个接一个。其实不是。多个事务的"执行中"阶段是交织在一起的:事务 A 的 SQL、事务 B 的 SQL 你一下我一下穿插执行。正因如此才需要隔离性来"强行建立先后秩序":让"先来的事务"和"后来的事务"各看各该看的内容。这也是为什么我们强调,事务有"执行前/执行中/执行后",而并发事务在执行中阶段是交叉的。

事务的版本支持:不是所有引擎都支持

一个非常关键的坑,很多人会踩:在 MySQL 里,只有使用了 InnoDB 数据库引擎的数据库或表才支持事务,MyISAM 不支持。

这和引擎的底层设计有关。InnoDB 天生为"高并发 + 事务 + 行级锁 + 外键"设计,它有 redo log、undo log 那一整套支撑原子性和持久性的机制;而 MyISAM 是老旧引擎,主打"简单、快速、全表锁",没有那些日志机制,自然谈不上事务。所以我们建表时都会明确写 ENGINE=InnoDB。

如何查看当前 MySQL 支持哪些引擎呢?用 show engines:

-- 查看当前 MySQL 支持的所有存储引擎,用表格方式展示
mysql> show engines;

也可以加 \G 让它以"行"的方式展示,字段更多、更好读:

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           -- 是否支持保存点

你会在列表里看到 MRG_MYISAM、MEMORY(内存引擎)、BLACKHOLE、MyISAM、CSV、ARCHIVE、PERFORMANCE_SCHEMA、FEDERATED 等等——绝大多数引擎的 Transactions 那一列都是 NO,只有 InnoDB 是 YES。这再次印证:想用事务,先确认你的表是 InnoDB。

补充一个小细节:MySQL 8.0 起,InnoDB 已经是默认引擎,除非你显式指定,否则建表默认就是 InnoDB。所以绝大多数场景你不用操心。

事务的提交方式

事务"结束"的方式,其实有两种:提交(commit,保存)和回滚(rollback,撤销)。而"这一条 SQL 是不是要等我来手动 commit"这件事,是由 MySQL 的一个开关控制的,它叫 autocommit(自动提交)。它有两种取值,也是两种提交方式:

  • 自动提交:每执行一条 SQL,MySQL 都把它当作"单独一个事务",执行完立刻自动 commit,持久化。默认就是这种。
  • 手动提交:需要我们自己开事务、执行、最后手动 commit 或 rollback。

查看当前是否自动提交:

-- 查看 autocommit 变量的当前值
mysql> show variables like 'autocommit';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit    | ON    |   -- ON 表示开启自动提交(默认)
+---------------+-------+

用 SET 命令可以临时改变它(只影响当前会话):

-- 关闭自动提交:之后的 SQL 都要手动 commit 才会持久化
mysql> SET AUTOCOMMIT=0;
 
-- 查看一下,确实是 OFF 了
mysql> show variables like 'autocommit';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit    | OFF   |   -- 关闭成功
+---------------+-------+
 
-- 再重新打开自动提交
mysql> SET AUTOCOMMIT=1;
 
mysql> show variables like 'autocommit';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit    | ON    |   -- 又回到默认的自动提交
+---------------+-------+

这里有个极易踩的坑,我提前给你打个预防针:SET AUTOCOMMIT=0 只对"当前这个连接/会话"有效,不会影响别的连接,更不会永久生效。一旦你关掉这个连接,下次再连上来,autocommit 又会回到 ON。如果你连到一个共享的线上库,千万不要想当然地以为改一次就全局生效了。

事务的常见操作

光说不练假把式。我们用一个最简单的银行账户表,亲手把事务的各个操作跑一遍。先建表:

-- 建一张银行账户表:id 是主键,name 存户名,blance(课件里的拼写,实际应叫 balance)存余额
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 -- 余额,10 位精度小数点后 2 位,默认 0
)ENGINE=InnoDB DEFAULT CHARSET=UTF8;    -- 指定 InnoDB 引擎,UTF8 字符集

多说一句,decimal(10,2) 是存钱的正确姿势——它不会像 float/double 那样有二进制浮点的精度误差。钱的字段,永远优先 decimal。

开始事务,可以用 begin,也可以用 start transaction,两者等价,业内一般推荐 begin:

-- 开启一个事务
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
 
-- 在事务内部创建一个"保存点"save1(Savepoint:事务内部的记号,可用来回滚到这一格)
mysql> savepoint save1;
Query OK, 0 rows affected (0.00 sec)
 
-- 插入张三,余额 100
mysql> insert into account values (1, '张三', 100);
Query OK, 1 row affected (0.05 sec)
 
-- 再创建一个保存点 save2
mysql> savepoint save2;
Query OK, 0 rows affected (0.01 sec)
 
-- 再插入李四,余额 10000
mysql> insert into account values (2, '李四', 10000);
Query OK, 1 row affected (0.00 sec)
 
-- 查查看,两条记录都在了
mysql> select * from account;
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |   100.00 |
|  2 | 李四   | 10000.00 |
+----+--------+----------+
2 rows in set (0.00 sec)

现在见证奇迹的时刻——回滚到保存点:

-- 回滚到保存点 save2:注意,只撤销 save2 之后的操作
mysql> rollback to save2;
Query OK, 0 rows affected (0.03 sec)
 
-- 再查,李四那条记录没了,但张三还在
mysql> select * from account;
+----+--------+--------+
| id | name   | blance |
+----+--------+--------+
|  1 | 张三   | 100.00 |
+----+--------+--------+
1 row in set (0.00 sec)
 
-- 直接 rollback:不指定保存点,回滚到事务最开头,所有改动全撤
mysql> rollback;
Query OK, 0 rows affected (0.00 sec)
 
-- 再查,连张三也没了,表空了
mysql> select * from account;
Empty set (0.00 sec)

这四个操作——begin(开始)、savepoint(保存点)、rollback to(回滚到指定点)、rollback(回滚全部)、commit(提交)——就是事务的"全家桶"。你注意 rollback to save2 和 rollback 的区别:前者是"部分回退",后者是"全部回退"。这个"保存点"机制,让你在长事务里也能精准"仅撤销一段",不至于一撤就丢光前面所有成果。

再把提交做一遍,看看 commit 到底干了什么:

-- 开启事务
mysql> begin;
 
-- 插入一条记录
mysql> insert into account values (1, '张三', 100);
 
-- 提交事务:把数据真正持久化到磁盘
mysql> commit;
Query OK, 0 rows affected (0.04 sec)

这里 commit 意味着:事务到此成功结束,所有改动正式落盘。提交之后,就再也回不去了(下面会讲)。

非正常情况:客户端崩溃了会怎样?

这是把事务讲透的关键彩蛋。我们开两个"终端"(两个 MySQL 连接)来做对比。

实验一:开了事务、插了数据、但没 commit,客户端突然崩溃。

-- 终端A:开启事务
mysql> begin;
-- 终端A:插入张三
mysql> insert into account values (1, '张三', 100);

此时去终端 B(另一个连接)查,会发现张三的数据已经能被看到了(因为我们之前为了做实验,把全局隔离级别设成了读未提交;这点后面讲隔离级别会用到,先不管)。

紧接着,故意让终端 A 崩溃退出(比如 ctrl+\ 强制断开连接)。然后再回终端 B 查:

-- 终端B:终端A 崩溃之后,再查
mysql> select * from account;
Empty set (0.00 sec)   -- 表空了!

发现没有?数据自动回滚了。因为终端 A 那个事务根本没 commit,连接一断、事务被迫中断,MySQL 就按"未提交视为失败"处理,自动把所有改动回滚掉。这告诉我们:不发 commit,事务就是不成立的,数据库不会为你"赖账"任何一次未完成的改动。

实验二:开事务、插数据、并且 commit 了,然后客户端崩溃。

-- 终端A:开启事务、插入、提交三步走
mysql> begin;
mysql> insert into account values (1, '张三', 100);
mysql> commit;

再把终端 A 强制断掉。回终端 B 查:

-- 终端B:终端A 崩溃之后,再查
mysql> select * from account;
+----+--------+--------+
| id | name   | blance |
+----+--------+--------+
|  1 | 张三   | 100.00 |
+----+--------+--------+
1 row in set (0.00 sec)   -- 数据还在!

这次数据没丢——因为 commit 已经让改动持久化了,客户端怎么崩都影响不到磁盘上的结果。这就是持久性的现场证据,也是 commit 和 rollback 的分界点。

一个关键的对比实验:begin 和 autocommit 的关系

很多初学者在这里绕晕:我 SET AUTOCOMMIT=0 了,那 begin 还有没有意义?我 autocommit 是 ON,那 begin 是不是多余?

课件里做过一个"对比实验",结论非常干净,我直接告诉你:

只要输入了 begin 或 start transaction,这个事务就必然要通过 commit 提交才会持久化,跟 autocommit 是 ON 还是 OFF 一概无关。

换句话说,begin 是"显式进入手动事务模式的信号"。一旦进入了这个模式,之前那条 SQL 是自动提交还是手动提交的 autocommit 设置,对这个事务内部就暂时"失效"了——事务里的每一句改动,都得靠一次显式的 commit 来最终落盘。

反过来,当 autocommit 是 ON、且你没有 begin 时,每一条 SQL 就是独立的一个事务,执行完自动 commit。这时你插入一条数据再崩溃,数据一样会留下(因为它已经单独提交了)。

最后再补一个结论,也是面试常考:

对于 InnoDB,每一条 SQL 语句默认都被封装成一个事务、自动提交。 只有 select 有特殊性——因为 MySQL 有多版本并发控制(MVCC,后面讲),查询走的是"快照",所以它不那么受"必须 commit"的约束。

事务操作注意事项(易错点清单)

把上面那些零散的坑汇总成一张清单,你以后写事务值得贴出来对照:

  1. 如果不设置保存点,也可以回滚,但只能回滚到事务的最开始。 直接 rollback(前提是事务还没提交)。
  2. 如果一个事务已经提交(commit),就不可以再 rollback 了。 提交是终点,没有后悔药。
  3. 可以选择回滚到哪个保存点, 用 rollback to 保存点名。
  4. InnoDB 支持事务,MyISAM 不支持。
  5. 开始事务可以用 start transaction 或者 begin,两者等价。 一个容易忽略的小坑:start transaction 是标准 SQL 写法中的一种,而 begin 更贴近日常习惯,但要注意 begin 在某些上下文里会和 begin...end(存储过程中的语法块)混淆,所以如果你在写存储过程,改用 start transaction 更稳妥。
  6. 事务内改动未提交时,当前连接自己查到的是改动后的数据,但其他连接查到的往往不是(取决于隔离级别)——这点是理解后面隔离级别的钥匙。

为什么需要隔离及隔离级别

前面我们把 ACID 的大致轮廓搭好了:原子性、持久性我们已经用实验验证过了。但剩下两个——隔离性和一致性——光看单个事务演示不出来,因为它们的价值只有在多个事务并发执行时才显现。

回顾一下:MySQL 服务端是一个常驻的服务进程(比如在 3306 端口监听,netstat 都能看到 mysqld 在听),它同时被很多客户端进程/线程访问。每个客户端都按事务的方式操作数据,一个事务又由多条 SQL 构成。那么多事务各自执行时,就极可能:

  • 多个事务同时访问同一张表;
  • 甚至同时访问同一行数据。

于是"互相干扰"就来了。为保证事务执行过程尽量不受干扰,有了隔离性;而允许事务受"不同程度的干扰",就有了隔离级别。

MySQL 提供了四个隔离级别,从松到严依次是:

隔离级别英文名是否阻止脏读是否阻止不可重复读是否阻止幻读
读未提交Read Uncommitted否否否
读提交Read Committed是否否
可重复读Repeatable Read是是是(InnoDB 下)
串行化Serializable是是是

这张表怎么读?"是否阻止"填的是"是",代表能防住该类问题。原则很清楚:隔离级别越高,越安全,并发性能越低;越低,越高效,问题越多。 往后走你还会体会到,"路越高,走得越稳但越慢"。

现在把四个级别逐一讲透,同时引出三个经典并发问题:脏读、不可重复读、幻读。

读未提交(Read Uncommitted)

定义:在该隔离级别下,所有事务都能看到其他事务没有提交的执行结果。

一句话记住它:就相当于几乎没有隔离。一个事务读到另一个正在执行、还没提交的事务的改动,很随意,也很危险。这么写的优缺点也很极端:

  • 优点:几乎不加锁,效率极高;
  • 缺点:脏读、幻读、不可重复读一个都防不住。

实际生产不可能用这个级别,我们前面做"客户端崩溃自动回滚"实验时,为了能"看到对方没提交的数据",才临时把它设成这个级别的——这是它唯一的课堂用途。

**脏读(Dirty Read)**就诞生在这个级别。它的定义:一个事务在执行过程中,读到了另一个"正在执行中、但还没提交(commit)"的事务所修改的数据。

为什么叫"脏"?因为这个数据是"脏的"——它源于一个尚未尘埃落定的事务,随时可能被对方回滚。你看到了它,可对方一 rollback,你手上这份数据就成了历史,等于读了个寂寞。用着别人"半生不熟"的数据,就是脏读。

看个现场演示(A、B 两个终端都处于读未提交):

-- 终端A:开启事务,更新 id=1 那条记录的余额为 123
mysql> begin;
mysql> update account set blance=123.0 where id=1;
-- 注意:这里没有 commit!!!
 
-- 终端B:另开一个事务,查全表
mysql> begin;
mysql> select * from account;
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |   123.00 |   -- 居然读到终端A"未提交"的 123!
|  2 | 李四   | 10000.00 |
+----+--------+----------+

终端 B 读到了终端 A 尚未 commit 的 123.00,这就是典型的脏读。带个演示补充:这种"读到更新(update)未提交数据"是脏读,insert、delete 未提交的数据被读到,同样算脏读。

读提交(Read Committed)

定义:一个事务只能看到其他事务已经提交(commit)了的改动。这是大多数数据库(比如 Oracle、PostgreSQL)的默认隔离级别,但不是 MySQL 的默认。

它挡住了脏读——你再也看不到对方未提交的东西了。但新的问题出现了:不可重复读(Non-repeatable Read)。

不可重复读的定义:在同一个事务内部,用同样的查询条件、你以为结果应该一样,可在不同时间(注意,都还处在这个事务中)执行,读到的值不一样了。变化的原因,是另一个事务恰好在你两次查询之间提交了对同一行/同一批数据的更新或删除。

做个区分锚点(超重要,面试爱考):

  • 不可重复读的重点在"改"和"删":同样的条件,你读过的数据,再读发现值变了(比如余额从 123 变成 321)。
  • 幻读的重点在"增":同样的条件,第一次和第二次读出来的记录数不一样(多出几条新记录)。

看不可重复读的现场(A、B 都处于读提交):

-- 终端B:先开启事务
mysql> begin;
 
-- 终端B:事务开始时查询,看到余额 123
mysql> select * from account;
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |   123.00 |   -- 老的值
+----+--------+----------+
 
-- ……中间终端A 把这条数据改成 321 并且 commit 了……
 
-- 终端B:还是在"同一个事务"里,再查一次按道理应该一样,结果……
mysql> select * from account;
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |   321.00 |   -- 新的值!在同一个事务里读到了不同值
+----+--------+----------+

看到没有,终端 B 全程没 commit、一直在同一个事务里,可两次 select 出来余额一个 123 一个 321——同一事务、同一条件、不同结果,这就是不可重复读。

(课件在这里留了个灵魂反问:"这个是问题吗?"——如果你只是在做一次报表快照、下一次之前本来就应该是最新的,那没问题;可如果业务要求"整个事务里我看到的必须是一致的快照",那这就是个必须解决的大问题。到底算不算问题,取决于业务,但它确实是"并发导致的读不一致",被归为事务隔离性问题。)

可重复读(Repeatable Read)

定义:这是 MySQL 的默认隔离级别。它确保同一个事务在执行中,多次读取操作看到的是同一份数据——仿佛这个事务一开始就给数据拍了个快照,之后自己在事务里怎么查,都基于这份快照,不受其他事务陆续提交的影响。

它挡住了脏读,也挡住了不可重复读。再接着看现场(A、B 处于可重复读):

-- 终端B:开启事务,事务一开始先查一次
mysql> begin;
mysql> select * from account;
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |   321.00 |   -- 事务开始时看到 321
+----+--------+----------+
 
-- ……中间终端A 把 id=1 改成 4321 并 commit 了……
 
-- 终端B:同样的条件再查,还是 321
mysql> select * from account;
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |   321.00 |   -- 还是老值!不受别人提交影响,可重复读成立
 
-- 终端B:结束本事务
mysql> commit;
 
-- 终端B:事务结束后,新事务再看,最新值出来了
mysql> select * from account;
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   | 4321.00 |   -- 事务结束后,读到最新的 4321
+----+--------+----------+

看懂这个"反差"了吗?同一个事务里查,永远看到快照(321);一旦事务结束、新开一个事务,才能看到别人新提交的 4321。 这就是可重复读的威力——它是"为单个事务保持稳定一致视角"而生的。

那在可重复读下,还有问题吗?课件又留了一个关键实验:把终端 A 的操作从 update 换成 insert,看看会怎样。

-- 终端A:开启事务,插入一条王五的新记录
mysql> begin;
mysql> insert into account (id,name,blance) values(3, '王五', 5432.0);
-- 中间……终端A commit 了
 
-- 终端B:开启事务,查全表
mysql> begin;
mysql> select * from account;
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   | 4321.00 |
|  2 | 李四   | 10000.00 |
+----+--------+----------+
2 rows in set (0.00 sec)   -- 注意:没有王五!被快照挡掉了

咦,在 MySQL 的可重复读下,终端 B 看不到终端 A 新 insert 的那条王五。而课件特别提醒:一般的数据库在可重复读下是挡不住这种"新插入记录"的——为什么?因为隔离性实现本质上靠"对数据加锁",而 insert 要插入的这条数据"根本没存在过",你没法对一条不存在的记录加锁;于是这条新记录就可能被并发事务读出来,导致同一个事务里多次查询,第一次查到 2 条、第二次查到 3 条,"凭空多出来"像幻觉一样。这个现象就叫 幻读(Phantom Read)。

那么问题来了:MySQL 到底防不防幻读?看上面演示,MySQL 在可重复读下,确实挡住了那条王五。**结论是:MySQL 的可重复读隔离级别,借助 Next-Key 锁(我们马上讲)把幻读也一并解决了。**这和教科书上"可重复读会留幻读"的经典说法不一致,因为经典说法以一般数据库(通过简单的行锁)为参照,而 InnoDB 用了更高级的锁方案。

串行化(Serializable)

定义:这是事务的最高隔离级别。它通过强制事务排序,让所有事务一个一个串行执行,使它们不可能相互冲突,从而把脏读、不可重复读、幻读统统解决。

实现方式是:在每个读到的数据行上都加共享锁,读读不冲突,但读写之间、写写之间彻底互斥——想读同一块数据,就得排队。

好处是绝对安全,坏处也极其明显:并发性能几乎归零,容易超时、容易锁竞争。实际生产基本不用这种"极端"级别。它是个"理论上完美、实践上过于昂贵"的兜底选项。

现场演示(A、B 处于串行化):

-- 终端A:开启事务
mysql> begin;
-- 终端B:开启事务
mysql> begin;
 
-- 两个终端都查这张表:读读不串行,都能顺利查到
mysql> select * from account;   -- 共享锁:读读之间不冲突,两边都成功
 
-- 但当终端A 想 update 同一行时,就被卡住了!
mysql> update account set blance=1.00 where id=1;
-- 阻塞了十几秒(实际执行耗时 18.19 秒),因为要等终端B 的事务提交、释放共享锁
-- 直到终端B 执行 commit,终端A 的 update 才成功

这就是串行化的代价:一个普通 update,愣是被并发的读锁堵了 18 秒。

四个级别小结

隔离级别一句话形象理解主要问题
读未提交什么都敢读,包括没提交的脏读、不可重复读、幻读
读提交只读已提交的不可重复读、幻读
可重复读整个事务内视角不变(InnoDB 下)基本全防住
串行化全部排队串行性能灾难

MySQL 默认就是可重复读,一般情况下不要改它——这是官方推荐的"默认不动的"行业惯例,尤其当你写的是事务密集型应用时,可重复读在"保证一致性"和"并发性能"之间给了一个优良的平衡。

查看与设置隔离级别

查看当前隔离级别,这里有个版本差异的坑,必须单独拎出来讲:

  • MySQL 5.7 及更早版本:用 tx_isolation 变量(课件用的就是它,但有 warning)。
  • MySQL 8.0 及以后版本:tx_isolation 已被废弃,改名成 transaction_isolation。

如果你在 MySQL 8 上执行 select @@tx_isolation;,会看到一条 warning 提示它已过时,建议换用 transaction_isolation。这也是很多旧教程在 8.0 上跑不通的根源。

查看的命令(以 8.0 的变量名为例,下面用 transaction_isolation):

-- 查看全局隔离级别
mysql> SELECT @@global.transaction_isolation;
+-------------------------------+
| @@global.transaction_isolation |
+-------------------------------+
| REPEATABLE-READ               |
+-------------------------------+-- MySQL 全局默认:可重复读
 
-- 查看当前会话隔离级别
mysql> SELECT @@session.transaction_isolation;
+--------------------------------+
| @@session.transaction_isolation |
+--------------------------------+
| REPEATABLE-READ                |  -- 当前会话默认:可重复读
 
-- 不带 session/global 前缀,等价于看当前会话的
mysql> SELECT @@transaction_isolation;
+-------------------------+
| @@transaction_isolation |
+-------------------------+
| REPEATABLE-READ         |
+-------------------------+

设置隔离级别的语法是:

-- 语法:SET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL <级别>
-- <级别> 取 READ UNCOMMITTED / READ COMMITTED / REPEATABLE READ / SERIALIZABLE 之一

两个选项的区别也很关键:

  • SET SESSION ...:只影响当前会话,别的连接看不到。适合自己临时调试。
  • SET GLOBAL ...:影响后续新建的所有会话,但正在运行的旧会话不会立即生效,通常需要重连才能看到效果。

举个设置会话隔离级别的例子:

-- 只把当前会话设为串行化
mysql> set session transaction isolation level serializable;
 
-- 此时查全局,还是可重复读
mysql> SELECT @@global.transaction_isolation;
REPEATABLE-READ                   -- 没被改变
 
-- 查当前会话,已经是串行化
mysql> SELECT @@session.transaction_isolation;
SERIALIZABLE                      -- 只影响了自己

再看设置全局隔离级别的例子(注意"另开一个会话才会被影响"):

-- 把全局隔离级别设为读未提交
mysql> set global transaction isolation level READ UNCOMMITTED;
 
-- 新开的连接里查,已是读未提交
mysql> SELECT @@global.transaction_isolation;
READ-UNCOMMITTED

有个现场血泪坑:set global 之后的"当前这个连接"可能看上去还没变,别慌,关掉重连(重新打开 mysql 终端)再看就有了。很多人卡在"我改了 global 怎么没效果",基本都是在同一个旧连接里反复查,没重连。

再补一个跟页面安全相关的实践忠告:set global transaction isolation level ... 是全局生效的,会改变全库所有人的隔离级别,影响其他业务。线上操作时务必谨慎,改之前先想清楚"是不是真的要动全局",通常改成 session 级别的临时测试就够用了。

隔离级别是如何实现的:锁

讲完了"上层怎么用",再往底层挖一层:隔离级别到底靠什么实现?

一句话:隔离,基本都是通过锁(Lock)实现的。不同的隔离级别,锁的使用方式不同。 常见的锁包括(先有个印象,我们用得最多的是行锁和它衍生的系列):

  • 表锁:锁住整张表,粒度最粗,并发度最低;
  • 行锁:只锁住某一行记录,粒度最细,并发度最高——InnoDB 的特色;
  • 读锁(共享锁,S 锁):多个事务可以同时读同一份数据,彼此不冲突;
  • 写锁(排他锁,X 锁):一个事务写时,独占锁定,其他事务既不能写也不能读这块数据;
  • 间隙锁(Gap Lock):锁住"两个索引值之间的空隙",用来阻止别人往这个区间插入新记录,专门对付幻读;
  • Next-Key 锁(GAP + 行锁):间隙锁和行锁的组合,既锁住记录本身,又锁住记录前后的空隙,是 InnoDB 在可重复读下解决幻读的利器。

这里给一个"我们现在只需要有的认识":锁是和具体的索引/范围强相关的,不同的隔离级别代表"你会不会而这种锁、锁的范围多大"。先关注怎么用、把上面的几种锁名词记住,具体的锁冲突细节,等用到时再回头追踪也不迟。

两阶段锁协议

聊锁就绕不开一个经典概念——两阶段锁(Two-Phase Locking, 2PL)。它是保证并发事务可串行化的底层协议,简单说就是两条铁律:

  • 加锁阶段(扩张/增长阶段):事务执行过程中,只允许不断"加锁",不允许"释放已持有的锁";
  • 解锁阶段(收缩阶段):事务一旦开始释放第一个锁,就只允许"释锁",不允许"再加新锁"。

为什么这么设计?因为只要所有事务都遵守"加锁在前、集中释放在后",就能保证一个关键性质:事务的结果等价于某种"串行执行"的结果。我们最常用的一句话实现是——把"加锁"一直攒到事务结束(commit/rollback)时才统一释放,这就天然满足两阶段,各种隔离问题自然被拦住。这也是 commit 之后锁才真正放开的原因:你看前面串行化的例子,终端 B 一 commit,终端 A 被卡的 update 立刻通过,正是"提交时统一释放锁"的体现。

一致性:靠 AID 来保证

足下可能会疑惑:ACID 讲了三个,还剩个一致性(Consistency)没深入。其实一致性是所有特性的"总纲",它说的是:

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

什么叫一致性状态?当数据库只包含"事务成功提交"的结果时,它就是一致的。 反之,如果系统运行中途中断,某个事务还没完成就被迫中断,而它未完成的修改已经写进了数据库,此时数据库就处于一种**不正确(不一致)**的状态——比如余额被扣了但票没记录上。

那一致性靠谁保证?技术上有个非常著名的分法:通过"原子性(A)、隔离性(I)、持久性(D)"来间接保证一致性(C)——简写为 AID 保证 C。

  • 原子性:万一失败,全部回滚,绝不让数据库停留在事务的"半路",这是保证一致性的第一道防线;
  • 隔离性:多个事务并发时,各自看到正确、稳定的视图,不会因为互相干扰制造出"错位的中间状态";
  • 持久性:一旦提交,改动永久保留,不会因为后来崩溃而"悄悄丢数据",让最终状态稳定可预期。

但还有个常常被低估的点,课件特意强调了一句:一致性其实和用户的业务逻辑强相关。 数据库只能提供"技术上的支持",真正的一致性约束——比如"转账后两账户总金额不变"、"余额不能为负"——往往是你业务代码需要靠自己逻辑去守住的。数据库做的,是保证"这些约束要么完整生效、要么完全不生效",而不替你定义"约束是什么"。

并发的三种场景

把并发拍成一张全景图,事务之间无非三种搭配:读-读、写-写、读-写。它们的处理难度天差地别:

  • 读-读(读读):两个事务都只读同一份数据,谁也不改,自然没冲突,不需要任何并发控制,放行即可。
  • 写-写(写写):两个事务都要改动同一数据,有很严重的线程安全问题,可能出现更新丢失(Lost Update),比如第一类更新丢失、第二类更新丢失。
  • 读-写(读写):一个读一个写,会有线程安全问题,可能造成事务隔离性问题——脏读、幻读、不可重复读都出在这个场景。

**更新丢失(Update Lost,丢失更新)**指:两个事务同时读旧值、各自加量、再写回,后写回的把先写回的覆盖了,导致一次更新凭空丢失。举个生活例子:两个收银员同时看到库存是 10,一个卖出 3 件写回"7",另一个也按"10"卖出 5 件写回"5",结果库存变成 5,那"先卖掉的 3 件"带来的库存变化被吞掉了——一共卖出 8 件,库存却只记少了 5。这就是丢失更新。它有"第一类"(回滚产生的丢更)和"第二类"(覆盖写产生的丢更)之分,进阶内容,这里先认识名词。

现在,重点来了:今天最想讲的,是那个"读-写"场景。

MVCC:多版本并发控制

读-写冲突是最常见的并发问题,处理它有一个极其优雅的方案,叫多版本并发控制,英文全称 Multi-Version Concurrency Control,缩写 MVCC。

它是这么个思路:当发生数据修改时,不直接覆盖旧值,而是"保留历史版本、新增一个新版本"。 于是同一份逻辑数据,在内部同时存在好几个"版本"(像一条能回溯的存档链)。这样一来,读操作可以"读自己该看的那个旧版本",写操作"写自己的新版本",读写互不阻塞。

MVCC 两大核心好处,正好接住前面挖的坑:

  1. 在并发读写时,读操作不用阻塞写操作,写操作也不用阻塞读操作,大幅提高并发的读写性能——这是它最值钱的地方;
  2. 可以顺带解决脏读、幻读、不可重复读这些事务隔离问题,但解决不了更新丢失问题(更新丢失要另外靠锁/原子操作去处理)。

理解 MVCC,需要先掌握三个前提知识:

  • 3 个记录隐藏字段(藏在每行记录里、用户看不见的元数据);
  • undo log(回滚日志);
  • Read View(读视图)。

一个都不能少,我们逐个拆。先看3 个记录隐藏列字段。

当 InnoDB 在维护一条记录时,除了你建的显式列,它还会悄悄给每行加上几个隐藏字段,用于支撑版本链和并发控制,最关键的是这三个:

  • DB_TRX_ID(6 字节):最近修改(修改或插入)这条记录的事务 ID。它"刻"着这条版本是哪个事务产生的。
  • DB_ROLL_PTR(7 字节):回滚指针,指向这条记录的上一个版本(历史版本数据通常存在 undo log 里)。正是它把各版本串成了一条链。
  • DB_ROW_ID(6 字节):隐含的自增 ID(隐藏主键)。如果建表时没有显式主键,InnoDB 会自动用 DB_ROW_ID 生成一个聚簇索引来组织数据。

课件还补了一个容易被忽略的隐藏字段——删除标志位(delete flag):记录被"更新"或"删除"并不代表真的物理清除,而是标记一个"删除标志"变了。这一点在后面讲版本链时很重要。

举例,一张 student 表(只有 name、age 两列):

nameageDB_TRX_IDDB_ROW_IDDB_ROLL_PTR
张三28null1null

意思是:我们目前还不知道"创建这条记录的事务"是谁(先记为 null),隐含主键是 1;因为它是第一条记录、没有历史版本,所以回滚指针也置为 null。

undo log:回滚日志

MVCC 里没有 undo log 寸步难行。**undo log(回滚日志)**是 InnoDB 的一支重要日志,它专门记录"修改前的旧值",用来支撑事务回滚、以及 MVCC 的版本回溯。

先纠正一个常见误解:很多人以为 undo log 是"一直存在磁盘上的文件",其实 MySQL 的服务进程常住内存,我们的索引、事务、隔离、日志这些机制,很多判断都是在内存中的相关缓冲区里完成的,之后再在合适的时机刷新到磁盘。所以为了理解方便,你可以先把 undo log 想成"MySQL 内部用来保存历史版本记录的一块内存缓冲区"——它在内存里维护着各记录的历史版本,必要时(如回滚、生成快照)从中取值。

配合 undo log,我们就能手把手模拟一遍 MVCC 的版本链是怎么长出来的。

模拟 MVCC:一条版本链的诞生

第一步:事务 10 修改"张三"这条记录(把 name 改成 李四)。

-- 建表(无主键,让 InnoDB 用隐藏的 DB_ROW_ID 维护聚簇索引,正好配合讲解)
mysql> create table if not exists student(
    name varchar(11) not null,
    age int not null
);
 
-- 插入一条记录
mysql> insert into student (name, age) values ('张三', 28);
-- 此刻表里有一条:张三/28
 
-- 查询确认
mysql> select * from student;
+--------+-----+
| name   | age |
+--------+-----+
| 张三   |  28 |
+--------+-----+

事务 10 执行 update student set name='李四' where name='张三' 的过程,在底层是这样的:

  1. 先加行锁:事务因为要修改,先给这条记录加行锁,防止别人同时写;
  2. 写时拷贝到 undo log:修改之前,把这条记录的原样(张三/28)拷贝一份到 undo log——这就是"写时拷贝"(copy-on-write)思想,改的是原记录,但改动前先把"旧影子"留档;
  3. 修改原始记录:把原始记录里的 name 改成 "李四",并且:
    • 把原始记录的隐藏字段 DB_TRX_ID 更新为当前事务 10 的 ID;
    • 把 DB_ROLL_PTR(回滚指针)填上 undo log 里那个副本的地址,让它指向历史版本 "张三/28",表示"我(李四/28)的上一个版本是它"。
  4. 事务 10 提交,释放锁。

此时:表里的"最新记录"是"李四",而它的旧版本"张三"安放在 undo log 的版本链上。

第二步:事务 11 再改一次(把 age 从 28 改成 38),敲开"版本链"的真容。

同样的流程再来一遍:事务 11 加行锁 → 把当前"最新记录(李四/28)"拷贝到 undo log → 修改 age 为 38 → 把 DB_TRX_ID 改成 11、DB_ROLL_PTR 指向最新拷贝的那个副本 → 提交。

注意课件强调的一个细节:undo log 里的新副本,采用"头插法"——新版本插在链表的头部,也就是让最新的历史版本离当前版本最近。于是所有版本被 DB_ROLL_PTR 串成了一条单向链表,链头是"当前最新版本",向后逐个回溯到最早版本:

当前记录(最新) ──DB_ROLL_PTR──▶ 副本(事务11改后) ──▶ 副本(事务10改后) ──▶ 最早版本
  李四 / 38                    李四 / 28                  张三 / 28              (null)

这条基于链表串起来的历史版本序列,就叫版本链;其中的每一个"版本",也就是课件说的快照(Snapshot)。所谓"回滚",本质就是拿版本链上的历史数据覆盖当前数据;所谓 MVCC 读到"旧版本",本质上也是沿着这条链去取合适的某一版。

这里还有三个边界值得记牢(课件专门点过):

  • 上面是拿 update 做主角,那么 delete 呢?一样可以形成版本链——删数据并不是物理清空,而是把那条记录的删除标志位标记为"已删除",它照样作为一个历史版本留在链上供并发查询回溯;
  • insert 呢? 插入的意思是"之前没有这条数据",所以它没有历史版本;但为了支持回滚,插入的数据也会被放进 undo log。一旦当前事务 commit 了,这条 insert 的历史记录就可以被清理掉(因为不再需要依赖它来回滚了)。
  • 汇总:update 和 delete 可以形成版本链,insert 暂时不参与形成链(凑数而已)。

以你现在的量级,能理解"版本链 + 快照"已经很不错了,很多学生能讲到这里就已经能应付一大半面试了。

当前读 vs 快照读:select 到底读哪一版?

这条版本链上摆着好几个历史版本,那 select 到底去读哪一个?这就引出 MVCC 里最重要的一个分岔——当前读(Current Read)和快照读(Snapshot Read)。

  • 当前读:读取"当前最新"的记录,也就是版本链上最新的那版。增删改(insert/update/delete)都算当前读,因为它们必须作用在真正最新、能上锁的那条记录上,否则"改了旧版本"毫无意义。而"当前读"就意味着要加锁——因为你要动真的,必须排他地锁住它。顺带一提,select 也可能变成当前读,比如 select ... lock in share mode(加共享锁强行读最新)和 select ... for update(加排他锁读最新)。
  • 快照读:读取"历史版本"(一般情况下),也就是版本链上那个对当前事务"可见"、但未必是最新的旧版本。它不需要加锁,因此可以和其他事务的读写并行执行!

这正是 MVCC 的威力所在:当多个事务同时"增删改"时,它们都是当前读,S要加锁、相互排队;但当有 select 过来时,如果它走快照读、去读历史版本,就完全不受锁的限制,可以和那些写操作并行跑,从而大幅提升并发度。 如果每读必读最新、每读必加锁,那就退化成串行化了。

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

  • 在可重复读 / 可串行化之类需要"稳定视角"的级别下,普通 select 走整数快照读;
  • 在某些级别下、或显式加了 for update/lock in share mode 的 select,就是当前读。

而这个分岔,就是我们接下来要讲的 Read View 的舞台。

Read View:读视图与其可见性判断

终于到最精巧的一环。你也许会问:版本链上一堆版本,快照读到底该选哪一版?凭什么选它?

答案藏在一个叫 Read View(读视图) 的对象里。

Read View 是什么? 它是事务进行快照读操作的瞬间,生成的一个"读视图"。在那个事务执行快照读的那一刻,系统会给当前数据库拍一张"状态快照",记录并维护一个"系统此刻正活跃的事务 ID 集合"。每个事务开启时都会被分配一个递增的 ID,越新的事务 ID 越大;所以 Read View 拿到的主料,就是"我生成时有哪些事务还在进行中"。

在 MySQL 源码里,ReadView 本质上是一个 C++ 类,用来做可见性判断:当我们某个事务执行快照读、读到某条记录时,会用这个 Read View 当"标尺",判断"当前这条记录的版本,我能不能看见"。可能看见的是最新版本,也可能是版本链(undo log)里的某个历史版本。它的核心结构(简化版)大致这样:

class ReadView {
    trx_id_t m_low_limit_id;   // 高水位:>= 这个 ID 的事务都不可见(下一个尚未分配的事务ID)
    trx_id_t m_up_limit_id;    // 低水位:< 这个 ID 的事务都可见(活跃列表中最小的事务ID)
    trx_id_t m_creator_trx_id; // 创建这个 Read View 的事务自身的 ID
    ids_t    m_ids;            // 创建视图那一刻,系统中活跃事务的 ID 列表
    // ...(其余如 m_low_limit_no 等是配合 undo 清理的内部字段)
};

把这四个字段用大白话拆开:

  • m_ids:一张列表,维护 Read View 生成时刻,系统正在运行(还没提交)的活动事务 ID;
  • m_up_limit_id(低水位):记录 m_ids 列表中最小的那个事务 ID(对,最小的,越老越先提交);
  • m_low_limit_id(高水位):Read View 生成时刻,系统尚未分配的下一个事务 ID,也就是"目前已出现过的最大事务 ID + 1";
  • creator_trx_id:创建这个 Read View 的事务自己的 ID。

而我们在实际读取版本链时,能拿到每个版本的 DB_TRX_ID(哪个事务造了这个版本)。于是,判断就化简成——用"记录的 DB_TRX_ID"去跟 Read View 的这几个水位线比对。

InnoDB 标准的可见性判断(我按标准算法整理成五步,比课件简化的更准确,二选一记即可):

  1. 若 trx_id < m_up_limit_id(低于低水位):说明这个版本所属的事务,在 Read View 生成时已经提交,版本可见;
  2. 若 trx_id == m_creator_trx_id:就是本事务自己改的,自己当然能看到,可见;
  3. 若 trx_id >= m_low_limit_id(不低于高水位):说明这个版本的事务,是在"我这个 Read View 生成之后"才启动的,我看不到它,不可见,沿版本链往旧走;
  4. 若 trx_id 恰好落在活跃列表 m_ids 里:说明生产这个版本的事务当时还在运行、没提交,不可见,沿版本链往旧走;
  5. 否则:这个事务已提交,但又不是最新(介于低水位和高水位之间、又不在活跃列表里),可见。

课件为了好记,给了一个"简化版"比对思路(同样成立,适合面试快速讲):

  • 比较一:DB_TRX_ID < up_limit_id?如果成立说明这版已经提交,直接可见;
  • 比较二:DB_TRX_ID >= low_limit_id?如果成立说明这也事务还没开始,不可见;
  • 比较三:DB_TRX_ID 在活跃事务列表 m_ids 里?如果不在,说明这个事务已提交,可见。

我们举课件的例子验证一遍。假设有事务 1(id=1)、事务 2(id=2)、事务 3(id=3)、事务 4(id=4);其中事务 4 修改了某条记录且已提交。现在事务 2对这条记录做快照读,生成 Read View:

事务2 的 Read View:
  m_ids           = {1, 3}      -- 那一刻活跃的事务,只有 1 和 3
  up_limit_id     = 1           -- m_ids 里最小的 id
  low_limit_id    = 4 + 1 = 5   -- 下一个未被分配的事务ID(宏现过最大的 id 是 4)
  creator_trx_id  = 2

记录的最新版本是事务 4 改的,DB_TRX_ID = 4。开始比对:

  • 4 < up_limit_id(1) 吗?不等于,不满足,继续;
  • 4 >= low_limit_id(5) 吗?不等于,不满足,继续;
  • m_ids.contains(4) 吗?活跃列表是 {1,3},不包含 4 —— 说明事务 4 不在当前活跃事务中,已经提交了。

综合一句结论:事务 4 的更改应该被看到。 于是事务 2 快照读拿到事务 4 提交的那一版——恰巧也就是全局角度上最新的版本。

这里插一个课件反复强调的点:这个 Read View 是在你执行 select 的时候自动形成的,你不需要手动创建;它就是你"该不该看见这版数据"的裁判。如果比对结果判定为不可见(比如记录最新版属于一个还在运行、未提交的事务),就沿着 DB_ROLL_PTR 一路往旧版本找,直到找到一个"满足可见条件"的版本为止。

RR 与 RC 的本质区别:Read View 的生成时机

MVCC 讲到这里,最大的疑团只剩一个:可重复读和读提交,表现完全不同,它们到底差在哪?

答案非常凝练:差在 Read View 的生成时机上。

我们先看一条用 RR(可重复读)级别反复验证过的关键规律——事务里快照读的结果,非常依赖该事务"第一次快照读发生的地点和时机":

  • 在 RR(可重复读) 级别下,某个事务对某条记录的第一次快照读,会创建一个快照并生成一个 Read View,把当时系统里活跃的其他事务统统记录在案;
  • 此后在这个事务里再执行快照读,用的都是同一个 Read View(不再重新生成);
  • 所以,只要当前事务在别的事务提交更新之前,已经做过一次快照读,那么它后续的所有快照读,都沿用这同一个旧 Read View,那些"后提交"的改动对它统统不可见。

这直接解释了可重复读"同事务稳定视角"的表现。看一个孪生的对比实验(都是 RR 级别):

用例 1(事务 B 在事务 A 修改前,已做过一次快照读):

事务A事务B
beginbegin
——快照读查询(此时就生成并固定了 ReadView)
update ... age=18——
commit——
——再快照读(普通 select):看不到 18,因为用的还是旧的 ReadView
——select ... lock in share mode(当前读):能看到 18,当前读直取最新

用例 2(事务 B 在事务 A 修改前,没做过快照读;等 A commit 之后才开始查):

事务A事务B
beginbegin
update ... age=28——
commit——
——快照读(第一次就在 A 提交之后,此时才生成 ReadView)
——看到 age=28(新 ReadView 自然看见已提交的 28)

两个用例唯一的区别:用例 1 里,事务 B 在事务 A 修改前过早地做过一次快照读,致使它的 ReadView 被"定格"在了旧时刻;用例 2 里,事务 B 的第一次快照读发生在 A 提交之后,ReadView 是新的。仅此而已,却让两次快照读的结果天差地别。结论一句话:某个事务里首次出现快照读的位置,决定了该事务后续所有快照读能看到什么。 delete、update、insert 同理。

于是,RR 与 RC 的本质区别就是:

  • RR(可重复读):同一个事务里的第一个快照读才创建 Read View,之后的快照读始终复用同一个 Read View。凡是在这个 ReadView 生成之后才提交的改动,本事务一概不可见——所以它"可重复读";
  • RC(读提交):事务里的每一次快照读都会新生成一个 Read View、重新照一张当前的"活跃快照"。于是每当别的提交了最新值,下一次 select 就能看到——所以它"不可重复读"。

正是"RC 每次快照读都重新形成 ReadView",导致 RC 有不可重复读问题;而 RR 固定一次 ReadView,杜绝了这种变化。

到这里,把前面那张并发场景图也收个尾:

  • 读-读:天然无冲突,不需要并发控制,MVCC 下更是顺畅;
  • 读-写:靠 MVCC 的快照读优雅解决,读写无阻塞;
  • 写-写:现阶段你直接理解成"都是当前读、都要加锁排队",互相写必然冲突,要靠行锁/两阶段锁去协调,这也是更新丢失的根源(MVCC 解决不了它,得靠锁)。

现状与坑:把容易踩的边界汇总

课件写到这里基本收官,我再把散落在全程里的"边界与坑"归拢成一份"避雷清单",每一句都是踩过的教训:

  1. MySQL 默认隔离级别是可重复读(REPEATABLE READ),与 Oracle/PostgreSQL 的默认读提交不同。记住这一差异,能解释大量"为啥我这边和教程表现不一样"。
  2. 只有 InnoDB 支持事务。建表务必 ENGINE=InnoDB;MyISAM 表上讨论事务没有意义。
  3. 8.0 开始 tx_isolation 废弃,改用 transaction_isolation;旧教程抄来的命令在 8.0 会打 warning,别被吓到。
  4. SET AUTOCOMMIT=0 只影响当前会话,新连接回到 ON;不要指望全局持久。
  5. 只要 begin/start transaction 了,事务就必须靠显式 commit 才持久化,与 autocommit 无关。
  6. 未 commit 就被崩溃/断连的事务会被自动回滚——数据库不会为"半途而废"的事务买单。
  7. commit 之后不可 rollback;需要"回退一段"时,用保存点 savepoint + rollback to。
  8. 读提交/可重复读这些"读"的行为,取决于当前读还是快照读:快照读慢、但要for update/lock in share mode变当前读、无需理会;普通 select 在 RR 下是快照读,在 for update 下是当前读。
  9. MySQL 在可重复读下其实挡住了幻读(靠 Next-Key 锁 + MVCC),这是它区别于"教科书经典可重复读会幻读"的最大差异点。
  10. 隔离级别越高并发越低:串行化基本不可用,读未提交基本不可用,多数生产建议优先用默认的"可重复读",需要更高并发且能接受轻微不可重复读时,再权衡改用读提交。
  11. 一致性(C)本质要靠你的业务逻辑守卫,数据库只保证"技术上的要么全生效、要么全不生效";把"余额不为负"这类约束写进 SQL 约束或业务代码,才是真的守住一致性。
  12. 线上 set global transaction isolation level 会影响全库所有新会话,谨慎用之,临时测试改 session 级别。

几个值得再喝一壶的思考题

课件本身抛了不少好问题,我挑最有价值的几个,连同详解答案一起给你,方便你自测完立刻对答案:

  1. 为什么 MyISAM 引擎用不了事务? 答:事务的原子性和持久性依赖一套"内存缓冲 + 日志 + 崩溃恢复"机制(redo log 保证崩溃后重放、undo log 保证回滚、两阶段提交维护一致性)。InnoDB 自带这套全套日志与恢复机制、并原生支持行级锁,因此能实现事务;MyISAM 是早期主打"简单快速、全表级锁"的引擎,既没有这些日志,也没有行级锁和崩溃恢复,自然不支持事务。这也是建表默认都选 InnoDB 的根本原因。

  2. autocommit=ON 时,我用了 begin,还用不用手动 commit? 答:要。begin/start transaction 显式把会话切到"手动事务模式",此时该事务内部的改动与 autocommit 无关,**必须显式 commit(或 rollback)**才会落盘/撤销。只有那些"没写 begin、autocommit 又是 ON"的 SQL,才会被自动逐条提交。所以"begin 之后忘 commit"是超高频事故——数据在自己的事务里看着在,一崩就没了,别期待数据库替你兜底。

  3. 脏读、不可重复读、幻读,到底怎么一句话区分,分别被哪个级别防住? 答:一句话版:脏读是"读到别人没提交的";不可重复读是"同样的行,值两次读不一样(改/删引起)";幻读是"同样的范围,记录数两次读不一样(新增引起)"。防住情况:读未提交全不防;读提交防脏读、不防不可重复读和幻读;可重复读防脏读和不可重复读(InnoDB 下连幻读也防);串行化全防。记忆锚点:脏读看"未提交",不可重复读看"值变",幻读看"多行"。

  4. MySQL 为什么默认可重复读,而不是像 Oracle 那样默认读提交? 答:可从两个角度理解。历史与兼容角度:MySQL 从早期就定下 RR 为默认,靠 MVCC + Next-Key 锁在"保证稳定一致视角"的同时保持不错并发,改动默认值会导致既有应用行为大变;能力角度:InnoDB 的 RR 并不像教科书说的那样留下幻读漏洞(它用 Next-Key 锁 + MVCC 一并堵掉了幻读),所以 RR 在 InnoDB 上其实是"高安全 + 可接受并发"的实惠档位,没必要降级。如果哪天你需要"更贴近实时最新、能接受事务内轻微不一致",才考虑显式改到 RC。

  5. RR 级别下,为什么我在备份读到的数据,跟另一个同时正在改数据的事务看到的"最新值"不一样? 答:因为在 RR 下,你的普通 select 是快照读,它读的是"你事务第一次快照读那一刻固定下来的 Read View",而非实时最新版本;而对方"改数据"是当前读,作用在版本链最新版本上、还要加锁。于是"你读旧快照"和"他写新值"两者并行不冲突。直到你结束当前事务、新开一个事务,新的 Read View 才会包含别人的新提交。这正是 MVCC 通过"读写分离版本、互不阻塞"来兼顾并发与隔离的体现。

  6. RC 和 RR 同是快照读,为什么 RC 会不可重复读、RR 不会? 答:差在 Read View 的生成时机。RC 是每次快照读都重新生成一个 ReadView,所以别的事务每次提交新值,你下一次 select 就换用新视图、看到新值,同一事务两次读到不同值(不可重复读);RR 是同一个事务的第一个快照读才生成 ReadView,之后复用同一个,之后别人提交再多,你这个事务都只看那个固定的旧视图,所以稳定可重复。一句话:RC"每次重新照镜子",RR"照镜一次、用到事务结束"。

  7. undo log 到底解决了哪两件事? 答:两件事。(1)支撑回滚:事务中途出错或有保存点时,从 undo log 里的历史版本反向覆盖当前数据,实现 rollback/rollback to;(2)支撑 MVCC 快照读:版本链上的旧版本都存在于 undo log,快照读通过 DB_ROLL_PTR 回溯到可见的旧版本,实现读写不阻塞和稳定的隔离视图。可以说 undo log 同时是"回滚的后备"和"多版本的仓库"。

  8. 为什么 insert 不参与版本链,update/delete 才参与? 答:因为 insert 之前"这条数据不存在",它没有历史版本可以回溯(首次出现即最新,回滚只需把它本身删掉,undo log 存一条"反向操作"即可)。而 update/delete 都作用在"已存在的记录"上,修改/删除前旧值需要留下档,才能供回滚和并发快照读回溯,于是它们会形成历史版本链。这也是为什么说"update 和 delete 可以形成版本链,insert 暂时不算"。delete 能形成版本链的另一关键,是它并非物理清除,而是标删除标志、旧版仍留在链上。

  9. 当前读和快照读有什么区别?什么场景会用到当前读? 答:当前读读"最新记录"、必须加锁(读加共享锁、写加排他锁),用于需要拿到最新值并防止并被并发改动的操作,如 insert/update/delete,或显式 select ... for update/select ... lock in share mode;快照读读"可见的历史版本"、不加锁、可与写并行,用于普通 select(在支持 MVCC 的隔离级别下)。业务上,"事务内我要查到的就是此刻最准的数据并可能马上改"用当前读(for update 顺便可当悲观锁防超卖/防重复更新);"只要稳定的历史视图、不影响别人"用普通快照读即可。

  10. 怎么验证一个事务是否真正被"持久化"?有什么简短实验? 答:最简单的实验——在两个终端连接同一库,终端 A begin 后插入一条数据然后强制断连(不 commit),回到终端 B 查询:数据没有,说明未被持久化,事务被自动回滚(原子性/未提交不生效);再让终端 A 重新 begin→insert→commit 后又断连,这时候回到终端 B 查询:数据存在,说明 commit 已把改动落盘(持久性生效)。同样实验还可以换一种切法:commit 过一次后,在终端 A 里再 rollback,会发现无论怎么回滚,已经提交的那条数据都还在——印证"commit 后不可回滚"。

参考资料与推荐阅读

这块内容很经典也很深,值得反复精读。我列几份你看得懂的参考资料,配合本文一起消化效果更佳:


写到这里,我们把 MySQL 事务这条线完整地捋了一遍。你如今知道了事务的定义——一组逻辑相关的 SQL,要么全成功要么全失败;弄清了 ACID 四兄弟各自的分工,尤其记住了"原子性、隔离性、持久性用技术兜底,而一致性最终要靠你的业务逻辑去捍卫";亲手用 begin/savepoint/rollback/commit 感受了提交与回滚,也明白了"没提交就崩会自动回滚、提交了就永久有效"这个分水岭。随后我们闯进了四个隔离级别的世界,认清脏读、不可重复读、幻读各长什么样,明白了为什么 MySQL 默认可重复读、为什么它在可重复读下连幻读都能一并挡住。最后深挖到 MVCC 的内核:三个隐藏字段、undo log 撑起的版本链、Read View 的可见性判断——以及那个决定一切的 Read View 生成时机,它正是 RR 与 RC 本质差异、也是快照读得以"读写不互斥"的秘密。

这一大套东西看着多,但你只要抓住三根主线就不迷路:原子性/持久性看"日志 + commit",隔离性看"锁 + MVCC + 隔离级别",一致性看"业务约束 + AID 保障"。把那些思考题亲手在本地 MySQL 上跑一遍、每个隔离级别都开两个终端各查一遍,比看十遍讲义都顶用。

如果你把我推荐的参考资料也顺着翻一翻,数据库并发这一片,你就已经不是"会用",而是"懂原理"了。下一站,可以接着往左钻 MySQL 的索引与执行计划,看看查询又是怎么被一层层优化的——准备好了吗?