数据库04:事务与隔离级别
从 ACID 特性、四种隔离级别、MVCC 实现到分布式事务,理解数据库事务的本质和在高并发场景下的取舍。
事务是数据库的核心特性,它保证了数据的一致性和可靠性。但在高并发场景下,事务的隔离性和性能之间存在天然的矛盾。
这篇文章围绕四个核心点:ACID 特性、隔离级别、MVCC 实现、分布式事务,理解数据库事务的本质和在高并发场景下的取舍。
ACID:事务的四大特性
事务是一组操作的集合,要么全部成功,要么全部失败。它由四个特性保证:
Atomicity(原子性)
事务中的操作要么全部执行成功,要么全部回滚。不能出现部分成功、部分失败的情况。
实现方式:
- Undo Log:记录事务执行前的数据状态,用于回滚
- Redo Log:记录事务执行后的变化,用于崩溃恢复
Consistency(一致性)
事务执行前后,数据库的完整性约束没有被破坏。
常见约束:
- 主键唯一性
- 外键约束
- 业务规则(如账户余额不能为负)
Isolation(隔离性)
多个事务并发执行时,彼此之间互不干扰。
隔离级别越低,并发度越高,但数据一致性越差。
Durability(持久性)
事务提交后,对数据库的修改永久保存,即使系统崩溃也不会丢失。
实现方式:
- WAL(Write-Ahead Logging):先写日志再写磁盘
- Double Write Buffer:防止写入过程中崩溃导致的数据损坏
隔离级别:并发控制的权衡
SQL 标准定义了四种隔离级别,从低到高分别是:
READ UNCOMMITTED(读未提交)
- 一个事务可以读取另一个事务未提交的数据
- 可能出现脏读、不可重复读、幻读
- 几乎不使用,仅适用于统计等对数据一致性要求极低的场景
READ COMMITTED(读已提交)
- 一个事务只能读取另一个事务已提交的数据
- 解决了脏读,但可能出现不可重复读、幻读
- Oracle、SQL Server 默认级别
- 适用于大部分业务场景,特别是需要最新数据的查询
REPEATABLE READ(可重复读)
- 在同一事务中,多次读取同一数据结果一致
- 解决了脏读、不可重复读,但可能出现幻读
- MySQL InnoDB 默认级别(通过 Gap Lock 解决了幻读)
- 适用于需要数据一致性的业务场景
SERIALIZABLE(串行化)
- 最高隔离级别,事务串行执行
- 解决了所有并发问题,但性能最差
- 仅适用于对数据一致性要求极高的场景(如金融交易)
隔离级别对比
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 并发度 | 适用场景 |
|---|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 最高 | 统计、日志 |
| READ COMMITTED | 不可能 | 可能 | 可能 | 高 | 大部分业务 |
| REPEATABLE READ | 不可能 | 不可能 | 不可能 | 中 | 需要一致性的业务 |
| SERIALIZABLE | 不可能 | 不可能 | 不可能 | 最低 | 金融交易 |
数据落盘:事务提交后数据如何持久化
理解数据落盘,才能理解崩溃恢复和性能调优的底层逻辑。MySQL 和 PostgreSQL 都遵循 WAL 原则,但落盘路径和细节差异很大。
MySQL InnoDB 的落盘路径
InnoDB 采用”先写日志、后刷数据页”的策略,核心流程:
- 修改数据页:事务修改 Buffer Pool 中的数据页(内存),标记为脏页
- 写 Redo Log Buffer:将修改记录写入 Redo Log Buffer(内存)
- 事务提交时刷 Redo Log:根据
innodb_flush_log_at_trx_commit决定刷盘策略 - 后台异步刷脏页:Checkpoint 线程将 Buffer Pool 中的脏页刷回磁盘
Redo Log 刷盘策略(innodb_flush_log_at_trx_commit):
| 值 | 行为 | 安全性 | 性能 |
|---|---|---|---|
| 1 | 每次提交都 fsync 到磁盘 | 最高,不丢数据 | 最慢 |
| 2 | 每次提交写到 OS 缓存,每秒 fsync | 崩溃可能丢 1 秒 | 较快 |
| 0 | 写到 Redo Log Buffer,每秒刷 | 崩溃可能丢 1 秒 | 最快 |
Double Write Buffer:InnoDB 的保险机制。因为 InnoDB 页大小 16KB,而 OS 通常按 4KB 写,如果写了一半宕机(partial write),数据页会损坏。Double Write 先把脏页写入共享表空间的 2MB 连续空间,再写到实际数据文件。如果崩溃,从 Double Write 恢复损坏的页。
Undo Log 落盘:Undo Log 本身也是 Redo Log 保护的对象。Undo Log 先写入 Undo Log Segment,再通过 Redo Log 保证持久性。回滚时从 Undo Log 恢复旧值。
PostgreSQL 的落盘路径
PG 同样遵循 WAL 原则,但实现方式不同:
- 修改数据页:在 Shared Buffers 中修改数据页(内存),标记为脏页
- 写 WAL Buffer:将修改记录写入 WAL Buffer(内存)
- 事务提交时刷 WAL:根据
synchronous_commit和wal_sync_method决定刷盘策略 - 后台刷脏页:BgWriter 和 Checkpoint 进程将脏页写回磁盘
WAL 刷盘策略:
| 参数 | 行为 | 安全性 |
|---|---|---|
synchronous_commit = on | 每次提交都 fsync WAL | 最高,不丢数据 |
synchronous_commit = off | 提交后异步刷 WAL | 崩溃可能丢少量事务 |
synchronous_commit = remote_apply | 等待备库回放完成 | 最高,但延迟大 |
没有 Double Write:PG 的数据页有 CRC 校验和 LSN(Log Sequence Number)。如果写入一半宕机,重启后通过 LSN 比对发现页面损坏,从 WAL 重放修复。PG 依靠”全页写”(full_page_writes = on)解决 partial write 问题:Checkpoint 后第一次修改某页时,把整个页写入 WAL。
Hint Bits:PG 的一个特殊设计。判断行版本是否可见需要检查事务状态(提交还是回滚),但每次都查 CLOG 太慢。PG 在数据页头部写入 Hint Bits 标记行的事务状态,加速可见性判断。Hint Bits 不写 WAL,崩溃后可以重建。
落盘差异对比
| 维度 | MySQL InnoDB | PostgreSQL |
|---|---|---|
| WAL 机制 | Redo Log(固定大小,循环写) | WAL(按段切换,归档) |
| 防 partial write | Double Write Buffer(2MB 连续空间) | Full Page Writes(首次修改写整页到 WAL) |
| 脏页刷盘 | Checkpoint + 后台线程 | BgWriter + Checkpoint |
| Undo 存储 | Undo Log Segment(独立表空间) | 不分离,旧版本直接存在数据页中 |
| 事务状态存储 | 不需要额外存储,Read View 动态判断 | CLOG(Commit Log)+ Hint Bits |
| 默认安全级别 | flush_log_at_trx_commit = 1(fsync) | synchronous_commit = on(fsync) |
为什么 PG 把旧版本放在数据页里:PG 没有 Undo Log,UPDATE 的旧版本和新版本都存在于同一个表中。好处是回滚极快(只需要标记事务状态为 aborted),坏处是表会膨胀,需要 VACUUM 回收空间。MySQL 的 Undo Log 是独立表空间,旧版本不影响数据页大小,但回滚需要遍历 Undo Log 链。
MVCC:读写不冲突的秘密
MVCC(Multi-Version Concurrency Control)是现代数据库实现高并发的核心技术,它让读操作不需要加锁。MySQL 和 PostgreSQL 都实现了 MVCC,但路径完全不同。
MVCC 的核心思想
每个事务看到的数据版本是事务开始时的快照,而不是最新数据。这样:
- 读操作可以不加锁,并发度高
- 写操作只锁必要的行,不影响其他事务的读
MySQL InnoDB 的 MVCC 实现
InnoDB 的 MVCC 依赖三个核心机制:隐藏字段 + Undo Log 版本链 + Read View。
隐藏字段:每行数据有三个隐藏字段
DB_TRX_ID:最后修改该行的事务 ID(6 字节)DB_ROLL_PTR:指向 Undo Log 的回滚指针(7 字节)DB_ROW_ID:隐藏自增主键(6 字节,无主键时使用)
Undo Log 版本链:每次 UPDATE 操作,旧值写入 Undo Log,通过 DB_ROLL_PTR 串成链表。例如:
当前行: {DB_TRX_ID=5, DB_ROLL_PTR→Undo3, data="新值"}
Undo3: {DB_TRX_ID=3, DB_ROLL_PTR→Undo1, data="旧值2"}
Undo1: {DB_TRX_ID=1, DB_ROLL_PTR=NULL, data="旧值1"}
Read View:事务执行快照读时创建的可见性判断依据,包含四个核心字段:
| 字段 | 含义 |
|---|---|
m_ids | 创建 Read View 时当前活跃(未提交)的事务 ID 列表 |
min_trx_id | m_ids 中最小的事务 ID |
max_trx_id | 创建 Read View 时系统应分配给下一个事务的 ID |
creator_trx_id | 创建该 Read View 的事务 ID |
版本可见性判断规则:
对于版本链中某个版本的 DB_TRX_ID:
DB_TRX_ID == creator_trx_id→ 可见(自己修改的)DB_TRX_ID < min_trx_id→ 可见(事务在 Read View 创建前已提交)DB_TRX_ID >= max_trx_id→ 不可见(事务在 Read View 创建后才开启)DB_TRX_ID在m_ids中 → 不可见(事务未提交)DB_TRX_ID不在m_ids中 → 可见(事务已提交)
Read View 的创建时机:
| 隔离级别 | 创建时机 | 效果 |
|---|---|---|
| RC | 每次 SELECT 都创建新 Read View | 能看到其他事务已提交的最新数据 |
| RR | 事务内第一次 SELECT 时创建,后续复用 | 事务内多次读取结果一致 |
这就是为什么 RC 级别会出现不可重复读,而 RR 级别不会——本质是 Read View 的生命周期不同。
当前读不走 MVCC:SELECT ... FOR UPDATE、UPDATE、DELETE 等操作是当前读,直接读最新版本并加锁,不经过 Read View。
PostgreSQL 的 MVCC 实现
PG 的 MVCC 实现思路完全不同:没有 Undo Log,没有 Read View,而是把版本信息直接写在数据行头部。
元组头部字段:每行数据(PG 称为 Tuple)头部包含:
xmin:插入该行的事务 IDxmax:删除或更新该行的事务 ID(初始为 0,表示未删除)infomask:状态标志位(包括 Hint Bits)
UPDATE = DELETE + INSERT:PG 的 UPDATE 不原地修改行,而是把旧行的 xmax 设为当前事务 ID(标记删除),再插入新行。旧行和新行同时存在于同一个表中。
旧行: {xmin=1, xmax=5, data="旧值"} ← 事务 5 标记删除
新行: {xmin=5, xmax=0, data="新值"} ← 事务 5 插入
快照可见性判断:PG 通过 SnapshotData 结构判断行可见性,核心逻辑:
| 条件 | 结果 |
|---|---|
xmin 对应事务已提交,且 xmin < 快照的 xmin horizon | 行可见(插入已提交) |
xmax 为 0 或 xmax 对应事务未提交 | 行未被删除,可见 |
xmax 对应事务已提交,且 xmax < 快照的 xmin horizon | 行已被删除,不可见 |
CLOG 和 Hint Bits:判断事务是否提交需要查 CLOG(Commit Log,磁盘上的位图),但每次查太慢。PG 在行的 infomask 中缓存提交状态(Hint Bits),加速判断。Hint Bits 不走 WAL,因为崩溃后可以从 CLOG 重建。
VACUUM:回收旧版本:PG 没有 Undo Log,旧版本直接留在表中。VACUUM 进程扫描表,回收已经没有任何快照需要访问的旧版本,释放空间。
VACUUM 分两种:
- 普通 VACUUM:标记空间为可复用,不收缩文件
- VACUUM FULL:重建整张表,释放磁盘空间,但锁表
自动 VACUUM:PG 默认开启 autovacuum,当表中死元组达到阈值自动触发。
MVCC 实现差异对比
| 维度 | MySQL InnoDB | PostgreSQL |
|---|---|---|
| 版本存储 | Undo Log(独立表空间) | 数据页内(旧版本和新版本同表) |
| 版本链结构 | 隐藏字段 + Undo Log 回滚指针链 | xmin/xmax 字段,更新 = 删旧插新 |
| 可见性判断 | Read View(事务级快照,包含活跃事务列表) | SnapshotData + CLOG + Hint Bits |
| 回滚方式 | 遍历 Undo Log 链,逆序恢复 | 只需标记事务为 aborted,无需恢复数据 |
| 回滚速度 | 慢(需要逐条恢复) | 快(只改事务状态) |
| 表膨胀 | 不会(旧版本在 Undo 中) | 会(旧版本留在表中),需 VACUUM |
| 快照创建开销 | 需要拷贝活跃事务 ID 列表 | 较轻量,只记录事务 ID 边界 |
| 读旧版本路径 | 从数据页沿 Undo 链遍历 | 直接在数据页中找到可见的行 |
| 幻读处理 | Gap Lock + Next-Key Lock | 无 Gap Lock,SI 天然避免 |
| 写写冲突 | 锁等待(后到的事务阻塞) | First-Updater-Wins(后提交的报错回滚) |
核心区别总结:
-
版本存储位置不同:MySQL 把旧版本放在 Undo Log,PG 把旧版本放在数据页中。这导致 MySQL 回滚慢但表不膨胀,PG 回滚快但需要 VACUUM。
-
可见性判断方式不同:MySQL 通过 Read View 中的活跃事务列表做排除法,PG 通过 xmin/xmax 做区间判断 + CLOG 查提交状态。MySQL 的 Read View 需要拷贝活跃事务列表,长事务开销大;PG 的 SnapshotData 更轻量。
-
写冲突策略不同:MySQL 两个事务改同一行,后到的阻塞等待;PG 两个事务改同一行,后提交的会收到错误需要重试。这决定了 MySQL 更适合写冲突频繁的场景,PG 更适合写冲突少的场景。
-
读旧版本路径不同:MySQL 需要从数据页出发,沿 Undo 链逐个版本判断可见性;PG 直接在数据页中找到可见的那一行。当 Undo 链很长时,MySQL 的查询性能会下降;PG 的问题在于数据页中堆积大量死元组,扫描效率低。
锁机制:并发控制的基石
锁是数据库保证数据一致性的另一种方式,和 MVCC 互补。
锁的分类
| 分类 | 类型 | 说明 |
|---|---|---|
| 按粒度 | 表锁 | 锁定整个表,并发度低 |
| 行锁 | 锁定单行,并发度高 | |
| 页锁 | 锁定一页数据 | |
| 按模式 | 共享锁(S) | 读锁,多个事务可同时持有 |
| 排他锁(X) | 写锁,同一时间只能有一个事务持有 | |
| 按实现 | 乐观锁 | 通过版本号或时间戳实现,不阻塞 |
| 悲观锁 | 通过数据库锁机制实现,会阻塞 |
InnoDB 的锁类型
- Record Lock:行锁,锁定索引记录
- Gap Lock:间隙锁,锁定索引之间的间隙
- Next-Key Lock:行锁 + 间隙锁,锁定索引记录及其前面的间隙
死锁:如何避免
死锁是两个或多个事务互相等待对方释放锁的情况。
常见场景:
- 两个事务以不同顺序更新同一组行
- 长事务持有锁时间过长
解决方案:
- 按固定顺序更新数据
- 缩短事务长度
- 设置锁等待超时(
innodb_lock_wait_timeout) - 使用乐观锁代替悲观锁
分布式事务:跨越多个数据库
当业务涉及多个数据库或服务时,需要分布式事务来保证数据一致性。
分布式事务协议
| 协议 | 特点 | 适用场景 |
|---|---|---|
| 2PC | 两阶段提交,强一致性,阻塞 | 资源充足,对一致性要求高 |
| 3PC | 三阶段提交,减少阻塞,但复杂度高 | 理论研究,实际少用 |
| TCC | 补偿事务,业务侵入性强 | 需要最终一致性的业务 |
| Saga | 长事务拆分,最终一致性 | 跨服务的长流程业务 |
| 本地消息表 | 通过消息队列保证最终一致 | 异步场景,可接受延迟 |
2PC:两阶段提交
第一阶段(Prepare):
- 协调者向所有参与者发送 Prepare 请求
- 参与者执行事务,但不提交,记录日志
- 参与者返回 Yes(可以提交)或 No(不能提交)
第二阶段(Commit):
- 如果所有参与者都返回 Yes,协调者发送 Commit 请求
- 如果有任何参与者返回 No,协调者发送 Rollback 请求
问题:
- 同步阻塞:事务期间所有资源被锁定
- 单点故障:协调者宕机导致事务无法完成
- 数据不一致:Commit 阶段部分参与者失败
TCC:Try-Confirm-Cancel
Try:尝试执行,预留资源 Confirm:确认执行,释放资源 Cancel:取消执行,回滚资源
优势:
- 非阻塞,性能好
- 适用于微服务架构
缺点:
- 业务侵入性强,需要手写补偿逻辑
- 代码复杂度高
Saga:长事务拆分
将长事务拆分为多个短事务,每个短事务独立提交。如果某个步骤失败,执行补偿事务。
优势:
- 最终一致性
- 适用于跨服务的长流程业务
缺点:
- 数据可能处于不一致状态
- 需要处理并发冲突
项目判断清单
- 需要强一致性(金融交易)→ SERIALIZABLE 或 2PC
- 普通业务场景 → REPEATABLE READ(MySQL)或 READ COMMITTED(PG)
- 读多写少,需要高并发 → PG 的快照隔离
- 写冲突频繁 → 优化事务粒度,使用乐观锁
- 跨服务事务 → TCC 或 Saga
- 事务超时频繁 → 缩短事务长度,优化锁竞争