多租户任务队列里,一个常见却容易被忽略的问题是“吵闹邻居”:某个租户一次提交 1,000 个任务,而另外十个租户各自只有 5 个任务。如果所有任务进入同一个全局 FIFO 队列,小租户的任务可能长期排在大租户后面。
PostgreSQL 17 可以借助 FOR UPDATE SKIP LOCKED 让多个 worker 并发领取任务,但 SKIP LOCKED 只解决锁竞争,不会自动带来租户公平性。要实现轮询,调度顺序必须显式包含“上一次服务该租户的时间”等状态。
全局 FIFO 为什么会放大吵闹邻居问题
最直接的领取方式是按任务 ID 选择最早的待处理任务:
BEGIN;
WITH next_job AS (
SELECT id
FROM jobs
WHERE status = 'queued'
AND run_at <= now()
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE jobs AS j
SET status = 'running',
claimed_at = clock_timestamp()
FROM next_job
WHERE j.id = next_job.id
RETURNING j.*;
COMMIT;
多个 worker 同时执行时,已经被其他事务锁住的任务会被跳过,因此不会全部阻塞在队首。这对并发吞吐很有帮助,但调度策略依旧是全局 FIFO。
假设租户 1 先写入 1,000 个任务,租户 2 到 11 随后各写入 5 个任务。只要 worker 的消费速度没有快到立即清空队列,后面十个租户仍可能等待很久。系统整体吞吐看起来正常,单个大租户的处理速度也很好,但其他租户感受到的尾延迟会很差。
因此需要区分两个目标:
SKIP LOCKED解决多个消费者如何避免互相等待。- 租户轮询解决下一次应该优先服务谁。
用一张调度表记录租户轮次
下面是一套可以直接在测试数据库中运行的最小模型。它创建 11 个租户,并复现“一个租户 1,000 个任务、十个租户各 5 个任务”的队列形态。
DROP TABLE IF EXISTS jobs;
DROP TABLE IF EXISTS tenant_schedule;
CREATE TABLE tenant_schedule (
tenant_id bigint PRIMARY KEY,
last_served_at timestamptz NOT NULL DEFAULT '-infinity'
);
CREATE TABLE jobs (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id bigint NOT NULL REFERENCES tenant_schedule(tenant_id),
status text NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued', 'running', 'done', 'failed')),
run_at timestamptz NOT NULL DEFAULT now(),
claimed_at timestamptz,
payload jsonb NOT NULL DEFAULT '{}'::jsonb
);
-- 支持全局 FIFO。
CREATE INDEX jobs_fifo_idx
ON jobs (id)
WHERE status = 'queued';
-- 支持按租户查找其最早的可执行任务。
CREATE INDEX jobs_tenant_claim_idx
ON jobs (tenant_id, run_at, id)
WHERE status = 'queued';
INSERT INTO tenant_schedule (tenant_id)
SELECT generate_series(1, 11);
-- 租户 1 制造 1,000 个任务。
INSERT INTO jobs (tenant_id, payload)
SELECT 1, jsonb_build_object('sequence', n)
FROM generate_series(1, 1000) AS n;
-- 租户 2 到 11 各有 5 个任务。
INSERT INTO jobs (tenant_id, payload)
SELECT tenant_id, jsonb_build_object('sequence', n)
FROM generate_series(2, 11) AS tenant_id
CROSS JOIN generate_series(1, 5) AS n;
ANALYZE jobs;
ANALYZE tenant_schedule;
轮询领取任务时,先锁定最久未被服务且当前有任务的租户,再锁定该租户最早的任务:
BEGIN;
WITH chosen_tenant AS MATERIALIZED (
SELECT ts.tenant_id
FROM tenant_schedule AS ts
WHERE EXISTS (
SELECT 1
FROM jobs AS j
WHERE j.tenant_id = ts.tenant_id
AND j.status = 'queued'
AND j.run_at <= now()
)
ORDER BY ts.last_served_at, ts.tenant_id
FOR UPDATE OF ts SKIP LOCKED
LIMIT 1
),
chosen_job AS MATERIALIZED (
SELECT j.id, j.tenant_id
FROM jobs AS j
JOIN chosen_tenant AS ct
ON ct.tenant_id = j.tenant_id
WHERE j.status = 'queued'
AND j.run_at <= now()
ORDER BY j.run_at, j.id
FOR UPDATE OF j SKIP LOCKED
LIMIT 1
),
mark_tenant AS (
UPDATE tenant_schedule AS ts
SET last_served_at = clock_timestamp()
FROM chosen_job AS cj
WHERE ts.tenant_id = cj.tenant_id
RETURNING ts.tenant_id
)
UPDATE jobs AS j
SET status = 'running',
claimed_at = clock_timestamp()
FROM chosen_job AS cj
WHERE j.id = cj.id
RETURNING j.id, j.tenant_id, j.payload, j.claimed_at;
COMMIT;
这段 SQL 有几个重要性质:
- 一个事务只领取一个任务,失败时可以整体回滚。
- 正在被其他 worker 服务的租户会被
SKIP LOCKED跳过,其他 worker 可以处理下一个租户。 - 每次成功领取任务后都会更新
last_served_at,使该租户排到其他活跃租户之后。 - 租户内部仍按
run_at, id处理,因此可以保留每个租户自己的近似 FIFO 顺序。
这里的公平是“每轮每个活跃租户获得一次机会”,不是严格的全局到达顺序。对于大多数多租户后台任务,这往往比全局 FIFO 更符合用户预期。
不要只测执行时间:十秒可能花在规划阶段
复杂队列 SQL 很容易让人只关注锁和索引,却忽略查询规划成本。相关测试中出现过接近十秒的 planner 陷阱,这提醒我们:一次领取任务很慢,不代表数据库真的花了十秒扫描或等待行锁,也可能是生成执行计划本身就非常昂贵。
在自己的数据规模和统计信息下,可以把领取语句放进 EXPLAIN,分别查看 Planning Time 与 Execution Time:
BEGIN;
EXPLAIN (ANALYZE, BUFFERS, SETTINGS, TIMING OFF)
WITH chosen_tenant AS MATERIALIZED (
SELECT ts.tenant_id
FROM tenant_schedule AS ts
WHERE EXISTS (
SELECT 1
FROM jobs AS j
WHERE j.tenant_id = ts.tenant_id
AND j.status = 'queued'
AND j.run_at <= now()
)
ORDER BY ts.last_served_at, ts.tenant_id
FOR UPDATE OF ts SKIP LOCKED
LIMIT 1
)
SELECT j.id, j.tenant_id
FROM jobs AS j
JOIN chosen_tenant AS ct
ON ct.tenant_id = j.tenant_id
WHERE j.status = 'queued'
AND j.run_at <= now()
ORDER BY j.run_at, j.id
FOR UPDATE OF j SKIP LOCKED
LIMIT 1;
ROLLBACK;
测试时要注意:
EXPLAIN ANALYZE会真的执行查询,所以修改型领取语句应放在事务中并回滚。- 同时记录规划时间、执行时间、共享缓冲区命中和读取情况。
- 在接近生产的数据量、租户数和任务分布上运行
ANALYZE后再比较。 - 如果应用使用预备语句,还应比较自定义计划和通用计划;参数分布高度倾斜时,两者表现可能不同。
- 避免把大量租户 ID 展开成巨型
IN、OR或动态拼接 SQL。这类写法既增加规划成本,也会让语句形态难以稳定复用。
只运行一次查询也不够。队列领取属于高频短事务,哪怕额外几毫秒,在大量 worker 下也会累积成明显的 CPU 开销。
上线前要明确的边界
轮询策略改善了租户间公平性,但不是免费的。
- 吞吐与公平需要取舍。 全局 FIFO 查询更简单;轮询需要维护额外的调度状态,并多锁一行租户记录。
- 长任务仍会占用 worker。 公平领取不等于公平使用 CPU。若任务耗时差异很大,还需要按租户限制并发数,或者把重任务放入独立 worker 池。
- 必须设计崩溃恢复。
running任务应有租约或超时机制,worker 崩溃后才能重新入队。 - 重试不能无限抢占。 建议给失败任务设置退避时间,并用
run_at控制下一次可执行时刻。 - 轮询状态可能成为热点。 当租户数很少而 worker 很多时,需要观察
tenant_schedule的锁竞争;必要时可按队列类别或租户分片。 - 公平的定义需要业务确认。 付费等级、任务成本和 SLA 不同时,可以把简单轮询扩展成加权轮询,但权重和饥饿保护必须可观测。
上线前至少同时比较全局 FIFO 与租户轮询的吞吐量、各租户等待时间、P95/P99 领取延迟、规划时间以及锁等待。只有总体吞吐、租户公平性和查询规划成本都被纳入基准测试,SKIP LOCKED 才真正成为可控的调度工具,而不只是一个避免阻塞的 SQL 语法。