PostgreSQL 架构原理第三期:事务与并发控制 —— MVCC、快照与锁机制

PostgreSQL 架构原理第三期:事务与并发控制 ------ MVCC、快照与锁机制

引言

前两期我们分别从进程模型、内存结构、查询流程以及存储引擎的角度剖析了 PostgreSQL 的内部机制。本期将聚焦于数据库并发控制的核心------事务与隔离。PostgreSQL 凭借其实现精巧的多版本并发控制(MVCC),能够在不使用传统读锁的情况下提供高并发读写的隔离性,同时避免了"读-写"阻塞问题。

本文将系统讲解:

  1. 事务 ID 与元组头中的版本信息
  2. 事务状态与 Commit Log(clog)
  3. 快照(Snapshot)的构成与可见性判断规则
  4. 四种隔离级别及 PostgreSQL 的具体实现
  5. 锁机制:表级锁、行锁、页锁与死锁检测
  6. 可串行化快照隔离(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 实际上执行的是:

  1. 将旧元组的 t_xmax 设置为当前事务 XID,t_ctid 指向新元组。
  2. 插入新元组,其 t_xmin 为当前事务 XID,t_xmax 为 0,t_ctid 指向自身。

执行 DELETE 时,仅将旧元组的 t_xmax 设为当前事务 XID。


二、事务状态与 Commit Log(clog)

光有 t_xmint_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 的状态时:

  1. 根据 XID 计算出所在 clog 页面及页内偏移。
  2. 若页面不在共享缓存中,从 pg_xact 读取并缓存。
  3. 读取 2 比特状态,返回 COMMITTEDABORTED

为了加速频繁访问的事务状态,PostgreSQL 还在元组头的 t_infomask 中缓存了两个标志位:HEAP_XMIN_COMMITTEDHEAP_XMIN_ABORTED(类似地对 t_xmax 也有缓存)。一旦确认过事务提交状态,就设置相应标志位,避免重复查询 clog。


三、快照(Snapshot)与可见性判断

MVCC 的核心思想是为每个查询(或事务)提供一个数据的"快照",根据快照中的事务状态信息判断每个元组是否对当前查询可见。

3.1 快照的结构

快照主要由以下几个数组组成:

  • xmin:快照中最早仍活跃的事务 ID(即所有小于 xmin 的 XID 要么已提交,要么已中止)。
  • xmax:快照中第一个未分配的事务 ID(即所有 XID >= xmax 都视为未开始)。
  • xip:当前活跃(进行中)的事务 ID 列表。

例如:当前活跃事务为 [100, 105, 110],则 xmin = 100xmax = 111(假设下一个未使用的 XID 是 111),xip 包含 100、105、110。

注意: 对于 READ COMMITTED 隔离级别,每个 SQL 语句都会获取新的快照;对于 REPEATABLE READSERIALIZABLE,整个事务使用同一个快照。

3.2 可见性判断规则

给定一个元组 T,其 t_xmin = XID_inst_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 READSnapShot 需要排除在事务开始后提交的事务)。

3.3 冻结与回卷保护

由于 XID 是 32 位循环使用,当 XID 回卷到小于之前已冻结的 XID 时,可见性判断会出错。PostgreSQL 通过 VACUUM FREEZE 或自动冻结,将足够旧的元组的 t_xmin 替换为 FrozenXid(值为 2),并设置 HEAP_XMIN_FROZEN 标志。冻结后的元组对所有事务直接可见,不再需要进行 XID 比对,从而解决了 XID 回卷问题。


四、隔离级别与行为差异

SQL 标准定义了四种隔离级别,PostgreSQL 的默认隔离级别为 READ COMMITTED ,同时支持 REPEATABLE READSERIALIZABLEREAD UNCOMMITTED 被映射为 READ COMMITTED)。

4.1 READ COMMITTED

  • 每个语句获得新的快照
  • 避免脏读(无法看到其他事务未提交的更改)。
  • 不可重复读:同一个事务内两次查询可能看到不同数据(因为语句间其他事务可能提交更改)。
  • 写操作会读取已提交的最新数据,不会使用语句开始时的快照。

表现: 如果事务 A 执行 UPDATE ... WHERE,事务 B 在 A 提交前修改了某些行并提交,A 的 UPDATE 会看到 B 提交的结果,并可能再次更新那些行。

4.2 REPEATABLE READ

  • 整个事务使用同一个快照(事务中第一个非事务控制命令执行时获取)。
  • 避免了脏读和不可重复读,但可能出现幻读 ?严格来说 PostgreSQL 的 REPEATABLE READ 实际上也避免了幻读,因为快照是基于事务开始时刻的,新插入的行对事务不可见。但 SQL 标准将更严格的幻读要求划给了 SERIALIZABLE
  • 写操作(UPDATEDELETESELECT FOR UPDATE)如果尝试修改其他事务已提交更改的行,则会因为"可重复读"冲突而报错,并回滚事务。

行为: 事务 A 开始后,事务 B 插入一行并提交。事务 A 再次查询不会看到新行。若事务 A 尝试更新 B 插入的行,会收到 could not serialize access due to concurrent update 错误。

4.3 SERIALIZABLE

  • 基于 可串行化快照隔离(SSI) 算法,比普通快照隔离更强。
  • 通过跟踪读/写依赖关系,检测可能导致环状冲突的事务,并主动中止其中一个,从而实现真正的可串行化。
  • 性能比 REPEATABLE READ 略有损耗,但避免了应用程序使用显式锁的复杂性。

4.4 与锁的结合

尽管 MVCC 避免了读-写阻塞,但写-写冲突仍需要锁来防止丢失更新。UPDATEDELETESELECT FOR UPDATE 会对行加上行锁。不同隔离级别下的写冲突处理详见下节锁机制。


五、锁机制

PostgreSQL 的锁分为多个层次:表级锁、行级锁、页级锁(轻量级锁,主要用于内部结构),以及用于死锁检测和 SSI 的谓词锁。

5.1 表级锁

通过 LOCK 命令或 DDL 语句自动获取。主要模式有:

  • AccessShareLockSELECT 获取,与 RowShareLockRowExclusiveLock 等不冲突。
  • RowShareLockSELECT FOR UPDATE/SHARE 获取,防止其他事务并行 ALTER TABLE 等 DDL。
  • RowExclusiveLockUPDATEDELETEINSERT 获取。允许并发读,但阻止 DDL 更改表结构。
  • ShareLock:创建索引时获取,允许读但不允许写。
  • ExclusiveLockREFRESH MATERIALIZED VIEW CONCURRENTLY 等操作,阻止所有并发读。
  • AccessExclusiveLockDROP TABLETRUNCATEVACUUM FULLALTER TABLE 等 DDL 获取,是最严格的锁,与所有其他锁冲突。

使用 pg_locks 视图可以查看当前锁的持有和等待情况。

5.2 行级锁

行级锁存储在元组的 t_infomask 中,而不是单独的一张锁表,因此非常轻量。主要模式:

  • 行共享锁SELECT FOR SHARE,阻止其他事务 FOR UPDATE 但允许 FOR SHARE
  • 行排他锁SELECT FOR UPDATEUPDATE/DELETE,阻止其他事务对这些行加任何行级锁。

行锁的冲突矩阵:

已有锁 / 请求锁 FOR SHARE FOR UPDATE
FOR SHARE 允许 阻塞
FOR UPDATE 阻塞 阻塞

当一个事务修改某行时,会在该行上设置行排他锁。如果另一个事务想要修改同一行,会等待行锁释放(即第一个事务提交或回滚)。若两个事务同时更新同一行,其中一个会等待,另一个完成后在 READ COMMITTED 下会重新读取该行并再次尝试更新(可能导致非直观结果);在 REPEATABLE READSERIALIZABLE 下则会直接报错并回滚。

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 是如何估算不同执行计划的代价,并最终选择最优的执行方式。


思考题

  1. REPEATABLE READ 隔离级别下,事务 A 先读取了一行,然后事务 B 删除了该行并提交。事务 A 随后尝试 UPDATE 这一行会发生什么?为什么?
  2. 如果 clog 损坏,可能导致哪些严重的数据库故障?如何预防或修复?
  3. 为什么 SELECT FOR UPDATE 即使在 READ COMMITTED 下也能防止"丢失更新"?它的底层是否使用了 MVCC?
相关推荐
xiaoshuaishuai82 小时前
C# Submodule 避坑指南
服务器·数据库·windows·c#
2501_914245932 小时前
C#怎么使用属性Property C#自动属性和完整属性的区别get set怎么用【基础】
jvm·数据库·python
绩隐金2 小时前
SQL 与查询优化(PostgreSQL 篇)· 第五期
数据库
安当加密2 小时前
SQL Server 数据库安全新范式:TDE 透明加密+ DBG数据库安全网关 双重装甲
数据库·oracle
java干货2 小时前
如果光缆被挖断导致 Redis 出现两个 Master,怎么防止数据丢失?
数据库·redis·缓存
2401_837163893 小时前
CSS如何实现网页打印样式优化_利用@media print重写布局
jvm·数据库·python
Irene19913 小时前
Oracle 21c XE 安装后默认不包含HR等示例表,CO 模式、SCOTT 模式安装过程记录
数据库·oracle
李白客3 小时前
能源系统数据库:面向智能电网与新能源场景的五大核心能力
数据库·能源
观北海3 小时前
机器人调度系统死锁卡死全复盘及解决方案
数据库·机器人