MySQL事务深度解析:ACID到MVCC实战+万字长文解析

🔥个人主页: Milestone-里程碑

❄️个人专栏: <<力扣hot100>> <<C++>><<Linux>>

<<Git>><<MySQL>>

🌟心向往之行必能至

目录

[深入理解 MySQL 事务:从 ACID 原理到 MVCC 实现,解决并发一致性难题](#深入理解 MySQL 事务:从 ACID 原理到 MVCC 实现,解决并发一致性难题)

一、事务的起源:为什么我们需要事务?

[1.1 没有事务的 "灾难现场":从火车票超卖说起](#1.1 没有事务的 “灾难现场”:从火车票超卖说起)

[1.2 事务的定义:什么是 MySQL 事务?](#1.2 事务的定义:什么是 MySQL 事务?)

[1.3 事务的前提:MySQL 引擎的支持](#1.3 事务的前提:MySQL 引擎的支持)

[二、事务的灵魂:ACID 属性详解](#二、事务的灵魂:ACID 属性详解)

[2.1 原子性(Atomicity):要么全做,要么全不做](#2.1 原子性(Atomicity):要么全做,要么全不做)

实战案例:原子性的体现

[原子性的实现原理:Undo Log](#原子性的实现原理:Undo Log)

[2.2 一致性(Consistency):从一个一致状态到另一个一致状态](#2.2 一致性(Consistency):从一个一致状态到另一个一致状态)

[一致性的保障:技术 + 业务](#一致性的保障:技术 + 业务)

[2.3 隔离性(Isolation):并发事务互不干扰](#2.3 隔离性(Isolation):并发事务互不干扰)

隔离性的痛点:并发事务的三大问题

隔离级别的解决方案:从低到高的四重保障

隔离级别的实战操作:查看与设置

不同隔离级别的效果演示(基于文档案例)

[(1)读未提交:能读到未提交的 "脏数据"](#(1)读未提交:能读到未提交的 “脏数据”)

(2)读提交:解决脏读,但存在不可重复读

[(3)可重复读:解决不可重复读,MySQL 中解决幻读](#(3)可重复读:解决不可重复读,MySQL 中解决幻读)

(4)串行化:完全隔离,但性能极低

[2.4 持久性(Durability):一旦提交,永久有效](#2.4 持久性(Durability):一旦提交,永久有效)

持久性的实战验证(文档案例)

[持久性的实现原理:Redo Log](#持久性的实现原理:Redo Log)

三、事务的操作实践:从提交方式到保存点

[3.1 事务的提交方式:自动提交 vs 手动提交](#3.1 事务的提交方式:自动提交 vs 手动提交)

(1)查看与修改提交方式

[(2)自动提交的特性:单条 DML 即事务](#(2)自动提交的特性:单条 DML 即事务)

[(3)手动提交的特性:begin/commit 控制](#(3)手动提交的特性:begin/commit 控制)

[(4)关键结论:begin 会临时覆盖 autocommit](#(4)关键结论:begin 会临时覆盖 autocommit)

[3.2 事务的保存点:部分回滚的利器](#3.2 事务的保存点:部分回滚的利器)

保存点的操作语法

实战案例:保存点的使用

注意事项

[3.3 事务操作的常见误区](#3.3 事务操作的常见误区)

[四、事务的核心:MVCC 多版本并发控制](#四、事务的核心:MVCC 多版本并发控制)

[4.1 MVCC 的三大基础组件](#4.1 MVCC 的三大基础组件)

[(1)隐藏字段:每行记录的 "身份信息"](#(1)隐藏字段:每行记录的 “身份信息”)

[(2)Undo Log:历史版本的 "仓库"](#(2)Undo Log:历史版本的 “仓库”)

[(3)Read View:版本可见性的 "裁判"](#(3)Read View:版本可见性的 “裁判”)

[4.2 MVCC 的核心逻辑:可见性判断规则](#4.2 MVCC 的核心逻辑:可见性判断规则)

[4.3 MVCC 的实战流程:为什么 RR 级别能 "可重复读"?](#4.3 MVCC 的实战流程:为什么 RR 级别能 “可重复读”?)

场景设定

[步骤 1:事务 2 生成 Read View](#步骤 1:事务 2 生成 Read View)

[步骤 2:判断当前版本的可见性](#步骤 2:判断当前版本的可见性)

[步骤 3:事务 2 再次执行快照读(RR 级别)](#步骤 3:事务 2 再次执行快照读(RR 级别))

[关键差异:RR 与 RC 级别的 Read View 生成时机](#关键差异:RR 与 RC 级别的 Read View 生成时机)

五、事务的进阶:锁机制与并发控制

[5.1 锁的基本分类:从粒度到类型](#5.1 锁的基本分类:从粒度到类型)

[(1)锁粒度:行锁 vs 表锁](#(1)锁粒度:行锁 vs 表锁)

[(2)锁类型:共享锁 vs 排他锁](#(2)锁类型:共享锁 vs 排他锁)

[5.2 InnoDB 的特殊锁:解决幻读的间隙锁](#5.2 InnoDB 的特殊锁:解决幻读的间隙锁)

[(1)间隙锁:锁定 "不存在的记录"](#(1)间隙锁:锁定 “不存在的记录”)

[(2)Next-Key 锁:行锁 + 间隙锁](#(2)Next-Key 锁:行锁 + 间隙锁)

[5.3 锁的实战问题:死锁与解决方案](#5.3 锁的实战问题:死锁与解决方案)

死锁的解决方案

六、事务的最佳实践:从理论到落地

[6.1 选择合适的隔离级别](#6.1 选择合适的隔离级别)

[6.2 避免长事务](#6.2 避免长事务)

[6.3 正确使用锁与 MVCC](#6.3 正确使用锁与 MVCC)

[6.4 异常处理与数据一致性校验](#6.4 异常处理与数据一致性校验)

[6.5 监控与调优](#6.5 监控与调优)

[七、总结:事务是 MySQL 的 "一致性守护者"](#七、总结:事务是 MySQL 的 “一致性守护者”)


深入理解 MySQL 事务:从 ACID 原理到 MVCC 实现,解决并发一致性难题

在数据库操作中,你是否遇到过这样的场景:火车票售票系统里同一张票被卖出两次,银行转账时钱扣了却没到账,电商下单后库存没减少导致超卖?这些问题的根源,往往是缺乏对 "事务" 的有效控制。作为 MySQL 数据库的核心特性之一,事务不仅是保证数据一致性的基石,更是高并发场景下系统稳定运行的关键。本文将从事务的基础概念出发,深入剖析 ACID 属性的实现原理、隔离级别差异、MVCC 多版本并发控制机制,结合大量实战案例,带你彻底掌握 MySQL 事务的设计逻辑与最佳实践。

一、事务的起源:为什么我们需要事务?

在讨论事务的技术细节前,我们不妨先思考一个问题:事务究竟是为了解决什么问题而诞生的?答案很简单 ------解决 "多个操作打包执行" 的一致性问题

1.1 没有事务的 "灾难现场":从火车票超卖说起

以文档中提到的 "火车票售票系统" 为例,假设tickets表中只有 1 张从西安到兰州的车票(nums=1),此时有两个客户端同时购票,操作流程如下:

  1. 客户端 A 查询票数:if (nums > 0),发现有票,准备卖票;
  2. 客户端 A 尚未执行update nums = nums - 1,客户端 B 也查询票数:if (nums > 0),同样发现有票;
  3. 客户端 A 执行更新,将nums改为 0,提交操作;
  4. 客户端 B 不知道 A 已更新,同样执行update nums = nums - 1,将nums改为 - 1。

最终结果是:同一张票被卖给了两个人,库存出现负数 ------ 这就是典型的 "超卖" 问题。类似的场景还有很多:

  • 银行转账:用户 A 给用户 B 转 1000 元,A 的账户扣了钱,但 B 的账户没加钱(网络中断);
  • 电商下单:用户下单后,订单创建成功,但商品库存未减少,导致后续用户继续下单超卖;
  • 日志记录:系统操作需要同时写入操作日志和业务数据,结果业务数据写了,日志没写,导致溯源困难。

这些问题的共性在于:一组相关的操作(如 "查库存 + 扣库存""扣钱 + 加钱")没有被当作一个 "整体" 执行------ 要么全部成功,要么全部失败。而事务的核心作用,就是将这组操作 "打包",保证其原子性与一致性。

1.2 事务的定义:什么是 MySQL 事务?

根据文档中的定义:事务是一组 DML(数据操纵语言,如 INSERT、UPDATE、DELETE)语句的集合,这些语句在逻辑上存在相关性,要么全部成功执行,要么全部失败回滚,是一个不可分割的整体

更通俗地说,事务就像 "打包快递":你把多个物品(DML 操作)放进一个箱子(事务),快递员要么把整个箱子送到目的地(全部执行成功),要么箱子中途丢失(全部失败回滚),绝不会出现 "部分物品送达" 的情况。

举个文档中的教务系统例子:删除学生信息时,需要同时删除 3 类数据:

  1. 学生基本信息表(姓名、电话、籍贯);
  2. 学生成绩表(各科分数、排名);
  3. 学生行为表(论坛发帖、考勤记录)。

这 3 个DELETE操作必须作为一个事务执行:如果前两个表删了,第三个表删失败,就会导致数据残留(成绩表有记录,但行为表没删),后续查询时出现逻辑矛盾。而事务可以保证:只要有一个操作失败,所有已执行的操作都会回滚到初始状态,避免数据不一致。

1.3 事务的前提:MySQL 引擎的支持

需要特别注意的是:并非所有 MySQL 存储引擎都支持事务 。根据文档中的show engines命令输出,只有 InnoDB 引擎支持事务,而 MyISAM、MEMORY、CSV 等引擎均不支持。

我们可以通过以下命令查看当前数据库的引擎支持情况:

sql

复制代码
-- 表格形式显示所有引擎
show engines;

-- 行形式显示,更清晰
show engines \G

关键输出如下(重点看Transactions字段):

plaintext

复制代码
*************************** 1. row ***************************
Engine: InnoDB
Support: DEFAULT  -- MySQL默认引擎
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 引擎的核心原因 ------ 事务支持是保证数据一致性的基础。

二、事务的灵魂:ACID 属性详解

事务之所以能解决上述问题,核心在于它必须满足ACID 四大属性。这四个属性是事务的 "基石",缺少任何一个,事务的一致性保障就会失效。

2.1 原子性(Atomicity):要么全做,要么全不做

定义:事务中的所有操作,要么全部执行成功,要么全部执行失败回滚,不会停留在 "中间状态"。

就像文档中提到的:"事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样"。

实战案例:原子性的体现

我们通过文档中的 "账户表" 案例,演示原子性:

  1. 创建account表(InnoDB 引擎):

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;
  1. 开启事务,插入两条记录,然后回滚:

sql

复制代码
-- 开启事务(begin或start transaction均可)
begin;

-- 创建保存点save1(用于部分回滚)
savepoint save1;

-- 插入第一条记录
insert into account values (1, '张三', 100);

-- 创建保存点save2
savepoint save2;

-- 插入第二条记录
insert into account values (2, '李四', 10000);

-- 查看当前数据(两条记录都存在)
select * from account;
-- 结果:id=1(张三,100)、id=2(李四,10000)

-- 回滚到save2(撤销第二条记录的插入)
rollback to save2;

-- 查看数据(只剩第一条记录)
select * from account;
-- 结果:id=1(张三,100)

-- 回滚到事务开始前(撤销所有操作)
rollback;

-- 查看数据(空表)
select * from account;
-- 结果:Empty set

这个案例完美体现了原子性:即使事务中部分操作已执行(插入张三),只要未提交,通过rollback就能回滚到初始状态,不会留下 "半完成" 的数据。

原子性的实现原理:Undo Log

InnoDB 通过Undo Log(回滚日志) 实现原子性。当事务执行 DML 操作时,InnoDB 会先将数据修改前的状态记录到 Undo Log 中:

  • 执行INSERT时,Undo Log 记录 "删除这条新插入的记录";
  • 执行UPDATE时,Undo Log 记录 "将字段改回原值";
  • 执行DELETE时,Undo Log 记录 "插入这条被删除的记录"。

如果事务需要回滚,InnoDB 就会读取 Undo Log 中的记录,将数据恢复到修改前的状态。

2.2 一致性(Consistency):从一个一致状态到另一个一致状态

定义:事务开始前和结束后,数据库的完整性约束(如主键唯一、外键关联、字段非空)没有被破坏,数据始终处于 "逻辑正确" 的状态。

一致性的核心是 "逻辑正确",它不仅依赖数据库的技术保障,更依赖业务逻辑的合理性。例如:

  • 银行转账:A 账户扣 1000 元,B 账户加 1000 元,总金额(A+B)保持不变;
  • 库存扣减:商品库存nums不能为负数,订单数量不能大于库存数量;
  • 字段约束:account表的blance(余额)不能为负数,name不能为 null。
一致性的保障:技术 + 业务

文档中提到:"一致性是通过原子性(Atomicity)、隔离性(Isolation)、持久性(Durability)来保证的(即 AID 保障 C),但最终还需要业务逻辑的支撑"。

举个反例:如果业务逻辑本身有问题(如没判断库存是否为负就扣减),即使事务满足 AID,一致性也会被破坏。例如:

sql

复制代码
-- 错误的业务逻辑:未判断库存是否为0,直接扣减
begin;
update tickets set nums = nums - 1 where id = 10;
commit;

如果初始nums=0,执行后nums=-1,此时事务满足原子性(执行成功)、隔离性(其他事务看不到中间状态)、持久性(修改永久保存),但数据逻辑错误(库存为负)------ 这就是 "业务逻辑导致的一致性问题"。

因此,一致性的保障需要两方面:

  1. 数据库层面:通过 AID 属性防止技术层面的不一致(如回滚失败、并发污染);
  2. 应用层面:通过业务逻辑判断(如扣库存前查库存、转账前查余额)防止逻辑层面的不一致。

2.3 隔离性(Isolation):并发事务互不干扰

定义:数据库允许多个事务同时执行,隔离性可以防止多个事务因 "交叉执行" 导致的数据不一致。

想象一下:如果 100 个用户同时操作同一个账户,每个事务都在 "查余额 + 改余额",如果不隔离,就会出现 "多个事务读取到同一个旧余额,导致最终余额计算错误" 的问题。

隔离性的痛点:并发事务的三大问题

当多个事务并发执行时,若不做隔离,会出现三类典型问题(文档中详细提及):

问题类型 定义 场景示例
脏读(Dirty Read) 事务 A 读取到事务 B 未提交的修改(B 后续回滚,A 读到的数据是 "脏数据") 事务 B 给 A 转账 1000 元(未提交),A 查询余额看到增加 1000 元;B 回滚后,A 的 "增加 1000 元" 是假的
不可重复读(Non-Repeatable Read) 事务 A 多次读取同一数据,期间事务 B 修改并提交该数据,导致 A 多次读取结果不一致 事务 A 第一次查余额是 1000 元;事务 B 转账 500 元并提交;A 再次查余额是 1500 元(同一事务内结果不同)
幻读(Phantom Read) 事务 A 多次执行同一查询(如 "查余额> 1000 的用户"),期间事务 B 插入 / 删除符合条件的数据,导致 A 多次查询的 "记录数" 不一致 事务 A 第一次查 "余额> 1000 的用户" 有 2 人;事务 B 插入 1 个余额 2000 的用户并提交;A 再次查时变成 3 人(像出现了 "幻觉")
隔离级别的解决方案:从低到高的四重保障

为了解决上述问题,MySQL 定义了四种隔离级别(优先级:串行化 > 可重复读 > 读提交 > 读未提交),不同级别对问题的解决能力不同:

隔离级别 脏读 不可重复读 幻读 加锁情况 并发性能
读未提交(Read Uncommitted) √(会出现) 不加锁 最高(但安全性最低)
读提交(Read Committed) ×(解决) 不加锁 较高
可重复读(Repeatable Read) × ×(解决) ×(MySQL 中解决) 不加锁(特殊情况加间隙锁) 中等(MySQL 默认)
串行化(Serializable) × × × 全加锁(读加共享锁,写加排他锁) 最低(安全性最高)

关键说明

  1. 隔离级别越高,安全性越强,但并发性能越低(因为加锁更严格,事务排队执行);
  2. MySQL 默认隔离级别是 "可重复读(RR)",且在 RR 级别下通过 "间隙锁" 解决了幻读(其他数据库的 RR 级别通常无法解决幻读);
  3. 生产环境中,很少使用 "读未提交"(太不安全)和 "串行化"(性能太差),常用 "读提交"(如 Oracle 默认)或 "可重复读"(MySQL 默认)。
隔离级别的实战操作:查看与设置

我们可以通过 SQL 命令查看和修改隔离级别,文档中提供了详细步骤:

  1. 查看隔离级别

sql

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

-- 查看当前会话隔离级别(仅当前会话生效)
select @@session.tx_isolation;

-- 简写(等同于查看当前会话)
select @@tx_isolation;
  1. 修改隔离级别

sql

复制代码
-- 格式:SET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL 级别;

-- 修改当前会话隔离级别为"读未提交"
set session transaction isolation level read uncommitted;

-- 修改全局隔离级别为"可重复读"(需重启会话生效)
set global transaction isolation level repeatable read;
不同隔离级别的效果演示(基于文档案例)

我们以 "账户表account(id=1,name = 张三,blance=100)" 为例,演示不同隔离级别的差异:

(1)读未提交:能读到未提交的 "脏数据"
  • 终端 A(事务 A):

sql

复制代码
-- 设置隔离级别为读未提交
set global transaction isolation level read uncommitted;
-- 重启会话后生效
select @@tx_isolation; -- 输出:READ-UNCOMMITTED

begin;
-- 修改张三余额为123(未提交)
update account set blance=123 where id=1;
  • 终端 B(事务 B):

sql

复制代码
begin;
-- 读取张三余额:看到123(事务A未提交的修改)
select * from account where id=1;
-- 结果:blance=123(脏数据)
  • 终端 A 回滚:

sql

复制代码
rollback;
  • 终端 B 再次读取:

sql

复制代码
select * from account where id=1;
-- 结果:blance=100(脏数据消失)

结论:读未提交级别下,事务能读到其他事务未提交的修改,存在脏读问题。

(2)读提交:解决脏读,但存在不可重复读
  • 终端 A(事务 A):

sql

复制代码
-- 设置全局隔离级别为读提交
set global transaction isolation level read committed;
-- 重启会话
select @@tx_isolation; -- 输出:READ-COMMITTED

begin;
-- 修改张三余额为321(未提交)
update account set blance=321 where id=1;
  • 终端 B(事务 B):

sql

复制代码
begin;
-- 读取张三余额:看到100(未读到未提交的修改,解决脏读)
select * from account where id=1;
  • 终端 A 提交事务:

sql

复制代码
commit;
  • 终端 B 再次读取:

sql

复制代码
select * from account where id=1;
-- 结果:blance=321(同一事务内两次读取结果不同,不可重复读)

结论:读提交级别下,事务只能读到其他事务已提交的修改(解决脏读),但同一事务内多次读取可能不一致(不可重复读)。

(3)可重复读:解决不可重复读,MySQL 中解决幻读
  • 终端 A(事务 A):

sql

复制代码
-- 设置全局隔离级别为可重复读
set global transaction isolation level repeatable read;
-- 重启会话
select @@tx_isolation; -- 输出:REPEATABLE-READ

begin;
-- 修改张三余额为4321(未提交)
update account set blance=4321 where id=1;
commit;
  • 终端 B(事务 B):

sql

复制代码
begin;
-- 第一次读:blance=100
select * from account where id=1;

-- 终端A提交后,第二次读:仍为100(同一事务内结果一致,解决不可重复读)
select * from account where id=1;
  • 终端 B 提交后读取:

sql

复制代码
commit;
select * from account where id=1;
-- 结果:blance=4321(事务结束后看到最新数据)

幻读的解决:文档中提到,MySQL 的 RR 级别通过 "Next-Key 锁(间隙锁 + 行锁)" 解决幻读。例如:

  • 事务 A 执行 "查余额> 1000 的用户"(初始有 2 人);
  • 事务 B 尝试插入 1 个余额 2000 的用户,会被间隙锁阻塞;
  • 事务 A 提交后,事务 B 才能插入,避免 A 再次查询时出现 "幻读"。
(4)串行化:完全隔离,但性能极低

串行化级别下,所有事务串行执行(读加共享锁,写加排他锁),完全不会出现并发问题,但并发性能骤降。例如:

  • 终端 A 开启事务,执行select * from account(加共享锁);
  • 终端 B 执行update account set blance=500 where id=1(需加排他锁),会被阻塞,直到终端 A 提交事务;
  • 若终端 A 长时间不提交,终端 B 会超时失败。

生产环境中几乎不用串行化,仅在对数据一致性要求极高(如金融核心交易)且并发量极低的场景下使用。

2.4 持久性(Durability):一旦提交,永久有效

定义:事务提交后,对数据的修改会永久保存到磁盘,即使系统崩溃(如断电、服务器宕机),修改也不会丢失。

持久性的实战验证(文档案例)
  1. 终端 A 开启事务,插入数据并提交:

sql

复制代码
begin;
insert into account values (3, '王五', 5000);
commit; -- 提交事务
  1. 终端 A 异常终止(如ctrl+\强制关闭 MySQL 客户端);
  2. 重新连接 MySQL,终端 B 查询:

sql

复制代码
select * from account where id=3;
-- 结果:id=3(王五,5000)------数据未丢失

即使服务器中途断电,只要事务已提交,重启后数据依然存在。这就是持久性的体现。

持久性的实现原理:Redo Log

InnoDB 通过Redo Log(重做日志) 实现持久性。其核心逻辑是:

  • 事务执行 DML 操作时,先将修改写入 Redo Log(顺序写入,速度快);
  • 事务提交时,InnoDB 确保 Redo Log 已刷写到磁盘("日志先行" 原则);
  • 即使系统崩溃,重启后 InnoDB 会读取 Redo Log,将未刷到数据文件的修改 "重做",恢复数据。

Redo Log 的存在,解决了 "数据刷盘慢" 的问题:直接刷数据文件(如.ibd)是随机写(速度慢),而 Redo Log 是顺序写(速度快),通过 "先写日志,再异步刷数据" 的方式,兼顾了性能与持久性。

三、事务的操作实践:从提交方式到保存点

掌握了 ACID 属性后,我们需要落地到实际操作 ------ 如何开启、提交、回滚事务?如何使用保存点?自动提交与手动提交有什么区别?

3.1 事务的提交方式:自动提交 vs 手动提交

MySQL 中事务的提交方式分为两种:自动提交(默认)手动提交

(1)查看与修改提交方式

通过autocommit变量控制提交方式:

sql

复制代码
-- 查看当前提交方式(ON=自动提交,OFF=手动提交)
show variables like 'autocommit';

-- 修改为手动提交(当前会话生效)
set autocommit=0;

-- 修改为自动提交(全局生效,需重启会话)
set global autocommit=1;
(2)自动提交的特性:单条 DML 即事务

默认情况下,autocommit=ON,此时每一条 DML 语句都是一个独立的事务,执行后自动提交,无法回滚。例如:

sql

复制代码
-- 自动提交开启
show variables like 'autocommit'; -- ON

-- 执行insert后自动提交
insert into account values (4, '赵六', 3000);

-- 尝试回滚(无效,因为已自动提交)
rollback;

select * from account where id=4; -- 结果:存在(回滚失败)
(3)手动提交的特性:begin/commit 控制

autocommit=OFF或通过begin/start transaction开启事务后,需要手动执行commit提交,rollback回滚。例如:

sql

复制代码
-- 手动提交开启
set autocommit=0;

-- 开启事务(可省略,因autocommit=OFF)
begin;

insert into account values (5, '孙七', 4000);

-- 未提交前,其他事务看不到该记录(隔离性)
-- 执行回滚
rollback;

select * from account where id=5; -- 结果:不存在(回滚成功)
(4)关键结论:begin 会临时覆盖 autocommit

文档中通过实验得出一个重要结论:只要执行beginstart transaction,事务就必须通过commit提交,与autocommit的设置无关

例如:

sql

复制代码
-- 自动提交开启(autocommit=ON)
show variables like 'autocommit'; -- ON

-- 开启事务
begin;

insert into account values (6, '周八', 5000);

-- 异常终止客户端(未提交)
-- 重新连接后查询:id=6不存在(自动回滚)

即使autocommit=ONbegin也会将后续操作 "打包" 为一个事务,必须手动提交才生效。

3.2 事务的保存点:部分回滚的利器

在复杂事务中,有时我们不需要回滚整个事务,只需回滚到某个 "中间状态"------ 这就需要保存点(Savepoint)

保存点的操作语法

sql

复制代码
-- 创建保存点(名称自定义)
savepoint 保存点名称;

-- 回滚到指定保存点(不影响保存点之后的操作)
rollback to 保存点名称;

-- 删除保存点(可选,事务结束后自动删除)
release savepoint 保存点名称;
实战案例:保存点的使用

sql

复制代码
begin;

-- 插入第一条记录,创建保存点s1
insert into account values (7, '吴九', 6000);
savepoint s1;

-- 插入第二条记录,创建保存点s2
insert into account values (8, '郑十', 7000);
savepoint s2;

-- 查看当前数据(两条记录都存在)
select * from account where id in (7,8); -- 均存在

-- 回滚到s1(撤销第二条记录的插入)
rollback to s1;

-- 查看数据(只剩第一条记录)
select * from account where id in (7,8); -- 仅id=7存在

-- 继续插入第三条记录
insert into account values (9, '钱十一', 8000);

-- 提交事务(最终保存id=7和id=9)
commit;
注意事项
  1. 保存点仅在当前事务内有效,事务提交或回滚到事务开始后,保存点自动失效;
  2. 若回滚到某个保存点后,再执行rollback(不指定保存点),会回滚到事务开始前;
  3. 若删除保存点后,再回滚到该保存点,会报错(保存点不存在)。

3.3 事务操作的常见误区

  1. 误以为 MyISAM 支持事务 :MyISAM 引擎不支持事务,即使执行begin/commit,也不会有事务效果(操作会立即生效);
  2. 提交后回滚 :事务提交(commit)后,无法再通过rollback回滚(数据已持久化);
  3. 忽略隔离级别的影响:在 "读未提交" 级别下,即使事务未提交,其他事务也能读到修改,需根据业务场景选择隔离级别;
  4. 长事务风险:长时间未提交的事务会占用锁资源,导致其他事务阻塞,甚至引发死锁(如电商下单后长时间不支付,占用库存锁)。

四、事务的核心:MVCC 多版本并发控制

在讲解隔离级别时,我们提到:InnoDB 在 "读提交" 和 "可重复读" 级别下,不加锁就能实现快照读 (读取历史版本),从而提高并发性能。这背后的核心技术,就是MVCC(Multi-Version Concurrency Control,多版本并发控制)

MVCC 的本质是:为数据维护多个版本,事务读取时根据 "可见性规则" 选择合适的版本,实现 "读不阻塞写,写不阻塞读"

4.1 MVCC 的三大基础组件

要理解 MVCC,必须先掌握它的三个核心组件(文档中详细说明):

  1. 隐藏字段:InnoDB 为每一行记录添加 3 个隐藏字段,用于维护版本信息;
  2. Undo Log:存储数据的历史版本,形成 "版本链";
  3. Read View:事务快照读时生成的 "读视图",用于判断版本的可见性。
(1)隐藏字段:每行记录的 "身份信息"

InnoDB 为每一行记录添加了 3 个隐藏字段(用户不可见,需通过底层工具查看):

字段名称 长度 作用
DB_TRX_ID 6 字节 记录创建或最后一次修改该记录的事务 ID(事务 ID 递增)
DB_ROLL_PTR 7 字节 回滚指针,指向该记录的上一个历史版本(存储在 Undo Log 中)
DB_ROW_ID 6 字节 隐式自增主键(若表无主键,InnoDB 会用该字段作为聚簇索引)

此外,还有一个删除标记(Delete Flag):记录被删除时,不会真的物理删除,而是将该标记设为 1(查询时过滤掉标记为 1 的记录)。

例如,插入一条student记录(name=张三,age=28),其隐藏字段初始状态如下:

name age DB_TRX_ID(创建事务 ID) DB_ROW_ID(隐式主键) DB_ROLL_PTR(回滚指针) Delete Flag
张三 28 null(初始无事务 ID) 1(自增) null(无历史版本) 0(未删除)
(2)Undo Log:历史版本的 "仓库"

Undo Log(回滚日志)不仅用于事务回滚,还用于存储数据的历史版本,形成 "版本链"。其工作流程如下:

  1. 事务修改记录时,先将修改前的记录拷贝到 Undo Log 中;
  2. 更新原记录的DB_TRX_ID为当前事务 ID,DB_ROLL_PTR指向 Undo Log 中的历史版本;
  3. 多次修改后,Undo Log 中的历史版本通过DB_ROLL_PTR串联,形成 "版本链"(最新版本在数据表中,历史版本在 Undo Log 中)。

实战案例:版本链的形成 假设事务 10(ID=10)修改student记录(将name=张三改为name=李四):

  1. 拷贝原记录到 Undo Log;
  2. 修改原记录的DB_TRX_ID=10DB_ROLL_PTR指向 Undo Log 中的历史版本;
  3. 事务 10 提交。

此时数据状态:

  • 数据表中的当前版本:

    name age DB_TRX_ID DB_ROW_ID DB_ROLL_PTR Delete Flag
    李四 28 10 1 0x112233(指向 Undo Log) 0
  • Undo Log 中的历史版本:

    name age DB_TRX_ID DB_ROW_ID DB_ROLL_PTR Delete Flag
    张三 28 null 1 null 0

接着,事务 11(ID=11)修改该记录(将age=28改为age=38):

  1. 拷贝当前版本(李四,28)到 Undo Log;
  2. 修改原记录的DB_TRX_ID=11DB_ROLL_PTR指向新的历史版本;
  3. 事务 11 提交。

最终版本链:

  • 数据表当前版本:name=李四,age=38,DB_TRX_ID=11,DB_ROLL_PTR=0x112236
  • Undo Log 版本 1:name=李四,age=28,DB_TRX_ID=10,DB_ROLL_PTR=0x112233
  • Undo Log 版本 2:name=张三,age=28,DB_TRX_ID=null,DB_ROLL_PTR=null

版本链的顺序:当前版本 → 事务 11 版本 → 事务 10 版本 → 初始版本。

(3)Read View:版本可见性的 "裁判"

当事务执行 "快照读"(如普通select)时,InnoDB 会生成一个Read View(读视图),用于判断版本链中的哪个版本对当前事务 "可见"。

Read View 包含 4 个核心属性(文档中简化后):

  • m_ids:生成 Read View 时,系统中所有 "活跃事务" 的 ID 列表(未提交的事务);
  • up_limit_idm_ids中最小的事务 ID(小于该 ID 的事务均已提交,其修改可见);
  • low_limit_id:生成 Read View 时,系统尚未分配的下一个事务 ID(大于等于该 ID 的事务均未开始,其修改不可见);
  • creator_trx_id:生成该 Read View 的事务 ID(当前事务 ID)。

4.2 MVCC 的核心逻辑:可见性判断规则

事务执行快照读时,会从版本链的 "最新版本" 开始,依次判断每个版本是否符合 Read View 的可见性规则。规则如下(文档中对应源码逻辑):

  1. 若当前版本的DB_TRX_ID == creator_trx_id:该版本是当前事务自己修改的,可见;
  2. 若当前版本的DB_TRX_ID < up_limit_id:该版本由 "已提交的事务" 修改(事务 ID 小于最小活跃 ID),可见;
  3. 若当前版本的DB_TRX_ID >= low_limit_id:该版本由 "未开始的事务" 修改(事务 ID 大于等于下一个分配 ID),不可见;
  4. DB_TRX_IDup_limit_idlow_limit_id之间:
    • DB_TRX_IDm_ids中(属于活跃事务):不可见;
    • 若不在m_ids中(事务已提交):可见。

若当前版本不可见,则通过DB_ROLL_PTR跳到上一个历史版本,重复上述判断,直到找到可见版本或版本链结束(返回空)。

4.3 MVCC 的实战流程:为什么 RR 级别能 "可重复读"?

我们结合文档中的案例,演示 MVCC 在 "可重复读(RR)" 级别下的工作流程:

场景设定
  • 初始数据:student表有一条记录(name=张三,age=28DB_TRX_ID=null);
  • 事务 1(ID=1):开启后未操作(活跃状态);
  • 事务 2(ID=2):开启后执行快照读(select * from student);
  • 事务 3(ID=3):开启后未操作(活跃状态);
  • 事务 4(ID=4):开启后修改记录(name=张三→李四),并提交。
步骤 1:事务 2 生成 Read View

事务 2 执行快照读时,系统中活跃事务的 ID 是 1、3(事务 1 和 3 未提交),因此生成的 Read View 如下:

  • m_ids = [1, 3]
  • up_limit_id = 1(m_ids 中最小 ID);
  • low_limit_id = 5(下一个分配的事务 ID,因当前最大 ID 是 4);
  • creator_trx_id = 2(当前事务 ID)。
步骤 2:判断当前版本的可见性

事务 4 已提交,修改后的当前版本信息:

  • DB_TRX_ID=4name=李四,age=28

根据可见性规则判断:

  1. 4 != 2(规则 1 不满足);
  2. 4 >= 1(规则 2 不满足);
  3. 4 < 5(规则 3 不满足);
  4. 41~5之间,且不在m_ids=[1,3]中(事务 4 已提交):满足规则 4,可见。

因此,事务 2 读到的版本是事务 4 修改后的版本(name=李四,age=28)。

步骤 3:事务 2 再次执行快照读(RR 级别)

事务 2 再次执行select * from student时,由于 RR 级别下 "同一事务内只生成一次 Read View",因此使用的还是步骤 1 中的 Read View。

即使此时有新的事务 5(ID=5)修改记录并提交(age=28→38),事务 2 的 Read View 依然是m_ids=[1,3]low_limit_id=5。新事务 5 的 ID=5 >= low_limit_id=5,其修改不可见,事务 2 读到的还是name=李四,age=28------ 这就是 "可重复读" 的实现原理。

关键差异:RR 与 RC 级别的 Read View 生成时机

MVCC 在 RR 和 RC 级别下的核心差异,在于Read View 的生成时机

  • RR 级别:同一事务内,第一次快照读生成 Read View 后,后续所有快照读都复用该 Read View(因此多次读取结果一致,可重复读);
  • RC 级别:同一事务内,每次快照读都会重新生成 Read View(因此能读到其他事务提交的最新修改,导致不可重复读)。

这也是为什么 RR 级别能解决 "不可重复读",而 RC 级别不能的根本原因。

五、事务的进阶:锁机制与并发控制

MVCC 解决了 "读 - 写" 并发的问题(读不阻塞写,写不阻塞读),但对于 "写 - 写" 并发(多个事务同时修改同一记录),还需要锁机制来保证数据一致性。InnoDB 的锁机制与事务隔离级别密切相关,是理解高并发场景的关键。

5.1 锁的基本分类:从粒度到类型

InnoDB 的锁可以从两个维度分类:锁粒度锁类型

(1)锁粒度:行锁 vs 表锁
  • 行锁 :仅锁定某一行记录(如update account set blance=100 where id=1),粒度细,并发性能高,是 InnoDB 的默认锁粒度;
  • 表锁 :锁定整个表(如lock table account write),粒度粗,并发性能低,MyISAM 引擎默认使用表锁。

InnoDB 优先使用行锁,但在某些场景下会升级为表锁(如update account set blance=100未加 where 条件,修改全表,会锁全表)。

(2)锁类型:共享锁 vs 排他锁
  • 共享锁(S 锁,读锁) :多个事务可同时加 S 锁,用于读取操作(如select ... lock in share mode);加 S 锁后,其他事务可加 S 锁,但不可加排他锁;
  • 排他锁(X 锁,写锁) :仅允许一个事务加 X 锁,用于修改操作(如insertupdatedeleteselect ... for update);加 X 锁后,其他事务不可加任何锁。

锁的兼容性如下("√" 表示兼容,"×" 表示冲突):

当前锁 \ 请求锁 共享锁(S) 排他锁(X)
共享锁(S) ×
排他锁(X) × ×

5.2 InnoDB 的特殊锁:解决幻读的间隙锁

在 "可重复读(RR)" 级别下,InnoDB 通过间隙锁(Gap Lock)Next-Key 锁解决幻读问题。

(1)间隙锁:锁定 "不存在的记录"

间隙锁针对的是 "记录之间的间隙",而非实际记录。例如,account表的id值为 1、3、5,那么存在的间隙是(-∞,1)、(1,3)、(3,5)、(5,+∞)。

当事务执行 "范围查询并修改" 时(如update account set blance=100 where id>3),InnoDB 会对(3,5)和(5,+∞)这两个间隙加锁,防止其他事务在这些间隙中插入新记录(如插入 id=4),从而避免幻读。

(2)Next-Key 锁:行锁 + 间隙锁

Next-Key 锁是 "行锁 + 间隙锁" 的组合,既锁定当前记录,也锁定记录前后的间隙。例如,update account set blance=100 where id=3,InnoDB 会锁定(1,3)间隙、id=3 的行、(3,5)间隙 ------ 这就是 Next-Key 锁。

Next-Key 锁的存在,确保了 RR 级别下不会出现幻读,但也可能导致 "锁等待"(如其他事务插入 id=4 时被间隙锁阻塞)。

5.3 锁的实战问题:死锁与解决方案

当两个事务互相等待对方释放锁时,会出现死锁。例如:

  • 事务 A:update account set blance=100 where id=1(加 X 锁 id=1);
  • 事务 B:update account set blance=200 where id=2(加 X 锁 id=2);
  • 事务 A:update account set blance=300 where id=2(等待事务 B 释放 id=2 的 X 锁);
  • 事务 B:update account set blance=400 where id=1(等待事务 A 释放 id=1 的 X 锁)。

此时两个事务互相等待,形成死锁,MySQL 会检测到死锁并终止其中一个事务(回滚),释放锁资源。

死锁的解决方案
  1. 固定锁顺序:所有事务修改记录时,按同一顺序加锁(如先锁 id 小的记录,再锁 id 大的);
  2. 减少锁持有时间:事务中尽量减少操作步骤,尽快提交(避免长事务占用锁);
  3. 使用低隔离级别:如 "读提交" 级别下,InnoDB 不会使用间隙锁,减少死锁概率(但需接受不可重复读);
  4. 设置锁超时时间 :通过innodb_lock_wait_timeout设置锁等待超时时间(默认 50 秒),超时后自动回滚。

六、事务的最佳实践:从理论到落地

掌握了事务的原理后,我们需要在实际开发中规避风险,优化性能。以下是 MySQL 事务的最佳实践总结:

6.1 选择合适的隔离级别

  • 读未提交:仅用于对数据一致性要求极低的场景(如日志统计),几乎不用;
  • 读提交:适用于对一致性要求一般,追求高并发的场景(如电商商品详情页、新闻列表);
  • 可重复读:MySQL 默认级别,适用于对一致性要求较高,需要避免不可重复读的场景(如订单创建、库存扣减);
  • 串行化:仅用于一致性要求极高,并发量极低的场景(如金融核心交易的对账)。

6.2 避免长事务

长事务会占用锁资源,导致其他事务阻塞,还会增加 Undo Log 的体积(历史版本无法清理)。解决方案:

  1. 拆分事务:将复杂事务拆分为多个短事务(如 "创建订单 + 扣库存 + 记录日志" 拆分为 3 个独立事务,通过业务逻辑保证一致性);
  2. 减少事务内操作:事务内仅包含必要的 DML 操作,避免冗余查询、外部接口调用(如事务内不调用第三方支付接口,可先提交事务,再异步调用);
  3. 设置事务超时时间 :通过wait_timeout或应用层设置超时时间,超时后自动终止事务。

6.3 正确使用锁与 MVCC

  1. 优先使用快照读 :普通select是快照读(不加锁),仅在需要 "读取最新数据并锁定" 时使用select ... for update(当前读);
  2. 避免锁升级 :修改数据时务必加where条件(且条件字段有索引),避免全表更新导致表锁;
  3. 慎用间隙锁:若业务能接受不可重复读,可将隔离级别改为 "读提交",避免间隙锁导致的锁等待。

6.4 异常处理与数据一致性校验

  1. 捕获事务异常:应用层需捕获事务执行中的异常(如死锁、超时),并执行回滚操作;
  2. 事后校验:通过定时任务校验数据一致性(如库存数量是否为负、订单与支付记录是否匹配),发现问题后手动修复;
  3. 使用分布式事务:跨数据库、跨服务的操作(如 "订单库 + 支付库"),需使用分布式事务(如 Seata、TCC)保证一致性。

6.5 监控与调优

  1. 监控事务状态 :通过information_schema.INNODB_TRX查看当前活跃事务:

sql

复制代码
select trx_id, trx_state, trx_started, trx_query from information_schema.INNODB_TRX;
  1. 调优 Undo Log :通过innodb_undo_logs设置 Undo Log 的数量(默认 128),innodb_undo_tablespaces设置独立 Undo 表空间,避免 Undo Log 过大;
  2. 调优 Redo Log :通过innodb_log_file_size设置 Redo Log 文件大小(建议 1-4GB),innodb_log_files_in_group设置文件组数(默认 2),平衡性能与恢复速度。

七、总结:事务是 MySQL 的 "一致性守护者"

从火车票超卖问题到银行转账的安全性,从 ACID 属性到 MVCC 的底层实现,MySQL 事务始终围绕一个核心目标 ------保证数据在并发场景下的一致性

回顾本文的核心要点:

  1. 事务的本质:将一组 DML 操作打包,要么全成,要么全败;
  2. ACID 的作用:原子性(回滚)、一致性(逻辑正确)、隔离性(并发控制)、持久性(永久保存);
  3. 隔离级别的差异:RR 级别通过 MVCC 和间隙锁解决了幻读,是 MySQL 的默认选择;
  4. MVCC 的核心:通过隐藏字段、Undo Log、Read View 实现 "读不阻塞写,写不阻塞读";
  5. 最佳实践:避免长事务、选择合适隔离级别、正确使用锁,是高并发系统稳定运行的关键。

事务不仅是 MySQL 的核心特性,更是分布式系统、微服务架构中数据一致性的基础。理解事务的原理,不仅能解决日常开发中的 bug,更能在设计高并发系统时做出合理决策 ------ 这也是每个后端工程师必须掌握的核心技能。

相关推荐
橄榄熊2 小时前
docker MySQL 密码报错,重新修改保留原样的数据
mysql·docker·容器
NineData2 小时前
NineData 将亮相 2026 德国汉诺威工业博览会
数据库·人工智能·数据库管理工具·ninedata·数据库迁移工具·玖章算术
qq_432703662 小时前
MySQL中如何编写带有循环的函数_MySQL函数流程控制技巧
jvm·数据库·python
LiAo_1996_Y2 小时前
如何保证MongoDB文档的数据质量_JSON Schema验证规则配置
jvm·数据库·python
Lyyaoo.2 小时前
【JAVA基础面经】native方法
java·开发语言
a***72892 小时前
SQL 注入漏洞原理以及修复方法
网络·数据库·sql
牛十二2 小时前
nacos2.4连接出错源码分析
java·linux·开发语言
qq_372906932 小时前
Python最短路径怎么求_Dijkstra算法与优先队列结合
jvm·数据库·python
qq_330037992 小时前
如何查看集群版本_crsctl query crs activeversion当前版本
jvm·数据库·python