事务基础与 ACID
事务(Transaction)是数据库保证一组读写作为一个整体生效的机制:要么全部成功提交,要么全部失败回滚,中间状态不对并发读者「半成品可见」(具体可见性由隔离级别决定)。
1. 一句话与最小示例
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- 任一步失败应 ROLLBACK,避免只扣不增
没有事务时,网络中断、进程崩溃、语句失败都可能导致部分更新,对账与资金类业务不可接受。
2. ACID(工程视角)
| 属性 | 含义 | 在 InnoDB 中的直观支撑 |
|---|---|---|
| Atomicity 原子性 | 全做或全不做 | undo 日志支持回滚 |
| Consistency 一致性 | 约束与业务不变量不被破坏 | 约束 + 应用正确使用事务;引擎保证不落半成品结构 |
| Isolation 隔离性 | 并发事务互不干扰的程度 | 锁 + MVCC;由隔离级别调节 |
| Durability 持久性 | 提交后宕机不丢 | redo 日志 + 刷盘策略 |
注意:
- 一致性很大程度依赖应用:把「转账两边都要改」放进同一事务,是业务正确性,不是引擎替你想好的业务规则。
- 隔离性不是绝对「互相看不见」:默认也不是可串行化,见 隔离级别。
3. 提交、回滚与会话
BEGIN; -- 或 START TRANSACTION
-- SQL ...
COMMIT; -- 持久化本事务的修改
-- 或
ROLLBACK; -- 撤销本事务未提交修改
常用点:
- 自动提交(autocommit):MySQL 默认往往为开启,单条语句即一事务。显式
BEGIN后进入多语句事务,直到COMMIT/ROLLBACK。 - 保存点(SAVEPOINT):长事务中可部分回滚到标记点,但仍属同一事务,锁与资源可能一直持有。
- 长事务危害:长时间不提交会占用 undo、阻塞 purge、拉长锁持有时间、放大主从延迟风险。原则是事务尽量短。
4. Undo Log 与 Redo Log 工作原理及完整示例
先抛开术语,想清楚数据库要同时满足两件事:
- 写要快:不能每改一行就把整页数据立刻写回磁盘(随机写太慢)。
- 不能丢、能反悔:已
COMMIT的改动掉电后还在;用户ROLLBACK或事务中途失败时,能退回改之前的样子。
InnoDB 的解法是:真正的数据页可以晚点写盘,但必须先记日志(WAL,Write-Ahead Logging,日志先行)。这里用到两种日志,职责完全不同:
| 日志 | 一句话 | 主要服务谁 |
|---|---|---|
| Undo Log(撤销日志) | 「改之前长什么样」——用来反悔 | 原子性(回滚)、隔离性(MVCC 多版本读) |
| Redo Log(重做日志) | 「崩溃后数据该怎么重做出来」——用来重做 | 持久性(崩溃后不丢已提交事务) |
生活类比:
- Undo 像文档的「撤销」:记住改之前的内容,随时
Ctrl+Z。 - Redo 像施工队的「施工记录」:哪怕原图还没贴到墙上,只要施工记录已存档,停电后按记录把墙重新刷一遍即可。
一次修改时,两者大致这样分工:
执行 UPDATE / INSERT / DELETE
│
├─► 先记 Undo:旧值是多少(以后好回滚 / 给别人看旧版本)
├─► 再改内存里的数据页(Buffer Pool,改完的页叫「脏页」)
└─► 同时记 Redo:哪个页、哪一块变成了什么
│
└─ COMMIT 时:把 Redo 刷到磁盘 → 事务就算「持久了」
(真正的数据文件 .ibd 可稍后由后台刷脏页)
下面先单独讲清楚各自怎么工作,再用一笔转账把两者串起来。
4.1 Undo Log:怎么「反悔」,以及别人怎么「看见旧版本」
它记什么?
Undo 是逻辑日志:不关心磁盘字节偏移,只关心「把这一行变回改之前」需要什么信息。
| 你做的操作 | Undo 里大致记什么 | 回滚时相当于 |
|---|---|---|
INSERT 一行 | 这行的主键 | 再 DELETE 掉 |
DELETE 一行 | 整行旧内容 | 再 INSERT 回来 |
UPDATE 一行 | 被改字段的旧值 | 再 UPDATE 回去 |
另外,每一行还有两个隐藏信息(实现细节,理解版本链就够了):
trx_id:最近改过这行的事务 ID。roll_pointer:指向「上一个版本」的 Undo 记录——像链表指针。
多次修改同一行时,就会串成一条版本链:当前行 → 上一版 → 更早的一版 → …
完整示例:同一行被改两次,版本链怎么长出来
初始:id=1, balance=1000(很早以前由已提交事务 T90 写好的)。
第 1 步:事务 T100 把余额改成 900(还没提交)
-- 事务 T100
UPDATE accounts SET balance = 900 WHERE id = 1;
引擎会做两件事:
- 写一条 Undo:
id=1的旧balance是1000。 - 改内存中的当前行:
balance=900,trx_id=T100,roll_pointer指向这条 Undo。
当前行: id=1, balance=900, trx_id=T100
│
│ roll_pointer
▼
Undo#1: 旧值 balance=1000 (更早可接到 T90 留下的版本)
第 2 步:事务 T101 再改成 800(也还没提交)
-- 事务 T101
UPDATE accounts SET balance = 800 WHERE id = 1;
- 再写一条 Undo:旧值现在是
900(T100 改出来的那个),这条 Undo 再指回 Undo#1。 - 当前行变成:
balance=800,trx_id=T101。
当前行: balance=800 (T101)
→ Undo#2: 旧值 900
→ Undo#1: 旧值 1000
可以记成:当前行永远是「最新样子」;Undo 链负责保存「往回走」的每一格台阶。
这些信息什么时候用得上?
场景 A:T101 执行 ROLLBACK(自己反悔)
- 顺着当前行的指针找到 Undo#2。
- 用里面的旧值,把行改回
balance=900,trx_id等也跟着回到 T100 那一版。 - 相当于只撤销 T101 这一步;T100 若还在,它的修改仍在。
场景 B:另一个事务 T102 用普通 SELECT 读这一行(快照读 / MVCC)
此时内存里最新值已经是 800,但 T100、T101 都还没提交。按隔离规则,别人不该看见未提交的改动。
引擎的做法是「顺着 Undo 往回找,直到找到对你可见的版本」:
- 看当前行
800(T101)→ 未提交 → 跳过。 - 看 Undo 里的
900(仍挂在 T101 的改动链上)→ 仍不可见 → 跳过。 - 再往回,得到已提交世界里的
1000→ T102 读到的就是 1000。
一句话:Undo 既是回滚的「撤销栈」,也是别人读数据时的「时光机」。隔离级别细节见 隔离级别。
4.2 Redo Log:为什么 COMMIT 后掉电也不丢
核心矛盾
改数据其实先发生在内存(Buffer Pool)里。若只改内存、数据文件还没写盘就宕机,改动会丢。
若每次 COMMIT 都把涉及的数据页立刻写盘:页大、位置分散,随机写很慢。
解法:提交时只保证「重做日志」上盘
Redo 是物理向的日志:大致记「某个数据页上,某一小段被改成了什么」(比整页拷贝轻得多)。
写路径可以记成三步:
- 改内存:数据页变成脏页。
- 记 Redo:顺序追加到 Redo 缓冲区(顺序写,很快)。
COMMIT时刷 Redo 到磁盘(常见配置innodb_flush_log_at_trx_commit = 1表示每次提交都刷稳)。
只要 Redo 已经稳稳落在磁盘上,数据库就可以对客户端说「提交成功」。真正的 .ibd 数据文件可以之后再由后台慢慢刷脏页。
SQL 修改
├─► 内存数据页(脏页) ──后台异步──► 磁盘数据文件 .ibd
└─► Redo 缓冲区 ──COMMIT 时刷盘──► 磁盘 redo 日志文件
(顺序写;有了它,崩溃也能重做)
完整示例:提交成功 → 断电 → 重启恢复
故事:事务 T200 把 id=1 的余额从 800 改成 500,并成功 COMMIT。此时数据页还在内存脏页里,还没写回数据文件。
正常提交时发生了什么
- 内存页上的余额改成
500。 - Redo 里记下一笔:某页某处应写成
500(引擎内部用 LSN 等序号标记日志先后,可先理解为「进度条」)。 COMMIT→ 把这笔 Redo(含提交标记)刷到磁盘。- 客户端收到「提交成功」。
注意:此时磁盘数据文件里可能仍是旧的800——没关系,Redo 里已经有「正确答案」了。
然后断电
内存全没了,脏页一起消失。磁盘数据文件若仍是 800,岂不是丢了已提交的 500?
重启后(崩溃恢复)
- 对比:数据文件「刷到哪了」 vs Redo「记到哪了」。发现 Redo 更靠前 → 说明有已提交修改还没落到数据文件。
- 重放 Redo(前滚):按日志把页重新改成
500。已提交事务回来了。 - 未提交的事务:再用 Undo 回滚掉,避免半成品留下来。
- 恢复结束,对外提供服务。
记忆口诀:提交靠 Redo 保证「不丢」;反悔和清半成品靠 Undo。
4.3 两者如何配合:一笔转账的完整时间线
BEGIN; -- 事务 T300
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- 1000 → 900
UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- 500 → 600
COMMIT;
| 步骤 | 你在做什么 | Undo | 内存数据页 | Redo | 磁盘上 |
|---|---|---|---|---|---|
| 1 | BEGIN | 准备记录空间 | 未改 | 无 | 无变化 |
| 2 | 扣 id=1 | 记下旧值 1000 | id=1 变成 900(脏页) | 记下这一页的修改 | 数据文件仍是旧值 |
| 3 | 加 id=2 | 记下旧值 500 | id=2 变成 600(脏页) | 再记一笔 | 同上 |
| 4 | 发起 COMMIT | 本事务修改进入「可后续清理」状态 | 仍是脏页 | 追加提交标记 | 准备刷日志 |
| 5 | 提交完成 | — | 仍是脏页 | Redo 刷盘成功 → 事务已持久 | 日志已落盘;数据文件可能仍旧 |
| 6 | 后台刷脏(稍后) | 无读者需要旧版本后,Purge 回收 Undo | 页写回 .ibd | Checkpoint 推进,旧 Redo 可覆盖 | 数据文件也变成 900 / 600 |
三种「出状况」分别靠谁救
-
步骤 3 之后你
ROLLBACK,或业务主动放弃
用 Undo:先把 id=2 改回 500,再把 id=1 改回 1000。整笔转账像没发生过(原子性)。
回滚时对数据页的反向修改同样会写 Redo(回滚过程也可崩溃恢复,见下节)。 -
步骤 5 的 Redo 还没刷稳就断电
磁盘上没有这次事务的提交记录。重启后按未完成事务处理,用 Undo 清掉半成品。
结果:不会出现「只扣了一边」的持久状态。
(「从哪知道有这笔未完成事务」:主要靠 Redo 分析 + 回滚段/事务状态 判定无可靠 COMMIT;怎么清靠 Undo。详见下节。) -
步骤 5 已成功(客户端已收到 COMMIT),但步骤 6 还没做就断电
数据文件可能还是旧余额,但 Redo 里有完整重做信息。重启重放 Redo,两边余额都变成 900 / 600。
结果:已提交不丢(持久性)。
用 Undo 清半成品时:若「清」做到一半或失败了怎么办?
这里要分清两件事:客户端眼里的失败,和 引擎最终会不会留下半转账。
(1)回滚 / 恢复清理本身也写 Redo
无论是你主动 ROLLBACK,还是崩溃恢复里对未提交事务做 Undo:
读 Undo 拿到旧值 → 反向改数据页 → 这次「改回去」同样记入 Redo
因此「清半成品」不是静默改内存,而是可记录、可中断后续的过程。
(2)清到一半又断电:下次重启接着清,不是从零猜
以转账为例,恢复中途只撤完了 B(600→500),还没撤 A 就又断电:
再次重启
① 用 Redo 前滚到「日志进度」
(含:曾做过的扣加,以及已经记过日志的「B 已改回」)
② 仍无 T300 的 COMMIT → 仍是未完成事务
③ 继续按 Undo 撤剩余部分(A 改回 1000)
④ 事务清理结束,对外一致
口诀:当次撤不完,崩溃恢复还要接着撤;未提交事务没有「永久卡在半成品」的正常结局。
(3)客户端看到 ROLLBACK / 恢复「失败」时
| 现象 | 常见原因 | 引擎侧通常怎么处理 |
|---|---|---|
ROLLBACK 报错 / 超时 / 连接断 | 网络断、会话被杀、实例崩溃 | 服务端仍按未提交处理;若进程已死,重启恢复继续用 Undo 清完 |
| 崩溃恢复阶段清理未提交事务时实例再次崩溃 | 恢复中又断电等 | 再启后从 checkpoint/日志进度继续,重复历史 + 继续回滚直到未提交事务结束 |
| 应用不确定转账有没有成功 | 超时、不知道 COMMIT 是否返回 | 不要猜:查业务单/余额;未提交最终会被清掉,已 COMMIT 则只能冲正,不能靠再 ROLLBACK |
对应用的含义:
- 未 COMMIT:最终不会以「半边转账已持久成功」的形式留下;客户端失败 ≠ 库永久半脏。
- 已 COMMIT:与 Undo 清半成品无关;失败只能新事务冲正。
(4)什么时候才算「真清不掉」?
正常设计下,Undo 失败的处理策略是 重试 / 下次恢复继续,而不是接受半成品。只有环境级故障才会让实例无法完成恢复,例如:
- 磁盘写不了 Redo、盘满、数据/日志文件损坏
- 严重页损坏等需要备份或人工修复的情况
这时表现往往是 实例起不来或恢复报错,属于运维/容灾问题,而不是业务里「ROLLBACK 返回 false 却把 900/600 当成功提交」。
再压缩一句:
判定未提交靠 Redo(无 COMMIT)+ 事务/回滚段状态;清理靠 Undo;清理过程靠 Redo 可续跑。
Undo「失败」在正常路径里 = 这次没做完 → 下次恢复继续做完,而不是放弃原子性。
4.4 对比小结(背这张表就够用)
| 对比点 | Undo Log | Redo Log |
|---|---|---|
| 解决什么问题 | 能回滚;别人能读到合适的历史版本 | 已提交的修改崩溃后还能重做出来 |
| 对应 ACID | 原子性 +(配合 MVCC)隔离性 | 持久性 |
| 记的是什么 | 逻辑上的「旧值 / 怎么反向操作」 | 页级上的「改成了什么」(便于重放) |
| 何时写入 | 改行时先记旧版本,再改当前行 | 改页时写缓冲;COMMIT 时保证刷盘 |
| 怎么读 | 回滚 / 快照读时从新往旧顺着版本链走 | 崩溃恢复时从旧往新按序重放 |
| 何时可回收 | 没有事务再需要这个旧版本后,由 Purge 清理 | 对应脏页已刷进数据文件后,日志空间可循环覆盖 |
再压缩成两句:
- Undo:往回走——反悔、多版本可见性。
- Redo:往前补——保证「我说提交了」在断电后仍然算数。
5. 事务与索引、锁的关系
- 更新某行时,通常要在对应索引上定位行,并加上行级锁(或间隙相关锁)。
- 二级索引更新可能导致「删除旧索引项 + 插入新索引项」式的维护,锁与 undo 更复杂。
- 隔离级别决定:别人未提交的改动你能否看见、范围查询会不会加间隙锁等。
因此推荐学习顺序:事务基础(本文)→ 隔离级别 → 锁机制。
6. 实践清单
- 多语句业务写操作包进显式事务,失败路径明确
ROLLBACK。 - 事务内避免远程 RPC、大文件 I/O、人工等待。
- 监控长事务、未提交事务、历史列表长度(History list length)。
- 资金、库存等关键路径:短事务 + 合理隔离级别 + 必要的唯一约束/乐观锁版本号。
- 只读报表尽量用只读事务或从库,避免在大事务里扫全表。
下一篇:隔离级别。