木匠钉子
Skip to content
博客
返回

PostgreSQL 为什么宁愿重启,也不让共享内存随便长大?

用 pg 的时候,改个 shared_buffers 或者 max_connections,它就让你重启。

每次我都想:这玩意儿真是一点都不”现代”。普通程序内存不够了再 malloc 一块就完事,云服务天天讲弹性伸缩,PostgreSQL 倒好:启动前把共享内存算得死死的,跑起来就别想动。

我一开始的理解很简单:main shared memory 又不能 realloc(),那不就只能重启吗,实现限制呗。

后来发现不对,PostgreSQL 其实有 DSM,Dynamic Shared Memory。这玩意儿可以在运行过程中动态创建共享内存段,其他进程 attach 上去用,用完还能释放。

既然已经能动态创建了,为什么还留着一套启动时定死、改个配置就要重启的老规矩?

这就很有意思了。

多申请一块内存,其实不难

先想想最直接的方案。

共享内存不够?那就再跟操作系统要一块呗:

原来的共享内存不够
        ↓
创建新的共享内存段
        ↓
通知所有 Backend attach
        ↓
继续运行

从操作系统角度看,这当然走得通。DSM 干的事跟这个差不多。

问题在于,这里偷偷换了一个概念。

我们真的想要的,通常不是”多一块能放东西的内存”,而是让一个已经存在、已经被一堆进程一起用的共享数据结构变大。

举个具体的例子。把 max_connections 从 100 改成 200,改的不是数字本身。

PG 需要给更多 Backend 准备进程槽位:PGPROC、ProcArray、锁管理相关结构,还有其他按 MaxBackends 规划好的共享状态,全都要跟着动。

假设现在要把一个正在用的 PGPROC 数组扩大一倍:

  • 原来的数组要不要搬?
  • 手里还攥着旧指针和下标在跑的 Backend 怎么办?
  • 新旧数组是不是得同时共存一段时间?
  • 所有进程怎么知道该切到新数组了?
  • 万一有 Backend 一直没跑到切换点呢?
  • 扩容途中正好有个进程崩了,系统该信哪一份状态?
  • 其他同样依赖这个容量上限的数据结构,要不要一起迁?

看到这应该就懂了:“多申请一块内存”已经不是主要矛盾了。

共享内存扩容,和共享状态扩容,根本是两回事。

前者是资源分配,后者是一次在线迁移。

这不是 realloc(),是一次在线升级

如果真要让核心共享状态在运行中扩容,流程大概是这样:

创建更大的新结构
        ↓
复制或迁移旧状态
        ↓
新旧结构暂时共存
        ↓
通知所有 Backend 切换
        ↓
确认旧结构已经没人用了
        ↓
回收旧结构

有没有觉得这已经有点像在线改表结构了?或者说,在给一个正在跑的系统升级内部协议。

PG 的 Backend 虽然通常在同一台机器上跑,但它们毕竟是不同的进程:各有各的虚拟地址空间,各有各的执行进度,各自可能独立遇到 ERROR、被干掉、或者在某个等待点卡住。

所以让所有 Backend 从旧共享状态切到新共享状态,本质就是一个多参与者协调问题。

需要考虑版本、迁移、确认、失败恢复、旧引用什么时候才能真正失效。如果迁移对象之间还有复杂的引用关系,嗯,更有意思了(大概就是多两周加班排查问题的那种意思)。

所以 main shared memory 的动态扩容根本不是给 allocator 加个增长函数,而是要给 PostgreSQL 增加一套核心状态的在线迁移协议。

这套协议当然可以设计。但值不值得呢?那就是另一个问题了。

PG 选择把麻烦留在开门营业之前

PostgreSQL 选了另一条路。

在普通 Backend 开始干活之前,postmaster 先把配置读了,加载需要预加载的扩展,让各个核心子系统报一下各自的共享内存需求,然后统一算总大小,创建 main shared memory,最后按确定的顺序初始化里面的共享状态。

你可以理解成:

收集所有人的需求
        ↓
算清楚需要多大的场地
        ↓
一次性把桌椅摆好
        ↓
确认基础设施就绪
        ↓
开门营业

服务器开始接受连接以后,这块 main shared memory 的总大小就是个硬边界了。

代价是什么呢?改某些参数必须重启,容量得提前规划。留少了不够用,留多了浪费。

但换来一个很重要的性质:

所有 Backend 从一开始就在同一份共享状态契约上工作。

系统不需要每次访问 PGPROC、锁表或者其他核心状态时,都纠结一遍:

  • 当前用的是第几代结构?
  • 这块状态是不是正在迁移?
  • 手里的地址还有效吗?
  • 旧版本能回收了吗?

如果配置不合法、扩展申请太多、或者操作系统给不出足够的资源,启动就失败。

启动失败当然挺糟心,但至少很干净。系统不会走到”一半 Backend 已经切换了,另一半还在用旧结构”这种中间状态。

PG 没有消除容量规划,只是把容量规划放在了启动之前;也没有消除失败的可能,只是尽量让失败发生在系统开始提供服务之前。

但 PG 并不反对动态共享内存

听到这里,你可能会觉得 PG 就是选了套保守的固定方案。

但它还有 DSM。

DSM 可以在运行过程中创建。创建者拿到一个 handle,其他进程通过它 attach 到同一块共享内存。等活干完了,这块内存可以 detach 和回收。

这适合另一类状态。

比如一个并行任务临时起了几个 Worker。它们只需要在这次任务期间共享数据,任务结束以后这些状态就没用了。

为这种状态在启动时就永久预留空间显然不合理:

  • 它什么时候出现根本不确定;
  • 大小跟具体任务相关;
  • 只有少部分参与进程需要访问;
  • 用完释放确实有收益。

这正是 DSM 的发光发热的地方。

所以,PostgreSQL 不是做不到动态共享内存。它只是没有把所有共享状态都塞进 DSM。

动态不是免费的

DSM 看起来比传统共享内存灵活多了,不过这份灵活需要一套额外的生命周期协议来换。

假设有这么一个场景:

Leader 创建 DSM
Worker A attach
Worker B attach

然后:
Leader 遇到 ERROR
Worker A 正常退出
Worker B 还在用共享状态

现在怎么办?

创建者 Leader 已经退了,但 Worker B 还在访问它,所以”创建者退出”不等于”资源可以释放”。

Worker A detach 了,也不能证明它是最后一个使用者。

如果某个进程希望这块 DSM 在自己退出后继续存活,还得 pin。ERROR 发生时,要有人负责清理当前进程持有的 mapping。postmaster 异常退出以后,残留的共享内存段也要有对应的清理逻辑。

这时候你会发现,动态资源真正的麻烦从来不是创建,而是判断:

最后一个合法使用者真的走了吗?

DSM 所以不只是”运行时再申请一块共享内存”那么简单。它还要处理 handle、attach、detach、引用关系、pin、资源所有权、异常清理。

更要命的是,创建者、使用者、所有者和最终回收者,可能根本不是同一个进程。

main shared memory 付的主要是启动期规划的成本;DSM 付的则是运行期协调和回收的成本。

把状态放进 DSM,也不会自动变弹性

还有一个容易搞混的地方。

DSM 能动态创建新的 segment,不代表放进里面的数据结构就自动支持动态扩容了。

就算把 PGPROC 数组挪到 DSM 里,数组满了以后,同样的问题照样找上门:

  • 创建更大的数组;
  • 迁移已有状态;
  • 让所有 Backend 切换;
  • 等旧引用消失;
  • 安全回收旧数组。

DSM 解决的是共享内存段的动态生命周期,并没有替数据结构解决在线迁移。

就像把一个固定长度数组从栈上搬到堆上,位置是灵活了,但数组本身并不会因此突然学会无痛扩容。

所以,“既然有 DSM,为什么不用它解决 main shared memory 扩容”这个问题里,混了两种不同的动态性:

  • 内存容器能不能在运行时创建;
  • 已有共享状态能不能在运行时改变布局。

PostgreSQL 已经支持前者,而后者仍然是一套贵得多的系统协议。

两种共享内存,两种责任

与其把 main shared memory 和 DSM 理解成”静态方案”和”动态方案”,我更愿意把它们理解成两种不同的责任。

main shared memory 更像一份启动契约。

从服务器启动就存在,承载几乎所有 Backend 都要依赖的基础状态,生命周期跟整个实例差不多长。它接受提前规划和改配置必须重启,换来运行期间稳定又简单的访问边界。

DSM 更像一份运行期租约。

由某个任务或服务按需创建,只有部分进程参与,生命周期可以独立结束。它获得了灵活性,但必须认真处理谁可以用、什么时候退出、出错以后谁来清理。

当一个对象从启动到退出一直存在,所有 Backend 都要访问,也没有独立释放的价值,为它增加动态创建和销毁的能力,未必有多少收益,反而会引入一套新的生命周期协议。

当一个对象只属于某次任务,参与者有限,大小难以提前预测,结束后释放确实有收益,DSM 的复杂度才值得支付。

PG 没有选出一套”能力最强”的共享内存机制,然后强迫所有状态用它。它做的是让不同状态承担跟自身生命周期相匹配的复杂度。

最后

回到最开始的问题:PostgreSQL 为什么宁愿重启,也不让共享内存随便长大?

表面上的答案是,main shared memory 启动时一次性确定,跑起来以后不能像普通堆内存一样扩展。

但更准确的答案是:这些配置改变的不只是一块内存的大小,而是所有 Backend 共同依赖的状态布局。

PG 选择在启动前建好这份布局,让运行期间不必承担核心共享状态在线迁移的复杂度。对于确实需要动态创建和释放的状态,它又提供了 DSM。

所以,main shared memory 和 DSM 不是落后方案和先进方案,也不是二选一的关系。

一个负责固定系统运行所需的前提,一个负责容纳运行过程中真正值得变化的部分。

PostgreSQL 并不是拒绝变化。它只是希望我们先证明:这个变化,值得付出相应的代价。

下次再看到某个参数改了以后要重启,我大概不会脱口而出”这也太落后了吧”。

更值得问的是:

它改变的究竟是一块资源,还是所有进程共同依赖的一份契约?