简介:基于Redis与Swoole实现的延时消息队列多进程消费端,面向中高级PHP开发者,解决订单超时、定时任务等延迟场景下消息处理效率低的问题。资源围绕twomiao-Serve项目,详细展示了如何利用Redis有序集合ZSet存储待处理消息,以时间戳作为score实现延迟触发,再通过Swoole的多进程模型并发消费队列,配合异步非阻塞I/O降低资源占用、提升系统吞吐量。压缩包约1.66MB,以PHP源码文件为主,同时涉及Swoole服务配置、Redis交互逻辑及消息处理核心代码,目录结构清晰,便于对照学习。已有1269人学习浏览,适合正在构建消息队列组件或需要优化高并发消费能力的开发者。通过阅读源码可掌握延时队列的设计思路、ZSet与延时分数的匹配机制、多进程消费的任务分配方式,并能将示例改造后迁移至自有业务系统中。
1. Redis延时队列与Swoole多进程消费:这两个东西合在一起能解决什么问题
做PHP后台开发的人迟早会遇到一类需求:订单超时未支付要关单、直播结束后要结算分成、活动结束后要发未中奖通知。这些任务有一个共同特征——不是立刻执行,而是"等N秒/等N分钟后再执行"。最常见的第一反应是写个crontab每分钟扫一次数据库,但数据量大之后轮询间隔压不下去,订单关了用户还在付款页跳转,体验很割裂。另一个思路是引入RabbitMQ的延迟插件,但很多团队的基础设施里根本没有RabbitMQ,只有一台Redis。
Redis延时队列基于zset实现延时消息,再配合Swoole做多进程消费端,解决的正是"没有专业MQ、只有Redis,却要把延时任务按时可靠地消费掉"这个场景。这套方案适合已经有PHP+Swoole经验、想给现有系统加一个轻量任务调度能力的开发者;如果你从来没碰过Swoole,也可以把它当做一个入门多进程消费的练手项目,代码量不大,但进程模型、信号处理、幂等这些工程问题全都覆盖到了。
2. 队列模型与score设计:为什么zset是延时队列的最优解而不选list
2.1 zset的score承载到期时间戳,而不是延迟秒数
延时队列的核心数据结构是Redis的有序集合zset。每个任务是一个member,任务的到期执行时间作为score。生产者写入时执行的是ZADD key score member,消费者轮询时执行的是ZRANGEBYSCORE key -inf 当前时间戳,就能把所有已经到期的任务取出来。
我见过不少人在这一步踩坑:score存的是"延迟多少秒",比如延迟600秒就存600。这个设计在任务量小、跑一会儿就重启的情况下看起来没问题,但只要服务重启或者队列堆积,score失去绝对时间参照,消费者无法判断哪些任务真的到期了,整个队列就废了。正确做法是score存绝对的到期时间戳,即time() + delay,这样消费者每次只需要跟当前时间比较,重启、堆积、多消费者并发都不会影响判定逻辑。
还有一点是member的设计。如果直接用任务内容当member,任务本身很长会浪费内存,而且任务重复入队时member会去重覆盖。我一般会把任务ID或者一个uniqid作为member,任务详情放进另一个hash或者直接作为ZADD的附加信息(比如ZADD queue 1730000000 task_1001,任务内容放Redis的其他key),消费时再取详情。这样score只负责时间维度,member只负责标识,各司其职。
2.2 从零实现一个最小的延时队列闭环
先用最朴素的PHP代码把整个链路跑通,再谈Swoole。入队端就一个操作:
<?php $redis = new Redis(); $redis->connect('127.0.0.1', 6379); // 入队:任务ID是 task_1001,延迟 60 秒执行 $taskId = 'task_1001'; $delay = 60; $executeAt = time() + $delay; $redis->zAdd('delay_queue:order_close', $executeAt, $taskId);这段代码的逻辑极简:zAdd的三个参数分别是key、score、member。score传的是绝对到期时间戳time() + $delay,当前时间戳为10位秒级整数。这里不建议用microtime,因为浮点数在zset里做范围查询时存在精度误差,而且大多数业务场景秒级精度已经够用,毫秒级任务量对PHP进程的轮询压力反而会造成负担。
消费端循环去取:
<?php $redis = new Redis(); $redis->connect('127.0.0.1', 6379); $key = 'delay_queue:order_close'; while (true) { $now = time(); // 取出所有已经到期的任务,最多取 100 个 $expiredTasks = $redis->zRangeByScore($key, '-inf', $now, ['limit' => [0, 100]]); if (empty($expiredTasks)) { usleep(500000); // 没有到期任务,睡 500ms 再轮询 continue; } // 从队列中移除已取出的任务 $removedCount = $redis->zRem($key, ...$expiredTasks); foreach ($expiredTasks as $taskId) { // 这里执行真正的业务逻辑,比如关单 closeOrder($taskId); } } function closeOrder(string $taskId) { // 业务实现 }这段循环代码是后面Swoole多进程版本的雏形。zRangeByScore的-inf参数代表负无穷,即从头取到当前时间所有score不大于$now的member,limit控制了单次拉取上限,避免一次性取出十万个任务把内存打爆。zRem负责删除已取出的任务,这里有个关键点:先取出再删除,和先删除再取出,后果完全不同。先删后取如果业务处理失败任务就丢了;先取后删如果进程在处理期间崩溃,任务会重复执行一遍——这是分布式系统的经典取舍,后面避坑章节我会详细展开。
2.3 单进程轮询的瓶颈:消费速度被锁死
上面这段代码跑着跑着你会发现几个问题。第一,PHP是单进程单线程的,closeOrder如果是个耗时操作——查订单、发请求、写回执——整个循环就被卡住了,后续到期任务只能排队等待。第二,PHP进程一旦因为内存超限或者Fatal Error退出,队列就停摆,没人消费,延时任务全部睡过头。第三,单进程消费吞吐量有上限,假设每个任务处理需要50ms,一秒钟最多处理20个,这在订单量稍微上来一点的活动场景下根本撑不住。
这就是为什么要引入Swoole。Swoole提供了进程管理能力,可以让多个Worker进程同时去消费同一个延时队列。Redis的zRem操作是原子的,天然支持多进程并发抢占——两个进程同时取到同一个任务,只有一个能删除成功,删除失败的那个进程会跳过这个任务。同时Swoole自带进程退出检测和重启机制,某个Worker挂了Master进程会重新拉起一个新的,消费端长期稳定运行的前提才真正具备。
3. 基于Swoole的消费端落地:进程模型与核心代码拆解
3.1 进程模型选择:用Server的Worker模式而不是自建Process池
Swoole实现多进程消费常见有两种姿势。第一种是用Swoole\Process自己管理子进程,创建N个子进程、监听退出信号、挂掉再拉起,全部手写。第二种是用Swoole\Server的Worker进程模式,把消费逻辑放在onWorkerStart回调里,进程由Server底层统一管理,天然自带reload和崩溃重启能力。
我会推荐第二种,理由很直接:Process自建池子等于把进程生命周期管理的责任全揽到自己身上,写信号处理、写重启逻辑、写退出码判断,这些都是成熟的轮子,没必要重复造。Server的Worker模式相当于白送了一套进程管理器,你只需要关注消费逻辑本身。
<?php use Swoole\Server; $server = new Server('0.0.0.0', 9501, SWOOLE_BASE, SWOOLE_SOCK_TCP); // 高精度定时器,影响轮询精度 $server->set([ 'worker_num' => 4, // 消费进程数量,按CPU核数配置 'max_wait_time' => 10, // 进程退出前最大等待时间 'reload_async' => true, // 异步重启 'log_file' => '/var/log/queue_consumer.log', ]); $server->on('WorkerStart', function ($server, $workerId) { // 每个Worker进程独立连接Redis $redis = new Redis(); $redis->connect('127.0.0.1', 6379); $server->redis = $redis; // 标记是否允许退出 $server->running = true; while ($server->running) { $now = time(); $tasks = $redis->zRangeByScore('delay_queue:order_close', '-inf', $now, ['limit' => [0, 50]]); if (empty($tasks)) { usleep(500000); continue; } foreach ($tasks as $taskId) { // 先删除,再处理;删除失败的说明被其他Worker抢走了 $removed = $redis->zRem('delay_queue:order_close', $taskId); if ($removed == 0) { continue; } handleTask($taskId, $redis); } } }); $server->on('WorkerStop', function ($server, $workerId) { // 把退出标记置为false,让循环正常结束 $server->running = false; }); $server->start();这份代码是整个消费端的核心骨架。worker_num设为4意味着有4个进程同时在轮询同一个zset,Redis的zRem原子性保证了同一个任务不会被重复消费。max_wait_time参数决定了Worker进程在处理完当前任务后最多等待多久退出,防止reload时进程被强杀导致任务丢失。reload_async配合max_wait_time一起用,才能实现平滑重启——否则kill信号一到,进程直接终止,正在处理的任务就断了。
3.2 Worker内的Redis连接管理:一个进程一个长连接
注意一个容易错的地方:onWorkerStart回调里的$redis连接是在Worker进程内创建的,这个连接的生命周期跟Worker进程完全一致。也就是说每有一个Worker进程,就有独立的一条Redis长连接,4个Worker就是4条,互不干扰。这一点是刻意的设计——如果是在Master进程里创建一个连接然后分给Worker用,跨进程文件描述符共享会带来连接竞争问题。
我在真实项目里的做法是把这个$redis对象挂到$server上,比如代码里的$server->redis = $redis,这样在handleTask里也能通过$server->redis访问同一个连接。但不建议把$redis作为参数传进一个类方法然后又在这个方法里重新connect,因为重新连接意味着旧连接没有关闭,时间长了文件描述符会泄漏。
3.3 任务处理函数的封装:把业务逻辑和队列逻辑解耦
消费端代码里最容易被写烂的地方,是把业务逻辑直接堆在WorkerStart的while循环里。一旦业务代码出了问题,整个消费进程的逻辑都难以定位。我习惯把任务处理抽成一个独立函数,里面带上异常捕获和重试机制:
<?php function handleTask(string $taskId, Redis $redis): void { try { // 从业务表或缓存中读取任务详情 $detail = $redis->hGet('task_detail', $taskId); if (empty($detail)) { // 详情都查不到,标记为死信 $redis->zAdd('dead_letter:order_close', time() + 3600, $taskId); return; } // 实际业务,比如订单关单 closeOrder($taskId, json_decode($detail, true)); // 成功后记录日志,方便排查 echo sprintf('[%s] 任务 %s 处理成功', date('Y-m-d H:i:s'), $taskId), PHP_EOL; } catch (Throwable $e) { // 处理失败重试到重试队列,设置新的到期时间,例如2分钟后重试 $retryCount = $redis->hIncrBy('task_retry_count', $taskId, 1); error_log(sprintf('任务 %s 处理失败,重试第 %d 次: %s', $taskId, $retryCount, $e->getMessage())); if ($retryCount >= 3) { // 超过重试上限,进死信队列 $redis->zAdd('dead_letter:order_close', time() + 3600, $taskId); $redis->hDel('task_retry_count', $taskId); } else { // 重新入队,延迟 120 秒再试 $redis->zAdd('delay_queue:order_close', time() + 120, $taskId); } } }这段封装的核心思路是"任务绝不能静默丢失"。查不到详情、业务异常、重试超限,各有各的去处——重新入队、死信队列、重试计数。hIncrBy用来记录每个任务的失败次数,Redis自增操作是高并发下比较可靠的方式。这里还有一个细节:重新入队时zAdd的score用的还是绝对时间戳,所以重试任务到期后会被消费者正常捞出来。
4. 参数调优与消费端压测:worker_num、轮询间隔、批量拉取数量怎么定
4.1 worker_num的选型依据:CPU密集还是IO密集决定线程数策略
Worker进程数量是这套消费端最关键的参数之一,很多人直接照抄worker_num => 4,这就有很大的问题。如果业务逻辑里有大量的远程调用——HTTP接口、MySQL查询、第三方支付回调——那这是IO密集型消费,Worker数可以开到CPU核数的4到8倍,因为进程大部分时间在等待IO,CPU没有被占满,多开进程能让更多请求并发出去。
如果业务逻辑主要是本地计算、加解密、序列化这类纯CPU操作,Worker数开到CPU核数即可,再往上加反而会因为进程频繁切换CPU导致吞吐量下降。以一台4核8G的服务器为例,IO密集型我可以开到16个Worker,CPU密集型的开到4个就封顶了。
判断依据很简单:先跑一版,用top看CPU负载。如果CPU跑不满而任务堆积,说明是IO等待,继续加Worker;如果CPU已经跑满但仍然堆积,说明Worker数到顶了,需要优化业务逻辑而不是继续加进程。
4.2 轮询间隔与批量拉取数量:500ms与50个是我验证过的平衡点
轮询间隔usleep(500000)即500毫秒,批量拉取50个任务是最常用的一组参数,但不是唯一的正确解。两个参数互相制约:拉取间隔决定了任务下发的最小延迟,批量大小决定了单轮的处理能力上限。
这里给出一个参考的调优空间:
| 参数 | 低延迟场景 | 高吞吐场景 | 说明 |
|---|---|---|---|
| 轮询间隔 | 100ms | 1s | 间隔越短,任务到期后下发越快,但Redis的QPS开销越大 |
| 批量拉取数 | 10 | 100 | 批量越大单轮处理越多,但单轮耗时会变长,阻塞下一轮轮询 |
| worker_num | 8~16 | 2~4 | IO密集型取高值,CPU密集取低值 |
每轮zRangeByScore都是一次Redis查询,假设间隔500ms,每个Worker每分钟就是120次查询,4个Worker合计480次QPS,Redis完全无压力。但如果把间隔压到100ms,QPS会涨到2400次,虽然Redis依然扛得住,但没必要为低延迟牺牲过多的Redis资源。我个人的习惯是:对时间精度要求高的业务单独拆一个队列,用100ms轮询;普通超时关单、通知类任务统一走500ms轮询。
4.3 消费端的幂等保障:Redis删除成功不等于业务成功
这个环节必须强调一个容易被忽略的陷阱——zRem成功删除任务,只代表当前进程抢到了这个任务的处理权,绝不等同于业务执行成功。极端情况下,进程在处理完业务之后、即将进入下一轮循环之前崩溃了,任务已经删了,业务也做了,那是正常状态;但如果进程在执行业务的过程中崩溃,任务已经被删,这条消息就彻底丢了。
为了给这个风险留后悔药,我在实际项目中会在业务处理开始时写一条处理中的标记:
<?php // 业务开始前置一个标记,TTL设置5分钟 $redis->set('processing:' . $taskId, 1, ['nx' => 'ex', 'ex' => 300]); try { // 业务逻辑 closeOrder($taskId); } finally { // 无论成功失败,处理完都要清理标记 $redis->del('processing:' . $taskId); }这并不能做到完全可靠,但能让你在排查问题时知道某条任务曾处于处理中状态。如果你的订单业务对可靠性要求极高,比如金额相关的结算任务,设计上应该再把处理中的任务重新放回队列,通过一个timestamp字段做延后补偿扫描——这笔任务我后面在避坑章节里详细展开。
5. 避坑:延时队列消费端最常见的六个翻车现场
5.1 所有的任务都在整点扎堆到期
现象:每天某个整点或者每小时的整点,消费端处理任务量突然暴涨,其他时间则很清闲。看起来像是用户行为导致,其实是代码逻辑埋下的坑。
原因:生产端入队时用了date('Y-m-d H:i')补齐整分或者直接按小时为单位计算延迟,导致同一批次任务的score完全相同,到期时刻完全一致,消费端瞬间拉出大批任务。
解决:入队时引入随机偏移。$executeAt = time() + $delay + mt_rand(0, 30);让任务散落在30秒的区间内。如果业务上不允许偏移,那就把批量拉取上限调低,配合多Worker去抢;但本质问题还是生产端的错,不要指望消费端消化。
5.2 Worker进程会莫名退出,又自动拉起
现象:日志里经常出现PHP Fatal Error,Swoole日志显示Worker进程退出然后重新启动,任务处理出现空档。
原因:业务代码里存在内存泄漏或者未捕获的异常。Swoole的Worker进程长时间运行,任何一次业务代码的Fatal Error都会直接杀死进程。
解决:生产环境关闭错误输出到屏幕,同时开启log_file记录所有异常;在handleTask里用try/catch兜住Throwable——注意不是Exception,PHP 7以上错误是Error类,只捕获Exception捕获不到。血泪经验是脚本里必须catch (Throwable $e),才能把绝大多数Fatal Error变成可控分支。
5.3 消费端偶发"处理了不该处理的任务"
现象:某些任务提前执行了,延时时间明显没到。
原因:服务器系统时钟出现过跳动。云服务器的NTP同步偶尔会把时间往前跳几十秒甚至几分钟,消费者拿time()去zset里比较score,时间一跳,未到期任务全部提前暴露。
解决:消费端不要直接用系统时间戳,先取一个基准时间存起来,每次轮询用基准时间加一个固定偏移去比较,或者更彻底一点——用Redis自身的TIME命令读取服务端时间,因为队列生产端写入时用的时间基准是Redis服务器时间。这里我发现一个玄学点:及时服务器时间不回跳,只要生产端和消费端两台机器的系统时间差超过2秒,就会出现早到或晚到的情况,所以统一从Redis取时间是最稳的。
5.4 大批量任务消费失败全部进入死信队列
现象:死信队列一天涨了几万条,查看原因全是同一个错误。
原因:业务逻辑里依赖的某个下游服务挂了。任务在短时间内全部失败,重试3次都打在了同一个宕机的服务上,然后批量进入死信队列。
解决:给重试增加指数退避,而不是固定间隔。第1次失败等60秒重试,第2次等300秒,第3次等1800秒,给下游服务恢复留出时间窗口。同时可以对死信队列的key加一个ZCARD监控,超过阈值就触发告警。
5.5 Redis连接在Worker进程内过期断开
现象:消费端运行一段时间后,突然批量报"Redis connection lost",然后所有Worker重新连接,期间任务没人消费。
原因:onWorkerStart里创建的长连接长期空闲时,Redis服务端的timeout参数会主动断开连接,PHP侧并没有感知。正常情况下每500ms就有一次zRangeByScore不会空闲,但如果前面的批量任务处理耗时特别长,轮询间隙被拉长到超过Redis的timeout(默认300秒),连接就断了。
解决:在zRangeByScore调用前加一个$redis->ping()检测,或者干脆在循环前重新连接——实际项目中我会在handleTask内部捕获连接异常,一旦报连接丢失就重新执行connect。这比ping更省一次网络往返。
5.6 队列key无限增长,内存告警
现象:Redis内存持续上涨,delay_queue相关key一直删但zcard不见下降。
原因:任务处理失败进入死信队列之后,没有给死信队列设置过期时间。死信队列里的member只进不出,越堆越多,最终撑爆内存。
解决:死信队列的key设置TTL,比如EXPIRE dead_letter:order_close 86400,或者定期写一个清理脚本把超过7天的死信删除。另外生产端的任务详情hash也要设置过期时间,否则task_detail这个hash也会无限膨胀。
6. 验证这套消费端:延时偏差测量与线上巡检技巧
搭建完消费端之后,不能只看任务"最终被执行了",还要验证两个指标:任务执行的准时度和消费端在不重启情况下的长期稳定性。我习惯在每次发布前跑一个定时脚本,批量生产一批已知延迟的任务,然后收集实际执行时间与预期时间的偏差。
<?php $redis = new Redis(); $redis->connect('127.0.0.1', 6379); $baseTime = time(); $testTasks = []; // 生成200个测试任务,延迟分别取 5s/10s/30s/60s 各50个 for ($i = 0; $i < 200; $i++) { $delay = [5, 10, 30, 60][$i % 4]; $taskId = 'perf_test_' . $i; $executeAt = $baseTime + $delay; $redis->zAdd('delay_queue:perf_test', $executeAt, $taskId); $testTasks[$taskId] = $executeAt; } // 等100秒,任务全部执行完后检查日志 sleep(100); // 从日志中解析实际执行时间 $logs = file('/var/log/queue_consumer.log'); $actualTimes = []; foreach ($logs as $line) { if (preg_match('/任务 (perf_test_\d+) 处理成功 · 实际执行:(\d+)/', $line, $matches)) { $actualTimes[$matches[1]] = (int)$matches[2]; } } $maxDiff = 0; $avgDiff = 0; foreach ($testTasks as $taskId => $expected) { if (!isset($actualTimes[$taskId])) { echo "任务 {$taskId} 未被消费\n"; continue; } $diff = $actualTimes[$taskId] - $expected; $maxDiff = max($maxDiff, $diff); $avgDiff += $diff; } printf("平均延时偏差: %.2f秒, 最大偏差: %d秒\n", $avgDiff / 200, $maxDiff);这个脚本配合日志文件输出,能直观看出消费端的实时性。平均延时偏差在500ms到1秒之间说明轮询间隔设置合理;如果平均偏差超过2秒,就要检查是不是Worker数太少导致任务排队了。注意验证脚本要单独用一个队列key,不要跟线上业务队列混在一起,否则测试任务会影响线上指标。
我把这套验证方式固化成了上线前的例行检查清单:先看zset剩余量监控,再看死信队列增长率,最后跑一遍上面的偏差脚本。之前有一次线上事故就是因为消费端进程静默退出导致延时任务大面积迟到,从那以后我每次发布前都强制走一遍完整验证流程,确认Worker进程全部拉起、Redis连接正常、任务延迟在可接受范围才放量。这套代码的价值不在于多复杂,而在于它把"消费"这件事变成了可观测、可验证、可兜底的工程实践,希望帮到你。
本文还有配套的精品资源,点击获取