跳到主要内容

事务基础与 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 工作原理及完整示例​

先抛开术语,想清楚数据库要同时满足两件事:

  1. 写要快:不能每改一行就把整页数据立刻写回磁盘(随机写太慢)。
  2. 不能丢、能反悔:已 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;

引擎会做两件事:

  1. 写一条 Undo:id=1 的旧 balance 是 1000。
  2. 改内存中的当前行: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;
  1. 再写一条 Undo:旧值现在是 900(T100 改出来的那个),这条 Undo 再指回 Undo#1。
  2. 当前行变成:balance=800,trx_id=T101。
当前行: balance=800 (T101)
→ Undo#2: 旧值 900
→ Undo#1: 旧值 1000

可以记成:当前行永远是「最新样子」;Undo 链负责保存「往回走」的每一格台阶。

这些信息什么时候用得上?​

场景 A:T101 执行 ROLLBACK(自己反悔)

  1. 顺着当前行的指针找到 Undo#2。
  2. 用里面的旧值,把行改回 balance=900,trx_id 等也跟着回到 T100 那一版。
  3. 相当于只撤销 T101 这一步;T100 若还在,它的修改仍在。

场景 B:另一个事务 T102 用普通 SELECT 读这一行(快照读 / MVCC)

此时内存里最新值已经是 800,但 T100、T101 都还没提交。按隔离规则,别人不该看见未提交的改动。

引擎的做法是「顺着 Undo 往回找,直到找到对你可见的版本」:

  1. 看当前行 800(T101)→ 未提交 → 跳过。
  2. 看 Undo 里的 900(仍挂在 T101 的改动链上)→ 仍不可见 → 跳过。
  3. 再往回,得到已提交世界里的 1000 → T102 读到的就是 1000。

一句话:Undo 既是回滚的「撤销栈」,也是别人读数据时的「时光机」。隔离级别细节见 隔离级别。


4.2 Redo Log:为什么 COMMIT 后掉电也不丢​

核心矛盾​

改数据其实先发生在内存(Buffer Pool)里。若只改内存、数据文件还没写盘就宕机,改动会丢。

若每次 COMMIT 都把涉及的数据页立刻写盘:页大、位置分散,随机写很慢。

解法:提交时只保证「重做日志」上盘​

Redo 是物理向的日志:大致记「某个数据页上,某一小段被改成了什么」(比整页拷贝轻得多)。

写路径可以记成三步:

  1. 改内存:数据页变成脏页。
  2. 记 Redo:顺序追加到 Redo 缓冲区(顺序写,很快)。
  3. COMMIT 时刷 Redo 到磁盘(常见配置 innodb_flush_log_at_trx_commit = 1 表示每次提交都刷稳)。

只要 Redo 已经稳稳落在磁盘上,数据库就可以对客户端说「提交成功」。真正的 .ibd 数据文件可以之后再由后台慢慢刷脏页。

SQL 修改
├─► 内存数据页(脏页) ──后台异步──► 磁盘数据文件 .ibd
└─► Redo 缓冲区 ──COMMIT 时刷盘──► 磁盘 redo 日志文件
(顺序写;有了它,崩溃也能重做)

完整示例:提交成功 → 断电 → 重启恢复​

故事:事务 T200 把 id=1 的余额从 800 改成 500,并成功 COMMIT。此时数据页还在内存脏页里,还没写回数据文件。

正常提交时发生了什么

  1. 内存页上的余额改成 500。
  2. Redo 里记下一笔:某页某处应写成 500(引擎内部用 LSN 等序号标记日志先后,可先理解为「进度条」)。
  3. COMMIT → 把这笔 Redo(含提交标记)刷到磁盘。
  4. 客户端收到「提交成功」。
    注意:此时磁盘数据文件里可能仍是旧的 800——没关系,Redo 里已经有「正确答案」了。

然后断电

内存全没了,脏页一起消失。磁盘数据文件若仍是 800,岂不是丢了已提交的 500?

重启后(崩溃恢复)

  1. 对比:数据文件「刷到哪了」 vs Redo「记到哪了」。发现 Redo 更靠前 → 说明有已提交修改还没落到数据文件。
  2. 重放 Redo(前滚):按日志把页重新改成 500。已提交事务回来了。
  3. 未提交的事务:再用 Undo 回滚掉,避免半成品留下来。
  4. 恢复结束,对外提供服务。

记忆口诀:提交靠 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磁盘上
1BEGIN准备记录空间未改无无变化
2扣 id=1记下旧值 1000id=1 变成 900(脏页)记下这一页的修改数据文件仍是旧值
3加 id=2记下旧值 500id=2 变成 600(脏页)再记一笔同上
4发起 COMMIT本事务修改进入「可后续清理」状态仍是脏页追加提交标记准备刷日志
5提交完成—仍是脏页Redo 刷盘成功 → 事务已持久日志已落盘;数据文件可能仍旧
6后台刷脏(稍后)无读者需要旧版本后,Purge 回收 Undo页写回 .ibdCheckpoint 推进,旧 Redo 可覆盖数据文件也变成 900 / 600

三种「出状况」分别靠谁救​

  1. 步骤 3 之后你 ROLLBACK,或业务主动放弃
    用 Undo:先把 id=2 改回 500,再把 id=1 改回 1000。整笔转账像没发生过(原子性)。
    回滚时对数据页的反向修改同样会写 Redo(回滚过程也可崩溃恢复,见下节)。

  2. 步骤 5 的 Redo 还没刷稳就断电
    磁盘上没有这次事务的提交记录。重启后按未完成事务处理,用 Undo 清掉半成品。
    结果:不会出现「只扣了一边」的持久状态。
    (「从哪知道有这笔未完成事务」:主要靠 Redo 分析 + 回滚段/事务状态 判定无可靠 COMMIT;怎么清靠 Undo。详见下节。)

  3. 步骤 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 LogRedo Log
解决什么问题能回滚;别人能读到合适的历史版本已提交的修改崩溃后还能重做出来
对应 ACID原子性 +(配合 MVCC)隔离性持久性
记的是什么逻辑上的「旧值 / 怎么反向操作」页级上的「改成了什么」(便于重放)
何时写入改行时先记旧版本,再改当前行改页时写缓冲;COMMIT 时保证刷盘
怎么读回滚 / 快照读时从新往旧顺着版本链走崩溃恢复时从旧往新按序重放
何时可回收没有事务再需要这个旧版本后,由 Purge 清理对应脏页已刷进数据文件后,日志空间可循环覆盖

再压缩成两句:

  • Undo:往回走——反悔、多版本可见性。
  • Redo:往前补——保证「我说提交了」在断电后仍然算数。

5. 事务与索引、锁的关系​

  • 更新某行时,通常要在对应索引上定位行,并加上行级锁(或间隙相关锁)。
  • 二级索引更新可能导致「删除旧索引项 + 插入新索引项」式的维护,锁与 undo 更复杂。
  • 隔离级别决定:别人未提交的改动你能否看见、范围查询会不会加间隙锁等。

因此推荐学习顺序:事务基础(本文)→ 隔离级别 → 锁机制。


6. 实践清单​

  1. 多语句业务写操作包进显式事务,失败路径明确 ROLLBACK。
  2. 事务内避免远程 RPC、大文件 I/O、人工等待。
  3. 监控长事务、未提交事务、历史列表长度(History list length)。
  4. 资金、库存等关键路径:短事务 + 合理隔离级别 + 必要的唯一约束/乐观锁版本号。
  5. 只读报表尽量用只读事务或从库,避免在大事务里扫全表。

下一篇:隔离级别。