Chris van Eijk:PostgreSQL 中按租户划分的任务队列:吵闹的邻居

Chris van Eijk:PostgreSQL 中按租户划分的任务队列:吵闹的邻居

💡 原文英文,约1600词,阅读约需6分钟。
📝

内容提要

多租户SaaS使用PostgreSQL队列时,FIFO会导致大租户的千个任务阻塞其他租户,小租户中位等待4.5秒。改用按租户轮询后等待降至0.1秒,但按id排序在积压不均时会走全表,单次出队耗时10秒。改为按(tenant_id, enqueued_at, id)部分索引排序可降至1毫秒级。

🔎

延伸解读

公平调度的代价:大租户的延迟增加

按租户轮询虽然将小租户的中位等待从4.5秒降至0.1秒,但大租户的最后一个任务完成时间从5.4秒增加到6.0秒,延迟约0.6秒。这是因为小租户的任务插队,且每次出队操作因轮询逻辑而变慢。如果业务对大租户的完成时间敏感,需要权衡公平性与整体吞吐。

轮询查询的隐藏陷阱:计划器选择错误索引

当大租户积压10万任务而小租户只有少量任务时,按id排序的子查询会走全表扫描,单次出队耗时近10秒。这是因为计划器假设租户平均分布,而实际数据严重倾斜。改用按(tenant_id, enqueued_at, id)的部分索引后,出队时间降至毫秒级。务必用倾斜数据测试查询计划。

空闲租户的累积成本:每出队一次多一次索引探测

queue_tenants表中每个空闲租户都会在每次出队时增加一次索引探测。当有989个空闲租户时,单次出队耗时1.17毫秒;当租户数达到10000时,耗时增至10.5毫秒。只保留有排队任务的租户可将耗时降至0.15毫秒。但删除空队列租户时存在竞态条件,文章未测量此部分。

租户上下文与连接池:必须使用SET LOCAL

工作进程服务所有租户,不能使用固定租户上下文。在事务中处理任务时,应使用SET LOCAL设置租户,而不是SET。因为SET在连接池中会将租户上下文泄漏给下一个任务。文章提到在PgBouncer和RLS中测量过此问题,但未给出具体数据。

❓

Q&A

多租户SaaS在PostgreSQL中使用FIFO任务队列有什么问题?

FIFO队列按全局到达顺序出队,一个大租户入队1000个任务会阻塞其他租户的任务。实测小租户的中位等待时间达4.5秒,最长4.59秒。

如何用PostgreSQL实现按租户轮询的任务队列?

使用一个辅助表queue_tenants记录每个租户上次被服务的时间,出队查询通过LATERAL子查询获取每个租户最旧的任务,并按last_served排序选择等待最久的租户。子查询中使用FOR UPDATE SKIP LOCKED锁定任务行。

按租户轮询相比FIFO能提升多少性能?

小租户的中位等待时间从4.5秒降至0.10-0.15秒,最长等待从4.59秒降至0.30秒。大租户的完成时间仅增加0.6秒,自身吞吐量成本约5%。

为什么按租户轮询的查询在积压不均时会变慢?

当使用ORDER BY id时,规划器假设能快速找到租户的任务,但对于小租户或空闲租户,它会扫描大量其他租户的任务,导致单次出队耗时约10秒。

如何优化轮询队列的查询以避免全表扫描?

创建部分索引ON jobs (tenant_id, enqueued_at, id) WHERE status = 'queued',并在子查询中使用ORDER BY enqueued_at, id,使每次探测通过索引快速定位,耗时降至1毫秒级。

在PostgreSQL多租户队列中如何处理行级安全(RLS)?

队列表本身通常无策略,但任务执行时应使用SET LOCAL在事务内设置租户上下文,避免使用SET,因为连接池会泄漏租户上下文到下一个任务。

🏷️

标签

➡️

继续阅读