PostgreSQL 架构原理第三期:事务与并发控制 ------ MVCC、快照与锁机制
引言
前两期我们分别从进程模型、内存结构、查询流程以及存储引擎的角度剖析了 PostgreSQL 的内部机制。本期将聚焦于数据库并发控制的核心------事务与隔离。PostgreSQL 凭借其实现精巧的多版本并发控制(MVCC),能够在不使用传统读锁的情况下提供高并发读写的隔离性,同时避免了"读-写"阻塞问题。
本文将系统讲解:
- 事务 ID 与元组头中的版本信息
- 事务状态与 Commit Log(clog)
- 快照(Snapshot)的构成与可见性判断规则
- 四种隔离级别及 PostgreSQL 的具体实现
- 锁机制:表级锁、行锁、页锁与死锁检测
- 可串行化快照隔离(SSI)的原理浅析
一、事务与事务 ID
每个事务在启动时都会获得一个唯一的事务标识符(XID),它是一个 32 位无符号整数,取值范围约为 42 亿。PostgreSQL 采用循环使用机制,通过 VACUUM 处理事务回卷(wraparound)问题。
XID 的分配发生在事务执行任意写操作或使用
SELECT FOR UPDATE等显式加锁语句时。只读事务默认不分配 XID,除非指定了SERIALIZABLE隔离级别。
1.1 特殊 XID
0:InvalidXid,表示无效事务。1:BootstrapXid,表示系统表初始化事务。2:FrozenXid,用于冻结元组,表示该元组对所有事务均可见且早于所有正常 XID。
1.2 元组头上的版本信息
回顾第二期中堆元组头的关键字段:
t_xmin:插入元组的事务 XID。t_xmax:删除或更新元组的事务 XID;若为 0,表示元组尚未被删除。t_cid:事务内命令序号,用于同一事务中前后命令间的可见性。t_ctid:指向当前元组或新版本元组的物理位置(页号+行指针)。
当执行 UPDATE 时,PostgreSQL 实际上执行的是:
- 将旧元组的
t_xmax设置为当前事务 XID,t_ctid指向新元组。 - 插入新元组,其
t_xmin为当前事务 XID,t_xmax为 0,t_ctid指向自身。
执行 DELETE 时,仅将旧元组的 t_xmax 设为当前事务 XID。
二、事务状态与 Commit Log(clog)
光有 t_xmin 和 t_xmax 还不够,查询时需要知道一个事务到底是已提交 还是已中止 。PostgreSQL 将每个事务的状态存储在 Commit Log(clog) 中,位于数据目录下的 pg_xact 子目录(早期版本为 pg_clog)。
2.1 clog 存储格式
每个事务状态占用 2 个比特位,可能的取值:
TRANSACTION_STATUS_IN_PROGRESS(0x00) ------ 进行中TRANSACTION_STATUS_COMMITTED(0x01) ------ 已提交TRANSACTION_STATUS_ABORTED(0x02) ------ 已中止TRANSACTION_STATUS_SUB_COMMITTED(0x03) ------ 子事务已提交(内部用)
clog 被划分为多个 8KB 页面,每个页面可存储 32K 个事务的状态(8KB × 1024 字节/页 × 4 个事务/字节 = 32768)。PostgreSQL 会将 clog 页面缓存到共享内存中,以减少 I/O。
2.2 事务状态查询流程
当可见性判断需要知道某个 XID 的状态时:
- 根据 XID 计算出所在 clog 页面及页内偏移。
- 若页面不在共享缓存中,从
pg_xact读取并缓存。 - 读取 2 比特状态,返回
COMMITTED或ABORTED。
为了加速频繁访问的事务状态,PostgreSQL 还在元组头的 t_infomask 中缓存了两个标志位:HEAP_XMIN_COMMITTED 和 HEAP_XMIN_ABORTED(类似地对 t_xmax 也有缓存)。一旦确认过事务提交状态,就设置相应标志位,避免重复查询 clog。
三、快照(Snapshot)与可见性判断
MVCC 的核心思想是为每个查询(或事务)提供一个数据的"快照",根据快照中的事务状态信息判断每个元组是否对当前查询可见。
3.1 快照的结构
快照主要由以下几个数组组成:
xmin:快照中最早仍活跃的事务 ID(即所有小于xmin的 XID 要么已提交,要么已中止)。xmax:快照中第一个未分配的事务 ID(即所有 XID >= xmax 都视为未开始)。xip:当前活跃(进行中)的事务 ID 列表。
例如:当前活跃事务为 [100, 105, 110],则 xmin = 100,xmax = 111(假设下一个未使用的 XID 是 111),xip 包含 100、105、110。
注意: 对于 READ COMMITTED 隔离级别,每个 SQL 语句都会获取新的快照;对于 REPEATABLE READ 或 SERIALIZABLE,整个事务使用同一个快照。
3.2 可见性判断规则
给定一个元组 T,其 t_xmin = XID_ins,t_xmax = XID_del(0 表示未删除)。判断元组对当前快照 Snap 是否可见的伪代码:
yaml
if XID_ins 为 ABORTED → 不可见
if XID_ins 为 IN_PROGRESS:
if XID_ins == 当前事务:
根据 t_cid 判断当前命令是否能看见之前更新(同一事务内)
else:
→ 不可见(其他未提交事务的更改不可见)
if XID_ins 为 COMMITTED:
if XID_del == 0:
→ 可见
if XID_del 为 ABORTED:
→ 可见(删除未生效)
if XID_del 为 IN_PROGRESS:
if XID_del == 当前事务:
根据 t_cid 判断是否在当前命令之前已删除
else:
→ 可见(其他事务的删除尚未提交)
if XID_del 为 COMMITTED:
→ 不可见(已被已提交的删除或更新移除)
实际实现中,PostgreSQL 使用 HeapTupleSatisfiesMVCC() 等一系列函数进行判断,并考虑了 SNAPSHOT 类型(如用于 REPEATABLE READ 的 SnapShot 需要排除在事务开始后提交的事务)。
3.3 冻结与回卷保护
由于 XID 是 32 位循环使用,当 XID 回卷到小于之前已冻结的 XID 时,可见性判断会出错。PostgreSQL 通过 VACUUM FREEZE 或自动冻结,将足够旧的元组的 t_xmin 替换为 FrozenXid(值为 2),并设置 HEAP_XMIN_FROZEN 标志。冻结后的元组对所有事务直接可见,不再需要进行 XID 比对,从而解决了 XID 回卷问题。
四、隔离级别与行为差异
SQL 标准定义了四种隔离级别,PostgreSQL 的默认隔离级别为 READ COMMITTED ,同时支持 REPEATABLE READ 和 SERIALIZABLE (READ UNCOMMITTED 被映射为 READ COMMITTED)。
4.1 READ COMMITTED
- 每个语句获得新的快照。
- 避免脏读(无法看到其他事务未提交的更改)。
- 不可重复读:同一个事务内两次查询可能看到不同数据(因为语句间其他事务可能提交更改)。
- 写操作会读取已提交的最新数据,不会使用语句开始时的快照。
表现: 如果事务 A 执行 UPDATE ... WHERE,事务 B 在 A 提交前修改了某些行并提交,A 的 UPDATE 会看到 B 提交的结果,并可能再次更新那些行。
4.2 REPEATABLE READ
- 整个事务使用同一个快照(事务中第一个非事务控制命令执行时获取)。
- 避免了脏读和不可重复读,但可能出现幻读 ?严格来说 PostgreSQL 的
REPEATABLE READ实际上也避免了幻读,因为快照是基于事务开始时刻的,新插入的行对事务不可见。但 SQL 标准将更严格的幻读要求划给了SERIALIZABLE。 - 写操作(
UPDATE、DELETE、SELECT FOR UPDATE)如果尝试修改其他事务已提交更改的行,则会因为"可重复读"冲突而报错,并回滚事务。
行为: 事务 A 开始后,事务 B 插入一行并提交。事务 A 再次查询不会看到新行。若事务 A 尝试更新 B 插入的行,会收到 could not serialize access due to concurrent update 错误。
4.3 SERIALIZABLE
- 基于 可串行化快照隔离(SSI) 算法,比普通快照隔离更强。
- 通过跟踪读/写依赖关系,检测可能导致环状冲突的事务,并主动中止其中一个,从而实现真正的可串行化。
- 性能比
REPEATABLE READ略有损耗,但避免了应用程序使用显式锁的复杂性。
4.4 与锁的结合
尽管 MVCC 避免了读-写阻塞,但写-写冲突仍需要锁来防止丢失更新。UPDATE、DELETE、SELECT FOR UPDATE 会对行加上行锁。不同隔离级别下的写冲突处理详见下节锁机制。
五、锁机制
PostgreSQL 的锁分为多个层次:表级锁、行级锁、页级锁(轻量级锁,主要用于内部结构),以及用于死锁检测和 SSI 的谓词锁。
5.1 表级锁
通过 LOCK 命令或 DDL 语句自动获取。主要模式有:
- AccessShareLock :
SELECT获取,与RowShareLock、RowExclusiveLock等不冲突。 - RowShareLock :
SELECT FOR UPDATE/SHARE获取,防止其他事务并行ALTER TABLE等 DDL。 - RowExclusiveLock :
UPDATE、DELETE、INSERT获取。允许并发读,但阻止 DDL 更改表结构。 - ShareLock:创建索引时获取,允许读但不允许写。
- ExclusiveLock :
REFRESH MATERIALIZED VIEW CONCURRENTLY等操作,阻止所有并发读。 - AccessExclusiveLock :
DROP TABLE、TRUNCATE、VACUUM FULL、ALTER TABLE等 DDL 获取,是最严格的锁,与所有其他锁冲突。
使用 pg_locks 视图可以查看当前锁的持有和等待情况。
5.2 行级锁
行级锁存储在元组的 t_infomask 中,而不是单独的一张锁表,因此非常轻量。主要模式:
- 行共享锁 :
SELECT FOR SHARE,阻止其他事务FOR UPDATE但允许FOR SHARE。 - 行排他锁 :
SELECT FOR UPDATE或UPDATE/DELETE,阻止其他事务对这些行加任何行级锁。
行锁的冲突矩阵:
| 已有锁 / 请求锁 | FOR SHARE | FOR UPDATE |
|---|---|---|
| FOR SHARE | 允许 | 阻塞 |
| FOR UPDATE | 阻塞 | 阻塞 |
当一个事务修改某行时,会在该行上设置行排他锁。如果另一个事务想要修改同一行,会等待行锁释放(即第一个事务提交或回滚)。若两个事务同时更新同一行,其中一个会等待,另一个完成后在 READ COMMITTED 下会重新读取该行并再次尝试更新(可能导致非直观结果);在 REPEATABLE READ 和 SERIALIZABLE 下则会直接报错并回滚。
5.3 页级锁与轻量级锁
- 页级锁:主要用于缓冲区管理器替换策略的短期锁定,对用户透明。
- 轻量级锁(LWLocks):保护共享内存中的数据结构(如缓冲区描述符、锁管理器等)。分为两种模式:独占(Exclusive)和共享(Shared)。为了性能,LWLocks 不支持排队等待,而是通过自旋+休眠实现。
5.4 死锁检测
PostgreSQL 提供自动死锁检测机制。当进程等待一个锁时,会启动超时计时器(deadlock_timeout,默认 1 秒)。超时后,锁管理器会沿着等待关系构建有向图,如果检测到环,则选择其中一个事务中止,从而打破死锁。被选中回滚的事务会收到 deadlock detected 错误。
5.5 谓词锁与 SSI
SERIALIZABLE 隔离级别使用谓词锁 (predicate locks)来防止幻读。与传统行锁不同,谓词锁锁定了查询的条件范围(例如 WHERE id > 100)。PostgreSQL 的 SSI 实现并不对所有谓词加实际锁,而是通过检测读依赖 (读过的行)和写依赖(写入的行)之间的冲突,推断出是否存在可串行化异常。当检测到可能产生交换的循环依赖时,回滚事务。
六、总结与预告
本期我们全面讲解了 PostgreSQL 事务与并发控制的内部机制:
- 事务 XID 和元组版本头字段(
t_xmin/t_xmax)的组合构成了 MVCC 的基石。 - clog 用于持久化事务的提交/回滚状态,配合快照实现可见性判断。
- 不同的隔离级别通过快照获取时机和写冲突处理方式区分。
- 表级锁与行级锁保证了 DDL 和写-写的隔离,而死锁检测解决了多事务循环等待。
- 可串行化快照隔离提供了真正的可串行化,避免了昂贵的手动锁。
理解这些机制对调优高并发事务、避免死锁和性能瓶颈至关重要。下一期我们将从优化器角度出发,讲解 统计信息与代价模型,看看 PostgreSQL 是如何估算不同执行计划的代价,并最终选择最优的执行方式。
思考题
- 在
REPEATABLE READ隔离级别下,事务 A 先读取了一行,然后事务 B 删除了该行并提交。事务 A 随后尝试UPDATE这一行会发生什么?为什么? - 如果 clog 损坏,可能导致哪些严重的数据库故障?如何预防或修复?
- 为什么
SELECT FOR UPDATE即使在READ COMMITTED下也能防止"丢失更新"?它的底层是否使用了 MVCC?