☰
Redis ZSET + Swoole 多进程延时队列:订单超时关闭实战
2026/10/21 7:52:41 网站建设 项目流程

简介:这份资源面向PHP后端开发者与消息队列学习者,聚焦高并发场景下的延迟消息处理难题,给出基于Redis与Swoole多进程消费端的完整实现方案。其核心思路是用Redis的Sorted Set以时间戳为score暂存延时消息,再借助Swoole Server的多进程模型并行消费,从而提升系统吞吐量并降低阻塞开销。压缩包为zip格式,整体约1.66MB,内含项目源码及相关资源,涉及Redis交互、Swoole多进程配置与消息处理等模块,解压后可按目录结构对照研读。目前已有1269人学习下载,适合希望理解延时队列原理、掌握Swoole多进程消费写法的中高级PHP开发者参考,也可作为构建可扩展消息处理系统的实践样例。

1. 当订单超时关闭遇上多进程消费:这个 Redis 延时队列项目能帮你省掉一堆定时脚本

电商后台最常见的需求之一:下单后 30 分钟未支付自动关单,同时释放库存。很多团队第一版都是 crontab 每分钟扫一次数据库,量小的时候没问题,一旦订单表上千万,扫描 SQL 直接把数据库拖垮。这个项目就是冲着这类场景来的——用 Redis 的有序集合(ZSET)存延时任务,用 Swoole 起多进程消费端,把「到点触发」这件事从数据库轮询里彻底剥离出来。它适合正在用 PHP 做订单、通知、重试类业务的开发者,尤其是已经上了 Swoole 但还没把延时队列跑稳的团队。整套代码不依赖额外中间件,Redis 加 PHP 就能跑,二次开发的门槛很低。

2. 架构拆解:ZSET 存任务、Swoole 多进程抢单的完整链路

2.1 为什么是 ZSET 而不是 List

延时队列的核心矛盾是「按时间排序」和「实时取出到期任务」。List 只能做到先进先出,没法按 score 排序;而 ZSET 的每个成员带一个 score,天然就是按时间戳排序的优先队列。常见做法是把任务的执行时间戳(毫秒级)作为 score,任务体序列化后作为 member。

<?php // 投递一个延时任务:30 分钟后执行 $redis = new Redis(); $redis->connect('127.0.0.1', 6379); $delay = 30 * 60; // 秒 $executeAt = (int)(microtime(true) * 1000) + $delay * 1000; $task = [ 'id' => uniqid('order_', true), 'type' => 'close_order', 'payload' => ['order_id' => 1024], 'retry' => 0, ]; // score 是毫秒时间戳,member 是 JSON 串 $redis->zAdd('delay_queue', $executeAt, json_encode($task));

这里有几个参数值得注意。executeAt用毫秒而不是秒,是因为秒级精度在高峰期会出现同一秒内大量任务堆积,消费端一次拉太多容易打满内存。retry字段是给消费失败重试用的,投递时初始化为 0。member 用 JSON 而不是 PHP serialize,是为了跨语言消费时不用反序列化 PHP 对象,后面做多语言扩展会省事。

2.2 消费端怎么「到点才取」

消费端不能无脑zRange全量拉,那样每次都要扫整个 ZSET。正确姿势是用zRangeByScore带上限:只取 score 小于等于当前时间的成员。

<?php // 每次只取到期的前 N 条 $now = (int)(microtime(true) * 1000); $tasks = $redis->zRangeByScore('delay_queue', 0, $now, [ 'limit' => [0, 100], // 偏移 0,取 100 条 ]); foreach ($tasks as $member) { // 先删再执行,避免多进程重复消费 $removed = $redis->zRem('delay_queue', $member); if ($removed === 0) { continue; // 已被其他进程抢走 } $task = json_decode($member, true); dispatch($task); }

zRem的返回值是关键:ZSET 的删除是原子操作,返回 1 表示这条任务确实被你删掉了,返回 0 说明已经被别的进程抢走。这就是多进程「抢单」的实现方式,不需要额外的分布式锁。limit的 100 是单次批量上限,设太大单进程会长时间占用,设太小空转次数变多,一般按消费能力压测后定在 50 到 200 之间。

2.3 Swoole 多进程的进程模型

消费端用 Swoole 的Process起多个 worker,每个 worker 独立跑一个循环。主进程只负责拉起和监控,不参与消费。

<?php $workerNum = 4; $workers = []; for ($i = 0; $i < $workerNum; $i++) { $process = new Swoole\Process(function (Swoole\Process $worker) { // 每个子进程独立连 Redis,避免连接共享 $redis = new Redis(); $redis->connect('127.0.0.1', 6379); $redis->setOption(Redis::OPT_READ_TIMEOUT, -1); while (true) { $now = (int)(microtime(true) * 1000); $tasks = $redis->zRangeByScore('delay_queue', 0, $now, ['limit' => [0, 100]]); if (empty($tasks)) { usleep(100000); // 100ms 空转间隔 continue; } foreach ($tasks as $member) { if ($redis->zRem('delay_queue', $member) === 0) { continue; } dispatch(json_decode($member, true)); } } }, false, false); $pid = $process->start(); $workers[$pid] = $process; } // 主进程回收退出信号 Swoole\Process::wait(true);

$workerNum设多少要看任务的平均执行耗时。如果单个任务执行 50ms,4 个进程理论吞吐是 80 QPS;如果任务里有 HTTP 调用,耗时波动大,建议先压测再定。usleep(100000)是空转间隔,100ms 意味着最坏情况下任务会晚 100ms 执行,对大多数业务够用;如果要求更精确,可以降到 10ms,但 CPU 空转成本会上升。OPT_READ_TIMEOUT设为 -1 是防止 Redis 连接在长时间空闲后被服务端断开。

3. 跑起来:从零搭建到验证任务触发

3.1 环境依赖和目录结构

跑这个项目需要 PHP 7.4 以上、Swoole 4.8 以上、Redis 扩展。目录结构一般长这样:

delay-queue/ ├── src/ │ ├── Producer.php # 投递端 │ ├── Consumer.php # 消费端主逻辑 │ ├── Worker.php # 单进程消费循环 │ └── Handler/ │ └── CloseOrder.php # 具体任务处理器 ├── config/ │ └── redis.php ├── bin/ │ └── consumer.php # 启动入口 └── composer.json

Handler目录是留给业务扩展的,每种任务类型对应一个处理器类,消费端根据type字段路由过去。这种设计的好处是新增任务类型不用改消费端核心代码,只加一个 Handler 就行。

3.2 启动消费端

# 安装依赖 composer install # 启动 4 个消费进程 php bin/consumer.php start -w 4

-w参数控制 worker 数量,不传默认 2。启动后可以用ps aux | grep consumer确认进程数。如果进程起来后立刻退出,先看 Redis 连接配置,再看 Swoole 版本是否匹配——Swoole 4 和 5 的Process构造参数有差异,这是最常见的翻车点。

3.3 验证任务是否按时触发

投递一条 10 秒后执行的任务,观察日志输出:

<?php // test_produce.php $redis = new Redis(); $redis->connect('127.0.0.1', 6379); $executeAt = (int)(microtime(true) * 1000) + 10000; $redis->zAdd('delay_queue', $executeAt, json_encode([ 'id' => 'test_1', 'type' => 'close_order', 'payload' => ['order_id' => 999], 'retry' => 0, ])); echo "投递完成,10 秒后触发\n";

执行后等 10 秒,消费端日志里应该出现close_order的处理记录。如果没出现,按这个顺序排查:zRangeByScore的 score 上限是不是用了秒级时间戳(和投递时的毫秒对不上)、Redis 的 key 是不是写错了、消费进程有没有真的在跑。

3.4 任务处理器的写法

<?php namespace Handler; class CloseOrder { public function handle(array $task): bool { $orderId = $task['payload']['order_id']; // 业务逻辑:关单、释放库存 // 常见做法是加一层幂等判断,防止重复执行 if ($this->isClosed($orderId)) { return true; } $this->close($orderId); return true; } }

handle返回true表示消费成功,返回false时消费端会把任务重新投递回 ZSET,score 设为当前时间加一个退避间隔。这个重试机制要配合retry字段做次数上限,不然失败任务会无限循环。

4. 避坑与排查:多进程消费最容易踩的五个坑

4.1 任务被重复消费

现象:同一个订单的关单逻辑执行了两次,日志里出现两条相同 task id 的记录。原因通常是消费端先执行再删除,或者删除和执行的顺序反了。解决方式是严格保证zRem在业务逻辑之前,并且判断返回值。如果业务逻辑本身耗时较长,zRem之后进程崩溃,任务会丢失——这是「至少一次」和「至多一次」的取舍,要精确一次就得在业务侧做幂等。

4.2 消费进程 CPU 跑满

现象:top看到消费进程 CPU 占用 90% 以上。原因是空转间隔设得太小,或者zRangeByScore的 limit 太大导致每次都在处理大量数据。把usleep调到 100ms 以上,limit 降到 100 以内,观察 CPU 是否回落。如果任务量本身就大,应该加 worker 而不是减小间隔。

4.3 任务延迟越来越大

现象:任务实际执行时间比预期晚了几分钟甚至更久。原因一般是 worker 数量不够,或者单个任务执行太慢拖住了整个循环。先看日志里单任务的执行耗时,如果某个 Handler 里有慢查询或外部调用,把它拆成异步任务。另一个可能是 ZSET 里积压了大量过期任务,消费端每次只取 100 条,积压多的时候要临时调大 limit 或加进程。

4.4 Redis 连接断开导致进程退出

现象:消费端跑一段时间后进程消失,日志里有RedisException: read error on connection。原因是 Redis 服务端有timeout配置,空闲连接会被断开。解决方式是设置OPT_READ_TIMEOUT为 -1,或者在循环里加心跳。更稳妥的做法是捕获异常后重连,不要让异常直接冒泡到进程顶层。

4.5 时间戳精度不一致

现象:任务投递后不触发,或者触发时间偏差很大。原因是投递端用了秒级时间戳,消费端用毫秒级比较,导致 score 永远大于当前时间。统一用毫秒,并且在投递和消费两端都打印时间戳做对比。这个坑在新手项目里出现频率极高,因为 PHP 的time()和microtime(true)很容易混用。

5. 二次开发:把延时队列改造成你自己的任务调度中心

这个项目最值得改造的地方是 Handler 路由和重试策略。默认实现里dispatch是一个简单的 switch,实际业务中任务类型会越来越多,建议改成注册制:

<?php // 在消费端启动时注册所有 Handler $handlers = [ 'close_order' => \Handler\CloseOrder::class, 'send_sms' => \Handler\SendSms::class, 'retry_pay' => \Handler\RetryPay::class, ]; // dispatch 时查表 $handlerClass = $handlers[$task['type']] ?? null; if (!$handlerClass) { // 未知类型直接丢弃,避免死循环 return true; } (new $handlerClass())->handle($task);

重试策略也可以从固定间隔改成指数退避。在任务体里加一个next_retry_at字段,失败时按retry次数计算下次执行时间:

<?php $retry = $task['retry'] + 1; if ($retry > 5) { // 超过上限,落到死信队列人工处理 $redis->zAdd('delay_queue_dead', time() * 1000, json_encode($task)); return true; } $task['retry'] = $retry; $backoff = min(300, pow(2, $retry)); // 2s, 4s, 8s... 最大 300s $nextAt = (int)(microtime(true) * 1000) + $backoff * 1000; $redis->zAdd('delay_queue', $nextAt, json_encode($task));

死信队列这个设计我强烈建议加上。没有死信队列的延时队列,失败任务要么无限重试拖垮消费端,要么直接丢失无从排查。加一个delay_queue_dead,配合一个简单的后台页面就能看到所有处理失败的任务,排查效率提升非常明显。

还有一个改造方向是加监控指标。每个 worker 在循环里定期往 Redis 写心跳和消费计数,主进程汇总后暴露一个 HTTP 接口,用 Prometheus 拉取。这样能实时看到队列积压量、消费速率、失败率。我一般会在Worker.php里加一个report()方法,每处理 100 条任务上报一次,成本很低但排查问题时能省大量时间。

从那以后我每次上线延时队列,都强制先跑一遍「投递 10 秒任务 + 观察日志 + 手动 kill 一个 worker 看任务是否被其他进程接管」这三步验证,确认抢单和容错都正常再放量。希望帮到你。

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

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

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

立即咨询