DeepSeek总结的Postgres 扩展天花板:当一个实例试图包揽一切时

原文链接:https://www.pgedge.com/blog/the-scaling-ceiling-when-one-postgres-instance-tries-to-be-everything


标题:扩展天花板:当一个 Postgres 实例试图包揽一切时

作者:Shaun Thomas | 2026年4月24日

数据库领域一直存在一种观念,认为垂直扩展能解决所有问题。需要更高吞吐量?增加 CPU。缓存不足?增加内存。查询命中磁盘?提高 IOPS。这是一种令人欣慰的理念,因为它很简单,而且在相当长的时间内,它确实有效。一个强大的 Postgres 单实例在不堪重负之前,能够承受巨大的压力。

但其上存在一个天花板,而这个天花板并非由硬件构成。Postgres 被设计为一个单实例数据库引擎,其许多内部结构在该实例包含的所有数据库之间共享。在单个适度负载的实例中,这些共享资源很少会引起关注。但是,当二十个数据库混合运行着繁重的 OLTP 工作负载、分析查询,甚至大部分处于空闲状态时,这些内部组件的共享特性就变得至关重要了。

让我们来谈谈这些过度配置的实例最终会遇到的障碍,并适当引用 Postgres 源代码本身作为佐证。其中一些广为人知,而另一些则是在凌晨 2 点所有监控仪表盘同时变红时突然爆发的问题。

一池统万方

shared_buffers 参数可能是每个 Postgres 管理员遇到的第一个可调参数。它控制着 Postgres 自身缓冲区缓存的大小,这是一块共享内存区域,用于存放频繁访问的磁盘页面,这样就不必在每次读取时都从存储中获取。文档建议从系统 RAM 的 25% 开始设置,这对于单数据库实例来说是合理的建议。多数专家也认同这一点。

人们很容易忘记,这个分配是实例级别的。src/backend/storage/buffer/buf_init.c 文件的内容证实了这一点,缓冲池在启动时作为共享内存中的一个页面平面数组一次性分配:

c 复制代码
BufferBlocks = (char *)
    TYPEALIGN(PG_IO_ALIGN_SIZE,
              ShmemInitStruct("Buffer Blocks",
                              NBuffers * (Size) BLCKSZ + PG_IO_ALIGN_SIZE,
                              &foundBufs));

没有按数据库分区,没有优先级系统,也没有预留机制。实例上的每个数据库都在同一个池中竞争相同的页面。一个数据库中扫描 500GB 表的分析查询,会愉快地驱逐属于另一个数据库中延迟敏感的 OLTP 工作负载的缓存页面。缓冲区替换算法(一种时钟扫描 LRU 变体)没有"此页面属于重要数据库"的概念。

操作系统层面也是如此。内核的文件系统缓存(在 Postgres 圈子中常被称为"双缓冲",因为 effective_cache_size 会将其计算在内)也在机器上的所有进程之间共享。两个具有根本不同访问模式的数据库(一个进行顺序扫描,另一个进行随机索引查找)会相互冲击对方的缓存页面,且无法干预。

投入更多内存能解决问题吗?只有在最大的工作集发生碰撞之前才有效。到那时,它就会成为"嘈杂邻居"问题最糟糕的例子。

32 位的传送带

Postgres 事务 ID (XID) 的 32 位特性,如今几乎成了一个老生常谈的笑话。警告关于可怕的"XID 回卷"的博客比比皆是。Postgres 对此的修复方法是 VACUUM,特别是 VACUUM FREEZE 操作。大多数元组都有一个关联的 XID,但由于 XID 数量有限,超过某个"水位线"的元组会被"冻结"。冻结的元组仍然有一个 XID,但 Postgres 会忽略它,并将数据视为一直存在。于是神奇地,那个 40 亿的事务窗口只关心"最近"的事务("最近"的定义因情况而异)。

不幸的是,这个计数器是跨整个实例的。在 src/backend/access/transam/varsup.c 文件中,GetNewTransactionId() 函数从一个单一的全局池中获取 ID:

c 复制代码
if (TransactionIdFollowsOrEquals(xid, TransamVariables->xidVacLimit))
{
    /* ... */
    if (IsUnderPostmaster &&
        TransactionIdFollowsOrEquals(xid, xidStopLimit))
    {
        ereport(ERROR,
                (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED),
                 errmsg("database is not accepting commands that assign "
                        "new transaction IDs to avoid wraparound data "
                        "loss in database \"%s\"",
                        oldest_datname),
                 errhint("Execute a database-wide VACUUM in that database.")));
    }
}

请仔细阅读该错误消息。该实例拒绝所有新事务以保护某个特定的数据库。几十个数据库中,一个被忽视的数据库可能会累积足够的 XID 年龄,导致整个实例停止运行。每个租户都会受到影响,仅仅因为一个数据库没有及时被清理,或者某个资源人为地持有一个可见元组过久而使其无法被清理。

同一文件中的 SetTransactionIdLimit() 函数更明确地说明了这一点。它基于"我们集群中任何数据库可能存在的最旧 XID"来计算回卷危险阈值。一个数据库的冻结 XID 年龄成为了共享该实例的所有其他数据库的约束。

Multixact:另一种回卷

如果说 XID 回卷是 Postgres 众所周知的"恶棍",那么 multixact 回卷就是那个晦涩难懂的威胁。Multixact 的存在是为了跟踪共享的行级锁;当多个事务持有同一行上的锁时,Postgres 会将它们记录为一个"multixact"组,而不是单独存储每个锁。与 XID 一样,multixact ID 也是 32 位计数器,会发生回卷,并且与 XID 一样,它们也是实例范围的。

但是,成员存储(即记录哪些事务参与每个 multixact 的实际数据)有其自身的严重限制。src/backend/access/transam/multixact.c 中的源代码以典型的 Postgres 风格详细说明了其磁盘布局:

c 复制代码
/*
 * ...我们存储四个字节的标志,然后是对应的4个XID。
 * 每个这样的5字(20字节)组我们称之为一个"组",
 * 并作为一个整体存储在页面中。因此,使用8kB的BLCKSZ,
 * 每页可存放409个组。每页浪费12个字节,但这没关系------
 * 简单性(和性能)胜过空间效率。
 */
#define MULTIXACT_FLAGBYTES_PER_GROUP       4
#define MULTIXACT_MEMBERS_PER_MEMBERGROUP   \
    (MULTIXACT_FLAGBYTES_PER_GROUP * MXACT_MEMBER_FLAGS_PER_BYTE)
#define MULTIXACT_MEMBERGROUP_SIZE \
    (sizeof(TransactionId) * MULTIXACT_MEMBERS_PER_MEMBERGROUP \
     + MULTIXACT_FLAGBYTES_PER_GROUP)
#define MULTIXACT_MEMBERGROUPS_PER_PAGE \
    (BLCKSZ / MULTIXACT_MEMBERGROUP_SIZE)

计算很简单,但影响却很严重。每 8KB 页面有 409 个组,每组有 4 个 XID,我们可以计算出总的 SLRU 地址空间:2^32 个成员偏移量除以每页 1636 个成员,再乘以每页 8KB。整个实例的 multixact 成员存储空间大约为 21GB。

这 21GB 的上限听起来可能很宽裕,但考虑到具有激进行级锁定的多租户设置时就不一定了。一个对许多行执行 SELECT ... FOR UPDATE 的工作负载,或者任何导致多个事务在同一元组上持有共享锁的应用程序模式,都会迅速消耗 multixact 成员。一旦耗尽,实例就会像 XID 回卷一样开始拒绝操作,只不过大多数环境中对 multixact 使用情况的监控远未成熟。

更糟糕的是,同样的"最慢数据库胜出"的动态也适用。所有数据库中的全局最小值决定了 SLRU 何时可以被截断。一个对 multixact-heavy 表清理不足的数据库可以为整个实例固定住这个最小值。类似地,仅仅因为不寻常或激进的锁定行为,一个数据库就可能贪婪地垄断这种宝贵资源。

WAL 重放的单车道高速公路

Postgres 流复制的工作原理是将预写日志 (WAL) 记录从主库发送到从库,从库随后重放这些记录以保持同步。这是一个实用且可靠的工作马,但存在一个基本限制:重放是单线程的。

src/backend/access/transam/xlogrecovery.c 文件中,在从库上处理 WAL 的主重做循环正如其看起来那样:

c 复制代码
/*
 * 主重做应用循环
 */
do
{
    ProcessStartupProcInterrupts();
 
    /* ... 暂停检查、恢复目标检查 ... */
 
    /*
     * 应用记录
     */
    ApplyWalRecord(xlogreader, record, &replayTLI);
 
    /* 否则,尝试获取下一条WAL记录 */
    record = ReadRecord(xlogprefetcher, LOG, false, replayTLI);
} while (record != NULL);
 
/*
 * 主重做应用循环结束
 */

一次一条记录,顺序执行,在单个进程中。启动进程(负责 WAL 恢复)是从库上 WAL 数据的唯一消费者。没有并行应用。即使一台超配的 128 核机器作为从库,也只能利用单个核心来处理 WAL 数据。

recovery_prefetch 参数(自 Postgres 15 起默认为 try)在瓶颈是 IO 时会有所帮助。它会向前查看 WAL 流,并为即将需要的页面发起异步读取,从而减少由冷缓存命中引起的停顿。src/backend/access/transam/xlogprefetcher.c 中的预取器文档将其描述为"XLogReader 的替代品,通过向前查看 WAL 来最大限度地减少 IO 停顿"。

但是,如果主库生成 WAL 的速度超过了单个核心的处理能力,预取就无济于事了。瓶颈会从 IO 转移到 CPU,并且无处可去。一个写操作繁重且有许多并发后台进程的主库,其生成 WAL 的速度在结构上可能会超过单个重放进程的消费能力。从库会开始落后,并且在持续负载下差距只会越来越大。我曾亲眼目睹一个从库上的这个进程在数小时内 CPU 使用率一直保持在 100%,而复制延迟仍在继续累积。

这在多数据库实例中尤其痛苦。每个数据库的 WAL 都通过同一个单线程漏斗。一个数据库中的批量导入会产生大量的 WAL,从而延迟另一个数据库中关键事务的重放。在单独的实例上,每个数据库都有自己的从库和独立的重放进程------不再有一个繁忙数据库导致的级联延迟。

单例瓶颈大队列

除了上述大问题之外,Postgres 还运行着几个后台进程,每个进程都是服务于整个实例的单一工作进程。单独来看,它们很少成为问题。但合在一起,它们就形成了一列潜在的瓶颈"车队"。

Autovacuum 有一个共享的工作进程池,默认最大数量为 3(由 autovacuum_max_workers 控制)。src/backend/postmaster/autovacuum.c 中的启动器进程会在实例的所有数据库之间调度这些工作进程。在一个有十个数据库和三个工作进程的实例中,几个变更频繁的数据库可能会独占整个池子,而其他数据库则会积累死元组和 XID 年龄。这种 autovacuum"饥饿"问题直接导致了前面讨论的 XID 和 multixact 回卷风险。

当然,可以提高 autovacuum_max_workers,但这些工作进程与应用程序的后台进程共享相同的 CPU 预算。我们需要多少工作进程来满足所有数据库的需求?不可能将工作进程分配给特定的数据库,所以这个问题永远不会真正消失,只是变得不太可能发生。独立的实例将确保每个数据库获得自己全套的 autovacuum 工作进程,无需竞争。

检查点进程 (checkpointer) 是一个单独的进程,负责在检查点间隔将脏缓冲区刷新到磁盘。由一个数据库的繁重写入活动触发的检查点,会强制刷新整个实例中的所有脏页,包括被其他数据库弄脏的页面。大型检查点引起的 IO 风暴会导致每个租户的延迟峰值,而不仅仅是触发它的那个租户。

后台写进程 (background writer) 也是一个单独的进程,它持续将脏共享缓冲区写入磁盘,以保持有可用的干净页面。它管理着整个共享缓冲池,其速度由实例级别的设置(如 bgwriter_lru_maxpagesbgwriter_delay)控制。没有办法优先处理一个数据库的脏页而不是另一个数据库的。

附带损害

也许,将所有数据库塞进一个实例的最直接论据就是故障的"爆炸半径"。当一个 Postgres 实例宕机时,无论是由于崩溃、OOM kill、内核恐慌,还是仅仅是有计划的维护,该实例上的每个数据库都会随之宕掉。

Postmaster 将许多故障模式视为可能导致共享内存损坏的情况。单个后台进程的崩溃会触发完整的重启周期并终止所有用户会话。检查点进程代码中的这条注释体现了这种理念:

"如果检查点进程意外退出,postmaster 会将其视为后端崩溃:共享内存可能已损坏,因此应终止其余的后端进程。"

维护窗口会使问题更加复杂。Postgres 主版本升级、扩展更新,甚至需要重启的配置更改,都会同时影响所有租户。协调具有不同 SLA、不同高峰期以及对中断不同容忍度的多个团队之间的停机时间,是一个组织上的难题,其复杂程度会随着数据库数量的增加呈几何级数(甚至更糟)增长。

然后是可怕的紧急清理。如果一个数据库接近 XID 回卷,Postgres 将拒绝所有数据库的事务(正如我们在 varsup.c 中看到的那样)。一个数据库上的紧急维护任务现在变成了所有人面临的高严重性停机事件。一个被遗忘的 cron 作业或一个卡住的长期运行事务的"爆炸半径"刚刚扩大到了整个数据层。

分化原子

解决这些问题的方法,也许与直觉相反,不是更强大的硬件,而是更多的实例。把同一台物理机器,将其分割成虚拟环境(虚拟机、容器,甚至只是在不同端口上运行多个 Postgres 安装),然后每个实例运行一个数据库。

会发生什么变化?让我们看看......

  • 每个实例都有自己的 shared_buffers,并根据其工作负载进行适当的大小调整。一个 OLTP 数据库可以拥有一个大型的热缓冲池,而一个分析型数据库则获得一个较小的、针对文件系统缓存访问进行调整的缓冲池。不再有不兼容访问模式之间的缓冲区争用。
  • 事务 ID 变为每个实例的。一个数据库的清理债务无法将其他数据库拖入回卷区域。这同样适用于 multixact 成员;那 21GB 的上限现在仅适用于单个工作负载,而不是所有租户的总和。
  • WAL 重放是每个实例的。一个写操作繁重的数据库生成的 WAL 只需要它自己的从库进行重放。一个延迟敏感的 OLTP 从库无需在属于完全不同数据库的批量导入的 WAL 记录后面等待。
  • Autovacuum 工作进程、检查点进程和后台写进程各自服务于单个数据库。不再有饥饿问题,不再有共享的检查点风暴,不再有"一刀切"的后台写进程 pacing。
  • 故障变得孤立。一个实例中的崩溃对其他实例是不可见的。维护窗口可以独立安排。紧急清理不会引发跨租户的事故。

代价是操作复杂性。更多的实例意味着需要管理更多的配置,维护更多的备份计划,监控更多的仪表盘。但是,借助现代基础设施工具(Ansible, Terraform, Kubernetes operators),增加一个 Postgres 实例的边际成本,与调试一次紧急的多租户资源耗尽事件的成本相比是很低的。

知道何时退出

垂直扩展是一种完全有效的策略,并且有充分的理由表明,许多 Postgres 安装在一个大型单实例上运行得很愉快。对于中等工作负载,Postgres 内部组件的共享特性不仅是可接受的,而且是高效的。共享内存、共享进程、共享缓存:当工作负载能够良好配合时,它们都能减少开销。

问题始于"良好配合"不再能保证的时候。具有根本不同 I/O 特征、清理需求、可用性 SLA、活动模式以及其他方面的数据库,并不总能很好地混合在一起。资源不再是高效利用,而是变得争用激烈。再多的 RAM、CPU 或存储也无法解决这个问题,因为瓶颈是架构性的。

信号通常在开始时是微妙的。Autovacuum 无法跟上所有数据库的需求。在某个不相关数据库执行批量作业期间,从库延迟增加。检查点持续时间逐渐变长。日志中出现没有人配置警报的 Multixact 警告。等到 XID 回卷威胁要锁定整个实例时,通常已经出现了许多其他迹象,只是没有被注意到。社区中的许多人之所以认为多数据库实例是一种反模式,是有原因的;共享的资源同时也是共享的节流阀。

因此,如果你正盯着一个托管着越来越多数据库(或者越来越少但规模非常大的数据库)的单一 Postgres 实例,请仔细审视其共享的内部组件。阅读源代码。计算你的 multixact 空间余量。检查你的 autovacuum 工作进程是否在每个数据库上都跟上了进度,而不仅仅是你正在关注的那些。如果这些数字开始显得令人不安,请考虑在进行拆分,不要等到万不得已。

计划迁移总比在事故期间执行迁移要容易得多。


相关推荐
我要升天!2 小时前
C语言连接 MySQL:libmysqlclient 获取方式详解
c语言·开发语言·数据库·mysql·adb
roman_日积跬步-终至千里3 小时前
【系统架构师案例题-知识点】数据库与缓存设计
数据库·缓存·系统架构
不剪发的Tony老师3 小时前
DBcooper:一款面向开发者的现代数据库客户端
数据库·sql
添砖java‘’4 小时前
MYSQL数据类型
数据库·mysql
qq_372154234 小时前
如何配置表中某列的排序权重_全文索引配置与权重分配
jvm·数据库·python
2501_914245934 小时前
CSS如何使用-nth-of-type精确选择列表项_通过元素类型限制提升样式健壮性
jvm·数据库·python
吕源林4 小时前
Golang如何做本地缓存加速_Golang本地缓存教程【核心】
jvm·数据库·python
Magic@4 小时前
Redis学习[1] ——基本概念和数据类型
linux·开发语言·数据库·c++·redis·学习
你觉得脆皮鸡好吃吗4 小时前
SQL注入 基础防御
数据库·sql