使用 Postgres 作为作业队列的潜在后果
摘要
文章分析了使用 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 是自然的选择。任君挑选——队列方案多的是!
相似文章
扩展PostgreSQL以支持8亿ChatGPT用户
OpenAI分享了扩展PostgreSQL以支持8亿ChatGPT用户及每秒数百万查询的技术见解,采用了单主架构搭配50个只读副本,同时通过分片和优化策略管理写入密集型工作负载带来的挑战。
PostgresBench: 一个可复现的 Postgres 服务基准测试
ClickHouse 发布了 PostgresBench,这是一个公开且可复现的基准测试,用于比较托管式 Postgres 服务,它使用标准的 pgbench 工具,在多个缩放因子下运行类似 TPC-B 的工作负载。
生产中的荒谬
Armin Ronacher(pocoo)分享了他在Absurd(一个完全基于Postgres构建的持久执行系统)上的生产经验,重点介绍了诸如分解步骤、任务结果以及名为absurdctl的命令行工具等改进。
Postgres中唯一可扩展的删除操作是DROP TABLE
本文解释了为什么在Postgres中进行大规模DELETE操作效率低下且会增加额外工作,并建议使用DROP TABLE或TRUNCATE作为批量数据删除的更可扩展的替代方案。
SurrealDB 3.x 与 Postgres、Mongo、Neo4j 和 Redis 的基准测试(含Fsync)
SurrealDB 发布了基准测试,在生产级持久化设置下将其 3.x 版本与 Postgres、Mongo、Neo4j 和 Redis 进行对比,结果显示其性能较之前版本大幅提升,且与其他数据库相比具有竞争力。