简介:这份资源面向具备PHP基础、希望用Redis与Swoole构建延时消息队列消费端的中高级开发者,重点解决延迟消息到期触发、多进程并发消费与吞吐提升等问题。内容围绕Redis Sorted Set以时间戳作score组织延时消息、通过ZRANGEBYSCORE拉取到期任务,以及Swoole多进程模型下多个消费者并行处理的实现思路展开,并涉及Serve消息队列消费服务的组织方式。压缩包约1.66MB,现有条目未给出有效文件总数与类型明细,需解压后结合源码查看Redis交互、进程配置和消息处理逻辑。已有1269人浏览学习。读者可据此理解延时队列调度机制、多进程消费拆分方式与非阻塞I/O下的性能取舍,适合作为高并发PHP消息处理方案的学习参考和二次开发起点。
1. 延时队列不是“睡一觉再重试”:先看清这套 Redis + Swoole 多进程消费端管什么
订单超时关单、优惠券到期提醒、支付回调失败后第 3 分钟补一次——这类“到点才干活”的需求,很多 PHP 团队第一反应是上专业消息中间件,结果运维成本比业务代码还高。这套 Redis 延时消息队列配 Swoole 多进程消费端的思路是另一条路:ZSET 按到期时间排序存任务,Lua 脚本把到期任务原子搬进就绪池,Swoole 起一组常驻进程把任务取走执行。它适合单机日百万级以内、已经有 Redis、不想为延时能力单独养一套集群的 PHP 项目;不适合要求严格顺序、跨机房强一致、或者单机日千万级的场景,那种量级老老实实上专用组件。下面按「数据怎么摆 → 进程怎么起 → 可靠性怎么兜 → 哪里会翻车」拆开讲。
2. 数据怎么摆:ZSET 到期排序、Lua 原子搬运、LIST 就绪池
延时队列最容易写错的地方不是消费逻辑,是数据结构。见过太多实现把任务直接塞一个 LIST,然后消费端sleep到点再取——那是定时任务,不是队列:任务没法按到期时间排序,进程一重启全乱,积压了也没法按时间窗排查。正确的摆法是「到期时间」和「就绪顺序」分开存,中间靠一次原子搬运连起来。这一章先把键设计定死,后面的多进程消费端才有稳定的地基。
2.1 四个键的职责边界
一个 topic 对应四个键,各管一件事,不要合并:
| 键 | 类型 | 元素内容 | score | 谁写 | 谁读 |
|---|---|---|---|---|---|
delay:pool:{topic} | ZSET | 任务 JSON(含 uuid) | 到期时间戳(毫秒) | 生产者 | 扫描进程 |
delay:ready:{topic} | LIST | 任务 JSON | 无 | 扫描进程 | 消费进程 |
delay:running:{topic} | ZSET | 任务 JSON | 可见性超时时间戳(毫秒) | 消费进程 | 回收扫描 |
delay:dead:{topic} | LIST | 任务 JSON + 失败原因 | 无 | 消费进程 | 人工/补偿脚本 |
delay:pool用 ZSET 而不是 LIST,是因为要按 score 做范围查询:ZRANGEBYSCORE -inf now LIMIT 0 200一次就能捞出所有已到期任务,复杂度 O(log N + M),积压百万条也不会全表扫。任务是整段 JSON 直接当 member,好处是搬运、ack、重投全程只操作一个字符串,不用二次查 Hash;代价是 member 偏大,任务体建议控制在 4KB 以内,超了大字段另存 key、member 里只放引用。
delay:running是关键设计。任务出队后立刻在这里登记一条,score 设为「当前时间 + 可见性超时」。进程如果被 kill -9、机器断电、或者业务代码卡死,这条记录会一直躺在里面;扫描进程定期捞 score 小于当前时间的,判定为「疑似丢失」,重新投回延时池。没有这个集合,任务一旦出队就再也找不回来,属于典型的黑匣子。
提示:member 必须是任务 JSON 原文,不要用 uuid 当 member 再去 Hash 里取值。ack 时需要原样字符串做
ZREM,一旦两边不一致就删不掉,running 集合会越积越大。
2.2 搬运脚本把「扫描 + 出队」压成一次原子动作
新手最容易写的版本是这样:
// 反例:三步非原子,进程在任意一步挂掉都会出问题 $items = $redis->zRangeByScore($poolKey, '-inf', $now, ['limit' => [0, 200]]); foreach ($items as $item) { $redis->rPush($readyKey, $item); // 若此处进程崩溃,ready 有数据但 pool 没删 $redis->zRem($poolKey, $item); // 重复投递已发生 }崩在中间就是重复投递,而且没有任何痕迹能定位。正确做法是把「取一批 + 推入就绪池 + 从池里删除」塞进一个 Lua 脚本,Redis 单线程执行期间不会被打断:
-- delay_move.lua -- KEYS[1] = delay:pool:{topic} KEYS[2] = delay:ready:{topic} -- ARGV[1] = 当前毫秒时间戳 ARGV[2] = 单批上限 -- ARGV[3] = ready 队列长度上限(防止消费端挂了导致内存暴涨) local now = tonumber(ARGV[1]) local batch = tonumber(ARGV[2]) local maxLen = tonumber(ARGV[3]) -- ready 已经堆积过多就不再搬,让上游感知背压 if redis.call('LLEN', KEYS[2]) >= maxLen then return -1 end local items = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', now, 'LIMIT', 0, batch) local n = #items if n == 0 then return 0 end -- 先删后推:删成功才推,避免同一批被两个扫描进程重复搬 for i = 1, n do if redis.call('ZREM', KEYS[1], items[i]) == 1 then redis.call('RPUSH', KEYS[2], items[i]) end end return n逻辑上做了三件事:按 score 捞到期任务、逐条ZREM抢所有权、抢到才RPUSH。ZREM返回 1 表示这条归我,返回 0 说明被别的扫描进程先拿走了,直接跳过——这就是多扫描进程并存时的去重手段,不需要额外加分布式锁。
参数上,ARGV[2]单批上限别超过 500。Lua 里循环ZREM是逐条 RPC,批量太大时脚本执行时间会顶到lua-time-limit(默认 5 秒),期间整个 Redis 阻塞,线上表现是其他业务接口突然抖动。ARGV[3]的 ready 上限按「消费 TPS × 允许积压秒数」估,比如消费 2000/s、允许积压 30 秒,就设 60000;触发 -1 时扫描进程要打日志告警,这是消费端扛不住的最早信号。
PHP 侧调用:
$script = file_get_contents(__DIR__ . '/lua/delay_move.lua'); $sha = $redis->script('load', $script); $moved = $redis->evalSha($sha, [ $poolKey, $readyKey, (int) (microtime(true) * 1000), 200, 60000, ], 2); if ($moved === -1) { // ready 堆积,触发背压告警,同时降低扫描频率 $logger->warning('ready backlog', ['topic' => $topic]); }用evalSha而不是每次传完整脚本,省带宽;Redis 重启后脚本缓存会丢,捕获NOSCRIPT错误再script load一次即可,别在每次循环里都 load。
2.3 就绪池与可见性集合怎么配合
任务进了 ready 之后,消费进程要做一个「出队 + 登记」的组合动作。这里有个坑:BLPOP是阻塞命令,没法放进 Lua,所以只能拆两步。主流有两种写法:
第一种是非阻塞LPOP加短睡,两步合成一个 Lua:
-- pop_mark.lua -- KEYS[1] = delay:ready:{topic} KEYS[2] = delay:running:{topic} -- ARGV[1] = 可见性超时时间戳(毫秒) local item = redis.call('LPOP', KEYS[1]) if not item then return false end redis.call('ZADD', KEYS[2], ARGV[1], item) return item第二种是BRPOP阻塞拿任务、再单独ZADD登记。后者省 CPU,但两步之间有个极短窗口:任务已经出队、还没登记,进程此刻被 kill 就彻底丢了。第一种写法用 50ms 空转换掉这个窗口,单进程每秒多 20 次空查询,对 Redis 来说可以忽略。我一般选第一种,宁可多几次空转,也不要那个丢任务的窗口——真丢了任务,排查成本远高于这点 CPU。
回收逻辑同样用 Lua,扫delay:running里 score 已过期的:
-- reclaim.lua -- KEYS[1] = delay:running:{topic} KEYS[2] = delay:pool:{topic} -- ARGV[1] = 当前毫秒 ARGV[2] = 单批上限 ARGV[3] = 重投延迟毫秒 local now = tonumber(ARGV[1]) local batch = tonumber(ARGV[2]) local back = tonumber(ARGV[3]) local items = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', now, 'LIMIT', 0, batch) local n = 0 for i = 1, #items do if redis.call('ZREM', KEYS[1], items[i]) == 1 then -- 不立即重投,退避一段时间,避免坏任务把消费端打爆 redis.call('ZADD', KEYS[2], now + back, items[i]) n = n + 1 end end return n注意重投不是直接塞回 ready,而是塞回 pool 并给个退避时间。否则一个必崩的任务会在几秒内被反复捞起、反复崩,形成重试风暴,线上表现为 CPU 打满但业务量没涨。
2.4 参数表:扫描间隔、批量、score 精度
| 参数 | 建议值 | 依据 |
|---|---|---|
| score 精度 | 毫秒 | 秒级精度下同一秒的任务会成批涌出,整点尖峰明显 |
| 扫描间隔 | 100ms | 决定延时精度上限,误差不超过一个间隔 |
| 单批搬运量 | 200 | Lua 内逐条 ZREM,超过 500 有阻塞风险 |
| ready 长度上限 | 消费 TPS × 30 | 触发即背压,是消费能力不足的预警线 |
| 可见性超时 | 业务 P99 耗时 × 2,最低 30s | 太短会误判执行中的任务,太长故障恢复慢 |
延时精度这块要提前跟产品对齐:100ms 扫描间隔意味着「第 30 分钟整执行」实际上可能是 30 分 00.1 秒。如果业务要求秒级严格,间隔调到 20ms,代价是空查询量翻五倍,得算一下 Redis 的 QPS 余量。
3. 多进程消费端骨架:Swoole\Process 池、信号接管与优雅退出
单个 PHP 进程消费,一天几十万条勉强够用;一旦上量,单进程就是天花板——CPU 只用了一个核,一条慢任务就把后面全堵住。多进程消费端解决的就是这个,但多进程带来的新问题是:父进程怎么管住子进程、子进程挂了怎么拉起、发版时怎么不丢任务地退出。这三点做不好,多进程只是把单点故障放大成多点故障。
3.1 为什么不用 Server 的 task_worker
Swoole 里能跑多进程的方案有好几种,选型时容易走弯路。
Swoole\Server的 task_worker 看着现成,但它是绑在 Server 生命周期上的:为了跑消费端专门起一个不监听端口的 Server,等于为了用 task 机制硬塞一个不需要的网络服务;而且 task_worker 的数量、重启策略跟 Server 配置耦合,调消费并发要去动 Server 参数,边界很糊。
Swoole\Coroutine单进程多协程,IO 密集场景确实更省资源。但消费端里常有同步阻塞的老业务代码(比如某些 SDK、某些 DB 封装),一个协程卡住整个进程的调度,反而比多进程更危险。多进程的隔离性在这里是优点:一个任务把进程搞崩,只影响这一个进程。
所以选Swoole\Process自己拉进程池,进程数、重启、退出全部自己控制,行为可预期。进程数按CPU 核数 × 1.5起步,任务偏 CPU 密集就设成核数,偏 IO 等待可以到核数 × 3。
3.2 父进程:拉进程、转发信号、回收僵尸
父进程的活很单纯:拉起子进程、接信号、等子进程退出、退出了就重启。
<?php declare(strict_types=1); final class WorkerPool { /** @var array<int, Swoole\Process> pid => process */ private array $workers = []; private bool $shutdown = false; public function __construct( private \Redis $redis, private string $topic, private int $workerNum = 8, private int $scannerNum = 2 ) {} public function run(): void { // 扫描进程与消费进程分开,避免 CPU 密集的消费把扫描饿死 for ($i = 0; $i < $this->scannerNum; $i++) { $this->spawnScanner($i); } for ($i = 0; $i < $this->workerNum; $i++) { $this->spawnWorker($i); } // 父进程只接 SIGTERM,然后逐个通知子进程 Swoole\Process::signal(SIGTERM, function () { $this->shutdown = true; foreach ($this->workers as $pid => $proc) { Swoole\Process::kill($pid, SIGTERM); } }); $this->reapLoop(); } private function reapLoop(): void { while (true) { // 非阻塞 wait:阻塞式 wait 会让信号回调迟迟得不到执行 $ret = Swoole\Process::wait(false); if ($ret !== false) { $pid = $ret['pid']; unset($this->workers[$pid]); if ($this->shutdown) { if (count($this->workers) === 0) { break; // 全部子进程已退出,父进程收工 } continue; } // 非正常退出:休息一下再拉,避免崩溃循环打满 CPU usleep(500000); $this->spawnWorker($pid % $this->workerNum); } usleep(100000); // 100ms 轮询,够用且不吃 CPU } echo "all workers exited\n"; } }Swoole\Process::wait(false)那个false是必填项。用阻塞版本的话,父进程卡在 wait 上,SIGTERM 回调要等到下一个子进程退出才执行,发版时表现为「kill 命令发了十几秒没反应」。加上usleep(100000)后父进程每秒醒 10 次,信号响应延迟在 100ms 内,实测够用。
重启前那个usleep(500000)是血泪经验。早期版本没加,某个必崩的任务进来后,子进程起来拿任务、崩、父进程立刻拉起、再拿再崩,半分钟内 8 个进程重启了上万次,日志直接刷爆磁盘。加 500ms 退避之后,崩溃频率被压到可控范围,同时异常也很显眼。
3.3 子进程:取任务、执行、ack 的闭环
子进程里最重要的是三件事:信号只置标志位、任务执行包 try/catch、无论成败都要处理 running 记录。
private function spawnWorker(int $index): void { $proc = new Swoole\Process(function (Swoole\Process $p) use ($index) { $readyKey = "delay:ready:{$this->topic}"; $runningKey = "delay:running:{$this->topic}"; $visibleMs = 30000; $stop = false; // 关键:信号回调里只改标志位,绝不做业务、绝不 exit Swoole\Process::signal(SIGTERM, function () use (&$stop) { $stop = true; }); while (!$stop) { $raw = $this->popAndMark($readyKey, $runningKey, $visibleMs); if ($raw === null) { usleep(50000); // 50ms 空转 continue; } $job = json_decode($raw, true); try { $this->handle($job); // 业务处理 $this->redis->zRem($runningKey, $raw); // ack } catch (\Throwable $e) { // 失败也要先清 running,再按重试次数决定去向 $this->redis->zRem($runningKey, $raw); $this->retryOrDead($job, $raw, $e); } } $p->exit(0); }, false, false); $proc->start(); $this->workers[$proc->pid] = $proc; } private function popAndMark(string $readyKey, string $runningKey, int $visibleMs): ?string { $now = (int) (microtime(true) * 1000); $res = $this->redis->eval( "local item = redis.call('LPOP', KEYS[1]) if not item then return false end redis.call('ZADD', KEYS[2], ARGV[1], item) return item", [$readyKey, $runningKey, $now + $visibleMs], 2 ); return $res === false ? null : (string) $res; }几个细节值得说。$p->exit(0)而不是exit(0):后者是 PHP 原生退出,Swoole 的进程管理收不到正确状态,父进程 wait 出来的是异常码,容易误判成崩溃然后无脑重启。信号回调里只改$stop不做别的:如果在回调里做redis->close()之类,一旦回调重入就会拿到已关闭的连接,报一堆看不懂的错。
popAndMark里 visible 设为 30 秒,意味着任务执行超过 30 秒就会被回收扫描判定为丢失、重新投递。所以长任务要单独处理——要么按实际耗时把 visible 调大,要么在执行过程中定期续期(把 running 里这条的 score 往后推)。续期代码三行,但能避免大量「任务明明执行成功了却被重投」的幽灵问题:
// 长任务每 10 秒续一次可见性超时 Swoole\Timer::tick(10000, function () use ($runningKey, $raw, $visibleMs) { $this->redis->zAdd($runningKey, (int)(microtime(true)*1000) + $visibleMs, $raw); });3.4 优雅退出要做的三件事
发版时kill一下就跑,是丢任务的头号原因。完整的退出流程要保证三点:
第一,父进程收到信号后立刻转发给所有子进程,自己停止重启逻辑($this->shutdown = true这一步),否则刚杀掉的进程会被立刻拉起来。
第二,子进程收到信号后不再取新任务,但当前手上的任务必须跑完。这就是为什么信号回调只置标志位——while (!$stop)会在当前循环结束后自然退出,正在执行的handle()不受影响。
第三,给退出设一个上限。业务代码里有超时兜底还好,没有的话可能永远退不掉。父进程转发信号后启动一个 30 秒的定时器,到点还有子进程活着就SIGKILL强杀,靠 running 集合的回收机制兜底。这部分不是完美方案,但比无限等待强。
# 发布脚本里的退出片段 kill -TERM $(cat /var/run/delay-consumer.pid) for i in $(seq 1 30); do if ! kill -0 $(cat /var/run/delay-consumer.pid) 2>/dev/null; then echo "graceful exit in ${i}s" exit 0 fi sleep 1 done echo "force kill, rely on running reclaim" kill -9 $(cat /var/run/delay-consumer.pid)这个循环在发布脚本里跑了两年多,最长一次是某个任务卡在外部 HTTP 调用上,30 秒后强杀,任务靠回收重新投递,最终执行成功。整个过程业务无感,但日志里那次重投记录后来成了排查超时问题的线索。
4. 可靠投递的参数账:可见性超时、重试阶梯与死信兜底
到这里,任务能进能出能跑了。但真正上生产前必须回答一个问题:这套东西的语义到底是 at-most-once 还是 at-least-once?答案是后者——任何环节崩溃都可能导致重复投递。承认这一点,然后把幂等和重试阶梯做扎实,比追求「绝不重复」实际得多。
4.1 至少一次语义从哪里来
重复会在三个地方产生,逐个看清楚:
场景一,popAndMark的 Lua 执行成功、任务已进 running,但业务代码执行完、zRem之前进程崩了。回收扫描发现这条超时,重新投回 pool,任务被执行第二次。
场景二,任务执行成功、zRem也成功了,但 Redis 主从切换时这条ZREM没同步到从库,从库升主后 running 里还有记录,超时后重投。
场景三,可见性超时设得太短,任务还在跑就被判定超时,另一个进程把它捞走了。
三种场景的共同点是:任务会执行不止一次。所以消费端必须做幂等,这是硬约束,不是可选项。常见做法是在handle()入口用 Redis 做一次去重:
private function handle(array $job): void { $idemKey = "idem:{$this->topic}:" . $job['uuid']; // SET NX EX:第一次执行为 true,重复投递为 false $first = $this->redis->set($idemKey, '1', ['nx', 'ex' => 86400]); if (!$first) { return; // 已处理过,直接当成功,走正常 ack } // ... 真正的业务逻辑 }TTL 设 24 小时,覆盖最长的重投窗口即可。注意这个去重键要在业务成功后才有意义,如果业务抛异常,得把键删掉,否则重试会被自己挡掉——这个顺序很多人写反,结果是「任务失败了一次就再也不重试了」。
4.2 重试阶梯与退避表
失败重试不能是固定间隔。固定 5 秒重试,遇到下游服务整体抖动时,等于给对方持续加压,本来 30 秒能恢复的,被自己打挂十分钟。指数退避是标配:
| 重试次数 | 下次执行延迟 | 累计耗时 | 适用判断 |
|---|---|---|---|
| 1 | 5 秒 | 5s | 网络抖动、瞬时超时 |
| 2 | 30 秒 | 35s | 下游短暂不可用 |
| 3 | 2 分钟 | 2m35s | 下游重启中 |
| 4 | 10 分钟 | 12m35s | 需要人工介入的早期信号 |
| 5 | 30 分钟 | 42m35s | 最后一次机会 |
| > 5 | — | — | 进死信,停止自动重试 |
private function retryOrDead(array $job, string $raw, \Throwable $e): void { $retry = (int) ($job['retry'] ?? 0) + 1; $job['retry'] = $retry; $job['last_error'] = mb_substr($e->getMessage(), 0, 500); if ($retry > 5) { // 死信保留原始报文和错误,方便补偿脚本重放 $this->redis->rPush("delay:dead:{$this->topic}", json_encode([ 'job' => $job, 'error' => $e->getMessage(), 'trace' => mb_substr($e->getTraceAsString(), 0, 2000), 'at' => date('Y-m-d H:i:s'), ], JSON_UNESCAPED_UNICODE)); return; } $delayMs = [0, 5000, 30000, 120000, 600000, 1800000][$retry]; $score = (int) (microtime(true) * 1000) + $delayMs; // 改过 retry 计数的新报文重新入池,旧报文不会再出现 $this->redis->zAdd("delay:pool:{$this->topic}", $score, json_encode($job, JSON_UNESCAPED_UNICODE)); }这里有个容易忽略的点:重投时 member 变了(多了 retry 和 last_error 字段),所以不能直接拿原$raw塞回去,否则 retry 计数永远停在 1,无限重试。必须重新json_encode后写入,让新报文成为新的 member。
另外错误信息要截断。见过一个项目把完整 stack trace 写进任务体,单条任务涨到 200KB,几十条死信就把 Redis 内存打上去了。500 字符的 message 加 2000 字符的 trace 足够定位,再多应该去日志系统里查。
4.3 死信队列与人工补偿入口
死信不是垃圾桶,是待办清单。至少要能回答三个问题:这条为什么失败、什么时候失败的、怎么重放回去。
死信结构里我固定放job(原始报文)、error(异常消息)、trace(截断的堆栈)、at(时间)。补偿脚本的逻辑很简单——把job里的 retry 归零,重新塞回 pool:
// replay_dead.php php replay_dead.php order_timeout 10 $topic = $argv[1]; $limit = (int) ($argv[2] ?? 10); for ($i = 0; $i < $limit; $i++) { $raw = $redis->lPop("delay:dead:{$topic}"); if ($raw === null) { break; } $dead = json_decode($raw, true); $job = $dead['job']; $job['retry'] = 0; $job['replayed_at'] = date('Y-m-d H:i:s'); $redis->zAdd("delay:pool:{$topic}", (int)(microtime(true)*1000), json_encode($job, JSON_UNESCAPED_UNICODE)); echo "replayed uuid={$job['uuid']}\n"; }重放必须限量。早期有人写了个全量重放,几千条死信同时回池、同时到期,消费端瞬间被打满,还连累了下游接口。现在默认一次 10 条,观察一轮没问题再继续。
死信数量要进监控。日增超过阈值就说明某类任务在系统性失败,这时候盯消费端的错误日志比盯队列长度有用得多。
4.4 参数表
| 参数 | 值 | 说明 |
|---|---|---|
| 可见性超时 | 30s(短任务)/ 按 P99 × 2(长任务) | 太短误判,太长故障恢复慢 |
| 长任务续期间隔 | 10s | 必须小于可见性超时的 1/2 |
| 最大重试次数 | 5 | 超过进死信 |
| 幂等键 TTL | 24h | 覆盖最长重投窗口 |
| 死信单次重放量 | 10 | 防止重放打爆消费端 |
| 错误信息截断 | message 500 / trace 2000 | 控制内存占用 |
5. 避坑与排查:五个反复翻车的现场
下面五条都是真实踩过的,按发生频率从高到低排。每条按「现象 → 原因 → 解决」写,遇到了直接对照。
5.1 整点任务洪峰把 Redis 打抖
现象:每到整点、半点,其他业务接口的 P99 从 8ms 涨到 200ms 以上,监控显示 Redis 的 slowlog 里全是EVAL。
原因:score 用了秒级time()。所有「30 分钟后执行」的任务 score 完全相同,扫描进程一次ZRANGEBYSCORE捞出来几千条,Lua 里逐条ZREM,单个脚本执行时间顶到几百毫秒,期间 Redis 单线程被独占。
解决:score 统一改成毫秒(int)(microtime(true) * 1000),天然打散;同时把单批上限压到 200,扫描间隔从 1s 改成 100ms。改完之后整点峰值消失,慢查询归零。这个改动看起来只是单位问题,实际影响的是整个系统的抖动水平。
5.2 Lua 里 unpack 报 too many results
现象:批量调到 1000 之后,日志里出现Lua script attempted to unpack too many results,任务停止搬运,队列开始堆积。
原因:早期版本用了redis.call('ZREM', KEYS[1], unpack(items))一次性删,Lua 的unpack在元素超过约 8000 时会爆栈,不同 Redis 版本阈值还不一样。
解决:改成 for 循环逐条ZREM,单批上限锁死在 500。逐条删比批量删多几次内部调用,但脚本执行时间稳定在毫秒级,不会因为某个时刻任务特别多就突然变慢。稳定性比那点性能重要。
5.3 子进程退出后变成僵尸
现象:ps aux里能看到一堆<defunct>进程,父进程 worker 数越跑越多,最后文件句柄耗尽。
原因:子进程里用了 PHP 原生exit(0)而不是$proc->exit(0),Swoole 的进程管理没收到退出通知;同时父进程用的是阻塞Process::wait(),配合信号后出现竞态,某些退出事件被漏掉。
解决:子进程一律$proc->exit(0);父进程改Process::wait(false)加 100ms 轮询。改完之后僵尸进程彻底消失。这类问题不会立刻暴露,往往跑几天才发作,上线前一定要在测试环境持续跑够时间。
5.4 发版时任务被重复执行两次
现象:每次发布,总有几条任务在日志里出现两次执行记录,间隔几十秒。
原因:发布脚本kill -9强杀,子进程正在执行的任务被打断,running 记录还在,30 秒后被回收重投。而业务侧没有做幂等,同一笔退款被处理了两次。
解决:两头一起改。发布脚本改成SIGTERM加 30 秒等待(前文的退出片段);消费端加SET NX EX幂等键。只做前者不够——机器断电、OOM 被杀这些场景照样会重投,幂等是最后的防线。
5.5 时钟漂移导致延时不准
现象:任务设的「10 分钟后执行」,实际有时 9 分 40 秒就跑了,有时 10 分 20 秒才跑。
原因:生产者和扫描进程不在同一台机器,两台机器之间有几秒的时钟偏差;score 用的是各自机器的本地时间。
解决:所有时间戳统一从 Redis 取,用TIME命令返回的服务器时间做基准,生产者和扫描进程都不信任本机时钟。多花一次 RPC,换来的是全链路时间一致。NTP 同步当然也要开,但那是运维层面的事,应用层不能假设它一定生效。
6. 敢上生产前的压测口径与一个幂等技巧
压测这套东西有个坑:很多人只压消费端,看到 TPS 达标就认为没问题。实际上延时队列的瓶颈往往在搬运环节——扫描进程每秒能搬多少条,才是整条链路的天花板。我的压测口径是分三段单独测:
| 环节 | 压测方法 | 关注指标 |
|---|---|---|
| 入队 | 并发ZADD10 万条,score 随机分布在未来 1 小时 | 入队 QPS、单条耗时 |
| 搬运 | 造 10 万条已到期任务,只跑扫描进程 | 每秒搬移条数、Lua 单次耗时 |
| 消费 | ready 池灌满,只跑消费进程 | 消费 TPS、running 峰值 |
三段里最短的那块就是整体上限。我遇到过一次:消费端能跑 8000/s,搬运只有 1200/s,整条链路卡在 1200。当时扫进程只起了 1 个,加到 3 个之后才追平——如果只压消费端,这个瓶颈上线后才会暴露,而那时候队列已经在积压了。
搬运段的压测命令很简单,用redis-cli就能看 Lua 的实际耗时:
# 观察 Lua 脚本的执行耗时,超过 10ms 就要降低批量 redis-cli --latency-history -i 1 redis-cli slowlog get 20再说一个幂等技巧,比朴素SET NX更稳一点:把幂等键和业务状态绑定,而不是纯粹的存在性判断。
// 业务状态机版本的幂等:只有处于「待处理」才允许推进 $ok = $redis->eval( "if redis.call('HGET', KEYS[1], 'status') == 'done' then return 0 end redis.call('HSET', KEYS[1], 'status', 'done') redis.call('EXPIRE', KEYS[1], 86400) return 1", ["biz:order:{$orderId}"], 1 ); if ($ok === 0) { return; // 已处理,直接 ack }这样即使幂等键因为内存淘汰被提前删掉,只要业务状态还是done,重复执行也会被挡住。纯SET NX在 Redis 内存紧张时被 LRU 淘汰,防护就失效了,业务侧会莫名其妙重跑。
最后一条习惯:每次上线前,我都会在测试环境人为杀掉几个消费进程,观察 running 集合的回收是否正常、任务是否被重投、重投后幂等是否生效。这个动作五分钟就能做完,但能提前发现绝大多数「上线后才丢任务」的问题。延时队列这类组件,平时安安静静,出事就是批量丢单,宁可多花这五分钟。希望帮到你。
本文还有配套的精品资源,点击获取