写在前面
承接前三篇,本文进入第三个子系统:事务。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_BUFFER、FAST_START_MTTR_TARGET |
| SQL Server |
Transaction Log(.ldf) |
recovery interval、target_recovery_time |
| MySQL |
Redo Log(ib_logfile,8.0 改名 ib_redo) |
innodb_flush_log_at_trx_commit |
| PostgreSQL |
WAL(pg_wal/ 目录) |
wal_level、synchronous_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,每秒 fsync(OS 崩溃丢数据)
|
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 的实现差异。