11. 事务【由浅入深-MySQL】

文章目录

  • [第一章 事务初探 ------ 从数据混乱到原子操作](#第一章 事务初探 —— 从数据混乱到原子操作)
    • [1.1 失控的 CRUD:当数据操作失去"底线"](#1.1 失控的 CRUD:当数据操作失去“底线”)
    • [1.2 事务的庐山真面目:一组 DML 的逻辑集合](#1.2 事务的庐山真面目:一组 DML 的逻辑集合)
      • [ACID 四大铁律](#ACID 四大铁律)
    • [1.3 事务诞生的意义:为应用层"减负"](#1.3 事务诞生的意义:为应用层“减负”)
    • [1.4 硬件基础:存储引擎与提交方式](#1.4 硬件基础:存储引擎与提交方式)
      • [1.4.1 引擎对事务的支持差异](#1.4.1 引擎对事务的支持差异)
      • [1.4.2 事务的提交方式](#1.4.2 事务的提交方式)
    • [1.5 实战环境搭建:隔离级别与连接误区](#1.5 实战环境搭建:隔离级别与连接误区)
      • [1.5.1 透彻理解隔离级别的设置与生效(图解准备)](#1.5.1 透彻理解隔离级别的设置与生效(图解准备))
      • [1.5.2 关于数据库连接的实用提醒](#1.5.2 关于数据库连接的实用提醒)
  • [第二章 事务常见操作方式](#第二章 事务常见操作方式)
    • [2.1 环境准备与测试表创建](#2.1 环境准备与测试表创建)
    • [2.2 隔离级别预设](#2.2 隔离级别预设)
    • [2.3 事务的基本操作:BEGIN、COMMIT 与 ROLLBACK](#2.3 事务的基本操作:BEGIN、COMMIT 与 ROLLBACK)
      • [2.3.1 开始事务](#2.3.1 开始事务)
      • [2.3.2 提交事务](#2.3.2 提交事务)
      • [2.3.3 回滚事务](#2.3.3 回滚事务)
    • [2.4 保存点机制:定向回滚](#2.4 保存点机制:定向回滚)
      • [2.4.1 创建保存点](#2.4.1 创建保存点)
      • [2.4.2 回滚到保存点](#2.4.2 回滚到保存点)
      • [2.4.3 释放保存点](#2.4.3 释放保存点)
      • [2.4.4 保存点完整演示](#2.4.4 保存点完整演示)
    • [2.5 无保存点的回滚](#2.5 无保存点的回滚)
    • [2.6 事务结束后的不可回滚性](#2.6 事务结束后的不可回滚性)
    • [2.7 异常退出与自动回滚机制](#2.7 异常退出与自动回滚机制)
      • [2.7.1 未提交事务 + 异常退出 → 自动回滚](#2.7.1 未提交事务 + 异常退出 → 自动回滚)
      • [2.7.2 已提交事务 + 异常退出 → 数据持久化](#2.7.2 已提交事务 + 异常退出 → 数据持久化)
      • [2.7.3 `Ctrl + \` 的含义](#2.7.3 Ctrl + \ 的含义)
    • [2.8 autocommit 与显式事务的关系](#2.8 autocommit 与显式事务的关系)
      • [2.8.1 `autocommit` 默认行为](#2.8.1 autocommit 默认行为)
      • [2.8.2 显式 BEGIN 覆盖 autocommit](#2.8.2 显式 BEGIN 覆盖 autocommit)
    • [2.9 单条 SQL 的事务行为](#2.9 单条 SQL 的事务行为)
      • [2.9.1 `autocommit = ON` 时(默认)](#2.9.1 autocommit = ON 时(默认))
      • [2.9.2 `autocommit = OFF` 时](#2.9.2 autocommit = OFF 时)
    • [2.10 多语句事务:必须显式 BEGIN 和 COMMIT](#2.10 多语句事务:必须显式 BEGIN 和 COMMIT)
  • [补充:MySQL 中大多数 DDL 语句会导致当前事务被隐式提交。](#补充:MySQL 中大多数 DDL 语句会导致当前事务被隐式提交。)
  • [第三章 事务隔离级别](#第三章 事务隔离级别)
    • [3.1 隔离性的必要性](#3.1 隔离性的必要性)
    • [3.2 隔离级别概览](#3.2 隔离级别概览)
      • [3.2.1 读未提交(READ UNCOMMITTED)](#3.2.1 读未提交(READ UNCOMMITTED))
      • [3.2.2 读已提交(READ COMMITTED)](#3.2.2 读已提交(READ COMMITTED))
      • [3.2.3 可重复读(REPEATABLE READ)](#3.2.3 可重复读(REPEATABLE READ))
      • [3.2.4 可串行化(SERIALIZABLE)](#3.2.4 可串行化(SERIALIZABLE))
    • [3.3 隔离级别的查看与设置](#3.3 隔离级别的查看与设置)
      • [3.3.1 查看当前隔离级别](#3.3.1 查看当前隔离级别)
      • [3.3.2 设置隔离级别](#3.3.2 设置隔离级别)
    • [3.4 读未提交:脏读演示](#3.4 读未提交:脏读演示)
    • [3.5 读已提交:避免脏读,但不可重复读](#3.5 读已提交:避免脏读,但不可重复读)
      • [3.5.1 避免脏读验证](#3.5.1 避免脏读验证)
      • [3.5.2 不可重复读的典型场景](#3.5.2 不可重复读的典型场景)
      • [3.5.3 不可重复读的业务影响](#3.5.3 不可重复读的业务影响)
    • [3.6 可重复读:MySQL 默认隔离级别](#3.6 可重复读:MySQL 默认隔离级别)
      • [3.6.1 可重复读演示](#3.6.1 可重复读演示)
      • [3.6.2 幻读问题与 MySQL 的解决方案](#3.6.2 幻读问题与 MySQL 的解决方案)
    • [3.7 可串行化:最高的隔离级别](#3.7 可串行化:最高的隔离级别)
      • [3.7.1 可串行化演示](#3.7.1 可串行化演示)
      • [3.7.2 串行化的适用场景](#3.7.2 串行化的适用场景)
    • [3.8 不同事务操作是否"先到先执行"?](#3.8 不同事务操作是否“先到先执行”?)
    • [3.9 隔离级别与读写并发](#3.9 隔离级别与读写并发)
    • [3.10 一致性:由原子性、隔离性、持久性共同保证](#3.10 一致性:由原子性、隔离性、持久性共同保证)
  • [第四章 MVCC 内核剖析:隐藏字段、Undo Log 与 Read View 可见性判断](#第四章 MVCC 内核剖析:隐藏字段、Undo Log 与 Read View 可见性判断)
    • [4.1 事务的底层管理模型](#4.1 事务的底层管理模型)
    • [4.2 数据表的"隐形铠甲"------三大隐藏字段](#4.2 数据表的“隐形铠甲”——三大隐藏字段)
    • [4.3 Undo Log:从"写时拷贝"到版本链](#4.3 Undo Log:从“写时拷贝”到版本链)
      • [4.3.1 Undo Log 的存储形态与生命周期](#4.3.1 Undo Log 的存储形态与生命周期)
      • [4.3.2 版本链的生成过程](#4.3.2 版本链的生成过程)
    • [4.4 Read View:事务的快照"滤镜"](#4.4 Read View:事务的快照“滤镜”)
      • [4.4.1 Read View 的核心结构与关键字段](#4.4.1 Read View 的核心结构与关键字段)
      • [4.4.2 可见性判断源码逐行解析](#4.4.2 可见性判断源码逐行解析)
    • [4.5 隔离级别与 Read View 生成策略的本质差异](#4.5 隔离级别与 Read View 生成策略的本质差异)
    • [4.6 实战验证:RC 与 RR 下快照读 vs 当前读的差异](#4.6 实战验证:RC 与 RR 下快照读 vs 当前读的差异)
      • [4.6.1 测试用例 1](#4.6.1 测试用例 1)
      • [4.6.2 测试用例 2](#4.6.2 测试用例 2)
    • 补充:当前读
    • [4.7 写-写冲突与 MVCC 的局限性](#4.7 写-写冲突与 MVCC 的局限性)

第一章 事务初探 ------ 从数据混乱到原子操作

在数据库的世界里,数据本身是静态的,而对数据的"增删改查"(CRUD)操作才是赋予数据生命力的动态行为。然而,当我们在多用户、高并发的真实生产环境中不加任何控制地执行这些操作时,往往会陷入数据错乱、逻辑断裂的泥潭。本章将从底层痛点出发,深入剖析事务这一核心机制是如何解决这些问题的。

1.1 失控的 CRUD:当数据操作失去"底线"

在日常开发中,我们习惯性地执行一条条 SQL 语句,但很少去思考:如果这些语句在执行过程中突然中断,或者同时有其他客户端在修改相同的数据,会发生什么?

假设我们模拟一个经典的在线购票 业务场景,数据库中有 ticket 表记录剩余票数,order 表记录订单信息。一次购票操作在应用程序中可能对应以下两条 SQL:

sql 复制代码
-- 1. 插入订单记录
INSERT INTO orders (user_id, ticket_id, amount) VALUES (1, 100, 1);
-- 2. 扣减库存
UPDATE tickets SET stock = stock - 1 WHERE id = 100;

如果不加任何控制,至少会面临以下几类严重问题:

  1. 原子性被破坏(执行中断):假设第 1 条插入语句成功执行,但在执行第 2 条扣减库存时,数据库发生了宕机或网络超时。此时,用户付了钱(订单生成),但票却没扣减成功,或者反过来,票扣了但订单没生成。业务逻辑出现了无法自洽的断裂。

  2. 隔离性被破坏(并发干扰):如果此时另一个客户端也在抢购同一张票,它同样在执行上述两条 SQL。由于两个流程交叉执行,很可能出现"超卖"现象------两个客户端都读到了剩余的 1 张票,都去扣减,最终库存变为负数。

  3. 持久性的缺失:如果购票成功后,数据库还没来得及将内存中的变更刷新到磁盘文件,系统突然断电,那么这笔刚刚成功的交易数据就会彻底丢失。

  4. 一致性的偏离:在上述三种异常的共同作用下,数据库最终呈现的数据状态是不符合业务规则的(例如:订单存在但无票可兑,或总销售额与实际扣减不匹配)。

由此可见,单纯的 CRUD 语句堆砌,在真实复杂的系统环境中是极其脆弱的。要解决上述问题,我们必须为一组逻辑相关的操作赋予特定的"安全属性"。而这些属性,正是事务(Transaction)的核心价值所在。

1.2 事务的庐山真面目:一组 DML 的逻辑集合

事务(Transaction) 是指由一条或多条 DML(数据操纵语言,Data Manipulation Language) 语句组成的一个逻辑工作单元。这些语句在业务上具有紧密的相关性,它们作为一个整体提交给数据库系统执行。

事务的本质规定 :这一组 DML 语句要么全部成功(提交),要么全部失败(回滚),绝不允许存在"执行一半"的中间状态。

为了加深理解,我们再看一个教务系统删除毕业生的例子。如果要删除一名学生的全部痕迹,至少需要执行如下操作:

sql 复制代码
-- 1. 删除基本信息
DELETE FROM students WHERE student_id = 2021001;
-- 2. 删除成绩记录
DELETE FROM scores WHERE student_id = 2021001;
-- 3. 删除考勤记录
DELETE FROM attendance WHERE student_id = 2021001;
-- 4. 删除论坛帖子
DELETE FROM posts WHERE author_id = 2021001;

这些 SQL 虽然在语法上独立,但在业务逻辑上高度耦合。只有将它们包裹在同一个事务中,才能确保学生数据被彻底"抹除",而不会留下一个"有成绩但无姓名"的孤儿数据。

ACID 四大铁律

一个完整且严格的事务,必须同时满足以下四个属性,简称 ACID:

  • 原子性(Atomicity)

    这是事务最鲜明的特征。事务被视为不可分割的最小执行单元 。如果事务执行过程中发生任何错误(如语法错误、死锁、服务器崩溃),MySQL 会利用 Undo Log(回滚日志) 将已执行的操作逆向恢复,将数据回滚到事务开始前的原始状态。就像这个事务从未发生过一样。

  • 一致性(Consistency)

    一致性是事务追求的最终目标 。它要求事务执行前后,数据库必须处于"正确"的状态,即所有数据必须符合定义的约束规则(主键、外键、唯一索引、自定义 CHECK 约束等)。

    需要注意的是,一致性是应用层和数据库层共同的责任。数据库通过原子性、隔离性和持久性来辅助达成一致性 ,但如果在事务中写入了违反业务逻辑的非法数据(例如向 age 字段插入 -10,但该字段没有检查约束),事务依然可以提交,此时一致性由业务代码保证。

  • 隔离性(Isolation)

    隔离性用于处理并发 场景。当多个事务同时对同一张表或同一行数据进行读写时,隔离性保证它们之间不会相互干扰,防止产生脏读、不可重复读、幻读等问题。 MySQL 提供了四种隔离级别(读未提交、读已提交、可重复读、串行化),隔离性越强,并发性能越低,反之则越高。

  • 持久性(Durability)

    一旦事务被成功提交(COMMIT),它对数据库所做的修改就应该是永久性的。即使此时数据库系统立即崩溃或宕机,重启后,已提交的数据也必须完整存在 。MySQL 的 InnoDB 引擎通过 Redo Log(重做日志) 来保证持久性:事务提交时,日志先行落盘,即使内存数据页未刷新,重启后也能根据 Redo Log 恢复。

1.3 事务诞生的意义:为应用层"减负"

很多初学者会疑惑:"事务机制这么复杂,数据库底层做了这么多日志、锁、MVCC 的工作,为什么不让开发人员自己写代码来处理异常和并发呢?"

事务被设计出来,本质上是数据库向应用层提供的一套高级抽象模型。

设想一下,如果没有事务,当我们在 Java 或 Python 代码中执行 CRUD 时,我们需要显式地处理以下无数种异常场景:

  • 网络突然闪断,SQL 执行超时怎么办?
  • 服务器磁盘满了,写入失败怎么办?
  • 两个线程同时修改一行数据,需要手动加分布式锁或版本号比对吗?
  • 执行到一半,业务校验不通过,如何干净地撤销前面已经执行成功的 SQL?

如果没有事务,上述每一个问题都需要开发者编写大量的异常捕获、补偿逻辑和并发控制代码,这将导致业务代码极度臃肿且错误率极高。

而有了事务,我们的编程模型变得极其简单而优雅:

python 复制代码
try:
    start_transaction()       # 开启事务
    execute_sql_1()           # 执行 SQL 1
    execute_sql_2()           # 执行 SQL 2
    execute_sql_3()           # 执行 SQL 3
    commit()                  # 提交:全部永久生效
except Exception as e:
    rollback()                # 回滚:所有修改全部撤销

我们只需要关注 "提交(Commit)" 或 "回滚(Rollback)" 这两个动作,中间发生的任何底层错误、并发冲突,都由数据库引擎代为处理。这就是事务为应用层带来的巨大简化。

1.4 硬件基础:存储引擎与提交方式

事务并不是 MySQL 天生自带的特性,它高度依赖于底层的存储引擎(Storage Engine)。

1.4.1 引擎对事务的支持差异

在 MySQL 中, InnoDB 引擎支持事务,而 MyISAM 引擎不支持。我们可以通过命令查看当前数据库支持的引擎及其事务属性:

存储引擎 是否支持事务 说明
InnoDB ✅ 支持 MySQL 默认引擎,支持完整 ACID、行级锁、外键、MVCC
NDB Cluster ✅ 支持 用于 MySQL 集群环境,也支持事务
TokuDB(已较少使用) ✅ 支持 曾支持事务
MyISAM ❌ 不支持 不支持事务、回滚、崩溃恢复
Memory ❌ 不支持 数据存储在内存中,不支持事务
sql 复制代码
SHOW ENGINES \G

执行结果(关键字段截取)如下:

复制代码
*************************** 1. row ***************************
Engine: InnoDB
Support: DEFAULT
Comment: Supports transactions, row-level locking, and foreign keys
Transactions: YES      -- 关键点:支持事务
XA: YES
Savepoints: YES        -- 支持保存点(嵌套事务回滚)
*************************** 5. row ***************************
Engine: MyISAM
Support: YES
Comment: MyISAM storage engine
Transactions: NO       -- 关键点:不支持事务
XA: NO
Savepoints: NO

在实际生产选型中,如果业务对数据一致性有要求(绝大多数业务都是如此),必须选择 InnoDB 引擎。如果在 MyISAM 表上执行事务操作(如 START TRANSACTION 后回滚),MySQL 并不会报错,但回滚不会生效------数据依然会被提交,这是非常危险的陷阱。

1.4.2 事务的提交方式

MySQL 的事务提交方式分为两种:

  1. 自动提交(Autocommit ON)

    这是 MySQL 的默认模式。在这种模式下,每一条单独的 SQL 语句都会被当作一个独立的事务 ,执行成功后系统自动执行 COMMIT,无需手动干预。

  2. 手动提交(Autocommit OFF)

    关闭自动提交后,必须显式地执行 COMMIT 语句才能使修改生效,或者执行 ROLLBACK 撤销修改。

查看当前会话的提交方式:

sql 复制代码
SHOW VARIABLES LIKE 'autocommit';

输出如下:

复制代码
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit    | ON    |
+---------------+-------+

使用 SET 命令可以动态修改当前会话的提交模式(注意:这仅对当前客户端连接有效,不影响其他会话):

sql 复制代码
-- 关闭自动提交(改为手动提交模式)
SET AUTOCOMMIT = 0;
SHOW VARIABLES LIKE 'autocommit';  -- 显示 OFF

-- 开启自动提交(恢复默认)
SET AUTOCOMMIT = 1;
SHOW VARIABLES LIKE 'autocommit';  -- 显示 ON

常见误区 :MySQL 默认开启 autocommit,每条 DML 语句都会自动开启并提交一个事务;关闭 autocommit 后,事务不会自动提交,需要显式执行 COMMIT 或 ROLLBACK。但两种情况下执行的 SQL 都属于事务,只是事务边界不同。

1.5 实战环境搭建:隔离级别与连接误区

在正式开始后续的事务隔离性实验之前,我们需要做好数据库客户端的准备工作。这里涉及两个关键的技术细节:全局隔离级别的生效时机 以及 远程连接的权限误区。

1.5.1 透彻理解隔离级别的设置与生效(图解准备)

在事务的四大属性中,"隔离性"是最复杂、最需要动手实验的一环。在实验开始前,我们需要先设定当前 MySQL 服务的隔离级别。

隔离级别分为四档,由低到高分别是:

  • READ UNCOMMITTED(读未提交)
  • READ COMMITTED(读已提交)
  • REPEATABLE READ(可重复读,InnoDB 默认)
  • SERIALIZABLE(串行化)

设置隔离级别的语法为:

sql 复制代码
-- 设置全局级别(影响后续新建的连接)
SET GLOBAL TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

-- 设置会话级别(仅影响当前连接)
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
查看对象 SQL
全局隔离级别 SELECT @@global.transaction_isolation;
当前连接隔离级别 SELECT @@session.transaction_isolation;
简写当前连接 SELECT @@transaction_isolation;

在上述提供的图 1 与图 2 的操作日志中,我们看到一个非常典型且容易令初学者困惑的细节。我们分步拆解:

第一步(图1初始状态): 管理员执行了 SET GLOBAL TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;,这条 SQL 返回 Query OK,说明语法无误且执行成功。

第二步(图1的困惑点): 紧接着在当前会话执行 SELECT @@tx_isolation;,结果显示的却是 REPEATABLE-READ。

为什么会这样?

这是 MySQL 隔离级别设置中最基础的陷阱之一:GLOBAL 关键字只对新建立的连接生效,对当前已经存在的连接(会话)没有任何影响。 当前的客户端会话是在执行 SET GLOBAL 之前就连接上服务器的,因此它的隔离级别依然保持着服务器启动时默认的 REPEATABLE-READ。

第三步(图2的验证): 退出 后,重新建立了 MySQL 连接。此时,新会话应用了全局配置,再次查询 @@tx_isolation,结果成功变成了 READ-UNCOMMITTED。

知识扩展: 在较新版本的 MySQL(8.0+)中,@@tx_isolation 变量已被弃用,应改用 @@transaction_isolation。在 5.7 版本中我们依然使用 @@tx_isolation,这一点在跨版本维护时需特别注意。

1.5.2 关于数据库连接的实用提醒

在实际开发中,我们通常通过 IP 和端口远程连接数据库服务器,例如:

bash 复制代码
mysql -h 192.168.1.100 -P 3306 -u root -p

这里需要明确一个重要的安全概念:"网络能连通(ping 通)"绝不等于"数据库能登录成功" 。即使防火墙放行了 3306 端口,且 telnet 测试端口是开放的,登录时依然会失败,常见的失败原因包括:

  1. 用户权限限制 :MySQL 的 user 表中的 host 字段限制了客户端来源。如果 root 用户的 host 是 localhost,那么从远程 IP 发起的连接会被直接拒绝,报 Access denied。
  2. 密码策略或认证插件不匹配 :MySQL 8.0 默认使用 caching_sha2_password 插件,而旧客户端可能期望 mysql_native_password。
  3. 最大连接数耗尽 :如果服务器当前的 max_connections 被占满,即使认证信息正确,也会被服务器拒绝连接。

因此,在排查数据库问题时,请务必区分"网络层可达"和"应用层认证通过"这两个截然不同的阶段。在本章的后续实验中,我们将默认使用本地 localhost 或具有正确远程授权(%)的账户进行操作。

第二章 事务常见操作方式

在上一章中,我们了解了事务的 ACID 特性以及 InnoDB 对事务的支持情况。本章将进入实战环节,通过一个典型的银行账户表,详细演示事务的各类操作方式,包括事务的开始、提交、回滚、保存点使用,以及各种边界场景下的行为表现。理解这些操作细节,是正确编写事务型应用程序的基础。

2.1 环境准备与测试表创建

为了便于演示事务的各种操作,我们首先创建一个简单的银行用户账户表。该表结构简洁,包含用户 ID、姓名和账户余额三个字段,足以覆盖事务操作的核心场景。

sql 复制代码
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=UTF8;

存储引擎说明 :此处显式指定 ENGINE=InnoDB,因为 InnoDB 是 MySQL 中支持事务的存储引擎,而 MyISAM 不支持事务。如果使用 MyISAM 引擎,事务操作将不会产生预期效果。

环境连接说明:演示环境为一台 CentOS 7 云服务器,MySQL 服务运行在默认端口 3306。通过 Windows 客户端的 mysql 命令行工具远程连接服务器,便于观察多会话场景下的行为表现。

2.2 隔离级别预设

为了在演示过程中能够在一个会话中看到另一个会话未提交的数据变更(即"脏读"现象),从而更清晰地观察事务操作的中间状态,我们需要将事务隔离级别设置为 READ UNCOMMITTED(读未提交)。

sql 复制代码
-- 设置全局隔离级别为读未提交(需要相应权限)
SET GLOBAL TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

-- 重新连接后查看当前隔离级别
SELECT @@tx_isolation;

执行结果:

复制代码
+------------------+
| @@tx_isolation   |
+------------------+
| READ-UNCOMMITTED |
+------------------+

2.3 事务的基本操作:BEGIN、COMMIT 与 ROLLBACK

在 MySQL 中,一个事务的生命周期通常包含三个核心操作:开始事务、提交事务、回滚事务。

2.3.1 开始事务

MySQL 提供了两种方式显式地开始一个事务:

sql 复制代码
-- 方式一:使用 START TRANSACTION
START TRANSACTION;

-- 方式二:使用 BEGIN(推荐写法)
BEGIN;

两种方式在功能上完全等价,但 BEGIN 关键字书写更简洁,在日常开发中使用更为广泛。

2.3.2 提交事务

当事务中的所有操作执行成功,需要将变更永久保存到数据库中时,使用 COMMIT 提交事务:

sql 复制代码
COMMIT;

提交后,事务的变更将持久化到磁盘,对其他会话可见,且不可再回滚。

2.3.3 回滚事务

当事务执行过程中发生逻辑错误或业务异常,需要撤销当前事务中所有未提交的变更时,使用 ROLLBACK 回滚事务:

sql 复制代码
ROLLBACK;

回滚后,事务从开始到回滚点之间的所有数据变更将被撤销,数据库恢复到事务开始之前的状态。

2.4 保存点机制:定向回滚

保存点(Savepoint)是事务操作中一个非常实用的特性,它允许在事务内部设置标记点,从而实现在不撤销整个事务的情况下,只回滚到事务中的某个特定位置。

2.4.1 创建保存点

使用 SAVEPOINT 语句可以在事务的当前执行位置创建一个命名保存点:

sql 复制代码
SAVEPOINT savepoint_name;

保存点的命名应当具有业务含义,便于后续识别和回滚。

2.4.2 回滚到保存点

使用 ROLLBACK TO 语句可以将事务回滚到指定的保存点位置:

sql 复制代码
ROLLBACK TO savepoint_name;

语法注意事项 :ROLLBACK TO savepoint_name 是正确的语法格式。新手常犯的错误是写成 ROLLBACK savepoint_name(省略 TO 关键字),这会触发语法错误。在演示过程中,执行 ROLLBACK s3 会收到 ERROR 1064 (42000) 的语法错误提示,而正确写法是 ROLLBACK TO s3。

2.4.3 释放保存点

当保存点不再需要时,可以使用 RELEASE SAVEPOINT 语句将其删除:

sql 复制代码
RELEASE SAVEPOINT savepoint_name;

释放后的保存点将无法再用于回滚。

2.4.4 保存点完整演示

下面通过一个完整示例展示保存点的使用过程。该示例向 account 表中连续插入三条记录,并在每个插入点设置保存点,然后回滚到第二个保存点。

步骤 1:开始事务并设置保存点

sql 复制代码
-- 查看当前 autocommit 状态
SHOW VARIABLES LIKE 'autocommit';

执行结果:

复制代码
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit    | ON    |
+---------------+-------+
sql 复制代码
-- 开始事务
START TRANSACTION;

-- 创建保存点 s1(事务起始位置)
SAVEPOINT s1;

-- 插入第一条记录:张三
INSERT INTO account VALUES (1, '张三', 1234.50);

-- 创建保存点 s2(插入张三之后)
SAVEPOINT s2;

-- 插入第二条记录:李四
INSERT INTO account VALUES (2, '李四', 4321.60);

-- 创建保存点 s3(插入李四之后)
SAVEPOINT s3;

-- 插入第三条记录:王五
INSERT INTO account VALUES (3, '王五', 3321.60);

此时表中有三条记录:

sql 复制代码
SELECT * FROM account;
复制代码
+----+--------+---------+
| id | name   | blance  |
+----+--------+---------+
|  1 | 张三   | 1234.50 |
|  2 | 李四   | 4321.60 |
|  3 | 王五   | 3321.60 |
+----+--------+---------+

步骤 2:回滚到指定保存点

sql 复制代码
-- 回滚到保存点 s3(撤销王五的插入)
ROLLBACK TO s3;

SELECT * FROM account;
复制代码
+----+--------+---------+
| id | name   | blance  |
+----+--------+---------+
|  1 | 张三   | 1234.50 |
|  2 | 李四   | 4321.60 |
+----+--------+---------+

此时王五的记录已被撤销,但张三和李四的记录仍然存在。

步骤 3:继续回滚到更早的保存点

sql 复制代码
-- 回滚到保存点 s1(撤销张三和李四的插入)
ROLLBACK TO s1;

SELECT * FROM account;
复制代码
Empty set (0.00 sec)

此时所有插入的记录都被撤销,表恢复到事务开始前的空状态。

步骤 4:完整回滚事务

sql 复制代码
-- 将事务完全回滚到开始状态(等同于回滚到事务起点)
ROLLBACK;

SELECT * FROM account;
复制代码
Empty set (0.00 sec)

关键理解 :回滚到保存点时,该保存点之后创建的所有保存点将被自动释放,且该保存点之后执行的所有数据变更均被撤销,但该保存点本身及其之前的数据变更则被保留。

2.5 无保存点的回滚

如果在事务执行过程中没有设置任何保存点,执行 ROLLBACK 将直接回滚整个事务,撤销事务中所有的数据变更。

sql 复制代码
-- 开始事务
START TRANSACTION;

-- 执行三条插入操作
INSERT INTO account VALUES (1, '张三', 1234.50);
INSERT INTO account VALUES (2, '李四', 4321.60);
INSERT INTO account VALUES (3, '王五', 3321.60);

-- 此时三条记录均存在于当前会话
SELECT * FROM account;
复制代码
+----+--------+---------+
| id | name   | blance  |
+----+--------+---------+
|  1 | 张三   | 1234.50 |
|  2 | 李四   | 4321.60 |
|  3 | 王五   | 3321.60 |
+----+--------+---------+
sql 复制代码
-- 直接回滚整个事务(无保存点)
ROLLBACK;

-- 所有插入被撤销
SELECT * FROM account;
复制代码
Empty set (0.00 sec)

适用场景:无保存点的回滚适用于遇到严重业务错误、需要完全放弃当前事务所有操作的场景,操作简单直接。

2.6 事务结束后的不可回滚性

事务一旦被提交(COMMIT),其所有变更已持久化到数据库,此时任何回滚操作都将无效。

sql 复制代码
-- 开始事务
BEGIN;

-- 执行数据插入
INSERT INTO account VALUES (1, '张三', 1234.50);
INSERT INTO account VALUES (2, '李四', 4321.60);
INSERT INTO account VALUES (3, '王五', 3321.60);

-- 提交事务------变更被持久化
COMMIT;

-- 试图回滚已提交的事务
ROLLBACK;

-- 数据仍然存在,回滚无效
SELECT * FROM account;
复制代码
+----+--------+---------+
| id | name   | blance  |
+----+--------+---------+
|  1 | 张三   | 1234.50 |
|  2 | 李四   | 4321.60 |
|  3 | 王五   | 3321.60 |
+----+--------+---------+

核心原则 :回滚操作仅对当前尚未提交的事务有效。一旦提交,数据的变更就永久生效,无法通过回滚撤销 。如需撤销已提交的数据,只能通过执行新的补偿事务(如使用 DELETE 或 UPDATE 反向操作)来实现。

2.7 异常退出与自动回滚机制

在事务执行过程中,如果客户端连接异常终止(如进程崩溃、网络断开等),MySQL 服务器会自动回滚该客户端当前未提交的事务,以确保数据的原子性和一致性。

2.7.1 未提交事务 + 异常退出 → 自动回滚

终端 A(执行数据插入后异常退出):

sql 复制代码
-- 确认当前表为空
SELECT * FROM account;
复制代码
Empty set (0.00 sec)
sql 复制代码
-- 查看 autocommit 设置
SHOW VARIABLES LIKE 'autocommit';
复制代码
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit    | ON    |
+---------------+-------+
sql 复制代码
-- 显式开始事务
BEGIN;

-- 插入记录
INSERT INTO account VALUES (1, '张三', 100.00);

-- 确认数据在本会话中可见
SELECT * FROM account;
复制代码
+----+--------+--------+
| id | name   | blance |
+----+--------+--------+
|  1 | 张三   | 100.00 |
+----+--------+--------+

此时终端 A 使用 Ctrl + \ 异常终止(模拟客户端崩溃)。

终端 B(观察数据变化):

  • 终端 A 崩溃前查询 → 可见张三记录(因为隔离级别为读未提交)

  • 终端 A 崩溃后查询 → 数据自动回滚,表恢复为空

    -- 终端 A 崩溃后,数据自动回滚
    SELECT * FROM account;

    Empty set (0.00 sec)

2.7.2 已提交事务 + 异常退出 → 数据持久化

若事务在异常退出前已经提交,则崩溃不会影响已持久化的数据:

终端 A:

sql 复制代码
BEGIN;
INSERT INTO account VALUES (1, '张三', 100.00);
COMMIT;  -- 提交后数据已持久化
-- 使用 Ctrl + \ 异常退出

终端 B(异常退出后查询):

sql 复制代码
SELECT * FROM account;
复制代码
+----+--------+--------+
| id | name   | blance |
+----+--------+--------+
|  1 | 张三   | 100.00 |
+----+--------+--------+

数据依然存在,COMMIT 确保了持久性。

2.7.3 Ctrl + \ 的含义

在 MySQL 命令行客户端中:

  • Ctrl + C:发送 SIGINT 信号,通常用于中断当前正在执行的 SQL 语句,但不会退出客户端进程。
  • Ctrl + \:发送 SIGQUIT 信号,会强制终止整个 MySQL 客户端进程,相当于模拟了应用程序的意外崩溃。

通过 Ctrl + \ 模拟客户端崩溃,可以清晰地观察到 MySQL 服务器对于未提交事务的自动回滚处理------这正是事务原子性的体现:一个事务要么全部执行成功(提交),要么全部不执行(回滚),不存在中间状态。

2.8 autocommit 与显式事务的关系

autocommit 是 MySQL 的一个系统变量,用于控制单条 SQL 语句是否自动提交 。理解 autocommit 与显式 BEGIN 的关系,是正确编写事务代码的关键。

2.8.1 autocommit 默认行为

autocommit 默认值为 ON,表示每条 SQL 语句在执行成功后会被立即自动提交,形成一个隐式的单语句事务。

sql 复制代码
SHOW VARIABLES LIKE 'autocommit';
复制代码
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit    | ON    |
+---------------+-------+

2.8.2 显式 BEGIN 覆盖 autocommit

核心结论 :只要显式地执行了 BEGIN 或 START TRANSACTION 开启事务,无论 autocommit 是 ON 还是 OFF,都必须通过显式的 COMMIT 才能持久化数据。 如果事务未提交而会话异常终止,所有变更将被自动回滚。

演示场景 :将 autocommit 设置为 OFF,然后显式开始事务并插入数据,最后异常退出。

sql 复制代码
-- 将 autocommit 设置为 OFF
SET autocommit = 0;

SHOW VARIABLES LIKE 'autocommit';
复制代码
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit    | OFF   |
+---------------+-------+
sql 复制代码
-- 显式开始事务
BEGIN;

INSERT INTO account VALUES (2, '李四', 10000.00);

SELECT * FROM account;
复制代码
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   | 100.00   |
|  2 | 李四   | 10000.00 |
+----+--------+----------+

此时使用 Ctrl + \ 异常退出终端 A,终端 B 查询发现数据已被自动回滚(只保留之前已提交的张三记录):

复制代码
SELECT * FROM account;

+----+--------+--------+
| id | name   | blance |
+----+--------+--------+
|  1 | 张三   | 100.00 |
+----+--------+--------+

结论 :显式的 BEGIN 会"覆盖" autocommit 的设置。事务是否自动提交,取决于是否存在显式的 BEGIN,而非 autocommit 的值。

2.9 单条 SQL 的事务行为

在 MySQL InnoDB 中,每一条 SQL 语句默认都被封装成一个事务,这是 autocommit = ON 时的默认行为。但这里需要区分两种情况:

2.9.1 autocommit = ON 时(默认)

每一条 INSERT、UPDATE、DELETE 语句在执行成功后会自动提交,形成一个完整的事务。

演示 (autocommit = ON):

sql 复制代码
-- 确认 autocommit 为 ON
SHOW VARIABLES LIKE 'autocommit';
复制代码
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit    | ON    |
+---------------+-------+
sql 复制代码
-- 当前表只有张三一条记录
SELECT * FROM account;
复制代码
+----+--------+--------+
| id | name   | blance |
+----+--------+--------+
|  1 | 张三   | 100.00 |
+----+--------+--------+
sql 复制代码
-- 执行单条 INSERT(未显式 BEGIN)
INSERT INTO account VALUES (2, '李四', 10000.00);

SELECT * FROM account;
复制代码
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   | 100.00   |
|  2 | 李四   | 10000.00 |
+----+--------+----------+
sql 复制代码
-- 使用 Ctrl + \ 异常退出

终端 B 查询:

sql 复制代码
SELECT * FROM account;
复制代码
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   | 100.00   |
|  2 | 李四   | 10000.00 |
+----+--------+----------+

数据仍然存在,说明单条 INSERT 已被自动提交并持久化。

2.9.2 autocommit = OFF 时

当 autocommit = OFF 时,即使没有显式的 BEGIN,单条 SQL 语句也不会自动提交,需要显式执行 COMMIT 才能持久化。

sql 复制代码
-- 关闭自动提交
SET autocommit = 0;

-- 执行单条 INSERT(未显式 BEGIN)
INSERT INTO account VALUES (3, '王五', 5000.00);

-- 此时数据在当前会话可见,但尚未持久化
SELECT * FROM account;
复制代码
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   | 100.00   |
|  2 | 李四   | 10000.00 |
|  3 | 王五   | 5000.00  |
+----+--------+----------+
sql 复制代码
-- 未执行 COMMIT,直接异常退出
-- Ctrl + \ 退出

终端 B 查询:

sql 复制代码
SELECT * FROM account;
复制代码
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   | 100.00   |
|  2 | 李四   | 10000.00 |
+----+--------+----------+

王五的记录被自动回滚了,因为 autocommit = OFF 时单条语句未自动提交。

2.10 多语句事务:必须显式 BEGIN 和 COMMIT

如果希望将多条 SQL 语句放在同一个事务中执行------即要么全部成功提交,要么全部失败回滚------则必须使用显式的 BEGIN 和 COMMIT 来界定事务边界。

sql 复制代码
-- 开启多语句事务
BEGIN;

-- 多条 SQL 语句作为一个原子单元执行
INSERT INTO account VALUES (1, '张三', 1234.50);
INSERT INTO account VALUES (2, '李四', 4321.60);
INSERT INTO account VALUES (3, '王五', 3321.60);

-- 所有语句执行成功,提交事务
COMMIT;

如果其中任意一条语句执行失败,或者业务逻辑判定需要撤销所有操作,可以执行 ROLLBACK:

sql 复制代码
-- 开启事务
BEGIN;

INSERT INTO account VALUES (4, '赵六', 2000.00);
-- 假设此处发生业务异常,需要撤销所有操作
INSERT INTO account VALUES (5, '钱七', 3000.00);  -- 此语句可能失败

-- 回滚整个事务
ROLLBACK;

核心原则 :多条 SQL 语句若需保持原子性,必须用 BEGIN 和 COMMIT/ROLLBACK 显式包裹。默认的 autocommit 模式无法满足多语句原子性的需求。

补充:MySQL 中大多数 DDL 语句会导致当前事务被隐式提交。

不是"所有 DDL 都一定"。

常见会隐式提交的 DDL:

sql 复制代码
CREATE TABLE
ALTER TABLE
DROP TABLE
TRUNCATE TABLE
CREATE INDEX
DROP INDEX

例如:

sql 复制代码
START TRANSACTION;

UPDATE account SET money=money-100 WHERE id=1;

ALTER TABLE account ADD COLUMN age INT;

ROLLBACK;

执行过程:

text 复制代码
UPDATE
  ↓
事务中未提交

ALTER TABLE
  ↓
隐式 COMMIT

ROLLBACK
  ↓
无法撤销 UPDATE

原因:

  • DDL 修改数据库结构;
  • MySQL 需要保证结构变更一致性;
  • 因此会自动结束当前事务。

补充:

  • 与隔离级别无关;
  • 与 autocommit=0/1 无关;
  • 主要影响当前未提交事务。

MySQL 中执行大多数 DDL 会隐式提交当前事务,导致之前未提交的 DML 修改无法回滚。

第三章 事务隔离级别

在上一章中,我们已经掌握了事务的基本操作方式,包括如何开始、提交、回滚事务,以及保存点的使用。但实际生产环境中,数据库往往同时服务于大量客户端,多个事务并发执行时,彼此之间会产生怎样的影响?MySQL 又是如何控制这种影响的?本章将围绕事务的隔离性展开深入探讨。

3.1 隔离性的必要性

MySQL 服务通常同时为多个客户端进程(或线程)提供服务,每个客户端通过事务的方式执行 SQL 操作。一个事务由多条 SQL 构成,从开始到提交(或回滚)存在一个执行过程。事务的原子性使得从用户视角来看,事务要么完全执行前,要么完全执行后------中间状态对用户不可见。

但当多个事务并发执行时,情况变得复杂起来。多个事务可能同时访问同一张表,甚至同一行数据,执行过程中难免产生相互影响。

可以用一个生活中的例子来帮助理解:母亲告诉孩子:"你要么别学,要学就学到最好。"至于孩子学习过程中遇到了什么困难、经历了怎样的波折,母亲并不关心。从母亲的视角来看,孩子的学习过程是"原子的"。但孩子在学习过程中很容易受到外界干扰,这时就需要一个独立的学习环境来保证专注度。

数据库中亦是如此。为了保证事务在执行过程中尽量不受其他并发事务的干扰,数据库引入了隔离性(Isolation)。而隔离性并非一个开关------数据库允许事务受到不同程度的干扰,这种不同干扰程度的划分,就是 隔离级别(Isolation Level)。

3.2 隔离级别概览

SQL 标准定义了四种隔离级别,隔离级别从低到高依次为:

隔离级别 脏读 不可重复读 幻读 加锁读
读未提交(READ UNCOMMITTED) 会发生 会发生 会发生 不加锁
读已提交(READ COMMITTED) 不会发生 会发生 会发生 不加锁
可重复读(REPEATABLE READ) 不会发生 不会发生 不会发生(InnoDB) 不加锁
可串行化(SERIALIZABLE) 不会发生 不会发生 不会发生 加锁

说明 :上表中的"加锁读"指的是读取数据时,数据库会给数据加锁,限制其他事务对这些数据的修改或读取方式,以保证串行化执行效果。 。MySQL InnoDB 在可重复读级别下通过 MVCC 机制实现了不加锁的一致性读取,同时利用间隙锁(Gap Lock)解决了幻读问题,这是 InnoDB 对 SQL 标准的扩展实现。

3.2.1 读未提交(READ UNCOMMITTED)

在该隔离级别下,一个事务可以读取到另一个事务尚未提交的数据。这是最低级别的隔离,实际生产中几乎不会使用,因为它几乎没有隔离性可言,脏读、不可重复读、幻读等问题都会出现。

在上一章的演示中,为了能够在一个会话中看到另一个会话未提交的变更,我们将隔离级别临时设置为读未提交,正是利用了这个级别的特点。

特点:不加锁,并发性能最高,但数据一致性最差。

3.2.2 读已提交(READ COMMITTED)

这是大多数数据库系统(如 Oracle、SQL Server)的默认隔离级别。它满足隔离性的基本定义:一个事务只能看到其他事务已经提交的变更。

特点:避免了脏读,但会引发不可重复读问题------同一个事务中多次执行相同的查询,可能得到不同的结果。

3.2.3 可重复读(REPEATABLE READ)

这是 MySQL InnoDB 的默认隔离级别。它确保同一个事务在执行过程中,多次读取同一行数据时,会看到相同的值。

特点:避免了脏读和不可重复读。在 SQL 标准中,可重复读级别下仍可能发生幻读,但 MySQL InnoDB 通过 Next-Key Lock(行锁 + 间隙锁)机制解决了幻读问题。

3.2.4 可串行化(SERIALIZABLE)

这是最高的隔离级别。它通过对所有读取的数据行加共享锁,强制事务串行执行,从而完全避免了脏读、不可重复读和幻读。

特点:隔离性最强,但并发性能极低,实际生产环境中很少使用。由于加锁粒度大,容易导致锁等待和超时。

3.3 隔离级别的查看与设置

3.3.1 查看当前隔离级别

在 MySQL 中,可以使用以下三种方式查看隔离级别:

sql 复制代码
-- 查看全局隔离级别(对所有新建会话生效)
SELECT @@global.tx_isolation;

-- 查看当前会话隔离级别
SELECT @@session.tx_isolation;

-- 查看当前会话隔离级别(与上面等效,MySQL 5.7 及以下版本支持)
SELECT @@tx_isolation;

执行示例:

复制代码
+-----------------------+
| @@global.tx_isolation |
+-----------------------+
| REPEATABLE-READ       |
+-----------------------+

+------------------------+
| @@session.tx_isolation |
+------------------------+
| REPEATABLE-READ        |
+------------------------+

版本说明 :MySQL 8.0 中,tx_isolation 变量已被 transaction_isolation 替代,使用时可查阅对应版本的手册。

3.3.2 设置隔离级别

设置隔离级别的语法如下:

sql 复制代码
SET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL {
    READ UNCOMMITTED |
    READ COMMITTED |
    REPEATABLE READ |
    SERIALIZABLE
}

设置当前会话隔离级别(仅影响当前会话,其他会话不受影响):

sql 复制代码
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;

验证效果:

sql 复制代码
SELECT @@global.tx_isolation;   -- 全局仍为 REPEATABLE-READ
SELECT @@session.tx_isolation;  -- 当前会话变为 SERIALIZABLE

执行结果:

复制代码
+-----------------------+
| @@global.tx_isolation |
+-----------------------+
| REPEATABLE-READ       |
+-----------------------+

+------------------------+
| @@session.tx_isolation |
+------------------------+
| SERIALIZABLE           |
+------------------------+

设置全局隔离级别(所有新建会话都将继承该设置):

sql 复制代码
SET GLOBAL TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

重要提示 :设置全局隔离级别后,需要重新建立数据库连接(即重启客户端)才能生效。已存在的会话不受影响。

3.4 读未提交:脏读演示

脏读(Dirty Read)是指一个事务读取到了另一个事务尚未提交的数据变更。如果数据最终被回滚,那么读取到的就是"脏"数据。

演示步骤

终端 A:设置全局隔离级别为读未提交并重启连接生效。

sql 复制代码
-- 设置全局隔离级别
SET GLOBAL TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

-- 需要重启 MySQL 客户端
-- 重新连接后确认
SELECT @@tx_isolation;
复制代码
+------------------+
| @@tx_isolation   |
+------------------+
| READ-UNCOMMITTED |
+------------------+

查看当前表中数据:

sql 复制代码
SELECT * FROM account;
复制代码
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   | 100.00   |
|  2 | 李四   | 10000.00 |
+----+--------+----------+

终端 A :开启事务,更新张三的余额(不提交)。

sql 复制代码
BEGIN;
UPDATE account SET blance = 123.00 WHERE id = 1;
-- 此时未执行 COMMIT

终端 B(隔离级别同样为读未提交):开启事务并查询。

sql 复制代码
BEGIN;
SELECT * FROM account;
复制代码
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   | 123.00   |  -- 读到了终端 A 未提交的数据!
|  2 | 李四   | 10000.00 |
+----+--------+----------+

这就是典型的脏读。终端 B 读到了终端 A 尚未提交的数据,如果此时终端 A 执行回滚:

sql 复制代码
-- 终端 A 执行回滚
ROLLBACK;

那么终端 B 此前读到的 123.00 就是一个"脏"值,实际数据库中张三的余额仍然是 100.00。

3.5 读已提交:避免脏读,但不可重复读

读已提交级别解决了脏读问题,但引入了不可重复读问题。不可重复读 是指在同一个事务内,多次执行相同的查询语句,由于其他事务提交了更新,导致两次查询结果不一致。

3.5.1 避免脏读验证

终端 A:设置全局隔离级别为读已提交,重启连接生效。

sql 复制代码
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 重启客户端后确认

终端 A:开启事务,更新张三余额(不提交)。

sql 复制代码
BEGIN;
UPDATE account SET blance = 321.00 WHERE id = 1;
-- 未 COMMIT

终端 B:开启事务查询。

sql 复制代码
BEGIN;
SELECT * FROM account;
复制代码
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   | 123.00   |  -- 仍然是旧值,读已提交避免了脏读
|  2 | 李四   | 10000.00 |
+----+--------+----------+

3.5.2 不可重复读的典型场景

当终端 A 提交事务后,终端 B 在同一个事务内再次查询,结果发生了变化。

终端 A:提交事务。

sql 复制代码
COMMIT;

终端 B(事务尚未结束):再次查询。

sql 复制代码
SELECT * FROM account;
复制代码
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   | 321.00   |  -- 值变了!从 123.00 变成了 321.00
|  2 | 李四   | 10000.00 |
+----+--------+----------+

终端 B 的同一个事务中 ,两次查询得到不同的结果(第一次读到 123.00,第二次读到 321.00),这就是不可重复读。

3.5.3 不可重复读的业务影响

不可重复读在某些业务场景下可能引发问题。让我们通过一个实际场景来理解其影响:

场景:某公司按员工薪资区间发放奖品

假设公司准备为薪资在 3000 元至 4000 元之间的员工发放奖品(微波炉)。HR 小张开启了一个事务,准备查询符合条件的员工名单:

复制代码
薪资区间        奖品
[1000, 2000] → 水杯
[2000, 3000] → 键盘
[3000, 4000] → 微波炉
[4000, 5000] → 电视
[5000, 10000] → 手机

当前员工 Tom 的薪资为 3200 元,符合微波炉的发放条件。小张的事务首先查询 sal >= 3000 AND sal < 4000 的员工名单,Tom 出现在结果中。

与此同时,另一个事务(小王的操作)将 Tom 的薪资从 3200 更新为 4500 元并提交。

随后,小张的同一个事务再次查询 sal >= 3000 AND sal < 4000 的员工名单,Tom 已经不在结果中了------他"消失"了。而在查询 sal >= 4000 AND sal < 5000 时,Tom 又"出现"了。

同一个事务中,Tom 一会符合条件一会不符合,导致小张在发放奖品时无法做出正确的决定------这是典型的不可重复读问题。

3.6 可重复读:MySQL 默认隔离级别

可重复读 是 MySQL InnoDB 的默认隔离级别。在该级别下,同一个事务中的多次查询结果是一致的,不会因为其他事务的提交而改变。

SQL 标准中的 REPEATABLE READ(可重复读) 主要保证的是:同一个事务中多次读取同一条记录 时结果保持一致,即解决不可重复读问题,但它并不要求禁止其他事务在查询范围内插入新的记录,因此按照标准定义仍可能发生幻读。所谓幻读,是指同一个查询条件在两次执行时返回的记录集合发生变化,例如第一次查询得到两条数据,第二次查询时多出了一条满足条件的新数据。不过,MySQL InnoDB 对 REPEATABLE READ 进行了增强,通过 MVCC(多版本并发控制) 保证普通快照读的一致性,并通过 Next-Key Lock(临键锁) 防止范围内插入新数据,因此在 InnoDB 中 REPEATABLE READ 级别下通常不会出现幻读。

3.6.1 可重复读演示

终端 A:设置全局隔离级别为可重复读,重启连接。

sql 复制代码
SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 重启客户端
SELECT @@tx_isolation;
复制代码
+-----------------+
| @@tx_isolation  |
+-----------------+
| REPEATABLE-READ |
+-----------------+

查看当前数据:

sql 复制代码
SELECT * FROM account;
复制代码
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   | 321.00   |
|  2 | 李四   | 10000.00 |
+----+--------+----------+

终端 A 和 终端 B 同时开启事务。

终端 A:

sql 复制代码
BEGIN;
UPDATE account SET blance = 4321.00 WHERE id = 1;
COMMIT;  -- 提交更新

终端 B (事务进行中,在终端 A 提交前后分别查询):

sql 复制代码
BEGIN;

-- 终端 A 提交前查询
SELECT * FROM account;
复制代码
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   | 321.00   |  -- 旧值
|  2 | 李四   | 10000.00 |
+----+--------+----------+
sql 复制代码
-- 终端 A 提交后再次查询(事务 B 仍未提交)
SELECT * FROM account;
复制代码
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   | 321.00   |  -- 仍然是旧值,可重复读!
|  2 | 李四   | 10000.00 |
+----+--------+----------+

终端 B 中的两次查询结果完全一致,这就是可重复读的体现。只有当终端 B 提交当前事务后,再次查询才能看到最新的数据:

sql 复制代码
COMMIT;  -- 结束事务
SELECT * FROM account;
复制代码
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   | 4321.00  |  -- 新值
|  2 | 李四   | 10000.00 |
+----+--------+----------+

3.6.2 幻读问题与 MySQL 的解决方案

根据 SQL 标准,可重复读级别下仍会发生幻读。幻读 是指:在同一个事务中,使用相同的条件多次执行查询,由于其他事务插入了符合条件的新行,导致每次查询返回的记录数不一致,仿佛产生了"幻觉"。

幻读针对的是"满足查询条件的记录集合发生变化",不仅包括 INSERT 新增记录,也包括 DELETE 删除记录;而 UPDATE 修改已有记录通常属于不可重复读。

标准场景演示(终端 A 插入新数据,终端 B 在事务中查询):

终端 A:

sql 复制代码
BEGIN;
INSERT INTO account (id, name, blance) VALUES (3, '王五', 5432.00);
COMMIT;

终端 B(事务中,在终端 A 提交前后多次查询):

sql 复制代码
BEGIN;

-- 终端 A 提交前
SELECT * FROM account WHERE id <= 3;
复制代码
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   | 4321.00  |
|  2 | 李四   | 10000.00 |
+----+--------+----------+
sql 复制代码
-- 终端 A 提交后再次查询
SELECT * FROM account WHERE id <= 3;
复制代码
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   | 4321.00  |
|  2 | 李四   | 10000.00 |
+----+--------+----------+

在 MySQL InnoDB 中,终端 B 的事务在终端 A 提交前后查询到的记录数相同(都是两条),王五的记录并没有出现幻象。这说明 MySQL InnoDB 在可重复读级别下解决了幻读问题。

实现原理 :MySQL InnoDB 通过 Next-Key Lock (由行锁 Record Lock 和间隙锁 Gap Lock 组合而成)来解决幻读问题。间隙锁锁定的是索引记录之间的"间隙",防止其他事务在该间隙中插入新行。这意味着在终端 B 的事务结束之前,其他事务无法在 id 小于等于 3 的范围内插入新的记录,从而保证了查询结果集的一致性。关于锁机制的详细探讨将在后续章节展开。

3.7 可串行化:最高的隔离级别

可串行化通过强制事务串行执行来完全避免并发问题。它会对所有读取的数据行加共享锁(Shared Lock),写操作会加排他锁(Exclusive Lock),导致事务之间需要排队等待。

3.7.1 可串行化演示

终端 A:设置全局隔离级别为可串行化,重启连接。

sql 复制代码
SET GLOBAL TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- 重启客户端
SELECT @@tx_isolation;
复制代码
+----------------+
| @@tx_isolation |
+----------------+
| SERIALIZABLE   |
+----------------+

终端 A 和 终端 B 分别开启事务。

终端 A:

sql 复制代码
BEGIN;
SELECT * FROM account;  -- 读取操作加共享锁
复制代码
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   | 4321.00  |
|  2 | 李四   | 10000.00 |
|  3 | 王五   | 5432.00  |
+----+--------+----------+

终端 B:

sql 复制代码
BEGIN;
SELECT * FROM account;  -- 读取操作也可以进行(共享锁兼容)
复制代码
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   | 4321.00  |
|  2 | 李四   | 10000.00 |
|  3 | 王五   | 5432.00  |
+----+--------+----------+

终端 A 尝试执行更新操作(需要排他锁):

sql 复制代码
-- 终端 A 执行更新,会被阻塞,直到终端 B 提交事务
UPDATE account SET blance = 1.00 WHERE id = 1;
-- 此处会等待,直到终端 B 提交后才会继续执行

终端 B 提交事务,释放共享锁:

sql 复制代码
COMMIT;

此时终端 A 的更新操作得以继续执行:

复制代码
Query OK, 1 row affected (18.19 sec)  -- 等待了约 18 秒
Rows matched: 1 Changed: 1 Warnings: 0

串行化的工作机制 :事务对读取的数据加共享锁,多个事务可以同时读取(共享锁兼容),但当一个事务需要写入数据时,必须等待其他事务释放共享锁。这就导致了"串行化"的效果------事务一个接一个地执行。

在数据库锁机制中,不同事务对同一数据的访问会根据操作类型产生不同的锁竞争关系: 读操作之间通常可以共存 ,因为多个事务同时读取同一份数据不会破坏数据一致性,因此可以同时持有共享锁(S锁); 读操作和写操作不能同时进行 ,因为写操作可能改变正在被读取的数据,所以写事务必须等待读事务释放锁; 写操作和写操作之间也不能同时进行 ,因为多个事务同时修改同一数据可能造成冲突,因此后执行的写事务必须等待先执行的写事务提交并释放排他锁(X锁)后才能继续执行。 简单来说就是:读读不冲突,读写互斥,写写互斥。

3.7.2 串行化的适用场景

串行化隔离级别保证了最高级别的数据一致性,但代价极其昂贵:

  • 并发性能极低,事务吞吐量大幅下降
  • 锁等待超时风险高
  • 死锁概率增加

在实际生产环境中,几乎不会使用串行化隔离级别。MySQL 默认的可重复读级别已经能够在大多数场景下提供足够的数据一致性保障,且并发性能远优于串行化。

3.8 不同事务操作是否"先到先执行"?

在并发事务场景中,是否"先到先执行"取决于具体的隔离级别和操作类型:

  • 读未提交 :不加锁,事务之间互不阻塞,不存在先到先执行的概念。
  • 读已提交 :读操作不加锁,写操作加锁。一个事务的写操作不会阻塞另一个事务的读操作,但会阻塞另一个事务的写操作。
  • 可重复读 :读操作通过 MVCC 机制不加锁,写操作加锁。同时,MySQL 通过间隙锁阻止幻读。
  • 可串行化 :读操作加共享锁,写操作加排他锁 。当一个事务持有锁时,其他事务的相应操作需要排队等待,体现出"先到先执行"的特征。

需要注意的是,在可串行化级别下,两个读操作可以同时进行(共享锁兼容),而读写或写写操作则需要等待锁释放。

3.9 隔离级别与读写并发

隔离级别是围绕读写并发来设计的。核心思想是:在保证数据一致性的前提下,允许尽可能高的并发度。

  • 读-读并发:所有隔离级别都允许,因为读取之间不存在数据冲突。
  • 读-写并发:不同隔离级别的表现不同。读未提交允许读取未提交的写,读已提交只允许读取已提交的写,可重复读通过 MVCC 提供一致性快照读。
  • 写-写并发:所有隔离级别都通过锁机制来保证,写入操作需要获得排他锁,同一时间只有一个事务可以写入同一行数据。

因此,隔离级别的核心作用就是管理读操作与写操作之间的相互影响程度,在一致性和并发性之间做出权衡。

3.10 一致性:由原子性、隔离性、持久性共同保证

在 ACID 特性中,一致性(Consistency)是一个特殊的存在。它不像原子性、隔离性、持久性那样可以通过数据库的技术手段来保证,而是更依赖于业务逻辑的正确性。

一致性的定义:事务执行的结果,必须使数据库从一个一致性状态转换到另一个一致性状态。当数据库中只包含事务成功提交的结果时,数据库处于一致性状态。

理解一致性的例子:在转账业务中,张三向李四转账 100 元,业务规则要求:

  1. 张三账户减少 100 元
  2. 李四账户增加 100 元
  3. 总金额不变

如果事务只执行了第一步而因故障中断,此时数据库中的总金额减少了 100 元,处于不一致状态。但事务的原子性会通过回滚来阻止这种中间状态,保证数据库最终要么都更新,要么都不更新。

一致性与其他三者的关系:

技术特性 对一致性的贡献
原子性(A) 确保事务要么全部执行、要么全部回滚,避免部分更新导致的不一致
隔离性(I) 确保并发事务互不干扰,避免脏数据传播导致的不一致
持久性(D) 确保提交的数据不丢失,避免已确认的变更因系统故障而丢失

因此,业界有一个经典的说法:一致性是通过 AID 来保证的。

但需要注意的是,数据库只能保证技术层面的一致性,而业务层面的一致性需要开发者自行保证。例如,转账总额不变的业务约束,需要开发者在事务中显式编写逻辑来确保------如果只更新了张三的账户而没有更新李四的账户,数据库不会自动发现这个问题。技术上的 ACID 保证的是:要么两个更新都成功(提交),要么两个更新都撤销(回滚),但业务规则的正确性,需要开发者来维护。

第四章 MVCC 内核剖析:隐藏字段、Undo Log 与 Read View 可见性判断

读-写冲突是数据库并发控制的核心难题。MySQL InnoDB 通过多版本并发控制(MVCC)实现了读操作无锁化,MySQL InnoDB 通过 MVCC 实现读操作无锁化,主要用于 RC(读已提交)和 RR(可重复读)两个隔离级别。,在不牺牲隔离性的前提下大幅提升吞吐量。本章将深入源码实现层面,彻底拆解 MVCC 的三大基石------隐藏字段、Undo Log 版本链与 Read View 可见性算法,并结合 RC 与 RR 隔离级别的本质差异进行实战验证。

隔离级别 是否使用MVCC 普通SELECT是否无锁 Read View特点
READ UNCOMMITTED(读未提交) 否(不需要) 是 直接读取最新数据,可能脏读
READ COMMITTED(读已提交) 是 是 每次快照读生成新的Read View
REPEATABLE READ(可重复读) 是 是 一个事务第一次快照读生成Read View,后续复用
SERIALIZABLE(串行化) 否(主要依赖锁) 否 读也会加锁

4.1 事务的底层管理模型

在 MySQL 服务进程(mysqld)中,多个客户端连接对应多个并发事务。InnoDB 层使用 trx_t 结构体(类对象)来描述每一个活跃事务。该结构体不仅维护事务状态(活跃、提交、回滚)、锁信息、回滚段指针,还持有一个核心字段------事务 ID (trx_id)。

事务 ID 是 InnoDB 内部维护的一个全局单调递增的整数 (通常在实例启动后从 7 左右开始分配)。这个递增特性决定了事务 ID 的大小直接反映事务启动或首次写操作的先后顺序 。即便两个事务时间上交错执行,通过对比其 trx_id,InnoDB 也能清晰地界定"谁先谁后" 。mysqld 内部维护着一个全局的事务管理系统,负责分配 ID、跟踪活跃事务列表、并在事务提交或回滚时将其移出活跃集。

4.2 数据表的"隐形铠甲"------三大隐藏字段

我们执行的 CREATE TABLE 语句仅定义了显式列(如 name、age)。但 InnoDB 在聚簇索引的每行记录上,强制附加了三个隐藏字段,它们是 MVCC 能够运作的数据基础:

隐藏字段 长度 含义与核心作用
DB_TRX_ID 6 字节 最近修改(插入或更新)该记录的事务 ID。删除操作本质上也是更新,会将删除标志置位并更新该字段。
DB_ROLL_PTR 7 字节 回滚指针,指向 Undo Log 中该记录上一个版本(即修改前的"前镜像")的地址。通过它,记录的历史版本被串联成链表。
DB_ROW_ID 6 字节 隐含的自增 ID。若表未定义主键,InnoDB 会用它生成聚簇索引;若表有主键,此字段仅作为行标识,不参与索引组织。

此外,记录头部还有一个隐藏的 DELETE_FLAG 位。删除操作并非物理移除磁盘数据,而是将该标志位置为 1,并将 DB_TRX_ID 更新为当前删除事务的 ID。因此,删除也会形成版本链,为 MVCC 可见性判断提供依据。

4.3 Undo Log:从"写时拷贝"到版本链

4.3.1 Undo Log 的存储形态与生命周期

许多初学者误以为 Undo Log 只是磁盘上的一个文件。实际上,Undo Log 是 InnoDB 在内存(Buffer Pool)与磁盘(系统表空间或独立 Undo 表空间)中协同维护的逻辑日志 。当执行修改操作时,修改前的数据会先被拷贝到 Buffer Pool 内的 Undo 页中,形成内存副本;随后在合适时机(如 Checkpoint)刷新至磁盘,确保持久性。

对于 INSERT 操作 :Undo Log 仅记录新行的主键信息,用于事务回滚时执行逻辑删除 (即根据主键将插入的行标记为不存在)。一旦该事务提交(COMMIT) ,这条 INSERT Undo Log 就不再需要服务于回滚,系统会将其标记为可回收,后续由后台 Purge 线程物理清理(注意:并非"立即 free",而是异步回收空间)。

对于 UPDATE 和 DELETE 操作 :Undo Log 保存了记录的完整前镜像(旧版本),并必须保留 ,直到所有可能读到该历史版本的 Read View 都关闭后,才能被 Purge 线程清理。

4.3.2 版本链的生成过程

假设初始表有一行数据:(name='张三', age=28),其 DB_TRX_ID 为创建它的旧事务 ID(示意为 null),DB_ROLL_PTR 为 null。

步骤一:事务 10 执行 UPDATE

将 name 改为 李四。InnoDB 执行流程如下:

  1. 加锁:先对该行记录加行锁(当前写操作互斥)。
  2. 写时拷贝 :将修改前的完整行数据(张三, 28)拷贝至 Undo Log,作为旧版本。
  3. 修改当前行 :将最新记录 name 改为 李四。
  4. 更新隐藏字段 :最新记录的 DB_TRX_ID 赋值为 10;DB_ROLL_PTR 指向 Undo Log 中旧版本的地址(如 0x11223344)。
  5. 提交释放锁:事务 10 提交,释放行锁。

此时,版本链形成:最新记录(李四) → 回滚指针 → Undo 旧记录(张三)。

步骤二:事务 11 再次执行 UPDATE

将 age 改为 38。同样流程:

  1. 拷贝当前最新记录(李四, 28, trx_id=10)至 Undo Log,采用头插法,使其成为版本链的新头部。
  2. 修改最新记录为 age=38,更新 DB_TRX_ID=11,DB_ROLL_PTR 指向刚才 Undo 中新插入的版本(李四, 28)。

最终形成如图 所示的单向历史版本链表 :

当前记录 (李四, 38, trx=11) → Undo (李四, 28, trx=10) → Undo (张三, 28, trx=null)

这一链表结构正是"快照"的物理载体。回滚操作 无非是利用版本链中的历史数据覆盖当前记录,并将 DB_TRX_ID 恢复为历史版本的事务 ID。

InnoDB 的版本链是以"数据行"为单位维护的。同一行数据被多次 UPDATE,这些历史版本会按照修改顺序连接成一条链;不同的数据行之间拥有各自独立的版本链。

4.4 Read View:事务的快照"滤镜"

有了版本链,不同事务如何从中挑选自己"应该"看到哪个版本?答案就是 Read View。

4.4.1 Read View 的核心结构与关键字段

  1. 快照读(Snapshot Read)指的是读取数据时,不直接读取数据库当前最新版本,而是根据 MVCC 规则读取某个时间点的数据快照。
  2. 不是只要事务中存在 SELECT 就一定创建 Read View,而是事务执行普通快照读(普通 SELECT)时才会创建属于该事务自己的 Read View。 每个事务的 Read View 是独立的,不会被其他事务共享; 但是不同隔离级别下生成方式不同:在 RR(可重复读) 中,一个事务第一次执行快照读时创建 Read View,后续普通 SELECT 复用同一个 Read View;在 RC(读已提交) 中,每一次普通 SELECT 都会重新创建 Read View。而像 SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE 这类当前读不会创建 Read View,而是通过加锁读取最新数据。**

Read View 并非事务一开始就存在,而是在该事务执行第一条快照读(普通 SELECT)时由 InnoDB 瞬间创建的一个内存视图对象。 它本质是一组"可见性判断条件",其核心字段如下:

字段名 含义与计算方式
m_ids 活跃事务 ID 列表 :创建 Read View 这一刻,系统中所有尚未提交(活跃)的事务 ID 集合。
m_up_limit_id 低水位 :m_ids列表中的最小事务 ID 。小于该 ID 的事务,在快照创建前必然已提交或回滚。
m_low_limit_id 高水位 :创建 Read View 时系统尚未分配的下一个事务 ID,即"当前已出现的最大事务 ID + 1"。大于等于该 ID 的事务,都是快照生成后才启动的。
m_creator_trx_id 创建者事务 ID :即执行该快照读的当前事务 ID。

关键时间窗 :m_ids 捕获的是"创建瞬间"的活跃状态。早于此瞬间提交的事务 ID 不在列表中;晚于此瞬间才提交的事务 ID 必然大于等于 m_low_limit_id。

4.4.2 可见性判断源码逐行解析

InnoDB 源码中的 changes_visible() 函数(核心逻辑精简如下)实现了对版本链某个版本的 DB_TRX_ID 的过滤:

cpp 复制代码
bool changes_visible(trx_id_t id) {
    // 1. 小于低水位,或等于本事务自身 → 可见
    if (id < m_up_limit_id || id == m_creator_trx_id) {
        return true;
    }
    // 2. 大于等于高水位 → 不可见(这是未来的事务,快照时它还不存在)
    if (id >= m_low_limit_id) {
        return false;
    } 
    // 3. 处于 [m_up_limit_id, m_low_limit_id) 区间
    //    若 id 在活跃列表 m_ids 中 → 不可见(活跃未提交)
    //    若 id 不在 m_ids 中 → 可见(虽然在区间内,但已提前提交)
    else if (m_ids.empty()) {
        return true;
    }
    return !binary_search(m_ids.begin(), m_ids.end(), id);
}

判断逻辑的精髓(参考上图 流程):

  • DB_TRX_ID < m_up_limit_id :该版本修改者太"老"了,在快照生成前就已提交,可见。
  • DB_TRX_ID >= m_low_limit_id :该版本修改者是快照生成后新来的事务,不可见。
  • m_up_limit_id <= DB_TRX_ID < m_low_limit_id :此时检查 m_ids。若 DB_TRX_ID 在 m_ids 中,说明修改者当时仍活跃未提交,不可见 (否则可能读到脏数据);若不在 m_ids 中,说明虽然它 ID 较大,但在生成快照前已经提交,可见。

遍历版本链时,InnoDB 从最新记录开始,逐个取出其 DB_TRX_ID 送入该函数过滤。第一个通过可见性检查的版本,就是该事务应该读到的快照结果。

4.5 隔离级别与 Read View 生成策略的本质差异

追问:四个隔离级别下的 Read View 机制一样吗?是否"一旦生成再也不变"?

结论如下表所示:

隔离级别 使用 MVCC 快照读? Read View 生成时机 是否保持不变?
READ UNCOMMITTED ❌ 不使用 无 Read View,直接读最新记录(当前读) 不适用
READ COMMITTED (RC) ✅ 使用 每条普通 SELECT 语句执行前,都重新生成一个新的 Read View ❌ 每次快照读都变,因此能看到其他事务已提交的最新更改
REPEATABLE READ (RR) ✅ 使用 事务内首次执行普通 SELECT 时生成一个 Read View ✅ 整个事务期间复用同一个 Read View,后续快照读不再更新
SERIALIZABLE ❌ 使用但退化 普通 SELECT 会被隐式加锁(LOCK IN SHARE MODE),退化为当前读 不适用

RC 的"不可重复读"根源:同一事务内的两次快照读,因为各生成一次 Read View,第二次快照读时可能包含其他事务刚提交的新版本,导致两次结果不一致。

RR 的"可重复读"保障 :首次快照读生成 Read View 后便"冻结"了事务 ID 的可见范围,后续任何其他事务的提交都无法影响已生成的 m_ids、m_up_limit_id 和 m_low_limit_id,从而保证事务内多次查询结果始终一致。

4.6 实战验证:RC 与 RR 下快照读 vs 当前读的差异

我们基于 user 表(id=1, name='黄蓉', age=15),在 RR 隔离级别下验证经典场景。

4.6.1 测试用例 1

时序 事务 A(ID=10) 事务 B(ID=11) 关键说明
T1 BEGIN; BEGIN; 两事务开启
T2 SELECT * FROM user;(快照读) 事务 B 首次快照读 ,生成 Read View,此时 age=15
T3 UPDATE user SET age=18 WHERE id=1; 事务 A 修改并提交(T4 提交)
T4 COMMIT;
T5 SELECT * FROM user;(快照读) 使用同一个 Read View ,过滤掉 DB_TRX_ID=10(因 A 在 m_ids 中),读到历史版本 age=15
T6 SELECT * FROM user LOCK IN SHARE MODE;(当前读) 当前读 不走 Read View,直接读取最新记录,读到 age=18

结论:RR 下,首次快照读的时机决定了"一致性视图"的范围。即便其他事务提交,快照读仍返回旧值;只有加锁的当前读才获取最新数据。

4.6.2 测试用例 2

时序 事务 A(ID=10) 事务 B(ID=11) 关键说明
T1 BEGIN; BEGIN; 两事务开启
T2 UPDATE user SET age=18 WHERE id=1; 事务 A 修改,但此时事务 B 尚未进行过快照读
T3 COMMIT; 事务 A 提交,age=18 已成正式历史版本
T4 SELECT * FROM user;(快照读) 首次快照读 在此刻生成 Read View。此时事务 A 已不在 m_ids 中,最新记录的 DB_TRX_ID=10 不在活跃列表,可见 ,读到 age=18
T5 UPDATE user SET age=28; 基于最新可见值进行修改并提交
T6 SELECT * FROM user;(快照读) 仍用首次的 Read View,但此版本 DB_TRX_ID=11 等于 creator_trx_id,可见,读到 age=28

对比两张表 :唯一的变量就是"首次快照读发生在其他事务提交之前,还是之后"。这决定了 m_ids 中是否包含修改者 ID,进而决定了后续一致读的结果。这一差异完美揭示了 RR 级别下"可重复读"的边界条件------它依赖于事务内第一次读取的时间点。

补充:当前读

当前读(Current Read)指读取数据时获取数据库中的最新提交版本,并通过加锁保证数据一致性。常见语法有:

sql 复制代码
-- 加共享锁(读锁)
SELECT * FROM 表名 
WHERE 条件
LOCK IN SHARE MODE;

多个事务可以同时读取,但阻止其他事务修改数据。

sql 复制代码
-- 加排他锁(写锁)
SELECT * FROM 表名 
WHERE 条件
FOR UPDATE;

读取最新数据,并阻止其他事务修改或加锁。

此外,INSERT、UPDATE、DELETE 语句本身也属于当前读,因为它们需要读取最新数据后进行修改。

例如:

sql 复制代码
UPDATE student 
SET age=20 
WHERE id=1;

本质也是当前读。

总结:

当前读 = 读取最新版本 + 加锁控制并发,主要用于需要保证数据实时一致性的场景;普通 SELECT 属于快照读,不加锁,依靠 MVCC 实现。

4.7 写-写冲突与 MVCC 的局限性

MVCC 仅解决读-写冲突(无锁并发读) ,对 写-写冲突(两个事务同时修改同一条记录) 则无能为力。在 UPDATE、DELETE、INSERT 操作上,InnoDB 一律采用当前读 并施加行锁 。第二个修改事务必须等待第一个事务提交并释放锁后才能继续。这便是"更新丢失"(Lost Update)问题的防范机制。需要强调的是,MVCC 虽然能消除脏读、不可重复读和幻读(RR 级别下),但无法避免两个并发写操作带来的逻辑冲突,这部分必须依赖锁机制(行锁、间隙锁)来保证最终一致性。

相关推荐
林伽一2 小时前
训练断网、部署上门槛:AI 基础设施的两道新闸门|2026年10月25日
人工智能·科技·安全·ai·1024程序员节
西凉的悲伤2 小时前
Spring Boot 条件装配注解详解
java·springboot·1024程序员节·条件装配
秦时明月之君临天下2 小时前
MySQL purge 参数详解:innodb_purge_threads 与 innodb_purge_batch_size
数据库·mysql·1024程序员节
云和数据.ChenGuang2 小时前
python sorted函数详细剖析
开发语言·人工智能·windows·python·1024程序员节
qq_355939492 小时前
标签打印发虚、断线、糊成一团?先分清是哪种“不清“
1024程序员节
云和数据.ChenGuang2 小时前
Python map() 函数详解:从语法、原理到实战用法
开发语言·人工智能·python·langchain·1024程序员节
AI视频工具指南3 小时前
一个人能做 AI 短剧吗?实际要做哪些决定
人工智能·aigc·音视频·1024程序员节
终端安全笔记3 小时前
下一个大版本要动的三处:更新指令、传输要求、策略写法
网络安全·ios·1024程序员节·移动设备管理
砚底藏山河3 小时前
python量化实操:回测数据缓存与重放
java·python·金融·1024程序员节