土法炼钢兴趣小组的算法知识备份

【PG 内核】事务与子事务:Savepoint 的 TransactionState 栈和 2PC 的状态文件

文章导航

分类入口
databasekernel
标签入口
#postgresql#pg-kernel#transaction#subtransaction#savepoint#two-phase-commit#2pc#transstate#xid#wraparound#xact

目录

事务与子事务:Savepoint 的 TransactionState 栈和 2PC 的状态文件

你执行 BEGIN 的时候,PG 内核里发生了什么?不是”开始了一个事务”这种层面——你想知道的是:哪个数据结构被创建了?事务 ID 什么时候分配的?如果中途 ROLLBACK TO sp1,PG 怎么知道回退到哪里?

这些问题的答案在 src/backend/access/transam/xact.ctwophase.c 里。本文从这两个文件出发,拆解 PG 事务系统的三层结构:顶层状态机(TransState)驱动事务生命周期,中间层 TransactionState 栈支撑 savepoint 嵌套,底层事务 ID 分配机制与 wraparound 防线。同时覆盖两阶段提交(2PC)的实现——PREPARE TRANSACTION 如何把事务状态持久化到磁盘。


一、事务状态机:TransState 的五种状态

PG 用 TransState 枚举表示顶层事务状态,定义在 src/backend/access/transam/xact.c

// src/backend/access/transam/xact.c
typedef enum TransState {
    TRANS_DEFAULT,      // 空闲,没有活跃事务
    TRANS_START,        // 事务已开始,但还未分配事务 ID
    TRANS_INPROGRESS,   // 事务正在进行(已分配 XID)
    TRANS_COMMIT,       // 正在提交
    TRANS_ABORT,        // 正在回滚
    TRANS_PREPARE       // 正在准备 2PC
} TransState;
```text

状态转换路径:

TRANS_DEFAULT │ StartTransactionCommand() ▼ TRANS_START ← 读事务可能一直停留于此(未分配 XID) │ AssignTransactionId() ← 第一个写操作触发 XID 分配 ▼ TRANS_INPROGRESS │ CommitTransactionCommand() │ AbortTransactionCommand() ▼ ▼ TRANS_COMMIT TRANS_ABORT │ │ ▼ ▼ TRANS_DEFAULT TRANS_DEFAULT


### 状态之间的关键边界

TRANS_START 和 TRANS_INPROGRESS 的区别在于是否分配了事务 ID(`XID`)。这个区别直接影响 MVCC 快照——`GetSnapshotData()` 只扫描已经分配了 XID 的事务(`TRANS_INPROGRESS`),处于 `TRANS_START` 的纯读事务不会出现在快照的 `xip[]` 数组中。因此一个执行 `SELECT` 的长事务不会阻止 VACUUM 清理(至少在它还没写任何东西之前)——这是 PG 相比某些数据库的一个重要优化。

### TransactionStateData:单个事务的完整状态

每个事务对应一个 `TransactionStateData` 结构体:

```c
// src/backend/access/transam/xact.c
typedef struct TransactionStateData {
    TransactionId   transactionId;       // 顶层事务 XID
    SubTransactionId subTransactionId;    // 子事务计数器
    char           *name;                 // savepoint 名称(子事务时有效)
    int             savepointLevel;       // savepoint 嵌套深度
    TransState      state;               // 当前状态
    TBlockState     blockState;          // 事务块状态(BEGIN/END 嵌套级别)
    int             nestingLevel;        // 嵌套深度(1 = 顶层)
    int             gucNestLevel;        // GUC 嵌套级别
    MemoryContext    curTransactionContext; // 事务内存上下文
    ResourceOwner    curTransactionOwner;  // 资源追踪器
    TimestampTz      startTimestamp;        // 事务开始时间
    bool             startedInRecovery;     // 是否在 recovery 中开始
    bool             didLogXid;             // 是否已 WAL-log 此 XID
    int              parallelModeLevel;     // 并行模式嵌套级别
} TransactionStateData;

这个结构体不直接暴露给调用方,但它是整个事务系统的核心数据结构。PG 用栈来管理它——这是 savepoint 机制的基础。


二、子事务与 Savepoint:TransactionState 栈

栈结构

PG 的 savepoint 通过子事务实现。CurrentTransactionState 是一个 TransactionStateData 的栈,保存了所有活跃的事务和子事务:

nestingLevel = 3:  CurrentTransactionState  ← 栈顶(当前最内层 savepoint sp2)
    │
nestingLevel = 2:  parent  ← savepoint sp1
    │
nestingLevel = 1:  parent  ← 顶层事务(BEGIN)
    │
nestingLevel = 0:  TopTransactionStateData ← 无活跃事务时的占位节点

nestingLevel 从 1(顶层事务)开始计数。每次 SAVEPOINT sp 创建一个新的 TransactionStateData 压栈,ROLLBACK TO spRELEASE sp 弹栈。

Savepoint 的三个操作

1. DefineSavepoint() — 创建 savepoint

// src/backend/access/transam/xact.c
void DefineSavepoint(const char *name) {
    TransactionState s;

    // 1. 检查同名 savepoint 是否存在
    // 2. 分配新的 TransactionState 并压栈
    s->transactionId = GetCurrentTransactionId();
    s->subTransactionId = currentSubTransactionId++;
    s->name = memory_strdup(name);
    s->savepointLevel = s->nestingLevel;
    s->state = TRANS_INPROGRESS;
    s->blockState = TBLOCK_SUBINPROGRESS;

    // 3. 创建子事务的资源追踪器
    s->curTransactionOwner =
        ResourceOwnerCreate(CurrentResourceOwner, "SubTransaction");

    // 4. 创建子事务的内存上下文
    s->curTransactionContext =
        AllocSetContextCreate(CurrentMemoryContext, ...);

    // 5. 通知各子系统(resource owner、buffer、catcache 等)
    AtSubStart_Notify();
    AfterTriggerBeginSubXact();
}
```text

关键点:每个子事务有独立的 `ResourceOwner` 和 `MemoryContext`。当 `ROLLBACK TO sp` 时,PG 通过销毁子事务的 ResourceOwner 来释放该子事务持有的所有 buffer pin、锁和内存——不需要遍历所有资源逐个释放。

**2. RollbackToSavepoint() — 回滚到 savepoint**

```c
// src/backend/access/transam/xact.c
void RollbackToSavepoint(List *names) {
    // 1. 解析 savepoint 名称,找到对应的 nestingLevel
    // 2. AbortSubTransaction() 逐层回滚子事务
    while (CurrentTransactionState->nestingLevel > targetLevel) {
        AbortSubTransaction();  // 释放 ResourceOwner、MemoryContext
        PopTransaction();       // 弹栈
    }
    // 3. 重新设置为 TRANS_INPROGRESS(不是 TRANS_ABORT)
    CurrentTransactionState->state = TRANS_INPROGRESS;
    CurrentTransactionState->blockState = TBLOCK_SUBINPROGRESS;
}

ROLLBACK TO sp 之后,事务继续运行——这是它和 ROLLBACK(终止整个事务)的本质区别。在 PG 内部,ROLLBACK TO sp 通过 AbortSubTransaction() 逐层销毁子事务,但顶层事务保持 TRANS_INPROGRESS

3. ReleaseSavepoint() — 释放 savepoint

释放 savepoint 就是把子事务提交到父事务中。PG 执行 CommitSubTransaction(),将子事务的 ResourceOwner 并入父事务的 ResourceOwner(ResourceOwnerReleaseAll 不会在此时被调用,资源只是转移所有权),然后弹栈。

ResourceOwner 的嵌套释放

ResourceOwner 是 PG 资源管理的关键抽象。每个事务(和子事务)有一个 ResourceOwner,追踪该事务持有的所有资源:

// src/include/utils/resowner.h
// ResourceOwner 追踪的资源类型
// - buffer pins (ReadBuffer → ReleaseBuffer)
// - catcache / relcache references
// - heavyweight locks (LockAcquire → LockRelease)
// - plan cache references (CachedPlanSource)
// - JIT contexts
// - snapshot references
// - files (OpenTemporaryFile)
```text

当子事务回滚时,`ResourceOwnerRelease()` 遍历子事务的 `ResourceOwner`,释放该子事务独立获取的所有资源。父事务持有的资源(如之前获取的锁)不受影响。这是 savepoint 回滚的工程基础——不需要知道每个资源的具体类型和处理方式,`ResourceOwner` 统一管理。

### 子事务与 XID 分配

子事务也有独立的事务 ID——SubTransactionId。但注意 `SubTransactionId` 不是全局可见的 `TransactionId`。MVCC 可见性判断只看顶层事务的 XID,子事务的 XID 被缓存在 `PGPROC.subxids` 数组中(`PGPROC_MAX_CACHED_SUBXIDS` = 64),并通过 `pg_subtrans` SLRU 目录管理父子关系。

---

## 三、两阶段提交(2PC):PREPARE TRANSACTION 的持久化

### 为什么需要 2PC

两阶段提交解决的是**跨多个资源管理器**的分布式事务问题。如果两个 PG 数据库需要原子地提交同一个分布式事务,单靠 `COMMIT` 不够——第一个数据库可能成功提交,第二个可能失败,没有回滚机制。2PC 引入一个额外的 `PREPARE` 阶段:所有参与者在真正提交前先确认"我能提交",只有所有参与者都 PREPARE 成功,协调者才发出 COMMIT。

在 PG 中,2PC 通过 `PREPARE TRANSACTION 'gtrid'` 触发。准备后的事务不依赖原始 Backend 进程——它被持久化到磁盘,即使 PG 重启,准备状态的事务也能被恢复并最终提交或回滚。

### PREPARE 的流程

```c
// src/backend/access/transam/xact.c
void PrepareTransactionBlock(const char *gid) {
    // 1. 获取事务的完整状态
    // 2. 调用 EndPrepare() → 写 WAL 和状态文件
    // 3. 释放 Backend 持有的资源(锁、buffer pin 等)
}

EndPrepare()src/backend/access/transam/twophase.c 中实现,做了两件事:

第一步:写 WAL record(XLOG_XACT_PREPARE

WAL record 包含事务的所有信息:XID、GID(全局事务标识符)、持有的锁列表、pg_database 信息等。这是崩溃恢复的必要信息——如果刷盘后崩溃,startup 进程需要知道哪些事务处在 PREPARE 状态。

第二步:写状态文件

状态文件放在 pg_twophase/ 目录下,文件名是 GID 的 8 字节 hash。文件格式由 TwoPhaseFileHeader 定义:

// src/include/access/twophase.h
typedef struct TwoPhaseFileHeader {
    uint32          magic;           // 魔数:TWOPHASE_MAGIC
    uint32          total_len;       // 文件总长度
    TransactionId   xid;             // 事务 XID
    Oid             database;        // 数据库 OID
    Oid             owner;           // 事务的拥有者
    int32           nsubxacts;       // 子事务数量
    bool            initdb;          // 是否在 initdb 中准备
    int16           ncommitrels;     // 提交时需要失效的 relcache 数量
    // 后面跟着:
    // - GID(字符串)
    // - 子事务 XID 数组
    // - 失效消息列表
    // - 锁信息
} TwoPhaseFileHeader;
```text

状态文件包含足够的信息让事务在 PG 重启后恢复。关键内容:

- **锁信息**:PREPARE 后事务不持有锁(为了不阻塞其他事务),但锁信息被写入状态文件。在 PREPARE 和决议之间的窗口期,事务在 `LockManager` 中不持有锁,但锁信息存在状态文件中,需要恢复时才重建。
- **子事务列表**:PREPARE 之前 PG 会先内部提交所有活跃子事务,并将子事务 XID 列表记录在状态文件中。

### PREPARE 后的事务恢复

重启后,`StartupXLOG()` 在 recovery 结束时调用 `PrescanPreparedTransactions()` 和 `RecoverPreparedTransactions()`:

```c
// src/backend/access/transam/twophase.c
void RecoverPreparedTransactions(void) {
    // 扫描 pg_twophase/ 目录
    // 对每个状态文件:
    //   1. 读取 TwoPhaseFileHeader
    //   2. 重新获取锁(从状态文件中读取锁信息)
    //   3. 把事务加入全局 prepared transaction 列表
    //   4. 重建失效消息
}

恢复后,prepared transaction 出现在 pg_prepared_xacts 视图中,等待管理员执行 COMMIT PREPAREDROLLBACK PREPARED

max_prepared_transactions

max_prepared_transactions 控制可以同时处于 PREPARE 状态的事务数量和预分配的共享内存结构数量。默认为 0——设为 0 意味着 PREPARE TRANSACTION 不可用。这是因为 PG 为每个潜在的 prepared transaction 预留了共享内存(TwoPhaseState 结构),包括一个锁表的完整副本。

// src/backend/access/transam/twophase.c
typedef struct GlobalTransactionData {
    TransactionId   xid;           // 事务 XID
    char            gid[GIDSIZE];  // 全局标识符
    int             pgprocno;      // 关联的 PGPROC slot
    LM_LOCK        *lockchain;     // 锁链
    bool            valid;         // 此 slot 是否在使用
    bool            ondisk;        // 状态文件是否已存在
    bool            inredo;        // 是否在 redo 中重建
} GlobalTransactionData;
```text

每个 `max_prepared_transactions` slot 大约占用几百字节,加上锁表的开销。如果你需要 2PC(例如分布式事务协调器),需要在 `postgresql.conf` 中设为实际需求的 2 倍——为并发 PREPARE 留出余量。

---

## 四、事务 ID 分配与 Wraparound 防线

### AssignTransactionId

事务 ID 不是在 `BEGIN` 时分配,而是在第一个**写操作**时才分配。这是 PG 的"懒分配"策略:

```c
// src/backend/access/transam/xact.c
TransactionId GetCurrentTransactionId(void) {
    if (CurrentTransactionState->state == TRANS_DEFAULT)
        elog(ERROR, "GetCurrentTransactionId: not in a transaction");

    if (!TransactionIdIsValid(CurrentTransactionState->transactionId))
        AssignTransactionId();  // 第一次写操作触发

    return CurrentTransactionState->transactionId;
}

AssignTransactionId() 调用 GetNewTransactionId() 从全局计数器分配一个新的 XID:

// src/backend/access/transam/varsup.c
TransactionId GetNewTransactionId(bool isSubXact) {
    TransactionId xid;

    LWLockAcquire(XidGenLock, LW_EXCLUSIVE);
    xid = ShmemVariableCache->nextXid;
    // 推进 nextXid
    TransactionIdAdvance(ShmemVariableCache->nextXid);

    // 检查 wraparound 危险
    if (ShmemVariableCache->xidVacLimit < xid) {
        // 触发 autovacuum 以冻结旧 XID
        SendPostmasterSignal(PMSIGNAL_START_AUTOVAC_LAUNCHER);
    }
    LWLockRelease(XidGenLock);

    // 把 XID 写入 PGPROC
    if (!isSubXact)
        ProcArrayAdd(MyProc, xid);

    return xid;
}
```text

注意 `XidGenLock`——这是一个全局 LWLock,在高并发插入场景(很多事务同时第一次写入)时,所有 Backend 都排队等这个锁。每个事务只获取一次(在分配 XID 时),不构成常见性能瓶颈,但在极端的单语句短事务场景中值得关注。

### xidStopLimit 和 xidWrapLimit

PG 的 `TransactionId` 是 32-bit 无符号整数,取值范围约 40 亿($2^{32} - 3$,0 的 `InvalidTransactionId`、1 的 `BootstrapTransactionId`、2 的 `FrozenTransactionId` 为保留值)。40 亿个事务之后,XID 会**回卷(wraparound)**。PG 通过 MVCC 的"XID 比较使用模运算"来使回卷安全——但这依赖一个关键假设:**任何事务 ID 的"年龄"不超过 20 亿**($2^{31}$)。

如果不冻结旧 tuple 的 `xmin`,回卷会把"旧的数据"误判为"未来的数据",导致数据静默丢失。PG 设置了三道防线:

```c
// src/backend/access/transam/varsup.c
void SetTransactionIdLimit(TransactionId oldest_datfrozenxid,
                            Oid oldest_datoid) {
    // 第一道防线:autovacuum 加速
    xidVacLimit = oldest_datfrozenxid +
        (autovacuum_freeze_max_age -
         autovacuum_freeze_max_age * 0.05);

    // 第二道防线:强制 autovacuum(即使 autovacuum=off)
    xidWarnLimit = oldest_datfrozenxid +
        (autovacuum_freeze_max_age -
         autovacuum_freeze_max_age * 0.025);

    // 第三道防线:拒绝分配新 XID
    xidStopLimit = oldest_datfrozenxid +
        (autovacuum_freeze_max_age -
         autovacuum_freeze_max_age * 0.01);

    // 绝对上限:wraparound 发生点
    xidWrapLimit = oldest_datfrozenxid +
        (autovacuum_freeze_max_age + 1);

    // 将防线写入共享内存
    ShmemVariableCache->xidVacLimit = xidVacLimit;
    ShmemVariableCache->xidWarnLimit = xidWarnLimit;
    ShmemVariableCache->xidStopLimit = xidStopLimit;
    ShmemVariableCache->xidWrapLimit = xidWrapLimit;
}

四条界线的作用:

界线 触发条件 行为
xidVacLimit 已到达 向 Postmaster 发信号,触发 autovacuum launcher 唤醒更多 worker
xidWarnLimit 已到达 WARNING 日志:“database XXX must be vacuumed within N transactions”
xidStopLimit 已到达 拒绝分配新 XID——ERROR: database is not accepting commands to avoid wraparound data loss
xidWrapLimit 已到达 数据库变为只读(single-user mode 是唯一的恢复路径)

xidStopLimit 已经到达时,数据库拒绝所有写操作(包括 VACUUM),只有 superuser 可以连接。此时唯一的恢复路径是:在 single-user mode 下启动,执行 VACUUM FREEZE

防线距离的实际计算

以默认 autovacuum_freeze_max_age = 200,000,000(2 亿)为例:

xidVacLimit   = oldest_datfrozenxid + 190,000,000
xidWarnLimit  = oldest_datfrozenxid + 195,000,000
xidStopLimit  = oldest_datfrozenxid + 198,000,000
xidWrapLimit  = oldest_datfrozenxid + 200,000,001

如果你的集群 oldest_datfrozenxid 在 XID 100,000,000,那么 xidStopLimit 在 298,000,000 处——还有约 1.98 亿个事务的余量。在高吞吐系统中(如每秒 1000 个事务),\(1.98 \times 10^8 / 1000 / 3600 \approx 55\) 小时就会用光。这就是为什么必须监控 age(datfrozenxid)


五、运维排查:三个常见坑

坑 1:2PC 泄露——PREPARE 了但永远不决议

这是分布式事务协调器 bug 或人工误操作的典型结果:

-- 某个事务被 PREPARE 但从未 COMMIT PREPARED
BEGIN;
INSERT INTO orders VALUES (1, 'pending');
PREPARE TRANSACTION 'order_1';
-- 忘记执行 COMMIT PREPARED 'order_1'
```text

这个 prepared transaction 会:
- 永久占用一个 `max_prepared_transactions` slot
- 恢复后持有它 PREPARE 时持有的所有锁(阻塞对相关表的写入)

排查 SQL:

```sql
-- 查看所有 prepared transaction
SELECT transaction, gid, prepared,
       extract(epoch FROM now() - prepared) AS age_seconds,
       owner, database
FROM pg_prepared_xacts;

-- 如果发现有 age_seconds 超过预期的记录:
-- 1. 先确认事务的来源:owner 和 database
-- 2. 检查分布式事务协调器状态
-- 3. 如果确认已无法正常决议:ROLLBACK PREPARED 'gid'

注意:ROLLBACK PREPARED 会回滚该事务的所有修改——确认事务协调器不会再要求提交此事务。如果误把应该提交的事务回滚了,数据就永久丢失了。

坑 2:子事务栈过深——PL/pgSQL 的 EXCEPTION 块

PG 的 PL/pgSQL 中,每个 BEGIN ... EXCEPTION ... END 块创建一个隐式 savepoint。如果在一个循环中反复使用 EXCEPTION 块,子事务数量会线性增长:

CREATE OR REPLACE FUNCTION bad_loop() RETURNS void AS $$
DECLARE
    i INTEGER;
BEGIN
    FOR i IN 1..10000 LOOP
        BEGIN
            -- 某些可能失败的操作
            INSERT INTO log VALUES (i);
        EXCEPTION WHEN OTHERS THEN
            -- 忽略错误
        END;
    END LOOP;
END;
$$ LANGUAGE plpgsql;
```text

每次进入 `BEGIN ... EXCEPTION ... END` 块,PG 创建一个子事务。虽然有 64 个缓存的 subxid 限制,但子事务本身(`TransactionState` 栈节点)没有硬限制——它可以无限增长,每次 `AbortSubTransaction()`(异常发生时)也需要清理当前子事务的资源。

更严重的后果是:如果外层事务是一个长事务,深层嵌套的子事务会让 `PGPROC.subxids` 溢出。当子事务超过 64 个时,PG 不再将子 XID 缓存在 `PGPROC` 中,而是每次可见性检查都去查 `pg_subtrans` SLRU——这比查 `PGPROC` 慢得多。

排查方法:

```sql
-- 查看当前活跃查询的事务年龄和子事务数量
SELECT pid, state, xact_start,
       now() - xact_start AS xact_duration,
       substring(query, 1, 80) AS query
FROM pg_stat_activity
WHERE state = 'active'
  AND now() - xact_start > interval '1 minute'
ORDER BY xact_start;

坑 3:XID 分配延迟导致的长事务隐患

纯读事务(只有 SELECT 没有写操作)不会分配 XID——它一直处于 TRANS_START 状态,不进入 procArray,不阻塞 VACUUM。但这个优化的边界条件值得注意:

-- Session A: 长查询
BEGIN;
SELECT * FROM huge_table;  -- 可能跑很久
-- 还没写任何东西,状态是 TRANS_START,没有 XID

-- ... 过了很久 ...

-- Session A: 突然写了一条
UPDATE huge_table SET status = 'done' WHERE id = 1;
-- 此时分配 XID,进入 TRANS_INPROGRESS,加入 procArray
-- 这个 XID 的"年龄"会从现在开始计算——之前的等待时间不算

COMMIT;
```text

Session A 在分配 XID 之后仍然在 `procArray` 中,会阻止 VACUUM 清理此后产生的 dead tuple。但因为 XID 分配发生在 UPDATE 执行时(而非事务开始时),`xmin` 快照的年龄比直觉中要年轻——这对 wraparound 计算有利,但对 VACUUM 效率没有实质帮助(VACUUM 关心的是所有活跃 XID 的最小值 `oldest xmin`,不关心它何时开始)。

---

## 六、实验:观察事务状态和 2PC

### 1. 观察读事务没有 XID

```sql
-- Session 1
BEGIN;
SELECT txid_current();
-- 输出:一个数字(如 1000)
-- 注意:txid_current() 本身是一个写函数——它强制分配 XID!
-- 用 pg_current_xact_id() 在 PG 13+ 来避免:
SELECT pg_current_xact_id_if_assigned();
-- 如果是纯读事务,输出:NULL(未分配 XID)

-- 查看自己的 backend 信息
SELECT pid, backend_xid, backend_xmin
FROM pg_stat_activity
WHERE pid = pg_backend_pid();
-- backend_xid:NULL(没有 XID)
-- backend_xmin:可能有值(如果持有快照)

2. Savepoint 子事务

BEGIN;
INSERT INTO t VALUES (1);                 -- XID 分配
SAVEPOINT sp1;
INSERT INTO t VALUES (2);
SAVEPOINT sp2;
INSERT INTO t VALUES (3);

-- 查看当前子事务状态
SELECT txid_current();
-- 输出:顶层事务 XID

-- 回滚到 sp1——sp2 和中间的工作被丢弃
ROLLBACK TO sp1;

-- 继续
INSERT INTO t VALUES (4);
COMMIT;
-- 最终表中:1, 4
```text

用 gdb 观察子事务栈:

```bash
gdb -p $(pgrep -f "postgres: .* testdb")

(gdb) p CurrentTransactionState->nestingLevel
# 输出:2  (在 BEGIN + 1savepoint 时)
(gdb) p CurrentTransactionState->name
# 输出:0x... "sp1"
(gdb) p CurrentTransactionState->curTransactionOwner
# 输出:独立的 ResourceOwner 指针

3. 2PC 的状态文件

-- 确保 max_prepared_transactions > 0
SET max_prepared_transactions = 5;

BEGIN;
INSERT INTO t VALUES (10);
PREPARE TRANSACTION 'test_2pc';
```text

准备后查看文件:

```bash
# 状态文件在 pg_twophase/
ls -la $PGDATA/pg_twophase/
# 输出:1 个文件,文件名是 GID 的 hash

# 查看内容(二进制)
xxd $PGDATA/pg_twophase/00000001 | head -5

以及系统视图:

```sql SELECT transaction, gid, prepared, owner, database FROM pg_prepared_xacts; – 输出: – transaction | gid | prepared | owner | database – 550 | test_2pc | 2026-06-16 10:… | ltl | testdb

– 决议它 COMMIT PREPARED ‘test_2pc’; – 或 ROLLBACK PREPARED ‘test_2pc’; ```text

PG 重启后,即使没有执行 COMMIT PREPARED,prepared transaction 仍然在——因为状态文件持久化了。但重启后需要重新获取锁(RecoverPreparedTransactions() 从状态文件中重建锁信息)。


七、关键要点

  1. 事务状态机驱动 PG 的事务生命周期——TRANS_DEFAULT \(\rightarrow\) TRANS_START \(\rightarrow\) TRANS_INPROGRESS \(\rightarrow\) TRANS_COMMIT/TRANS_ABORT。XID 在第一个写操作时分配(懒分配),纯读事务不分配 XID,不进入 procArray
  2. Savepoint 通过 TransactionState 栈实现——每个 savepoint 压入一个新的 TransactionStateData,包含独立的 ResourceOwnerMemoryContextROLLBACK TO 通过逐层 AbortSubTransaction() + PopTransaction() 弹栈释放资源。
  3. 2PC 通过 WAL + 状态文件持久化 PREPARE 状态——状态文件放在 pg_twophase/,重启后通过 RecoverPreparedTransactions() 重建。max_prepared_transactions = 0 禁用 2PC。泄露的 prepared transaction 占用 slot 和锁,必须通过 pg_prepared_xacts 排查。
  4. 四道防线防止 XID wraparound——xidVacLimit(加速 autovacuum)\(\rightarrow\) xidWarnLimit(WARNING 日志)\(\rightarrow\) xidStopLimit(拒绝分配新 XID)\(\rightarrow\) xidWrapLimit(只读 shutdown)。监控 age(datfrozenxid) 不是可选的——它在高吞吐系统中几个小时内就可能触发 xidStopLimit
  5. 子事务过多有两重代价——PL/pgSQL 的 EXCEPTION 块隐式创建 savepoint,循环中使用会导致子事务栈极深。深度超过 64 时 PGPROC.subxids 溢出,每次可见性检查都去查 pg_subtrans SLRU。

上一章:锁管理器 下一章:VACUUM 与 Freezing


参考资料

源码(PG 17)

官方文档

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-06-16 · database / kernel

【PG 内核】MVCC 实现:CLOG、hint bit 与快照可扩展性

在已有 MVCC 文章基础上深入 PG 并发控制的三个基础设施:CLOG 的 SLRU 结构(事务状态位、页面格式、SLRU 淘汰)、hint bit 的写入时机和竞争问题(何时写、谁写、写坏了怎么办)、PG 14 snapshot scalability 优化的具体机制(ProcArrayLock 为什么是瓶颈、xid/xmin 的原子更新如何减少持锁路径),以及事务 ID 回卷(wraparound)的威胁模型。最后与 InnoDB undo log 方案做系统性对比。

2026-06-16 · database / kernel

【PG 内核】VACUUM 与 Freezing:膨胀的根因和 Wraparound 危机

拆解 PostgreSQL VACUUM 的完整内部流程:heap scan、dead tuple 回收、索引清理、FSM/VM 更新。深入可见性映射和空闲空间映射的结构设计,以及 Index-Only Scan 如何依赖 VM 判断页面全可见。解析 Freezing 机制与事务 ID 回卷防御,Autovacuum 的触发阈值和 cost-based delay。最后用一条从 n_tup_del 增长到数据库强制只读的完整危机时间线,讲清楚 Anti-wraparound VACUUM 的预警信号链、典型陷阱和排查方法。

2026-06-16 · database / kernel

【PG 内核】进程模型与共享内存:Postmaster 如何管理 100 个 Backend

拆解 PostgreSQL 多进程架构的核心:Postmaster 的启动与信号处理、Backend 进程的 fork()→InitPostgres→主循环生命周期、CreateSharedMemoryAndSemaphores() 的共享内存初始化流程、PGPROC/ProcArray/PGXACT 等关键共享内存结构的内存布局,以及 Background Worker 的注册与调度。理解了这个地基,才能理解 PG 为什么用进程而不是线程,以及 max_connections 为什么不能随便调大。

2026-06-16 · database / kernel

【PG 内核】页面布局与元组格式:PG 如何把一行数据塞进 8KB

拆解 PostgreSQL 的物理存储层:Page 的 8KB 布局(PageHeaderData、ItemId 数组、special space)、HeapTupleHeaderData 的字段语义(xmin/xmax/ctid/t_infomask/t_infomask2)、TOAST 外存机制的压缩阈值与四种策略(PLAIN/EXTENDED/EXTERNAL/MAIN),以及用 pageinspect 扩展直接观察页面字节。理解页面格式是理解 VACUUM、Index Scan、MVCC 可见性判断的共同前提。


By .