当前位置: 代码网 > it编程>数据库>Mysql > MySQL 事务解析

MySQL 事务解析

2026年09月14日 Mysql 我要评论
mysql 事务是一组 sql 操作的集合,这些操作要么全部成功,要么全部失败回滚,不会停留在中间状态。最典型的例子就是转账:扣款和入账必须同时完成,否则整个操作撤销。‌‌hell

mysql 事务是一组 sql 操作的集合,这些操作要么全部成功,要么全部失败回滚,不会停留在中间状态。最典型的例子就是转账:扣款和入账必须同时完成,否则整个操作撤销。‌‌

hello,大家,在数据库开发中,事务是保障数据一致性的核心机制 —— 小到银行转账、火车票售票,大到电商订单创建、财务对账,都离不开事务的支持。

那么事务究竟是什么,他的原理是什么,他又是怎么帮助到我们的呢?本篇博客,我们就来学习一下。

这个问题我无法回答,请修改后重试。如果还需要其他信息或者有其他问题,我会尽力为你提供帮助。

一、事务是什么?

在讲复杂概念前,先通过 5 个你每天都可能接触的场景,理解事务的本质 —— 事务的核心就是 “要么全成,要么全败”,没有中间态。

场景 1:银行转账(最经典的事务场景)

你给朋友转账 1000 元,整个过程必须包含两步:

  1. 从你的银行卡扣除 1000 元;
  2. 给朋友的银行卡增加 1000 元。

这两步的结果只有两种可能:

  • 都成功:你的余额减少 1000,朋友的余额增加 1000,转账完成;
  • 都失败:你的余额不变,朋友的余额也不变,转账取消。

绝对不能出现 “你扣了钱,朋友没收到”(你损失 1000)或 “你没扣钱,朋友收到了”(银行损失 1000)的情况 —— 这就是事务要解决的核心问题。

场景 2:火车票售票系统(并发场景下的事务)

某趟火车只剩 1 张票,a 和 b 同时抢票,整个过程需要两步:

  1. 查询剩余票数(显示 1 张);
  2. 锁定车票并减少库存(从 1 变成 0)。

如果没有事务控制:

  • a 查询到 1 张票,还没来得及锁定,b 也查询到 1 张票;
  • a 锁定车票(库存变 0),b 也尝试锁定,导致同一张票被卖给两个人 —— 这就是 “超卖”,是电商、售票系统的致命问题。

事务的作用就是把 “a 的查询 + 锁定” 打包成一个整体,在 a 的操作完成前,b 无法修改库存,避免超卖。

场景 3:外卖下单(多表关联场景)

你在美团下单一份外卖,后台需要执行 3 步操作:

  1. 创建订单记录(订单表);
  2. 扣减商家商品库存(商品表);
  3. 扣除你的账户余额 / 积分(用户表)。

这三步必须同时成功或同时失败:

  • 若只成功 2 步(比如创建订单、扣减库存,但余额扣除失败),会导致 “订单创建了但没付钱”“库存少了但没收到钱” 的混乱;
  • 事务会保证:要么三步都成功(下单完成),要么三步都回滚(下单失败,库存和余额不变)。

场景 4:写作业(原子性场景)

你写一篇作文,需要完成 “审题→构思→写作→修改→定稿”5 步:

  • 要么全部完成(作文提交),要么全部不完成(作文没交,老师看不到);
  • 不可能出现 “只写了一半就提交” 的情况 —— 这和事务的 “原子性” 完全一致。

场景 5:团队项目开发(隔离性场景)

一个团队开发软件,a 负责前端,b 负责后端,c 负责测试:

  • a 开发前端时,不会影响 b 的后端开发(互不干扰);
  • b 修改后端接口后,a 需要等 b “提交”(完成开发)才能使用新接口;
  • 这和事务的 “隔离性” 一致 —— 多个事务并发执行时,互不干扰,只能看到对方提交后的结果。

事务的通俗定义(一句话记住)

事务就是把一组 sql 操作(dml:insert/update/delete)打包成一个 “不可分割的整体”,这组操作要么全部执行成功(提交后永久生效),要么全部执行失败(回滚到操作前状态);同时,事务能保证多个并发事务互不干扰,避免数据混乱。

mysql 中的事务,本质是为了简化应用程序的编程模型 —— 你不用考虑网络异常、服务器宕机、并发冲突等问题,只需关注 “业务逻辑”,剩下的 “原子性、一致性、隔离性、持久性” 都由 mysql 帮你兜底。

二、事务的核心属性:acid

要真正理解事务,必须掌握它的四个核心属性 ——acid,这是事务的 “灵魂”,也是面试高频考点。

2.1 原子性(atomicity):“要么全做,要么全不做”

定义

原子性指事务中的所有操作,是一个不可分割的 “原子单位”—— 要么全部执行成功,要么全部执行失败回滚,没有任何中间状态。

通俗理解

就像 “拧灯泡”:要么把灯泡拧好(全部成功),要么没拧(全部失败),不可能出现 “灯泡拧了一半悬在灯座上” 的中间态;又像 “切蛋糕”:要么把蛋糕切成 4 块(全部成功),要么不切(全部失败),不可能出现 “切了 2 块就停手” 的情况。

场景验证(银行转账)

  • 事务包含两步:扣你 1000 元(update user set balance=balance-1000 where id=1)、加朋友 1000 元(update user set balance=balance+1000 where id=2);
  • 若第一步成功(你扣了钱)、第二步失败(朋友的账户不存在,sql 报错),事务会自动回滚:你被扣的 1000 元恢复,朋友的账户也不会有变化;
  • 只有两步都成功,事务才会提交,变化才会永久生效。

底层原理(通俗版)

mysql 通过 “undo log(回滚日志)” 实现原子性,你可以把 undo log 理解成 “操作的反向备份”:

  • 执行事务中的每一条 sql 时,mysql 都会记录一条 “反向操作”:
    • 执行update user set balance=balance-1000,就记录 “update user set balance=balance+1000”;
    • 执行insert into account values(1, '张三', 1000),就记录 “delete from account where id=1”;
    • 执行delete from account where id=1,就记录 “insert into account values(1, '张三', 1000)”;
  • 若事务执行失败(比如 sql 报错、服务器宕机),mysql 会通过 undo log 执行所有 “反向操作”,把数据回滚到事务开始前的状态;
  • 若事务执行成功,undo log 会被标记为 “可删除”,后续由 mysql 后台线程定期清理(不会占用过多磁盘空间)。

2.2 一致性(consistency):“事务前后,数据状态合法”

定义

一致性指事务执行前后,数据库的 “完整性约束”(比如主键唯一、外键关联、业务规则)没有被破坏,数据从一个 “合法状态” 变成另一个 “合法状态”。

通俗理解

就像 “做蛋糕”:原料(面粉、鸡蛋、牛奶)是 “合法状态”,做成蛋糕后也是 “合法状态”;不会出现 “原料用了一半,蛋糕没做成,原料也没了” 的非法状态;又像 “考试得分”:考试前你得 0 分(合法),考试后得 80 分(合法),不会出现 “得分 - 5 分”“得分 101 分” 的非法状态。

场景验证(银行转账)

  • 事务前:你的余额 1000 元,朋友的余额 2000 元,两人总余额 3000 元(符合 “总金额不变” 的业务规则);
  • 事务后:你的余额 0 元,朋友的余额 3000 元,总余额还是 3000 元(依然符合业务规则);
  • 若事务回滚:两人余额恢复到 1000 和 2000,总余额还是 3000 元 —— 始终符合 “转账不改变总金额” 的业务规则,这就是一致性。

关键注意

一致性是 “业务层面的要求”,mysql 只提供技术支持(原子性、隔离性、持久性),但最终需要业务逻辑配合:

  • 比如转账时,你必须保证 “扣钱金额” 和 “加钱金额” 一致(扣 1000 就加 1000),如果业务逻辑写错(扣 1000 加 500),mysql 无法保证一致性;
  • 再比如库存扣减时,你必须保证 “库存不能为负”,如果业务逻辑没判断库存是否充足,导致库存变成 - 10,这也是一致性被破坏;
  • 简单说:一致性是目标,原子性、隔离性、持久性是实现目标的三大手段。

2.3 隔离性(isolation):“并发事务,互不干扰”

定义

隔离性指多个用户同时执行事务时,一个事务的执行不会被其他事务干扰 —— 每个事务都感觉自己是 “单独执行” 的,看不到其他事务执行过程中的中间状态。

通俗理解

就像 “两个同学在不同的教室考试”:a 的答题过程不会影响 b,b 也看不到 a 的答案,两者互不干扰;又像 “戴耳机听音乐”:你听你的歌,别人做别人的事,你不会被别人干扰,别人也不会被你影响。

并发事务的 3 个经典问题(隔离性要解决的问题)

没有隔离性或隔离级别过低时,多个事务并发执行会出现 3 个严重问题,我们用 “两个事务同时操作同一条数据” 来演示:

问题类型通俗描述场景例子
脏读(dirty read)事务 a 读取了事务 b “未提交” 的修改,后来事务 b 回滚,a 读取的数据是 “无效的脏数据”。事务 b 给 a 转账 1000 元(未提交),a 查询到余额增加 1000 元;事务 b 突然回滚,a 的余额实际没变化,但 a 已经看到了 “脏数据”,可能误以为转账成功。
不可重复读(non-repeatable read)事务 a 内多次读取同一条数据,事务 b 在中间修改并提交了这条数据,导致 a 多次读取结果不一致。事务 a 第一次查询余额 1000 元;事务 b 转账 1000 元并提交;事务 a 再次查询,余额变成 2000 元 —— 同一事务内两次读取结果不同,影响业务逻辑(比如统计金额)。
幻读(phantom read)事务 a 内多次执行 “相同条件的查询”,事务 b 在中间插入了符合条件的新数据,导致 a 多次查询的记录数不一致。事务 a 查询 “余额 < 500 元的用户”,返回 1 条记录;事务 b 插入 1 个余额 400 元的用户并提交;事务 a 再次查询,返回 2 条记录 —— 像出现了 “幻觉”,影响批量操作(比如批量转账)。

隔离性的核心作用

就是解决上面 3 个问题 —— 不同的隔离级别,能解决的问题不同(后面会详细讲 4 个隔离级别,从低到高逐步解决这些问题)。

2.4 持久性(durability):“事务提交,永久生效”

定义

持久性指事务一旦提交(commit),对数据的修改就是永久的 —— 即使后续系统崩溃(比如服务器断电、宕机、数据库崩溃),修改后的数据也不会丢失。

通俗理解

就像 “写日记”:把事情写在日记本上(事务提交),即使日记本掉在地上、被雨淋,写的内容也不会消失;如果只是在脑子里想(事务未提交),没写下来,转头就可能忘记(系统崩溃数据丢失);又像 “保存文件”:编辑文档后点击 “保存”(提交),即使电脑死机,下次打开文档依然是保存后的内容;如果没保存(未提交),死机后编辑的内容就会丢失。

场景验证

  • 你执行事务:扣 1000 元→加朋友 1000 元→commit;
  • 提交后,即使服务器突然断电,重启后两人的余额依然是修改后的状态;
  • 若未提交(只执行了扣钱和加钱,没 commit),服务器断电,重启后数据会回滚到原来的状态 —— 这就是 “未提交的事务不具备持久性”。

底层原理(通俗版)

mysql 通过 “redo log(重做日志)” 实现持久性,你可以把 redo log 理解成 “操作的永久备份”:

  • 执行事务中的每一条 sql 时,mysql 会先把修改写入 redo log(先写日志,再写磁盘,这叫 “wal 机制”);
  • 事务提交时,redo log 会被标记为 “持久化完成”(相当于 “确认保存”);
  • 即使服务器崩溃,重启后 mysql 会通过 redo log 恢复已提交的事务 —— 因为 redo log 已经记录了所有修改,相当于 “重做” 一遍事务,确保数据不会丢失;
  • 对比 undo log 和 redo log:undo log 负责 “回滚”(事务失败时),redo log 负责 “恢复”(系统崩溃后),两者配合保障原子性和持久性。

acid 总结(口诀记忆)

  • 原子性:要么全成,要么全败(undo log 保障);
  • 一致性:数据合法,前后不变(业务规则 + aid 保障);
  • 隔离性:并发操作,互不干扰(锁 + mvcc 保障);
  • 持久性:提交之后,永久生效(redo log 保障)。

口诀:“原子全成或全败,一致合法不破坏,隔离并发不干扰,持久提交不丢失”。

三、mysql 事务的支持与基础语法

理解了 acid,接下来进入实战 —— 先搞懂 mysql 对事务的支持情况,再掌握核心语法,跟着操作就能上手

3.1 事务的引擎支持:只有 innodb 支持事务

mysql 有多种存储引擎(innodb、myisam、memory、csv 等),但只有 innodb 引擎支持事务,其他引擎(如 myisam)不支持 —— 这是 innodb 成为 mysql 默认引擎的核心原因之一,

语法 1:查看 mysql 支持的引擎及事务支持情况

-- 方式1:表格形式显示(适合快速查看所有引擎)
show engines;
-- 方式2:行形式显示(适合查看详细信息,加\g格式化输出)
show engines \g;

执行结果关键信息解读(重点看 innodb 和 myisam)

*************************** 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 引擎(默认就是),如果表是 myisam,需要先转换(转换前备份数据,避免丢失):

-- 转换表引擎为innodb
alter table 表名 engine=innodb;

新建表时,显式指定 innodb 引擎(推荐,避免默认引擎被修改):

create table 表名 (
    字段1 类型 约束,
    字段2 类型 约束
) engine=innodb default charset=utf8mb4; -- utf8mb4支持emoji,推荐使用

验证表引擎:

-- 查看表的引擎
show table status like '表名' \g;
-- 重点看engine字段,显示innodb即为支持事务

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

mysql 中事务的提交方式有两种,默认是 “自动提交”—— 这是很多新手误以为 “事务失效” 的根源,必须彻底搞懂。

3.2.1 查看当前提交方式

-- 查看autocommit变量(on=自动提交,off=手动提交)
show variables like 'autocommit';

这个大家可记可不记,因为平时我们是用不到的,只是在我们这里的学习过程中,需要用到罢了。

执行结果(默认状态)

+---------------+-------+
| variable_name | value |
+---------------+-------+
| autocommit    | on    |
+---------------+-------+

3.2.2 自动提交(默认方式)

  • 定义:每执行一条 dml 语句(insert/update/delete),mysql 会自动把这条语句当作一个独立事务提交,不需要手动 commit,所以聪明的你也就知道了,其实事务和我们平时的操作是息息相关的。
  • 通俗理解:就像 “写一句保存一句”,每写一句话,自动保存到磁盘,无法撤销;
  • 验证示例:
-- 新建测试表(innodb引擎)
create table account (
    id int primary key auto_increment,
    name varchar(50) not null,
    balance decimal(10,2) not null default 0.0
) engine=innodb default charset=utf8mb4;
-- 插入一条记录(自动提交,无需commit)
insert into account (name, balance) values('张三', 1000.00);
-- 查看记录(能查到,因为insert已自动提交)
select * from account; -- 结果:id=1, name=张三, balance=1000.00
-- 修改记录(自动提交,无需commit)
update account set balance=2000.00 where id=1;
-- 再次查看(能查到修改后的数据,update已自动提交)
select * from account where id=1; -- 结果:balance=2000.00
-- 删除记录(自动提交,无需commit)
delete from account where id=1;
-- 再次查看(记录已删除,delete已自动提交)
select * from account where id=1; -- 结果:空

3.2.3 手动提交(开发常用方式,推荐)

  • 定义:关闭自动提交后,需要手动用 begin/start transaction 开启事务,用 commit 提交事务,用 rollback 回滚事务;
  • 通俗理解:就像 “写文章先存草稿,全部写完再最终保存”,中间可以随时撤销草稿;
  • 语法与示例(逐行解析):
-- 方式1:关闭自动提交(当前会话有效,重启客户端失效)
set autocommit=0; -- 0=关闭,1=开启
-- 方式2:验证关闭结果(确保autocommit=off)
show variables like 'autocommit'; -- 结果:value=off
-- 开启事务(两种写法都可以,推荐begin,更简洁)
begin;
-- 或 start transaction;
-- 执行第一条sql(插入记录,事务内未提交)
insert into account (name, balance) values('张三', 1000.00);
-- 执行第二条sql(修改记录,事务内未提交)
update account set balance=balance+500 where id=1; -- 张三余额变成1500.00
-- 查看事务内的修改(当前事务能看到,其他事务看不到,这就是隔离性所在)
select * from account where id=1; -- 结果:balance=1500.00
-- 提交事务(所有操作永久生效)
commit;
-- 再次查看(修改已生效)
select * from account where id=1; -- 结果:balance=1500.00
-- 再次开启事务
begin;
-- 执行第三条sql(扣减余额)
update account set balance=balance-1000 where id=1; -- 张三余额变成500.00
-- 查看事务内的修改
select * from account where id=1; -- 结果:balance=500.00
-- 回滚事务(撤销所有未提交的操作)
rollback;
-- 查看回滚后结果(修改被撤销,余额恢复到1500.00)
select * from account where id=1; -- 结果:balance=1500.00

3.2.4 关键注意点

自动提交只对 dml 语句生效(insert/update/delete),ddl 语句(create/alter/drop/truncate)永远自动提交,不受 autocommit 影响

begin;
-- ddl语句(自动提交,事务直接结束)
create table test (id int);
rollback; -- 无效,test表已经创建成功

手动提交时,begin/start transaction 会临时覆盖 autocommit 设置 —— 即使 autocommit=on,开启事务后,也需要手动 commit/rollback

-- 自动提交开启(autocommit=on)
show variables like 'autocommit'; -- 结果:on
begin;
insert into account (name, balance) values('李四', 2000.00);
-- 未提交,其他事务看不到
select * from account where name='李四'; -- 当前事务能看到,其他事务看不到
commit; -- 提交后,其他事务才能看到

不同客户端的提交方式互不影响 —— 客户端 a 关闭自动提交,不会影响客户端 b 的自动提交设置。

3.3 事务的核心操作语法

事务的操作语法主要包括 “开启事务、提交事务、回滚事务、保存点”

3.3.1 开启事务(2 种写法,无差异)

-- 写法1:begin(推荐,简洁易懂)
begin;
-- 写法2:start transaction(功能完全一致,更规范)
start transaction;

语法解析

  • 作用:标记事务的开始,之后执行的所有 dml 语句都会被纳入事务管理;
  • 注意:开启事务后,事务内的操作会暂时保存在内存中,未提交前,只有当前事务能看到修改,其他事务看不到(隔离性的体现);
  • 示例:
begin;
-- 事务内的所有操作都会被打包
insert into account (name, balance) values('王五', 3000.00);
update account set balance=balance+500 where name='王五';

3.3.2 提交事务(1 种写法)

commit;

语法解析

  • 作用:确认事务内的所有操作,将修改永久写入磁盘(具备持久性);
  • 执行后:事务结束,所有修改对其他事务可见;undo log 会被标记为可删除;

示例:

begin;
insert into account (name, balance) values('赵六', 1500.00);
commit; -- 提交后,赵六的记录永久生效

3.3.3 回滚事务(1 种写法)

rollback;

语法解析

  • 作用:撤销事务内的所有操作,回到事务开始前的状态;
  • 适用场景:事务执行过程中出现错误(sql 报错、业务逻辑不满足),需要撤销所有操作;
  • 注意:只能回滚 “未提交” 的事务,提交后的事务无法回滚;

示例:

begin;
update account set balance=balance-1000 where name='张三'; -- 张三余额1500→500
update account set balance=balance+1000 where name='不存在的用户'; -- sql执行失败(无此用户)
rollback; -- 回滚所有操作,张三余额恢复到1500
select * from account where name='张三'; -- 结果:balance=1500

3.3.4 保存点(savepoint):回滚到事务中间状态

保存点是事务中的 “临时标记”,可以让你回滚到事务的中间某个状态,而不是回滚整个事务 —— 适合复杂事务(多步操作,只需要撤销某几步)。

核心语法(创建 + 回滚 + 删除)

-- 1. 创建保存点(语法:savepoint 保存点名称)
savepoint save1;
-- 2. 回滚到保存点(语法:rollback to 保存点名称)
rollback to save1;
-- 3. 删除保存点(语法:release savepoint 保存点名称)
release savepoint save1;

大家要记住格式哦

实战示例(多步操作 + 保存点回滚,逐行解析)

-- 准备工作:关闭自动提交,account表已有张三(1500)、李四(2000)、王五(3500)
set autocommit=0;
begin;
-- 第一步:张三余额+500(1500→2000)
update account set balance=balance+500 where name='张三';
-- 创建保存点save1(标记第一步完成)
savepoint save1;
-- 第二步:李四余额+500(2000→2500)
update account set balance=balance+500 where name='李四';
-- 创建保存点save2(标记第二步完成)
savepoint save2;
-- 第三步:王五余额-1000(3500→2500)
update account set balance=balance-1000 where name='王五';
-- 创建保存点save3(标记第三步完成)
savepoint save3;
-- 查看当前状态(三步都执行了)
select name, balance from account;
-- 结果:张三2000,李四2500,王五2500
-- 回滚到save2(撤销第三步:王五余额恢复到3500)
rollback to save2;
-- 查看回滚后状态
select name, balance from account;
-- 结果:张三2000,李四2500,王五3500(第三步被撤销)
-- 回滚到save1(撤销第二步和第三步:李四2000,王五3500)
rollback to save1;
-- 查看回滚后状态
select name, balance from account;
-- 结果:张三2000,李四2000,王五3500(第二步、第三步都被撤销)
-- 执行第四步:张三余额+1000(2000→3000)
update account set balance=balance+1000 where name='张三';
-- 查看当前状态
select name, balance from account;
-- 结果:张三3000,李四2000,王五3500
-- 回滚到事务开始(撤销所有操作)
rollback;
-- 查看最终状态(所有修改都被撤销)
select name, balance from account;
-- 结果:张三1500,李四2000,王五3500

保存点的注意事项(必记)

提交事务后,所有保存点会被自动删除:

begin;
update account set balance=balance+500 where name='张三';
savepoint save1;
commit; -- 提交后,save1被删除
rollback to save1; -- 报错:savepoint save1 does not exist

回滚到某个保存点后,该保存点之后的保存点会被自动删除:

begin;
savepoint save1;
update account set balance=balance+500 where name='张三';
savepoint save2;
rollback to save1; -- 回滚后,save2被删除
rollback to save2; -- 报错:savepoint save2 does not exist

保存点名称区分大小写(不同 mysql 版本可能有差异,建议统一小写)。

3.4 事务操作的实战验证(证明 acid,多终端演示)

案例 1:验证原子性和持久性(客户端崩溃后数据状态)

场景:事务执行中,客户端异常崩溃,验证数据是否回滚;事务提交后,客户端崩溃,验证数据是否持久。

准备工作

  • 客户端 a:关闭自动提交(set autocommit=0);
  • 客户端 b:保持默认设置;
  • 测试表 account 初始数据:张三 1500,李四 2000,王五 3500。

步骤 1:事务执行中崩溃(验证原子性)

客户端 a:开启事务,执行修改(未提交);

begin;
update account set balance=balance-1000 where name='张三'; -- 张三1500→500
update account set balance=balance+1000 where name='李四'; -- 李四2000→3000

手动崩溃客户端 a(windows:ctrl+\;linux:kill -9 进程id;mac:command+\);

客户端 b:查询数据(验证回滚);

select name, balance from account;
-- 结果:张三1500,李四2000(修改被回滚,原子性成立)

步骤 2:事务提交后崩溃(验证持久性)

客户端 a:重新连接,开启事务,执行修改并提交;

set autocommit=0;
begin;
update account set balance=balance-1000 where name='张三'; -- 张三1500→500
update account set balance=balance+1000 where name='李四'; -- 李四2000→3000
commit; -- 提交事务

手动崩溃客户端 a;

客户端 b:查询数据(验证持久性);

select name, balance from account;
-- 结果:张三500,李四3000(修改永久生效,持久性成立)

案例 2:验证隔离性(并发事务互不干扰)

场景:两个客户端同时操作同一条数据,验证隔离性(默认隔离级别:可重复读)。

准备工作

  • 客户端 a:关闭自动提交(set autocommit=0);
  • 客户端 b:关闭自动提交(set autocommit=0);
  • 测试表 account 初始数据:张三 500,李四 3000,王五 3500。

步骤 1:客户端 a 开启事务,执行修改(未提交)

begin;
update account set balance=balance+500 where name='张三'; -- 张三500→1000

步骤 2:客户端 b 查询数据(验证隔离性)

begin;
select name, balance from account where name='张三';
-- 结果:500(看不到客户端a未提交的修改,隔离性成立)

步骤 3:客户端 a 提交事务

commit;

步骤 4:客户端 b 再次查询(验证隔离性)

select name, balance from account where name='张三';
-- 结果:500(同一事务内,依然看不到提交的修改,可重复读特性)

步骤 5:客户端 b 提交事务后查询

commit;
select name, balance from account where name='张三';
-- 结果:1000(事务结束后,看到最新数据)

案例 3:验证一致性(业务规则约束)

场景:转账时保证 “总金额不变”,验证一致性。

-- 客户端a:关闭自动提交,开启事务
set autocommit=0;
begin;
-- 查询转账前总金额
select sum(balance) from account; -- 结果:500(张三)+3000(李四)+3500(王五)=7000
-- 执行转账操作(张三转1000给王五)
update account set balance=balance-1000 where name='张三'; -- 张三500→-500?不,业务逻辑不允许!
-- 这里故意写错,验证一致性需要业务逻辑配合,正确的应该先判断余额是否充足:
rollback; -- 回滚错误操作
-- 正确的转账流程
begin;
-- 先判断余额是否充足
if (select balance from account where name='张三') < 1000 then
    signal sqlstate '45000' set message_text='余额不足';
end if;
-- 执行转账
update account set balance=balance-1000 where name='张三'; -- 张三500→-500?不,余额只有500,会触发业务逻辑判断失败
-- 实际执行会报错:余额不足,事务回滚
-- 调整转账金额为300(张三500→200)
update account set balance=balance-300 where name='张三';
update account set balance=balance+300 where name='王五'; -- 王五3500→3800
-- 查询转账后总金额
select sum(balance) from account; -- 结果:200+3000+3800=7000(总金额不变,一致性成立)
-- 提交事务
commit;

四、事务的隔离级别

隔离性是事务 acid 中最复杂的属性,mysql 通过 “隔离级别” 控制隔离性的强弱 —— 隔离级别越高,数据越安全,但并发性能越低;隔离级别越低,并发性能越高,但数据越容易出现问题。

mysql 有 4 个标准隔离级别(从低到高):

  1. 读未提交(read uncommitted);
  2. 读提交(read committed);
  3. 可重复读(repeatable read)——mysql 默认隔离级别;
  4. 串行化(serializable)。

4.1 隔离级别的基础操作语法(查看 + 设置)

在演示隔离级别前,先掌握 “查看” 和 “设置” 的语法

4.1.1 查看隔离级别(3 种方式,无差异)

-- 方式1:查看全局隔离级别(影响所有新连接,不影响当前连接)
select @@global.tx_isolation;
-- 方式2:查看当前会话隔离级别(影响当前连接,默认和全局一致)
select @@session.tx_isolation;
-- 方式3:查看当前事务隔离级别(和会话隔离级别一致)
select @@tx_isolation;

执行结果(默认隔离级别)

+-----------------------+
| @@global.tx_isolation |
+-----------------------+
| repeatable-read       |
+-----------------------+

4.1.2 设置隔离级别(2 种范围,附示例)

-- 语法:set [global | session] transaction isolation level 隔离级别;
-- 隔离级别可选:read uncommitted / read committed / repeatable read / serializable
-- 方式1:设置全局隔离级别(需要重启客户端生效,影响所有新连接)
set global transaction isolation level read uncommitted;
-- 方式2:设置当前会话隔离级别(立即生效,只影响当前连接)
set session transaction isolation level read committed;

注意事项(必记)

  1. 全局隔离级别修改后,已存在的连接不受影响,只有新连接生效;
  2. 会话隔离级别修改后,当前连接立即生效,不影响其他连接;
  3. 开发中一般使用默认隔离级别(repeatable read),不建议手动修改,除非有特殊业务需求;
  4. 修改隔离级别后,建议重新开启事务,确保隔离级别生效。

4.2 隔离级别 1:读未提交(read uncommitted)—— 最低隔离级别

定义

事务 a 能读取到事务 b “未提交” 的修改 —— 这是最低的隔离级别,几乎没有隔离性,会出现所有并发问题(脏读、不可重复读、幻读)。

通俗理解

就像 “两个同学考试时互相看答案”:b 还没写完答案(未提交),a 就能看到 b 的答案(未提交的修改);b 后来发现答案错了,擦掉重写(回滚),a 看到的就是 “无效答案”(脏数据)。

实战演示(两个客户端)

准备工作

客户端 a:设置全局隔离级别为读未提交,重启客户端,关闭自动提交;

set global transaction isolation level read uncommitted;
-- 重启客户端后
set autocommit=0;

客户端 b:重启客户端(继承全局隔离级别),关闭自动提交;

set autocommit=0;

测试表 account 初始数据:张三 200,李四 3000,王五 3800。

步骤 1:客户端 a 开启事务,执行修改(未提交)

begin;
update account set balance=balance+500 where name='张三'; -- 张三200→700(未提交)

步骤 2:客户端 b 查询数据(脏读)

begin;
select name, balance from account where name='张三';
-- 结果:700(读到未提交的修改,脏读出现)

步骤 3:客户端 a 回滚事务

rollback;

步骤 4:客户端 b 再次查询(脏数据消失)

select name, balance from account where name='张三';
-- 结果:200(脏数据消失,验证脏读问题)

读未提交的特点

  • 优点:并发性能最高(几乎不加锁,所有事务可以同时执行);
  • 缺点:出现脏读、不可重复读、幻读,数据一致性无法保证;
  • 适用场景:几乎不用(仅适用于对数据一致性要求极低的场景,如查询实时日志、临时统计数据,生产环境绝对禁用)。

4.3 隔离级别 2:读提交(read committed)—— 多数数据库默认级别

定义

事务 a 只能读取到事务 b “已提交” 的修改 —— 解决了 “脏读” 问题,但会出现 “不可重复读” 和 “幻读”。

通俗理解

就像 “两个同学考试,b 写完答案后交卷(提交),a 才能抄到答案”:b 没交卷(未提交),a 看不到;b 交卷(提交),a 才能看到,避免了 “抄到无效答案”(脏读);但如果 b 交卷后又申请修改答案(重新提交),a 再次抄就会得到不同的答案(不可重复读)。

实战演示(两个客户端)

准备工作

客户端 a:设置全局隔离级别为读提交,重启客户端,关闭自动提交;

set global transaction isolation level read committed;
-- 重启客户端后
set autocommit=0;

客户端 b:重启客户端,关闭自动提交;

set autocommit=0;

测试表 account 初始数据:张三 200,李四 3000,王五 3800。

步骤 1:客户端 a 开启事务,执行修改(未提交)

begin;
update account set balance=balance+500 where name='张三'; -- 张三200→700(未提交)

步骤 2:客户端 b 查询数据(解决脏读)

begin;
select name, balance from account where name='张三';
-- 结果:200(看不到未提交的修改,脏读问题解决)

步骤 3:客户端 a 提交事务

commit;

步骤 4:客户端 b 再次查询(不可重复读)

select name, balance from account where name='张三';
-- 结果:700(同一事务内两次查询结果不同,不可重复读出现)

读提交的特点

  • 优点:解决脏读,并发性能较高(比读未提交略低,但依然优秀);
  • 缺点:出现不可重复读、幻读;
  • 适用场景:对数据一致性要求一般,追求并发性能的场景(如电商商品详情查询、新闻列表查询);
  • 注意:oracle、sql server 的默认隔离级别就是读提交,mysql 不是。

4.4 隔离级别 3:可重复读(repeatable read)——mysql 默认级别

定义

事务 a 在执行过程中,多次读取同一条数据,结果始终一致 —— 即使事务 b 修改并提交了这条数据,事务 a 也看不到修改后的结果;解决了 “脏读”“不可重复读” 问题,mysql 的可重复读还额外解决了 “幻读” 问题(其他数据库的可重复读不解决幻读)。

通俗理解

就像 “两个同学考试,a 先抄了 b 的答案(第一次查询),之后 b 修改了答案并交卷(提交),但 a 手里的答案副本不会变(多次查询结果一致)”:a 的事务内,第一次查询得到的结果会被 “快照”,之后不管 b 怎么修改,a 看到的都是快照里的旧数据,避免了不可重复读;同时,a 的快照会屏蔽 b 插入的新数据,避免了幻读。

实战演示 1:解决不可重复读

准备工作

客户端 a:设置全局隔离级别为可重复读,重启客户端,关闭自动提交;

set global transaction isolation level repeatable read;
-- 重启客户端后
set autocommit=0;

客户端 b:重启客户端,关闭自动提交;

set autocommit=0;

测试表 account 初始数据:张三 700,李四 3000,王五 3800。

步骤 1:客户端 a 开启事务,第一次查询

begin;
select name, balance from account where name='张三';
-- 结果:700(第一次查询,生成快照)

步骤 2:客户端 b 开启事务,修改并提交

begin;
update account set balance=balance+500 where name='张三'; -- 张三700→1200
commit; -- 提交事务

步骤 3:客户端 a 再次查询(解决不可重复读)

select name, balance from account where name='张三';
-- 结果:700(同一事务内,结果和第一次一致,不可重复读问题解决)

步骤 4:客户端 a 提交事务后查询

commit;
select name, balance from account where name='张三';
-- 结果:1200(事务结束后,看到最新数据)

实战演示 2:mysql 可重复读解决幻读

准备工作

  • 客户端 a、b 保持隔离级别为可重复读,关闭自动提交;
  • 测试表 account 初始数据:张三 1200,李四 3000,王五 3800。

步骤 1:客户端 a 开启事务,第一次条件查询

begin;
-- 查询“余额<1000的用户”(当前只有李四3000、王五3800,张三1200,无符合条件的用户)
select name, balance from account where balance<1000;
-- 结果:空(第一次查询,生成快照)

步骤 2:客户端 b 插入符合条件的记录并提交

begin;
insert into account (name, balance) values('赵六', 800); -- 赵六余额800,符合条件
commit; -- 提交事务

步骤 3:客户端 a 再次查询(解决幻读)

select name, balance from account where balance<1000;
-- 结果:空(同一事务内,看不到新插入的记录,幻读问题解决)

步骤 4:客户端 a 提交事务后查询

commit;
select name, balance from account where balance<1000;
-- 结果:赵六800(事务结束后,看到新插入的记录)

可重复读的特点

  • 优点:解决脏读、不可重复读、幻读(mysql 特有),并发性能适中(比读提交略低,但满足绝大多数场景);
  • 缺点:并发性能比读提交略低(因为需要维护快照);
  • 适用场景:大多数业务场景(如电商订单、银行转账、财务系统、库存管理)——mysql 默认级别,也是开发中最常用、最推荐的级别。

4.5 隔离级别 4:串行化(serializable)—— 最高隔离级别

定义

事务串行执行(一个事务执行完,另一个才开始)—— 解决了所有并发问题(脏读、不可重复读、幻读),但并发性能最低。

通俗理解

就像 “两个同学考试,一个先考,考完另一个再考”:a 先考试(开启事务),b 必须等 a 考完(提交 / 回滚)才能开始,完全没有并发冲突,但效率极低;又像 “排队买奶茶”:一个人买完,下一个人才能买,不会出现 “两个人同时买最后一杯奶茶” 的冲突,但排队时间很长。

实战演示(两个客户端)

准备工作

客户端 a:设置全局隔离级别为串行化,重启客户端,关闭自动提交;

set global transaction isolation level serializable;
-- 重启客户端后
set autocommit=0;

客户端 b:重启客户端,关闭自动提交;

set autocommit=0;

测试表 account 初始数据:张三 1200,李四 3000,王五 3800,赵六 800。

步骤 1:客户端 a 开启事务,执行查询

begin;
select name, balance from account where name='张三';
-- 结果:1200(加共享锁,阻止其他事务修改)

步骤 2:客户端 b 尝试修改数据(被阻塞)

begin;
-- 尝试修改张三的余额(被阻塞,直到客户端a提交/回滚)
update account set balance=balance+500 where name='张三';
-- 卡住,无响应(阻塞中)

步骤 3:客户端 a 提交事务

commit; -- 提交后,释放锁

步骤 4:客户端 b 的修改执行完成

-- 阻塞解除,修改成功
query ok, 1 row affected (12.36 sec) -- 阻塞了12秒,直到a提交
-- 查询结果
select name, balance from account where name='张三';
-- 结果:1700(修改成功)

串行化的特点

  • 优点:解决所有并发问题,数据一致性最高(几乎不会出现数据混乱);
  • 缺点:并发性能最低(完全串行执行,相当于单线程,无法应对高并发);
  • 适用场景:几乎不用(仅适用于对数据一致性要求极高、并发量极低的场景,如银行对账、财务结算、证券交割,生产环境中除非特殊需求,否则禁用)。

4.6 4 个隔离级别的总结对比(一目了然)

隔离级别脏读不可重复读幻读并发性能适用场景备注
读未提交最高实时日志查询、临时统计(极少用)生产环境禁用
读提交较高商品详情、新闻列表(oracle 默认)并发优先,一致性一般
可重复读(mysql 默认)适中订单、转账、库存(大多数场景)一致性和并发平衡,推荐
串行化最低银行对账、财务结算(极少用)一致性优先,并发极差

✅:会出现该问题;❌:不会出现该问题。

4.7 隔离级别选择建议(实战指南)

  1. 优先使用 mysql 默认隔离级别(可重复读)—— 兼顾一致性和并发性能,满足 99% 的业务场景;
  2. 不要使用读未提交 —— 数据一致性无法保证,容易出现生产事故;
  3. 除非有特殊需求(如 oracle 迁移到 mysql,需要保持隔离级别一致),否则不要使用读提交;
  4. 不要使用串行化 —— 并发性能极差,无法应对高并发请求;
  5. 若业务对数据一致性要求极高(如金融交易),可在可重复读的基础上,通过 “悲观锁”“乐观锁” 进一步保障,而不是升级到串行化。

五、事务的底层原理:mvcc(多版本并发控制)

前面我们提到,mysql 的可重复读级别能解决幻读,还能保证 “读不加锁、写加锁”(读操作不阻塞写,写操作不阻塞读),这背后的核心技术就是mvcc(multi-version concurrency control,多版本并发控制)。

mvcc 听起来高深,其实本质很简单 ——通过保存数据的 “历史版本”,让不同事务看到不同版本的数据,从而实现并发控制。

5.1 mvcc 的核心组成:3 个隐藏字段 + undo log+read view

要理解 mvcc,必须先知道它的 3 个核心组成部分 —— 就像 “做饭需要食材、厨具、菜谱”,mvcc 需要这 3 个部分才能工作

5.1.1 3 个隐藏字段(数据的 “版本身份证”)

innodb 引擎的表,每条记录都会隐藏 3 个字段(用户看不到,mysql 自动维护),用来标识数据的版本 —— 你可以把这 3 个字段理解成 “文章的修改记录标签”。

隐藏字段字节数通俗作用类比(文章修改)
db_trx_id6最近修改这条记录的 “事务 id”—— 事务开始时,mysql 会分配唯一的递增事务 id,修改记录时写入该字段文章的 “最后修改人 id”—— 记录是谁最后修改了这篇文章
db_roll_ptr7回滚指针 —— 指向这条记录的 “历史版本”(存储在 undo log 中),形成 “版本链”文章的 “历史版本链接”—— 点击链接可以查看这篇文章的上一版草稿
db_row_id6隐含主键 —— 如果表没有主键,mysql 会自动用这个字段作为聚簇索引的主键(自增)文章的 “唯一编号”—— 如果文章没有手动编号,系统自动分配一个唯一编号

示例:一条记录的隐藏字段

假设我们创建一条记录:

insert into account (name, balance) values('张三', 1000.00);

这条记录的隐藏字段状态(简化):

idnamebalancedb_trx_id(修改事务 id)db_roll_ptr(回滚指针)db_row_id(隐含主键)
1张三1000.00100(假设当前事务 id=100)null(无历史版本)1(自增)

5.1.2 undo log(数据的 “历史版本仓库”)

undo log(回滚日志)我们之前提过,它的核心作用有两个:

  1. 事务回滚(原子性);
  2. 存储数据的历史版本(mvcc)—— 你可以把 undo log 理解成 “文章的历史版本存档”。

当修改记录时,mysql 会执行 “写时拷贝”:

  1. 先把记录的旧版本拷贝到 undo log 中;
  2. 再修改原记录的内容;
  3. 原记录的 db_roll_ptr 指向 undo log 中的旧版本;
  4. 这样,原记录和 undo log 中的旧版本形成 “版本链”—— 通过 db_roll_ptr 可以遍历所有历史版本。

示例:修改记录形成版本链

-- 事务id=101,修改张三的余额为1500
begin;
update account set balance=1500.00 where id=1;
commit;
-- 事务id=102,修改张三的余额为2000
begin;
update account set balance=2000.00 where id=1;
commit;

修改后,原记录和 undo log 中的版本链(简化):

原记录(最新版本):

idnamebalancedb_trx_id=102db_roll_ptr = 指向 undo log 中的版本 2db_row_id=1
1张三2000.001020x123456(undo log 地址)1

undo log 中的版本 2(事务 101 修改后的版本):

idnamebalancedb_trx_id=101db_roll_ptr = 指向 undo log 中的版本 1db_row_id=1
1张三1500.001010x7890ab(undo log 地址)1

undo log 中的版本 1(事务 100 插入后的版本):

idnamebalancedb_trx_id=100db_roll_ptr=nulldb_row_id=1
1张三1000.00100null1

通俗理解版本链

就像 “文章的修改记录”:

  • 最新版本:当前显示的文章(余额 2000);
  • 版本 2:上一版修改(余额 1500);
  • 版本 1:最初版本(余额 1000);
  • 回滚指针:文章底部的 “查看历史版本” 链接,点击可以跳转到对应的旧版本。

5.1.3 read view(事务的 “版本可见性规则”)

read view(读视图)是事务执行 “快照读” 时生成的 “版本可见性判断规则”—— 你可以把它理解成 “考试的阅卷规则”:哪些答案(数据版本)是允许你看的,哪些是不允许的。

read view 有 4 个核心属性(简化版),用来判断版本链中的哪个版本 “对当前事务可见”:

read view 属性通俗作用类比(考试阅卷规则)
m_ids生成 read view 时,系统中正在活跃的事务 id 列表(比如当前有事务 103、104 在执行)正在考试的同学 id 列表 —— 他们的答案还没提交,不能参考
up_limit_idm_ids 中的最小事务 id(比如 103)正在考试的同学中最小的 id——id 比这个小的同学已经考完交卷,答案可参考
low_limit_id生成 read view 时,系统尚未分配的下一个事务 id(比如当前最大事务 id=104,low_limit_id=105)下一个要参加考试的同学 id——id 比这个大的同学还没开始考,答案不可参考
creator_trx_id生成 read view 的当前事务 id(比如事务 106 生成 read view,creator_trx_id=106)你自己的 id—— 你自己写的答案可以参考

版本可见性判断规则(核心逻辑)

事务查询记录时,会从版本链的 “最新版本” 开始,依次判断每个版本的 db_trx_id 是否符合 read view 规则,直到找到 “可见的版本”—— 就像 “阅卷老师批改试卷,从最新的答案开始,判断是否符合阅卷规则”。

判断规则(通俗版):

  1. 如果版本的 db_trx_id <up_limit_id:这个版本是 “已提交的事务” 修改的,可见(比如同学 id=102 < 103,已经交卷,答案可参考);
  2. 如果版本的 db_trx_id >= low_limit_id:这个版本是 “生成 read view 后才开始的事务” 修改的,不可见(比如同学 id=105 >= 105,还没考试,答案不可参考);
  3. 如果版本的 db_trx_id 在 m_ids 中:这个版本是 “正在活跃的事务” 修改的,不可见(比如同学 id=103 在 m_ids 中,正在考试,答案不可参考);
  4. 如果版本的 db_trx_id 不在 m_ids 中:这个版本是 “已提交的事务” 修改的,可见(比如同学 id=102 不在 m_ids 中,已经交卷,答案可参考);
  5. 如果版本的 db_trx_id == creator_trx_id:这个版本是 “当前事务自己” 修改的,可见(比如你自己的 id=106,自己的答案可参考)。

5.2 mvcc 的工作流程(实战演示,逐步解析)

结合上面的组成部分,我们用一个实战案例,演示 mvcc 的完整工作流程 —— 解释 “为什么可重复读级别下,事务内多次查询结果一致”。

案例场景

  • 事务 a(id=106):查询张三的余额(可重复读级别);
  • 事务 b(id=107):修改张三的余额并提交;
  • 版本链:最新版本(db_trx_id=102,balance=2000)→ 版本 2(db_trx_id=101,balance=1500)→ 版本 1(db_trx_id=100,balance=1000)。

步骤 1:事务 a 开启,第一次查询(生成 read view)

-- 事务a(id=106):关闭自动提交,开启事务,第一次查询
set autocommit=0;
begin;
select name, balance from account where id=1;

生成的 read view(假设当前活跃事务 id=107)

  • m_ids=[107](事务 107 正在活跃);
  • up_limit_id=107(m_ids 中的最小事务 id);
  • low_limit_id=108(下一个事务 id);
  • creator_trx_id=106(当前事务 id)。

步骤 2:判断版本链中的版本是否可见

  1. 先判断最新版本(db_trx_id=102):
    • 102 < up_limit_id(107)→ 符合规则 1,可见;
    • 所以事务 a 第一次查询到的余额 = 2000。

步骤 3:事务 b 修改并提交

-- 事务b(id=107):关闭自动提交,开启事务,修改并提交
set autocommit=0;
begin;
update account set balance=2500.00 where id=1; -- db_trx_id=107,balance=2500
commit; -- 提交事务

此时版本链更新(新增版本 3)

  • 原记录(最新版本):db_trx_id=107,balance=2500,db_roll_ptr = 指向版本 2;
  • 版本 2:db_trx_id=102,balance=2000;
  • 版本 1:db_trx_id=101,balance=1500;
  • 版本 0:db_trx_id=100,balance=1000。

步骤 4:事务 a 第二次查询(复用 read view)

-- 事务a:第二次查询张三的余额
select name, balance from account where id=1;

再次判断版本链中的版本是否可见

  1. 先判断最新版本(db_trx_id=107):
    • 107 在 m_ids=[107] 中→ 符合规则 3,不可见;
  2. 再判断版本 2(db_trx_id=102):
    • 102 < up_limit_id(107)→ 符合规则 1,可见;
  3. 所以事务 a 第二次查询到的余额 = 2000(和第一次一致,可重复读)。

步骤 5:事务 a 提交后查询(生成新的 read view)

-- 事务a:提交事务,第三次查询
commit;
select name, balance from account where id=1;

生成新的 read view(当前活跃事务 id 为空)

  • m_ids=[];
  • up_limit_id=108(m_ids 为空时,up_limit_id=low_limit_id);
  • low_limit_id=108;
  • creator_trx_id=106。

判断版本是否可见

  1. 最新版本(db_trx_id=107):
    • 107 < up_limit_id(108)→ 符合规则 1,可见;
  2. 所以第三次查询到的余额 = 2500(看到最新修改)。

5.3 mvcc 的核心价值(为什么需要 mvcc)

mvcc 的核心价值是 “读不加锁、写加锁”,实现 “并发读和并发写”,解决了 “隔离性” 和 “并发性能” 的矛盾:

  1. 读操作(快照读)不阻塞写操作:事务 a 查询时,事务 b 可以修改数据,因为事务 a 看到的是历史版本,不会受事务 b 的影响;
  2. 写操作(当前读)不阻塞读操作:事务 b 修改数据时,事务 a 可以正常查询,因为事务 a 看到的是修改前的历史版本;
  3. 解决了隔离级别中的核心问题:可重复读、幻读,同时保证了并发性能 —— 这也是 mysql 可重复读级别成为默认级别的核心原因。

5.4 快照读 vs 当前读(mvcc 中的两种读操作)

在 mvcc 中,mysql 的读操作分为两种,这是理解隔离级别的关键

快照读(snapshot read)

  • 定义:读取数据的 “历史版本”(通过 mvcc 实现),不加锁,不阻塞写操作;
  • 适用 sql:普通 select 语句(不包含 for update、lock in share mode);
  • 示例:select * from account where id=1;
  • 特点:可重复读级别下,同一事务内的快照读复用同一个 read view,所以结果一致;
  • 通俗理解:读的是 “数据的快照”,不是最新版本,所以不会被写操作阻塞。

当前读(current read)

  • 定义:读取数据的 “最新版本”(当前版本),加锁,阻塞其他写操作;
  • 适用 sql:insert/update/delete、select for update、select lock in share mode;
  • 示例:select * from account where id=1 for update;
  • 特点:每次读取都是最新版本,同一事务内多次当前读可能得到不同结果;
  • 通俗理解:读的是 “数据的最新状态”,需要加锁防止其他事务修改,所以会阻塞写操作。

实战验证快照读和当前读

-- 事务a(可重复读级别)
set autocommit=0;
begin;
-- 快照读:第一次查询
select * from account where id=1; -- 结果:2000
-- 事务b
set autocommit=0;
begin;
update account set balance=2500 where id=1;
commit;
-- 事务a:快照读(第二次查询,结果不变)
select * from account where id=1; -- 结果:2000
-- 事务a:当前读(查询最新版本)
select * from account where id=1 for update; -- 结果:2500
-- 事务a:快照读(第三次查询,结果还是2000)
select * from account where id=1; -- 结果:2000

六、事务的常见问题与避坑指南

坑 1:使用 myisam 引擎,事务失效

现象:执行 begin/commit/rollback,数据没有按预期回滚,事务失效;比如执行 rollback 后,修改依然生效。

原因:myisam 引擎不支持事务,所有 dml 语句都会自动提交,事务操作无效;

解决方案

新建表时显式指定 innodb 引擎:

create table 表名 (字段 类型 约束) engine=innodb default charset=utf8mb4;

现有表转换为 innodb 引擎(转换前备份数据,避免数据丢失):

alter table 表名 engine=innodb;

验证表引擎是否转换成功:

show table status like '表名' \g;
-- 查看engine字段,显示innodb即为成功

坑 2:自动提交未关闭,事务不生效

现象:执行 begin 后,执行 insert/update/delete,未 commit 但数据已经生效,rollback 无效;比如插入一条记录后,未 commit,其他客户端已经能看到。

原因:mysql 默认开启自动提交(autocommit=on),虽然用 begin 开启了事务,但部分客户端或 sql 工具会自动提交,导致事务失效;

解决方案

  1. 手动关闭自动提交(当前会话有效):
set autocommit=0;
  1. 验证关闭结果,确保 autocommit=off:
show variables like 'autocommit';
  1. 执行事务时,严格遵循 “begin→执行 sql→commit/rollback” 流程,不省略任何步骤:
begin;
insert into account (name, balance) values('孙七', 2000.00);
-- 未提交,其他客户端看不到
rollback; -- 回滚后,记录消失
  1. 若需要永久关闭自动提交,修改 mysql 配置文件(my.cnf/my.ini),添加autocommit=0,重启 mysql(不推荐,影响全局)。

坑 3:事务中包含 ddl 语句,事务被自动提交

现象:事务中执行 create/alter/drop/truncate,之后执行 rollback 无效;比如事务中创建表后回滚,表依然存在。

原因:ddl 语句(数据定义语言)会强制自动提交事务,不管是否手动开启事务,执行 ddl 后事务立即结束;

解决方案

  1. 事务中只包含 dml 语句(insert/update/delete),不包含任何 ddl 语句;
  2. 如果需要执行 ddl,单独执行,不要和 dml 放在同一个事务中:
-- 错误示例:事务中包含ddl
begin;
update account set balance=balance+500 where name='张三';
create table test (id int); -- ddl自动提交事务
rollback; -- 无效,update已提交
  1. 若必须在事务前后执行 ddl,拆分流程:先执行 ddl→开启事务执行 dml→提交 / 回滚。

坑 4:隔离级别设置错误,导致并发问题

现象:生产环境出现脏读、不可重复读、幻读,比如库存超卖、数据统计不一致;

原因:手动修改了隔离级别,设置过低(如读未提交、读提交),导致并发问题;

解决方案

  1. 恢复 mysql 默认隔离级别(可重复读):
-- 设置全局隔离级别
set global transaction isolation level repeatable read;
-- 重启客户端生效
  1. 查看当前隔离级别,确保为 repeatable read:
select @@global.tx_isolation, @@session.tx_isolation;
  1. 开发中不要随意修改隔离级别,除非有特殊业务需求,且经过充分测试。

坑 5:长事务导致锁等待、性能下降

现象:事务执行时间过长(超过 1 分钟),导致其他事务锁等待,数据库 cpu、磁盘 io 飙升,响应变慢;

原因:长事务会占用锁资源,阻塞其他事务,同时产生大量 undo log,占用磁盘空间,拖慢数据库性能;

解决方案

  1. 事务尽量短小精悍,只包含必要的 sql 操作,避免复杂查询、远程调用(如调用第三方接口)、用户输入等待:
-- 错误示例:长事务包含远程调用
begin;
update account set balance=balance-100 where name='张三';
-- 远程调用第三方支付接口(耗时可能几秒到几十秒)
call third_party_pay(100);
update account set balance=balance+100 where name='商家';
commit;
  1. 拆分长事务为多个短事务,避免一次处理过多操作:
-- 拆分后:先扣钱,提交事务
begin;
update account set balance=balance-100 where name='张三';
insert into pay_log (user_name, amount, status) values('张三', 100, 'pending');
commit;
-- 远程调用(事务外)
call third_party_pay(100);
-- 支付成功后,更新状态、商家加钱
begin;
update pay_log set status='success' where user_name='张三' and amount=100;
update account set balance=balance+100 where name='商家';
commit;
  1. 定期监控长事务,及时终止:
-- 查看执行时间超过60秒的长事务
select * from information_schema.innodb_trx 
where time_to_sec(timediff(now(), trx_started)) > 60;
-- 终止长事务(trx_id为事务id)
kill trx_id;

坑 6:忽略事务的并发冲突,导致更新丢失

现象:并发事务修改同一条数据,导致 “更新丢失”;比如两个事务同时扣减库存,库存只扣减一次,而不是两次。

原因:未处理并发冲突,默认的隔离级别虽然能避免部分问题,但极端场景下(如高并发扣减库存)仍可能出现更新丢失;

解决方案

  1. 方案 1:使用 “悲观锁”(select for update),加行锁阻止其他事务修改:
-- 事务a:扣减库存
begin;
-- 加行锁,其他事务无法修改该记录,直到当前事务提交/回滚
select * from product where id=1 for update;
update product set stock=stock-1 where id=1;
commit;
-- 事务b:同时扣减库存(会被阻塞,直到事务a提交)
begin;
select * from product where id=1 for update;
update product set stock=stock-1 where id=1;
commit;
  1. 方案 2:使用 “乐观锁”(版本号 / 时间戳),适合并发量高的场景:
-- 1. 表中添加版本号字段
alter table product add version int default 1;
-- 2. 事务a:乐观锁更新
begin;
-- 查询商品信息,获取当前版本号
select stock, version from product where id=1; -- stock=10, version=1
-- 更新时校验版本号,只有版本号一致才更新
update product set stock=stock-1, version=version+1 where id=1 and version=1;
-- 影响行数=1,更新成功
commit;
-- 3. 事务b:同时更新
begin;
select stock, version from product where id=1; -- stock=10, version=1
update product set stock=stock-1, version=version+1 where id=1 and version=1;
-- 影响行数=0,更新失败,回滚重试
rollback;
-- 重试:重新查询版本号,再次更新
begin;
select stock, version from product where id=1; -- stock=9, version=2
update product set stock=stock-1, version=version+1 where id=1 and version=2;
-- 影响行数=1,更新成功
commit;

坑 7:事务回滚后,自增主键不回滚

现象:事务中插入记录(自增主键),回滚后,自增主键的值不会回滚,导致主键不连续;比如插入一条记录 id=5,回滚后,下一次插入 id=6,跳过 id=5。

原因:mysql 的自增主键是由自增计数器维护的,计数器的值在插入时就已递增,事务回滚不会影响计数器,导致主键不连续;

解决方案

  1. 接受主键不连续:自增主键的作用是唯一标识记录,不要求连续,大多数场景下无需处理;
  2. 若必须主键连续(如财务场景),不使用自增主键,改用业务主键(如日期 + 序列号):
-- 示例:业务主键(日期+序列号)
insert into order (order_id, user_id) 
values(concat(date_format(now(), '%y%m%d'), lpad(last_insert_id()+1, 4, '0')), 1);
  1. 避免在事务中频繁插入 / 回滚,减少主键不连续的情况。

坑 8:保存点使用不当,导致回滚失败

现象:创建保存点后,提交事务再回滚到保存点,报错 “保存点不存在”;或回滚到保存点后,再回滚到后续保存点,报错

原因:提交事务后,所有保存点会被自动删除;回滚到某个保存点后,该保存点之后的保存点会被删除;

解决方案

  1. 提交事务前使用保存点,提交后不依赖保存点回滚:
begin;
update account set balance=balance+500 where name='张三';
savepoint save1;
update account set balance=balance+500 where name='李四';
rollback to save1; -- 有效,回滚到save1
commit; -- 提交后,save1被删除
rollback to save1; -- 报错:savepoint save1 does not exist
  1. 回滚到保存点后,不再使用后续保存点:
begin;
savepoint save1;
update account set balance=balance+500 where name='张三';
savepoint save2;
rollback to save1; -- 回滚后,save2被删除
rollback to save2; -- 报错:savepoint save2 does not exist
  1. 保存点名称使用有意义的名称(如 save_after_update1),避免混淆。

坑 9:事务中使用 select for update,导致锁表

现象:事务中执行select * from table for update(未加 where 条件),导致全表锁,其他事务无法修改任何记录,并发性能暴跌。

原因select for update是悲观锁,未加 where 条件时,会对全表加锁,而不是行锁,阻塞所有写操作;

解决方案

  1. 执行select for update时,必须加 where 条件,且 where 条件字段有索引,确保加行锁而非表锁:
-- 正确示例:where条件+索引字段
begin;
select * from account where id=1 for update; -- id有主键索引,加行锁
update account set balance=balance+500 where id=1;
commit;
  1. 避免在事务中对大表执行无 where 条件的select for update,必要时拆分查询范围;
  2. 若需查询全表且不加锁,使用普通 select(快照读),而非select for update

坑 10:事务日志(undo/redo log)满了,导致事务失败

现象:执行事务时,报错 “the total number of locks exceeds the lock table size” 或 “undo log is full”,事务执行失败。

原因:undo log 或 redo log 的存储空间不足,无法记录事务操作;通常是长事务、大事务导致日志量过大;

解决方案

  1. 增大日志文件大小:修改 mysql 配置文件(my.cnf/my.ini),调整以下参数,重启 mysql:
# redo log大小(默认48m)
innodb_log_file_size = 2g
innodb_log_group_home_dir = /var/lib/mysql/
# undo log表空间大小
innodb_undo_tablespaces = 3
innodb_undo_log_size = 1g
  1. 拆分大事务为多个小事务,减少单次事务的日志量;
  2. 定期清理过期日志:mysql 会自动清理已提交事务的 undo log,确保日志空间循环使用;
  3. 监控日志空间使用情况:
-- 查看redo log使用情况
show engine innodb status like 'log%';
-- 查看undo log使用情况
select * from information_schema.innodb_trx_undo;

七、事务的实战场景落地

场景 1:银行转账(最经典场景)

需求:张三给李四转账 1000 元,要求:

  1. 扣减张三的余额(余额需充足);
  2. 增加李四的余额;
  3. 记录转账日志;
  4. 所有操作原子性,要么全成,要么全败。

实现代码(含存储过程 + 事务 + 异常处理)

-- 1. 创建转账日志表
create table transfer_log (
    id int primary key auto_increment,
    from_name varchar(50) not null, -- 转出用户名
    to_name varchar(50) not null, -- 转入用户名
    amount decimal(10,2) not null, -- 转账金额
    status varchar(20) not null, -- 状态:success/fail
    create_time datetime not null default now() -- 转账时间
) engine=innodb default charset=utf8mb4;
-- 2. 创建转账存储过程(含事务+异常处理)
delimiter $$
create procedure transfer_money(
    in from_name varchar(50), -- 转出用户名
    in to_name varchar(50), -- 转入用户名
    in transfer_amount decimal(10,2) -- 转账金额
)
begin
    -- 关闭自动提交
    set autocommit=0;
    begin
        -- 异常捕获:如果出错,回滚事务,记录失败日志
        declare exit handler for sqlexception
        begin
            rollback;
            -- 记录失败日志
            insert into transfer_log (from_name, to_name, amount, status)
            values(from_name, to_name, transfer_amount, 'fail');
            select '转账失败' as result;
        end;
        -- 1. 验证转出用户是否存在
        if not exists (select 1 from account where name=from_name) then
            signal sqlstate '45000' set message_text='转出用户不存在';
        end if;
        -- 2. 验证转入用户是否存在
        if not exists (select 1 from account where name=to_name) then
            signal sqlstate '45000' set message_text='转入用户不存在';
        end if;
        -- 3. 验证转出用户余额是否充足
        if (select balance from account where name=from_name) < transfer_amount then
            signal sqlstate '45000' set message_text='余额不足';
        end if;
        -- 4. 扣减转出用户余额
        update account set balance=balance-transfer_amount where name=from_name;
        -- 5. 增加转入用户余额
        update account set balance=balance+transfer_amount where name=to_name;
        -- 6. 记录成功日志
        insert into transfer_log (from_name, to_name, amount, status)
        values(from_name, to_name, transfer_amount, 'success');
        -- 7. 提交事务
        commit;
        select '转账成功' as result;
    end;
end $$
delimiter ;
-- 3. 调用存储过程:张三给李四转账1000元
call transfer_money('张三', '李四', 1000.00);
-- 4. 查看结果
select * from account where name in ('张三', '李四'); -- 查看余额变化
select * from transfer_log where from_name='张三' and to_name='李四'; -- 查看转账日志

场景 2:电商订单创建(多表关联场景)

需求:用户购买商品,要求:

  1. 扣减商品库存(库存需充足);
  2. 创建订单记录;
  3. 扣减用户余额(余额需充足);
  4. 所有操作原子性,要么全成,要么全败。

实现代码

-- 1. 商品表(含库存)
create table product (
    id int primary key,
    name varchar(50) not null,
    price decimal(10,2) not null,
    stock int not null default 0 -- 库存
) engine=innodb default charset=utf8mb4;
-- 2. 订单表
create table `order` (
    id int primary key auto_increment,
    user_id int not null, -- 用户id
    product_id int not null, -- 商品id
    product_name varchar(50) not null, -- 商品名称
    product_price decimal(10,2) not null, -- 商品单价
    num int not null, -- 购买数量
    total_amount decimal(10,2) not null, -- 订单总金额
    create_time datetime not null default now(), -- 订单创建时间
    foreign key (product_id) references product(id) -- 外键关联商品表
) engine=innodb default charset=utf8mb4;
-- 3. 插入测试数据
insert into product values(1, 'iphone 15', 5999.00, 10); -- 商品1:库存10
insert into account values(2, '用户2', 10000.00); -- 用户2:余额10000
-- 4. 创建创建订单的存储过程
delimiter $$
create procedure create_order(
    in u_id int, -- 用户id
    in p_id int, -- 商品id
    in buy_num int -- 购买数量
)
begin
    set autocommit=0;
    begin
        declare exit handler for sqlexception
        begin
            rollback;
            select '订单创建失败' as result;
        end;
        -- 声明变量:商品名称、单价、用户余额、订单总金额
        declare p_name varchar(50);
        declare p_price decimal(10,2);
        declare u_balance decimal(10,2);
        declare total decimal(10,2);
        -- 1. 查询商品信息(加行锁,防止并发修改)
        select name, price, stock into p_name, p_price, stock from product where id=p_id for update;
        -- 2. 验证库存是否充足
        if stock < buy_num then
            signal sqlstate '45000' set message_text='库存不足';
        end if;
        -- 3. 查询用户余额
        select balance into u_balance from account where id=u_id;
        -- 4. 计算订单总金额
        set total = p_price * buy_num;
        -- 5. 验证用户余额是否充足
        if u_balance < total then
            signal sqlstate '45000' set message_text='余额不足';
        end if;
        -- 6. 扣减商品库存
        update product set stock=stock-buy_num where id=p_id;
        -- 7. 扣减用户余额
        update account set balance=balance-total where id=u_id;
        -- 8. 创建订单
        insert into `order`(user_id, product_id, product_name, product_price, num, total_amount)
        values(u_id, p_id, p_name, p_price, buy_num, total);
        -- 9. 提交事务
        commit;
        select '订单创建成功' as result;
    end;
end $$
delimiter ;
-- 5. 调用存储过程:用户2购买商品1,数量2
call create_order(2, 1, 2);
-- 6. 查看结果
select * from product where id=1; -- 库存从10→8
select * from account where id=2; -- 余额从10000→10000-5999*2= -1998?不,余额充足,实际为10000-11998= -1998?错误,调整测试数据:用户2余额改为20000
update account set balance=20000 where id=2;
call create_order(2, 1, 2); -- 订单创建成功
select * from `order` where user_id=2; -- 订单记录

场景 3:用户注销(多表删除场景)

需求:用户注销账号,要求:

  1. 删除用户基本信息(account 表);
  2. 删除用户订单(order 表);
  3. 删除用户积分(points 表);
  4. 所有操作原子性,要么全成,要么全败。

实现代码

-- 1. 积分表
create table points (
    id int primary key auto_increment,
    user_id int not null, -- 用户id
    score int not null default 0, -- 积分
    foreign key (user_id) references account(id) -- 外键关联用户表
) engine=innodb default charset=utf8mb4;
-- 2. 插入测试数据
insert into points values(1, 2, 1000); -- 用户2:积分1000
-- 3. 创建用户注销存储过程
delimiter $$
create procedure user_logout(in u_id int)
begin
    set autocommit=0;
    begin
        declare exit handler for sqlexception
        begin
            rollback;
            select '注销失败' as result;
        end;
        -- 1. 验证用户是否存在
        if not exists (select 1 from account where id=u_id) then
            signal sqlstate '45000' set message_text='用户不存在';
        end if;
        -- 2. 删除用户积分(外键关联,需先删除子表数据)
        delete from points where user_id=u_id;
        -- 3. 删除用户订单
        delete from `order` where user_id=u_id;
        -- 4. 删除用户基本信息
        delete from account where id=u_id;
        -- 5. 提交事务
        commit;
        select '注销成功' as result;
    end;
end $$
delimiter ;
-- 4. 调用存储过程:注销用户2
call user_logout(2);
-- 5. 查看结果
select * from account where id=2; -- 无数据
select * from `order` where user_id=2; -- 无数据
select * from points where user_id=2; -- 无数据

结语:掌握事务,让你的数据操作更 “靠谱”

从银行转账的原子性保障,到电商订单的并发安全,再到财务系统的数据一致性,事务始终是数据库开发中守护数据安全的 “核心防线”。我们用通俗易懂的场景拆解了事务的本质,用一步步的实操讲清了 acid 属性、隔离级别、mvcc 原理,也梳理了开发中最容易踩的 10 个坑,最后落地到 3 个企业级实战场景 —— 其实事务并不复杂,它的核心始终围绕 “要么全成,要么全败”,以及 “并发下的数据一致性”。

对于刚接触事务的你来说,不必一开始就死磕 mvcc 的底层细节,先从 “用对” 开始:确保表使用 innodb 引擎、手动控制事务提交、优先使用默认的可重复读隔离级别、避免长事务和 ddl 混入事务,就能解决绝大多数业务场景的问题。当你在实战中遇到并发冲突、锁等待、数据不一致等问题时,再回头深挖隔离级别、悲观锁 / 乐观锁、mvcc 的原理,会有更深刻的理解。

数据是业务的核心资产,而事务是保障数据资产安全的 “基石”。希望这篇内容能让你不仅学会事务的语法,更能建立 “事务思维”—— 在设计任何涉及多步数据修改的业务逻辑时,第一时间想到用事务来兜底,用隔离级别和锁机制来应对并发,用避坑指南来规避生产事故。

最后,实践是掌握事务的最好方式。把文中的转账、订单、注销案例复刻到你的开发环境中,尝试修改参数、模拟并发场景、故意制造错误,看看事务是否能按预期回滚;在自己的项目中,从最简单的 “新增 + 修改” 事务开始落地,逐步过渡到高并发的库存扣减、支付对账等场景。当你能从容应对事务相关的 bug,能根据业务需求选择合适的隔离级别和锁策略时,你就真正掌握了 mysql 事务的精髓。

愿你的每一次数据操作,都有事务保驾护航,让业务系统在高并发下依然能保持数据的精准与安全。

到此这篇关于mysql 事务解析的文章就介绍到这了,更多相关mysql 事务解析内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!

(0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2026  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com