☰
Redis延时消息队列基于Swoole实现多进程消费端的工程实践
2026/10/15 17:30:44 网站建设 项目流程

简介:一套基于Redis延迟队列与Swoole多进程模型实现的PHP消息队列消费端源码,面向需要处理高并发延时任务的PHP服务端开发者,常见于订单超时、定时推送、异步重试以及任务调度等场景。压缩包大小约1.66MB,核心代码围绕Redis Sorted Set的score时间戳设计,通过ZRANGEBYSCORE按到期时间批量拉取消息,并配合Swoole Server的Worker进程池进行多进程并行消费。解压后可查看Serve项目的源码结构,包括Redis交互逻辑、Swoole多进程配置以及消息处理流程,便于对照学习。目前已有1269人学习下载。整体实现既能保证延迟消息的按时触发,又利用多进程与异步非阻塞I/O提升了消费吞吐能力,适合希望掌握Redis延时队列与Swoole多进程配合方式的PHP开发者。

1. Redis延时消息队列基于Swoole实现的多进程消费端:能解决什么问题

Redis延时消息队列不是什么新鲜概念,但把它和Swoole的多进程消费端拼在一起,是一个经常被讨论也经常被做歪的方向。先说结论:这套方案适合“延时范围从几十秒到几天”“消息量不算极大但需要稳定消费”“不想为延时队列单独引入一套中间件”的业务场景——订单超时未支付、定时任务分发、重试机制延迟执行,都能直接用。核心思路是:用ZSET按到期时间存任务,用LIST装已经到期的任务让消费者去抢,再借助Swoole的进程池把消费能力摊到多核上。相比单进程轮询,多进程消费端才真正把Redis延时队列用出了生产价值。下面把整个链路拆开:设计选型、核心代码、参数调优、常见翻车点,以及怎么打磨到可以长期运行。

2. 整体设计:ZSET存到期、LIST装待办、多进程抢着干

2.1 为什么是ZSET + LIST,而不是单靠ZSET轮询

最简单的延时队列,一个ZSET就够了:score放到期时间戳,value放任务体,消费者每隔一秒用ZRANGEBYSCORE把到期的任务拉出来处理。为什么还要多一个LIST?因为ZRANGEBYSCORE不是弹出操作,你拉出一批任务去处理时,得先ZREM把它们删掉,不然下次又会拉到;可一旦ZREM删掉,这批任务就只存在于消费者的内存里,消费者处理到一半进程崩了,任务随之蒸发。

LIST解决的就是“取走即所有权转移”的问题。把到期任务从ZSET搬到LIST,消费端用BRPOP(或更稳妥的BRPOPLPUSH)从LIST取任务,取走之后任务才真正从Redis里消失。整个过程里,ZSET负责延时排序,LIST负责排队分发,职责拆得干净。我一般把这两个结构的职责说成一句话:ZSET只在“到期那一刻”活跃一次,LIST承担后面的所有读写压力,这样ZSET的查询压力不会成为瓶颈。

2.2 为什么消费端必须多进程,而不是单进程协程

有人会说,Swoole单进程里开几百个协程不也能消费?能,但协程只解决IO等待,不解决CPU。消费端一旦开始干活,总要经过反序列化、业务处理,可能还有HTTP调用和MySQL写入,这些在协程里确实能并发,但单进程最终只能吃满一个核。任务量一上来,单核就是天花板。

多进程消费端的价值有两层:第一层是能利用多核,worker数量可以压到和CPU核数相当甚至更多;第二层是隔离,一个worker里出现内存泄漏、死循环甚至崩溃,其他worker不受影响,Swoole的进程池Manager还能把挂掉的worker重新拉起来。Swoole的Process\Pool组件正好提供了这个骨架,每个worker是独立进程,进程间天然隔离,配合协程Redis,每个进程内部还能再并发处理多路IO,一个worker的实际吞吐远高于“一进程一任务”的朴素模型。

2.3 任务从投递到消费的完整流转

整个链路由三个角色组成:投递方、调度器、消费worker。投递方可以是业务后端,拿到任务后把序列化数据写入ZSET,score设为“现在 + 延时秒数”。调度器负责周期扫描ZSET,凡是score小于等于当前时间的任务,就一批一批搬到LIST,这一步必须原子,用Lua脚本完成ZRANGEBYSCORE + ZREM + RPUSH。消费worker启动后,每个进程建立自己的Redis连接,在协程里循环BRPOP从LIST取任务,取到就处理,处理完不需要回写LIST,处理失败则带着重试次数重新投递回ZSET或一个专门的重试队列。

这里有一个容易混淆的点:调度器到底放在哪里。常见做法有两种,一是作为Swoole进程池里的一个特殊worker,专门跑定时扫描;二是独立成一个PHP常驻脚本,由Swoole的Timer驱动。我更推荐第二种——把调度器和消费worker进程池分开,这样调度器挂掉时消费端依然能消化LIST里已有任务,消费端全部挂掉时调度器也会把到期任务继续堆积到LIST,两边的故障不会互相放大。

3. 用Swoole落地多进程消费端:核心代码与运行链路

3.1 生产者投递:写入ZSET的最小代码

投递这一步不需要Swoole环境参与,任何能连Redis的服务都能投。关键是把到期时间算准,我统一用秒级时间戳,延时精度就可以认为是“扫描周期”的数量级,不用毫秒,毫秒级延迟对绝大多数业务没有意义。

// producer.php —— 普通PHP即可,不需要常驻 $redis = new Redis(); $redis->connect('127.0.0.1', 6379); $task = [ 'id' => uniqid('task_', true), 'type' => 'order_timeout', 'order_id' => '2025001', 'retry' => 0, 'payload' => '...业务数据...' ]; $delay = 60; // 延时60秒 $redis->zAdd('delay:order_timeout', time() + $delay, json_encode($task));

ZSET里value直接用序列化后的完整任务体,而不是只存ID,是为了省一次查询。任务体大到几百字节以上时,才考虑只存ID、详情放Hash或数据库,这个取舍后面讲。zAdd的score是到期时间戳,同一个任务重复投递会产生两个member,投递前要用业务唯一ID去重,否则会出现重复消费。

3.2 到期扫描:Lua脚本保证移动的原子性

调度器是延时的“发令枪”。它反复执行同一个Lua脚本:把score小于等于当前时间的member全部拿出来、删掉、再压入LIST。这三步必须在一个脚本里完成,否则扫描进程一崩,任务被ZREM删了却还没进LIST,直接丢。

-- move_expired.lua -- KEYS[1] = 延时队列ZSET的key -- KEYS[2] = 待消费LIST的key -- ARGV[1] = 当前时间戳,由调度器传入 -- ARGV[2] = 本次最多搬多少条,防止单次操作过大 local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, ARGV[2]) if #tasks == 0 then return 0 end redis.call('ZREM', KEYS[1], unpack(tasks)) for i = 1, #tasks do redis.call('RPUSH', KEYS[2], tasks[i]) end return #tasks

用PHP侧定期执行这个脚本:

// dispatcher.php —— Swoole Timer / crontab / 常驻进程均可 $lua = file_get_contents('move_expired.lua'); $now = time(); $moved = $redis->eval($lua, ['delay:order_timeout', 'todo:order_timeout', $now, 500], 2); if ($moved > 0) { // 记录搬运量,方便监控 }

注意eval最后一个参数是key数量2,后面才是ARGV;ARGV的顺序必须和脚本里一致。每次扫描条数上限设500,因为单次ZREM拆包太多Lua参数会拖慢Redis主线程,宁可多循环几次调度,也不能一次搬上万条把Redis卡住。

3.3 多进程消费端:Swoole进程池 + 协程Redis消费循环

下面是重头戏,Swoole多进程消费端的完整骨架。用Process\Pool固定拉起N个worker进程,每个worker在启动时创建一个协程Redis连接,并进入消费协程循环。

// worker.php —— 常驻进程 use Swoole\Process\Pool; use Swoole\Coroutine; use Swoole\Coroutine\Redis; $workerNum = (int) ($argv[1] ?? 4); $pool = new Pool($workerNum); $pool->on('WorkerStart', function (Pool $pool, int $workerId) { // 每个worker单独建立自己的Redis协程连接,不要跨worker共享 $redis = new Redis(); $redis->connect('127.0.0.1', 6379); // 优雅退出标志:进程收到SIGTERM后,处理完当前任务再退出 $running = true; Swoole\Process::signal(SIGTERM, function () use (&$running) { $running = false; }); // 消费主循环,必须用协程包一层 Coroutine::create(function () use ($redis, &$running, $workerId) { while ($running) { try { // BRPOP最多阻塞1秒,超时返回false,正好借着间隙检查退出标志 $task = $redis->brPop(['todo:order_timeout'], 1); if (!$task) { continue; } // $task[0]是队列名,$task[1]是任务体 $data = json_decode($task[1], true); // 业务处理…… handleTask($data); } catch (Throwable $e) { // 记录错误。任务已经从LIST取出,丢了就丢了,必须依赖幂等+重投 } } $redis->close(); }); }); $pool->on('WorkerStop', function (Pool $pool, int $workerId) { // 收尾工作:上报worker存活时间等 }); $pool->start();

这段代码的几个要点拆开讲:第一,每个worker必须new自己的Redis客户端,协程Redis连接不能跨进程使用,这是铁律;第二,BRPOP超时时间设1秒,既不会频繁空转浪费CPU,又能让循环每秒钟检查一次$running退出标志,不用等一个长阻塞任务卡住退不了;第三,catch里的Throwable要区分场景,反序列化失败这类错可以丢到死信队列,连接断开类错误要重连而不是直接吞掉。

3.4 从启动到消费:跑通完整链路的步骤

前面三个角色凑齐后,部署顺序和验证顺序很讲究。我一般先在本地按“投递→调度→消费”三步依次启动,先把链路跑通再调参数。

步骤一写任务:执行一次producer.php,往ZSET里塞一个延时60秒的任务。步骤二起调度器:把dispatcher.php跑起来,每1秒扫描一次ZSET。步骤三起消费端:执行php worker.php 4。等60秒,观察日志里任务是否被消费端打印。最后检查Redis里delay:order_timeout的ZCARD和todo:order_timeout的LLEN,两个都归零,说明任务完整走完了。

这里我习惯在handleTask里先打一行日志,记录任务ID、worker编号、处理耗时,排查问题时第一件事就是看这个日志,任务到底进没进worker一目了然。

4. 参数怎么设:worker数量、扫描周期与队列Key的取舍

4.1 worker数量:不是越多越好,CPU、IO与Redis连接的三角关系

worker数是一个容易被拍脑袋定死的参数。常见误区是“Redis快,我起32个worker肯定更快”。实际上worker数取决于任务性质。CPU密集型任务(图片缩放、加解密),worker数接近CPU核数,多了只会增加进程切换开销;IO密集型任务(调用外部接口、查MySQL),worker可以适当超过核数,因为绝大部分时间在等网络。

我实践下来,IO密集场景worker数取核数的1.5到2倍,CPU密集场景取核数或核数减1,是一个不容易犯错的起点。另一个限制是Redis连接数:每个worker一个连接,32个worker就是32个长连接,Redis单实例扛几千连接没问题,但你的网络层和防火墙不一定扛得住,连接是有限资源,别让连接数成为隐性瓶颈。

4.2 扫描周期与批量大小:延时精度的直接上限

延时队列的“延时精度”不是由ZSET的时间戳精度决定的,而是由调度器的扫描周期决定的。score精确到秒,但扫描周期是5秒,那实际延时就是5秒的倍数加上误差。下表是常用的三档配置:

场景扫描周期单次批量上限说明
订单超时关闭(分钟级)1s500默认配置,最稳
定时任务精准执行(秒级)0.2s200扫描进程CPU占用升高,谨慎使用
批量通知/数据补偿(小时级)10s1000扫描几乎无压力

扫描周期的代价是双重的:调度器本身也是一段常驻进程,越频繁它自己越耗CPU;每次扫描都是一次Lua脚本执行,在Redis主线程里跑,批量太大同样会把Redis主线程卡住。所以扫描周期宁可慢一点,批量宁可小一点,除非业务要求秒级触发,否则1秒扫描是性价比最高的区间。

4.3 队列Key和任务体大小:容易忽略的性能细节

队列Key的命名直接影响排查效率和运维体验。我见过有人把队列key叫queue、dq这种极简名字,出问题的时候三个环境对不上号。我一般按delay:{业务标识}和todo:{业务标识}成对命名,同一个业务的两个key前缀一定相同,后面能一眼看出延时队列和待消费队列的对应关系。

任务体的大小也要心里有数。ZSET的member直接存完整JSON,任务体几百字节没事;但如果把整个订单快照塞进去,一个member几KB,ZSET一多Redis内存就吃紧,而且ZRANGEBYSCORE返回的大字符串会撑高网络带宽。任务体超过1KB,建议ZSET里只存任务ID,详情放到Hash里,消费端先BRPOP拿到ID再HGET取详情。

5. 避坑排查:延时不准、任务丢失与worker退出的血泪经验

5.1 现象:任务到期了却迟迟不进入消费

排查过的一个经典案例:任务已经写进ZSET,调度器也在跑,但消费端就是收不到。用ZRANGEBYSCORE delay:order_timeout 0 +inf一查,score值明显是过去的时间戳,任务一直躺在ZSET里没人搬。原因最后定位到调度器进程静默退出:该机器时区配置不同,我用date('Y-m-d H:i:s')拼进日志后做了字符串比较,导致判断条件永远不成立。

解决很简单:一切时间判断统一用time()整数时间戳,不要用格式化时间字符串做比较。调试这个问题的正确姿势是:先检查调度器有没有报错日志,再手动执行一次Lua脚本确认能不能搬到任务,最后才看Redis里的score和当前时间差多少。

5.2 现象:消费端崩溃,任务在LIST里神秘消失

这是延时队列最隐蔽的坑。我早期用RPOP从LIST取任务,取出来之后还没来得及处理,worker进程因为内存溢出被系统kill,任务就永远消失了。BRPOP和RPOP一样,都是“取出即删除”,拿到的瞬间任务就从队列里没了。

解决思路有两条,按可靠性要求选。一是BRPOPLPUSH把任务先搬到一个processing:{queue}临时LIST,处理成功再LREM删掉,崩溃后启动时把processing里的任务重新LPUSH回主LIST,这叫“处理中队列”兜底;二是接受丢任务的风险,靠业务侧幂等和补偿来兜底,适合丢弃影响不大的场景。生产环境建议选第一条,代码量多一截但能避免凌晨被电话叫醒。

5.3 现象:worker挂掉了,但进程池没有拉起新的

Swoole的Process\Pool默认在worker异常退出后会重新拉起新worker,但有一个前置条件:worker不能是自行调用exit退出。有些同学在消费循环里捕获到异常后直接exit(1),这会被Manager理解为“正常退出”,不再拉起。正确做法是在处理异常后继续循环,或者用throw抛出让外层兜住;真要主动退出,也先做好连接清理再exit。

另一个相关问题是重复崩溃导致的“拉起风暴”:onWorkerStart里Redis连不上就直接exit,Manager马上拉起新worker,拉起又连不上又退,日志刷屏,Redis还没恢复,CPU先被打满。我一般在WorkerStart里重试连接:失败后sleep 2秒再试,最多试10次,仍然失败再退出,给Redis留恢复窗口。

5.4 现象:协程Redis连接莫名其妙断掉

协程Redis的问题比普通Redis更多:长时间BRPOP阻塞超过服务端空闲时间被断开、跨协程错误地复用同一个连接、断线后没有重连继续调用导致抛异常。这些都是Swoole下Redis消费端的高频事故。

我的习惯是在消费循环开头加一个连接检查:每执行N次(比如100次)或每10秒ping一次Redis,ping失败就重新connect,connect放在try里。还有一条纪律:一个协程创建的连接不要clone给另一个协程用,同一个进程内如果有多个消费协程,每个协程独自建连,互不共享。连接复用的坑最脏,你永远不知道上一个协程把连接状态改成了什么。

5.5 现象:大批任务一次性涌入,消费端集体超时

延时队列的天然风险是“同一刻到期”的雪崩——比如一批定时任务在整点同时到期,调度器一口气搬了几千条到LIST,worker们同时BRPOP到任务,同时发起外部调用,外部接口瞬间被打挂。

见过比较惨的案例是接了个群发通知的需求,10万个任务同一时间到期,消费端32个worker全部在调通知接口,接口超时后重试,又叠加新一轮超时,整个队列卡了半小时才缓过来。后面加了两道约束:调度器单次批量限制在200以内,同一批到期任务被拆到多次扫描分批放出,相当于给Redis层加了个流量整形;消费端内部也加了并发控制,同一worker内同时处理的任务协程数设上限,超出的等待,保证外部依赖不会被瞬时的并发冲垮。

6. 进阶:优雅停机、幂等消费与监控那点事

6.1 优雅停机:让worker把手里的事干完再走

进程池拉起worker容易,让worker体面地退下来需要设计。我现在用的模式是:在WorkerStart里注册SIGTERM信号处理函数,把$running置为false,消费循环靠着BRPOP那1秒超时间隙感知到这个变化,处理完当前任务后自然退出。部署更新时,发SIGTERM给进程池主进程,Manager会逐一通知worker,等它们退出后再拉起新代码的worker,整个发布过程零任务丢失。

这里有一个细节:WorkerStop回调里不要做耗时操作,Swoole对这个阶段的超时控制比较紧,我在里面只做连接关闭和最后的日志落盘,耗时统计都提前算好放进独立存储。

6.2 幂等消费:多进程环境下最朴素的保障

不管怎么设计,多进程消费端一定会遇到重复消费:任务在processing队列里还没LREM,worker被kill,重启后任务被重新放回队列,又处理了一遍。所以任务体里必须带业务唯一键,比如订单ID、消息ID,处理前先查一下这个业务键是否已经处理过,处理时把这个键写入Redis或数据库带唯一索引,重复写入会被挡住。

这套逻辑单独写在业务层可能不完整,我习惯把它下沉到handleTask之前的一层公共代码里,所有worker统一走一遍“查重—处理—记账”的流程,谁接入谁少踩一半的坑。配上一组监控指标就齐了:队列积压量(LIST长度)、每分钟搬移量、消费成功率、单个任务平均耗时,这四项能看到这个系统90%的健康状况。

做这个方向几年下来,最大的教训是:延时队列的坑永远不在“延时”上,而在“队列”上——任务丢失、重复处理、流量雪崩,任何一个都比延时不准难缠得多。先把可靠性和监控做扎实,再谈性能优化,这个顺序不能反。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询