进程模型与共享内存:Postmaster 如何管理 100 个 Backend
如果你用 ps aux | grep postgres 看过 PG
的进程列表,大概率被一堆叫 postgres
的进程吓到过。每个客户端连接都是一个独立的 OS 进程——这和
MySQL(线程模型)、MongoDB(线程模型)、Redis(单线程)都不一样。
多进程意味着 fork()
的代价、共享内存的复杂协调、信号驱动的父子通信。但 PG
的进程模型不是”老派设计”——它把 OS
的进程隔离和信号机制变成了故障隔离和资源管理的工程优势。
本文从源码路径拆解 PG 的进程骨架:Postmaster
如何孵化和管理子进程、共享内存里塞了哪些关键数据结构、Backend
从 fork() 到退出的完整生命周期。读完你就能对着
ps 输出判断每个进程在干什么、为什么
max_connections 设太高比你想的更危险。
一、PG 的进程全景
进程类型速览
| 进程类型 | 进程名示例 | 职责 |
|---|---|---|
| Postmaster | postgres: postmaster |
进程树根节点,监听新连接、fork Backend、处理子进程 crash |
| Backend | postgres: user db 10.0.0.1(54321) SELECT |
处理一条客户端连接的所有 SQL |
| Background Writer | postgres: background writer |
定期将 shared_buffers 脏页写入磁盘 |
| WAL Writer | postgres: walwriter |
将 WAL buffer 刷到 WAL 文件 |
| Checkpointer | postgres: checkpointer |
执行 checkpoint |
| Autovacuum Launcher | postgres: autovacuum launcher |
监控需要 VACUUM 的表,fork Autovacuum Worker |
| Autovacuum Worker | postgres: autovacuum worker db |
执行具体的 VACUUM / ANALYZE 操作 |
| WAL Sender | postgres: walsender user |
把 WAL 发送给 standby(流复制) |
| WAL Receiver | postgres: walreceiver |
standby 上接收 WAL |
| Logical Replication Worker | postgres: logical replication worker |
逻辑复制的 apply worker |
| Background Worker | postgres: bgworker: name |
用户注册的自定义后台进程(如
pg_cron、pg_stat_statements 的
hash 表清理) |
这是 PG 的多进程设计哲学:每种职责一个独立进程,进程之间通过共享内存和信号通信。一个 Backend crash 不会影响其他连接,因为 OS 保证了进程的地址空间隔离。
进程树结构
flowchart TD
PM["postmaster<br/>(PID=pid1)"] --> BG["background writer"]
PM --> WA["walwriter"]
PM --> CK["checkpointer"]
PM --> AL["autovacuum launcher"]
PM --> BE1["backend 1<br/>(client connection)"]
PM --> BE2["backend 2<br/>(client connection)"]
PM --> BE3["backend N<br/>(client connection)"]
PM --> WS["walsender(s)"]
AL --> AV1["autovacuum worker 1"]
AL --> AV2["autovacuum worker 2"]
```text
Autovacuum Launcher 是唯一会自己 fork 子进程的辅助进程(fork Autovacuum Worker);其他所有进程都由 Postmaster 直接创建。
---
## 二、Postmaster:进程树的根
### 入口路径
Postmaster 的启动入口在 `src/backend/postmaster/postmaster.c` 的 `PostmasterMain()` 函数。调用链:
main() // src/backend/main/main.c → PostmasterMain(argc, argv) // src/backend/postmaster/postmaster.c → InitializeGUCOptions() → set up signal handlers // SIGHUP, SIGTERM, SIGINT, SIGQUIT, SIGCHLD → StreamServerPort(AF_UNSPEC) // 对每个 listen_addresses 创建 socket → ServerLoop() // 主循环:accept → fork backend
关键信号处理:
| 信号 | 处理方式 | 效果 |
|------|---------|------|
| `SIGTERM` | `pmdie = SIGTERM` → 等待所有子进程退出 → shutdown | 智能关闭(smart shutdown),等所有连接自然断开 |
| `SIGINT` | `pmdie = SIGINT` → 回滚在线事务 → 断开连接 → shutdown | 快速关闭(fast shutdown) |
| `SIGQUIT` | `pmdie = SIGQUIT` → 立即 SIGKILL 所有子进程 → 退出 | 立即关闭(immediate shutdown),crash recovery 在下次启动时执行 |
| `SIGHUP` | `ProcessConfigFile()` → 所有 Backend 重新加载配置 | reload 配置文件,不重启 |
| `SIGCHLD` | 子进程退出通知 | 回收子进程、决定是否 restart |
```c
// src/backend/postmaster/postmaster.c, PostmasterMain()
// 信号处理器注册(简化)
pqsignal(SIGHUP, SIGHUP_handler); // reload config
pqsignal(SIGTERM, pmdie); // smart shutdown
pqsignal(SIGINT, pmdie); // fast shutdown
pqsignal(SIGQUIT, pmdie); // immediate shutdown
pqsignal(SIGCHLD, reaper); // child process cleanup
SIGCHLD 的处理器 reaper() 是
Postmaster 最关键的函数之一——当 Backend crash 时,Postmaster
通过 waitpid() 回收子进程状态,并决定是否
restart restart_after_crash=on
的所有子进程。
ServerLoop:accept 和 fork
// src/backend/postmaster/postmaster.c, ServerLoop()
// 主循环(简化伪代码)
for (;;) {
// 1. select() 等待新连接或子进程信号
selres = WaitEventSetWait(pm_wait_set, ...);
// 2. 如果是新连接
if (socket ready) {
port = ConnCreate(AF_UNSPEC);
port->sock = accept(listen_socket, ...);
// 3. fork Backend 进程
pid = fork_process();
if (pid == 0) {
// 子进程:不再需要 Postmaster 的资源
ClosePostmasterPorts(false); // 关闭监听 socket
InitPostmasterChild(); // 注册到 PMChildFlags
BackendMain(port); // → 进入 Backend 主循环(永不返回)
}
// 父进程:记录子进程信息,回到 select() 等下一个事件
BackendStartup(port);
}
// 4. 如果是 SIGCHLD,reaper() 已经在信号处理器中回收了子进程
}
```text
注意 `fork_process()` 是一个对 `fork()` 的包装——在 fork 之前它先获取 `postmaster_lock` 以保证 fork 的原子性,防止多个 Postmaster 子进程同时 fork 引发竞态。
---
## 三、Backend 进程:从 fork 到查询执行
### 生命周期全景
```mermaid
sequenceDiagram
participant Client
participant PM as Postmaster
participant Backend
Client->>PM: TCP connect
PM->>PM: ServerLoop accept()
PM->>Backend: fork()
Backend->>Backend: ClosePostmasterPorts()
Backend->>Backend: InitPostmasterChild()
Backend->>Backend: InitProcess() — 绑定到 PGPROC slot
Backend->>Backend: InitPostgres() — 加载 catalog cache, 设置搜索路径
Backend->>Backend: PostgresMain() 主循环
loop 每一条 SQL
Client->>Backend: SQL query
Backend->>Backend: exec_simple_query()
Backend->>Client: result
end
Client->>Backend: disconnect
Backend->>Backend: ProcKill() — 释放 PGPROC, 释放锁
Backend->>Backend: exit(0)
PM->>PM: SIGCHLD → reaper() 回收InitPostgres:最关键的三件事
InitPostgres() 在 fork 之后、第一条 SQL
执行之前运行。它做三件关键的事:
1. 绑定 SharedInvalidationCatCache —
订阅共享失效消息。当另一个 Backend 执行 DDL(比如
ALTER TABLE)时,catalog cache
需要被通知失效。这是通过 SI Message
Queue(共享内存中的一个环形缓冲区)实现的——发送方写入失效消息,所有订阅方在下一个
AcceptInvalidationMessages() 调用时处理。
2. 初始化 Catalog Cache — PG 的
catalog(pg_class、pg_attribute、pg_proc
等)是 Relation Cache (relcache) 和 Catalog
Cache (catcache)
的后端。InitPostgres() 加载
pg_database、pg_authid、pg_tablespace
等关键系统表的 entries。
3. 初始化 Portal 和 Resource Owner — Portal 是”执行单元”的抽象(一条 SQL 或一个 CURSOR),Resource Owner 跟踪每个事务分配的内存、buffer pin、锁等资源。
// src/backend/utils/init/postinit.c, InitPostgres()
// 简化调用链
void InitPostgres(const char *in_dbname, ...) {
// 1. 与共享内存中的 PGPROC entry 关联
InitProcess(); // → 获取 PGPROC slot, 设置 MyProc
// 2. 初始化 relcache / catcache / plan cache
InitPostgres_RelCache();
InitPostgres_CatCache();
// 3. 设置搜索路径、时区等 session 参数
InitializeSession();
// 4. 启动事务以读取 catalog
StartTransactionCommand();
// ... 加载数据库 OID, namespace, 权限信息 ...
CommitTransactionCommand();
}
```bash
### PostgresMain:查询处理主循环
```c
// src/backend/tcop/postgres.c, PostgresMain()
// 主循环(简化)
for (;;) {
// 1. 读取客户端消息(SQL 查询)
firstchar = ReadCommand(&input_message);
switch (firstchar) {
case 'Q': // simple query
exec_simple_query(input_message.data);
break;
case 'P': // parse (extended query protocol)
exec_parse_message(...);
break;
case 'B': // bind (extended query protocol)
exec_bind_message(...);
break;
case 'E': // execute (extended query protocol)
exec_execute_message(...);
break;
case 'X': // terminate
goto normal_exit;
}
// 2. 处理共享失效消息(DDL 通知)
AcceptInvalidationMessages();
}PG 支持两种查询协议:Simple Query(一条 Q
消息发整个 SQL 字符串)和 Extended Query(Parse → Bind →
Execute 分三步,支持 prepared statement
和参数化查询)。JDBC、libpq 的 PQexecParams()
使用 Extended 协议;psql 默认使用 Simple
协议。
四、共享内存初始化:CreateSharedMemoryAndSemaphores
CreateSharedMemoryAndSemaphores() 是 PG
启动时最长也最关键的初始化函数之一。它创建各个子系统所需的共享内存段和信号量,并且由
Postmaster 一次性分配,然后 fork
给子进程继承——这就是为什么 Postmaster 必须是所有
Backend 的父进程。
调用时序
PostmasterMain()
→ CreateSharedMemoryAndSemaphores()
1. 计算总共享内存大小
2. shmget() / mmap() 分配共享内存段
3. PGShmemHeader 初始化
4. 分配信号量集合
5. 按子系统顺序初始化共享内存数据:
a. ShmemInitHash("LOCK hash", ...) // 锁表
b. ShmemInitHash("Proc Signal hash", ...) // 进程间信号
c. ShmemInitHash("Buffer Table", ...) // Buffer 查找表
d. InitBufferPool() // shared_buffers
e. InitLockTable() // 重量级锁
f. InitProcGlobal() // PGPROC 数组
g. XLOGShmemInit() // WAL buffer
h. InitLWLocks() // LWLock 数组
i. InitBufferStrategy() // Clock sweep 状态
...
关键是分配顺序——Postmaster 在 fork
之前一次性分配所有共享内存,子进程通过继承指针直接访问。如果子进程自己做
shmget(),不但重复分配,而且每个 Backend
有自己的独立视图,无法通信。
// src/backend/storage/ipc/ipci.c, CreateSharedMemoryAndSemaphores()
// 共享内存大小的计算(简化)
size = add_size(size, ShmemBackendArraySize());
size = add_size(size, BufferShmemSize()); // shared_buffers
size = add_size(size, LockShmemSize());
size = add_size(size, ProcGlobalShmemSize());
size = add_size(size, XLOGShmemSize());
size = add_size(size, BTreeShmemSize());
size = add_size(size, LWLockShmemSize());
// ... 以及后面每个模块的 request
```bash
### 共享内存的寻址
所有进程通过 `ShmemIndex`(一个 hash table)查找共享内存对象——类似操作系统中的"符号表"。每个子系统在初始化时调用 `ShmemInitStruct(name, size, found)`,第一次调用分配空间,后续调用返回已有空间的指针。
```c
// 示例:ProcGlobal 的分配
ProcGlobal = (PROC_HDR *)
ShmemInitStruct("Proc Header", ProcGlobalShmemSize(), &found);
if (!found) {
// Postmaster 初始化路径:清零并设置
ProcGlobal->FreeProcs = ...;
}
// Backend 路径:found == true,直接使用五、关键共享内存结构
PGPROC 与 ProcArray
PGPROC 是每个 Backend
在共享内存中的”名片”。ProcArray 是所有
PGPROC 的数组:
// src/include/storage/proc.h
struct PGPROC {
// 基础标识
int pgprocno; // 在 ProcArray 中的索引
BackendId backendId; // 在 PGPROC 数组之外的唯一 ID
// 事务信息
TransactionId xid; // 当前事务 ID(只读事务为 InvalidTransactionId)
TransactionId xmin; // 快照的最小活跃事务
LocalTransactionId lxid; // 本 Backend 的本地事务 ID
// 锁等待
LOCK *waitLock; // 正在等的锁
PGPROC *lockGroupLeader; // 并行查询中 leader 的 PGPROC
int waitStatus; // STATUS_OK / STATUS_WAITING
// LWLock 等待
LWLock *lwWaiting; // 正在等的 LWLock
int lwWaitMode; // LW_SHARED / LW_EXCLUSIVE
// 快照
XidCache subxids; // 缓存的子事务 ID 列表
// 信号
sem_t *sem; // 本 Backend 的信号量(用于锁等待唤醒)
};
```text
`ProcArray` 是所有活跃 Backend 的 `PGPROC` 的数组,定义在 `src/backend/storage/ipc/procarray.c`。它被快照构建(`GetSnapshotData()`)频繁扫描——这是 PG 的一个可扩展性热点。在 PG 14 之前,`GetSnapshotData()` 需要用 `ProcArrayLock` 的共享锁保护整个扫描;在有大量连接的场景下(如 1000+ 连接),`ProcArrayLock` 成为瓶颈。PG 14 引入了 `xid`/`xmin` 的 per-backend 进度原子更新,大幅减少了需要持锁的代码路径。
### PGXACT
`PGXACT` 是 `PGPROC` 的轻量级伴随结构,把 `GetSnapshotData()` 扫描需要读的字段从 `PGPROC` 中分离出来,减少 CPU cache miss:
```c
// src/include/storage/proc.h
struct PGXACT {
TransactionId xid; // 当前事务 ID
TransactionId xmin; // 最小活跃事务
uint8 nxids; // 缓存的子事务数
uint8 vacuumFlags; // PROC_IN_VACUUM / PROC_IS_AUTOVACUUM
};LWLock 数组
LWLock 是 PG 内部使用最频繁的轻量级锁。所有 LWLock
在共享内存中作为 LWLockPadded 数组分配:
// src/include/storage/lwlock.h
typedef union LWLockPadded {
LWLock lock;
char pad[LWLOCK_PADDED_SIZE]; // CPU cache line padding(避免 false sharing)
} LWLockPadded;
// 在共享内存中:
LWLockPadded *MainLWLockArray; // 全局 LWLock 数组,每个子系统的锁有固定索引
```text
PG 17 中 LWLock 的实现使用原子操作(`pg_atomic_*`)而非信号量——对短临界区来说,自旋等待比 `semop()` 快得多。只有当自旋超过一定次数后,等待者才会 `semop()` 进入内核等待。这个设计在"锁持时间极短"的常见场景中很高效,但如果某处代码持 LWLock 做了重 I/O 或函数调用链过深,就会触发 LWLock 争用风暴。
---
## 六、Background Worker:可编程的后台进程
PG 9.3 引入了 Background Worker 框架,允许扩展注册自定义后台进程。`pg_stat_statements`(定期合并统计信息)、`pg_cron`(定时任务调度器)、`Citus` 的分布式执行器都基于这个框架。
### 注册与调度
Worker 在 `shared_preload_libraries` 加载时通过 `RegisterBackgroundWorker()` 注册到共享内存中的 `BackgroundWorkerArray`。Postmaster 在 `ServerLoop` 中检查是否有 worker 需要启动,并 fork:
```c
// src/backend/postmaster/bgworker.c
void maybe_start_bgworkers(void) {
for (slot = 0; slot < max_worker_processes; slot++) {
if (worker 注册了且还没启动) {
pid = fork_process();
if (pid == 0) {
// 子进程
InitPostmasterChild();
StartBackgroundWorker();
}
}
}
}Worker
类型有:BgWorkerStart_PostmasterStart(Postmaster
启动后就启动)、BgWorkerStart_RecoveryFinished(crash
recovery
完成后启动)、BgWorkerStart_ConsistentState(达到一致状态后启动)。
Worker 的限制
- Worker 不能接受客户端连接(除非通过
BGWORKER_CLASS_PARALLEL的并行查询 worker) - Worker 不能启动事务(需要自行调用
StartTransactionCommand()) - Worker PID 不列在
pg_stat_activity中,除非它打开一个数据库连接 max_worker_processes限制所有辅助进程的总数(autovacuum worker + background worker + logical replication worker)
七、为什么是进程而不是线程
这是 PG 最常被问的问题。MySQL 和 InnoDB 用线程处理连接,PG 用进程。为什么?
| 维度 | 进程模型 (PG) | 线程模型 (MySQL) |
|---|---|---|
| 内存隔离 | OS 保证——crash 不污染其他连接 | 需自行保证——野指针可能破坏整个进程 |
| fork 开销 | ~5-10MB 私有内存/连接,fork 本身 ~0.5ms | 创建线程 ~0.05ms,共享地址空间 |
| 1000 连接时的内存 | 5-10GB(只有私有内存) | 100-500MB(共享代码段和 buffer pool) |
| 共享缓存 | shared_buffers 在 fork 之前分配,被所有 Backend 继承 | InnoDB buffer pool 在所有线程间可见 |
| 信号处理 | 每个进程独立信号 mask,SIGCHLD 通知父进程 | 线程间信号混乱,通常不用信号做 IPC |
| 调试 | gdb attach 单个 Backend 即可 |
需要在多线程中导航 |
PG 的进程模型在一个关键点上很聪明:所有共享状态在
fork 之前分配在共享内存中,fork 之后的私有内存浪费只来自
Backend 自己的栈、堆、catalog
cache。shared_buffers(通常是内存大户)在所有进程间共享,不计入每个
Backend 的私有内存。
真正的风险不是 fork() 本身(0.5ms),而是
max_connections 设太高导致的工作内存膨胀——每个
Backend 可能同时使用多个 work_mem 配额(参考 配置陷阱)。
八、实验:观察 PG 的进程树和共享内存
进程树
# 观察 Postmaster 及其直接子进程
ps -eo pid,ppid,comm,args | grep postgres | grep -v grep
# 示例输出(PID 列 → PPID 列 → 进程名 → 参数):
# 1024 1 postgres /usr/local/pgsql/bin/postgres -D /data/pgdata
# 1026 1024 postgres postgres: checkpointer
# 1027 1024 postgres postgres: background writer
# 1028 1024 postgres postgres: walwriter
# 1029 1024 postgres postgres: autovacuum launcher
# 1030 1024 postgres postgres: user db 10.0.0.1(54321) SELECT
```text
注意所有子进程的 PPID 都是 Postmaster 的 PID(1024)。
### 共享内存段
```bash
# 查看 PG 分配的共享内存段
ipcs -m | grep postgres
# 每个 PG 集群通常有 1-3 个共享内存段(取决于大小和平台)
# 0x0052e2c1 32769 postgres 600 134217728 1
# 13GB shared_buffers + 其他共享结构用 GDB 观察 PGPROC
# attach 到一个 Backend
gdb -p $(pgrep -f "postgres: user")
# 打印当前 Backend 的 PGPROC 结构
(gdb) p *MyProc
# 输出:xid, xmin, waitLock, pgprocno 等字段
# 查看 ProcArray 中的活跃事务数
(gdb) p ProcGlobal->allPgXact
```bash
### 连接开销的量化
```bash
# 用 pgbench 测试不同连接数下的内存和 fork 开销
pgbench -i -s 100 postgres
# 测量 10 个连接
pgbench -c 10 -T 30 postgres &
sleep 2
ps -eo pid,rss,comm | grep "postgres: user" | awk '{sum+=$2} END {print "Total RSS (KB):", sum}'
# 测量 100 个连接
pgbench -c 100 -T 30 postgres &
sleep 2
ps -eo pid,rss,comm | grep "postgres: user" | awk '{sum+=$2} END {print "Total RSS (KB):", sum}'你会观察到每个 Backend 的 RSS 初始在 5-10MB,但会随着 catalog cache 的增长而上升。
九、关键要点
- PG 是多进程架构——Postmaster 是根节点,每个连接是一个独立的 OS 进程。进程隔离是故障隔离的基础。
- 共享内存在 fork
之前一次性分配——Postmaster 在
CreateSharedMemoryAndSemaphores()中分配所有共享内存段,子进程通过继承指针直接访问。不需要每个 Backend 做shmget()。 - PGPROC 和 ProcArray
是核心枢纽——
GetSnapshotData()扫描ProcArray获取活跃事务列表,这是快照隔离的基础。大量连接时这是性能热点。 - Background Worker
框架让扩展可以注册自定义后台进程——
pg_cron、pg_stat_statements等扩展都基于此。 - 进程模型的代价是每个连接 ~5-10MB
私有内存——
max_connections设太高真正的风险不在 fork,而在work_mem × 连接数 × 操作数的内存爆炸。
系列索引:PostgreSQL 内核机制深度拆解 下一章:页面布局与元组格式,拆解 PG 的 8KB 页面物理布局、Heap Tuple 的 header 结构(xmin/xmax/ctid/t_infomask)、TOAST 外存机制——理解数据如何在磁盘和内存中表示。
参考资料
源码(PG 17)
src/backend/postmaster/postmaster.c:PostmasterMain(), ServerLoop(), BackendStartup(), reaper()src/backend/storage/ipc/ipci.c:CreateSharedMemoryAndSemaphores()src/backend/storage/ipc/procarray.c:ProcArrayAdd(), ProcArrayRemove(), GetSnapshotData()src/backend/storage/ipc/shmem.c:ShmemInitStruct(), ShmemAlloc()src/backend/storage/lmgr/lwlock.c:LWLockInitialize(), LWLockAcquire(), LWLockRelease()src/backend/utils/init/postinit.c:InitPostgres()src/backend/tcop/postgres.c:PostgresMain(), ReadCommand()src/include/storage/proc.h:PGPROC, PGXACT 结构体定义src/include/storage/lwlock.h:LWLock, LWLockPadded 定义src/backend/postmaster/bgworker.c:RegisterBackgroundWorker(), StartBackgroundWorker()
官方文档
- PostgreSQL Documentation, Chapter 18: Server Setup and Operation(连接管理、共享内存配置)
- PostgreSQL Documentation, Chapter 19: Server
Configuration(
max_connections,shared_buffers) - PostgreSQL Documentation, Chapter 50: Overview of PostgreSQL Internals(进程架构概述)
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【PG 内核】锁管理器:从 SpinLock 到死锁检测的三层体系
拆解 PostgreSQL 锁管理器的完整架构:SpinLock 自旋锁的硬件原语与使用边界、LWLock 从 PG 9.4 前到 PG 16 LWLockWaitListLock 的三代演进、Heavyweight Lock 的 LockAcquire() 完整路径和锁表结构、死锁检测 DeadLockCheck() 的等待图 DFS 算法、行级锁 FOR UPDATE/FOR SHARE/FOR KEY SHARE 的 t_infomask 实现,以及用 pg_locks 和 pg_blocking_pids() 追踪生产锁等待链的诊断方法。
【PG 内核】性能异常调查方法论:从现象到内核根因的五层调查链
不是工具箱罗列,而是一条按顺序推进的调查链:从 pg_stat_statements 定位可疑 queryid,到 EXPLAIN (ANALYZE, BUFFERS) 解剖执行计划,到 pg_stat_activity + wait_event 诊断等待类型,到 pg_locks + pg_blocking_pids() 追踪锁等待树,最后用 OS 层工具(iostat/perf/bpftrace)确认物理瓶颈。覆盖三个特殊场景:计划缓存的快慢切换、CPU 100% 无慢查询的 LWLock 自旋根因、命中率 99% 但 IO 打满的统计骗局。
【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 可见性判断的共同前提。
【PG 内核】MVCC 实现:CLOG、hint bit 与快照可扩展性
在已有 MVCC 文章基础上深入 PG 并发控制的三个基础设施:CLOG 的 SLRU 结构(事务状态位、页面格式、SLRU 淘汰)、hint bit 的写入时机和竞争问题(何时写、谁写、写坏了怎么办)、PG 14 snapshot scalability 优化的具体机制(ProcArrayLock 为什么是瓶颈、xid/xmin 的原子更新如何减少持锁路径),以及事务 ID 回卷(wraparound)的威胁模型。最后与 InnoDB undo log 方案做系统性对比。