news 2026/10/21 7:50:29

Swoole多进程+Redis ZSet延时队列:秒级订单超时关闭实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Swoole多进程+Redis ZSet延时队列:秒级订单超时关闭实战

简介:这份资源面向PHP后端开发者与消息队列学习者,提供一套基于Redis延时队列与Swoole多进程模型构建的高并发消费端实现,可用于订单超时关闭、定时任务触发等需要延迟处理的业务场景。压缩包为zip格式,整体约1.66MB,内含项目源码及相关资源文件,涵盖Redis交互、Swoole多进程配置与消息消费逻辑等核心模块,便于读者直接解压研读与二次改造。目前已有1269人学习下载,说明其在PHP异步编程与队列实践领域具有一定参考价值。通过阅读源码,读者可以掌握用Sorted Set配合时间戳score实现延时投递、借助ZRANGEBYSCORE拉取到期消息,以及利用Swoole Server多子进程并行消费提升吞吐量的完整思路,同时理解非阻塞I/O在降低资源消耗方面的作用,适合作为进阶PHP工程师构建可靠消息处理系统的实战参考。

1. 从一次订单超时未关闭说起:这套 Redis 延时队列到底能干什么

电商后台最常见的翻车场景之一:用户下单后迟迟不付款,库存被锁死,订单状态却一直挂在“待支付”。你写个定时脚本每分钟扫一次数据库,订单量一上来,SELECT * FROM orders WHERE status=0 AND create_time < ?直接把慢查询打满,数据库 CPU 飙到 90%。更尴尬的是,扫描间隔设 1 分钟,用户实际超时时间就在 60 到 120 秒之间飘,业务方天天追着问“到底几分钟关单”。

这套基于 Swoole 多进程消费端的 Redis 延时消息队列,解决的就是这类“到点才触发”的定时任务。核心思路是把延时任务塞进 Redis 的有序集合(ZSet),score 存到期时间戳,消费端用 Swoole 起多个 worker 进程轮询到期任务并投递执行。相比纯 crontab 扫表,它把精度压到秒级,把数据库压力转移到内存,还能横向扩消费进程。适合谁?手上是 PHP 技术栈、已经在用 Redis、又不想为了一个延时功能去引 Kafka 或 RabbitMQ 的团队。下面我按“结构怎么设计 → 怎么跑起来 → 坑在哪 → 怎么验证”拆一遍。

2. 拆开看结构:ZSet 存任务、Swoole 多进程怎么分工

2.1 为什么是 ZSet 而不是 List

延时队列的本质是“按时间排序,到点取出来”。Redis 里能排序的结构就 ZSet 最合适:member 存任务内容(一般序列化后的 JSON),score 存到期时间戳(毫秒或秒,全项目统一即可)。取到期任务用ZRANGEBYSCORE queue 0 <now>,拿到就删,天然去重。

有人会问 List 行不行。List 是 FIFO,你没法按时间取,只能靠BLPOP阻塞等,但它不支持“延迟到某个时间点才可见”。用 List 做延时,常见做法是配合一个轮询线程把到期的搬到就绪 List,多了一层中转,反而更绕。ZSet 一步到位,代价是ZRANGEBYSCORE是 O(log N + M),任务量百万级时单次拉取要控制 M 的大小,别一次拉几万条。

任务 member 的格式建议固定成带业务前缀的字符串,方便排查:

{ "job_id": "order_close_20240521_8891", "topic": "order.timeout", "payload": {"order_id": 8891, "uid": 100233}, "delay": 1800, "expire_at": 1716280000000, "retry": 0 }

expire_at是绝对到期时间,delay只作记录。为什么存绝对时间?因为任务可能在队列里躺很久,存相对 delay 的话消费端还得自己算基准,容易错。

2.2 Swoole 多进程的角色划分

单进程轮询有两个问题:一是ZRANGEBYSCORE和业务执行串在一起,业务一慢,拉取就卡;二是单核跑不满。Swoole 的进程模型刚好能拆开:

  • Master 进程:Swoole\Process或Swoole\Server的管理进程,负责拉起和回收 worker。
  • Timer 进程(1 个):只做一件事,定时(比如每 200ms)从 ZSet 拉到期任务,投递到就绪队列。它不碰业务。
  • Worker 进程(N 个):从就绪队列取任务,执行业务逻辑,失败按策略重试。

就绪队列可以用 Redis List,也可以用 Swoole 的Channel做进程间通信。用 Redis List 的好处是 worker 崩了任务不丢,坏处是多一次网络往返;用Channel快,但进程重启任务就没了。生产环境我一般选 Redis List,稳。

进程数怎么定?不是越多越好。worker 是 CPU 密集型就设成核数,IO 密集型(比如要调第三方接口)可以设到核数的 2 到 4 倍。Timer 进程永远只留 1 个,多个 Timer 会重复拉取,除非你在拉取时用ZREM的返回值做原子抢占。

2.3 拉取与投递的原子性

Timer 拉取时有个经典竞态:两个进程同时ZRANGEBYSCORE拿到同一批任务。解决办法是用ZREM的返回值判断——先ZRANGEBYSCORE拿 member,再对每个 member 执行ZREM,返回 1 说明抢到了,返回 0 说明被别人抢走,跳过。别用ZPOPMIN,它按 score 最小的弹,但你没法限定“只弹到期的”。

<?php // Timer 进程核心拉取逻辑 $now = (int)(microtime(true) * 1000); $jobs = $redis->zRangeByScore('delay_queue', 0, $now, ['limit' => [0, 200]]); foreach ($jobs as $job) { // ZREM 返回 1 才算抢到,避免多进程重复消费 if ($redis->zRem('delay_queue', $job) === 1) { $redis->lPush('ready_queue', $job); } }

limit控制单次拉取量,200 是个经验值,太大单次循环耗时长,太小空转多。$now用毫秒时间戳,和写入时保持一致,混用秒和毫秒是新手最容易犯的错。

3. 把队列跑起来:从写任务到消费的完整链路

3.1 投递一个延时任务

投递端就是往 ZSet 里ZADD,score 是当前时间加延时。封装成一个函数,统一入口,方便以后换存储。

<?php function pushDelayJob(Redis $redis, string $topic, array $payload, int $delaySec): string { $jobId = $topic . '_' . uniqid('', true); $job = [ 'job_id' => $jobId, 'topic' => $topic, 'payload' => $payload, 'delay' => $delaySec, 'expire_at' => (int)(microtime(true) * 1000) + $delaySec * 1000, 'retry' => 0, ]; // score 用到期毫秒时间戳,member 存 JSON $redis->zAdd('delay_queue', $job['expire_at'], json_encode($job, JSON_UNESCAPED_UNICODE)); return $jobId; } // 调用:30 分钟后关闭订单 pushDelayJob($redis, 'order.timeout', ['order_id' => 8891], 1800);

JSON_UNESCAPED_UNICODE别省,不然中文 payload 会变成\uXXXX,排查时看着头疼。job_id用uniqid保证唯一,方便日志追踪。

3.2 消费端进程启动

消费端用Swoole\Process起进程池,Timer 和 Worker 分开。下面是一个可跑的最小骨架。

<?php $workerNum = 4; $pool = new Swoole\Process\Pool($workerNum + 1); // 1 个 timer + N 个 worker $pool->on('WorkerStart', function (Swoole\Process\Pool $pool, int $workerId) { $redis = new Redis(); $redis->connect('127.0.0.1', 6379); $redis->auth('your_password'); if ($workerId === 0) { // Timer 进程:定时拉取到期任务 Swoole\Timer::tick(200, function () use ($redis) { $now = (int)(microtime(true) * 1000); $jobs = $redis->zRangeByScore('delay_queue', 0, $now, ['limit' => [0, 200]]); foreach ($jobs as $job) { if ($redis->zRem('delay_queue', $job) === 1) { $redis->lPush('ready_queue', $job); } } }); } else { // Worker 进程:阻塞取就绪任务并执行 while (true) { $job = $redis->brPop('ready_queue', 1); if (!$job) { continue; } $data = json_decode($job[1], true); try { handleJob($data); // 业务处理函数 } catch (Throwable $e) { retryJob($redis, $data, $e); // 失败重试 } } } }); $pool->start();

Swoole\Process\Pool的WorkerStart里每个进程独立连 Redis,别在on外面建连接再 fork,连接会被子进程共享导致数据错乱。brPop的超时设 1 秒,既能及时响应,又不会空转烧 CPU。handleJob和retryJob按业务自己实现。

3.3 重试与死信处理

任务失败不能无限重试。常见做法是重试次数写进 payload,超过阈值丢到死信队列(另一个 List 或 ZSet),人工介入。

<?php function retryJob(Redis $redis, array $data, Throwable $e): void { $data['retry'] = ($data['retry'] ?? 0) + 1; if ($data['retry'] > 3) { // 超过 3 次进死信,保留现场 $data['error'] = $e->getMessage(); $redis->lPush('dead_letter_queue', json_encode($data, JSON_UNESCAPED_UNICODE)); return; } // 指数退避:2^retry 秒后重试 $delay = pow(2, $data['retry']); $data['expire_at'] = (int)(microtime(true) * 1000) + $delay * 1000; $redis->zAdd('delay_queue', $data['expire_at'], json_encode($data, JSON_UNESCAPED_UNICODE)); }

指数退避避免失败任务瞬间打满重试。死信队列要配监控,不然任务悄悄堆着没人知道。重试次数阈值 3 是经验值,对时效敏感的业务可以降到 1 到 2。

3.4 关键参数对照

参数建议值说明
Timer 轮询间隔200ms越小精度越高,CPU 占用越大
单次拉取量 limit100~500按任务平均执行耗时调
Worker 进程数CPU 核数 × 1~4IO 密集取高值
brPop 超时1s兼顾响应与空转
最大重试次数3按业务容忍度调
退避基数22^retry 秒

4. 避坑排查:这几个问题我踩过不止一次

4.1 任务重复消费

现象:同一个订单被关闭两次,日志里两条相同 job_id。原因:Timer 进程起了多个,或者拉取时没用ZREM返回值做原子判断,两个进程同时拿到同一 member。解决:Timer 只留 1 个进程;拉取必须ZRANGEBYSCORE加ZREM返回值判断,别用ZPOPMIN图省事。

4.2 时间戳单位混用

现象:任务要么立刻被消费,要么永远不触发。原因:写入用秒级time(),拉取用毫秒microtime(true)*1000,score 差三个数量级。解决:全项目统一毫秒,封装一个nowMs()函数,禁止各处自己写。

4.3 Worker 阻塞导致就绪队列堆积

现象:ready_queue长度持续上涨,任务延迟越来越大。原因:handleJob里有同步 HTTP 请求或大文件操作,worker 被占满。解决:把耗时操作异步化,或增加 worker 数;给handleJob加超时,超时任务走重试。

4.4 Redis 连接在 fork 后失效

现象:worker 启动后报Redis server went away。原因:在Pool外面建了 Redis 连接,fork 后子进程共用同一个 socket。解决:连接必须在WorkerStart回调里建,每个进程独立。

4.5 内存持续增长

现象:Redis 内存只涨不降,delay_queue里堆了大量过期任务。原因:Timer 进程挂了或拉取逻辑异常,到期任务没被清理。解决:给 Timer 进程加健康检查,挂了自动重启;定期用ZCOUNT delay_queue 0 <now>监控积压量,超阈值告警。

5. 验证与压测:怎么确认它真的按秒触发

写完不验证等于没写。我一般分三步:单任务精度、批量积压、故障恢复。

单任务精度:投一个 10 秒后触发的任务,记录投递时间和实际执行时间,差值应在 200ms 到 400ms 之间(取决于轮询间隔)。写个脚本循环 100 次,统计 P99 延迟。

<?php // 精度测试:投 100 个 5 秒任务,统计实际触发延迟 for ($i = 0; $i < 100; $i++) { pushDelayJob($redis, 'test.precision', ['seq' => $i], 5); } // 消费端记录每个任务的 (实际执行时间 - expire_at),输出 P50/P99

批量积压:一次性投 10 万个任务,观察ready_queue长度变化和消费速率。如果消费速率上不去,先看 worker 是不是被慢业务卡住,再看 Redis 是不是成了瓶颈(SLOWLOG GET看慢命令)。

故障恢复:手动 kill 掉 Timer 进程,确认到期任务没丢(还在 ZSet 里),重启后能补消费。再 kill 一个 worker,确认就绪队列里的任务被其他 worker 接管。这两步过了,才算能上生产。

压测时用redis-cli --latency看 Redis 响应,用top -H看 Swoole 各进程 CPU 分布。Timer 进程 CPU 应该很低(个位数),如果它飙高,多半是轮询间隔太短或单次拉取量太大。

从那以后我每次上线延时队列,都强制先跑一遍精度测试和 kill 恢复测试,确认 P99 延迟和任务不丢,才敢接真实业务。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 7:49:11

从内存泄漏到性能优化:深入理解堆与栈的核心原理与排查实践

1. 从一次内存泄漏排查说起&#xff1a;为什么我还要再写一遍堆和栈我敢打赌&#xff0c;很多程序员真正开始认真学内存&#xff0c;都是从线上出问题那天开始的。我自己就是。某个深夜在跑模拟项目X的压测&#xff0c;监控面板上的内存占用一条直线往上走&#xff0c;重启以后…

作者头像 李华
网站建设 2026/10/21 7:48:47

RecyclerView悬浮Header实战:悬浮View+滚动监听方案

做 Android 列表的&#xff0c;早晚都会遇到这个需求&#xff1a;列表滚动时&#xff0c;顶部分组标题要固定不动&#xff0c;也就是俗称的 Sticky Header 悬浮效果。通讯录按字母分组、商品列表按分类筛选、小说阅读按卷章跳转&#xff0c;这些场景里都能看到它的身影。我最早…

作者头像 李华
网站建设 2026/10/21 7:48:46

网络安全防御进阶:系统加固与日志监测实战

我无法提供与恶意软件、后门逆向分析相关的任何内容。这类话题可能涉及用于绕过安全防护的技术细节&#xff0c;存在被滥用于攻击他人系统或网络的风险。出于安全考虑&#xff0c;我不能协助生成可能助长恶意行为的分析教程、操作步骤或技术原理。如果你正在学习网络安全防御方…

作者头像 李华
网站建设 2026/10/21 7:48:29

FANUC机器人SRVO-300机械手断裂报警:触发逻辑与现场排查全解析

简介&#xff1a;一份围绕发那科机器人SRVO-300机械手断裂故障报警的专项分析文档&#xff0c;面向工业自动化现场的调试维修人员与设备管理者&#xff0c;帮助系统掌握报警原因和处置方法。内容从报警手册解读切入&#xff0c;说明机械手断裂信号在设定为无效时却被检测到所引…

作者头像 李华
网站建设 2026/10/21 7:48:23

Playwright MCP实战:AI驱动浏览器自动化与前端测试

1. 为什么是 Playwright MCP&#xff0c;而不是让 AI 直接写脚本1.1 传统 AI 写浏览器脚本的“最后一公里”难题很多做过 AI 辅助测试的人都经历过这种尴尬&#xff1a;让 AI 生成一段爬虫或者自动化脚本&#xff0c;它写得飞快&#xff0c;但一运行就报错&#xff0c;元素找不…

作者头像 李华
网站建设 2026/10/21 7:48:22

赢顺云指标公式买卖多空声音提示:信号去重与预警配置实战

简介&#xff1a;这份文华财经指标公式文档面向期货程序化交易爱好者与赢顺云软件用户&#xff0c;解决买卖点多空信号缺乏直观提示的问题。资源为单个doc文件&#xff0c;压缩包约35KB&#xff0c;内容以指标公式源码与注释为主&#xff0c;可直接导入软件使用。文档核心围绕M…

作者头像 李华