ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Redis延时队列实战:Swoole多进程消费与订单超时关闭

Redis延时队列实战:Swoole多进程消费与订单超时关闭

简介:这份资源聚焦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的好处是进程管理交给 Swoole,Worker 异常退出会自动拉起,不用自己写守护脚本。每个 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 #due

LIMIT限制单次搬运条数,防止一次捞太多把内存打爆。搬运和消费分离的设计,让 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 "extension=swoole.so" >> /etc/php.ini # 确认版本和进程池支持 php --ri swoole | grep -E "Version|Process"

Redis 服务端如果没现成的,用包管理器装一个就行,默认配置够用。关键是maxmemory-policy别设成allkeys-lru,否则内存吃紧时延时消息会被当缓存淘汰掉,这是最隐蔽的坑。

3.2 配置文件与参数说明

项目把可调参数集中在一个 config 文件里,避免散落在代码各处。下面这张表是几个必须关注的参数:

参数名默认值作用调整建议
worker_num4消费进程数按 CPU 核数,IO 密集可翻倍
batch_size100单次搬运条数消息积压时调大,内存紧张调小
max_retry3最大重试次数下游不稳定可加到 5
brpop_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 2>&1 & # 投递一条 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 能清空、死信为零,再上生产。延时队列这东西,平时不声不响,一出问题就是订单和资金的事,宁可多花十分钟验证。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进