用 PHP 写一个可靠的队列任务系统

要做什么:让任务在进程死了之后还能活着

发公告要调第三方接口、订单超时要关单、用户注册后要发邮件——这些”不能当场做完”的事都需要队列。”队列”这个约束会让每个设计决策都变重:任何”发出去就算完成”的写法,在进程被杀、数据库连接超时、业务侧重试面前都会丢任务。这篇用一张 MySQL 表加两段 PHP 脚本,做一个不丢、不重、可恢复的队列系统,不引入任何消息队列中间件。

指标 目标值 怎么测
任务不丢 worker 被 kill -9 后,重启能自动接续跑完 插 100 个任务,跑到一半 kill,重启后按状态对账
任务不重 同一幂等键最多入队一次、成功一次 同一幂等键插两次,第二次必须撞唯一约束
超时回收 “执行中”死掉的任务 5 分钟内被重投 插一个 sleep 600 秒的任务,kill worker,观察状态回到待执行
投递延迟 常驻 worker 秒级;cron 方案最差等于 cron 间隔 任务里记录入队与完成时间戳,取差值分布

诚实声明:本文不写”本站实测”数字,以上是验收口径;吞吐量只给定性估算,见文末。

整体架构:一张表,两个角色

生产方(Web 请求 / 业务代码)
   │  INSERT(幂等键唯一约束兜底)
   ▼
jobs 表(MySQL)───── 状态机:pending → running → done / failed
   ▲                            │
   │ UPDATE 抢占(CAS)          │ 超时重投:running 超 5 分钟放回 pending
   │                            ▼
worker(常驻循环 或 cron 拉取)→ 成功置 done;失败退避后可重试

选型一句话:队列介质用 MySQL 而不是 Redis 或 RabbitMQ——任务量和并发没到瓶颈之前,MySQL 的事务与唯一约束白送你可靠性,换 Redis 得自己补持久化策略和重试语义。也不要一上来就上消息队列中间件:瓶颈通常在”任务本身跑多快”,不在”任务怎么排队”。

难点一:状态机与表设计

CREATE TABLE jobs (
  id              BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  idempotency_key VARCHAR(64) NOT NULL,             -- 业务幂等键,如 "order:1001:close"
  payload         JSON        NOT NULL,
  status          TINYINT     NOT NULL DEFAULT 0,   -- 0待执行 1执行中 2成功 3失败
  attempts        TINYINT     NOT NULL DEFAULT 0,
  available_at    DATETIME    NOT NULL,             -- 何时可被领取(重试退避靠它)
  claimed_at      DATETIME    NULL,                 -- 被领取时间,超时判断依据
  created_at      DATETIME    NOT NULL DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uk_idem (idempotency_key),             -- 幂等的最后一道防线在数据库
  KEY idx_poll (status, available_at)               -- worker 轮询只走这个索引
) ENGINE = InnoDB;
从 到 触发 前置条件
待执行 执行中 worker 抢占(CAS 更新) available_at 已到且状态仍是待执行
执行中 成功 处理成功 无
执行中 待执行 超时重投 claimed_at 超过 5 分钟没有进展
执行中/失败 待执行 失败重试 attempts 未达上限,available_at 指向退避后的时间
任意 不再重试 attempts 达上限 留在失败态,等人工处理

反模式一:把队列建在内存里(数组、APCu、Swoole Table),进程一死任务全丢。内存里只能放”正在执行的句柄”,不能放”还没执行的任务”——后者的唯一合法存放地是能和进程生命周期解耦的存储。

生产方入队一侧很简单,幂等键交给唯一约束,撞了就说明任务已在队列里:

// 入队:业务代码只负责"声明要做的事",撞唯一键视为重复投递
$st = $pdo->prepare(
    "INSERT INTO jobs (idempotency_key, payload, available_at)
     VALUES (:key, :payload, NOW())"
);
try {
    $st->execute([':key' => $key, ':payload' => $payload]);
} catch (PDOException $e) {
    if (!isDupEntry($e)) {
        throw $e;                    // 只有唯一约束冲突才忽略,其余照常上抛
    }
    // 已有同键任务:要么在排队要么已成功,本次入队是重复调用,静默返回
}

isDupEntry 判断错误码是否为 1062(MySQL 唯一约束冲突)——这个判断同样适用于其他表,是 PHP 操作 MySQL 时的通用基础设施。注意入队永远不阻塞在”查询是否已存在”上,冲突语义全部交给数据库。

// PDOException 里嵌着 SQLSTATE 与驱动错误码,1062 才是"撞唯一键"
function isDupEntry(PDOException $e): bool
{
    return $e->errorInfo[1] === 1062;
}

难点二:抢占——多个 worker 不踩同一个任务

“先 SELECT 出来再 UPDATE”在多 worker 下必出竞态:两个进程同时查到同一个 id,都认为自己领到了。正确做法是把判断压进一条 UPDATE,用受影响行数判断是否抢到:

// 抢占一个任务:CAS(compare-and-set),谁更新到谁拥有
$st = $pdo->prepare(
    "UPDATE jobs
        SET status = 1, claimed_at = NOW(), attempts = attempts + 1
      WHERE id = :id
        AND status = 0
        AND available_at <= NOW()"
);
$st->execute([':id' => $candidateId]);
if ($st->rowCount() === 1) {
    // 抢到了;rowCount 为 0 说明刚被别的 worker 抢走,换下一个候选
}

候选 id 从轮询索引里取:

$ids = $pdo->query(
    "SELECT id FROM jobs
      WHERE status = 0 AND available_at <= NOW()
      ORDER BY id
      LIMIT 10"
)->fetchAll(PDO::FETCH_COLUMN);
// 逐个对上面的 CAS 尝试,直到抢满或候选用尽

如果 MySQL ≥ 8.0.1(MariaDB ≥ 10.6),可以用 SKIP LOCKED 在事务里一次锁定一批,跳过别的 worker 已锁定的行,减少”白跑一趟”:

SELECT id FROM jobs
 WHERE status = 0 AND available_at <= NOW()
 ORDER BY id
 LIMIT 10
 FOR UPDATE SKIP LOCKED;

注意 SKIP LOCKED 必须在事务里使用,拿到 id 后在同一事务内完成抢占更新;旧版本 MySQL 没有它。CAS 方案在任何版本都能跑,本文以 CAS 为主线。

反模式二:用应用层锁”先查再改”。无论是自己造互斥还是拿 GET_LOCK 当万能锁,锁的粒度和生命周期都交给应用去管:worker 一崩锁还挂着,比没有锁更难排查。抢占语义交给一条 UPDATE 的原子性,崩了就是没抢到,没有中间状态。

难点三:超时重投与幂等——”至少一次”的代价

// 每轮循环顺手做:把"宣称在跑但超过 5 分钟没动静"的任务放回队列
$pdo->exec(
    "UPDATE jobs
        SET status = 0, claimed_at = NULL,
            available_at = NOW() + INTERVAL 60 SECOND
      WHERE status = 1
        AND claimed_at < NOW() - INTERVAL 300 SECOND"
);

重投时把 available_at 往后推 60 秒:刚”死掉”的 worker 可能只是卡顿没死,立刻重投会造成两个进程同时跑同一任务,宽限期宁可慢一点。

关键认知:超时重投让投递语义变成”至少一次”——一个任务可能被真的执行两次(worker 其实活着,只是慢)。所以幂等必须做在业务侧,队列帮不了你:

  • 发邮件、发短信这类天然不幂等的任务:处理成功后写一张以幂等键为唯一约束的完成标记,处理前先查标记;或者接受”极小概率重发一次”的现实,在文案里写”如已收到请忽略”。
  • 关订单、加减余额这类操作:本身就能写成幂等形式——UPDATE orders SET status='CLOSED' WHERE order_no=? AND status='PAID',执行两次结果一样,天然免疫重投。

反模式三:拿 attempts 当幂等保障。“最多重试 3 次”限制的是重试次数,不防”两次执行并存”——超时误判下的双跑不消耗 attempts 的机会窗口,该重复还是会重复。

难点四:worker 常驻还是 cron 拉取

维度 cron 拉取(每分钟跑一个脚本) 常驻 worker(while 循环)
部署心智 简单:进程生命周期归 cron 管 需要 supervisor/systemd 守护与开机自启
投递延迟 最差等于 cron 间隔 秒级
内存与连接泄漏 天然免疫:进程跑完即死 真实风险:要处理连接失效、主动限制内存
代码热更新 天然生效:下次执行就是新代码 要设计优雅重启(处理完当前任务再退出)
适合场景 任务量小、分钟级延迟可接受 任务量大、要求秒级时效

cron 版本是很多系统的正确起点:* * * * * php /app/cron.php,脚本里跑一批(比如最多 100 个)就退出。等 cron 间隔成了延迟瓶颈、或单批跑不完的时候,再换常驻。

// worker.php:php worker.php 运行,由 supervisor 守护
while (true) {
    $n = claimAndRun($pdo, 10);          // 抢一批并执行
    if ($n === 0) {
        sleep(1);                        // 没任务别空转打数据库
    }
    if (memory_get_usage(true) > 128 * 1024 * 1024) {
        exit(0);                         // 内存超限主动退出,交给 supervisor 重启——
    }                                    // 泄漏不可怕,可怕的是不设防
}

常驻进程还要接住连接失效——长连接被 MySQL 的 wait_timeout 掐断是常态,不是异常:

try {
    claimAndRun($pdo, 10);
} catch (PDOException $e) {
    if (str_contains($e->getMessage(), 'MySQL server has gone away')) {
        $pdo = reconnect();              // 重建连接,下一轮照常
    } else {
        throw $e;                        // 其他异常照常上抛,别吞
    }
}

验证与瓶颈定位

# 1. 不丢:插 100 个任务,跑到一半 kill,重启后对账
mysql -e "SELECT status, COUNT(*) FROM jobs GROUP BY status"
1. 2. 不重:同一幂等键插两次,第二次必须报唯一约束错误
mysql -e "INSERT INTO jobs (idempotency_key, payload, available_at)
          VALUES ('order:1:x','{}',NOW())"
1. 3. 超时重投:插一个 sleep 长任务,kill worker,观察状态回到 0
阶段现象 瓶颈在哪 怎么改
轮询越来越慢 done 任务堆在表里,索引与统计信息失真 定期归档终态任务到历史表,活跃表保持小
没任务时数据库负载仍高 空转 sleep 太短 退避:连续空轮时把 sleep 从 1 秒渐增到 5 秒
单 worker 吞吐不够 任务是 IO 密集(调第三方接口),串行在等 先多开几个 worker 进程;再不够走协程化(见下一节)

下一步:协程化演进

当任务 90% 的时间在等第三方接口返回,多进程只是把”等待”复制了 N 份,协程才能让一个进程并发挂起多个等待。这条路上的坐标:

诚实边界:协程化之后连接池、超时控制都要重新审视;而且大多数队列系统的瓶颈在数据库写入而不在 worker 侧——先量化确认瓶颈真的在”等待”,再动架构。

数量级估算

本站未实测,给估算口径:单个常驻 worker 串行处理一个 200ms 的第三方调用,约 5 个任务/秒;开 8 个 worker 进程约 40 个/秒——对”每天几万次”量级的业务已经绰绰有余。够不着的时候,先怀疑任务本身能不能拆小,再怀疑 worker 数量。

总结

  • 可靠性是表结构和 SQL 语义给的:唯一约束管幂等,UPDATE 的原子性管抢占,状态机管恢复——PHP 代码只是把这些语义串起来
  • 超时重投意味着”至少一次”投递,幂等必须做在业务侧,这是队列替不掉的责任
  • cron 拉取是常驻 worker 的合法前身,分钟级延迟可接受就别给自己找守护进程的活
  • 常驻进程的三大病:内存泄漏、连接失效、热更新——每个都要显式设防
  • 演进顺序:先多进程,量化确认瓶颈在”等待”再协程化

版本核实(以官方文档为准):写作时点(2025 年 1 月)PHP 稳定版为 8.4(2024 年 11 月发布),本文代码兼容 PHP 8.1+;Workerman 稳定线为 v5(要求 PHP ≥ 8.1);Swoole 稳定线为 5.1.x,6.0 尚处于 RC 阶段;FOR UPDATE SKIP LOCKED 自 MySQL 8.0.1、MariaDB 10.6 起可用。安装命令均未钉版本号。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇