用 PostgreSQL 17 实现租户公平队列:从全局 FIFO 到 SKIP LOCKED 轮询

2026-10-01 26 预计阅读时间: 1 分钟
来源: postgr.es AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:10 分钟

多租户任务队列里,一个常见却容易被忽略的问题是“吵闹邻居”:某个租户一次提交 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 有几个重要性质:

  1. 一个事务只领取一个任务,失败时可以整体回滚。
  2. 正在被其他 worker 服务的租户会被 SKIP LOCKED 跳过,其他 worker 可以处理下一个租户。
  3. 每次成功领取任务后都会更新 last_served_at,使该租户排到其他活跃租户之后。
  4. 租户内部仍按 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 语法。


相关推荐