在 PostgreSQL 中实现任务队列是件看似简单却暗藏陷阱的事。大多数开发者会本能地选择「一行一任务」配合 SKIP LOCKED 的模式,这种方式在开发环境和小规模生产中表现良好,但当系统进入持续高负载状态后,表膨胀、VACUUM 压力、性能衰退等问题会接踵而至。PgQue 这个库试图从根本上解决这一问题,它不使用常见的 SKIP LOCKED claiming 模式,也不依赖 advisory lock,而是采用一种更古老但经过大规模生产验证的架构 —— 基于快照的批处理加上表轮转。
传统 SKIP LOCKED 队列的膨胀困境
当前主流的 PostgreSQL 队列实现几乎都遵循同一套范式:用一个状态字段标记任务是否被处理,消费者通过 SELECT ... FOR UPDATE SKIP LOCKED 抢走一行,然后将状态更新为已完成。这种模式的核心问题在于每次消费都会产生一次行级更新和一次删除操作。已完成的行变成死元组,虽然 PostgreSQL 的 VACUUM 机制最终会清理它们,但在高吞吐场景下,清理速度往往跟不上产生速度。
这种膨胀并非理论假设,而是有明确的生产事故记录。Brandur 在 Heroku 观察到使用 Que(Ruby 队列库)时,一小时内积压了六万条待处理任务,根源在于表膨胀导致的查询性能下降。PlanetScale 在 2026 年初的博客中披露,他们的一个队列在 800 jobs/sec 的持续负载下陷入了「死亡螺旋」——VACUUM 因资源竞争无法及时运行,死元组堆积导致索引膨胀,索引膨胀又反过来加剧查询延迟,形成恶性循环。River 队列维护者也在 GitHub Issue #59 中记录了 autovacuum 饥饿的问题。这些案例的共同特征是:系统原本设计为处理突发流量,但在稳态高负载下暴露了底层存储的慢性病。
PgQue 对这个问题的回应是,既然无法避免产生死元组,那就从根本上消除死元组产生的机制。其核心思路来自 Skype 时代的 PgQ 队列引擎 —— 不修改行的状态,而是在快照隔离级别下一次性获取一批事件,用完后直接清空整张表。
Snapshot 批处理与 TRUNCATE 轮转的运作原理
PgQue 的架构围绕三个核心概念展开:tick(.tick)、batch(批次)和 rotation(轮转)。当生产者调用 send 函数时,事件被插入到当前的活动队列表中。此时消费者还看不到这条消息 —— 它需要一个 tick 动作来将事件推送到可消费状态。
tick 操作的本质是一次快照边界推进。PostgreSQL 的事务隔离级别保证了每个消费者在开始一个批次时看到的是一致的数据库视图。ticker 函数(通常由 pg_cron 每秒调用一次)会创建一个新的批次边界,将尚未被任何消费者领取的事件标记为对下一个批次可见。这个过程不涉及任何行的更新或删除操作,因此不会产生死元组。
当一个批次的所有消费者都完成确认(ack)后,对应的表会被 TRUNCATE 清空。TRUNCATE 是 PostgreSQL 中最快的清空表方式,它不仅比 DELETE * 快几个数量级,还会立即释放磁盘空间而不是等到 VACUUM 运行。这就是 PgQue 声称「零膨胀」的底气 —— 在热路径上根本不存在需要清理的行级操作。
这种设计的代价是端到端延迟。由于依赖定时 ticker 来推进批次边界,消息从发送到可消费之间存在一个 tick 周期的延迟。默认配置下,这个延迟通常在 1 到 2 秒之间:最多 1 秒等待下一个 tick,加上消费者轮询间隔。如果业务对单数字毫秒级的分发延迟有严格要求,PgQue 显然不是合适的选择。但对于优先追求「持续运行数月不降速」的系统,这个 trade-off 值得考虑。
与 Advisory Lock 队列的本质区别
需要澄清一个常见误解:虽然需求 brief 提到「基于 PostgreSQL advisory lock 实现零膨胀任务队列」,但 PgQue 本身并不使用 advisory lock。真正使用 advisory lock 的队列实现是 Que(Ruby)和部分使用 advisory lock 做分布式任务协调的场景。PgQue 的零膨胀机制与锁类型无关,而是来自其独特的表结构设计。
Advisory lock 的工作方式是将锁信息存储在共享内存中,而非关联到具体的数据库行。Que 使用 advisory lock 来标记「某个 job 正在被处理」,这确实避免了传统行锁带来的膨胀问题 —— 因为没有行被锁定或更新。但完成的任务仍然需要被删除或标记为已完成,这意味着死元组仍然会产生,只是来源从 claiming 阶段转移到了完成阶段。PgQue 的 TRUNCATE 机制则彻底绕过了这个问题,完成的事件直接随整个表一起被清空。
从并发模型角度看,两者的差异更加明显。PgQue 属于事件流队列,天然支持 fan-out—— 每个注册的消费者维护独立的光标,从同一个共享事件日志中拉取消息。这意味着一条消息可以被多个消费者独立处理,类似于 Kafka topic 的语义。而 Que 以及大多数基于 SKIP LOCKED 的队列实现属于竞争消费模型,每条消息只能被一个消费者领取。这两种模型并无优劣之分,只是适用于不同的业务场景:前者适合事件驱动架构和日志聚合,后者适合传统的任务分配场景。
性能对比与工程参数
根据 PgQue 项目给出的基准测试数据(基于笔记本环境的初步测量),PL/pgSQL 插入吞吐量约为 86k events/s,消费者读取速率约为 2.4M events/s。在 30 分钟的持续负载测试中,观察到零死元组增长。这些数字应当谨慎解读为概念验证级别的参考值,真实环境下的表现取决于硬件配置、并发连接数和数据库参数调优。
对于考虑采用 PgQue 的团队,以下参数值得关注。首先是 ticker 频率,默认配置下 pg_cron 每秒调用一次 pgque.ticker(),如果业务能接受更长的端到端延迟,可以降低调用频率以减少调度开销;如果需要更低的延迟,可以将频率提高到 500ms 甚至更低,但这会增加 cron job 调度开销和批次边界推进的频率。其次是批次大小,receive 函数接受一个参数控制单次返回的最大事件数,过大的批次会增加消费者内存压力和单次事务时长,过小的批次则会增加数据库往返次数。
对于消息持久化要求极高的场景,需要确认 WAL 级别和 checkpoint 配置是否满足业务的 RPO 需求。PgQue 本质上依赖 PostgreSQL 的内置复制和备份机制,这意味着已有的数据库高可用方案可以直接复用,无需为队列单独设计。
选型决策框架
选择 PgQue 意味着放弃对毫秒级延迟的追求,换取在持续高负载下的稳定表现。如果系统设计吞吐量在数百到数千 events 每秒级别,且需要 7×24 小时不间断运行且不希望频繁进行表维护操作,PgQue 的架构设计值得认真考虑。其对托管 PostgreSQL 环境的兼容性也是显著优势 —— 无需安装 C 扩展,无需修改 shared_preload_libraries,可以在 RDS、Cloud SQL、Supabase 等平台上直接使用。
反之,如果业务场景要求极低的端到端延迟、需要按任务优先级调度、或者需要深度集成特定语言的生态系统(如 Ruby 的 Que、Elixir 的 Oban、Go 的 River),则应该选择对应的专用队列库。这些库在延迟和功能丰富度上的优势恰好是 PgQue 为了零膨胀设计而主动放弃的部分。
从运维角度看,PgQue 的最大价值在于可预测的资源消耗曲线。由于不存在需要 VACUUM 清理的死元组,表大小会呈周期性波动但不会持续增长,索引大小也能保持稳定。这对于在共享数据库实例上运行多个队列的团队尤为重要 —— 一个队列的膨胀可能会拖慢整个实例上所有查询,而 PgQue 从根本上消除了这种风险。
资料来源
- PgQue GitHub 仓库:https://github.com/nikolays/pgque
- Advisory Lock 与 SKIP LOCKED 队列对比:https://devtechtools.org/zh/blog/postgresql-advisory-locks-vs-skip-locked-job-queues