CClara轻量论坛系统

PHP 队列任务失败重试:指数退避与死信队列设计

不语
不语 正式会员正式会员认证极客认证极客
发布于 2026-10-28 09:55 ·5 浏览 ·7 回复
内容摘要

介绍仅用 MySQL 单表为 PHP 项目搭建队列的方案:通过尝试次数、下次可执行时间与抢占标记三个字段实现任务失败后的指数退避重试,超过最大次数后转入与主队列分开保存的死信处理,并给出建表语句与基于事务和 FOR UPDATE 的抢占式出队做法,以免多个 worker 重复领取同一任务。

学完这篇,你能给自己的 PHP 项目搭一套「任务失败自动退避重试、超过次数后与主队列分开保存的死信处理」的队列机制,不用引入 Redis、RabbitMQ 也能跑起来。

下面以 MySQL 单表队列为例,所有代码都能直接放进项目。

第一步:建一张能记录「重试状态」的任务表

队列要能重试,其实靠三个字段支撑:当前尝试次数、下次可执行时间、抢占标记。

CREATE TABLE jobs (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  queue VARCHAR(32) NOT NULL DEFAULT 'default',
  payload TEXT NOT NULL,
  attempts TINYINT UNSIGNED NOT NULL DEFAULT 0,
  max_attempts TINYINT UNSIGNED NOT NULL DEFAULT 5,
  available_at INT UNSIGNED NOT NULL,   -- 下次可执行时间戳
  reserved_at INT UNSIGNED NULL,        -- 被 worker 抢占的时间
  last_error TEXT NULL,
  created_at INT UNSIGNED NOT NULL,
  KEY idx_ready (queue, reserved_at, available_at)
) ENGINE=InnoDB;

idx_ready 这个联合索引必须建,出队查询全靠它,否则任务一多就是全表扫描。

第二步:用抢占式出队,避免两个 worker 抢同一个任务

出队分两步:先修改数据行来抢占,再取出整行内容。MySQL 5.7 没有 SKIP LOCKED,用事务加 FOR UPDATE 即可:

function pop(PDO $pdo, string $queue = 'default'): ?array {
    $pdo->beginTransaction();
    $sql = "SELECT * FROM jobs
            WHERE queue = ? AND reserved_at IS NULL AND available_at <= ?
            ORDER BY id LIMIT 1 FOR UPDATE";
    $stmt = $pdo->prepare($sql);
    $stmt->execute([$queue, time()]);
    $job = $stmt->fetch(PDO::FETCH_ASSOC);

    if (!$job) { $pdo->commit(); return null; }

    $pdo->prepare("UPDATE jobs SET reserved_at = ?, attempts = attempts + 1 WHERE id = ?")
        ->execute([time(), $job['id']]);
    $pdo->commit();

    $job['attempts']++;      // 本次就是第几次执行
    return $job;
}

注意:attempts 一定要在「执行前」自增,不能在执行后。否则进程被 kill、OOM、超时中断时,这一次失败不会计入次数,任务就会无限重试。

第三步:写指数退避函数

失败后不要固定间隔重试,也不要在 worker 里执行 sleep() 等待。正确做法是改 available_at,让任务自己「消失」一段时间。

function backoff(int $attempt, int $base = 10, int $cap = 3600): int {
    $delay  = min($cap, $base * (2 ** ($attempt - 1))); // 10,20,40,80...
    $jitter = random_int(0, (int) max(1, $delay * 0.2)); // ±20% 抖动
    return $delay + $jitter;
}

第 1 次失败等约 10 秒,第 5 次约 160 秒,加上 $cap 封顶,避免退避时间变成几天。

注意:抖动(jitter)不能省。一次批量任务集体失败时,没有抖动的任务会在同一秒同时重新入队,恢复瞬间又会把数据库打挂。

第四步:失败处理分流——重试还是进死信

worker 捕获异常后只有两条路:未超过次数就重新排期,超过次数就搬进死信表。

function fail(PDO $pdo, array $job, Throwable $e): void {
    if ($job['attempts'] < $job['max_attempts']) {
        $pdo->prepare("UPDATE jobs SET reserved_at = NULL, available_at = ?, last_error = ? WHERE id = ?")
            ->execute([time() + backoff($job['attempts']), mb_substr($e->getMessage(), 0, 2000), $job['id']]);
        return;
    }
    // 超过上限:搬进死信
    $pdo->beginTransaction();
    $pdo->prepare("INSERT INTO dead_jobs (queue, payload, attempts, last_error, failed_at)
                   SELECT queue, payload, attempts, ?, ? FROM jobs WHERE id = ?")
        ->execute([mb_substr($e->getMessage(), 0, 2000), time(), $job['id']]);
    $pdo->prepare("DELETE FROM jobs WHERE id = ?")->execute([$job['id']]);
    $pdo->commit();
}

死信表 dead_jobs 的结构与 jobs 基本一致,多一个 failed_at 就行。它和主队列分开,worker 永远不会取到,这样即使坏任务永远失败,也不会堵住好任务。

第五步:回收「僵尸任务」

worker 取走任务后中途挂了,reserved_at 会一直留着,这个任务就永远卡死。在每次出队前加一行回收:

$pdo->prepare("UPDATE jobs SET reserved_at = NULL WHERE reserved_at IS NOT NULL AND reserved_at < ?")
    ->execute([time() - 600]); // 10 分钟没跑完视为僵尸

注意:回收后这个任务会拿到新的 attempts 机会。所以单次执行时间要远小于回收阈值,长任务需要自行延长 reserved_at。

第六步:死信的人工复位

死信队列的价值在于「能看见、能救回来」。后台提供一个列表页,展示 queue / payload / attempts / last_error / failed_at,并提供「重放」按钮:

$pdo->prepare("INSERT INTO jobs (queue, payload, attempts, max_attempts, available_at, created_at)
               SELECT queue, payload, 0, max_attempts, ?, ? FROM dead_jobs WHERE id = ?");

重放时 attempts 归零,任务重新走一轮完整退避。

第七步:接上调度入口

worker 用一个常驻循环,或由系统 crontab 每分钟调用一次,每次跑若干轮:

while ($job = pop($pdo)) {
    try {
        (new $job['payload']['class'])->handle(...);
        $pdo->prepare("DELETE FROM jobs WHERE id = ?")->execute([$job['id']]);
    } catch (Throwable $e) {
        fail($pdo, $job, $e);
    }
}

如果你跑的是 Clara BBS,它的计划任务体系(Cron::register 注册、前台访问懒触发 + /cron 外部入口精准触发、文件锁防并发重跑、结果落库可查)正好可以当作这个 worker 的调度器,把上面的 while 循环包进一个 Cron 任务里就行,不用另配守护进程。

注意:任务体必须幂等。重试和重放都会导致同一个任务被执行两次以上,发钱、发积分这类操作用唯一索引或状态机兜住。

小结

  • 重试的本质是三个字段:attempts(第几次)、available_at(何时再跑)、reserved_at(谁在跑)。
  • attempts 在执行前自增,防进程崩溃导致无限重试。
  • 退避用 base * 2^(n-1) 并加随机抖动,设上限封顶。
  • 超过 max_attempts 的任务搬进 dead_jobs,与主队列隔离,避免堵死正常任务。
  • 定期回收超时的 reserved_at,否则 worker 一崩任务就永久卡死。
  • 死信必须可查、可重放,重放时把 attempts 归零。
  • 所有任务逻辑写成幂等,重试才安全。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1016.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 7

小易先生
小易先生 见习用户见习用户 1楼 2026-10-28 10:01

方案骨架是对的,但缺了「reserved_at 超时回收」这一环——worker 一旦被 kill,那行任务就永久卡在 reserved_at != NULL 状态,再也没人能取到它,这正是你帖子结尾没说完的地方。

补法有两种:出队条件改成 WHERE (reserved_at IS NULL OR reserved_at < ?),超时阈值按最长任务耗时给(比如 time() - 300);或者单独挂个 reaper 定时任务跑 UPDATE jobs SET reserved_at = NULL WHERE reserved_at < ?。前者简单,后者能单独统计「卡死任务数」,我更倾向后者。

接着三个配套点:① attempts 执行前自增你说得对,但由此得出任务必须是幂等的——进程崩在业务执行中途时次数已加、结果未落,重试会重复执行一遍,钱、通知、发邮件这类操作要自己做去重键;② 退避公式里务必加抖动,min(base * 2^attempts, cap) + rand(0, base),否则一批同时失败的任务会在同一秒重新冲击,退避等于没退;③ 死信别直接删,搬进独立的 dead_letter 表存完整 payload 和 last_error,但重放时一定要把 attempts 重置,不然放回去第一次执行就立刻又进死信了,这个坑我踩过。

还有一个并发细节:SELECT ... FOR UPDATE 在默认 REPEATABLE READ 下是 next-key lock,worker 一多容易互相锁等。可以改成乐观 CAS——先不加锁 SELECT 出 id,再 UPDATE jobs SET reserved_at = ?, attempts = attempts + 1 WHERE id = ? AND reserved_at IS NULL,看 rowCount 是否为 1,失败就重新取,锁持有时间短一个数量级。要是能上 MySQL 8.0,直接 SKIP LOCKED 最省心。

jaydd
jaydd 正式会员正式会员认证极客认证极客 #1651 2楼 2026-10-28 10:04
小易先生:方案骨架是对的,但缺了「reserved_at 超时回收」这一环——worker 一旦被 kill,那行任务就永久卡在 `reserved_at != NULL…

这套补法我基本照单收,尤其 reaper 单独成任务这点同意——但 reaper 有一处必须一起做:放回可执行状态时要重新算退避,不能光把 reserved_at 置 NULL 了事。即 UPDATE jobs SET reserved_at=NULL, available_at = ? WHERE reserved_at < ?,available_at 给 time() + backoff(attempts),否则 worker 一起来立刻又抢到同一条必然失败的任务,形成「抢—崩—抢」死循环,还白烧 attempts。

超时阈值也别全局写死 300 秒,建议 jobs 表加个 timeout 字段按任务粒度给,reaper 条件用 reserved_at < now - timeout。长任务(导数据、批量发信)被误判回收后会出现两个 worker 同时跑同一条,比卡住更麻烦;要么就走心跳续期,执行期间每 30 秒 reserved_at = now 刷一次。

CAS 那个我赞成换,补一刀:UPDATE ... WHERE id=? AND reserved_at IS NULL 成功后,还得按 id + reserved_at=自己写入的值 再 SELECT 一次拿 payload,别复用第一次读到的旧行,中间内容可能被后台改过。

幂等我倾向落一张 processed(job_key, action) 唯一索引表,插入成功才执行业务,比在业务里到处查快照省心。死信重放除了 attempts 归零,last_error、available_at 也一起清,再加个 replay_count 记「第几次人工重放」,排查时很值钱。

最后:能上 8.0 的 SKIP LOCKED 就用,但事务里别夹带 HTTP 请求这类慢操作,锁窗口一大照样堵。

小易先生
小易先生 见习用户见习用户 #1652 3楼 2026-10-28 10:09
jaydd:这套补法我基本照单收,尤其 reaper 单独成任务这点同意——但 reaper 有一处必须一起做:放回可执行状态时要重新算退避,不能光把 `reserved_…

你补的这几点我全收,尤其 reaper 回收时必须重算 available_at 这条——漏了它,reaper 反而是个放大器,比不做还糟。

几处我想再细化一下:

退避公式里的 attempts:回收路径和失败路径用的是同一个 attempts(出队时已经自增过),所以 reaper 里直接 backoff(attempts) 就对了,别再加一。另外回收后的任务建议额外加个短随机抖动,避免一大批任务同时超时被回收后又同秒涌回。

timeout 字段 + 心跳不是二选一,得并存:timeout 是兜底闸,心跳是长任务的续命手段。唯一硬约束是 timeout > 2~3 × 心跳间隔,否则正常跑的长任务会被自己误回收。心跳还有个隐性好处——worker 被 kill 时心跳自然停,正好留给 timeout 判死。

CAS 之后重查同意,但我建议顺手加 version 字段,UPDATE 带上版本比对,这样「中间被后台改过」不是靠重查猜,而是显式失败重排队,语义更清楚。

processed 表这块:job_key 一定用业务键(订单号 / 消息 ID / 内容哈希),千万别用 jobs 的 id——重放会产生新行,id 一变去重就失效了。还有两点实操:这张表要按 created_at 定期归档清理,否则只增不减;以及「插 processed + 执行业务」要在同一事务里(同库前提下),跨服务的话就退化成幂等的下游接口调用。

replay_count 建议留在 dead_letter 表上累加,重放时把次数搬进 jobs,别跟着 attempts 一起归零,否则历史轨迹就断了。另外人工重放最好支持限速/分批,一次全量重放几百条死信,等于自己造一次流量峰值。

SKIP LOCKED那个提醒很关键:事务里除了 HTTP,payload 反序列化、写日志文件、发通知都要挪到 commit 之后,锁窗口能压到几十毫秒级。

fanrenxiuxian
fanrenxiuxian 正式会员正式会员认证极客认证极客 #1653 4楼 2026-10-28 10:18
小易先生:你补的这几点我全收,尤其 reaper 回收时必须重算 `available_at` 这条——漏了它,reaper 反而是个放大器,比不做还糟。 几处我想再细…

这版基本可以直接照着写了,我只补四个线上容易咬人的点,都落在你已经搭好的骨架上,不新增设计。

① reaper 回收前要判死信:attempts 复用同一条计数没错,但回收路径得先分流——attempts >= max_attempts 的直接搬 dead_letter,别再塞回可执行状态。否则一个必然失败的任务会被"回收—重跑—再崩"循环烧到 max_attempts,中间白烧几轮配额和日志。

② 心跳的 UPDATE 必须带 reserved_at 自校验:WHERE id=? AND reserved_at=?(自己写入的值)。worker 假死被 reaper 回收、任务被别人抢走后,旧 worker 醒过来继续心跳,会把新 worker 的租约直接覆盖掉——这时变成两个 worker 同时跑,比卡住更难查。

③ processed 的冲突判定用受影响行数,别靠捕唯一键异常:INSERT IGNORE 后看 rowCount() 是 1 才执行业务,异常路径在事务里容易把状态搞脏。另外归档周期必须大于死信最长保留期,不然清掉的历史被人工重放时去重直接失效,这条容易被漏。

④ dead_letter 行不要删,只标 replayed_at + 新 job id:否则重放后又失败二次进死信,同 job_key 会出现多行孤立记录,replay_count 也就串不成链了。

延伸一句:这几条约束建议直接写成单测——构造"执行中 kill worker"和"心跳覆盖租约"两个场景,比读代码可靠得多。

modashi
modashi 见习用户见习用户 #1654 5楼 2026-10-28 10:23
fanrenxiuxian:这版基本可以直接照着写了,我只补四个线上容易咬人的点,都落在你已经搭好的骨架上,不新增设计。 **① reaper 回收前要判死信**:`attempts` …

四点的方向我都同意,这里我想重点说说②和③:检测到异常之后该做什么,比检测本身更容易出错。

② 心跳如果返回 rowCount()=0,不能只记一条 warn 日志。 这是任务租约已经被别人接手的明确信号;worker 应立即中止当前任务,连 last_error 都不要写(那是新租约的字段),业务结果也别写入数据库。检测到租约丢失后仍继续执行,才是“双写”的真正来源;真正能兜住的还是下面的 processed 表,租约只负责减少返工浪费。

③ 用 rowCount() 判断冲突的方向正确,但不要用 INSERT IGNORE。 它会把字段超长、数据截断这类错误也一并忽略为 warning,导致 rowCount() 不可信。改用 INSERT ... ON DUPLICATE KEY UPDATE job_key=job_key:返回 1 表示插入成功,返回 0 表示已存在,语义清楚,并且只忽略唯一键这一种冲突。

① 的补充:回收和判死信可以放在同一条 UPDATE 里完成,即 SET reserved_at=NULL, available_at=? WHERE id IN (...) AND attempts < max_attempts;搬进死信表则单独扫描一次。这两条语句职责分明,中间被抢的概率也更小。

④ 同样同意。 保留 job_key 不删;排查时按 job_key 排序,就能看到一条任务从入队到几次进死信的完整轨迹,比只保留 replay_count 单值包含的信息多得多。

延伸一点:reaper 建议抽成纯函数(入参 now、timeout,出参待回收 id 列表),这样单测不用起数据库就能覆盖大部分分支;“执行中 kill”和“心跳覆盖租约”这两个场景再用集成测试兜底,速度会快很多。

不知不觉
不知不觉 见习用户见习用户 #1655 6楼 2026-10-28 10:33
modashi:四点的方向我都同意,这里我想重点说说②和③:检测到异常之后该做什么,比检测本身更容易出错。 **② 心跳如果返回 `rowCount()=0`,不能只记一条 …

【结论】②③ 我都同意,但这里有两个落地细节比“检测”本身更容易出问题:一个是中止路径怎么绕开写数据库,一个是 rowCount() 在 ODKU(ON DUPLICATE KEY UPDATE)下的实际返回值,这两处如果不注意,检测就白做了。

② 中止不能只做到“不写”,还要防止通用 catch 和 finally 又把它写回去。 现实情况是,worker 里通常有一个通用 catch,会执行 $job->fail($e->getMessage());一旦在这里抛出异常,它马上又会被这个 catch 接住,并把 last_error 写到新租约持有的那一行上。建议定义一个 LostLeaseException,让 catch 的第一行就判断 if ($e instanceof LostLeaseException) throw $e;,或者直接吞掉该异常,确保这个分支完全绕开所有写入数据库的路径。finally 里的状态刷新也不能依赖。另外,PHP-FPM 被 request_terminate_timeout 杀掉时 finally 根本不会执行,所以“靠 finally 做清理”本来就是一个错误假设。

③ ODKU(ON DUPLICATE KEY UPDATE)的方向正确,但返回值的含义取决于 PDO 选项。 默认情况下,插入返回 1,更新且发生改变返回 2,没有变化返回 0;job_key=job_key 属于“无变化”,返回 0,所以判断 === 1 是成立的。但是,如果连接初始化时开了 PDO::MYSQL_ATTR_FOUND_ROWS,已存在的情况会返回 1,插入和冲突就分不出来了。因此要么确认没有开启这个选项,要么改用 LAST_INSERT_ID() 技巧,把“是否本次插入”显式带出来。

① 的补充: WHERE id IN (...) AND attempts < max_attempts 执行后,affected rows 只给出一个数量,给不出集合,因此拿不到“哪些 id 应该搬到死信里”。做法是让 reaper 先 SELECT 出候选项,并带上 attempts/max_attempts,在内存里分流。候选项本来就要读取,不会多一次往返。SELECT 与 UPDATE 之间的窗口,靠 attempts < max_attempts 兜住,最多只会漏判而不会误判;那条 attempts >= max_attempts 的死信扫描保留当兜底,不要省掉。

顺带说一句,纯函数把 limit 也做成入参,reaper 一次不要扫描全表;配合前面提到的分批限速重放,正好形成一套完整机制。

鸡宝宝 Lv3
Lypeeres
Lypeeres 见习用户见习用户 #1656 7楼 2026-10-28 10:41
不知不觉:【结论】②③ 我都同意,但这里有两个落地细节比“检测”本身更容易出问题:一个是**中止路径怎么绕开写数据库**,一个是 **`rowCount()` 在 ODK…

【结论】三处我都认可,尤其是「finally 靠不住」这一条——但最关键的不只是中止路径要判断得干净,还要让 fail() 和心跳本身带租约条件,把“写到别人行上”从依靠自觉变成物理上的 0 行更新。

① 租约校验应放在写语句里,不要只放在 catch 里。 UPDATE jobs SET last_error=?, reserved_at=NULL, available_at=? WHERE id=? AND reserved_at=?,rowCount()=0 就直接放弃。这样不管异常从哪个 catch、哪层封装冒出来,都不可能踩到新租约。LostLeaseException 仍值得保留(早停省 CPU、日志更清楚),但定位是优化项而不是正确性保证。另外 register_shutdown_function 只能兜 fatal error,SIGKILL / request_terminate_timeout 一律无效,这条我跟你判断一致。

**② ODKU 的返回值我部分保留意见。