任务队列的可靠性设计

任务队列的可靠性设计

本文整理自实习中接触到的两种任务队列实现:一是纯 Redis 方案,以 list 作队列、以 hash 存状态,由多进程 worker 消费(每个进程内以 asyncio 协程并发);二是 PostgreSQL 方案,以数据库存状态,Redis 仅作信号。二者的差异,最终决定了任务在进程崩溃与高并发场景下是否可靠。


背景:两种实现

在讨论可靠性之前,先说明两种任务队列的实现方式。

第一种(下文称朴素实现):任务队列与任务状态均存放在 Redis 中,前者为 list,后者为 hash,由多个 worker 进程负责消费,每个进程内部以 asyncio 协程并发执行任务。整体流程为:通过 BLPOP 弹出任务,将状态由 queued 改为 running,执行任务,最后将状态改为 completed。

第二种(下文称稳健实现):任务状态存放在 PostgreSQL 中,Redis 的 list 仅用于发出"有任务待处理"的信号。认领任务由一条带条件 WHERE 子句的 UPDATE 语句原子完成,执行期间依靠租约与心跳续租作为保障,worker 启动时还会执行 recover 扫描,将"应当运行而未运行"的任务重新投递。

二者的差别表面上是"状态存放于何处",实际上决定了任务是否会在异常场景中丢失。下文先分析朴素实现的问题,再说明稳健实现如何逐一解决。


一、朴素实现如何丢失任务

任务丢失并非偶然,而对应若干条具体的路径。以下依次说明。

1. 弹出后、状态变化前崩溃

朴素实现的认领分两步:第一步,BLPOP 从 Redis list 弹出 job_id,这一步是原子的,弹出后 job_id 即离开队列;第二步,take_job 将 hash 中的 status 由 queued 改为 running。

问题出在两步之间的时间窗口:若 worker 在弹出之后、修改状态之前崩溃(内存溢出、机器重启、进程被杀),job_id 已经不在队列中,但 hash 中的状态仍为 queued。

关键在于,没有任何机制去扫描"不在队列中但状态仍为 queued"的任务。队列与状态都在 Redis 中,BLPOP 弹出后队列即空,无从得知该任务需要被重新消费。于是任务成为孤儿:状态可查到为 queued,却永远不会被执行。这就是任务的丢失。

2. 状态改为 running 之后、完成之前崩溃

take_job 成功后 status 为 running,worker 开始执行任务。若执行到一半崩溃,则 status 永远停留在 running,队列中已无该 job_id,且没有心跳、没有超时检测、没有恢复机制。任务对外表现为"一直处于 running",实际无人执行。这是"半丢失":状态机卡死,永远不会收敛到 completed 或 failed,也不会被重新投递。

3. 进程崩溃后缺少恢复机制,在途任务仍然丢失

朴素实现采用多进程部署,每个 worker 进程内部以 asyncio 协程并发执行多个任务,进程之间通过 Redis 共享队列与状态。相较于单进程,多进程将崩溃的影响范围限制在单个进程之内:一个进程崩溃只影响其在途的任务,其余进程不受影响。

然而这一改进并不能消除丢失。无论影响范围大小,崩溃进程在途的任务一旦落入前述两种场景(孤儿或卡死),便再也不会被重新投递:没有心跳、没有租约、没有 recover 扫描,Redis 中也没有任何信息能够表明这些任务需要重新执行。Celery 的多进程 prefork 模型同样是一个进程崩溃只丢失其当前任务,但它带有 ack 与重投机制,任务未确认时会被重新分发;朴素实现则完全缺少这一层保障。

4. 中间结果不落盘

朴素实现的中间产物(执行状态、抽取结果、评分结果)全部保存在 worker 进程内存中,只有最终报告写入对象存储。若 worker 在"执行完毕但尚未写入结果、尚未将状态改为 completed"时崩溃,重新执行便需从头开始,先前抽取与评分的结果全部丢失,因为没有 checkpoint。

除上述四条路径外,其余风险(Redis 单点故障、TTL 到期遇上极端积压、连接池耗尽导致吞吐下降)属于次要问题,此处不展开。它们的共同根源在于:纯 Redis 方案没有一张持久化的状态表,无法发现"应当执行而未执行"或"应当完成而未完成"的孤儿任务。

综上所述,朴素实现丢失任务的本质原因在于:队列与状态都存放在 Redis 中,BLPOP 弹出后任务即离开队列,而状态翻转与完成是其后相互独立的步骤。worker 在任意中间环节崩溃,任务便既不在队列中,又停留在错误状态,且缺少持久化状态表进行兜底,因此进程一旦崩溃,其在途任务即成为孤儿或卡死,且不会再被重投。


二、PostgreSQL 相关的三个基础概念

在说明稳健实现之前,先补充三个 SQL 概念,它们足以理解后文。

1. UPDATE ... RETURNING *

UPDATE 本身会加行锁,与 SELECT FOR UPDATE 一样锁住被修改的行。RETURNING * 使 UPDATE 在修改的同时返回修改后的行,即"修改"与"读取"一次完成。认领任务因此无需先 SELECT FOR UPDATE 再 UPDATE 两步,而是一条 UPDATE ... WHERE ... RETURNING * 原子地完成"判断能否认领、修改状态、返回记录"三件事。

2. WHERE 条件中的 CAS 语义

关键在于 WHERE status IN ('queued','retrying')。这一 WHERE 子句将"仅当任务处于可认领状态时才允许修改"这一约束写入了 SQL。当两个 worker 同时执行该 UPDATE 时,PostgreSQL 的行锁保证同一行同一时刻只有一个事务可以修改(另一事务阻塞等待);第一个 worker 修改成功,将 status 改为 running;第二个 worker 获得锁后重新执行 WHERE,此时 status 已为 running,条件不匹配,更新 0 行,RETURNING 返回空,即认领失败。

这就是"以 WHERE 条件充当状态机 CAS":WHERE status IN (...) 是比较,UPDATE 是赋值,二者结合即 compare-and-swap。无需额外的 SELECT FOR UPDATE,因为 UPDATE 本身即是原子的条件更新。


三、稳健实现的完整流程

任务从创建到完成,可分为七个环节。

环节 1:入队(双写)

INSERT INTO innovation_polish_jobs (job_id, status='queued', ...);  -- 先写 PostgreSQL
rpush queue_key job_id;                                             -- 再推 Redis

顺序为先写 PostgreSQL 再推 Redis。PostgreSQL 写入成功即表示任务已持久化,Redis 的 push 仅用于发出"有任务待处理"的信号。

环节 2:worker 领取任务(从 Redis)

job_id = blpop(queue_key, timeout)

这一步只是从 Redis list 取得一个 job_id 信号,任务尚未被认领,PostgreSQL 中的状态仍为 queued。若 worker 取得 job_id 后崩溃,任务在 PostgreSQL 中仍为 queued,可被环节 7 的 recover 恢复。

环节 3:认领(原子 CAS 与授租约)

UPDATE innovation_polish_jobs
SET status='running',
    worker_id=$2,
    heartbeat_at=$3,
    lease_expires_at=$4,          -- now + lease_seconds(租约到期时间)
    attempt_count = attempt_count + 1
WHERE job_id=$1
  AND ( status IN ('queued','retrying')                    -- 正常可认领
        OR (status='running' AND lease_expires_at < $3) )  -- 或租约过期可抢占
RETURNING *;

一条语句完成三件事:WHERE 子句负责 CAS 判断,保证只有处于 queued、retrying 或租约过期的 running 状态才能被认领;SET 子句负责修改状态并授租约,写入 running、worker_id 与 lease_expires_at;RETURNING * 负责返回修改后的记录。

若两个 worker 同时认领同一 job_id,第一个成功,第二个在获得行锁后发现 WHERE 条件不匹配(status 已为 running),更新 0 行,认领失败并跳过该任务,转而领取下一个。幂等性即在此得到保证。

认领涉及两个关键字段:

字段 含义 写入方
heartbeat_at 最近一次心跳的时间戳 认领时写入,续租时更新
lease_expires_at 租约到期时间(此后其他 worker 可抢占) 认领时写入 now + lease_seconds,续租时顺延

环节 4:执行任务与续租

worker 认领成功后,启动一个独立协程周期性续租:

UPDATE innovation_polish_jobs
SET heartbeat_at=$3,
    lease_expires_at = now + lease_seconds
WHERE job_id=$1 AND worker_id=$2 AND status='running'
RETURNING job_id;

WHERE 子句中的 worker_id=$2 是所有权校验。若任务已被其他 worker 抢占(租约过期,worker_id 已改变),该 UPDATE 返回空,heartbeat loop 发现 updated 为 False,随即触发 owner_lost_event,使正在执行任务的 worker 停止运行(执行 LLM 调用时依靠 asyncio.wait 与事件软取消协程)。

环节 5:完成或失败(写 PostgreSQL,结果落对象存储)

UPDATE innovation_polish_jobs SET status='completed', result_oss_key=..., finished_at=... WHERE job_id=$1;

结果体写入对象存储,PostgreSQL 仅保存 key。此步只写 PostgreSQL,不涉及 Redis。失败时同理,将 status 置为 failed 并写入 error。

环节 6:后续状态仅在 PostgreSQL 中流转

至此任务执行完毕。Redis list 在环节 2 已清空,此后的状态流转(running 到 completed)全部在 PostgreSQL 中进行,不存在双写一致性问题。

环节 7:容灾恢复(recover)

UPDATE innovation_polish_jobs
SET status = CASE WHEN status='running' THEN 'retrying' ELSE status END,
    worker_id='', heartbeat_at=NULL, lease_expires_at=NULL
WHERE status IN ('queued','retrying')                       -- ① 从未被认领,或上次重投未跑完
   OR (status='running' AND (lease_expires_at IS NULL OR lease_expires_at < NOW()))  -- ② 认领后租约过期
RETURNING job_id;
-- 对每个返回的 job_id:rpush queue_key job_id,重新推回 Redis 队列

该环节在 worker 启动时执行,覆盖两类情况:其一,队列中的孤儿任务,即 PostgreSQL 中为 queued、Redis list 中却不存在(入队时 Redis push 失败,或被弹出但未认领成功),此时从 PostgreSQL 重新推入;其二,卡死的 running 任务,即已认领但租约过期(worker 崩溃或卡死未续租),此时将状态改为 retrying 并重新推入队列,等待其他 worker 认领。


四、三个机制对应三个问题

将上述流程抽象为三个机制,恰好对应纯 Redis 方案缺失的三项能力。

机制一:解决任务丢失

BLPOP 弹出即意味着任务从队列中消失,但任务状态保存在 PostgreSQL 中。即使 Redis list 丢失或清空,PostgreSQL 中 status 为 queued 的记录仍然存在,recover 时可据此重建。Redis 丢失后可以恢复。

机制二:认领的原子 CAS,解决重复与两步窗口

认领由一条 UPDATE 原子地完成判断、修改与读取,消除了"已弹出但未修改状态"的中间窗口。WHERE 子句中的状态机条件保证同一任务同一时刻只有一个持有者:两个 worker 竞争时,行锁使只有一个成功,另一个因条件不匹配而返回空。重复投递因此天然被状态机拦截,这就是幂等的实现。

机制三:租约与续租,解决卡死

running 状态的任务带有一个绝对到期时间 lease_expires_at。worker 存活时定时续租、不断顺延;进程崩溃后不再续租,租约自然到期,任务变为可抢占状态。


五、幂等:重复入队

recover 重推或入队时的异常,可能使同一个 job_id 在 Redis list 中多次出现。这不影响正确性,因为幂等由认领的 CAS 保证,而非依赖队列中元素不重复:

  1. worker A 弹出第一个 jobX,执行认领,UPDATE ... WHERE status IN ('queued','retrying') 成功,status 由 queued 变为 running;
  2. worker B 弹出第二个 jobX,执行认领时 WHERE 条件中的 status 已为 running,不匹配,更新 0 行,RETURNING 为空,认领失败并跳过。

因此同一任务的多次入队中,只有一次能够真正认领成功,其余均被幂等地跳过。


六、为何不用celery

最后讨论一个常见问题:这套 worker 的实现方式,以及为何不使用 Celery。

实现上,worker 以多个进程运行,每个进程内部通过 asyncio.create_task 启动若干个协程(10 个),各进程独立运行自己的事件循环,通过 Redis 队列共享任务。提交接口 POST /jobs 仅向 Redis push 一个 job_id 便立即返回 202,实际分析由这些后台进程中的协程消费。多个进程与协程竞争同一队列时,依靠 Redis BLPOP 的原子性完成分派:同一队列上的多个客户端阻塞等待时,一个元素只能被一个客户端取得,无需在应用侧加锁。

不使用 Celery,主要是以下技术层面的考量:

  • 任务几乎全部为 IO 密集(调用 LLM、解析、下载、上传对象存储),每个进程内以协程并发,资源开销低于为每个任务启动一个独立的 Celery worker 进程;
  • 队列本身即 Redis 的 BLPOP,再引入 Celery 相当于在 Redis 之上再叠加一层任务协议,增加的抽象层收益有限。

相应的代价是:进程崩溃后缺少 Celery 的 ack 与重投机制,在途任务的恢复需要依赖应用自身的逻辑(即第一部分所讨论的缺陷);阻塞性 CPU 操作需在每个进程内手动以 asyncio.to_thread 隔离;进程内的限流(asyncio.Semaphore)不跨进程,多进程部署时实际的 LLM 并发数等于进程数乘以单进程内的限制,要实现真正的全局上限需引入 Redis 分布式限流。


posted @ 2026-08-31 18:11  Sun-Wind  阅读(7)  评论(0)    收藏  举报