11 KiB
tags, create time
| tags | create time | |||||||
|---|---|---|---|---|---|---|---|---|
|
2026-05-16 00:00 |
ACID 与原子性实现
概述
ACID 是事务的四大特性——Atomicity(原子性)、Consistency(一致性)、Isolation(隔离性)、Durability(持久性)。本节聚焦原子性和持久性的底层实现机制,回答「MySQL 如何保证你提交的修改不会丢失、回滚的操作真的撤销了」。
ACID 全景
flowchart LR
A["Atomicity<br/>原子性"] -->|"Undo Log 回滚"| T1["要么全部完成<br/>要么全部不做"]
C["Consistency<br/>一致性"] -->|"约束 + 事务组合"| T2["数据库从一个合法状态到另一个合法状态"]
I["Isolation<br/>隔离性"] -->|"MVCC + Lock"| T3["并发事务互不干扰"]
D["Durability<br/>持久性"] -->|"Redo Log 刷盘"| T4["一旦提交永不丢失"]
style A fill:#C44569,color:#fff
style D fill:#EE5A24,color:#fff
[!NOTE] Consistency 是目标,其他三者是手段 Atomicity、Isolation、Durability 是 InnoDB 提供的技术保障,而 Consistency(业务层面的一致性)需要应用层通过合理的事务设计来实现。InnoDB 保证你提交的 SQL 不会丢、不会乱,但「转账后余额对不对」是你的责任。
Consistency —— 一致性如何落地
Consistency 不是靠某个单独的日志机制实现的,而是多种约束机制叠加的结果:
| 层级 | 约束类型 | 示例 | 强制时机 |
|---|---|---|---|
| 表级 | PRIMARY KEY | id INT AUTO_INCREMENT PRIMARY KEY |
INSERT / UPDATE |
| UNIQUE | email VARCHAR(255) UNIQUE |
INSERT / UPDATE | |
| NOT NULL | balance DECIMAL(10,2) NOT NULL |
INSERT / UPDATE | |
| FOREIGN KEY | user_id INT REFERENCES users(id) |
INSERT / UPDATE / DELETE | |
| CHECK | balance >= 0 (MySQL 8.0.16+) |
INSERT / UPDATE | |
| 行级 | Row locks | SELECT ... FOR UPDATE |
查询时持锁 |
| 事务级 | SERIALIZABLE 隔离级别 | 整个事务串行化执行 | 事务执行期间 |
-- 约束是 Consistency 的第一道防线
CREATE TABLE accounts (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
balance DECIMAL(10,2) NOT NULL DEFAULT 0 CHECK (balance >= 0),
UNIQUE KEY uk_user (user_id),
CONSTRAINT fk_accounts_user FOREIGN KEY (user_id) REFERENCES users(id)
);
-- 即使有约束,业务一致性仍需事务保障
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1; -- A 转出
UPDATE accounts SET balance = balance + 100 WHERE user_id = 2; -- B 转入
COMMIT;
[!QUESTION] 如果第二条 UPDATE 失败了怎么办? 单条 UPDATE 失败只影响本条语句,但如果放在事务中,整个事务可以 ROLLBACK —— 这正是原子性为一致性保驾护航的场景。三者的关系:原子性是手段,一致性是结果。
Redo Log —— 保证持久性
Redo Log(重做日志)是 InnoDB 特有的物理日志,记录的是「在哪个数据页的什么位置改了什么字节」。
Redo Log 的工作流程
sequenceDiagram
participant TX as 事务 A
participant BP as Buffer Pool
participant RL as Redo Log Buffer
participant Disk as Redo Log File(磁盘)
TX->>BP: UPDATE accounts SET balance=100 WHERE id=1
Note over BP: 找到数据页 → 修改内存中的值<br/>→ 标记为脏页(dirty page)
BP->>RL: 写入 Redo Log entry<br/>(LSN+1)
alt "innodb_flush_log_at_trx_commit = 1"
RL->>Disk: sync() 强制刷盘 ✅
else innodb_flush_log_at_trx_commit = 2
RL->>OS: 写入 OS Page Cache
Note over Disk: 每秒由后台线程刷盘
end
TX->>TX: COMMIT 返回成功
LSN (Log Sequence Number)
每条 Redo Log 都有一个单调递增的 LSN,用于追踪和排序:
// InnoDB 内部数据结构伪代码
type redo_log_entry struct {
lsn uint64 // 全局唯一的日志序号
page_id uint32 // 被修改的数据页编号
offset uint16 // 页内的偏移量
data []byte // 实际修改的字节(如: old_balance=500 → new_balance=100)
}
LSN 是 InnoDB 中最核心的序列号之一,它贯穿了 Redo Log、Undo Log、数据页、Check Point 等所有涉及「顺序」的场景。比较两个 LSN 就能判断哪个操作先发生——崩溃恢复全靠它排定先后。
理解关键:Redo Log 记录的不是「把 balance 改成 100」这种 SQL 语义,而是「第 3 号数据页第 2048 字节,从
0x1F4改成了0x64」。这就是为什么叫物理日志 —— 恢复时直接按字节写回即可,不需要解析 SQL,也不需要重新做权限检查、约束校验。
Redo Log 的两个关键属性
| 属性 | 说明 | 影响 |
|---|---|---|
| 循环写入 | 大小固定,写满后从头覆盖 | 不会无限增长占满磁盘 |
| 物理日志 | 记录页级别修改而非 SQL | 恢复速度快,不需要重执行 SQL |
# 配置文件
innodb_log_file_size = 512M # 每个 log file 大小,两个共 1GB
innodb_log_files_in_group = 2 # log group 中的文件数
innodb_max_undo_log_size = 1G # undo log 最大体积
[!TIP] innodb_log_file_size 怎么设?
- 默认各 48MB(太小!频繁 checkpoint)
- 推荐 256M~2G(写入密集型可更大)
- 越大意味着:checkpoint 间隔越长、崩溃恢复越慢、缓冲池可容纳更多脏页
- 调大需要在停机窗口操作(需要先删旧 log file,MySQL 会报错提示)
两阶段提交(Two-Phase Commit, 2PC)
这是保证 Binlog 和 Redo Log 一致性的核心协议:
sequenceDiagram
participant App as 应用
participant S as Server Layer
participant IB as InnoDB Engine
participant BGL as Binlog
participant RDL as Redo Log
App->>S: BEGIN; UPDATE ... COMMIT;
S->>IB: Prepare 事务
IB->>RDL: 写入 Redo Log (prepare 状态)
Note over S: Phase 1: Redo Log Prepared
S->>BGL: 写入 Binlog
Note over S: Phase 2: 确认提交
S->>IB: Commit 通知
IB->>RDL: 标记 Redo Log 为 committed
IB-->>S: 返回 OK
S-->>App: 事务提交成功
[!QUESTION] 为什么要两阶段? 如果只写 Binlog 不写 Redo Log(或反过来),崩溃后会出现不一致:
- 只有 Binlog 没有 Redo Log:主从复制时,从库执行了 Binlog 但该事务从未在本库提交 → 数据错乱
- 只有 Redo Log 没有 Binlog:本库恢复了但主从不一致 → 从库缺少这条数据
中间态处理:如果 Phase 1 写完准备日志后崩溃,恢复时会检查 Binlog 是否存在:
- Binlog 存在 → Redo Log 标记为 committed → 恢复后提交
- Binlog 不存在 → Redo Log 标记为 aborted → 恢复后回滚
Undo Log —— 保证原子性
Undo Log(回滚日志)是 InnoDB 的逻辑日志,记录的是「数据修改前的样子」。
Undo Log 的核心用途
| 用途 | 说明 |
|---|---|
| 事务回滚 | ROLLBACK 时读取 undo log 反向操作 |
| MVCC 多版本读 | 构建 Read View 时的历史版本链 |
| 事务一致性快照 | 保证同一事务内多次读看到相同数据 |
原始数据:balance = 500
事务 A: UPDATE accounts SET balance = 100 WHERE id = 1
Undo Log 记录:
- undo_no: 1024
- prev_lsn: 12345678
- type: ROW_UPDATE
- table_id: 42
- row_id: 1
- before_image: {balance: 500} ← 修改前的值
rollback 时:用 before_image 恢复 balance = 500
Undo Log 本质上维护了一个修改前镜像链(before-image chain)。每次对某行做 UPDATE,InnoDB 会把修改前的整行数据(不只是变化的列)写入 undo log,然后在新行版本上应用修改。ROLLBACK 时,InnoDB 从 undo log 中沿着这个链逐个还原即可。
为什么是逻辑日志? 因为 undo log 记录的是「对哪行的哪个字段做了什么操作」,而不是物理字节偏移。这使得 undo log 可以被重放到其他存储引擎,也是 MVCC 能够构建历史版本的基础。
Rollback Segment
Undo Log 存储在专门的 Rollback Segment 中:
innodb_rollback_segments = 128 # 回滚段数量
innodb_max_purge_lag_delay = 0 # purge 延迟阈值(微秒)
[!NOTE] Undo Log 的生命周期
- 事务执行过程中持续追加 undo log
- 事务 commit 后,undo log 不会立即删除
- 直到没有其他事务需要它做 MVCC 可见性判断时,Purge Thread 才清理它
- undo log 空间会被循环回收
崩溃恢复 (Crash Recovery) —— ACID 的最终保障
InnoDB 拥有强大的崩溃恢复机制:每次重启,都会让数据库回到一致状态。
ARIES 恢复算法简述
flowchart TD
A[MySQL Start] --> B[Redo Analysis Stage]
B --> C[Scan Redo Log by LSN]
C --> D[TxId Page Dirty Flag Table]
D --> E{Committed?}
E -->|Yes: committed| F[Redo Forward Apply]
E -->|No: not committed| G[Undo Rollback]
F --> H[Data Pages Restored to Consistent State]
G --> H
H --> I[Recovery Complete<br/>DB Online]
style A fill:#4A90D9,color:#fff
style I fill:#50E3C2,color:#000
恢复流程图
sequenceDiagram
participant DB as Crash MySQL
participant RDL as Redo Log
participant UDL as Undo Log
participant DP as Data Pages(Disk)
Note over DB: Post-crash state<br/>dirty pages unflushed, tx status unclear
rect rgba(76, 175, 80, 0.15)
Note over DB,RDL: Phase 1 Analysis: rebuild tx state
DB->>DB: Build TxId Page Dirty Flag Table
end
rect rgba(255, 87, 34, 0.15)
Note over DB,RDL: Phase 2 Redo: forward-apply committed tx
loop by LSN order
DB->>DP: Reapply committed modifications
end
end
rect rgba(244, 67, 54, 0.15)
Note over DB,UDL: Phase 3 Undo: rollback uncommitted tx
loop reverse scan undo log
DB->>DP: Undo uncommitted changes
end
end
DB-->>DB: DB restored to consistent state
典型场景分析
| 崩溃时机 | Redo Log 状态 | Undo Log 状态 | 恢复行为 |
|---|---|---|---|
| UPDATE 后、COMMIT 前 | 有 redo entry(未标记 committed) | 有 undo record | 回滚未提交事务 |
| Prepare 后、Binlog 写入前 | prepare 状态 | 有 undo record | 检查 Binlog → 不存在则回滚 |
| Prepare 后、Commit 通知前 | prepare 状态 | 有 undo record | 检查 Binlog → 存在则提交 |
| Binlog 写完、Redo Commit 前 | Binlog 已写,redo uncommitted | 有 undo record | 通过 2PC 判断:提交 |
| ** COMMIT 返回后** | 已 committed,可能尚未刷盘 | 不再需要 | 若 redo 未刷盘:crash-safe 窗口内丢失(依赖 binlog replication) |
[!TIP] crash-safe 的边界
innodb_flush_log_at_trx_commit = 1时,COMMIT 后 Redo Log 已刷盘,即使进程崩溃也不会丢数据(前提是操作系统没挂)。但如果整台机器断电,且
innodb_flush_log_at_trx_commit = 2,最近一秒钟的数据可能丢失。这就是为什么金融系统必须设为 1。
关联笔记
- hhs/MySQL/24-隔离级别与可见性 — ACID 中 Isolation 的具体实现
- hhs/MySQL/25-MVCC 原理 — Undo Log 如何支撑多版本并发控制
- hhs/GORM/08-事务管理 — GORM 的事务 API 与 MySQL 引擎层的对应关系