数据库系列(四):事务与 ACID — 从单机原子性到分布式一致性

写在前面

承接前三篇,本文进入第三个子系统:事务。ACID 是关系数据库区别于 NoSQL 最核心的特征,但 A、C、I、D 各自怎么实现?为什么 MySQL 写一条数据要刷三次盘?分布式事务为什么又慢又复杂?

本文横向对比 Oracle、SQL Server、MySQL、PostgreSQL 四家在事务实现上的差异。


一、ACID 严格定义

ACID 是 1983 年 Andreas Reuter 和 Theo Härder 提出的概念:

性质 含义 直觉解释
Atomicity(原子性) 事务要么全部成功,要么全部回滚 “要么都做,要么都不做”
Consistency(一致性) 事务前后数据库满足约束 “钱不会凭空消失或出现”
Isolation(隔离性) 并发事务互不干扰 “你转账时我看到的是旧余额”
Durability(持久性) 提交后数据永久保存 “断电也不丢”

1.1 C 不是引擎保证的

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
很多人误以为"一致性是数据库做的",其实不是:
  - 引擎保证 A、I、D
  - C 是"应用 + 约束 + 触发器"共同保证
  - 引擎提供约束(外键、CHECK、唯一)作为工具
  - 但 C 的根本责任在业务代码

举例:
  约束:账户余额不能为负
  事务:UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  - 如果余额只有 50:数据库靠 CHECK 约束拦截(C 由数据库帮助保证)
  - 如果转账总额对不上:数据库无能为力(C 由应用代码保证)

1.2 谁负责实现 A、I、D

性质 实现机制
A Undo Log(回滚日志)
I 锁 + MVCC(详见第 5、6 篇)
D Redo Log + WAL + fsync
C 应用 + 数据库约束(外键、CHECK)

本文聚焦 A 和 D,I 留给第 5、6 篇。


二、原子性靠 Undo

2.1 为什么需要 Undo

1
2
3
4
5
6
7
8
事务:UPDATE accounts SET balance = balance - 100 WHERE id = 1;
       INSERT INTO log VALUES (...);
       UPDATE summary SET count = count + 1;

如果第二条 INSERT 失败 → 整个事务必须回滚
  → 第 1 条 UPDATE 已经改了内存页 balance
  → 必须知道"原值"才能撤销
  → 这就是 Undo Log 的作用

2.2 Undo 的工作流程

1
2
3
4
5
6
7
1. 事务开始
2. 修改行之前,先把"旧值"写入 Undo Log
   Undo Log: (tx_001, account.id=1, balance: 1000 → ...)
3. 修改 Buffer Pool 中的数据页(balance 变 900)
4. 后续操作失败 → 用 Undo Log 把 balance 改回 1000
5. 事务提交 → Undo Log 标记为"可清理"
   (但 MVCC 场景下不能立即删,详见第 5 篇)

2.3 四家的 Undo 实现差异

数据库 Undo 存放 命名 特点
Oracle Undo 表空间(独立段) Undo Segment 进程可独立管理,能扩能缩
SQL Server TempDB(短事务)/ 数据库日志 —— 长事务 Undo 在 Transaction Log
MySQL InnoDB Undo TableSpace Undo Log 5.6+ 独立表空间,5.7+ 可独立配置
PostgreSQL Heap 表内(旧 tuple) Old Tuple 不存独立 Undo,靠多版本
1
2
3
4
5
6
关键差异:PG 不用独立 Undo
  - PG 的每行有 xmin/xmax(创建/删除事务 ID)
  - UPDATE = 标记旧行 xmax + 插入新行(新 xmin)
  - 旧 tuple 留在 heap 里,直到 VACUUM 清理
  - 优点:回滚极快(标记 xmax = 0 即可)
  - 缺点:表会"膨胀",需要 VACUUM 维护

2.4 Savepoint 与嵌套事务

1
2
3
4
5
6
7
8
9
-- 标准 SQL Savepoint
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
SAVEPOINT before_log;
INSERT INTO log VALUES (...);  -- 假设失败
ROLLBACK TO before_log;        -- 只回滚到 Savepoint
-- 第 1 条 UPDATE 仍然有效
INSERT INTO log VALUES ('fallback', ...);  -- 重试
COMMIT;

四家都支持 SAVEPOINT,但真正的嵌套事务只有 Oracle 部分支持(通过 Autonomous Transaction)。其他三家都靠 Savepoint 模拟。

2.5 隐式提交的坑

1
2
3
4
5
-- DDL 通常会隐式 COMMIT
BEGIN;
INSERT INTO log VALUES ('test');
CREATE TABLE temp (id INT);  -- 隐式 COMMIT!前一个 INSERT 也被提交
ROLLBACK;  -- 无法回滚 INSERT
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
隐式 COMMIT 的语句:
  - DDL(CREATE / ALTER / DROP / TRUNCATE)
  - 部分 DCL(GRANT / REVOKE)
  - 部分 ADMIN(ANALYZE / VACUUM in PG 不触发)

各家差异:
  - MySQL:DDL 必隐式 COMMIT(即使是 InnoDB)
  - PostgreSQL:DDL 也可以在事务内(CREATE TABLE 不触发 COMMIT)
  - Oracle:DDL 必隐式 COMMIT
  - SQL Server:DDL 可在事务内

→ PG 这点是优势,可以"事务化 DDL",回滚 CREATE TABLE 没问题

三、持久性靠 Redo + WAL

3.1 为什么需要 Redo

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
问题:内存改了,磁盘还没刷
  - Buffer Pool 改了 balance = 900(内存)
  - 数据页还没刷到磁盘
  - 此时断电 → 改动丢失

朴素方案:每次 COMMIT 都把数据页刷盘
  - 一个数据页 16KB
  - 改 1 字节也要刷 16KB
  - 100 个事务改不同行 → 100 次随机 I/O
  - 极慢

正确方案:每次 COMMIT 只刷"Redo Log"
  - Redo Log 只记录"改了什么"(物理日志)
  - 顺序写(追加到日志文件末尾)
  - 一次 fsync 即可
  - 后台异步把数据页刷盘

3.2 WAL(Write-Ahead Logging)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
WAL 原则:
  - 数据页刷盘之前,对应的 Redo Log 必须先刷盘
  - 否则崩溃时数据页是"半新半旧"状态,无法恢复

流程:
  1. 改 Buffer Pool(dirty page)
  2. 写 Redo Log Buffer
  3. COMMIT → fsync Redo Log Buffer 到磁盘
  4. 后台:dirty page 缓慢刷盘(每次 checkpoint)
  5. 崩溃恢复:用 Redo Log 重做未刷盘的页
1
2
3
4
5
6
顺序写 vs 随机写:
  - 顺序写:100~300 MB/s(SSD)
  - 随机写:5~50 MB/s(SSD)
  - 差距 10~30 倍

→ 这就是为什么 COMMIT 性能远高于"直接刷数据页"

3.3 四家的 Redo 命名

数据库 Redo Log 关键参数
Oracle Redo Log(online + archive) LOG_BUFFERFAST_START_MTTR_TARGET
SQL Server Transaction Log(.ldf) recovery intervaltarget_recovery_time
MySQL Redo Log(ib_logfile,8.0 改名 ib_redo) innodb_flush_log_at_trx_commit
PostgreSQL WAL(pg_wal/ 目录) wal_levelsynchronous_commit

3.4 fsync 的代价

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
"双一"标准(金融场景):
  - innodb_flush_log_at_trx_commit = 1:每次 COMMIT 都 fsync Redo Log
  - sync_binlog = 1:每次事务都 fsync Binlog

fsync 性能:
  - HDD:每次 ~10ms(每秒最多 100 个事务)
  - SSD:每次 ~1ms(每秒最多 1000 个事务)
  - NVMe:每次 ~0.1ms(每秒最多 10000 个事务)

优化:
  - Group Commit:多个事务的 COMMIT 合并成一次 fsync
  - 异步 fsync(牺牲持久性换性能):innodb_flush_log_at_trx_commit = 2

四、Group Commit

4.1 朴素 COMMIT 的性能问题

1
2
3
4
5
6
7
8
9
事务 A → COMMIT → fsync → 等 1ms
事务 B → COMMIT → fsync → 等 1ms
事务 C → COMMIT → fsync → 等 1ms

3 个事务共 3ms

事务 A、B、C 几乎同时到达 → 仍然各自 fsync → 3ms

→ fsync 是顺序的,每次都是磁盘写

4.2 Group Commit 的思路

1
2
3
4
5
6
7
事务 A、B、C 几乎同时到达:
  1. A 进入队列,成为 Leader
  2. B、C 看到队列有人,加入(成为 Follower)
  3. Leader 把 ABC 的 Redo Log 一次性 fsync
  4. ABC 全部 COMMIT 成功

→ 3 个事务共 1ms(吞吐量 3 倍)

4.3 四家的 Group Commit

数据库 实现名称 关键参数
Oracle Log Writer Group Commit 自动
SQL Server Log Writer Grouping 自动
MySQL 5.7+ Binary Log Group Commit binlog_group_commit_sync_delay
PostgreSQL Group Commit(自动) synchronous_commit = on 配合
1
2
3
-- MySQL:开启 Group Commit
SET GLOBAL binlog_group_commit_sync_delay = 1000;  -- 微秒
SET GLOBAL binlog_group_commit_sync_no_delay_count = 10;  -- 满 10 个立即提交

五、两阶段提交(2PC)

为什么 MySQL InnoDB 有 Redo Log 还有 Binlog?它们怎么协调?

5.1 Binlog vs Redo Log

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Redo Log:
  - InnoDB 引擎层
  - 物理日志("页号 X 偏移 Y 改成 Z")
  - 用于崩溃恢复
  - 循环写(覆盖式)

Binlog:
  - Server 层
  - 逻辑日志("行 R 的字段 C 改成 V")
  - 用于主从复制、数据回放
  - 追加写(不覆盖)
1
2
3
4
5
为什么不只用一种?
  - 只用 Redo Log:从库要执行物理日志(页级别),跨版本不兼容
  - 只用 Binlog:崩溃恢复时无法精确重放(Binlog 是逻辑日志,无"页是否刷盘"信息)

→ 两套日志各自有用途,必须协调一致

5.2 内部 2PC 流程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
1. InnoDB 改 Buffer Pool,写 Redo Log(内存)
2. 【Prepare 阶段】
   - Redo Log 写入 PREPARE 标记
   - fsync Redo Log
3. 【Server 层】
   - 写 Binlog
   - fsync Binlog
4. 【Commit 阶段】
   - Redo Log 写入 COMMIT 标记
   - fsync Redo Log
5. 返回客户端成功

5.3 崩溃恢复时怎么处理

1
2
3
4
5
6
7
重启后扫描 Redo Log:
  - 找到所有"PREPARE 但没 COMMIT"的事务
  - 检查 Binlog 是否完整:
    - Binlog 已写完整 → 重做该事务(保证主从一致)
    - Binlog 不完整 → 回滚该事务

→ 这就是"MySQL 内部 XA"——保证 Redo Log 和 Binlog 一致

5.4 半同步复制用到了 2PC

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
半同步复制(Semi-Sync):
  - 主库 COMMIT 时,至少一个从库 ACK 收到 Binlog
  - 否则主库降级为异步复制

阶段:
  1. 主库 PREPARE
  2. 主库写 Binlog
  3. 主库推送 Binlog 到从库
  4. 从库 ACK
  5. 主库 COMMIT

六、分布式事务

跨多个数据库实例的事务,需要外部协调。

6.1 XA 标准

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
XA = X/Open DTP 模型
  - TM(Transaction Manager):协调者
  - RM(Resource Manager):参与者(数据库)
  - 通信协议:两阶段提交(2PC)

阶段 1:Prepare
  TM → 各 RM:PREPARE
  各 RM → TM:OK / FAIL

阶段 2:Commit / Rollback
  全部 OK → TM → 各 RM:COMMIT
  任一 FAIL → TM → 各 RM:ROLLBACK

6.2 四家的分布式事务实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
Oracle:XA 接口(OCI)
  - 长期支持 X/Open XA
  - 跨多个 Oracle DB 或异构数据源

SQL Server:MSDTC(Microsoft Distributed Transaction Coordinator)
  - Windows 系统服务
  - .NET 通过 TransactionScope 使用
  - 跨多个 SQL Server 或异构数据源

MySQL:XA 协议
  - XA START 'xid'; ... XA END 'xid'; XA PREPARE 'xid'; XA COMMIT 'xid';
  - 性能较差,生产较少使用
  - 通常用业务层 TCC / SAGA 替代

PostgreSQL:Prepared Transaction
  - PREPARE TRANSACTION 'xid'; COMMIT PREPARED 'xid';
  - 实现标准 2PC
  - max_prepared_transactions 控制上限
1
2
3
4
5
6
7
8
9
-- PG 2PC 示例
BEGIN;
UPDATE account_a SET balance = balance - 100 WHERE id = 1;
PREPARE TRANSACTION 'transfer_001';
-- 此处可以连接另一个数据库做对应操作
-- 完成后:
COMMIT PREPARED 'transfer_001';
-- 或者失败:
ROLLBACK PREPARED 'transfer_001';

6.3 2PC 的痛点

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
1. 同步阻塞
   - PREPARE 后所有 RM 锁定资源,直到 COMMIT
   - 一个慢节点拖垮整个事务

2. 协调者单点
   - TM 挂了 → 参与者卡在 PREPARE 状态
   - 数据锁死,需要人工干预

3. 数据不一致(极端情况)
   - 阶段 2 时部分 RM 收到 COMMIT,部分没收到
   - 需要"重试 + 补偿"机制

4. 性能差
   - 多次网络往返
   - 锁定时间长

6.4 替代方案:TCC 与 SAGA

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
TCC(Try-Confirm-Cancel):
  - 业务层补偿事务
  - Try:预留资源(如冻结余额)
  - Confirm:确认(扣减冻结)
  - Cancel:取消(解冻)
  - 性能好,但侵入业务

SAGA:
  - 长事务拆成多个本地事务
  - 每个本地事务有对应的"补偿事务"
  - 失败时按逆序补偿
  - 最终一致性

本地消息表:
  - 业务表 + 消息表同事务写入
  - 后台扫描消息表,推送到 MQ
  - 下游消费后回调
  - 解耦性强,最常用

七、实战:不同持久性级别的权衡

7.1 MySQL 的 innodb_flush_log_at_trx_commit

1
2
3
4
-- 值含义:
0 = 每秒 fsync Redo Log(崩溃可能丢 1 秒数据)
1 = 每次 COMMIT fsync Redo Log(最安全,最慢)   默认
2 = 每次 COMMIT  OS Cache,每秒 fsyncOS 崩溃丢数据)
1
2
3
4
5
6
7
实测性能(同硬件,1000 个事务):
  - 0:1 秒
  - 1:30 秒
  - 2:3 秒

→ 0 和 2 性能好但都不"金融级安全"
→ 1 是必须的双一标准之一

7.2 PostgreSQL 的 synchronous_commit

1
2
3
4
5
6
7
8
9
-- 值含义:
off  = 不等待 fsync(极快,可能丢事务)
local = 等待本地 fsync(默认)
on   = 等待本地 + 同步副本 fsync
remote_write = 等待副本写入 OS Cache
remote_flush = 等待副本 fsync

-- 可在会话级调整
SET synchronous_commit = off;  -- 临时降低持久性换性能

7.3 事务隔离级别速览

留给第 5 篇深入,这里先列出名称:

隔离级别 脏读 不可重复读 幻读
Read Uncommitted
Read Committed
Repeatable Read 部分解决
Serializable

八、小结

本文学习了事务与 ACID:

  • ACID 各性质的实现责任:引擎做 A/I/D,应用做 C
  • 原子性靠 Undo Log(PG 用多版本 tuple)
  • 持久性靠 Redo Log + WAL + fsync
  • 顺序写 fsync 是性能瓶颈,Group Commit 是核心优化
  • MySQL 的内部 XA:Redo Log 与 Binlog 的两阶段提交
  • 分布式事务的 XA 标准、痛点与替代方案(TCC / SAGA / 本地消息表)
  • 持久性 vs 性能的权衡参数
1
2
3
4
记住三句话:
  1. A 靠 Undo,D 靠 Redo,C 靠约束,I 靠锁 + MVCC
  2. COMMIT 慢的本质是 fsync,Group Commit 是关键
  3. 分布式事务能用本地消息表就别用 XA

下一篇进入隔离性的核心机制——MVCC,深入 Oracle SCN、InnoDB ReadView、PG xmin/xmax 的实现差异。