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文件的内容证实了这一点缓冲池在启动时作为共享内存中的一个页面平面数组一次性分配BufferBlocks(char*)TYPEALIGN(PG_IO_ALIGN_SIZE,ShmemInitStruct(Buffer Blocks,NBuffers*(Size)BLCKSZPG_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()函数从一个单一的全局池中获取 IDif(TransactionIdFollowsOrEquals(xid,TransamVariables-xidVacLimit)){/* ... */if(IsUnderPostmasterTransactionIdFollowsOrEquals(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 风格详细说明了其磁盘布局/* * ...我们存储四个字节的标志然后是对应的4个XID。 * 每个这样的5字20字节组我们称之为一个“组” * 并作为一个整体存储在页面中。因此使用8kB的BLCKSZ * 每页可存放409个组。每页浪费12个字节但这没关系—— * 简单性和性能胜过空间效率。 */#defineMULTIXACT_FLAGBYTES_PER_GROUP4#defineMULTIXACT_MEMBERS_PER_MEMBERGROUP\(MULTIXACT_FLAGBYTES_PER_GROUP*MXACT_MEMBER_FLAGS_PER_BYTE)#defineMULTIXACT_MEMBERGROUP_SIZE\(sizeof(TransactionId)*MULTIXACT_MEMBERS_PER_MEMBERGROUP\MULTIXACT_FLAGBYTES_PER_GROUP)#defineMULTIXACT_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 的主重做循环正如其看起来那样/* * 主重做应用循环 */do{ProcessStartupProcInterrupts();/* ... 暂停检查、恢复目标检查 ... *//* * 应用记录 */ApplyWalRecord(xlogreader,record,replayTLI);/* 否则尝试获取下一条WAL记录 */recordReadRecord(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_maxpages和bgwriter_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 工作进程是否在每个数据库上都跟上了进度而不仅仅是你正在关注的那些。如果这些数字开始显得令人不安请考虑在进行拆分不要等到万不得已。计划迁移总比在事故期间执行迁移要容易得多。