1. 项目概述:为什么一个“简单消息队列”值得花一整天搭出来?
PHP + Redis 实现简单消息队列——这八个字,是我过去三年在某中型互联网公司后端团队里,被问得最多、也复用率最高的一句技术方案开场白。它不是高大上的分布式消息中间件,不涉及Kafka的分区重平衡,也不需要RocketMQ的NameServer集群;它就是用PHP写几行逻辑,靠Redis的LPUSH和BRPOP两个命令,把“任务发出去”和“任务取出来”这两件事稳稳地串起来。但恰恰是这个“简单”,让它成了我处理异步通知、邮件发送、日志归档、图片缩略图生成、用户行为埋点上报等几十类低延迟、中低吞吐场景的首选方案。
你可能会说:“不就是个队列?Laravel有Horizon,ThinkPHP有TaskWorker,直接装包不就完了?”——这话没错,但问题在于:当你的项目还处在MVP验证阶段,服务器只有1台2核4G的云主机,数据库刚从MySQL迁到MariaDB,运维同学正忙着调优慢查询,而产品总监明天就要看注册成功后的短信通知是否能秒发——这时候,你真敢为一个“发短信”的功能,去部署一套需要独立JVM、配置ZooKeeper协调、还要写Consumer Group消费位点管理的Kafka集群吗?答案是否定的。而PHP + Redis方案,从零开始到第一个任务成功入队出队,实测耗时7分32秒:3分钟装好Redis(apt install redis-server),2分钟写好生产者脚本,2分钟写好消费者守护进程,再加32秒调试BRPOP超时参数。它不解决万亿级消息投递,但它完美匹配“单机扛住500QPS、延迟控制在200ms内、代码可读性高于配置复杂度”的真实业务现场。
这个方案的核心价值,从来不在“多先进”,而在“多省心”。它把消息队列最本质的契约——生产者只管发,消费者只管收,中间有个缓冲区保证不丢不乱——用最直白的PHP函数和Redis原生命令具象化了。没有抽象层,没有中间件协议解析,没有序列化反序列化的隐式开销。你var_dump($message)看到的就是最终进队列的内容,echo $job['user_id']拿到的就是原始数组键值。这种“所见即所得”的确定性,在快速迭代、人手紧张、线上问题必须5分钟定位的中小项目里,比任何架构图都管用。
适合谁来参考这篇?第一类是刚转PHP后端的开发者,还在用sleep(1)轮询数据库表模拟队列,看到BRPOP能阻塞等待就眼前一亮;第二类是运维或全栈工程师,需要给现有PHP站点加个轻量异步能力,不想动架构、不希望引入新语言(比如Go写的消费者);第三类是技术负责人,在做技术选型评审时,需要一份能说清“为什么不用RabbitMQ”的客观对比依据。如果你属于这三类中的任何一类,接下来的内容,就是我踩过坑、压过测、上线跑过半年的真实记录,不是教程,是战报。
2. 整体设计思路:为什么选Redis而不是数据库轮询或文件队列?
2.1 三种常见轻量队列方案的硬碰硬对比
在决定用PHP + Redis前,我系统性地对比过三种在小团队中高频出现的“简易队列”实现方式:MySQL轮询表、文件追加队列、Redis列表。这不是纸上谈兵,而是基于某次促销活动短信服务崩溃后做的复盘分析。当时我们用的是MySQL轮询方案——建一张sms_queue表,字段含id,phone,content,status,created_at,生产者插入数据,消费者每2秒SELECT * FROM sms_queue WHERE status = 0 LIMIT 1 FOR UPDATE,处理完再UPDATE status = 1。结果活动高峰时,数据库连接数飙到200+,SELECT ... FOR UPDATE锁表导致订单写入延迟,最终被迫临时切回同步发送。
| 方案 | 核心机制 | 优势 | 致命缺陷 | 实测500QPS下表现 |
|---|---|---|---|---|
| MySQL轮询 | INSERT+ 定时SELECT ... FOR UPDATE | 数据持久化强,天然支持事务 | 高频锁表、连接数爆炸、无阻塞等待机制 | CPU持续92%,平均延迟1.8s,失败率12% |
| 文件队列 | file_put_contents($file, $msg.PHP_EOL, FILE_APPEND)+fgets() | 零依赖,磁盘IO可控 | 并发写入冲突(需flock)、无优先级、无法跨机器共享 | 多进程争抢文件锁,30%任务重复消费 |
| Redis列表 | LPUSH入队 +BRPOP出队(带阻塞) | 原子操作、毫秒级响应、天然支持多消费者、内存高速存取 | 断电丢失(可配AOF+RDB缓解) | CPU峰值38%,平均延迟86ms,零失败 |
这个表格里的数据,全部来自同一台测试机(4核8G,Redis 7.0单节点,PHP 8.1 CLI)。关键结论很清晰:当QPS超过200,且要求延迟低于500ms时,MySQL和文件方案会从“能用”迅速滑向“不可控”。而Redis方案,不仅性能达标,更重要的是它的语义简洁——BRPOP queue_name 0这条命令,0代表“永远阻塞直到有数据”,它天然解决了“消费者空转耗CPU”和“轮询间隔难设定”这两个经典痛点。你不需要写while(true){ sleep(0.1); check(); },Redis替你做了智能等待,有数据立刻唤醒,没数据就挂起,操作系统级调度,比PHP层的sleep()精准十倍。
2.2 Redis列表结构为何是队列的黄金搭档?
很多人初学时会困惑:Redis有List、Set、Sorted Set、Stream这么多数据结构,为什么队列首选List?答案藏在它的底层实现和命令语义里。Redis List在3.2版本前用双向链表(linkedlist)实现,之后对短列表改用压缩列表(ziplist),但无论哪种,LPUSH(左推入)和RPOP(右弹出)都是O(1)时间复杂度的原子操作。这意味着:
- 生产者调用
LPUSH job_queue '{"type":"email","to":"a@b.com"}',数据瞬间落库,无需考虑并发写冲突; - 消费者调用
BRPOP job_queue 30,若队列为空,Redis内核直接让该客户端连接进入阻塞状态,不消耗CPU,30秒内有新任务则立即返回; - 多个消费者同时执行
BRPOP,Redis保证同一时刻只有一个消费者能拿到任务(内部用单线程事件循环+原子dequeue实现),天然避免了“重复消费”。
这里有个极易被忽略的细节:必须用BRPOP而非RPOP。RPOP是非阻塞的,如果队列空,它立刻返回nil,消费者只能自己写while(! $job = redis->rPop('queue')){ usleep(10000); },这叫“忙等待”,10微秒一次空轮询,10个消费者就是10万次/秒无效调用,CPU直接拉满。而BRPOP把轮询逻辑下沉到Redis服务端,客户端连接挂起,资源零占用。我曾在线上误用RPOP,监控显示消费者进程CPU常年维持在95%,改成BRPOP后瞬间回落至3%,这是架构师必须刻进DNA的认知。
2.3 PHP与Redis协同的关键设计决策
PHP本身是无状态的请求生命周期模型,而消息队列需要长期运行的消费者进程。这就引出第一个关键设计点:消费者不能放在Web请求里启动。很多新手会写index.php里放个while(true){ $job = $redis->brPop(['queue'], 0); process($job); },然后用浏览器访问index.php——这会导致HTTP请求超时(Nginx默认60秒),进程被强制杀死。正确做法是:将消费者写成独立CLI脚本(如consumer.php),通过php consumer.php在后台运行,或用Supervisor守护。
第二个决策是序列化格式的选择。Redis List存的是字符串,PHP数组必须序列化。我对比过serialize()、json_encode()、igbinary_serialize()三种:
serialize()是PHP原生,兼容性最好,但体积大(含类型信息),且PHP8.2已标记为废弃;json_encode()体积最小,跨语言友好,但不支持资源、闭包、部分对象;igbinary是二进制序列化,速度最快,但需额外安装扩展,且PHP版本升级易出兼容问题。
最终选择json_encode()——因为我们的消息体全是标准关联数组(['type'=>'sms','phone'=>'138...','content'=>'...']),无特殊对象,JSON体积比serialize()小40%,且未来要对接Node.js写的备用消费者时无缝兼容。这点看似微小,却决定了后续系统演进的平滑度。
第三个决策是错误处理与死信队列(DLQ)的轻量化实现。真正的消息中间件有完善的重试、死信路由机制,但我们用Redis,就用最朴素的方式:消费者捕获异常后,把失败任务LPUSH到queue_dlq,并记录失败次数。当某任务在DLQ里超过3次,就转入人工排查队列。代码就三行:
} catch (Exception $e) { $failedCount = $redis->hIncrBy("dlq:stats", $job['id'], 1); if ($failedCount <= 3) { $redis->lPush('queue_dlq', json_encode($job)); } }没有复杂的重试策略,但足够覆盖99%的瞬时故障(如短信网关超时、邮件SMTP连接拒绝)。这才是“简单”的精髓:用最少的代码,解决最痛的问题。
3. 核心细节解析:从环境准备到生产就绪的12个关键动作
3.1 环境准备:三步完成Redis与PHP的握手
第一步:安装Redis并确认基础配置。在Ubuntu 22.04上,执行:
sudo apt update && sudo apt install redis-server -y sudo systemctl enable redis-server sudo systemctl start redis-server关键检查项:
redis-cli ping返回PONG,证明服务正常;redis-cli config get maxmemory查看内存限制,默认-1(无限制),生产环境建议设为物理内存的40%,例如sudo redis-cli config set maxmemory 2gb;redis-cli config get appendonly确认AOF持久化开启(yes),这是防断电丢消息的底线保障。
第二步:PHP安装Redis扩展。不要用pecl install redis这种可能版本不匹配的方式,推荐用系统包管理器:
# Ubuntu/Debian sudo apt install php-redis -y # CentOS/RHEL sudo yum install php-pecl-redis -y验证是否生效:创建test_redis.php,内容为:
<?php $redis = new Redis(); $redis->connect('127.0.0.1', 6379); echo $redis->ping() ? "Connected!" : "Failed"; ?>执行php test_redis.php输出Connected!即成功。注意:如果PHP-FPM和CLI用不同配置,需分别确认php.ini中extension=redis.so已启用。
第三步:创建专用Redis数据库(非必须但强烈推荐)。默认Redis有16个DB(0-15),生产环境绝不使用SELECT 0。执行:
redis-cli > SELECT 1 OK > CONFIG SET databases 16 OK > CLIENT SETNAME php_queue_app OK然后在PHP代码中显式指定DB:
$redis = new Redis(); $redis->connect('127.0.0.1', 6379); $redis->select(1); // 使用DB 1,与缓存、Session隔离这样做的好处是:当某天需要FLUSHALL清理缓存时,队列数据毫发无损;监控时也能单独看DB1的keyspace_hits指标,精准定位队列性能瓶颈。
3.2 生产者脚本:如何确保消息100%入队不丢失?
生产者看似简单,但藏着三个致命陷阱:连接异常、序列化失败、网络中断。我见过太多项目因$redis->lPush()返回false却未检查,导致用户下单成功但优惠券未发放。以下是经过压测验证的健壮生产者模板:
<?php // producer.php require_once 'RedisFactory.php'; // 封装连接池与重试 function publishJob(array $jobData): bool { $redis = RedisFactory::getInstance(); // 步骤1:预校验数据结构(防御性编程) if (!isset($jobData['type']) || !is_string($jobData['type'])) { error_log("Invalid job: missing 'type' field"); return false; } // 步骤2:JSON序列化,失败则记录并返回 $json = json_encode($jobData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($json === false) { error_log("JSON encode failed for job: " . json_last_error_msg()); return false; } // 步骤3:带重试的LPUSH(网络抖动时自动恢复) $maxRetries = 3; for ($i = 0; $i < $maxRetries; $i++) { try { $result = $redis->lPush('job_queue', $json); if ($result !== false) { // 记录入队成功日志(仅DEBUG级别,避免IO拖慢) if (defined('DEBUG') && DEBUG) { error_log("[PROD] Job {$jobData['type']} enqueued, length: {$result}"); } return true; } } catch (RedisException $e) { error_log("[PROD] Redis error on attempt {$i}: " . $e->getMessage()); if ($i < $maxRetries - 1) { usleep(100000); // 100ms后重试 } } } return false; // 三次重试均失败 } // 使用示例 if (publishJob([ 'type' => 'send_sms', 'phone' => '13812345678', 'content' => '您的验证码是1234' ])) { echo "Message sent to queue successfully"; } else { echo "Failed to enqueue message"; }关键细节说明:
- 预校验:在序列化前检查必填字段,避免
json_encode(null)产生空字符串入队; - JSON标志位:
JSON_UNESCAPED_UNICODE防止中文变\u4f60\u597d,JSON_UNESCAPED_SLASHES避免URL中的/被转义,提升可读性; - 重试机制:不是无限重试,而是3次后放弃,防止雪崩。每次重试间隔100ms,符合指数退避思想(实际用固定间隔更简单可靠);
- 错误日志分级:生产环境关闭DEBUG日志,但
error_log()始终记录,便于ELK收集分析。
提示:永远不要在生产者里用
$redis->lPush()后直接var_dump(),PHP的var_dump会触发__debugInfo()魔术方法,若对象里有Redis连接属性,可能导致连接泄漏。用error_log()替代。
3.3 消费者守护进程:如何让PHP进程7x24小时稳定运行?
消费者是整个队列的心脏,但PHP天生不适合长连接。我总结出四个必须落地的稳定性措施:
第一,进程守护与自动重启。绝不用nohup php consumer.php &这种原始方式。必须用Supervisor,配置/etc/supervisor/conf.d/php-queue.conf:
[program:php-queue-consumer] command=php /var/www/app/consumer.php directory=/var/www/app user=www-data autostart=true autorestart=true startretries=3 redirect_stderr=true stdout_logfile=/var/log/php-queue-consumer.log stopwaitsecs=10stopwaitsecs=10是关键:当执行supervisorctl stop php-queue-consumer时,Supervisor会先发SIGTERM,等待10秒让消费者优雅退出(处理完当前任务再停),超时才发SIGKILL强杀。这避免了任务进行到一半被砍断。
第二,内存泄漏防护。PHP CLI模式下,变量不会像Web请求那样自动销毁。一个典型泄漏点是:在while循环里不断$jobs[] = $redis->brPop(...)却不清理数组。解决方案是:
- 使用
gc_collect_cycles()定期触发垃圾回收(每100次循环调用一次); - 关键变量用
unset()显式释放; - 最重要的是:永远不要在循环内new大对象。例如发送邮件,不要每次循环都
$mailer = new PHPMailer(),而应提前实例化,循环内复用。
第三,优雅退出信号处理。消费者必须响应SIGTERM,完成当前任务后退出。在consumer.php开头加入:
declare(ticks = 1); pcntl_signal(SIGTERM, function($signo) { global $shouldExit; $shouldExit = true; echo "\nReceived SIGTERM, will exit after current job...\n"; }); $shouldExit = false; while (!$shouldExit) { $job = $redis->brPop(['job_queue'], 30); // 30秒超时,避免永久阻塞 if ($job === null) continue; // 超时,继续下一轮 // 处理任务... processJob($job[1]); // 每100次检查内存,超限则主动重启 if (++$loopCount % 100 == 0) { if (memory_get_usage() > 100 * 1024 * 1024) { // 100MB echo "Memory usage high, restarting...\n"; exit(0); } } }第四,超时与心跳监控。BRPOP的超时参数不能设为0(永远阻塞),否则进程卡死无法被Supervisor检测。设为30秒,配合Supervisor的startsecs(启动成功需连续运行秒数)和stopwaitsecs,形成双重保险。同时,每5分钟向Redis写入心跳:
if (time() - $lastHeartbeat > 300) { $redis->setex('consumer:heartbeat', 600, date('Y-m-d H:i:s')); $lastHeartbeat = time(); }运维可通过redis-cli GET consumer:heartbeat实时确认消费者存活。
3.4 消息体设计:为什么用扁平数组而不是嵌套对象?
消息体结构直接影响可维护性。早期我们用stdClass对象:
$obj = new stdClass(); $obj->type = 'email'; $obj->data = (object)['to'=>'a@b.com', 'subject'=>'Hi'];结果出了大问题:json_encode($obj)生成{"type":"email","data":{"to":"a@b.com"}},但PHP反解时json_decode($json)默认返回stdClass,而我们的处理器期望关联数组。$job['data']['to']报错,必须写$job->data->to,类型不一致导致大量is_object()判断。
最终统一为纯关联数组,并制定三条铁律:
- 顶层键名标准化:
type(任务类型)、id(唯一标识,由生产者生成UUID)、timestamp(毫秒时间戳)、retry_count(重试次数,初始0); - 业务数据放
payload子键:所有业务字段包裹在payload里,如['type'=>'send_email', 'payload'=>['to'=>'a@b.com','subject'=>'Hi']],解耦任务元数据与业务逻辑; - 禁止深层嵌套:
payload内最多两层,如payload['user']['id']允许,payload['order']['items'][0]['product']['name']禁止,强制拆分为多个独立任务。
这样设计的好处是:
- 消费者代码高度统一:
function processJob(string $json): void { $job = json_decode($json, true); switch ($job['type']) { case 'send_email': sendEmail($job['payload']); break; case 'generate_thumbnail': generateThumbnail($job['payload']); break; } }- 日志审计极简:
error_log("Processing {$job['type']} for {$job['payload']['user_id']}"); - 后续扩展方便:加新任务类型只需新增
case分支,无需改消息结构。
注意:
id字段必须由生产者生成,不能依赖Redis的INCR。因为INCR是全局计数器,多个队列共用会冲突;且UUID保证分布式环境下唯一,便于追踪。
4. 实操过程详解:从本地测试到线上灰度发布的完整流程
4.1 本地开发与单元测试:用Docker快速搭建隔离环境
本地开发最怕“在我机器上是好的”。我们用Docker Compose一键拉起Redis+PHP环境,docker-compose.yml如下:
version: '3.8' services: redis: image: redis:7.2-alpine ports: ["6379:6379"] command: redis-server --appendonly yes --maxmemory 512mb volumes: - ./redis-data:/data php: image: php:8.2-cli-alpine volumes: - ./:/app - ./php-ini-overrides.ini:/usr/local/etc/php/conf.d/overrides.ini working_dir: /app depends_on: [redis]配套php-ini-overrides.ini启用Redis扩展:
extension=redis.so启动命令:docker-compose up -d && docker-compose exec php sh,进入容器后即可运行php producer.php。
单元测试用PHPUnit,重点覆盖三个场景:
- 序列化边界测试:传入含
null、NaN、超长字符串的$jobData,验证publishJob()返回false并记录日志; - Redis故障模拟:用
Mockerymock Redis连接,设置lPush()返回false,验证重试逻辑执行3次; - 消息体结构验证:
json_decode($json, true)后,断言isset($arr['type']) && isset($arr['payload'])。
测试代码片段:
public function testPublishJobWithInvalidType(): void { $this->assertFalse(publishJob(['payload' => ['to' => 'a@b.com']])); $this->assertStringContainsString('missing \'type\' field', file_get_contents('/tmp/error.log')); }本地测试通过后,再提交代码。这套流程让我们在CI/CD流水线中,composer test成为合并PR的强制门禁。
4.2 性能压测:用Apache Bench实测500QPS下的真实表现
压测不是摆样子,而是为了找到系统的拐点。我们用ab(Apache Bench)模拟高并发生产者:
# 模拟100个并发,发送10000个任务 ab -n 10000 -c 100 -p post_data.json -T "application/json" http://localhost:8000/api/queue/pushpost_data.json内容为:
{"type":"send_sms","phone":"13800138000","content":"Test"}压测结果关键指标:
- Requests per second: 482.32 [#/sec](接近理论极限500)
- Time per request: 207.332 [ms](平均延迟)
- Failed requests: 0
- Transfer rate: 1.23 [KB/sec]
但更值得关注的是Redis监控:
redis-cli info memory | grep used_memory_human显示used_memory_human:124.50M,内存增长平缓;redis-cli info stats | grep instantaneous_ops_per_sec峰值instantaneous_ops_per_sec:968,说明Redis处理能力绰绰有余;redis-cli info clients | grep connected_clients稳定在connected_clients:15,证明连接池复用有效。
压测中发现一个隐藏问题:当并发从100升到200时,Failed requests突增至5%,查日志发现是PHP的max_execution_time超时(默认30秒)。解决方案不是调大超时,而是优化生产者:将100个任务打包成一个LPUSH命令(Redis Pipeline),代码改为:
$redis->pipeline(); foreach ($batch as $job) { $redis->lPush('job_queue', json_encode($job)); } $redis->exec(); // 一次网络往返完成100次入队优化后,200并发下Failed requests归零,Time per request降至182ms。这印证了一个原则:压测暴露的从来不是Redis的瓶颈,而是PHP层的低效调用。
4.3 线上灰度发布:如何零 downtime 切换到新队列?
线上切换最怕“一刀切”。我们的灰度策略分四步:
Step 1:双写并行。在旧有同步逻辑旁,增加新队列调用,但不删除旧逻辑:
// 旧逻辑(保持运行) sendSmsSync($phone, $content); // 新逻辑(灰度开关) if (config('queue.enabled') && rand(1,100) <= 10) { // 10%流量走新队列 publishJob(['type'=>'send_sms', 'payload'=>['phone'=>$phone,'content'=>$content]]); }Step 2:监控对齐。在Prometheus中建立两个指标:sms_sent_total{method="sync"}和sms_sent_total{method="queue"},用Grafana看板对比成功率、延迟。当新队列成功率连续1小时≥99.9%,进入下一步。
Step 3:流量渐进。将灰度比例从10%逐步提高到30%、70%、100%,每步观察2小时。关键看Redis的rejected_connections(拒绝连接数)和evicted_keys(驱逐键数),若这两项飙升,立即回滚。
Step 4:旧逻辑下线。确认新队列稳定运行24小时后,删除sendSmsSync()调用,只保留队列逻辑。此时再补上消费者守护进程,完成闭环。
整个灰度过程历时3天,期间无任何用户感知。这就是“简单”方案的另一重价值:它足够轻量,所以可以小步快跑;它足够透明,所以可以全程监控。
4.4 监控告警:用5个Redis命令构建低成本可观测体系
没有监控的队列等于盲人开车。我们用Redis原生命令+简单Shell脚本,实现零成本监控:
1. 队列长度监控(核心指标):
# 每分钟执行 QUEUE_LEN=$(redis-cli -n 1 LLEN job_queue) if [ $QUEUE_LEN -gt 1000 ]; then echo "ALERT: job_queue length $QUEUE_LEN > 1000" | mail -s "Queue Alert" ops@company.com fi阈值1000不是拍脑袋:按单任务平均处理200ms,1000个任务≈200秒积压,超过此值说明消费者跟不上。
2. 消费者心跳检查:
HEARTBEAT=$(redis-cli -n 1 GET consumer:heartbeat) if [ -z "$HEARTBEAT" ] || [ $(($(date +%s) - $(date -d "$HEARTBEAT" +%s))) -gt 600 ]; then # 心跳超10分钟未更新,告警 fi3. DLQ积压监控:
DLQ_LEN=$(redis-cli -n 1 LLEN queue_dlq) if [ $DLQ_LEN -gt 50 ]; then # DLQ超50条,说明有系统性故障,需人工介入 fi4. 内存使用率:
MEM_USED=$(redis-cli info memory | grep used_memory_human | cut -d: -f2 | sed 's/M//') MEM_MAX=$(redis-cli info memory | grep maxmemory_human | cut -d: -f2 | sed 's/M//') if (( $(echo "$MEM_USED > $MEM_MAX * 0.8" | bc -l) )); then echo "Redis memory usage > 80%" fi5. 连接数监控:
CLIENTS=$(redis-cli info clients | grep connected_clients | cut -d: -f2) if [ $CLIENTS -gt 100 ]; then echo "Too many Redis clients: $CLIENTS" fi这些脚本全部放入crontab,每分钟执行一次,告警信息统一发到企业微信机器人。整套监控投入为0,但效果远超某些商业APM工具的基础队列监控。
5. 常见问题与排查技巧实录:那些文档里不会写的血泪教训
5.1 典型问题速查表
| 问题现象 | 可能原因 | 排查命令 | 解决方案 |
|---|---|---|---|
| 消费者进程CPU 100% | 误用RPOP导致忙等待 | redis-cli monitor看是否高频RPOP | 改为BRPOP,检查代码是否漏写B |
| 任务重复消费 | 多个消费者用相同BRPOP但未设超时 | ps aux | grep consumer看进程数 | 检查Supervisor配置,确保numprocs=1;或用BRPOP的timeout参数 |
| 队列长度持续增长 | 消费者异常退出未被Supervisor捕获 | supervisorctl status看进程状态 | 检查consumer.php是否有未捕获异常,添加全局try-catch |
| JSON解码失败 | 消息体含非法UTF-8字符(如Windows换行符) | redis-cli LINDEX job_queue 0 | xxd | 生产者序列化前mb_convert_encoding($str, 'UTF-8', 'auto') |
| Redis内存暴涨 | BRPOP超时设为0,连接挂起但未释放 | redis-cli client list | grep idle | 改为BRPOP queue 30,配合Supervisor的stopwaitsecs |
5.2 我踩过的三个深坑及独家修复技巧
坑一:BRPOP的“假超时”陷阱
现象:消费者日志显示BRPOP返回NULL,但明明队列里有数据。查redis-cli monitor发现,BRPOP命令发出后,Redis返回了数据,但PHP层没收到。原因竟是PHP的default_socket_timeout太小!默认是60秒,而BRPOP 30要求连接至少保持30秒,网络抖动时可能超时断开。
修复技巧:在php.ini中加大超时:
default_socket_timeout = 60 redis.timeout = 60 redis.read_timeout = 60 redis.write_timeout = 60并在连接时显式设置:
$redis = new Redis(); $redis->connect('127.0.0.1', 6379, 60); // 第三个参数是连接超时坑二:json_encode()的静默失败
现象:生产者返回true,但redis-cli LLEN job_queue为0。用redis-cli monitor发现LPUSH命令根本没发出去。跟踪代码发现,json_encode($job)返回false,但生产者没检查返回值,直接传false给lPush,而lPush把false转成字符串"0"入队了!
修复技巧:永远用=== false严格比较:
$json = json_encode($job); if ($json === false) { // 不能用 != false 或 !empty() throw new Exception("JSON encode failed: " . json_last_error_msg()); }坑三:Supervisor的autorestart失效
现象:消费者进程因内存溢出被OOM Killer干掉,supervisorctl status显示FATAL,但没自动重启。查Supervisor日志发现Exited too quickly (process log may have details)。原因是进程启动后1秒内就退出,Supervisor判定为配置错误,拒绝重启。
修复技巧:在消费者脚本开头加usleep(500000)(500ms),确保进程存活超1秒;同时在Supervisor配置中加:
startsecs=2 startretries=3 exitcodes=0,2exitcodes=0,2表示退出码0(正常)和2(自定义错误)都算成功,避免因exit(2)被误判。
5.3 性能调优的三个反直觉结论
**结论1:BRPOP的timeout设为30秒比0秒