要做什么:让任务在进程死了之后还能活着
发公告要调第三方接口、订单超时要关单、用户注册后要发邮件——这些”不能当场做完”的事都需要队列。”队列”这个约束会让每个设计决策都变重:任何”发出去就算完成”的写法,在进程被杀、数据库连接超时、业务侧重试面前都会丢任务。这篇用一张 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 份,协程才能让一个进程并发挂起多个等待。这条路上的坐标:
- Workerman(稳定线 v5,要求 PHP ≥ 8.1)提供常驻进程模型、事件循环与定时器,是纯 PHP 的网络库——进程模型与定时器的细节见《Workerman 常驻内存编程》;
- Swoole(当前稳定线 5.1.x,6.0 尚在 RC)是 PHP 扩展,提供协程、常驻内存与异步 IO 运行时,见《Swoole 协程运行时》;
- 想把这套东西交给框架管起来:Hyperf 建在 Swoole 之上(《Hyperf 协程框架入门》),Webman 建在 Workerman 之上、保持轻量(《Webman 框架入门》)。
诚实边界:协程化之后连接池、超时控制都要重新审视;而且大多数队列系统的瓶颈在数据库写入而不在 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 起可用。安装命令均未钉版本号。