24.1 ACID 与本地事务
一句话
ACID 里只有 A、I、D 是数据库能保证的,C(一致性)是业务语义;而 A/I/D 全靠三样东西:日志(原子 + 持久)、锁或 MVCC(隔离)、回滚段(撤销)。
四个特性靠什么实现
| 特性 | 机制 | 代价 |
|---|---|---|
| A 原子性 | undo/回滚段:失败时撤销已改的页 | 写 undo,空间与 IO |
| C 一致性 | 约束(唯一键、外键)、业务逻辑 | 不是数据库单方面能保证 |
| I 隔离性 | 锁(悲观)或 MVCC(乐观) | 并发度与复杂度 |
| D 持久性 | WAL:先写日志再刷页,提交时 fsync | 每次提交一次 fsync(~百 μs–ms) |
隔离级别与它允许的异常
| 级别 | 脏读 | 不可重复读 | 幻读 | 写偏斜 |
|---|---|---|---|---|
| Read Uncommitted | 可能 | 可能 | 可能 | 可能 |
| Read Committed | 不可能 | 可能 | 可能 | 可能 |
| Repeatable Read | 不可能 | 不可能 | 视实现(MySQL 间隙锁可防) | 可能 |
| Serializable | 不可能 | 不可能 | 不可能 | 不可能 |
⚠️ “RR 就没有幻读” 只对部分实现成立(MySQL InnoDB 用间隙锁;PostgreSQL 的 RR 是快照隔离,仍允许写偏斜)。
本地事务的边界
| 边界内 | 边界外 |
|---|---|
| 单库、单连接内的多条 SQL | 跨库、跨服务、跨消息队列 |
| 由数据库的锁/MVCC/日志保证 | 需要 2PC、Saga、消息表等(见 24.9 事务模型选型) |
代码:一次提交到底做了什么
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- 写 undo + 脏页
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
INSERT INTO transfers(...) VALUES (...);
COMMIT; -- ① 写 commit 记录到 WAL ② fsync ③ 返回成功 ④ 后台刷脏页
# 看提交是否真的落盘(PostgreSQL)
SHOW synchronous_commit; -- on = 每次提交 fsync;off = 可能丢最近事务
# 批量导入时降低 fsync 频率(用事务包住,而不是每条一次)
反模式
| 反模式 | 后果 |
|---|---|
| 事务里做 RPC/HTTP 调用 | 事务变长,锁持有久,超时导致回滚风暴 |
| 长事务 | 阻止 MVCC 清理,表膨胀(见 25.2 MVCC) |
| 依赖 “默认隔离级别” 不说清 | 不同库行为不同,跨库逻辑出错 |
| 把 C 当数据库保证 | 约束缺失时数据仍 “合法” 但业务错 |
| 用 SELECT ... FOR UPDATE 做长任务 | 锁等待扩散,连接池耗尽 |
| 事务里 sleep 重试 | 持锁时间随重试线性增长 |
三条铁律
🔑 事务只包 “必须一起成功” 的操作;RPC 与长计算放到事务外。
🔑 提交 = fsync;批量场景要合并提交,别每条一次。
🔑 隔离级别要显式声明;靠默认值等于靠运气。
自检
Q1 为什么 “提交很慢” 常常是 fsync 的锅?
▸ 答案:因为持久性要求每次提交都把 WAL 刷到磁盘,机械盘上一次 fsync 约 5–10ms,SSD 约 0.1–1ms;每秒 1000 次提交就吃掉大量 IOPS。解法是批量提交/组提交(group commit),把多个事务的日志一次刷盘。
Q2 原子性靠 undo,持久性靠 WAL,两者矛盾吗?
▸ 答案:不矛盾,它们是两个方向:WAL 记录 “要做什么”(redo),保证已提交的能重放;undo 记录 “怎么撤销”,保证未提交的能回滚。崩溃恢复时先 redo 再 undo,得到 “已提交都在、未提交都不在”。
Q3 Serializable 为什么性能差?
▸ 答案:因为它要消除所有异常(含写偏斜),通常靠范围锁/谓词锁或 SSI(可串行化快照隔离)的冲突检测:锁范围大、冲突检测会回滚事务。代价是高竞争下重试率上升。
Q4 写偏斜是什么,RC/RR 为什么挡不住?
▸ 答案:两个事务各读一个集合、各自写另一个集合,单看没有冲突(写不同行),组合起来违反约束(如 “至少一名医生值班”)。快照隔离下它们读到同一快照、写不同行,提交都成功——只有 Serializable 的范围/谓词冲突检测才能发现。
Q5 本地事务能跨消息队列吗?
▸ 答案:不能。发消息与本地提交是两个系统,无法原子。工程上用本地消息表(同一库内写 “待发消息”,再异步投递)或事务消息(见 24.6 本地消息表、24.7 事务消息 (半消息 / 回查))。
Q7 synchronous_commit=off 能提升吞吐,它牺牲了什么?
▸ 答案:牺牲最近若干事务的持久性:崩溃可能丢掉最后几百毫秒已 “提交成功” 的事务(但仍保证一致性,不会出现半条事务)。适合可重放的批量导入,不适合账务。这就是 “D 可以被放松,A 和 I 不能” 的具体体现。
关联
- 前置:24.1 ACID 与本地事务 前序概念见 44.10 事务与隔离级别的实现 (锁 / MVCC)
- 并列:25.1 乐观并发与悲观并发、25.2 MVCC
- 下游:24.2 2PC 与恢复协议、24.9 事务模型选型