返回
RSS ClickHouse Blog AI 逐段翻译 发布 2026-09-28 23:37 收录于 09-29

你的 Postgres 能扛住坏查询吗?

DataHot 速览

该文比较 ClickHouse Managed Postgres、Cloud SQL、PlanetScale 和 Amazon RDS 在递归查询耗尽内存时的表现,关注查询失败与集群存活。文章还解释 Postgres 的 work_mem 默认仅 4MB,且限制按查询计划中的操作而非整条查询生效,实际内存可远超该值。作者认为,在托管 Postgres 选择中,异常负载下的可靠性是一个常被忽略的维度,尤其当数据库由 Agent 自动配置和驱动时。

为什么值得关注:对数据库选型和平台可靠性评估有参考价值:它把“坏查询”下的故障隔离与集群存活作为托管 Postgres 的比较维度,并拆解 work_mem 等内存调优陷阱。

译文

AI 逐段翻译

在2026年,Postgres 提供商并不稀缺,人们可能会困惑该把支撑下一个应用的数据库部署在哪里。当然,可以从性能(我们在这方面做得相当不错)、定价和扩展支持等维度来考虑。一个不那么 prevalent 于时代思潮中的维度是可靠性。

数据库可靠性有很多方面。Postgres 本身固执地可靠。硬件可靠性是一个有趣的关注点,但超大规模云厂商要么提供、要么托管我们今天考察的每一种 Postgres 方案。所以在这方面,这里每个选项的表现都达到了硬件可靠性的极限。

对我来说,一个产品在它本不该承受的工作负载下仍能撑住,才算可靠。虽然我们会进行压力测试来找出 Postgres 方案中的 bug,但客户有时会因为意外而惩罚自己的数据库,因为 Postgres 内存调优并不是一个已解决的问题,而且如今越来越多的 Postgres 数据库完全由 agent 配置和驱动,这意味着“因为意外”这条路径只会更加繁忙。

内存管理与查询调优的流沙

遗憾的是,Postgres 并没有一个设置可以说“一个查询最多只能使用 X MB 内存”。它有的是work_mem(默认4MB),这个上限按每个“操作”生效,而不是按查询生效。计划中每个需要内存的查询节点都有自己的work_mem预算,而一个计划可以同时有多个这样的节点。基于哈希的节点还会通过hash_mem_multiplier额外获得一个预算乘数。Postgres 文档明确指出,实际内存使用“可能是 work_mem 值的许多倍”。

为了展示查询调优能有多棘手,让我们考虑一个使用 Postgres 全部默认设置的数据库上的简单 schema。只有两张表,支撑一个假设的 LLM 推理服务:

1CREATE TABLE wm_api_keys (2  api_key_id uuid         PRIMARY KEY,3  tier       smallintNOT NULL,4  scopes     text[]       NOT NULL,5  expires_at timestamptz,6  created_at timestamptz  NOT NULL7);89CREATE TABLE wm_api_calls (10  call_id     bigintPRIMARY KEY,11  api_key_id  uuid           NOT NULL,12  called_at   timestamptz    NOT NULL,13  model_id    smallintNOT NULL,14  tokens      integerNOT NULL,15  cost_usd    numeric(10,6)  NOT NULL,16  latency_ms  integerNOT NULL17);

除了来自 API 网关的 OLTP 流量外,你的客户还经常访问一个按 key 下钻的控制台视图,该视图由一个 SELECT 语句支撑:

1SELECT c.api_key_id,2sum(c.cost_usd)         AS total_cost_usd,3sum(c.tokens)           AS total_tokens,4count(*)                AS n_calls,5max(c.called_at)        AS last_active_at,6avg(c.latency_ms)::intAS avg_latency_ms7FROM wm_api_calls c JOIN wm_api_keys k USING (api_key_id)8WHERE k.tier IN (0, 1, 2)9GROUPBY c.api_key_id10ORDERBY total_cost_usd DESC;

上线后不久,你有了 2000 个 API key(恭喜!),它们到目前为止总共发起了 30,000 次调用。来自 EXPLAIN (ANALYZE, BUFFERS, VERBOSE) 的计划没什么特别;相关行如下:

1Sort2   Sort Method: quicksort  Memory: 87kB3   ->  HashAggregate4         Batches: 1  Memory Usage: 689kB5         ->  Hash Join6               ->  Seq Scan on wm_api_calls c7               ->  Hash8                     Buckets: 2048  Batches: 1  Memory Usage: 68kB

三个节点消耗内存:Hash(构建侧,68 kB)、HashAggregate(689 kB)和 Sort(87 kB)。这些加起来大约 0.8 MB,轻松低于默认的 work_mem 阈值。

关于内存核算的说明:本节中我们所说的“总内存”是指每个使用内存的节点的峰值之和。Postgres 通常会持有某个节点的内存直到查询结束,所以我们认为这已经足够接近实际情况。

几个月后,你的产品正式爆红。你现在有 55,000 个 API key 和 900 万次 API 调用。运行此查询的控制台页面开始变得沉重,所以你查看了计划:

1Sort2   Sort Method: quicksort  Memory: 2926kB3   ->  Finalize GroupAggregate4         ->  Gather Merge5               Workers Planned: 26               Workers Launched: 27               ->  Sort  (loops=3)8                     Sort Method: external merge  Disk: 4256kB9                       Worker 0: external merge  Disk: 4256kB10                       Worker 1: external merge  Disk: 4248kB11                     ->  Partial HashAggregate  (loops=3)12                           Batches: 5  Memory Usage: 8241kB  Disk Usage: 3408kB13                             Worker 0: Batches: 5  Memory Usage: 8241kB  Disk Usage: 3400kB14                             Worker 1: Batches: 5  Memory Usage: 8241kB  Disk Usage: 3392kB15                           ->  Hash Join16                                 ->  Parallel Seq Scan on wm_api_calls c17                                 ->  Hash18                                       Buckets: 32768  Batches: 1  Memory Usage: 1671kB

仅仅因为要处理的数据更多,就发生了两处变化。wm_api_calls 现在磁盘上超过 1 GB,远远超过 min_parallel_table_scan_size(默认 8 MB),规划器引入了两个后台 worker 来加速查询执行。现在有三个进程运行 Gather Merge 下方的部分子树:两个 worker 加上 leader,而 leader 默认也充当 worker。每个进程都会为 Gather Merge 下方的每个节点构建自己的副本,包括 join 的 Hash(1671 kB × 3 个进程 = 总共 5 MB)。这里的要点是,每个 worker 都有自己的使用内存的节点,并且它们有自己的内存预算。所以增加并行 worker 给我们的内存使用增加了一个不透明的约 3 倍乘数。

其次,溢出。Partial HashAggregate 在每个 worker 中显示 Memory Usage: 8241kB Disk Usage: 3408kB。每个 worker 的 Sort 显示 external merge Disk: 4256kB。每个进程有两个使用内存的节点,两者都在写入临时文件,因为它们达到了由 work_mem 强制执行的节点上限。Postgres 中默认的 work_mem 是 4 MB。但哈希类节点在溢出之前会获得 work_mem * hash_mem_multiplier(默认 2.0),所以 Partial HashAggregate 的上限是 8 MB。HashAggregate 如果不设上限,每个 worker 自然会使用约 14 MB,这放不进 8 MB,所以改为分 5 批写入磁盘。Sort 想要约 5 MB,这放不进 4 MB,所以进行外部归并。

在三个进程上合计:

  • RAM:3 × (8.2 MB Partial HashAgg + 1.7 MB Hash) + 2.9 MB outer Sort ≈ 33 MB
  • 磁盘:3 × (3.4 MB HashAgg partitions + 4.3 MB Sort) ≈ 23 MB

对于这个相对简单的查询,我们使用的内存超过 8x work_mem,而且每次控制台页面加载都会产生 23 MB 的磁盘 I/O。针对 Disk Usage 和 external merge 标记的一阶修复方法是在 EXPLAIN 中把 work_mem 提高到超过每个节点的工作集。

提高到 8 MB 可以消除溢出,代价是在运行查询的并行进程中由于各种可调因素的组合而使用 8.5× 于该数量(约 68MB)。规划器根据 worker + 1 从 wm_api_calls 表大小和来自查询与数据的、使用内存的节点数量中选择了 hash_mem_multiplier 是一个按节点修改器,而不是查询级上限。只有 work_mem,按节点、按进程应用,并对哈希类节点有乘数。

如果每个耗内存的查询都遵守这个模型,文章可以到此结束。但事实并非如此。

并非所有东西都会溢出

有些执行器内存分配位于这个整洁的 work_mem 模型之外。问题不只是把旋钮设得太高,或忘记并行 worker 会使其成倍增加,而在于有些结构根本没有有用的磁盘后备方案,因此它们可以一直增长,直到查询完成或后端内存耗尽。

溢出任意执行器状态是复杂、混乱的,而且如果做得不好,绝对会摧毁性能。Postgres 在权衡合理的地方投入了大量工作来实现溢出,但它有意将一些结构保留在内存中。这在实践中意味着,在病态条件下有可能运行不遵守内存调优参数的查询。构成我们测试工作负载的查询就表现出这种模式,值得进一步深入探讨。

考虑在 Postgres 中表示有向图。最简单的模式是一张边表,如下所示:

1CREATE TABLE edges (2   src bigintNOT NULL,3   dst bigintNOT NULL,4PRIMARY KEY (src, dst)5);

要找出从节点 0 可达的所有节点,你会写一个递归 CTE。由于图可能有环,递归需要使用 UNION(而不是 UNION ALL)来终止,否则它会永远重复访问节点。UNION 的使用会触发在执行器中创建一个哈希表来对行去重。该哈希表在整个查询生命周期内为每个可达节点保存一个条目,所用的内存上下文不遵循 work_mem 来溢写到磁盘。

1WITHRECURSIVE walk(n) AS (2SELECT0::bigint3UNION4SELECT e.dst5FROM walk6JOIN edges e ON e.src = walk.n7 )8SELECT n9FROM walk;

该哈希表由一个函数 BuildTupleHashTable 构建,而它本身没有溢写到磁盘的逻辑。同一个函数也支撑着我们在本文前面 HashAggregate 中用于 GROUP BY 示例的那个实现。那么为什么那个哈希表能干净地溢写,而这个却不能?这归根结底取决于哈希表的 用途。

在 HashAggregate 中,哈希表是一个 一次性累加器:输入结束它就结束。这种确定的结束让 Postgres 可以监控表的大小,一旦超过 work_mem * hash_mem_multiplier,就开始把新分组的元组路由到磁盘,而不是让内存中的表继续增长。当输入耗尽时,Postgres 会读回每个磁盘分区并单独聚合它。

在 WITH RECURSIVE … UNION 中,哈希表 需要为整个查询维护一个去重集合。每次迭代中的每个候选行都必须与目前见过的每个键进行比对。把表的一部分发送到磁盘意味着每次成员检查都要把它读回来,这对吞吐量是灾难性的。因此该表会在查询的整个生命周期内一直留在内存中,直到可达集合被完全物化。

基准测试

我们在一个大量施压内存使用的工作负载下测试了 4 家 Postgres 提供商。标准是数据库应当能够处理尽可能多的负载,并干净地卸掉其余负载而不崩溃。

我们在一个约 1260 万节点的图上运行了上面的有环图递归 UNION 查询,去重哈希表消耗约 1 GiB 的 RAM。它使用 CROSS JOIN 动态生成边,从而消除了存储和缓存性能带来的差异。从每个节点出发,查询会创建两条出边:一条指向下一个节点,另一条指向前方 251 个位置。"下一个节点"这条边确保每个节点都可以从零到达。第二条边让大多数节点拥有多条入路径,因此 UNION 必须在每次迭代时拒绝重复,并且它把递归从数百万步缩短到数万步。

1WITHRECURSIVE walk(n) AS (2SELECT03UNION4SELECT (walk.n + step.s) %126000005FROM walk6CROSSJOIN (VALUES (1), (251)) AS step(s)7)8SELECT n FROM walk;

关于内存记账的一点说明:Postgres 还会把 CTE 输出物化到一个遵循 work_mem 的 tuplestore 中,并把超出部分溢写到临时文件(对于这个图大约 200 MB),这会在多个后端之间产生一些 I/O 压力,但其速率低于同时施加的内存压力。

接受测试的提供商有:

  1. ClickHouse Managed Postgres [r8gd.large, AWS us-west-2, 118GB 本地 SSD, Postgres 18.6]
  2. Google Cloud SQL [db-c4a-highmem-2, us-west1, 118GB Hyperdisk Balanced, Postgres 18.6]
  3. PlanetScale Postgres [r8gd.large, AWS us-west-2, 118GB 本地 SSD, Postgres 18.6]
  4. Amazon RDS [db.r8g.large, us-west-2, 118GB gp3, Postgres 18.6]

ClickHouse Managed Postgres 和 PlanetScale 都使用本地挂载的 SSD,因此被配置为同步 HA(2 个备库),以匹配其他提供商的持久性保证。

对于每家提供商,我们打开 n 个并发连接运行同一个查询,n 从 7 到 23 不等。我们在每个 n 值下重复 10 次,两次运行之间冷却 60 秒,并全程采样每个连接的内存使用情况。每个连接设置 120 秒的 statement_timeout:单个查询通常不到 5 秒完成,因此超过两分钟的任何情况都计为失败。我们还把任何返回错误或被终止的连接记为失败。如果一次运行中所有已连接的客户端同时丢失会话,则归类为完全宕机。测试驱动是位于所有集群同一区域的一台 Amazon EC2 实例。

结果

这里有三种失败模式:查询失败、会话失败和集群失败。Postgres 能做的好事是,当分配器看到失败且调用方进行检查时:一条 SQLSTATE 为 53200(out_of_memory)的日志条目,失败的事务回滚,连接保持打开,连接池保留该槽位,该连接上的下一个查询可以正常工作。其他后端以及集群上的任何其他客户端甚至不会察觉。ClickHouse Managed Postgres 是唯一做到这一点的受测提供商。我们在内核中禁用内存过量使用并限制已提交内存。当后端请求超过总限制时,分配失败。Postgres 捕获这一点并以 SQL ERROR 响应,而不是崩溃。

当没有任何机制及时捕获内存压力时,Linux OOM killer 就会触发,并挑选一个后端 SIGKILL。Postgres 把这种异常退出视为可能的共享内存损坏,并将整个集群重启进入崩溃恢复,这会在繁忙系统上导致数分钟不可用。RDS 表现出这种模式,在 19 个连接时开始进入崩溃恢复,此时工作负载所需内存远超集群拥有量,不过少数连接偶尔会在崩溃前完成工作负载。在此之前,RDS 自己不会停止任何查询,但一些查询在 15 和 17 个连接时超过 120 秒的 statement_timeout 并报错。这是在重内存压力下发生抖动的迹象,但我们无法证实这一推测。

Cloud SQL 和 PlanetScale 采取了不同的方法,它们运行监控内存压力并在 OOM killer 触发之前杀死查询的监督程序。客户端会看到 FATAL: terminating connection due to administrator command,这比 ERROR 更不礼貌,因为连接本身在没有任何解释的情况下就断了,但 postmaster 仍然存活,集群仍在处理请求。这不是一个完美的解决方案。在 11 个以上连接时,大多数 PlanetScale 运行以崩溃告终。Cloud SQL 在重负载下表现更好,但在中等负载下更不稳定。Cloud SQL 偶尔也需要几分钟才能恢复,导致一些后续运行无法启动并过早报错。

这两张热力图对每个对应的表格单元格讲述了不同的故事。第一张显示的是完成查询的单个查询所占的比例;第二张显示的是集群本身保持存活的运行所占的比例。在 23 个连接时,ClickHouse Managed Postgres 的查询完成率为 32%,但集群存活率为 100%:内存上限以 ERROR 终止了约 70% 的查询,但集群始终保持着健康状态。RDS 在 23 个连接时的查询完成率为 7%,集群存活率为 0%:postmaster 在每次运行中都崩溃了,少数幸运的连接在主机放弃之前恰好完成了它们的查询。

在此基准测试中,ClickHouse Managed Postgres 在所有测试的连接数下都通过在被耗尽之前终止失控查询来保持集群运行。其配置带来了一个权衡:Postgres 后端无法像在允许工作负载运行得更接近极限的提供商上那样消耗那么多主机 RAM。下一张热力图直接展示了这一权衡。ClickHouse Managed Postgres 上的内存分配在主机 RAM 的 57% 处趋于平稳,在这些 16 GiB 实例上约为 9 GiB。Cloud SQL 和 PlanetScale 强制执行类似的内存上限,而 RDS 允许后端内存攀升得高得多之后才发生故障。

这看似是在浪费内存,但众所周知,Postgres 的性能很大程度上依赖于缓存,既通过 Postgres 的shared_buffers,也通过操作系统页面缓存。ClickHouse Managed Postgres 为这两种缓存划拨了专用内存,默认将 4GB(25%)完全分配给 Postgres 的shared_buffers,并为 Linux 内核开销(包括页面缓存)保留较小的份额。

ClickHouse Managed Postgres 对内存密集型工作负载的限制确实比 RDS 更严格,后者采取更放任自流的方式。这就是为什么 RDS 在中等负载下比 ClickHouse Managed Postgres 完成更多查询:我们的上限在 9 个连接时开始拒绝查询,此时工作负载达到了上限,而 RDS 则继续接受查询,直到主机耗尽。但 RDS 的方式付出的代价不止是崩溃:由于没有对失控内存消耗的保护,后端可能会与缓存竞争,拖慢一切。

结论

我们较低的内存上限是为了可用性而做出的有意权衡,哪种方式更好取决于你希望服务优化什么。当你完全掌控工作负载并接受糟糕的计划或病态的查询可能导致实例宕机时,让后端消耗几乎全部可用 RAM 可能是有用的。强制执行较低的上限在最理想的情况下会留下一些未使用的内存,但能让系统只让个别查询失败,而不是整个数据库失败。在失控的执行器内存下,我们宁愿返回一个干净的查询失败,也不愿让 postmaster 消失并迫使每个客户端经历崩溃恢复。

我们正在投资于进一步稳定 Postgres 以及每个 VM 上运行辅助组件的内存配置的方法,希望未来能给用户查询提供更多内存。

这篇内容对你有用吗?

反馈只用于改善内容筛选,不等同于收藏

分享这条资讯
分享海报
保存图片
iOS 也可以长按图片保存