让Postgres队列实现可扩展性
摘要
一篇详细的技术博文,解释如何使用SKIP LOCKED和适当的事务隔离级别来扩展基于PostgreSQL的队列,实现每秒3万次工作流执行。
暂无内容
查看缓存全文
缓存时间: 2026/07/30 19:51
# Postgres 队列实际上可以扩展
来源:https://www.dbos.dev/blog/making-postgres-queues-scale
关于 Postgres 支撑的队列,传统观点认为它们无法扩展。要处理大量工作负载,你不能使用 Postgres,而需要像 RabbitMQ + Celery 或 Redis + BullMQ 这样的专用队列系统。人们这么说是有原因的:队列对 Postgres 来说确实是一种要求很高的工作负载。在规模扩大时,成千上万个工作进程同时轮询你的队列表,造成争用并使索引混乱。但通过正确的优化,Postgres 可以应对这种情况。在这篇博文中,我们将展示我们如何优化大规模 Postgres 支撑的队列,在数千台服务器上实现了每秒 30K 个工作流执行。
### 经验 1:(重新)发现 SKIP LOCKED
要让 Postgres 支撑的队列正常工作,首先要解决的问题是多个工作进程在出队相同工作流时的争用。从高层次来看,Postgres 支撑队列的工作方式是:客户端通过将工作流添加到队列表中来入队它们,而工作进程则对最早入队的工作流进行出队和处理(假设是 FIFO 队列)。简单粗暴地,每个工作进程运行这样的查询来找到 N 个最早入队的工作流,然后将其出队:
使用 Postgres 进行队列 - SQL 示例
一旦多个工作进程并发运行此查询,争用就会出现。每个工作进程都看到相同的最旧排队工作流,并试图同时将它们出队。但每个工作流只能由一个工作进程出队,因此大多数工作进程将无法找到新任务,不得不重试。在大规模下,这种争用会在系统中造成瓶颈,限制了任务出队的速度。
使用 Postgres 进行队列 - 架构图
幸运的是,Postgres 提供了解决此问题所需的基本工具:锁定子句。下面是使用 FOR UPDATE SKIP LOCKED 的查询示例:
使用 Postgres 进行队列 - 出队 SQL
以这种方式选择行做了两件事。首先,它锁定了这些行,这样其他工作进程就无法也选中它们。其次,它跳过已经锁定的行,选择的不是 N 个最早入队的工作流,而是 **没有被其他工作进程锁定** 的 N 个最早入队的工作流。这样,许多工作进程就可以 **无争用地** 并发拉取新的工作流。一个工作进程选择最老的 N 个工作流并锁定它们,第二个工作进程选择接下来最老的 N 个工作流并锁定它们,以此类推。
锁定子句使 Postgres 支撑的队列成为可能(这就是为什么 SKIP LOCKED 是那些不断被重新发现的旧 Postgres 技巧之一)。没有它们,工作进程之间的争用会阻止扩展到每秒约 100 个工作流以上。有了它们,Postgres 可以扩展得更远,但实现这种扩展需要更多的优化。
### 经验 2:注意事务隔离级别
虽然锁定子句显著提升了性能,但我们很快遇到了另一个与争用相关的瓶颈:在规模扩大时,出队操作频繁失败,出现 Postgres “序列化失败”异常,需要重试。当每秒出队超过约 1000 个工作流时,大多数出队操作会遇到序列化失败,造成性能瓶颈。
罪魁祸首是 Postgres 事务隔离级别。出队事务最初运行在 REPEATABLE READ 级别,以便支持全局队列限制,例如“在所有工作进程中最多并发运行 N 个工作流”。执行这些全局限制需要工作进程共享一致的队列状态视图,而 REPEATABLE READ(在 Postgres 中)保证事务将在数据库的一个固定“快照”上操作,这个快照是在事务开始时获取的,并且事务不会“看到”在其运行期间完成的并发事务的影响。
问题在于,在高并发下 REPEATABLE READ 变得昂贵。如果多个工作进程并发修改重叠的行,Postgres 会中止其中一个并出现序列化失败。在规模扩大时,工作进程花在重试事务上的时间比处理工作流的时间还要多。
关键的认识是,最大的队列很少使用全局流量控制。在规模扩大时,用户通常更喜欢本地限制,例如“每个工作进程最多运行 10 个工作流”,这不需要跨工作进程协调。
因此,我们让隔离级别变为有条件的:
Postgres 事务隔离级别影响扩展性
具有全局流量控制的队列继续使用 REPEATABLE READ,而没有全局流量控制的队列使用 READ COMMITTED,这完全消除了序列化失败,并极大地提高了吞吐量。
### 经验 3:索引不是免费的
通过锁定子句和较低的隔离级别,即使有数千个工作进程,争用也几乎消失了。然而,当每秒运行超过约 8000 个工作流时,我们看到了一个新的瓶颈:高 CPU 使用率。这来自两个看似无关的地方:出队查询本身和 Postgres 自动清理。我们发现这两个根源是相同的:低效的索引。
工作流状态表有几个二级索引来加速查询。一个索引是专门为出队查询设计的,索引了 queue_name 和 status,以便 Postgres 能快速找到给定队列的所有 ENQUEUED 工作流:
使用 Postgres 进行队列 - 索引
其他索引主要用于可观察性,例如,父工作流 ID 上的索引,以便高效查询工作流层次结构:
使用 Postgres 进行队列 - 索引示例
在规模扩大时,这些索引效率低下。
出队索引有助于找到所有入队的工作流,但不会按特定顺序返回它们。因此,当 Postgres 运行出队查询时,它必须按时间戳对返回的工作流进行排序,以找到最早入队的工作流,这增加了查询的 CPU 使用率。
同时,维护许多索引成本高昂。每次工作流状态更新(入队、出队、完成)都需要更新每个索引。而且,在索引更新后,Postgres 的自动清理必须清理过期的索引条目。在高吞吐量下,索引维护和清理消耗了数据库 CPU 的很大一部分。
解决方案是让索引更具选择性。
首先,我们更新了主要的出队索引,使其不仅返回特定队列名称的所有入队工作流,而且还按优先级和时间戳排序。此外,我们将其转换为部分索引,仅在工作流状态为 ENQUEUED 时才进行维护。
使用 Postgres 进行队列 - 让索引更具选择性
这样做有两个原因可以提升性能。首先,出队查询不再需要昂贵的排序步骤。其次,当工作流被出队时,Postgres 可以直接删除其索引条目,而不是在工作流剩余生命周期中维护它,从而减少了维护和自动清理的成本。
我们将同样的原则应用于大多数可观察性索引。例如,父工作流 ID 上的索引仅针对确实有父工作流的工作流进行维护:
使用 Postgres 进行队列 - 用于观察队列的 SQL 示例
综合起来,这些优化大幅降低了 CPU 使用率,使队列能够扩展到每秒超过 30K 个工作流,即每月 800 亿个(https://www.dbos.dev/blog/benchmarking-workflow-execution-scalability-on-postgres)。
### 了解更多
如果你喜欢构建可扩展、可靠的系统,我们很乐意听到你的消息。在 DBOS,我们的目标是让 Postgres 支撑的持久执行尽可能简单和高效。来看看吧:
- 快速入门:https://docs.dbos.dev/quickstart
- GitHub:https://github.com/dbos-inc
- Discord 社区:https://discord.gg/eMUHrvbu67
相似文章
使用 Postgres 作为作业队列的潜在后果
文章分析了使用 PostgreSQL 作为作业队列的可扩展性限制,特别强调了高并发下 MultiXact SLRU 争用导致的性能瓶颈。文章解释了为什么这种架构在开发环境中表现良好,但在生产环境中却会失败,并建议考虑替代方案。
Postgres LISTEN/NOTIFY 实际上可扩展
这篇博文驳斥了 Postgres LISTEN/NOTIFY 无法扩展的传言,展示了如何优化它以实现每秒6万次写入,延迟为毫秒级。
Postgres事务是分布式系统的超能力
本文解释了如何通过与应用程序数据共置的工作流状态使用Postgres事务,来消除分布式工作流中的幂等性和原子性问题,从而实现精确一次执行。
我们将PgBouncer扩展到4倍吞吐量
ClickHouse Managed Postgres通过运行一组使用SO_REUSEPORT的进程,将PgBouncer扩展到4倍吞吐量,实现了多核利用率,并通过对等连接解决了取消转发问题。
大规模并行 Postgres 备份
PlanetScale 描述了它如何通过为每个分片启动 EC2 实例、从对象存储恢复之前的备份并重放 WAL,来对分片 Postgres 数据库执行大规模并行备份,从而实现超过 50 GB/s 的 PB 级备份速度。