事务与子事务:Savepoint 的 TransactionState 栈和 2PC 的状态文件
你执行 BEGIN 的时候,PG
内核里发生了什么?不是”开始了一个事务”这种层面——你想知道的是:哪个数据结构被创建了?事务
ID 什么时候分配的?如果中途 ROLLBACK TO sp1,PG
怎么知道回退到哪里?
这些问题的答案在
src/backend/access/transam/xact.c 和
twophase.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 sp 或 RELEASE 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 PREPARED 或
ROLLBACK 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 + 1 个 savepoint 时)
(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()
从状态文件中重建锁信息)。
七、关键要点
- 事务状态机驱动 PG
的事务生命周期——
TRANS_DEFAULT\(\rightarrow\)TRANS_START\(\rightarrow\)TRANS_INPROGRESS\(\rightarrow\)TRANS_COMMIT/TRANS_ABORT。XID 在第一个写操作时分配(懒分配),纯读事务不分配 XID,不进入procArray。 - Savepoint 通过 TransactionState
栈实现——每个 savepoint 压入一个新的
TransactionStateData,包含独立的ResourceOwner和MemoryContext。ROLLBACK TO通过逐层AbortSubTransaction() + PopTransaction()弹栈释放资源。 - 2PC 通过 WAL + 状态文件持久化 PREPARE
状态——状态文件放在
pg_twophase/,重启后通过RecoverPreparedTransactions()重建。max_prepared_transactions = 0禁用 2PC。泄露的 prepared transaction 占用 slot 和锁,必须通过pg_prepared_xacts排查。 - 四道防线防止 XID
wraparound——
xidVacLimit(加速 autovacuum)\(\rightarrow\)xidWarnLimit(WARNING 日志)\(\rightarrow\)xidStopLimit(拒绝分配新 XID)\(\rightarrow\)xidWrapLimit(只读 shutdown)。监控age(datfrozenxid)不是可选的——它在高吞吐系统中几个小时内就可能触发xidStopLimit。 - 子事务过多有两重代价——PL/pgSQL 的
EXCEPTION块隐式创建 savepoint,循环中使用会导致子事务栈极深。深度超过 64 时PGPROC.subxids溢出,每次可见性检查都去查pg_subtransSLRU。
上一章:锁管理器 下一章:VACUUM 与 Freezing
参考资料
源码(PG 17)
src/backend/access/transam/xact.c:事务状态机、StartTransactionCommand/CommitTransactionCommand/AbortTransactionCommand、DefineSavepoint/RollbackToSavepoint/ReleaseSavepoint、PrepareTransactionBlocksrc/backend/access/transam/twophase.c:EndPrepare、RecoverPreparedTransactions、PrescanPreparedTransactions、TwoPhaseFileHeadersrc/backend/access/transam/varsup.c:GetNewTransactionId、SetTransactionIdLimit、xidStopLimit/xidWrapLimit 计算src/include/access/twophase.h:TwoPhaseFileHeader、GlobalTransactionData 定义src/include/utils/resowner.h:ResourceOwner 定义与 APIsrc/include/storage/proc.h:PGPROC.subxids 缓存大小(PGPROC_MAX_CACHED_SUBXIDS)src/backend/storage/ipc/procarray.c:ProcArrayAdd/ProcArrayRemove
官方文档
- PostgreSQL Documentation, Chapter 74: Transaction Processing(事务状态机与子事务)
- PostgreSQL Documentation, Section 30.3: PREPARE TRANSACTION(2PC 语法与语义)
- PostgreSQL Documentation, Section 25.1: Routine Vacuuming(wraparound 防护与 autovacuum_freeze_max_age)
- PostgreSQL Documentation, Section 19.5: Write Ahead Log(WAL 中 2PC record 类型的说明)
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【PG 内核】MVCC 实现:CLOG、hint bit 与快照可扩展性
在已有 MVCC 文章基础上深入 PG 并发控制的三个基础设施:CLOG 的 SLRU 结构(事务状态位、页面格式、SLRU 淘汰)、hint bit 的写入时机和竞争问题(何时写、谁写、写坏了怎么办)、PG 14 snapshot scalability 优化的具体机制(ProcArrayLock 为什么是瓶颈、xid/xmin 的原子更新如何减少持锁路径),以及事务 ID 回卷(wraparound)的威胁模型。最后与 InnoDB undo log 方案做系统性对比。
【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 的预警信号链、典型陷阱和排查方法。
【PG 内核】进程模型与共享内存:Postmaster 如何管理 100 个 Backend
拆解 PostgreSQL 多进程架构的核心:Postmaster 的启动与信号处理、Backend 进程的 fork()→InitPostgres→主循环生命周期、CreateSharedMemoryAndSemaphores() 的共享内存初始化流程、PGPROC/ProcArray/PGXACT 等关键共享内存结构的内存布局,以及 Background Worker 的注册与调度。理解了这个地基,才能理解 PG 为什么用进程而不是线程,以及 max_connections 为什么不能随便调大。
【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 可见性判断的共同前提。