简介这份资源聚焦PHP高并发场景下的延迟消息处理面向已掌握PHP基础、希望深入Swoole多进程与Redis队列实战的开发者。它基于Redis的Sorted Set结构以时间戳作为score实现延时投递再借助Swoole Server的多进程模型并行消费从而提升消息吞吐与实时性适合订单超时关闭、定时通知等业务参考。压缩包为zip格式大小约1.66MB内含项目源码及相关资源主要涉及PHP脚本与Swoole服务配置可据此梳理Redis交互、多进程调度与消费逻辑的实现脉络。目前已有1269人学习下载具备一定参考热度。通过阅读源码读者可掌握延时队列的入队与到期拉取方式、多进程消费者的组织与容错思路并理解非阻塞I/O如何降低资源消耗为自建轻量级消息消费服务提供可复用的工程范例。1. 从一次订单超时未关闭说起这个 Redis 延时队列到底解决什么问题电商后台最常见的投诉之一用户下单后没付款订单却一直挂在“待支付”库存被锁死运营手动关单关到手软。更隐蔽的是有些关单动作发生在支付回调之后直接把已付款订单给关了。这类问题的根子不在业务逻辑而在“到点触发”这件事没做好。这个项目就是冲着这个场景来的——用 Redis 做延时消息存储用 Swoole 多进程做消费端把“延迟到点执行”从定时脚本轮询里解放出来。它适合正在用 PHP 做订单、通知、重试类业务又不想引入 RabbitMQ、Kafka 这类重型中间件的团队。整套东西不依赖额外服务Redis 加 PHP 扩展就能跑消费端是多进程常驻内存延迟精度能压到秒级比 crontab 一分钟一轮的粗粒度靠谱得多。2. 架构拆解Redis 有序集合 Swoole 多进程怎么配合2.1 为什么选 Redis ZSET 而不是 List延时消息的核心诉求是“按到期时间排序先到期的先出”。Redis 的 List 只能做 FIFO没法按时间戳排序而 ZSET 的 score 天然就是时间戳ZRANGEBYSCORE能直接捞出所有到点的消息。这个项目里每条延时消息序列化后作为 member到期时间戳毫秒作为 score写入一个统一的 ZSET。?php // DelayQueue.php 生产者侧核心写入逻辑 class DelayQueue { private $redis; private $queueKey delay_queue:zset; public function __construct($redis) { $this-redis $redis; } /** * 投递一条延时消息 * param array $payload 业务数据会被 json 序列化 * param int $delayMs 延迟毫秒数 */ public function push(array $payload, int $delayMs): bool { // 到期时间 当前毫秒时间戳 延迟毫秒 $score (int)(microtime(true) * 1000) $delayMs; $member json_encode($payload, JSON_UNESCAPED_UNICODE); // ZADD 写入score 为到期时间 return (bool)$this-redis-zAdd($this-queueKey, $score, $member); } }这里有几个参数值得说清楚。$delayMs用毫秒而不是秒是因为订单关单这类场景对精度有要求秒级在高峰期容易堆积。$member用 JSON 而不是 PHP 序列化是为了跨语言消费时不用反序列化 PHP 对象也方便在 Redis 里直接肉眼查看。zAdd返回的是新增元素个数重复 member 会被覆盖 score所以业务侧要保证 payload 里有唯一标识避免同一条消息被不同延迟覆盖。2.2 多进程消费端的进程模型消费端是整个项目的重头戏。单进程轮询 ZSET 有两个问题一是ZRANGEBYSCORE和ZREM之间不是原子的多进程同时捞会重复消费二是单进程处理慢任务时会阻塞后续消息。这个项目用 Swoole 的Process起了三类进程一个 Master 负责拉起和监控N 个 Worker 负责实际消费一个 Timer 进程负责定时把到点消息从 ZSET 搬到就绪队列。?php // consumer.php 多进程消费端启动逻辑 $workerNum 4; // 消费进程数按 CPU 核数调整 $pool new Swoole\Process\Pool($workerNum); $pool-on(WorkerStart, function (Swoole\Process\Pool $pool, int $workerId) { // 每个 worker 独立连接 Redis避免连接共享竞争 $redis new Redis(); $redis-connect(127.0.0.1, 6379); $redis-setOption(Redis::OPT_READ_TIMEOUT, -1); // 常驻循环从就绪队列阻塞读取 while (true) { // BRPOP 阻塞式弹出没消息时挂起不空转 CPU $result $redis-brPop(delay_queue:ready, 1); if (empty($result)) { continue; } $payload json_decode($result[1], true); try { // 业务处理入口由具体项目注入 handleMessage($payload); } catch (\Throwable $e) { // 失败重试逻辑见 2.3 retryMessage($redis, $payload, $e); } } }); $pool-start();Swoole\Process\Pool的好处是进程管理交给 SwooleWorker 异常退出会自动拉起不用自己写守护脚本。每个 Worker 独立connectRedis 是血泪经验——早期版本共用一个连接brPop阻塞时其他进程全被卡死。brPop的超时设 1 秒是为了让进程有机会响应退出信号设 0 会永久阻塞导致kill不掉。2.3 到点搬运与失败重试Timer 进程的职责是扫描 ZSET把score 当前时间的消息原子性地搬到 List。这里用 Lua 脚本保证“取出即删除”的原子性避免多进程重复搬运。-- move_due.lua 原子搬运到点消息 -- KEYS[1] zset key, KEYS[2] ready list key -- ARGV[1] 当前毫秒时间戳, ARGV[2] 单次最大搬运条数 local due redis.call(ZRANGEBYSCORE, KEYS[1], 0, ARGV[1], LIMIT, 0, ARGV[2]) if #due 0 then return 0 end for i, msg in ipairs(due) do redis.call(RPUSH, KEYS[2], msg) redis.call(ZREM, KEYS[1], msg) end return #dueLIMIT限制单次搬运条数防止一次捞太多把内存打爆。搬运和消费分离的设计让 ZSET 只承担排序职责List 承担队列职责各司其职。失败重试这块项目里用的是“重试计数 延迟回投”消息处理失败后在 payload 里累加retry_count超过阈值写死信队列没超过就按指数退避重新push回 ZSET。?php function retryMessage($redis, array $payload, \Throwable $e): void { $maxRetry 3; $count $payload[retry_count] ?? 0; if ($count $maxRetry) { // 超过重试上限进死信队列人工排查 $redis-lPush(delay_queue:dead, json_encode($payload)); return; } $payload[retry_count] $count 1; // 指数退避2^count 秒后重试 $delayMs (int)(pow(2, $count) * 1000); (new DelayQueue($redis))-push($payload, $delayMs); }指数退避的底数是 2第一次失败 1 秒后重试第二次 2 秒第三次 4 秒。这个节奏对下游服务临时抖动足够友好又不会让消息在队列里躺太久。死信队列用 List 存方便运维用LRANGE直接查看积压了哪些处理不了的消息。3. 跑起来环境准备与启动步骤3.1 依赖清单与安装跑这个项目需要三样东西PHP 7.4 以上、Swoole 4.8 以上扩展、Redis 5.0 以上服务。Swoole 用 PECL 装最省事注意编译时要带上--enable-openssl否则后续如果消息体走加密会翻车。# 安装 Swoole 扩展 pecl install swoole # 在 php.ini 里启用 echo extensionswoole.so /etc/php.ini # 确认版本和进程池支持 php --ri swoole | grep -E Version|ProcessRedis 服务端如果没现成的用包管理器装一个就行默认配置够用。关键是maxmemory-policy别设成allkeys-lru否则内存吃紧时延时消息会被当缓存淘汰掉这是最隐蔽的坑。3.2 配置文件与参数说明项目把可调参数集中在一个 config 文件里避免散落在代码各处。下面这张表是几个必须关注的参数参数名默认值作用调整建议worker_num4消费进程数按 CPU 核数IO 密集可翻倍batch_size100单次搬运条数消息积压时调大内存紧张调小max_retry3最大重试次数下游不稳定可加到 5brpop_timeout1阻塞读取超时秒保持 1便于优雅退出zset_keydelay_queue:zset延时集合键名多业务用不同前缀隔离配置加载用最简单的require返回数组不引入额外配置库减少依赖。?php // config.php return [ redis [ host 127.0.0.1, port 6379, auth , db 0, ], consumer [ worker_num 4, batch_size 100, max_retry 3, brpop_timeout 1, ], queue [ zset_key delay_queue:zset, ready_key delay_queue:ready, dead_key delay_queue:dead, ], ];3.3 启动与验证启动分两步先起消费端常驻进程再投递测试消息验证链路。消费端用nohup挂后台日志重定向到文件方便排查。# 启动消费端 nohup php consumer.php /var/log/delay_consumer.log 21 # 投递一条 5 秒后执行的测试消息 php -r require DelayQueue.php; \$redis new Redis(); \$redis-connect(127.0.0.1, 6379); \$q new DelayQueue(\$redis); \$q-push([order_id 1001, action close], 5000); echo pushed; 投递后等 5 秒看日志里有没有出现handleMessage的输出。如果没动静先查 ZSET 里有没有数据ZRANGE delay_queue:zset 0 -1 WITHSCORES。有数据但没被搬运说明 Timer 进程没跑起来数据被搬走了但没消费说明 Worker 的brPop键名对不上。这个排查顺序能覆盖八成启动问题。4. 避坑与常见问题排查4.1 消息重复消费现象同一条订单关单消息被执行了两次日志里出现两条相同order_id的处理记录。原因ZRANGEBYSCORE和ZREM分两步执行时两个 Timer 进程同时捞到了同一条消息。解决搬运逻辑必须用 Lua 脚本包起来利用 Redis 单线程特性保证原子性项目里move_due.lua就是干这个的。另外业务侧的处理函数最好做幂等比如关单前先查订单状态已关闭的直接跳过。4.2 消费进程假死现象ps看进程还在但消息堆积越来越多日志不再输出。原因brPop的超时设成了 0进程永久阻塞在读取上Swoole 的退出信号收不到也无法感知 Redis 连接断开。解决超时设 1 秒并在循环里加连接健康检查ping不通就重连。这个坑我在两个项目里都踩过后来把brpop_timeout写进配置默认值谁也别改。4.3 内存持续增长现象跑几天后 Redis 内存涨到报警ZSET里堆了大量过期消息。原因Timer 进程挂了但没人发现到点消息一直没被搬运ZSET 只进不出。解决给 Timer 进程加独立的心跳键用SETEX每 10 秒续期另起一个监控脚本检查心跳键是否存在不存在就告警。同时给 ZSET 设一个兜底清理任务把 score 小于一天前的时间戳消息直接删掉。4.4 时间戳精度问题现象设了 1 秒延迟的消息实际 2 到 3 秒后才执行。原因microtime(true) * 1000在 32 位 PHP 上会溢出成浮点数精度丢失。解决确认 PHP 是 64 位版本PHP_INT_SIZE等于 8。32 位环境要么升级要么改用秒级时间戳但秒级在密集延迟场景下排序会不准。4.5 优雅退出丢消息现象重启消费端时正在处理的消息丢了既没进死信也没重试。原因kill进程时brPop已经弹出消息但业务还没处理完进程直接退出。解决捕获SIGTERM信号在信号处理函数里设一个退出标志Worker 循环检查标志处理完当前消息再退出。Swoole 的Process\Pool支持on(WorkerStop)回调把清理逻辑放里面。5. 二次开发从通用队列到业务定制拿到这个项目后最常改的三个地方是消息体结构、消费路由和监控埋点。消息体结构决定了你能塞多少业务上下文我一般会在 payload 里固定几个字段biz_type标识业务类型、biz_id业务主键、retry_count重试计数、created_at投递时间。这样消费端可以根据biz_type做路由不同业务走不同的处理函数而不是把所有逻辑堆在一个handleMessage里。?php // 消费路由按 biz_type 分发到不同处理器 function handleMessage(array $payload): void { $handlers [ order_close handleOrderClose, notify_send handleNotifySend, stock_release handleStockRelease, ]; $type $payload[biz_type] ?? ; if (!isset($handlers[$type])) { throw new \InvalidArgumentException(unknown biz_type: {$type}); } call_user_func($handlers[$type], $payload[data]); }路由表用数组映射而不是switch好处是新增业务只加一行不用改控制流。每个 handler 只关心自己的data字段职责清晰。改造时注意一点handler 内部不要再抛异常给外层重试业务上可预期的失败比如订单已支付不需要关单应该直接return只有系统级异常才走重试。监控埋点这块我习惯在三个位置打点投递时记录push_count、搬运时记录move_count、消费成功和失败各记一个计数。用 Redis 的INCR按分钟做键比如metric:push:202501011200这样不用引入 Prometheus 也能用KEYS或SCAN拉出趋势。验证改造是否生效最直接的办法是写一个压测脚本循环投递一万条不同延迟的消息观察消费端日志的完成时间和死信队列长度。# 压测投递 10000 条随机延迟消息 php -r require DelayQueue.php; \$redis new Redis(); \$redis-connect(127.0.0.1, 6379); \$q new DelayQueue(\$redis); for (\$i 0; \$i 10000; \$i) { \$q-push([biz_type order_close, data [order_id \$i]], rand(1000, 60000)); } echo done; 压测时重点看两个指标ZSET 的ZCARD是否在延迟窗口后归零死信队列是否为空。如果 ZCARD 长时间不降说明 Timer 搬运跟不上调大batch_size或增加 Timer 进程数。如果死信堆积说明 handler 里有稳定触发的异常去日志里搜retry_count最大的那几条消息基本能定位到问题代码。从那以后我每次改消费逻辑都强制先跑一遍这个压测脚本确认 ZSET 能清空、死信为零再上生产。延时队列这东西平时不声不响一出问题就是订单和资金的事宁可多花十分钟验证。希望帮到你。本文还有配套的精品资源点击获取 SEO 优化官网定制响应式建站教育培训建站