PostgreSQL 到底能不能当消息队列?我们用 SKIP LOCKED 扛住了 8000 QPS,也说几个真会咬人的坑

🔑 关键词:PostgreSQL, SKIP LOCKED, 消息队列, autovacuum 表膨胀, Kafka 替代

📖 摘要:把 Kafka 换成 Postgres 单表队列,跑了 14 个月的真实数据、DDL、踩坑记录,以及什么时候千万别这么干。

先把结论摆这儿

图片

Postgres 当队列,在 QPS 8000 以下、消息体小于 8KB、能忍受 p99 一两百毫秒的场景里,完全够用。我们跑了 14 个月,一台 8C32G 的 PG 同时扛着业务读写和队列,没崩过。但它有几个地方是真的会咬人,尤其是表膨胀和长事务,下面都写了,包括我改错参数差点搞出事故那一段。

先交代背景。2023 年 8 月我们做的是一个物流轨迹推送服务,上游是几百家快递公司的回调,下游推给商家 ERP 和几个 C 端小程序。峰值 6000~8000 TPS,消息体平均 1.2KB,但有一家的回调会带完整的 base64 电子面单图,能到 30KB。

原来的架构是 Kafka 3 节点 + 12 个消费 Pod。老实讲 Kafka 本身没什么问题,是我们自己运维烂。3 个 broker 挂在 3 台 4C8G 上,其中一台还跟 Jenkins 混部——现在想起来真是离谱。2023 年 6 月那台机器磁盘满了,broker 起不来,我们花了 4 个小时做 leader 选举和数据恢复,因为某个 topic 的 replication.factor 被前任同事设成了 1。

那次事故之后我算了一笔账:3 台机器一年小两万,加上我每季度要花半天升级调参,而我们的峰谷比是 8:1,凌晨三点的流量只有白天的八分之一,那 3 个 broker 到晚上基本在空转。

表是怎么建的

核心就一张表加一个部分索引,DDL 大概长这样(业务字段去掉了):

CREATE TABLE msg_queue (
  id          bigserial PRIMARY KEY,
  topic       text NOT NULL,
  payload     jsonb NOT NULL,
  status      smallint NOT NULL DEFAULT 0,  -- 0待处理 1处理中 2成功 3失败
  retry       smallint NOT NULL DEFAULT 0,
  visible_at  timestamptz NOT NULL DEFAULT now(),
  created_at  timestamptz NOT NULL DEFAULT now(),
  locked_by   text
) WITH (fillfactor = 70);



![图片](http://img0.baidu.com/it/u=712039557,2369769995&fm=253&fmt=auto&app=138&f=JPEG?w=800&h=500)


CREATE INDEX idx_pending ON msg_queue (topic, visible_at)
  WHERE status IN (0,3);

fillfactor = 70 是我后改的,默认是 100。这个参数直接决定你会不会被表膨胀搞死,原因下面讲。另外索引是带 WHERE 的部分索引,因为已经处理完的行(status=2)永远不会再被扫,没必要留在索引里占地方。

消费端 SQL 就是那个经典的 SKIP LOCKED:

WITH picked AS (
  SELECT id FROM msg_queue
  WHERE topic = $1 AND status IN (0,3) AND visible_at <= now()
  ORDER BY visible_at
  LIMIT 50
  FOR UPDATE SKIP LOCKED
)
UPDATE msg_queue m SET status = 1, locked_by = $2
FROM picked WHERE m.id = picked.id
RETURNING m.*;

SKIP LOCKED 是 PG 9.5 加的,这个是真香。50 个消费者并发去抢,谁抢到算谁的,不会互相阻塞。实测在 2000 万行的表上批量取 50 条,这个语句大概 2~4ms。visible_at 是用来做退避重试的,失败的消息不是立刻能再被捞,而是把 visible_at 推到 30 秒后。

上了线之后的数据

图片

翻了当时的 Grafana 截图,2024 年 3 月的数字大概是:

指标 Kafka 方案 PG 队列
p50 端到端延迟 18ms 11ms
p99 42ms 180ms
堆积 100 万条时的 p99 60ms 1.4s
机器成本/月 ¥1600(3×4C8G) ¥0(复用主库)
我花的运维时间/季度 4~6 小时 1 小时

p99 我们确实变差了,180ms,堆积的时候能到 1.4 秒,根源是行锁竞争加上索引页争用,躲不掉。但业务能接受——ERP 那边本来就是 5 秒轮询一次,你给他 20ms 还是 200ms 没区别。如果你的业务是实时竞价或者在线对战,那这个 p99 就是灾难。

这里得说一句,别拿我这个表当 benchmark。我们有 60% 的消息集中在每天早上 8 点半那个 10 分钟窗口爆发,你的流量曲线不一样,结论就不一样。

三个真会咬人的坑

第一个是表膨胀,我差点因为这个被叫去喝茶。

一开始我没设 fillfactor,也没调 autovacuum。跑了 3 个月,表从 200 万行涨到 800 万行,磁盘占用却到了 40GB。用 pgstattuple 一查,dead tuple 占 71%,活数据只有 11GB。

图片

原因是这样:消费者每处理一条消息要 UPDATE 两次(0→1,1→2),PG 的 UPDATE 是写新版本再标记旧版本 dead,靠 autovacuum 回收。但默认的 autovacuum_vacuum_scale_factor = 0.2,意思是表死了 20% 的行才触发一次——800 万行的表得死 160 万行它才动一下。而且 autovacuum_vacuum_cost_delay 默认 2ms,在生产库上它跑得慢吞吞,我经常看到 vacuum 跑两小时还没完。

改法是把这几张表的参数单独调:

ALTER TABLE msg_queue SET (
  autovacuum_vacuum_scale_factor = 0.02,
  autovacuum_vacuum_cost_delay = 0,
  autovacuum_vacuum_cost_limit = 3000,
  autovacuum_analyze_scale_factor = 0.01
);

改完膨胀率稳定在 8% 左右。cost_delay = 0 确实会增加 IO 压力,我们 PG 用本地 NVMe,实测业务 QPS 掉了不到 3%,能接受。fillfactor = 70 的作用是给 HOT update 留页面空间,让 UPDATE 尽量不新建索引项——这个和上面那组参数是配套的,只改一个效果打折。

第二个是长事务,比膨胀还阴险。

有一天报警说 vacuum 卡了 6 小时。我上去查 pg_stat_activity,发现报表服务的连接 idle in transaction 挂了 5 个多小时,它开了个 REPEATABLE READ 的事务,然后去调外部 HTTP 接口了(对,就是这么写的)。PG 里只要有一个 backends_xmin 特别老的事务存在,比它晚的所有 dead tuple 都回收不了。

图片

解决是加了两样:idle_in_transaction_session_timeout = 60s,以及只给报表账号设 statement_timeout = 30s。注意是用 ALTER ROLE 设的,不是全局,不然会误伤某些批处理任务。

同类问题还有长查询、逻辑复制槽没消费完、pg_dump 跑太久,都会把 xmin 卡住。我的建议是给 pg_stat_activity 加个监控,只要发现有事务超过 5 分钟还活着就报警,别等 vacuum 卡了才发现。

第三个是 8KB 限制。

我们那个 30KB 的电子面单回调,一开始想用 pg_notify 做"有新消息了"的推送,省掉轮询。结果直接报 payload string too long。PG 的 NOTIFY payload 上限是 8000 字节,这个数字是硬编码在源码里的,改不了。

后来改成 pg_notify 只推一个 topic 名和 id,消费者收到再去表里捞。但很快我们连 LISTEN/NOTIFY 都放弃了——它不持久化,消费者断线重连期间的通知全丢,最后你还是得靠轮询兜底。现在干脆把轮询间隔从 100ms 改成 200ms,通知机制整个删掉,代码少了 300 行,也没见延迟变差。

什么时候千万别这么干

消息体经常超过 8KB 就别放队列里了。大文件丢 S3,队列里只传 URL,我们后来就是把面单图挪到对象存储,payload 从 30KB 降到 400 字节。

图片

要求全局严格有序,PG 队列也做不了。我们用 topic 加一个哈希字段映射到 32 个逻辑队列,实现的是弱顺序(同一个快递单号有序)。真要全局有序,你得上 Kafka 单分区或者 Redis Stream。

如果 QPS 稳定超过 2 万,我大概率还是选 Kafka 或者 Pulsar。我们单表在 8000 QPS 下 CPU 已经到 45% 了,再往上加,业务读会被队列写拖死,因为它们在同一个实例里抢 shared_buffers 和 WAL 带宽。真要用 PG 也可以,但队列得拆到独立实例上,那成本优势就没了,还多了个要同步数据的麻烦。

现在什么状态

14 个月,没再出过 P0。表用 pg_partman 按天分区,索引建在每个分区上,过期分区直接 DETACH 再 DROP,比 DELETE 快得多——DROP 一个分区是毫秒级,DELETE 2000 万行能把 WAL 打爆。

对了,还有个笑死人的小坑:pg_partman 的 retention 默认是关的,我第一次配的时候忘了开,结果三个月后又攒了 2400 万行,DROP 的时候手都是抖的。

所以你要问我推荐不推荐,我会说看你的量。小厂、峰谷比大、没有专职 DBA 的团队,PG 队列是个被严重低估的选择,你本来就要维护一个 PG,为什么还要再养一个 MQ。但如果你的消息量大到需要独立部署,或者对 p99 有硬要求,那还是老老实实上 Kafka,别跟我一样拿业务库冒险。

🏷️ 标签: