别让“死元组”拖垮你的应用:PlanetScale 揭开 Postgres 队列的隐形病灶

当“就用 Postgres”变成一种工程习惯
这几年,技术圈流行一句半开玩笑的话:遇事不决,先上 Postgres。你要做事务处理,它行;要跑全文检索、地理信息、时序数据,扩展生态也能凑出个八九不离十;甚至连消息队列、任务调度这种原本应该交给专用系统的活儿,很多团队也乐意塞进 Postgres 里统一管理。
从工程效率看,这个选择并不荒唐。把业务状态和任务状态放在同一个事务里,最大的好处就是“别出岔子”:订单写入成功,异步任务也一起落库;任务失败,事务回滚,数据状态不会分裂。比起外接 Kafka、RabbitMQ 或云上任务服务,这套方案少了跨系统一致性的心病,对中小团队尤其有吸引力。
PlanetScale 这篇文章有意思的地方在于,它没有再争论“Postgres 适不适合做队列”这种老问题。答案其实早就很明确:适合,而且在很多场景里表现得相当好。真正麻烦的是,当一个数据库同时扛着 OLTP、报表分析、全文检索和队列任务时,队列这种“插入—取出—删除”高速循环的负载,会把 Postgres 最隐蔽的一面暴露出来:清理跟不上,系统就会悄悄长胖,最后胖到走不动路。
队列表面简单,底层却像一条不停制造垃圾的流水线
文章举了一个极简的 jobs 表:任务写进来,worker 按 run_at 顺序挑最早的 pending 任务,拿到锁、执行、删除、提交。看上去非常朴素,甚至可以说是“数据库新手友好型”设计。配上 FOR UPDATE SKIP LOCKED,多 worker 并发抢任务也不容易重复消费,这正是今天很多 SaaS 公司、内部平台和中后台系统最常见的做法。
但问题在于,Postgres 的删除不是“立刻消失”。在 MVCC 机制下,被删除的行不会马上从磁盘上抹掉,而是先被标记成对新事务不可见,等后续 vacuum 来善后。这些暂时留在现场的“尸体”,就是数据库工程师常说的 dead tuples,中文一般翻成“死元组”。
这个概念听着学术,后果却很生活化。你可以把 jobs 表想象成一家高峰期外卖店:前台接单、后厨出餐、骑手取餐都很快,但如果没人及时收拾打包盒、塑料袋和空餐盘,走道很快就会被堵住。最糟糕的是,顾客看到的可能只是“今天出餐慢了一点”,却不知道后厨已经快没地方下脚了。Postgres 队列也是一样,应用层看到的是一条查询突然从毫秒级变成几十毫秒甚至几百毫秒,真正的病根却在底层那些不断堆积、还没来得及清掉的死元组和索引垃圾上。
真正的凶手,常常不是队列本身,而是隔壁那条慢查询
PlanetScale 点得很准:队列表现恶化,很多时候不是因为 Postgres 吞吐不够,也不是队列 SQL 写得多烂,而是数据库里还有别的活儿在同时跑。
比如说,一边是毫秒级的任务 worker,不停地插入、锁定、删除 jobs;另一边是分析团队或者 BI 系统在主库上跑一个 40 秒的报表查询。单看这个查询,好像也不算太离谱,没到“查询跑了 20 分钟”那种明显事故级别。可一旦这类查询交错运行——这个刚结束,那个又开始——MVCC 的“可见性地平线”就会一直被钉住。vacuum 不是不想扫地,而是它不敢把某些死元组清掉,因为对某个还活着的事务来说,那些旧版本理论上还可能“看得见”。
这就像商场保洁明明在岗,却总有一群顾客站在通道里不走,垃圾车推不过去。结果是,新的垃圾继续产生,旧的垃圾又不能收,数据库膨胀就成了迟早的事。
很多工程团队在这里会有一个错觉:我队列表只有几万行,怎么会慢?问题恰恰在这里——队列表“当前规模”不大,不代表“累计吞吐”不大。队列最大的特点就是瞬时库存看起来稳定,但生命周期极短,单位时间内可能发生海量插入和删除。今天 jobs 表里可能永远只有 5 万条待处理任务,可背后一天已经流过了几亿次行版本变更。你只盯着表大小看,往往会错过真正的风险信号。
更危险的是,死元组不仅占 heap 空间,也会污染索引。worker 明明是按索引去找最早一条 pending 任务,但 B-tree 叶子节点里可能已经堆着大量指向“已死行”的索引项。数据库每次都得顺着指针过去看一眼,再失望地发现这行早就不能用了,于是丢弃、继续扫。应用并不知道它在白跑,只会觉得“今天怎么又慢了”。这种慢,是最讨厌的那种慢:起初不致命,但会稳步变坏。
这件事为什么在 2026 年尤其值得警惕
如果把时间拨回几年前,很多团队还会把队列天然地放到 Redis、RabbitMQ 或专用任务系统里。如今情况不一样了。云数据库更强了,Postgres 的生态更成熟了,开发团队也更强调“少组件、少运维、少心智负担”。于是,越来越多公司愿意把任务队列重新收回数据库。
这股潮流并没有错。事实上,它反映的是工程实践的现实主义:能少维护一个系统,就少一份复杂度;能用事务解决一致性,就别把问题丢给补偿逻辑和重试风暴。可现实主义的另一面是,数据库开始承受越来越复杂的混合负载。你以为自己是在“简化架构”,实际上可能只是把复杂性压缩进了 Postgres 这一个容器里。
PlanetScale 这篇文章提醒行业的一点,我觉得特别有价值:数据库的瓶颈,早已不是“单点性能参数”那么简单,而是工作负载之间能否和平共处。队列、分析、主业务读写,本质上不是一类工作。它们争抢同一套 CPU、I/O、缓存、锁和 vacuum 能力时,任何一个环节都可能成为拖累全局的那块短板。
这也是为什么越来越多数据库厂商这两年都在强调 workload isolation、读写分离、自动调参和更细粒度的后台维护能力。无论是云上托管的 Postgres,还是 MySQL 阵营的新架构,大家都逐渐意识到:现代数据库的竞争,不只是跑分高低,而是谁能更优雅地处理“一个库干五份工”的现实。
能救队列的,不只是参数,还有架构纪律
文章后半段提到了一些传统工具,比如调 autovacuum 参数、设置 statement_timeout、限制事务空转时间等。这些当然有用,而且是基本功。一个健康的 Postgres 队列,几乎离不开更积极的 autovacuum 策略,尤其是针对高 churn 表单独定制阈值,而不是全库吃一套默认配置。
但如果把希望都寄托在参数上,我觉得多少有点像给长期熬夜的人狂灌维生素。补是能补一点,生活方式不改,问题还是会回来。真正靠谱的做法,往往是把系统边界重新画清楚:报表查询尽量别打主库,长事务要有硬性超时,worker 的事务必须短,能拆分的分析任务尽量拆到副本或数据仓库,甚至必要时就承认一个事实——某些队列负载确实该交给专用系统。
这也是这篇文章最值得琢磨的争议点:我们到底该不该“只用 Postgres”?我的判断是,Postgres 做队列没有问题,甚至很多时候是最佳工程选择;但前提是团队对 MVCC、vacuum、索引膨胀和事务生命周期有足够敬畏。如果组织里没人盯这些细节,只是图省事把一切都塞进数据库,那么“就用 Postgres”很容易从聪明方案,变成昂贵幻觉。
说到底,数据库不会因为你热爱简单而自动变简单。你省下的是组件数量,没省下的是系统规律。队列之所以健康,不在于 SQL 看起来多优雅,而在于数据库有没有足够的“消化能力”,把那些已经完成使命的数据残骸及时排出去。这个比喻听起来有点粗糙,却非常准确:一套系统最危险的时候,往往不是吃不下,而是排不动。