返回

文章详情

Postgres 队列实际上可以扩展

Hacker News2026年7月30日 18:39

关于 Postgres 支持的队列的传统观念是,它们无法扩展。要处理大工作负载,不能使用 Postgres,而是需要像 RabbitMQ + Celery 或 Redis + BullMQ 这样的专用队列系统。人们之所以这样说,是有原因的:队列确实对 Postgres 是一个苛刻的工作负载。在大规模下,成千上万的工作进程同时轮询你的队列表,从而造成争用和索引波动。但通过正确的优化,Postgres 可以应对这一挑战。在这篇博文中,我们将展示如何在大规模下优化 Postgres 支持的队列,实现每秒 30K 次工作流执行,跨越数千台服务器。第 1 课:(重新)发现 SKIP LOCKED 使 Postgres 支持的队列正常工作,第一个需要解决的问题是多个工作进程在解锁相同工作流时的争用。从高层次来看,Postgres 支持的队列的工作原理是客户端通过将工作流添加到队列表中来入队,工作进程则解锁并处理最早入队的工作流(假设 FIFO 队列)。天真地,每个工作进程运行如下查询以查找 N 个最早入队的工作流,然后解锁它们:一旦多个工作进程并发运行此查询,就会出现争用。每个工作进程看到相同的最早入队的工作流,并尝试同时解锁它们。但每个工作流只能由一个工作进程解锁,因此大多数工作进程无法找到新工作而必须重试。在大规模下,这种争用在系统中造成了瓶颈,限制了任务被解锁的速度。幸运的是,Postgres 提供了解决此问题所需的原语:锁定子句。以下是使用 FOR UPDATE SKIP LOCKED 的查询示例:以这种方式选择行有两个作用。首先,它锁定了行,以便其他工作进程无法选择它们。其次,它跳过已经被锁定的行,选择的不是 N 个最早入队的工作流,而是 N 个尚未被其他工作进程锁定的最早入队的工作流。这样,许多工作进程可以同时拉取新工作流而不会发生争用。一个工作进程选择最早的 N 个工作流并锁定它们,第二个工作进程选择下一个最早的 N 个工作流并锁定这些,以此类推。锁定子句使得 Postgres 支持的队列成为可能(这就是为什么 SKIP LOCKED 是那些不断被重新发现的老 Postgres 技巧之一)。如果没有它们,工作进程之间的争用将无法扩展到每秒 approximately ~100 个工作流。有了它们,Postgres 可以进一步扩展,但实现这种扩展需要更多优化。第 2 课:注意事务隔离级别 尽管锁定子句显著提高了性能,但我们很快就遇到了另一个与争用相关的瓶颈:在规模化时,解锁操作经常会因 Postgres “序列化失败”异常而失败,并需要重试。当每秒解锁超过 ~1000 个工作流时,大多数解锁操作都有序列化失败,从而导致性能瓶颈。肇因最终被发现是 Postgres 的事务隔离级别。解锁事务最初在可重复读下运行,以支持全局队列限制,例如“在所有工作进程中最多同时运行 N 个工作流”。强制执行这些全局限制需要工作进程共享队列状态的一致全局视图,而可重复读(在 Postgres 中)保证交易将基于交易开始时的数据库固定“快照”运行,并且在其运行时不会“看到”完成的并发交易的影响。问题在于,高并发下可重复读变得昂贵。如果多个工作进程同时修改重叠的行,Postgres 将中止其中一个并出现序列化失败。在大规模下,工作进程在重试事务上花费的时间超过了处理工作流的时间。关键的认识是,最大的队列很少使用全局流控制。在大规模下,用户通常更喜欢本地限制,例如“每个工作进程最多运行 10 个工作流”,这些限制不需要跨工作进程的协作。因此,我们使隔离级别具有条件性:具有全局流控制的队列继续使用可重复读,而没有全局流控制的队列使用已提交读,从而完全消除序列化失败并显著提高吞吐量。第 3 课:索引不是免费的 通过锁定子句和较低的隔离级别,争用几乎消失,即使有成千上万的工作进程。然而,当每秒运行超过 ~8000 个工作流时,我们看到一个新的瓶颈:高 CPU 使用率。这来自两个看似无关的地方:解锁查询本身和 Postgres 自动真空。我们发现这两个源头共享同一个根本原因:低效的索引。工作流状态表上有多个二级索引,以加速查询。其中一个索引专为解锁查询而设计,对 queue_name 和状态进行了索引,以便 Postgres 可以快速找到给定队列中的所有已入队工作流:其他索引则存在于...

赞助内容

NordVPN Next-gen Antivirus

本站免费、广告极少。如果觉得有帮助,可以请我们喝杯咖啡 —— 任何金额都对持续运营有实际帮助。

请我喝杯咖啡