海山数据库进程管理:Postmaster 架构与实现深度剖析
注:本文涉及的源码都以 PostgreSQL 15 版本为例说明。
海山数据库采用多进程架构,每个客户端连接、后台任务都是独立的操作系统进程。这种设计带来了进程隔离、故障隔离的优势,但也引入了进程管理的复杂性:如何统一监控所有子进程?如何检测子进程崩溃?如何协调系统启动和关闭?这一切的核心是 Postmaster —— 海山数据库的进程管理器和系统协调者。本文将深入剖析 Postmaster 的架构设计、实现细节和关键技术决策。
进程管理的必要性
在深入实现之前,让我们先理解为什么海山数据库需要一个专门的进程管理器。如果没有 Postmaster,每个进程都独立管理自己,会带来一系列问题:
-
资源泄漏问题:子进程死亡后,如果没有父进程及时调用 waitpid() 回收,会变成僵尸进程,占用进程号。长期运行会导致 PID 耗尽,系统无法创建新进程。
-
崩溃感知问题:某个 Backend 进程崩溃了(如段错误),其他进程如何知道?如果没有统一的监控机制,崩溃可能被忽略,导致数据损坏持续扩散。
-
关闭协调问题:关闭数据库时,如何确保所有进程都优雅退出?如果进程正在处理查询,直接杀死会导致数据不一致。
海山数据库的解决方案是 Postmaster —— 海山数据库进程树的根进程,其负责:
-
启动时初始化系统:创建监听 socket、初始化进程间通信机制
-
为客户端连接分配 Backend 进程:每个客户端连接对应一个独立的 Backend
-
监控所有子进程的生命周期:通过 SIGCHLD 信号检测子进程死亡
-
协调系统关闭:确保所有进程优雅退出,数据落盘
进程管理架构
海山数据库的进程架构是"一个 Postmaster + N 个子进程",但这些子进程并不是一视同仁的。根据职责不同,Postmaster 用三种不同的方式管理它们:
Postmaster 进程管理的三层架构:
第一层:全局变量管理 (辅助进程)
├─ StartupPID → Startup Process
│ └─ 崩溃恢复进程,读取 WAL 日志将数据库恢复到一致状态
├─ CheckpointerPID → Checkpointer
│ └─ 执行检查点,将共享缓冲区的脏页写入磁盘,确保数据持久化
├─ BgWriterPID → Background Writer
│ └─ 后台定期将脏页写入磁盘,减少检查点时的 I/O 峰值
├─ WalWriterPID → WAL Writer
│ └─ 将 WAL 日志从内存刷写到磁盘,确保持久性
├─ WalReceiverPID → WAL Receiver
│ └─ WAL 接收进程(备用库),从主库接收 WAL 流并应用到本地
├─ AutoVacPID → AutoVacuum Launcher
│ └─ 自动清理启动器,负责调度 autovacuum worker 回收死元组
├─ PgArchPID → Archiver
│ └─ WAL 归档进程,将已填充的 WAL 日志文件复制到归档存储
├─ SysLoggerPID → System Logger
│ └─ 系统日志进程,收集所有子进程的日志输出并写入日志文件
├─ CleanfilecachePID → Cleanfilecache Leader (海山数据库特有)
│ └─ 清理本地缓存数据进程,控制本地缓存数据量并调整共享表模式
├─ ParallelFlushPID → Parallel Flush Worker (海山数据库特有)
│ └─ 并行回放数据页进程,多进程推进与回放 WAL 日志
└─ CleanLogindexPID → Clean Logindex To LSN (海山数据库特有)
└─ 清理并行回放链表进程,回收 Page 与 WAL 日志的链表关系
第二层:BackendList (双向链表)
└─ dlist_head 双向链表
├─ Backend 1 (客户端连接 A)
├─ Backend 2 (客户端连接 B)
└─ Backend 3 (WAL Sender)
└─ 流复制发送进程(主库),将 WAL 日志发送到备库
第三层:BackgroundWorkerList (单向链表)
└─ slist_head 单向链表
├─ RegisteredBgWorker 1 (Parallel Query Worker)
│ └─ 并行查询执行,利用多核 CPU 加速查询
├─ RegisteredBgWorker 2 (Logical Replication Worker)
│ └─ 逻辑复制 worker,捕获逻辑变更并应用到订阅端
└─ RegisteredBgWorker 3 (用户自定义扩展)
└─ 通过 bgworker APIs 注册的自定义后台任务
海山数据库在标准 PostgreSQL 基础上,针对共享存储架构引入了三个专用辅助进程,解决多节点共享存储的特殊问题:
- Cleanfilecache Leader:本地缓存管理。
• 当本地缓存超过预设大小时,清理可回收的缓存数据。
• 根据用户需求调整实例的共享模式(共享表 vs 非共享表)。
- Parallel Flush Worker:并行 WAL 回放。
• 传统单进程回放 → 改为多进程并行推进与回放。
• 充分利用多核 CPU,提升备机回放速度。
- Clean Logindex To LSN:回放链表回收。
• 回收并行回放后 Page 与 WAL 日志的链表关系。
• 与 Parallel Flush Worker 配套使用,确保资源及时释放。
这三个进程协同工作,使海山数据库在共享存储架构下实现高性能的主备同步。
为什么不统一用一种数据结构管理所有进程?因为每类进程有不同的特征:
• 辅助进程(Checkpointer、BgWriter 等):系统级服务,每个类型通常只有一个实例。用全局变量管理,O(1) 访问时间。
• Backend:动态数量,随客户端连接变化。需要频繁增删,双向链表支持高效插入/删除。
• Background Worker:用户可扩展的后台任务(如并行查询、逻辑复制)。需要存储额外元数据(注册信息、崩溃时间)。
这种"按需管理"的设计体现了海山数据库的工程哲学:不同场景使用不同数据结构,权衡访问时间、内存开销和代码复杂度。理解了三层架构, 接下来详解核心的数据结构。
核心数据结构
Backend 结构体
每个 Backend 进程在 Postmaster 中都有一个对应的 Backend 结构体:
// src/backend/postmaster/postmaster.c
typedef struct Backend
{
pid_t pid; // 进程 PID
int32 cancel_key; // 取消密钥
int child_slot; // PMChildSlot 索引
int bkend_type; // Backend 类型
bool dead_end; // 死端进程标志
bool bgworker_notify; // Background Worker 通知
dlist_node elem; // 双向链表节点
} Backend;
static dlist_head BackendList; // 双向链表头
关键字段解析:
• pid:进程的 PID。Postmaster 通过它发送信号(如 SIGTERM、SIGQUIT)来控制进程。
• cancel_key:取消密钥。客户端用这个密钥可以取消正在执行的查询。Postmaster 验证密钥后代为发送 SIGINT。
• child_slot:Postmaster 内部的槽位索引(1 到 N),用于追踪子进程是否正确清理了共享内存资源。
• bkend_type:Backend 类型(NORMAL/AUTOVAC/WALSND/BGWORKER)。
• NORMAL:普通客户端连接,执行 SQL 查询。
• AUTOVAC:自动清理进程,回收死元组防止事务 ID 回卷。
• WALSND:WAL Sender(流复制发送进程),将主库 WAL 发送到备库。
• BGWORKER:后台工作进程(如并行查询 worker、逻辑复制 worker)。
• dead_end:是否为死端进程(只发送错误消息后退出)。
• elem:双向链表节点,用于链入 BackendList。
BackendList:双向链表管理
Postmaster 使用双向链表 BackendList 管理所有 Backend 进程:
-
动态进程追踪:管理所有客户端连接、autovacuum worker、WalSender 等动态创建的进程。
-
快速查找和删除:双向链表支持 O(1) 的插入和删除操作。
-
遍历信号发送:崩溃时遍历所有 Backend 发送 SIGQUIT。
BackednList的典型操作如下(src/backend/postmaster/postmaster.c):
// 添加 Backend(fork 成功后)
static bool assign_backendlist_entry(RegisteredBgWorker *rw)
{
Backend *bn;
bn = malloc(sizeof(Backend));
bn->pid = worker_pid;
bn->child_slot = MyPMChildSlot;
bn->bkend_type = BACKEND_TYPE_BGWORKER;
// 加入双向链表头部 - O(1)
dlist_push_head(&BackendList, &bn->elem);
rw->rw_backend = bn;
return true;
}
// 删除 Backend(进程退出时)
dlist_foreach_modify(iter, &BackendList)
{
Backend *bp = dlist_container(Backend, elem, iter.cur);
if (bp->pid == dead_pid)
{
// 从双向链表删除 - O(1)
dlist_delete(iter.cur);
free(bp);
break;
}
}
// 遍历发送信号(崩溃处理时)
dlist_foreach(iter, &BackendList)
{
Backend *bp = dlist_container(Backend, elem, iter.cur);
if (!bp->dead_end)
{
signal_child(bp->pid, SIGQUIT); // 发送信号
}
}
与辅助进程不同(每个类型只有一个实例,如 CheckpointerPID),Backend 的数量是动态的(随客户端连接变化),无法用固定数量的全局变量管理。双向链表提供了:
• 动态扩容:可以处理任意数量的连接(受 max_connections 限制)。
• 高效管理:插入删除都是 O(1) 操作。
• 简洁代码:一个循环即可遍历所有 Backend。
服务端 Postmaster 启动流程
Postmaster 的生命周期始于 PostmasterMain() 函数。让我们先看整体流程:
调用栈(谁调用了 PostmasterMain):
操作系统内核
└─ main() (src/backend/main/main.c)
└─ PostmasterMain (src/backend/postmaster/postmaster.c:574)
├─ ProcessConfigFile(PGC_POSTMASTER)
├─ pqsignal_pm(SIGCHLD, reaper)
├─ StreamServerPort() // 创建监听 socket
├─ StartChildProcess(StartupProcess)
└─ ServerLoop()
ServerLoop:I/O 多路复用
ServerLoop 使用 select() 实现 I/O 多路复用,监听多个监听 socket:
// src/backend/postmaster/postmaster.c:1727
static void ServerLoop(void)
{
for (;;)
{
// 1. select() 监听所有 ListenSocket
selres = select(ListenSocketCount, ListenSocket,
NULL, NULL, &timeout);
if (selres < 0 && errno == EINTR)
{
// 被信号中断,重新计算状态后继续
goto restart;
}
else if (selres > 0)
{
// 2. 有 socket 可读
for (i = 0; i < ListenSocketCount; i++)
{
if (FD_ISSET(ListenSocket[i], &readfds))
{
Port *port = ConnCreate(ListenSocket[i]);
BackendStartup(port);
}
}
}
// 3. 处理信号、状态机转换
PostmasterStateMachine();
}
}
ListenSocket[] 数组:支持多网卡和 Unix socket。
// ListenSocket[0] = 192.168.1.10:5432
// ListenSocket[1] = 192.168.1.11:5432
// ListenSocket[2] = Unix socket /tmp/.s.PGSQL.5432
理解了启动流程,我们来看 Postmaster 如何处理客户端连接。
客户端 Postgres 连接处理
当客户端连接进来时,Postmaster 会 fork 一个 Backend 进程处理该连接。
Backend 初始化序列
Backend 进程启动后执行一系列初始化:
调用栈(Postmaster 如何启动 Backend):
ServerLoop (src/backend/postmaster/postmaster.c:1727)
└─ BackendStartup (src/backend/postmaster/postmaster.c:4167)
└─ fork_process()
└─ [子进程]
├─ InitPostmasterChild() (src/backend/utils/init/miscinit.c:96)
├─ ClosePostmasterPorts() (src/backend/postmaster/postmaster.c:4230)
├─ BackendInitialize(port) (src/backend/postmaster/postmaster.c:4237)
├─ InitProcess() (src/backend/storage/lmgr/proc.c:301)
└─ BackendRun(port) (src/backend/postmaster/postmaster.c:4243)
└─ PostgresMain(port->database_name, port->user_name)
Backend的初始化的关键点是:
• InitPostmasterChild:设置 IsUnderPostmaster = true,初始化 Latch(进程间通信机制)。
• ClosePostmasterPorts:关闭所有 ListenSocket(子进程不需要监听新连接)。
• ProcessStartupPacket:读取客户端发送的 startup packet(包含 database_name、user_name)。
• InitProcess:从共享内存分配 PGPROC slot。
• BackendRun:调用 PostgresMain,进入查询处理循环。
BackendRun 的 port 参数**。**port 结构体传递客户端连接信息(socket、database_name、user_name),PostgresMain 用这些信息进行认证和查询处理。
理解了正常流程,我们来看异常情况:进程崩溃时如何处理?
进程崩溃检测
当子进程死亡时,内核向父进程发送 SIGCHLD 信号。Postmaster 注册 reaper() 作为 SIGCHLD 处理函数。
waitpid() 如何知道哪个进程死了
答案在于 waitpid() 系统调用的返回值:
// src/backend/postmaster/postmaster.c:2986
static void reaper(SIGNAL_ARGS)
{
int pid;
int exitstatus;
// 循环回收所有僵尸进程
while ((pid = waitpid(-1, &exitstatus, WNOHANG)) > 0)
{
// pid 就是死掉的进程的 PID!
if (pid == StartupPID)
{
// ... 拉起其他辅助进程
}
else if (pid == BgWriterPID)
{
BgWriterPID = 0;
if (!FatalError) // 非 FatalError 状态,重启辅助进程
BgWriterPID = StartBackgroundWriter();
}
// ... 其他辅助进程
else
{
// 不是辅助进程,就是 Backend
HandleChildCrash(pid, exitstatus);
}
}
}
waitpid() 的函数签名:pid_t waitpid(pid_t pid, int *status, int options)。详细参数如下表:
参数 | 值 | 含义 |
pid | -1 | 等待任意子进程 |
status | &exitstatus | 输出参数,返回子进程退出状态 |
options | WNOHANG | 非阻塞:如果没有子进程死亡,立即返回 0 |
返回值语义:(1) > 0:死掉的子进程的 PID;(2) = 0:没有子进程死亡(WNOHANG 模式);(3)-1:出错(如没有子进程)。
循环必要性:如果多个子进程同时死亡(如批量 kill),SIGCHLD 只发送一次(Unix 信号不排队),但 waitpid() 的循环可以回收所有僵尸进程。
进程死亡处理
当进程死亡时,Postmaster 的处理策略截然不同:
(1)辅助进程(如 BgWriter):
if (pid == BgWriterPID)
{
BgWriterPID = 0;
if (!FatalError)
BgWriterPID = StartBackgroundWriter();
}
如果 FatalError == false(系统健康),Postmaster 会立即重启辅助进程。这是因为辅助进程是"基础设施",必须存在。但前提是系统没有致命错误。
(2)Backend 进程:
else
{
HandleChildCrash(pid, exitstatus);
}
Backend 进程死亡不会自动重启,而是调用 HandleChildCrash()。为什么?因为 Backend 处理客户端连接,如果它崩溃可能是因为 SQL 逻辑错误或内存 bug,重启没有意义,客户端应该重新连接。更重要的是,Backend 崩溃可能意味着共享内存已损坏,继续运行会破坏数据一致性。因此 HandleChildCrash 会设置 FatalError = true,阻止所有自动重启,并让系统进入关闭流程。
这个"Fail-Fast"策略是海山数据库高可靠性的关键:宁可整个系统停止,也不允许在可能损坏的状态下继续运行。
HandleChildCrash:崩溃处理的完整流程
当子进程异常退出时,Postmaster 需要快速响应并采取行动。HandleChildCrash() 就是这个紧急响应机制的核心。
整体流程:从崩溃到系统保护的连锁反应
当某个 Backend 崩溃时,HandleChildCrash() 执行一系列精心设计的操作,目标是最大限度保护数据一致性。整体流程可以分为四个阶段:
-
确认崩溃严重性 - 检查退出状态,判断是否需要触发全系统关闭
-
清理死亡进程 - 释放 PMChildSlot,从进程列表中移除
-
通知其他进程 - 向所有存活的进程发送 SIGQUIT,让它们优雅退出
-
进入保护模式 - 设置 FatalError 标志,转换状态机到 PM_WAIT_BACKENDS
这个"连锁反应"设计确保:一旦检测到任何 Backend 崩溃,整个系统迅速进入安全状态,而不是继续运行可能损坏的数据。
调用栈(谁调用了 HandleChildCrash):
内核发送 SIGCHLD
└─ reaper() (src/backend/postmaster/postmaster.c:2986)
└─ HandleChildCrash() (src/backend/postmaster/postmaster.c:3489)
├─ LogChildExit(LOG, procname, pid, exitstatus)
├─ SetQuitSignalReason(PMQUIT_FOR_CRASH)
├─ slist_foreach(&BackgroundWorkerList)
│ └─ ReleasePostmasterChildSlot() + signal_child(..., SIGQUIT)
├─ dlist_foreach_modify(&BackendList)
│ └─ ReleasePostmasterChildSlot() + signal_child(..., SIGQUIT)
├─ signal_child(StartupPID, SIGQUIT)
├─ signal_child(BgWriterPID, SIGQUIT)
├─ signal_child(CheckpointerPID, SIGQUIT)
├─ FatalError = true
├─ pmState = PM_WAIT_BACKENDS
└─ AbortStartTime = time(NULL)
FatalError 防止在损坏状态下继续运行
一旦检测到 Backend 崩溃,Postmaster 会立即设置 FatalError = true,这个标志就像一个"紧急刹车",防止系统在可能损坏的状态下继续运行。
if (Shutdown != ImmediateShutdown)
FatalError = true; // 设置致命错误标志
FatalError 标志控制辅助进程是否自动重启:
// reaper() 中
if (pid == BgWriterPID)
{
BgWriterPID = 0;
if (!FatalError) // 只有 FatalError == false 时才重启
BgWriterPID = StartBackgroundWriter();
}
(1)设置 FatalError 的时机:
-
Backend 异常退出(exit status != 0)- 检测到用户进程崩溃。
-
Checkpointer fork 失败(无法创建 checkpoint 进程)- 系统资源耗尽
(2)清除 FatalError 的时机:
- Startup 进程成功完成恢复(exit status == 0)- 系统恢复到一致状态
这个标志的设计体现了"Fail-Fast"原则:一旦发现异常,立即停止所有操作,避免错误扩散。
结语
Postmaster 本身不处理 SQL 查询,但它是整个海山数据库进程架构的基石。理解了 Postmaster,就理解了海山数据库如何通过精心设计的进程管理、信号处理和状态机,实现高可用性、高可靠性的数据库系统。