Skip to content

任务队列(SKIP LOCKED) ​

几乎每个业务系统迟早都需要一个「异步任务」能力:发邮件、生成报表、转码视频、调用第三方接口。这些任务不用立刻完成,但不能丢、不能重、还要能重试。于是一个经典的架构问题出现了——任务队列放在哪里?

答案是:很多场景下,PostgreSQL 自己就是最好的任务队列。你已经有了它,它有事务、有锁、有持久化、有可观测性;再引入一套独立的 RabbitMQ 或 Kafka,意味着多一个要部署、监控、备份、升级的系统组件,多一份数据一致性的对账负担(业务数据在库里,任务在 MQ 里,两边永远需要同步)。当任务量在每秒几百到几千以内、任务与业务数据强相关时,用数据库表 + 行级锁实现的队列,往往是最简单、最可靠的选择。

实现这个队列的核心是一条 SQL 技巧:FOR UPDATE SKIP LOCKED。它让多个 worker 在同一张队列表上「各抢各的任务,抢到谁就是谁」,互不阻塞、互不重复——这正是本页的主角。

前置知识

建议先阅读:软删除设计 与 SKIP LOCKED 行级锁。

基础概念 ​

概念英文 / 记法说明
任务表job / task table用普通表存储待处理任务
生产者producer向队列表 INSERT 新任务的进程
消费者 / workerconsumer / worker轮询领取并处理任务的进程
领取claim / dequeue把任务从 pending 标记为 processing 的原子操作
FOR UPDATE SKIP LOCKED—跳过已被其他事务锁定的行,避免 worker 互相等待
FOR UPDATE NOWAIT—遇到锁立即报错而不是等待,用于快速失败的场景
可见时间visible_at任务下次可被领取的时间,实现延迟与重试退避
尝试次数attempts记录已尝试次数,控制最大重试
超时回收lease timeoutworker 崩溃后把超时的 processing 任务放回队列
幂等idempotency同一任务被执行多次结果不变,队列系统的必备属性
LISTEN/NOTIFY—服务端推送通知,唤醒 worker 立即取任务

为什么行级锁能当队列用 ​

队列的本质诉求有三条:

  1. 一条任务只被一个 worker 处理(互斥);
  2. 多个 worker 并发领取时不互相阻塞(吞吐);
  3. 领取是原子的(不会被中途打断、不会领到一半丢任务)。

普通的 SELECT ... FOR UPDATE 满足第 1、3 条,但不满足第 2 条:两个 worker 同时扫队列,都盯上了第一行,后到的事务必须等先到的提交(或回滚)才能继续——worker 越多排队越长,吞吐反而下降。

FOR UPDATE SKIP LOCKED 补上了第 2 条:扫到已被锁定的行时直接跳过,继续找下一行没被锁的。于是 N 个 worker 并发领取,各拿各的行,零等待、零重复。这个特性自 PostgreSQL 9.5 起可用,加上它诞生于数据库内核的事务与锁机制,天然继承了 ACID 的全部保障。

数据库队列 vs 消息中间件 ​

维度PostgreSQL 队列表RabbitMQKafka
引入组件无(已有)新增部署新增部署
与业务数据的事务一致性同一事务内原子需要两段式/对账需要两段式/对账
语义精确一次领取,可重复消费需幂等at-least-onceat-least-once(分区有序)
吞吐每秒千级任务(可调)万级十万级以上
重试/延迟自由实现(SQL 即代码)内建死信/延迟插件需要外建
可观测性SQL 直接查表需要管理界面需要监控体系
消息堆积能力受磁盘与 VACUUM 影响内存/磁盘队列优秀(日志追加)
运维复杂度零增量中高
典型适用业务内任务、与库数据强耦合业务解耦、路由灵活事件流、大吞吐管道

结论不是「数据库队列取代 MQ」,而是按耦合度与吞吐量选型:任务和业务数据同库同事务的(发邮件绑定「订单创建成功」),数据库队列一致性最好;纯粹的系统间事件分发、超大吞吐,才值得引入 MQ。

语法与用法 ​

队列表设计 ​

sql
CREATE TABLE jobs (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    queue       text        NOT NULL DEFAULT 'default',  -- 队列名(逻辑分流)
    payload     jsonb       NOT NULL,                    -- 任务参数
    status      text        NOT NULL DEFAULT 'pending',  -- pending / processing / done / failed
    attempts    int         NOT NULL DEFAULT 0,          -- 已尝试次数
    max_attempts int        NOT NULL DEFAULT 5,          -- 最大尝试次数
    visible_at  timestamptz NOT NULL DEFAULT now(),      -- 何时可被领取(延迟/退避)
    locked_by   text,                                    -- 领取者标识(主机+进程)
    locked_at   timestamptz,                             -- 领取时间(超时回收依据)
    last_error  text,                                    -- 最近一次失败原因
    created_at  timestamptz NOT NULL DEFAULT now(),
    updated_at  timestamptz NOT NULL DEFAULT now()
);

-- 领取扫描的核心索引:只看「可领取」的任务
CREATE INDEX jobs_claim_idx
    ON jobs (queue, visible_at)
    WHERE status = 'pending';

-- 超时回收扫描的索引:只看「处理中」的任务
CREATE INDEX jobs_recover_idx
    ON jobs (locked_at)
    WHERE status = 'processing';

-- 已完成任务定期清理
CREATE INDEX jobs_done_idx ON jobs (updated_at) WHERE status IN ('done', 'failed');

设计要点:

  • 部分索引只覆盖「活跃」任务。绝大多数任务很快进入终态,WHERE status = 'pending' 的部分索引只有几百字节到几 MB,扫描永远飞快。
  • visible_at 承担双重职责:入队延迟(visible_at = now() + interval '10 minutes')与重试退避(visible_at = now() + backoff(attempts))。
  • status 用 text 而不是 int,配合 CHECK 约束保证合法值,日志里人可直接读。
  • locked_by 建议放「主机名:进程号:随机串」,既定位进程又防混淆。

领取任务的核心 SQL ​

sql
WITH picked AS (
    SELECT id
    FROM jobs
    WHERE queue = 'emails'
      AND status = 'pending'
      AND visible_at <= now()
    ORDER BY visible_at
    LIMIT 10
    FOR UPDATE SKIP LOCKED
)
UPDATE jobs j
SET status     = 'processing',
    attempts   = j.attempts + 1,
    locked_by  = 'worker-1:31762',
    locked_at  = now(),
    updated_at = now()
FROM picked
WHERE j.id = picked.id
RETURNING j.id, j.queue, j.payload, j.attempts;

一条语句完成「挑选 + 锁定 + 标记 + 返回」,且整个过程在一个事务里原子完成。RETURNING * 把任务参数直接带回 worker,省一次回查。

FOR UPDATE SKIP LOCKED 写在 CTE 里:先挑出最多 10 个未锁定的候选 id(这一步锁定),再统一改成 processing。多个 worker 并发执行时,每个事务锁住自己挑中的行,其他事务 SKIP LOCKED 会绕开它们——这就是无阻塞并发领取的全部秘密。

完成与失败 ​

sql
-- 成功
UPDATE jobs
SET status = 'done', updated_at = now()
WHERE id = :job_id;

-- 失败:重试退避 or 终态
UPDATE jobs
SET status     = CASE WHEN attempts >= max_attempts THEN 'failed' ELSE 'pending' END,
    visible_at = now() + power(2, attempts) * interval '30 seconds',  -- 指数退避
    last_error = :error_message,
    locked_by  = NULL,
    locked_at  = NULL,
    updated_at = now()
WHERE id = :job_id AND status = 'processing';

失败处理放在事务里与业务回滚协同:任务处理涉及的业务写入回滚了,任务本身标记失败等待重试;业务写入提交了,任务标记 done——业务数据与任务状态永远在同一事务里保持一致,这是数据库队列独有的优势。唯一要提防的是失败标记本身失效:连接抖动导致「标记 failed」的 UPDATE 没执行,任务会回到 ready 被重复领取——所以 worker 的收尾逻辑要对失败标记做幂等重试,领取条件的 WHERE 也按幂等设计。

延迟任务:visible_at 当定时器 ​

很多队列需求里都有「10 分钟后再试」「明天早上 9 点执行」这类延迟语义。行模型里已经内置了答案:visible_at 就是任务的「最早可见时间」,入队时把它设到未来即可:

sql
-- 延迟 10 分钟执行的任务
INSERT INTO jobs (queue, payload, visible_at)
VALUES ('emails', '{"to": "a@b.c"}', now() + interval '10 minutes');

-- 定时任务:明早 9 点(时间基准统一用 UTC 或固定时区落库,避免时区歧义)
INSERT INTO jobs (queue, payload, visible_at)
VALUES ('reports', '{"type": "daily"}',
        date_trunc('day', now() + interval '1 day') + interval '9 hours');

领取 SQL 的 WHERE visible_at <= now() 天然跳过未来任务,不需要额外的定时器表或独立的扫描逻辑。只有一点要注意:延迟任务的「准时」取决于 worker 的轮询间隔,而不是数据库——领取排序是 ORDER BY visible_at,worker 每 5 秒轮询一次,任务最多晚 5 秒被执行。需要更高精度时把轮询间隔调小,或配合下一节的 NOTIFY 做即时唤醒。最后提醒:visible_at 不需要触发器配合,重试与延期直接 SET visible_at = now() + interval '5 minutes'——把「下一次什么时候可见」的所有语义收敛到这一个列上。

顺带的架构红利:visible_at 让重试策略完全变成数据问题——失败第 N 次延迟多久(线性退避、指数退避、固定间隔)都只是 UPDATE 的取值函数,不需要任何额外的定时器基础设施。

LISTEN/NOTIFY 唤醒 worker ​

纯轮询的问题是有延迟(轮询间隔)或空转(没有任务时白查)。LISTEN/NOTIFY 让生产者在入队时顺带广播一个通知,worker 收到后立即取任务:

sql
-- worker 会话:监听
LISTEN jobs_channel;

-- 生产者会话:入队 + 通知(同一事务)
BEGIN;
INSERT INTO jobs (queue, payload) VALUES ('emails', '{"to": "a@b.c"}');
NOTIFY jobs_channel, 'emails';
COMMIT;
text
-- worker 端收到
Asynchronous notification "jobs_channel" with payload "emails" received from server process with PID 12345.

NOTIFY 不携带大量数据(payload 上限约 8000 字节),正确用法是「通知有任务 + 队列名」,任务内容仍从表里取。worker 收到通知后执行领取 SQL;轮询作为兜底保留(比如每 5 秒一次),防止通知丢失。

详细说明 ​

SKIP LOCKED 的工作原理 ​

理解 SKIP LOCKED 的关键是回到 PostgreSQL 的 MVCC 与行锁模型:

  1. worker A 的事务执行 SELECT ... FOR UPDATE SKIP LOCKED,对扫到的行逐行加 row-level 锁。加锁成功的行进入结果集。
  2. worker B 几乎同时执行同样的查询。它扫到 worker A 已锁定的行时,不等待,直接跳过,继续扫后面的行,直到凑满 LIMIT n 或扫完索引。
  3. A 提交(或回滚)后,它锁的行恢复「可锁」状态——但此时行已经被 A 改成 processing,不再满足 status = 'pending',不会再被任何人领走。
  4. 若 A 回滚,行仍是 pending,下一个 worker 可以领走——事务回滚自动把任务放回队列。

对比另外两种取法:

写法并发行为问题
裸 SELECT(无锁)都能看到同一批任务重复消费
FOR UPDATE后到者阻塞等待worker 互相排队,吞吐塌陷
FOR UPDATE SKIP LOCKED后到者跳过正解
FOR UPDATE NOWAIT后到者立即报错不适合轮询领取,适合「必须立刻知道有没有锁」的治理类操作

SKIP LOCKED 的一个固有性质要注意:它不保证 FIFO。跳过被锁的行意味着后来者可能先取走后面的任务。绝大多数异步任务不要求严格先进先出;如果个别任务有顺序要求(如同一用户的操作必须串行),按 queue 或 payload->>'user_id' 拆分到不同队列,同队列内单 worker 串行。

幂等与事务边界 ​

领取的原子性由 FOR UPDATE 保证,但「至少一次送达」(at-least-once)的语义仍然存在:worker 可能在处理到一半时崩溃,任务被超时回收后重新执行。因此处理逻辑必须幂等——同一任务执行两次,结果与执行一次相同。

幂等的通用做法是给任务或业务操作一个唯一键:

sql
-- 每个任务带业务幂等键(如邮件任务用 message_id)
ALTER TABLE jobs ADD COLUMN idempotency_key text;
CREATE UNIQUE INDEX jobs_idem_uniq ON jobs (idempotency_key) WHERE status <> 'failed';

-- 业务表记录已处理键
CREATE TABLE processed_jobs (
    idempotency_key text PRIMARY KEY,
    processed_at    timestamptz NOT NULL DEFAULT now()
);

-- 处理逻辑:先登记后干活,冲突即说明已处理过
BEGIN;
INSERT INTO processed_jobs (idempotency_key) VALUES (:key);  -- 冲突则任务已处理
-- ... 业务写入 ...
UPDATE jobs SET status = 'done' WHERE id = :job_id;
COMMIT;

事务边界的黄金法则:领取任务、业务写入、标记完成尽量在同一个数据库事务里。任务状态与业务数据的一致性由此免费获得;只有调用外部服务(发 HTTP 请求)时才不得不拆开事务,此时把「外部调用」设计成可重试 + 幂等(带唯一请求 ID),本地状态与外部效果用对账兜底。

重试与指数退避 ​

重试的核心是「失败了别立刻再试」。指数退避让第 n 次重试的等待时间按倍数增长,给下游恢复的时间:

sql
-- 延迟公式:base * 2^attempts(30s, 1m, 2m, 4m, 8m ...)
UPDATE jobs
SET visible_at = now() + power(2, LEAST(attempts, 10)) * interval '30 seconds'
WHERE id = :job_id;

工程要点:

  1. 区分可重试与不可重试错误。参数非法、余额不足这类错误重试一万次也不会成功,直接进 failed 终态;网络超时、第三方 5xx 才值得重试。
  2. 设置 max_attempts 上限。超过上限进终态并告警,避免毒丸任务(poison job)无限重试耗尽 worker。
  3. 加抖动(jitter)。批量任务同时失败时,base * 2^n 会让它们同时再试,形成周期性尖峰;visible_at = now() + backoff * (1 + random()) 打散重试时间。
  4. 每次失败记录 last_error。重试十次失败的任务,排查时第一眼就能看到错误演进。

超时回收:worker 崩溃后任务怎么办 ​

worker 领了任务(状态 processing)然后崩溃,任务就卡死了。解决思路是租约(lease):locked_at 超过阈值的 processing 任务视为放弃,放回队列:

sql
-- 回收超时任务(阈值按最长任务耗时 * 2 估算,例如 10 分钟)
UPDATE jobs
SET status     = 'pending',
    visible_at = now(),
    locked_by  = NULL,
    locked_at  = NULL,
    last_error = 'lease expired, recovered'
WHERE status = 'processing'
  AND locked_at < now() - interval '10 minutes'
RETURNING id, attempts;

回收任务由任意 worker 周期执行(例如每分钟)。注意回收与 worker 仍在处理的慢任务之间的竞争:阈值必须大于最长任务耗时,否则「慢任务」会被回收导致重复执行——这也是幂等重要的另一个原因。回收时 attempts 已在领取时加过 1,重复领取会让重试计数自然增长,最终毒丸任务会被 max_attempts 拦下。

LISTEN/NOTIFY 的机制细节 ​

NOTIFY 的三个重要性质:

  1. 与事务绑定。NOTIFY 只在包含它的事务提交后才送达;回滚则通知消失(与「任务行回滚」一致,不会出现假通知)。
  2. 只送到在线的监听者。worker 掉线期间的通知不会补发——所以必须有轮询兜底。
  3. 通知在连接断开后才能被新连接的 LISTEN 收到(同连接先发后听收不到历史),且同一事务内同一 channel + payload 的多次 NOTIFY 会合并为一次。

PHP 实现监听要用驱动层接口轮询 socket(PDO 没有 LISTEN 支持,通常用 pg_query('LISTEN ...') + pg_get_notify()),这比纯轮询复杂一档。务实的选择是:先用轮询起步,任务量大了再补 NOTIFY。

索引设计与性能 ​

队列表的索引要精心设计,因为这张表的特点是高频率小事务读写 + 行快速失效:

  • 领取查询走部分索引。jobs_claim_idx 只含 pending 行,无论表里有多少历史任务,扫描都只看活跃部分。
  • 避免在 payload(jsonb)上做领取过滤。需要按 payload 过滤时加表达式索引(CREATE INDEX ON jobs ((payload->>'user_id')) WHERE status = 'pending'),而不是让领取查询全表扫 jsonb。
  • 已完成任务定期删除。done/failed 任务保留一段时间(审计用)后批量删除,防止表无限膨胀。删除也要分批(每批几千行、单独事务),避免长事务与 WAL 峰值。
  • 监控表膨胀。高频 UPDATE 的表容易膨胀,定期检查死元组比例,必要时调低该表的 autovacuum 阈值(ALTER TABLE jobs SET (autovacuum_vacuum_scale_factor = 0.01))。
  • 吞吐参考。单实例上,领取 SQL 每次 1~2ms、每 worker 每秒可领数十个任务;8 个 worker 并发领取单表可达每秒数百到上千任务。瓶颈通常在任务本身的处理耗时,而不是领取 SQL。

与 RabbitMQ / Kafka 的选型再细化 ​

把选型判断落成三个问题:

  1. 任务与业务数据是否同库? 是 → 数据库队列(同一事务保证一致)。否 → 继续第 2 问。
  2. 峰值吞吐是否超过每秒数千任务? 否 → 数据库队列足够,简单可靠优先。是 → 引入 MQ。
  3. 是否需要广播扇出 / 多消费者组重放? 是 → Kafka 这类日志型系统是正解;否 → RabbitMQ 或数据库队列。

反过来的判断同样重要:不要为了「架构先进」引入 MQ。业务里 90% 的「发个通知、跑个报表」用队列表三小时就能上线,且与业务事务天然一致;MQ 的运维成本、对账成本、双写一致性问题会在未来数年里持续收费。

实战示例 ​

示例一:并发领取演示(两个 psql 会话) ​

准备数据:

sql
INSERT INTO jobs (queue, payload)
SELECT 'emails', jsonb_build_object('to', 'user' || g || '@x.com')
FROM generate_series(1, 6) AS g;

会话 A:

sql
BEGIN;
-- 领取(先提交领取结果前先看锁效果:不提交)
WITH picked AS (
    SELECT id FROM jobs
    WHERE queue='emails' AND status='pending' AND visible_at <= now()
    ORDER BY visible_at LIMIT 2
    FOR UPDATE SKIP LOCKED
)
UPDATE jobs j SET status='processing', locked_by='workerA', locked_at=now()
FROM picked WHERE j.id = picked.id
RETURNING j.id;
COMMIT;
text
 id
----
  1
  2
(2 rows)

会话 B(在 A 未提交时执行同样的领取):

text
 id
----
  3
  4
(2 rows)

B 领到的是 3、4——A 锁着的 1、2 被 SKIP LOCKED 跳过,B 没有等待、没有重复。这就是多 worker 并发领取的行为。

示例二:PHP worker 完整实现 ​

php
<?php
// Queue/Worker.php —— 基于 PDO + PostgreSQL 的最小 worker
class Worker
{
    public function __construct(
        private PDO $pdo,
        private string $name,
        private string $queue = 'default',
        private int $batch = 10,
        private int $leaseSeconds = 600,
        private int $pollIntervalUs = 1_000_000,
    ) {
        $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    }

    /** 入队(生产者调用) */
    public function enqueue(array $payload, string $idempotencyKey = null, int $delaySeconds = 0): int
    {
        $stmt = $this->pdo->prepare(<<<'SQL'
            INSERT INTO jobs (queue, payload, idempotency_key, visible_at)
            VALUES (:queue, :payload::jsonb, :key, now() + make_interval(secs => :delay))
            RETURNING id
        SQL);
        $stmt->execute([
            ':queue'   => $this->queue,
            ':payload' => json_encode($payload, JSON_UNESCAPED_UNICODE),
            ':key'     => $idempotencyKey,
            ':delay'   => $delaySeconds,
        ]);
        return (int)$stmt->fetchColumn();
    }

    /** 主循环 */
    public function run(callable $handler): never
    {
        while (true) {
            $jobs = $this->claim();

            if (!$jobs) {
                usleep($this->pollIntervalUs);
                continue;
            }

            foreach ($jobs as $job) {
                $this->process($job, $handler);
            }
        }
    }

    /** 原子领取一批任务 */
    private function claim(): array
    {
        $sql = <<<SQL
            WITH picked AS (
                SELECT id FROM jobs
                WHERE queue = :queue
                  AND status = 'pending'
                  AND visible_at <= now()
                ORDER BY visible_at, id
                LIMIT :batch
                FOR UPDATE SKIP LOCKED
            )
            UPDATE jobs j
            SET status = 'processing',
                attempts = j.attempts + 1,
                locked_by = :name,
                locked_at = now(),
                updated_at = now()
            FROM picked
            WHERE j.id = picked.id
            RETURNING j.id, j.payload, j.attempts, j.max_attempts
        SQL;

        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([
            ':queue' => $this->queue,
            ':batch' => $this->batch,
            ':name'  => $this->name,
        ]);
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    /** 处理单个任务:成功标记 done,失败按策略重试或终态 */
    private function process(array $job, callable $handler): void
    {
        try {
            $handler(json_decode($job['payload'], true, 512, JSON_THROW_ON_ERROR));
            $this->finish((int)$job['id'], 'done');
        } catch (Throwable $e) {
            $retryable = !($e instanceof DomainException);   // 业务错误不重试
            $exhausted = $job['attempts'] >= $job['max_attempts'];

            $status = (!$retryable || $exhausted) ? 'failed' : 'pending';

            // 指数退避 + 抖动:30s * 2^n,最大 1h
            $backoff = min(30 * (2 ** (int)$job['attempts']), 3600)
                     * (1 + mt_rand(0, 100) / 100);

            $stmt = $this->pdo->prepare(<<<'SQL'
                UPDATE jobs
                SET status = :status,
                    visible_at = now() + make_interval(secs => :backoff),
                    last_error = :err,
                    locked_by = NULL,
                    locked_at = NULL,
                    updated_at = now()
                WHERE id = :id AND status = 'processing'
            SQL);
            $stmt->execute([
                ':status'  => $status,
                ':backoff' => $backoff,
                ':err'     => mb_substr($e->getMessage(), 0, 2000),
                ':id'      => $job['id'],
            ]);
        }
    }

    private function finish(int $id, string $status): void
    {
        $stmt = $this->pdo->prepare(
            "UPDATE jobs SET status = :s, updated_at = now() WHERE id = :id AND status = 'processing'"
        );
        $stmt->execute([':s' => $status, ':id' => $id]);
    }

    /** 超时回收(由任一 worker 周期调用) */
    public function recoverExpired(): array
    {
        $stmt = $this->pdo->prepare(<<<'SQL'
            UPDATE jobs
            SET status = 'pending', visible_at = now(),
                locked_by = NULL, locked_at = NULL,
                last_error = 'lease expired',
                updated_at = now()
            WHERE status = 'processing'
              AND locked_at < now() - make_interval(secs => :lease)
            RETURNING id
        SQL);
        $stmt->execute([':lease' => $this->leaseSeconds]);
        return $stmt->fetchAll(PDO::FETCH_COLUMN);
    }
}

// 启动:php worker.php
$pdo = new PDO('pgsql:host=127.0.0.1;dbname=app', 'app', 'secret', [PDO::ATTR_PERSISTENT => true]);
$worker = new Worker($pdo, name: gethostname() . ':' . getmypid(), queue: 'emails');
$worker->run(function (array $payload): void {
    // 实际业务:发送邮件等。必须幂等。
    mail($payload['to'], 'hello', 'world');
});

示例三:可观测性查询 ​

sql
-- 队列健康总览
SELECT
    queue,
    count(*) FILTER (WHERE status = 'pending')                          AS pending,
    count(*) FILTER (WHERE status = 'processing')                       AS processing,
    count(*) FILTER (WHERE status = 'failed')                           AS failed,
    max(visible_at) FILTER (WHERE status = 'pending')                   AS next_due,
    avg(attempts) FILTER (WHERE status = 'done')                        AS avg_attempts_done
FROM jobs
GROUP BY queue;
text
 queue  | pending | processing | failed |          next_due           | avg_attempts_done
--------+---------+------------+--------+-----------------------------+-------------------
 emails |      42 |          3 |      1 | 2026-10-07 12:34:56.789+08  | 1.18
(1 row)
sql
-- 卡住的任务(领取超过租期仍未完成)
SELECT id, locked_by, locked_at, attempts, last_error
FROM jobs
WHERE status = 'processing'
  AND locked_at < now() - interval '10 minutes';

-- 失败任务明细
SELECT id, queue, attempts, last_error, created_at
FROM jobs
WHERE status = 'failed'
ORDER BY updated_at DESC
LIMIT 20;

监控阈值建议:pending 持续增长(积压)、processing 长时间不动(worker 挂)、failed 突增(下游故障)——三个信号分别接入告警。

这套查询走部分索引、开销极低,每 30 秒跑一次直接画进看板毫无压力——数据库队列的可观测性不需要额外组件,一张表的 SELECT 就够。

注意事项 ​

SKIP LOCKED 必须与 FOR UPDATE 同用且在领取事务内。 领取 SQL 与后续处理必须在同一事务中完成状态翻转(或用一条 UPDATE 完成领取),裸 SELECT 出来的任务没有锁保护,等于没领。

领取事务不要混入耗时业务。 领取 → 标记 processing → 提交,然后才执行业务;否则长事务持有的行锁与连接占用会拖垮整个队列。

不保证严格 FIFO。 SKIP LOCKED 的跳过语义决定了顺序可能交错。有顺序要求的任务按键拆分队列、队列内串行处理。

任务必须幂等。 worker 崩溃、超时回收、重试都会导致重复执行。没有幂等的队列实现迟早双发邮件、重复扣款。用 idempotency_key + 已处理登记表是通用解法。

max_attempts 与不可重试错误都要有。 毒丸任务不限次重试会耗尽 worker;余额不足类业务错误重试无意义,直接终态 + 告警。

租期必须大于最长任务耗时。 否则慢任务被回收后重复执行;估算不出上限的任务,改为 worker 心跳续租(定期 UPDATE locked_at)。

队列表是高频 UPDATE 的表,关注膨胀。 调低该表的 autovacuum scale factor,已完成任务定期分批删除,防止索引与表无限增长拖慢领取扫描。

NOTIFY 不是可靠的投递保证。 掉线期间的通知不补发、重复通知会合并,轮询兜底不可省略。

jsonb payload 保持小而简单。 大对象存文件/对象存储,payload 里放引用;payload 里塞几 MB 的 jsonb 会让 TOAST 与 WAL 跟着遭殃。

连接要常驻并单独授权。 worker 使用持久连接与专用数据库角色,权限只限 jobs 表与业务所需表;连接池 transaction 模式下 LISTEN 不可用,worker 应用独立直连。

最佳实践 ​

  1. 优先考虑数据库队列,超过能力边界再上 MQ。 判断标准:任务与业务同库同事务、峰值每秒数千以内、不需要广播/重放语义。
  2. 领取 SQL 固定为「CTE 挑选 + UPDATE 标记 + RETURNING」一条语句。 不自造多语句领取流程,原子性由单条 SQL 保证,团队代码风格也统一。
  3. 部分索引覆盖活跃状态。 WHERE status = 'pending' 的索引让领取扫描与表大小解耦,这是队列表保持长期性能的关键。
  4. 幂等设计先于队列实现。 每个任务带业务幂等键,处理前登记已处理键,冲突即跳过;「至少一次」的投递语义与「精确一次」的业务效果分开对待。
  5. 重试用指数退避 + 抖动 + 上限。 退避基值与最大重试次数按下游承受力设定,终态任务必须有人工或自动的补偿路径。
  6. 超时回收作为后台例程常驻。 回收周期 1 分钟、租期按最长任务 2 倍估算,回收时写清 last_error,方便事后区分「真崩溃」与「慢任务」。
  7. 队列表单独调优 autovacuum 与删除策略。 autovacuum_vacuum_scale_factor = 0.01 起步,done/failed 任务保留 7 天后分批删除,删除与 VACUUM 排进低峰。
  8. 监控三个信号:积压、停滞、失败率。 pending 增长告警、processing 超租期告警、failed 比例超阈值告警;三个指标都从同一张表 SELECT,接入成本极低。
  9. 按业务域拆分队列名。 emails、reports、webhooks 各自独立索引扫描与独立 worker,互不拖累;单一大队列是吞吐与故障隔离的双重风险。
  10. 外部调用拆出事务并做幂等。 数据库事务内只做本地写入;调用第三方时带唯一请求 ID,失败重试由队列机制承担,本地与远端的一致性用对账任务兜底。
  11. worker 无状态可水平扩展。 状态全在表里,worker 只是「领取-处理-标记」的循环;扩容即多起进程,缩容即杀进程,配合租约机制无需优雅关闭。
  12. 把「为什么不用 MQ」写进架构决策记录。 队列选型是最容易被反复挑战的决策,ADR 里写清吞吐、一致性、运维三个维度的量化对比,避免一年后无意义地重做一遍。

下一节

继续学习:多租户设计

参考链接 ​