使用 Postgres 作为作业队列的潜在后果

Lobsters Hottest 新闻

摘要

文章分析了使用 PostgreSQL 作为作业队列的可扩展性限制,特别强调了高并发下 MultiXact SLRU 争用导致的性能瓶颈。文章解释了为什么这种架构在开发环境中表现良好,但在生产环境中却会失败,并建议考虑替代方案。

<p><a href="https://lobste.rs/s/o1p8ee/potential_consequences_using_postgres">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/05/08 09:35

# 使用 Postgres 作为作业队列的潜在后果 来源:https://richyen.com/postgres/2026/05/04/postgres_job_queue.html *本文最初发表于 Microsoft Tech Community 博客 (https://techcommunity.microsoft.com/blog/adforpostgresql/potential-consequences-of-using-postgres-as-a-job-queue/4514332)* ## 引言 在小规模场景下,使用 Postgres 作为作业队列完全没问题,我甚至认为这是正确的选择。更少的组件、更少的系统维护工作、作业还能享受 ACID 保证。有什么理由不爱呢? 问题在于"小规模"是有天花板的,而且这天花板比大多数人预期的要低。当你有数千个并发作业进程对作业表执行 `SELECT ... FOR UPDATE SKIP LOCKED` 时,事情会开始以应用层难以察觉的方式表现异常。CPU 使用率逐渐攀升,vacuum 有时跟不上节奏,最后在等待事件统计中,你会看到类似 `LWLock:MultiXactSLRU` 这样的不祥条目在许多后端进程中堆积。 这种模式已经让不少团队栽过跟头,而且通常的剧情都一样:在开发和测试环境一切正常,一到生产环境并发量上来就断崖式下跌。所以我们来深入分析一下为什么会这样,以及替代方案是什么样的。 --- ## 典型模式 使用 Postgres 作为作业队列时,标准做法大致如下: ```sql CREATE TABLE job_queue ( id bigserial PRIMARY KEY, status text NOT NULL DEFAULT 'pending', payload jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), locked_by text, locked_at timestamptz ); CREATE INDEX idx_job_queue_status ON job_queue (status) WHERE status = 'pending'; ``` 作业进程获取任务的方式: ```sql UPDATE job_queue SET status = 'processing', locked_by = 'worker-42', locked_at = now() WHERE id = ( SELECT id FROM job_queue WHERE status = 'pending' ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED ) RETURNING *; ``` 然后标记完成: ```sql UPDATE job_queue SET status = 'completed' WHERE id = $1; ``` 有些用户可能会直接 `DELETE` 整行。无论哪种方式,生命周期都是:插入、锁定并更新、更新或删除。每秒重复数千次。 在低并发下,这运行得非常顺畅。`SKIP LOCKED` 意味着作业进程不会互相阻塞等待同一行。Postgres 处理锁定、可见性和排序,很优雅。 那么问题出在哪里? --- ## MultiXact SLRU 问题 当多个事务对同一行持锁时,Postgres 会将这些锁持有者存储为 MultiXact ID——一个指向 `pg_multixact/` 下侧结构的指针。 使用 `SELECT ... FOR UPDATE SKIP LOCKED` 时,用户可能认为不会涉及 MultiXact——毕竟 `SKIP LOCKED` 就是为了避免争用。但实际上,当大量并发作业进程竞相锁行时,存在多个事务引用同一行的短暂窗口,然后其中一个"获胜",其他的跳过。如果再加上任何 `FOR SHARE` 或 `FOR KEY SHARE` 锁(这类锁通常由外键检查隐式创建),MultiXact ID 会迅速累积。 MultiXact 数据存储在 SLRU 缓冲区(Simple Least Recently Used,简单最近最少使用)中——这是一个小而固定大小的共享内存缓存。当后端进程需要读写 MultiXact 数据时,它们会获取 LWLock 来访问这些缓冲区。在高并发下,这成为瓶颈: ``` wait_event_type | wait_event -----------------+------------------- LWLock | MultiXactMemberSLRU LWLock | MultiXactOffsetSLRU ``` 你会看到数十甚至数百个后端进程堆积在这些等待事件上。SLRU 缓存很小(设计上就是如此——它是共享内存中固定数量的页面),当 MultiXact 查找的工作集超过缓存容量时,就会发生持续的淘汰和从磁盘重新读取。作业行上的每次锁获取和释放都可能触发一次 MultiXact SLRU 查找,而在数千个并发会话下,这些查找会在 LWLock 上串行化。 结果是:CPU 被打满,吞吐量崩溃,延迟飙升——不是因为查询本身昂贵,而是因为锁基础设施本身被压垮了。 --- ## 膨胀:无声的杀手 问题的另一面是表和索引膨胀。每个作业行都要经历多次更新(以及可能的删除),每次操作都会在堆中创建一个新的元组版本。旧版本会一直保留到 `VACUUM` 清理它们。 在繁忙的作业队列表上: - **死元组累积速度超过 autovacuum 的清理速度。**等 autovacuum 完成一轮清理,数万新的死元组又出现了。表不断增长。 - **索引膨胀加剧问题。**表上的每个索引也会累积死条目。`status = 'pending'` 上的部分索引尤其受创严重,因为行不断进入和离开这个条件。 - **顺序扫描变慢。**随着表膨胀,即使是索引扫描也会做更多 I/O,因为堆页面稀疏地填充着数据。Vacuum 能回收表尾部的空间,但无法回收中间的空间(除非页面完全为空)。 作业队列表可能膨胀到数十 GB,而实际的"存活"数据只有几 MB。这让所有操作都变慢:扫描、vacuum,甚至 `pg_dump`。 你可以通过更激进地运行 vacuum(降低 `autovacuum_vacuum_scale_factor`,提高 `autovacuum_vacuum_cost_limit`)来缓解,或者对表进行分区并删除旧分区。但到了某个点,你是在与 MVCC 的设计目标和作业队列的写入模式之间的根本错配作斗争。 --- ## CPU 和锁开销 除了 SLRU 争用和膨胀之外,使用 Postgres 完整的事务机制来处理本质上是一个 FIFO 调度操作,也存在纯粹的 overhead: 1. **每次加锁/解锁都是完整的 WAL 日志事务。**获取作业要写 WAL。标记完成要写 WAL。删除要写 WAL。在每秒处理数千作业的系统上,仅作业队列产生的 WAL 量就能饱和 `wal_writer` 和 checkpoint 进程。 2. **`SKIP LOCKED` 仍然会触及行。**名字暗示行被跳过,但 Postgres 仍然需要*找到*它们、检查锁状态、然后跳过。高并发下,许多作业进程最终会在找到可认领的行之前,扫描过相同的被锁行。这是浪费的 CPU。 3. **快照管理开销也成为问题。**每个事务需要一致性快照,而数千个并发事务下,ProcArray(跟踪活动事务的结构)本身成为争用点。你可能会看到 `LWLock:ProcArrayLock` 等待与 MultiXact 等待同时出现。 4. **Vacuum 争用。**Vacuum 清理死元组时也需要锁。在持续写入压力下的表上,vacuum 会干扰作业进程,反之亦然。我见过一些系统,禁用作业队列表上的 autovacuum 能在短期内提升吞吐量。 --- ## 更好的替代方案 那应该用什么替代呢?这取决于你的需求,但有几种选项比 Postgres 表更能优雅地处理高吞吐量作业调度。 ### Advisory Locks(留在 Postgres 内) 如果你想留在 Postgres 内、避免增加基础设施,advisory locks 对某些队列模式值得考虑。不是锁行,而是锁一个抽象的数字键: ```sql -- 作业进程尝试获取作业 ID 上的锁 SELECT pg_try_advisory_lock(id) FROM job_queue WHERE status = 'pending' ORDER BY created_at LIMIT 1; ``` Advisory locks 是轻量级的——不触及堆、不创建 MultiXact 条目、不产生死元组。它们完全存在于共享内存中。代价是你失去了 `FOR UPDATE SKIP LOCKED` 的原子性:你需要处理锁已获取但作业处理失败的情况,需要显式释放锁(或依赖会话结束时的清理)。 这种方式在队列深度可控且希望避免 MVCC 开销时表现良好。但它仍然是 Postgres,所以在极高的会话数下,你仍然受连接数限制、ProcArray 开销和一般资源争用的影响。 ### pgq (Skytools) pgq 正是为这个问题而生。它是内置于 Postgres 的队列实现,但使用批处理模型避免了大多数行级锁和 MVCC 陷阱。事件写入队列表,但消费者批量读取,队列维护通过 ticker 进程管理轮转完成。 核心优势: - 无行级争用。消费者不锁单行。 - 内置批处理。事件按块消费,减少事务开销。 - 高效清理。旧事件通过轮转移除,而非逐行 vacuum。 缺点是 pgq 不如以往活跃维护,且增加了运维复杂性(ticker 守护进程、消费者注册等)。但对于深度使用 Postgres 生态的团队,这是一个经过实战检验的选择。 ### PgQue 巧合的是,在撰写本文期间,Nikolay Samokhvalov 构建了 PgQue (https://github.com/NikolayS/pgque),它是 pgq 的衍生版本。与 pgq 类似,它内置于 Postgres,但以单个 SQL 文件形式交付——无 C 扩展、无外部守护进程——因此可部署在 RDS、Aurora、Cloud SQL、AlloyDB、Supabase 和 Neon 等托管服务上。生产者将事件 `INSERT` 到轮转的事件表中(通过 `TRUNCATE` 回收而非逐行删除),消费者通过对比 ticker 定期捕获的两个 `pg_snapshot` 值来读取批次——因此热路径上没有任何 `UPDATE`、`DELETE` 或 `SELECT ... FOR UPDATE SKIP LOCKED`,事件表上也不会产生死元组。关于算法的深入解析,参见 Christophe Pettus 的文章 (https://thebuild.com/blog/2026/05/03/pgque-two-snapshots-and-a-diff/)。 ### Redis 对许多团队来说,Redis 是作业队列的自然选择。使用 Redis 列表(BRPOPLPUSH 或 Streams API),你可以获得: - 亚毫秒级调度延迟。无磁盘 I/O、无 MVCC、无 vacuum。 - 原子弹出操作。作业进程获取作业无需任何锁协议。 - 简单扩展。Redis 轻松处理数千并发消费者。 代价是持久性。Redis 可以持久化到磁盘,但不是 ACID 的。如果 Redis 在弹出和作业完成之间崩溃,你可能会丢失或重复作业(不过 Redis Streams 配合消费者组能显著缓解这个问题)。对大多数作业队列场景,至少一次交付是可接受的,而 Redis 在这方面做得很好。 ### Kafka 对于真正高吞吐量、分布式的工作负载,Apache Kafka 是重量级选项。Kafka 分区提供并行消费、按分区保证顺序、持久存储和重放能力。它是以下场景的正确工具: - 需要每秒处理数千事件 - 多个消费者需要读取相同事件 - 需要事件重放或审计追踪 - 架构已经是事件驱动的 运维开销不可忽视——ZooKeeper(或 KRaft)、broker、topic 管理、消费者组协调。但对于已经在运行 Kafka 的团队,增加一个作业队列 topic 几乎零成本。 --- ## 粗略决策指南 | 场景 | 建议 | |------|------| | 低于 100 并发作业进程,简单作业 | Postgres 配合 `SKIP LOCKED` 即可 | | 中等并发,希望留在 Postgres 内 | Advisory locks 或 pgq | | 高吞吐量,低延迟调度 | Redis(Lists 或 Streams) | | 大规模,分布式,事件重放 | Kafka | 许多团队从 Postgres 开始(合理地)遇到扩展问题,然后试图修复 Postgres,而非认识到工作负载已经超出了工具的适用范围。他们投入更多 autovacuum 工作进程、增加 `max_connections`、添加连接池——这些在边际上有帮助,但无法解决根本问题:Postgres 的 MVCC 和锁机制并非为高并发下的这种访问模式而设计。 --- ## 结论 Postgres 很棒,但它不可能成为每个场景的最佳工具。在规模适中时,用它作为作业队列是完全合理的选择。但当你运行数千并发作业进程时,MultiXact SLRU 争用、堆膨胀、vacuum 压力和纯锁开销的组合,最终会迫使你转向专用解决方案。 好消息是你不必推倒重来。Advisory locks 可以在不增加基础设施的情况下为你争取空间。Redis 可以处理调度,而 Postgres 继续拥有数据。如果你已经在用 Kafka,作业 topic 是自然的选择。任君挑选——队列方案多的是!

相似文章

让Postgres队列实现可扩展性

Hacker News Top

一篇详细的技术博文,解释如何使用SKIP LOCKED和适当的事务隔离级别来扩展基于PostgreSQL的队列,实现每秒3万次工作流执行。

PostgreSQL 适用于一切场景

Hacker News Top

本文认为,PostgreSQL 是一个功能全面的数据库解决方案,足以替代搜索引擎、消息队列和缓存等多种专用技术,从而简化 IT 架构。

任务队列看似简单实则棘手

Lobsters Hottest

一篇探讨任务队列隐藏复杂性的技术博客文章,分析了它们为何看似简单实则棘手,并提供了系统设计的有用视角,如警惕队列、限制和故障模型。

扩展PostgreSQL以支持8亿ChatGPT用户

OpenAI Blog

OpenAI分享了扩展PostgreSQL以支持8亿ChatGPT用户及每秒数百万查询的技术见解,采用了单主架构搭配50个只读副本,同时通过分片和优化策略管理写入密集型工作负载带来的挑战。