vacuum 一直在跑,表却越来越大。排障的人多半会先猜:某个老事务的 xmin 挡着。可“挡着”到底是怎么挡的,一个 backend 的 xmin 凭什么能拖住全库的清理?
还有 pg_stat_activity 里那个卡在等待里的 backend:它在等什么,该由谁来唤醒?
这些问题的答案,都不在进程自己的内存里。它们散落在 PostgreSQL 的 shared memory 中——一个 backend 需要把一部分状态“发布”出去,让别的进程能找到它、观察它、等待它。
之前聊过一点pg的共享内存,这篇顺着 backend 从出生到退出的整个生命周期,看看它是怎么一步步进入这个“共享世界”的。
一个客户端连接进来,postmaster 就 fork 出一个 backend。
从操作系统的角度看,这个新进程的身份已经很明确了:一个 PID。
那为什么 PostgreSQL 还要在 shared memory 里提前准备一批 PGPROC?
不止如此。backend 启动以后,还要先通过 InitProcess() 领走一个 PGPROC,拿到 MyProc 和 MyProcNumber;紧接着执行 InitProcessPhase2(),把自己注册进 ProcArray。
更奇怪的是,PGPROC 里明明已经有 xid、xmin 等字段了,PostgreSQL 又单独维护了一套:
ProcGlobal->xids[]
ProcGlobal->subxidStates[]
ProcGlobal->statusFlags[]
同一份信息,看起来被保存了好几遍。
如果只盯着结构体,很容易把它们理解成几张不同的“进程表”。但顺着 backend 的生命周期继续往下看,会发现 PostgreSQL 真正要解决的问题,并不是“如何记录一个进程”。
它要解决的是:
一个普通的 OS 进程,怎么成为 PostgreSQL shared memory 世界里的一个参与者,让其他进程能够找到它、等待它、观察它发布的状态,最后又能安全地从这个世界里消失?
从这个角度再看 PGPROC 和 ProcArray,事情就清楚多了。
PID 还不够
PID 很适合回答一个问题:
这是哪个 OS 进程?
但数据库内部想知道的显然不止这些。
比如:
谁正在等待一把锁?
我要唤醒哪个 backend?
哪个 backend 当前有 XID?
哪些事务还处于运行状态?
哪个 backend 还保留着比较老的事务视野?
这个 backend 退出以后,
其他进程从什么时候开始可以彻底忽略它?
这些问题有一个共同特点:一个 backend 的状态,需要被另一个 backend 观察甚至操作。
而 PostgreSQL 的 backend 是独立进程,当前进程里的栈、普通指针、backend-local 对象,拿到另一个进程里并没有什么意义。它需要一个所有 backend 都能认识的身份。
这个身份就是 PGPROC。
Postmaster 启动时,会提前在 shared memory 中准备好一批 PGPROC slot:
ProcGlobal->allProcs[]
┌──────────┐
│ PGPROC 0 │
├──────────┤
│ PGPROC 1 │
├──────────┤
│ PGPROC 2 │ ← 某个 backend
├──────────┤
│ ... │
└──────────┘
backend 启动以后,从对应的 freelist 中领取一个 slot。
当前 backend 用 MyProc 指向它,而 MyProcNumber 则提供一个可以跨进程使用的编号。其他 backend 如果保存了这个编号,就可以重新在 ProcGlobal->allProcs[] 中找到对应的 PGPROC。
这也是为什么 PGPROC 里的东西看起来有些杂:
latch / semaphore
LWLock wait state
heavyweight lock state
wait event
xid / xmin / subxids
各种 group membership
这些东西并不属于某一个单独模块。
它们的共同点是:
都需要一个能够代表“这个 backend”的共享锚点。
所以把 PGPROC 理解成“进程信息结构体”多少有些低估它。
我更愿意把它理解成:
backend 在 PostgreSQL shared memory 世界里的身份。
这里还藏着一个挺有意思的 bootstrap 问题。
管理 PGPROC freelist 的锁是 spinlock(ProcStructLock),而不是更常见的 LWLock。
原因很简单:
先拿到 PGPROC
↓
才能完整参与 LWLock 的等待和唤醒
如果“领取 PGPROC”这件事本身又依赖 LWLock,就形成了循环依赖。
所以进入共享世界的第一步,只能站在比 LWLock 更底层的同步设施上。
单独看这只是个实现细节,但放回整个启动顺序里,就很合理了。
有了 PGPROC,为什么还需要 ProcArray?
PGPROC 给了 backend 一个共享身份,但事情并没有到此结束。
关键在于:
拥有一个共享身份,和向整个系统公开自己的事务状态,是两回事。
可以粗略地理解成:
PGPROC:
我是谁?
ProcArray:
哪些 backend 正在发布自己的事务状态?
一个 backend 内部有大量事务状态。
比如当前事务嵌套层级、command id、ResourceOwner,以及各种只对当前 backend 有意义的执行状态。
PostgreSQL 并没有因为其他 backend 偶尔需要了解事务状态,就把整个事务对象搬到 shared memory 中。
它只挑出别人必须知道的那部分:
xid
xmin
SubXID 状态
状态标志位
...
然后发布出去。
所以这里有一条很明确的边界:
完整的事务状态
│
└── backend-local
其他 backend 必须知道的事实
│
└── shared memory
我觉得这比单纯记住 ProcArrayAdd() 做了什么更重要。
多进程程序里,共享得越多,并不意味着设计越简单。
恰恰相反,共享状态意味着同步、内存可见性、生命周期和异常退出都要变成全局问题。
因此 PostgreSQL 没有试图共享“整个事务”,而是只发布并发协议真正需要的事实。
ProcArray 就站在这个边界上。
一个 backend 加入 ProcArray,并不意味着它现在一定有 XID。
事务开起来以后,完全可能暂时还没有真正的 XID。只有事务确实需要时,才通过 GetNewTransactionId() 分配 XID,并把它发布到共享状态里。
所以:
进入 ProcArray
≠
已经有 XID
更准确地说,进入 ProcArray 意味着:
从现在开始,如果我产生了会影响其他 backend 判断的事务状态,我会按照这里的规则发布和撤销它。
这样理解以后,ProcArray 就不像是一张“连接列表”了。
它更像是一组正在参与全局事务协议的成员。
为什么同一个 XID 还要再存一遍?
不过 PostgreSQL 又做了一件看起来很浪费的事情。
PGPROC 里已经有事务相关字段了,为什么还要维护:
ProcGlobal->xids[]
ProcGlobal->subxidStates[]
ProcGlobal->statusFlags[]
原因不是语义不同,而是访问方式不同。
假设我要回答:
backend 42 现在在等什么?
这时候最自然的方式是:
ProcNumber
↓
PGPROC
↓
读取 backend 42 的状态
这是典型的按对象访问。
但另一些操作关心的是:
所有 backend 现在分别是什么状态?
这就变成了:
backend 1
backend 2
backend 3
...
backend N
这是典型的批量扫描。
如果每次都在体积不小的 PGPROC 之间跳来跳去,只为了读取其中几个事务字段,CPU cache 的利用并不好。
于是 PostgreSQL 又维护了一套适合扫描的 dense arrays:
xids:
[xid][xid][xid][xid][xid]...
subxidStates:
[...][...][...][...][...]
statusFlags:
[...][...][...][...][...]
而且这套 dense arrays 并不是新发明,可以说是 PostgreSQL 坚持了很久的老设计。
严格说,这个“惊讶”只对 PG 14 之后的版本成立。在那之前,xid、xmin 这些字段住在另一个结构体 PGXACT 里:ProcGlobal->allPgXact[] 同样是一组按 pgxactoff 索引的 dense arrays,PGPROC 自己则主要负责锁、等待和 latch 这些并发现场。
PG 14 干脆把 PGXACT 拆散成 xids[]、subxidStates[]、statusFlags[] 三个平行数组——“运行事务集”仍然是一份紧凑、适合扫描的布局,只是从“结构体数组”变成了“数组的结构体”。
所以“运行事务集值得一份独立的紧凑布局”这个决定,在 PostgreSQL 里已经有二十多年历史了。PGPROC 负责身份和并发现场,事务状态另起炉灶,这个分工不是最近才想出来的。
这也带来了两个很容易混淆的编号:
MyProcNumber
pgxactoff
刚开始看到它们时,我下意识觉得都是“当前 backend 的下标”。
其实不是。
MyProcNumber 属于身份。
只要 backend 还占着这个 PGPROC slot,它就代表这个 backend 在 allProcs[] 中的位置。
而 pgxactoff 只是当前 backend 在那几组 dense arrays 里的位置。
ProcArray 成员发生变化以后,为了保持数组紧凑,会发生 memmove(),后面的 pgxactoff 也会随之变化。
所以可以这么区分:
MyProcNumber:
“我是谁?”
pgxactoff:
“为了方便这次批量扫描,
我现在排在哪里?”
看起来只是两个下标,背后却是完全不同的设计目标。
这也是这部分代码里我觉得挺有意思的地方:
同一份逻辑状态,不一定适合只有一种物理布局。
按 backend 定位和扫描所有 backend,本来就是两种不同的工作负载。
PostgreSQL 并没有强迫 PGPROC 同时把两件事情都做好。
发布出去以后,真的有人会看吗?
既然花了这么多功夫发布状态,当然得有人消费。
一个最典型的例子就是 snapshot。
一个 backend 想建立自己的事务视图,需要知道在某个时刻:
哪些事务还处于运行状态?
这个答案显然不可能只从自己的 backend-local memory 中得到。
其他事务都运行在别的进程里。
于是 GetSnapshotData() 会去扫描这些已经发布出来的共享事务状态。
大致可以画成:
共享内存
不断变化的全局事务状态
│
│ 扫描
▼
SnapshotData
│
▼
当前 backend 使用的稳定视图
这里有个值得注意的转换。
ProcArray 中保存的是全局的、不断变化的状态。
而 snapshot 想得到的是:
在某个时刻,把这份不断变化的状态切下来,变成当前 backend 后面可以反复使用的一份局部视图。
这至少说明:一个 backend 什么时候发布 XID、什么时候撤销 XID,并不是它自己的内部实现细节——同一时刻,另一个 backend 可能正在基于这些字段构造自己的判断。
类似地,一个 backend 发布的 xmin 等状态,也会被其他全局计算使用。
最典型的就是 vacuum 计算清理水位:扫一遍所有 backend 的 xmin,找出全局最老的事务视野,旧版本就清理到它为止。
所以一个 backend 如果把过时的 xmin 留在共享状态里,它拖住的不是自己,而是整个实例的清理水位——表里的旧版本清不掉,越积越多,就是我们熟悉的“表膨胀”。这时候监控里看到的往往是“vacuum 一直在跑,表却越来越大”,追到根上,其实是某个 backend 的 xmin 没有及时撤销。
重要的是:
这些字段一旦进入 shared memory,就变成了其他 backend 会真正依赖的事实。
那接下来的问题自然就来了:
进入这个世界很容易。
退出呢?
事务结束了,Backend 并没有消失
一个 backend 进入“共享世界”的过程,大概可以画成:
OS 进程
│
▼
InitProcess()
│
▼
PGPROC
│
▼
InitProcessPhase2()
│
▼
ProcArray
那退出的时候,反过来执行一遍就行了吗?
并没有这么简单。
首先得区分三件事情:
事务结束
backend 从 ProcArray 消失
PGPROC slot 被释放
它们是不同的生命周期。
最明显的例子就是长连接。
backend / PGPROC 生命周期
|----------------------------------------------|
事务 A 事务 B
|-----------| |-----------|
事务 A commit 以后,这个 backend 还会继续执行事务 B。
所以事务结束时当然不能把 PGPROC 给释放掉。
真正需要撤销的是当前事务发布出去的状态。
比如:
xid
xmin
SubXID 状态
相关标志位
这些状态失效以后,得让其他 backend 不再把它们当作当前事实。
但 PGPROC 本身仍然属于这个 backend。
直到整个 backend 真正退出,才进入另一层清理:
backend 退出
│
▼
从 ProcArray 中移除
│
▼
清理共享等待 / 进程状态
│
▼
ProcKill()
│
▼
PGPROC slot 回到 freelist
这几个阶段不能随便交换。
因为其他 backend 可能还拿这些共享状态做判断。
例如一个已经结束的事务,如果 XID 没有及时从共享的运行事务集里清掉,别人就可能继续把它当成运行中的事务。
如果一个已经不再需要的 xmin 留在那里,别人仍然会认为这个 backend 依赖更老的事务历史。
如果 lock wait state 没清理干净,影响的又是另一个完全不同的子系统。
更麻烦的是,PGPROC slot 最终还会被复用。
今天:
PGPROC[42] → backend A
过一段时间可能就是:
PGPROC[42] → backend B
因此释放一个 PGPROC,并不只是“把内存放回池子”。
它实际上意味着:
从这一刻开始,shared memory 中不能再有任何东西把这个 slot 当成原来的 backend。
否则 backend B 接过来的,就不再是一个干净的身份。
所以看下来,我觉得 PostgreSQL 的清理有个很重要的特点:
清理的顺序,本身就是正确性的一部分。
在 backend-local 世界里,一个对象用完以后 free(),很多时候生命周期就结束了。
但共享状态不一样。
只要还有其他进程可能观察你,退出就不是简单的资源释放,而是逐层撤销之前向整个系统做出的承诺。
先告诉别人:
这个事务已经不再运行。
然后:
这些事务状态已经不能再被依赖。
再到:
这个 backend 已经不属于 ProcArray。
最后才是:
这个 PGPROC 已经不再代表原来的进程,
slot 可以重新使用。
再看“共享世界”
这样回头看最开始的问题:
已经有 PID 了,为什么 PostgreSQL 还需要
PGPROC?
因为 PID 只能告诉 PostgreSQL:
有这么一个 OS 进程。
但数据库真正需要的是:
其他 backend 能找到我,锁和等待机制能引用我;
我能发布事务状态,别人可以扫描;
事务结束、进程退出时,我能逐层撤销;
最后这个身份还可以安全地交给下一个 backend。
所以一个 backend 真正进入 PostgreSQL 的“共享世界”,并不是从 fork 出来的那一刻开始。它还需要先拿到 PGPROC 这个身份,再把自己注册进 ProcArray,才算真正成为共享状态里的参与者。
而退出的过程,则是在一点点撤销自己曾经发布出去的状态和身份。
从这个角度看,PGPROC、ProcArray,甚至那些看起来有些重复的 dense arrays,都在解决同一个问题的不同侧面:
一个生命周期不断变化的进程,如何成为共享状态中的稳定参与者。
开头那两个问题现在都能对上了:xmin 凭什么拖住全库的清理?因为它发布进了共享世界,而 vacuum 一直拿这份发布当事实。卡住的 backend 在等什么?它把“我在等锁”的事实挂在共享的锁结构里,锁释放的那一刻,唤醒就顺着这份记录发生。
多进程数据库里,难的从来不只是“大家怎么共享一块内存”。
真正麻烦的是:
一旦别人开始依赖你发布的状态,你该如何存在,又该如何消失。