【1】哪怕服务器当场爆炸,你的钱也丢不了!一文带你理清MySQL事务原理
写在前面
设想一个很日常的场景:手机银行里点了一次转账,页面转了几秒,最后弹出来一句“系统繁忙,请稍后再试”。
这时候脑子里最先冒出来的往往不是“重试一下就行”,而是更具体也更扎心的那句:钱到底扣了没有?对方到底收到了没有?
MySQL事务要解决的,就是这类“看起来只是一次失败提示,但背后可能留下半成品”的问题。用最普通的银行转账把这个问题立住,再往下讲ACID、隔离级别、MVCC、锁、undo/redo,会顺很多。
先把例子定死:账户A有5000元,账户B有3000元。现在要从A给B转1000元。
账户表:
| id | owner | balance | status |
|---|---|---|---|
| 1 | A | 5000 | active |
| 2 | B | 3000 | active |
转账流水表:
| txn_id | from_account | to_account | amount | state | created_at |
|---|---|---|---|---|---|
| 9001 | 1 | 2 | 1000 | init | 2026-04-23 10:00:00 |
业务上看,这次转账至少有三步:
- 从
A账户扣掉1000 - 给
B账户加上1000 - 把这笔转账流水记为完成
如果把它们当作三条彼此独立的SQL去执行,中间任何一步失败,数据库都可能留下一个不该存在的中间状态。
比如先扣款成功,程序还没来得及给B加钱就崩了:
| 时刻 | A 余额 | B 余额 | 说明 |
|---|---|---|---|
| 初始 | 5000 | 3000 | 转账前 |
| 扣款后 | 4000 | 3000 | 第一步已经执行 |
| 崩溃后 | 4000 | 3000 | 第二步、第三步没执行完 |
这时候最严重的问题不是“程序报错了”,而是数据库已经把错误状态保存下来了。A的钱少了,B没收到,总额凭空少了1000。
事务就是为这种场景存在的。它要解决的不是“让多条SQL写在一起更整齐”,而是让一组业务动作要么一起成功,要么一起失败。
先看BEGIN到COMMIT包住了什么
把刚才的转账写成事务,大概会是这样:
STARTTRANSACTION;UPDATEaccountSETbalance=balance-1000WHEREid=1;UPDATEaccountSETbalance=balance+1000WHEREid=2;UPDATEtransfer_logSETstate='done'WHEREtxn_id=9001;COMMIT;下面这张示意图把这条链路放在同一张图里:事务从哪里开始,哪里可能回滚,提交之后又靠什么保证结果不丢。
如果中途发现任何问题,比如余额不足、网络异常、业务校验失败,也可以直接回滚:
STARTTRANSACTION;UPDATEaccountSETbalance=balance-1000WHEREid=1;-- 这里发现异常ROLLBACK;事务最朴素的语义可以先记成下面这张表:
| 操作 | 是否算最终生效 | 作用 |
|---|---|---|
START TRANSACTION | 否 | 开启一个事务上下文 |
中间若干条INSERT/UPDATE/DELETE | 暂时不算最终完成 | 这些修改后面还可能回滚 |
COMMIT | 是 | 把整组修改一起提交 |
ROLLBACK | 否 | 撤销整组尚未提交的修改 |
也就是说,事务真正保护的是业务动作的整体性。转账不是“扣款语句”和“加款语句”的简单相加,而是一个不可拆开的业务单元。
用银行转账把ACID四个字母落地
讲MySQL事务时,最常见的四个字母是ACID。这四个字母经常被背下来,但没有落到业务里就会显得很空。
还是回到这笔转账:
| 属性 | 中文名 | 普通解释 | 在转账里的意思 | 如果没有会怎样 |
|---|---|---|---|---|
Atomicity | 原子性 | 一组操作要么一起成功,要么一起失败 | 扣款、加款、记流水要么都成功,要么都失败 | 只扣不加,留下半成品 |
Consistency | 一致性 | 事务前后,数据和约束都要保持正确 | 转账前后,约束和业务规则仍成立 | 总金额失真,余额可能出现非法值 |
Isolation | 隔离性 | 并发事务之间不要互相看见半成品或互相干扰 | 并发事务之间尽量互不干扰 | 别人可能看到未完成状态 |
Durability | 持久性 | 一旦提交成功,结果就不能因为宕机丢掉 | 一旦提交成功,宕机后结果也不能丢 | 刚转成功,机器一掉电就没了 |
这四个词里,最容易让人误会的是Consistency。
它不是说“数据库自己替业务做所有正确性判断”,而是说事务执行前后,数据库约束和业务要求不能被破坏。比如转账前后,总余额应该守恒;比如余额字段不能写成非法值;比如流水状态不能既是done又没完成加款。
四个字母里最有工程味道的两个,其实是后面的Isolation和Durability。前者主要处理并发,后者主要处理宕机恢复。MySQL事务真正复杂的部分,大多都藏在这里。
两个人同时操作时,问题才真正开始
单线程世界里的事务并不难理解。复杂度来自并发。
设想两个柜员同时操作同一个账户:
- 事务
T1:执行A -> B转1000 - 事务
T2:在T1还没提交时,查询A的余额,或者也从A再扣一笔钱
这时数据库要回答的问题就不再只是“要不要回滚”,而是:
T2能不能看到T1还没提交的扣款结果?T2在同一个事务里查两次余额,结果能不能不同?T2做范围查询时,会不会突然多出一行刚插入的数据?
把几个典型并发时点压成一张示意表,大概是这样:
| 时间 | T1 | T2 | 风险 |
|---|---|---|---|
| 10:00 | 扣掉 A 的 1000,尚未提交 | - | A 出现未提交版本 |
| 10:01 | 未提交 | 第一次查询 A 的余额 | 在Read Uncommitted下可能脏读 |
| 10:02 | 提交 | 第二次查询 A 的余额 | 在Read Committed下可能前后不一致 |
| 10:03 | 若另一事务插入新待处理转账并提交 | 再次做范围统计 | 可能出现幻读 |
这就是隔离级别要解决的问题。它不是一个抽象开关,而是数据库在“并发性能”和“读写正确性”之间做的不同取舍。
隔离级别在防什么
InnoDB支持四种标准隔离级别:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 特点 |
|---|---|---|---|---|
Read Uncommitted | 可能 | 可能 | 可能 | 几乎不做隔离,能读到未提交数据 |
Read Committed | 避免 | 可能 | 可能 | 每次一致性读都拿新快照 |
Repeatable Read | 避免 | 避免 | 对普通一致性读固定快照;对锁定读依靠锁控制范围 | InnoDB默认级别 |
Serializable | 避免 | 避免 | 避免 | 最强,但并发代价最高 |
先把三个常见问题说清:
1. 脏读
T1扣了A的钱但还没提交,T2已经把这个结果读走了。后来T1回滚,说明T2刚才看到的是一份从未真正成立过的数据。
2. 不可重复读
T2在同一个事务里查了两次A的余额。第一次是5000,第二次变成4000。中间自己没改过任何东西,但读取结果变了。
3. 幻读
T2先查“金额大于5000的待处理转账有几笔”,结果是3笔。另一个事务插入一条新数据并提交后,T2再查一次变成4笔。不是某一行变了,而是结果集合里“多出了一行幻影”。
从银行系统的角度看,这三种问题都很糟。差别只是糟的方式不同。
为什么InnoDB不是所有读取都直接加锁
很多人第一次接触并发控制时,会自然想到一个简单方案:既然并发会出问题,那就把正在读写的数据都锁起来。
这个方案能工作,但扩展性很差。
银行系统有大量查询请求:
- 查账户余额
- 查最近流水
- 查某个时间段内的转账记录
如果一笔转账开始后,所有相关查询都必须等它彻底结束,系统吞吐会迅速下降。数据库不能只追求“绝对安全”,还得考虑“可并发地安全”。
InnoDB的核心思路是把读分成两类:
| 读取方式 | 典型语句 | 特点 |
|---|---|---|
| 一致性读 | 普通SELECT | 不加锁,基于快照读 |
| 当前读 | SELECT ... FOR UPDATE、SELECT ... FOR SHARE、UPDATE、DELETE | 读取当前最新可操作版本,并配合加锁 |
这个区分很重要。
查询余额这种场景,很多时候只需要一个逻辑自洽的快照,并不一定要把别人拦住。真正需要锁的是那些会参与后续修改、必须和最新状态对齐的操作。
于是InnoDB没有走“所有读都加锁”的路线,而是引入了MVCC。
MVCC先解决的是“读不要总跟写打架”
MVCC是多版本并发控制。它的核心不是一句“一个数据有多个版本”就结束了,而是:
- 一行数据被修改时,旧版本不会立刻消失
- 不同事务可以按自己的可见性规则看到不同版本
- 普通
SELECT尽量不阻塞写,写也尽量不阻塞普通SELECT
这一点用一张图会直观很多:同一行有版本链,普通SELECT更像是在读“对自己可见的快照”,而FOR UPDATE / UPDATE这类操作则需要盯住“最新且可修改”的版本。
如果更想直接看“快照读”和“当前读”分别盯住哪个版本,下面这张对照图会更直白一些:
还是看账户A的余额。初始是5000。事务T1发起转账,把它改成4000,但尚未提交。
可以把版本关系理解成下面这样:
| 版本 | balance | 创建该版本的事务 id | 是否已提交 | 备注 |
|---|---|---|---|---|
| V1 | 5000 | 100 | 是 | 初始版本 |
| V2 | 4000 | 101 | 否 | T1扣款后产生 |
这时另一个事务T2如果执行普通SELECT,在默认的Repeatable Read下,它通常不会直接看到V2,而是看到对自己可见的旧版本V1。因为V2还没提交。
等T1提交之后,后续新事务再来读取,就可以看到4000这个新版本。
这就是MVCC最直接的收益:转账事务还在执行,查余额的请求不必全部堵住;查询拿的是一个一致的历史快照,而不是半完成现场。
快照为什么能成立:旧版本要能找得回来
只说“有多个版本”还不够,数据库还得真的能把旧版本找出来。
这就是undo log的第一个作用。
当事务修改一行数据时,InnoDB不只是写新值,还会把回退所需的信息记下来。对于账户A来说,如果余额从5000改成4000,数据库得能在需要的时候知道:这行原来是5000。
可以把这个过程简化成下面的样子:
| 时刻 | 当前可写版本 | undo log中保存的旧值 | 说明 |
|---|---|---|---|
| 转账前 | 5000 | - | 还没发生修改 |
| 扣款后 | 4000 | 5000 | 已经具备回滚能力 |
| 提交后 | 4000 | 5000 的历史信息仍可能被旧快照访问 | 服务MVCC |
所以undo log不只是为了“出错时回滚”,它还支撑了历史版本读取。快照读之所以能看到旧值,不是因为数据库凭空记得住过去,而是因为这些过去被保存在版本链上。
Read Committed和Repeatable Read真正差在哪里
很多人知道InnoDB默认是Repeatable Read,但不一定知道它和Read Committed在转账场景里到底差在哪。
差别先记成一句话:
Read Committed:每次一致性读都拿一个新的已提交快照Repeatable Read:同一个事务里的普通一致性读,通常沿用第一次读建立的快照
还是看同一个查询事务T2:
| 时间 | T1 | T2在Read Committed下看到什么 | T2在Repeatable Read下看到什么 |
|---|---|---|---|
| 10:00 | T1开始转账,扣掉 A 的 1000,未提交 | 第一次查余额,看到 5000 | 第一次查余额,看到 5000 |
| 10:01 | T1提交 | 第二次查余额,看到 4000 | 第二次普通SELECT仍看到 5000 |
这就是为什么:
Read Committed可以避免脏读,但不能保证同一事务中两次普通读取结果一致Repeatable Read可以让同一事务里的普通SELECT保持稳定
这里有一个容易混淆的点。Repeatable Read下稳定的是普通一致性读,不是所有操作都永远固定在旧世界里。
像UPDATE、DELETE、SELECT ... FOR UPDATE这类当前读,面对的是最新的可操作记录,并会参与加锁。也正因为如此,在同一个事务里把普通快照读和锁定读混在一起看,经常会觉得“怎么像是看到了两套世界”,因为它们本来就是两种不同读取语义。
锁不是被MVCC取代了,而是被放到了更该出现的地方
MVCC解决的是普通读和写之间的很多冲突,但真正修改数据时,锁仍然不可少。
在转账里,下面这种操作显然不能让两个事务随便同时写:
UPDATEaccountSETbalance=balance-1000WHEREid=1;如果两个事务同时修改账户A的余额,却没有合适的锁保护,就可能出现覆盖更新、余额判断失效等问题。
InnoDB里和这条主线最相关的几种锁,可以先这样理解:
| 锁类型 | 锁住什么 | 在转账主线里的作用 |
|---|---|---|
| 行锁 | 某条记录 | 防止两个事务同时改同一账户余额 |
| 间隙锁 | 记录之间的区间 | 防止别人在范围里插入新记录 |
| 临键锁 | 行锁+间隙锁 | 处理范围条件下的并发插入问题 |
如果按主键精确更新一个账户,比如WHERE id = 1,通常关注的重点是行锁:这条账户记录在当前事务完成前,不希望被另一个事务随意改动。
如果是范围条件,比如查询并锁定“所有待处理的大额转账记录”,数据库就不只是要锁住现有行,还可能要把相关区间一起保护起来,否则别的事务可能趁空隙插入一条新记录,前后两次范围结果就变了。这也是间隙锁、临键锁存在的原因。
回滚为什么真的能把现场撤回去
再回到最开始那笔出错的转账。
假设事务已经执行了扣款:
UPDATEaccountSETbalance=balance-1000WHEREid=1;结果在给B加钱之前,应用发现异常,决定执行:
ROLLBACK;数据库之所以能撤回,不是因为它“记得刚才做过什么”,而是因为修改时已经把反向恢复所需的信息写到了undo log。
过程可以理解成这样:
| 时刻 | A 余额 | undo log中的信息 | 结果 |
|---|---|---|---|
| 转账前 | 5000 | - | 原始状态 |
| 扣款后 | 4000 | A 原余额是 5000 | 可以回滚 |
| 执行回滚后 | 5000 | 已完成恢复 | 状态撤回 |
这也是事务原子性的一个底层支撑:不是说数据库抽象上承诺“要么都成功”,而是说它真的准备好了恢复路径。
提交成功后为什么宕机也不怕
事务还有另一个问题:回滚讲的是“没做完怎么办”,持久化讲的是“已经说做完了,机器突然挂了怎么办”。
设想这样一个场景:
A扣了1000B加了1000- 应用收到“提交成功”
- 机器立刻断电
如果数据库只是把修改先放在内存里,还没来得及可靠地落到磁盘,那么重启之后,这笔已经告诉业务“成功”的转账就可能消失。
这就是redo log的作用。
redo log可以先理解成“重做这次已确认修改所需的信息”。一旦系统在数据页完全落盘之前崩溃,重启恢复时就可以依据redo log把那些已经确认过的修改重新补上。
把它放进转账里看:
| 场景 | 需要undo log吗 | 需要redo log吗 | 目标 |
|---|---|---|---|
| 转账做到一半决定撤销 | 是 | 否 | 撤回未完成修改 |
| 转账已经提交,随后宕机 | 否 | 是 | 恢复已确认修改 |
所以undo和redo分别解决的是两类不同失败:
undo面对的是事务内部失败或主动撤销redo面对的是提交之后的崩溃恢复~
这两个问题都跟“失败”有关,但不是同一种失败。
为什么事务日志要分成undo和redo
把两者放在一张表里看,区别会更清楚:
先把它们分别在解决什么问题讲清楚,会更不容易混:
| 日志 | 主要用途 | 面向什么失败 | 在转账中的作用 |
|---|---|---|---|
undo log | 回滚、提供旧版本 | 事务没做完,或者需要撤销 | 把 A 从 4000 恢复到 5000 |
redo log | 崩溃恢复、保证持久化 | 已提交,但数据页还没完全落盘时宕机 | 保证 A 减 1000、B 加 1000 不丢 |
可以把它们分别看成两个问题的答案:
- “如果现在不想要这次修改了,怎么退回去?”靠
undo - “如果已经确认这次修改有效了,机器挂掉后怎么补回来?”靠
redo
没有undo,事务很难真正做到原子回滚;没有redo,提交成功就不够可靠。
把同一笔转账从头到尾再串一遍
到这里,再把那笔A -> B转1000的过程完整走一遍,事务的几个关键机制就能串起来了。
第一步:开启事务
数据库知道接下来这些操作属于同一个业务单元,而不是三条彼此独立的SQL。
第二步:修改账户余额和流水
UPDATE账户时,InnoDB会针对当前读和写入需要处理锁;修改前后的版本关系会进入MVCC体系;回退所需的信息会写入undo log。
第三步:并发查询同时发生
其他事务如果执行普通SELECT,通常走一致性读,不一定被这次转账完全阻塞。它们看到的,是对自己可见的快照版本,而不是随手读到半成品。
第四步:决定提交还是回滚
- 如果业务失败,用
undo log把已经做过的修改撤回 - 如果业务成功,进入提交流程
第五步:提交后的持久化保障
一旦提交确认,redo log负责保证这次修改在崩溃后仍然可以恢复出来。提交成功就不再只是“当前内存里看起来成功”,而是“系统出故障后也能重建结果”。
压成一张表就是:
| 阶段 | 关键机制 | 它解决的问题 |
|---|---|---|
| 执行中 | 锁、当前读 | 防止并发写把同一账户改乱 |
| 读取中 | 一致性读、MVCC | 让普通查询尽量不和写全面冲突 |
| 失败时 | undo log | 把半完成事务撤回 |
| 提交后 | redo log | 保证宕机后结果不丢 |
| 整体上 | 事务 | 把多步业务变成一个可靠整体 |
最后只留下三件事
第一,事务不是给数据库语法加层包装,而是把多步业务动作捆成一个不可随意拆开的整体。银行转账这种场景如果没有事务,最容易留下半完成状态。
第二,MySQL事务的难点不在BEGIN和COMMIT这两个关键字,而在并发和恢复。隔离级别、锁、MVCC、undo log、redo log其实都在解决这两件事。
第三,MVCC和锁不是对立关系。普通查询尽量靠多版本快照减少冲突,真正要修改当前数据时仍然需要锁;事务失败靠undo撤回,提交后宕机靠redo恢复。把这些机制一起看,MySQL事务原理才算真正闭环。
