🔥个人主页: Milestone-里程碑
❄️个人专栏: <<力扣hot100>> <<C++>><<Linux>>
🌟心向往之行必能至
目录
[深入理解 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)读未提交:能读到未提交的 “脏数据”)
[(3)可重复读:解决不可重复读,MySQL 中解决幻读](#(3)可重复读:解决不可重复读,MySQL 中解决幻读)
[2.4 持久性(Durability):一旦提交,永久有效](#2.4 持久性(Durability):一旦提交,永久有效)
[持久性的实现原理:Redo Log](#持久性的实现原理:Redo Log)
[3.1 事务的提交方式:自动提交 vs 手动提交](#3.1 事务的提交方式:自动提交 vs 手动提交)
[(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),此时有两个客户端同时购票,操作流程如下:
- 客户端 A 查询票数:
if (nums > 0),发现有票,准备卖票; - 客户端 A 尚未执行
update nums = nums - 1,客户端 B 也查询票数:if (nums > 0),同样发现有票; - 客户端 A 执行更新,将
nums改为 0,提交操作; - 客户端 B 不知道 A 已更新,同样执行
update nums = nums - 1,将nums改为 - 1。
最终结果是:同一张票被卖给了两个人,库存出现负数 ------ 这就是典型的 "超卖" 问题。类似的场景还有很多:
- 银行转账:用户 A 给用户 B 转 1000 元,A 的账户扣了钱,但 B 的账户没加钱(网络中断);
- 电商下单:用户下单后,订单创建成功,但商品库存未减少,导致后续用户继续下单超卖;
- 日志记录:系统操作需要同时写入操作日志和业务数据,结果业务数据写了,日志没写,导致溯源困难。
这些问题的共性在于:一组相关的操作(如 "查库存 + 扣库存""扣钱 + 加钱")没有被当作一个 "整体" 执行------ 要么全部成功,要么全部失败。而事务的核心作用,就是将这组操作 "打包",保证其原子性与一致性。
1.2 事务的定义:什么是 MySQL 事务?
根据文档中的定义:事务是一组 DML(数据操纵语言,如 INSERT、UPDATE、DELETE)语句的集合,这些语句在逻辑上存在相关性,要么全部成功执行,要么全部失败回滚,是一个不可分割的整体。
更通俗地说,事务就像 "打包快递":你把多个物品(DML 操作)放进一个箱子(事务),快递员要么把整个箱子送到目的地(全部执行成功),要么箱子中途丢失(全部失败回滚),绝不会出现 "部分物品送达" 的情况。
举个文档中的教务系统例子:删除学生信息时,需要同时删除 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)到事务开始前的状态,就像这个事务从来没有执行过一样"。
实战案例:原子性的体现
我们通过文档中的 "账户表" 案例,演示原子性:
- 创建
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;
- 开启事务,插入两条记录,然后回滚:
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,此时事务满足原子性(执行成功)、隔离性(其他事务看不到中间状态)、持久性(修改永久保存),但数据逻辑错误(库存为负)------ 这就是 "业务逻辑导致的一致性问题"。
因此,一致性的保障需要两方面:
- 数据库层面:通过 AID 属性防止技术层面的不一致(如回滚失败、并发污染);
- 应用层面:通过业务逻辑判断(如扣库存前查库存、转账前查余额)防止逻辑层面的不一致。
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) | × | × | × | 全加锁(读加共享锁,写加排他锁) | 最低(安全性最高) |
关键说明:
- 隔离级别越高,安全性越强,但并发性能越低(因为加锁更严格,事务排队执行);
- MySQL 默认隔离级别是 "可重复读(RR)",且在 RR 级别下通过 "间隙锁" 解决了幻读(其他数据库的 RR 级别通常无法解决幻读);
- 生产环境中,很少使用 "读未提交"(太不安全)和 "串行化"(性能太差),常用 "读提交"(如 Oracle 默认)或 "可重复读"(MySQL 默认)。
隔离级别的实战操作:查看与设置
我们可以通过 SQL 命令查看和修改隔离级别,文档中提供了详细步骤:
- 查看隔离级别:
sql
-- 查看全局隔离级别(所有新会话生效)
select @@global.tx_isolation;
-- 查看当前会话隔离级别(仅当前会话生效)
select @@session.tx_isolation;
-- 简写(等同于查看当前会话)
select @@tx_isolation;
- 修改隔离级别:
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):一旦提交,永久有效
定义:事务提交后,对数据的修改会永久保存到磁盘,即使系统崩溃(如断电、服务器宕机),修改也不会丢失。
持久性的实战验证(文档案例)
- 终端 A 开启事务,插入数据并提交:
sql
begin;
insert into account values (3, '王五', 5000);
commit; -- 提交事务
- 终端 A 异常终止(如
ctrl+\强制关闭 MySQL 客户端); - 重新连接 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
文档中通过实验得出一个重要结论:只要执行begin或start transaction,事务就必须通过commit提交,与autocommit的设置无关。
例如:
sql
-- 自动提交开启(autocommit=ON)
show variables like 'autocommit'; -- ON
-- 开启事务
begin;
insert into account values (6, '周八', 5000);
-- 异常终止客户端(未提交)
-- 重新连接后查询:id=6不存在(自动回滚)
即使autocommit=ON,begin也会将后续操作 "打包" 为一个事务,必须手动提交才生效。
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;
注意事项
- 保存点仅在当前事务内有效,事务提交或回滚到事务开始后,保存点自动失效;
- 若回滚到某个保存点后,再执行
rollback(不指定保存点),会回滚到事务开始前; - 若删除保存点后,再回滚到该保存点,会报错(保存点不存在)。
3.3 事务操作的常见误区
- 误以为 MyISAM 支持事务 :MyISAM 引擎不支持事务,即使执行
begin/commit,也不会有事务效果(操作会立即生效); - 提交后回滚 :事务提交(
commit)后,无法再通过rollback回滚(数据已持久化); - 忽略隔离级别的影响:在 "读未提交" 级别下,即使事务未提交,其他事务也能读到修改,需根据业务场景选择隔离级别;
- 长事务风险:长时间未提交的事务会占用锁资源,导致其他事务阻塞,甚至引发死锁(如电商下单后长时间不支付,占用库存锁)。
四、事务的核心:MVCC 多版本并发控制
在讲解隔离级别时,我们提到:InnoDB 在 "读提交" 和 "可重复读" 级别下,不加锁就能实现快照读 (读取历史版本),从而提高并发性能。这背后的核心技术,就是MVCC(Multi-Version Concurrency Control,多版本并发控制)。
MVCC 的本质是:为数据维护多个版本,事务读取时根据 "可见性规则" 选择合适的版本,实现 "读不阻塞写,写不阻塞读"。
4.1 MVCC 的三大基础组件
要理解 MVCC,必须先掌握它的三个核心组件(文档中详细说明):
- 隐藏字段:InnoDB 为每一行记录添加 3 个隐藏字段,用于维护版本信息;
- Undo Log:存储数据的历史版本,形成 "版本链";
- 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(回滚日志)不仅用于事务回滚,还用于存储数据的历史版本,形成 "版本链"。其工作流程如下:
- 事务修改记录时,先将修改前的记录拷贝到 Undo Log 中;
- 更新原记录的
DB_TRX_ID为当前事务 ID,DB_ROLL_PTR指向 Undo Log 中的历史版本; - 多次修改后,Undo Log 中的历史版本通过
DB_ROLL_PTR串联,形成 "版本链"(最新版本在数据表中,历史版本在 Undo Log 中)。
实战案例:版本链的形成 假设事务 10(ID=10)修改student记录(将name=张三改为name=李四):
- 拷贝原记录到 Undo Log;
- 修改原记录的
DB_TRX_ID=10,DB_ROLL_PTR指向 Undo Log 中的历史版本; - 事务 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):
- 拷贝当前版本(李四,28)到 Undo Log;
- 修改原记录的
DB_TRX_ID=11,DB_ROLL_PTR指向新的历史版本; - 事务 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_id:m_ids中最小的事务 ID(小于该 ID 的事务均已提交,其修改可见);low_limit_id:生成 Read View 时,系统尚未分配的下一个事务 ID(大于等于该 ID 的事务均未开始,其修改不可见);creator_trx_id:生成该 Read View 的事务 ID(当前事务 ID)。
4.2 MVCC 的核心逻辑:可见性判断规则
事务执行快照读时,会从版本链的 "最新版本" 开始,依次判断每个版本是否符合 Read View 的可见性规则。规则如下(文档中对应源码逻辑):
- 若当前版本的
DB_TRX_ID == creator_trx_id:该版本是当前事务自己修改的,可见; - 若当前版本的
DB_TRX_ID < up_limit_id:该版本由 "已提交的事务" 修改(事务 ID 小于最小活跃 ID),可见; - 若当前版本的
DB_TRX_ID >= low_limit_id:该版本由 "未开始的事务" 修改(事务 ID 大于等于下一个分配 ID),不可见; - 若
DB_TRX_ID在up_limit_id和low_limit_id之间:- 若
DB_TRX_ID在m_ids中(属于活跃事务):不可见; - 若不在
m_ids中(事务已提交):可见。
- 若
若当前版本不可见,则通过DB_ROLL_PTR跳到上一个历史版本,重复上述判断,直到找到可见版本或版本链结束(返回空)。
4.3 MVCC 的实战流程:为什么 RR 级别能 "可重复读"?
我们结合文档中的案例,演示 MVCC 在 "可重复读(RR)" 级别下的工作流程:
场景设定
- 初始数据:
student表有一条记录(name=张三,age=28,DB_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=4,name=李四,age=28。
根据可见性规则判断:
4 != 2(规则 1 不满足);4 >= 1(规则 2 不满足);4 < 5(规则 3 不满足);4在1~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 锁,用于修改操作(如
insert、update、delete、select ... 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 会检测到死锁并终止其中一个事务(回滚),释放锁资源。
死锁的解决方案
- 固定锁顺序:所有事务修改记录时,按同一顺序加锁(如先锁 id 小的记录,再锁 id 大的);
- 减少锁持有时间:事务中尽量减少操作步骤,尽快提交(避免长事务占用锁);
- 使用低隔离级别:如 "读提交" 级别下,InnoDB 不会使用间隙锁,减少死锁概率(但需接受不可重复读);
- 设置锁超时时间 :通过
innodb_lock_wait_timeout设置锁等待超时时间(默认 50 秒),超时后自动回滚。
六、事务的最佳实践:从理论到落地
掌握了事务的原理后,我们需要在实际开发中规避风险,优化性能。以下是 MySQL 事务的最佳实践总结:
6.1 选择合适的隔离级别
- 读未提交:仅用于对数据一致性要求极低的场景(如日志统计),几乎不用;
- 读提交:适用于对一致性要求一般,追求高并发的场景(如电商商品详情页、新闻列表);
- 可重复读:MySQL 默认级别,适用于对一致性要求较高,需要避免不可重复读的场景(如订单创建、库存扣减);
- 串行化:仅用于一致性要求极高,并发量极低的场景(如金融核心交易的对账)。
6.2 避免长事务
长事务会占用锁资源,导致其他事务阻塞,还会增加 Undo Log 的体积(历史版本无法清理)。解决方案:
- 拆分事务:将复杂事务拆分为多个短事务(如 "创建订单 + 扣库存 + 记录日志" 拆分为 3 个独立事务,通过业务逻辑保证一致性);
- 减少事务内操作:事务内仅包含必要的 DML 操作,避免冗余查询、外部接口调用(如事务内不调用第三方支付接口,可先提交事务,再异步调用);
- 设置事务超时时间 :通过
wait_timeout或应用层设置超时时间,超时后自动终止事务。
6.3 正确使用锁与 MVCC
- 优先使用快照读 :普通
select是快照读(不加锁),仅在需要 "读取最新数据并锁定" 时使用select ... for update(当前读); - 避免锁升级 :修改数据时务必加
where条件(且条件字段有索引),避免全表更新导致表锁; - 慎用间隙锁:若业务能接受不可重复读,可将隔离级别改为 "读提交",避免间隙锁导致的锁等待。
6.4 异常处理与数据一致性校验
- 捕获事务异常:应用层需捕获事务执行中的异常(如死锁、超时),并执行回滚操作;
- 事后校验:通过定时任务校验数据一致性(如库存数量是否为负、订单与支付记录是否匹配),发现问题后手动修复;
- 使用分布式事务:跨数据库、跨服务的操作(如 "订单库 + 支付库"),需使用分布式事务(如 Seata、TCC)保证一致性。
6.5 监控与调优
- 监控事务状态 :通过
information_schema.INNODB_TRX查看当前活跃事务:
sql
select trx_id, trx_state, trx_started, trx_query from information_schema.INNODB_TRX;
- 调优 Undo Log :通过
innodb_undo_logs设置 Undo Log 的数量(默认 128),innodb_undo_tablespaces设置独立 Undo 表空间,避免 Undo Log 过大; - 调优 Redo Log :通过
innodb_log_file_size设置 Redo Log 文件大小(建议 1-4GB),innodb_log_files_in_group设置文件组数(默认 2),平衡性能与恢复速度。
七、总结:事务是 MySQL 的 "一致性守护者"
从火车票超卖问题到银行转账的安全性,从 ACID 属性到 MVCC 的底层实现,MySQL 事务始终围绕一个核心目标 ------保证数据在并发场景下的一致性。
回顾本文的核心要点:
- 事务的本质:将一组 DML 操作打包,要么全成,要么全败;
- ACID 的作用:原子性(回滚)、一致性(逻辑正确)、隔离性(并发控制)、持久性(永久保存);
- 隔离级别的差异:RR 级别通过 MVCC 和间隙锁解决了幻读,是 MySQL 的默认选择;
- MVCC 的核心:通过隐藏字段、Undo Log、Read View 实现 "读不阻塞写,写不阻塞读";
- 最佳实践:避免长事务、选择合适隔离级别、正确使用锁,是高并发系统稳定运行的关键。
事务不仅是 MySQL 的核心特性,更是分布式系统、微服务架构中数据一致性的基础。理解事务的原理,不仅能解决日常开发中的 bug,更能在设计高并发系统时做出合理决策 ------ 这也是每个后端工程师必须掌握的核心技能。
