数据库04:事务与隔离级别

从 ACID 特性、四种隔离级别、MVCC 实现到分布式事务,理解数据库事务的本质和在高并发场景下的取舍。

字数 3939 阅读时长 ≈ 12 分钟 2026-7-14 2026-7-14
数据库04:事务与隔离级别

事务是数据库的核心特性,它保证了数据的一致性和可靠性。但在高并发场景下,事务的隔离性和性能之间存在天然的矛盾。

这篇文章围绕四个核心点: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 采用”先写日志、后刷数据页”的策略,核心流程:

  1. 修改数据页:事务修改 Buffer Pool 中的数据页(内存),标记为脏页
  2. 写 Redo Log Buffer:将修改记录写入 Redo Log Buffer(内存)
  3. 事务提交时刷 Redo Log:根据 innodb_flush_log_at_trx_commit 决定刷盘策略
  4. 后台异步刷脏页: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 原则,但实现方式不同:

  1. 修改数据页:在 Shared Buffers 中修改数据页(内存),标记为脏页
  2. 写 WAL Buffer:将修改记录写入 WAL Buffer(内存)
  3. 事务提交时刷 WAL:根据 synchronous_commitwal_sync_method 决定刷盘策略
  4. 后台刷脏页: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 InnoDBPostgreSQL
WAL 机制Redo Log(固定大小,循环写)WAL(按段切换,归档)
防 partial writeDouble 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_idm_ids 中最小的事务 ID
max_trx_id创建 Read View 时系统应分配给下一个事务的 ID
creator_trx_id创建该 Read View 的事务 ID

版本可见性判断规则

对于版本链中某个版本的 DB_TRX_ID

  1. DB_TRX_ID == creator_trx_id → 可见(自己修改的)
  2. DB_TRX_ID < min_trx_id → 可见(事务在 Read View 创建前已提交)
  3. DB_TRX_ID >= max_trx_id → 不可见(事务在 Read View 创建后才开启)
  4. DB_TRX_IDm_ids 中 → 不可见(事务未提交)
  5. DB_TRX_ID 不在 m_ids 中 → 可见(事务已提交)

Read View 的创建时机

隔离级别创建时机效果
RC每次 SELECT 都创建新 Read View能看到其他事务已提交的最新数据
RR事务内第一次 SELECT 时创建,后续复用事务内多次读取结果一致

这就是为什么 RC 级别会出现不可重复读,而 RR 级别不会——本质是 Read View 的生命周期不同。

当前读不走 MVCCSELECT ... FOR UPDATEUPDATEDELETE 等操作是当前读,直接读最新版本并加锁,不经过 Read View。

PostgreSQL 的 MVCC 实现

PG 的 MVCC 实现思路完全不同:没有 Undo Log,没有 Read View,而是把版本信息直接写在数据行头部。

元组头部字段:每行数据(PG 称为 Tuple)头部包含:

  • xmin:插入该行的事务 ID
  • xmax:删除或更新该行的事务 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 InnoDBPostgreSQL
版本存储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(后提交的报错回滚)

核心区别总结

  1. 版本存储位置不同:MySQL 把旧版本放在 Undo Log,PG 把旧版本放在数据页中。这导致 MySQL 回滚慢但表不膨胀,PG 回滚快但需要 VACUUM。

  2. 可见性判断方式不同:MySQL 通过 Read View 中的活跃事务列表做排除法,PG 通过 xmin/xmax 做区间判断 + CLOG 查提交状态。MySQL 的 Read View 需要拷贝活跃事务列表,长事务开销大;PG 的 SnapshotData 更轻量。

  3. 写冲突策略不同:MySQL 两个事务改同一行,后到的阻塞等待;PG 两个事务改同一行,后提交的会收到错误需要重试。这决定了 MySQL 更适合写冲突频繁的场景,PG 更适合写冲突少的场景。

  4. 读旧版本路径不同:MySQL 需要从数据页出发,沿 Undo 链逐个版本判断可见性;PG 直接在数据页中找到可见的那一行。当 Undo 链很长时,MySQL 的查询性能会下降;PG 的问题在于数据页中堆积大量死元组,扫描效率低。

锁机制:并发控制的基石

锁是数据库保证数据一致性的另一种方式,和 MVCC 互补。

锁的分类

分类类型说明
按粒度表锁锁定整个表,并发度低
行锁锁定单行,并发度高
页锁锁定一页数据
按模式共享锁(S)读锁,多个事务可同时持有
排他锁(X)写锁,同一时间只能有一个事务持有
按实现乐观锁通过版本号或时间戳实现,不阻塞
悲观锁通过数据库锁机制实现,会阻塞

InnoDB 的锁类型

  1. Record Lock:行锁,锁定索引记录
  2. Gap Lock:间隙锁,锁定索引之间的间隙
  3. Next-Key Lock:行锁 + 间隙锁,锁定索引记录及其前面的间隙

死锁:如何避免

死锁是两个或多个事务互相等待对方释放锁的情况。

常见场景

  • 两个事务以不同顺序更新同一组行
  • 长事务持有锁时间过长

解决方案

  1. 按固定顺序更新数据
  2. 缩短事务长度
  3. 设置锁等待超时(innodb_lock_wait_timeout
  4. 使用乐观锁代替悲观锁

分布式事务:跨越多个数据库

当业务涉及多个数据库或服务时,需要分布式事务来保证数据一致性。

分布式事务协议

协议特点适用场景
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
  • 事务超时频繁 → 缩短事务长度,优化锁竞争