Files
cs-note/hhs/MySQL/06-事务与并发控制/23-ACID 与原子性实现.md
T
2026-05-24 11:42:38 +08:00

297 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [MySQL, ACID, Redo Log, Undo Log, durability, binlog, MVCC]
create time: 2026-05-16 00:00
---
# ACID 与原子性实现
## 概述
ACID 是事务的四大特性——Atomicity(原子性)、Consistency(一致性)、Isolation(隔离性)、Durability(持久性)。本节聚焦原子性和持久性的底层实现机制,回答「MySQL 如何保证你提交的修改不会丢失、回滚的操作真的撤销了」。
## ACID 全景
```mermaid
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 隔离级别 | 整个事务串行化执行 | 事务执行期间 |
```sql
-- 约束是 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 的工作流程
```mermaid
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,用于追踪和排序:
```go
// 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 |
```ini
# 配置文件
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 一致性的核心协议:
```mermaid
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 中:
```ini
innodb_rollback_segments = 128 # 回滚段数量
innodb_max_purge_lag_delay = 0 # purge 延迟阈值(微秒)
```
> [!NOTE] Undo Log 的生命周期
> 1. 事务执行过程中持续追加 undo log
> 2. 事务 commit 后,undo log 不会立即删除
> 3. 直到**没有其他事务需要它做 MVCC 可见性判断**时,Purge Thread 才清理它
> 4. undo log 空间会被循环回收
## 崩溃恢复 (Crash Recovery) —— ACID 的最终保障
InnoDB 拥有强大的崩溃恢复机制:**每次重启,都会让数据库回到一致状态**。
### ARIES 恢复算法简述
```mermaid
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
```
### 恢复流程图
```mermaid
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/06-事务与并发控制/24-隔离级别与可见性]] — ACID 中 Isolation 的具体实现
- [[hhs/MySQL/06-事务与并发控制/25-MVCC 原理]] — Undo Log 如何支撑多版本并发控制
- [[hhs/GORM/08-事务管理]] — GORM 的事务 API 与 MySQL 引擎层的对应关系