做PHP后端这些年消息通知是我见过被反复造轮子的模块之一。下单了要发短信用户注册了要发邮件后台出异常要告警运营活动要推站内信每来一个需求就临时拉一个发送函数最后代码里散落着各种各样直接调第三方SDK的调用。等接的渠道变多问题就来了短信要换服务商、邮件要加模板、站内信要存库改一处牵一发动全身。这篇文章就把我之前整理的一套PHP方案消息通知系统完整拆开讲。它解决的并不是“怎么调通一个短信接口”这种单点问题而是怎么把短信、邮件、站内信、WebSocket推送统一收口到一个消息中心里通过队列异步分发、模板渲染、失败重试、日志追溯这套完整链路来管理所有通知。无论你是刚入门的PHP开发者还是已经在业务系统里被各种通知需求缠住的老手这套方案都可以直接拿来改改用或者至少给你一个相对完整的参考架构。1. 方案定位先想清楚你要做的是一个“通知中心”而不是“一堆发送代码”很多团队一开始的需求只是“给我接个短信验证码”于是随手在UserController里写了个sendSms函数调一下阿里云SDK完事。后面又要发邮件通知再加一个sendMail又要发站内信再写个insertMessage。三个月后这套代码已经变成一团乱麻每个业务方都在自己Controller里调不同函数换个短信服务商要全项目搜索替换出了发送失败还不知道到底哪个环节出了问题。这套PHP方案的出发点就是先把“通知”这件事当成一个独立的业务域来设计。它不关心谁在调用、什么时候调用只关心三件事一条通知要发给谁、通过哪个渠道发、发完之后的记录和状态怎么处理。这样一来业务侧的代码只需要组装好通知参数扔给消息中心后面怎么发、走哪个渠道、失败了怎么补偿都是消息中心的事。从选型上讲PHP做消息通知系统完全够用。不要被“消息中心”这个词吓到它不是非要Kafka、非要微服务才能做的事情。绝大多数中小业务的通知量级用PHPFPM处理API请求、用CLI脚本消费队列性能完全撑得住。真正容易出问题的反而是代码组织方式和任务状态的丢失。这套方案里我选了MySQL做任务存储、Redis做待发送队列整体是“保守但可靠”的组合后面会把每层选型的理由说清楚。1.1 通知渠道为什么需要抽象统一先画一条最直接的分界线。如果你只是给一个渠道写发送函数那无所谓抽象不抽象。但只要是两个渠道以上就必须做一层统一封装否则调用方的代码会越来越难看。我见过最典型的反面例子是if ($channel sms) { $sms-send($phone, $content); } elseif ($channel email) { $email-send($address, $subject, $content); } elseif ($channel message) { DB::table(messages)-insert([...]); }每增加一个渠道这里的if分支就多一层每增加一种业务通知场景就要到这里改一遍。这个问题的本质是没有做通道适配把“渠道差异”和“业务逻辑”耦合在一起了。正确做法是定义一个统一的NotificationChannelInterface短信、邮件、站内信各自实现这个接口。调用方根本不关心对面是什么渠道它只知道要发一条通知传进去接收人、模板标识、模板参数然后由消息中心的路由逻辑去决定走哪个渠道。这个抽象带来的直接好处有两个第一新增渠道只需要新增一个实现类前台代码一行不用动第二业务逻辑和发送细节彻底解耦测试时可以很方便地用Mock替代真实发送。1.2 同步发送和异步队列的边界消息通知有一个非常典型的特点即时性要求不高但失败代价不小。用户注册验证码确实希望几秒内到但实际上晚个几秒也能接受邮件通知晚几分钟完全没问题运营推送晚几分钟也不会死人。所以完全没必要在业务请求里同步发完所有通知更不应该因为某个渠道超时就把用户的下单请求拖垮。这套方案选择“API接收 Redis队列暂存 CLI脚本消费”的异步模式。业务进程只做一件事把通知任务写入数据库、把任务ID丢进Redis队列然后立刻返回。真正去调短信网关、邮件服务器的是后台的CLI消费进程。这样业务接口的响应时间不会被通知拖累消息中心服务挂了任务还在数据库里躺着恢复后可以重新入队消费不会丢通知。举个实际场景用户下单成功后要通知他同时还要通知运营人员。如果把发送逻辑同步写在下单接口里短信通道抖动一次用户下单接口就可能超时这显然是无法接受的。异步化之后下单接口的业务代码只需要触发一次通知事件整个流程不会因为通知部分的故障而阻塞。这也是为什么我在方案里坚持“先DB后队列”的顺序具体后面在实现部分详细说。2. 数据库设计核心三张表把状态管住消息通知系统的数据量通常不会特别大核心诉求是任务状态可追踪、发送日志可回溯、模板内容可维护。基于这个目标我设计了通知任务表、通知模板表、通知日志表三张核心表。有些方案会把任务和日志合成一张表我不太建议因为任务表关注的是“这条通知接下来该怎么处理”日志表关注的是“这条通知历史上发生了什么”两者的读写频率和生命周期完全不同。2.1 通知任务表队列的持久化底座通知任务表是整个系统的数据核心。业务方调用API后先把任务记录落到这张表然后任务ID进Redis队列。CLI消费进程从队列拿出任务ID再去数据库查任务详情执行发送更新发送结果。CREATE TABLE notification_task ( id bigint unsigned NOT NULL AUTO_INCREMENT, task_no varchar(64) NOT NULL COMMENT 任务编号, scene varchar(64) NOT NULL COMMENT 业务场景register/login/order/alert, channel varchar(20) NOT NULL DEFAULT COMMENT 渠道sms/email/message/ws, receiver varchar(255) NOT NULL COMMENT 接收人手机号/邮箱/用户ID, template_code varchar(64) NOT NULL DEFAULT COMMENT 模板标识, params text COMMENT 模板参数JSON格式, status tinyint NOT NULL DEFAULT 0 COMMENT 0待发送 1发送中 2成功 3失败, retry_count tinyint NOT NULL DEFAULT 0 COMMENT 已重试次数, next_retry_time datetime DEFAULT NULL COMMENT 下次重试时间, send_time datetime DEFAULT NULL COMMENT 实际发送时间, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_status_next_retry (status, next_retry_time), KEY idx_created_at (created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT通知任务表;task_no字段虽然看起来和自增ID功能重复但我建议保留。因为任务最终要暴露给业务方做查询一个全局唯一的业务编号比自增ID更好记忆、更安全也不容易暴露出系统每天的发送量。status字段是整个任务状态流转的核心我设计了五个状态0待发送、1发送中、2成功、3失败。加上retry_count和next_retry_time失败重试机制就有数据基础了。一个核心设计决策为什么先写数据库再进Redis队列因为Redis只适合做“待办清单”它不应该作为持久化存储。如果业务进程先入队再写DB万一写DB失败队列里就出现了一条“幽灵任务”消费进程查不到任务详情最终这条通知就悄悄丢了。反过来先写DB、再入队即使入队失败业务接口返回后还可以通过定时扫描把status0的超时任务重新捞出来入队这就是可靠性的兜底。2.2 通知模板表内容统一管理模板表的存在非常有价值。没有模板表的时候业务方调用发送时直接传Content字符串这个做法看起来简单但问题很快浮现同一封邮件模板被多个业务复用改一个落款要通知所有调用方去改代码运营想临时改一下文案必须找开发发版。有了模板表业务方只传模板标识和参数内容管理交给后台。模板表有两个难点一是内容里允许有占位符二是不同渠道的内容格式不一样。以邮件为例HTML模板通常会很大存MySQL的text字段没问题但要注意与短信模板的字数限制区分。我的做法是模板表里用content_text和content_html两个字段短信、站内信走content_text邮件走content_htmlWebSocket推送走content_text再转JSON。关键点是模板参数要规范化。我在模板里约定使用${paramName}这种占位符渲染时用str_replace循环替换。这块看似简单实际有一些坑后面“模板渲染”部分单独展开。2.3 通知日志表出问题时的救命稻草日志表的设计容易被忽略但消息通知这种系统排查问题时的第一需求永远都是“这条通知到底发了没有发到哪了第三方返回了什么”。如果没有日志表线上短信发不出去你只能去第三方平台的后台一笔一笔对账单那个体验非常痛苦。日志表的写入策略我建议“每发必记”执行发送前记录一条待发送日志发送成功记录成功回执发送失败记录失败原因和第三方返回的原始信息。宁可日志冗余也不要缺失。日志表的字段通常包括任务ID、渠道、接收人、请求内容、响应内容、请求耗时、发送结果、错误码、错误信息、创建时间。按任务ID建索引查询时直接定位到某条通知的完整生命周期。这里有一个经验之谈第三方接口的返回内容一定要原样记录哪怕是报错信息。很多时候短信服务商返回的错误码在不同文档里解释不同把原始响应存下来排查时可以拿原始内容去搜比拆解后的错误码更有价值。3. 通道抽象与适配器实现让每个渠道都变成“插上去就能用”通道适配是整个代码层面的核心。目标是把不同渠道的发送差异彻底隔离在各自的Adapter里让上层分发逻辑不知道也不关心对面是短信、邮件还是站内信。3.1 接口设计与统一返回结构先定义一个基础的适配接口?php namespace App\Notification\Channel; interface NotificationChannelInterface { /** * 获取渠道标识 */ public function getChannel(): string; /** * 发送通知 * * param array $payload 包含receiver、template_code、params等 * return ChannelResult */ public function send(array $payload): ChannelResult; /** * 发送失败后的重试是否被允许 */ public function supportRetry(): bool; }ChannelResult是一个统一的返回结构体里面至少包含三个字段success布尔、providerMessageId第三方返回的消息ID、rawResponse第三方原始返回。所有Adapter执行完发送后必须返回这个结构体上层拿到它去更新任务状态和写日志。这样无论对接多少家服务商上层逻辑都是稳定的。短信、邮件、站内信、WebSocket四个通道的实现各自内部只需要关心一件事怎么把payload转成对应渠道需要的数据结构并调用出去。短信Adapter可能是这样?php namespace App\Notification\Channel; class SmsChannel implements NotificationChannelInterface { public function __construct( private SmsClientInterface $smsClient ) {} public function getChannel(): string { return sms; } public function send(array $payload): ChannelResult { $content $this-renderTemplate($payload[template_code], $payload[params] ?? []); try { $response $this-smsClient-send($payload[receiver], $content); return new ChannelResult(true, $response-getMessageId(), $response-toArray()); } catch (SmsException $e) { return new ChannelResult(false, , [ error_code $e-getCode(), error_msg $e-getMessage(), ]); } } public function supportRetry(): bool { return true; } private function renderTemplate(string $templateCode, array $params): string { // 模板渲染逻辑稍后单独讲 } }这里有个容易被忽略的设计点supportRetry方法。不同渠道对重试的容忍度完全不同短信和邮件天然允许重发但WebSocket推送这类实时通道用户已经不在线了重试没有意义反而会造成消息堆积。所以每个Adapter自己决定是否支持重试分发逻辑根据这个flag决定失败后是否重新入队。3.2 适配器模式实现多套服务商切换消息通知系统的另一个常见需求是多服务商容灾。比如短信主用A服务商但A挂了要自动切到B。如果代码里直接写死某个SDK切换就得改代码重新上线这在凌晨出故障的时候极其致命。我采用Adapter内部再做一层Provider封装。SmsChannel下再拆AliyunSmsProvider、TencentSmsProvider等服务商的选择可以通过配置项动态指定。Provider同样实现统一的ProviderInterfaceSmsChannel只依赖接口不依赖某个具体SDK。// config/notification.php return [ sms [ active_provider env(SMS_ACTIVE_PROVIDER, aliyun), providers [ aliyun [ class \App\Notification\Provider\Sms\AliyunSmsProvider::class, access_key_id env(ALIYUN_SMS_ACCESS_KEY_ID, ), access_key_secret env(ALIYUN_SMS_ACCESS_KEY_SECRET, ), sign_name env(ALIYUN_SMS_SIGN_NAME, ), endpoint dysmsapi.aliyuncs.com, ], tencent [ class \App\Notification\Provider\Sms\TencentSmsProvider::class, secret_id env(TENCENT_SMS_SECRET_ID, ), secret_key env(TENCENT_SMS_SECRET_KEY, ), sdk_app_id env(TENCENT_SMS_APP_ID, ), sign_name env(TENCENT_SMS_SIGN_NAME, ), ], ], ], ];具体用哪个服务商由配置决定上线时只需改一下env变量或者做一个简单的健康检查自动切换。这个设计在文章里看起来不复杂但处理事故时的价值非常大。之前有一次某个短信服务商全国性故障我们直接把配置切到备选服务商全程没有发版、没有停服通知就恢复正常了。3.3 模板渲染与字符串替换的坑模板渲染这里要详细说因为踩过的坑太多了。最基础的实现是遍历参数数组把占位符替换成真实值private function renderTemplate(string $templateCode, array $params): string { $template $this-templateRepository-findByCode($templateCode); $content $template-content_text; foreach ($params as $key $value) { $content str_replace(${ . $key . }, (string)$value, $content); } return $content; }这个写法第一次跑必然可用但有两个隐藏问题。第一替换顺序问题假设模板里有${user}和${username}两个占位符如果先替换了${user}后替换${username}时前面已经替换过的部分里刚好含有一个${username}片段就会被二次替换。虽然实际场景中不太常见但一旦发生排查起来非常费劲。对策是使用正则回调或逐个使用str_replace单独处理保证每次替换的键精确匹配。第二个问题更实际短信模板的字数限制和计费规则。国内短信一般按字数计费通常70字以内算一条超过70字按67字一条切割。模板替换后内容变长如果替换后参数字段太长会直接导致短信费用成倍增加甚至发送失败。所以模板里要规范参数长度并且在渲染后统一做一次短信字数统计超过阈值要告警。更推荐的做法是模板渲染使用preg_replace_callback配合占位符正则一次性完成所有替换并且校验是否有未识别的占位符残留private function renderTemplate(string $templateCode, array $params): string { $template $this-templateRepository-findByCode($templateCode); $content $template-content_text; $content preg_replace_callback(/\$\{(\w)\}/, function ($matches) use ($params) { $key $matches[1]; if (!array_key_exists($key, $params)) { throw new \RuntimeException(模板参数缺失: . $key); } return (string)$params[$key]; }, $content); return $content; }这样写模板里出现的每个占位符都必须在params里提供对应值否则渲染直接报错避免把${name}这种未替换的内容发给第三方平台。4. 队列任务流转从业务接入到最终送达的完整链路队列在这套系统里承担的角色是缓冲和异步化。设计队列相关的逻辑时最核心的原则是消息可以在队列里排队但任务的最终状态必须以数据库为准。4.1 业务方接入的API层业务方接入的入口是一个统一的通知服务类或者一个HTTP API接口。以PHP代码为例最简单的接入方式是这样?php namespace App\Services; class NotificationService { public function __construct( private NotificationTaskRepository $taskRepository, private RedisQueue $queue ) {} /** * 提交一个通知任务 * * param string $scene 业务场景 * param string $channel 渠道 * param string $receiver 接收人 * param string $templateCode 模板标识 * param array $params 模板参数 */ public function submit( string $scene, string $channel, string $receiver, string $templateCode, array $params [] ): string { $taskNo $this-generateTaskNo(); $taskId $this-taskRepository-create([ task_no $taskNo, scene $scene, channel $channel, receiver $receiver, template_code $templateCode, params json_encode($params, JSON_UNESCAPED_UNICODE), status 0, retry_count 0, next_retry_time null, ]); $this-queue-push(notification:send, $taskId); return $taskNo; } private function generateTaskNo(): string { return date(YmdHis) . str_pad((string)mt_rand(100000, 999999), 6, 0, STR_PAD_LEFT); } }业务方在控制器里只需要一行代码$notificationService-submit(user_register, sms, $phone, register_success, [username $user-name]);剩下的流程完全不需要业务方关心。这里要注意一点submit方法在队列push失败时要不要抛异常我的选择是不抛而是靠后续的定时扫描兜底。具体逻辑是只要任务写进了DB不管Redis队列是否push成功定时脚本都会扫描status0且创建时间超过1分钟的任务重新入队。这个兜底机制保证了队列偶尔抖动不会导致通知丢失。4.2 消费进程从队列里取任务并执行发送消费进程是一个常驻的CLI脚本用PHP的PCNTL扩展做多进程消费或者直接起多个进程各自循环pop。简化版逻辑如下#!/usr/bin/env php ?php // bootstrap require __DIR__ . /vendor/autoload.php; use App\Notification\Dispatcher; $queue new RedisQueue(); $dispatcher new Dispatcher($container); while (true) { try { $taskId $queue-pop(notification:send, 30); if ($taskId null) { continue; } $result $dispatcher-dispatch((int)$taskId); if (!$result) { // 失败且不允许重试记录日志 // 失败且允许重试标记status为0并设置next_retry_time } } catch (\Throwable $e) { // 记录异常日志避免进程直接退出 error_log($e-getMessage()); sleep(1); } }Dispatcher的dispatch方法负责整个发送流程加载任务 - 检查任务状态 - 根据channel找到对应Adapter - 调用Adapter发送 - 更新任务状态 - 写日志。这个类相当于整个消息中心的大脑。这里有一个非常实战的经验CLI常驻进程里必须对每个任务单独做try-catch不能因为一个任务的异常导致整个消费进程崩溃退出。同时循环里加一个sleep(1)或者使用Redis的阻塞pop避免空转大量消耗CPU。有个坑是如果使用while(true)加usleep(100000)这种写法多个消费进程同时空转会白白耗掉不少CPU。我最后用的是Redis的BRPOP阻塞式读取带超时时间没有任务的时候进程挂起等待几乎不占CPU。4.3 失败重试与死信处理消息发送不可能100%成功。短信通道偶尔返回流量控制限制、邮件偶尔被判为垃圾邮件、第三方接口频繁超时这些都是常态。失败重试机制的方案设计核心是控制重试频率不要让失败的短信在短时间内轰炸用户。我的策略是每个任务默认最多重试3次采用指数退避的间隔第一次失败后5分钟重试第二次失败后30分钟重试第三次失败后2小时重试。重试次数满后任务状态置为4死信等待人工处理。这个时间间隔可以通过配置修改。重试的执行方式有两种一种是被动的消费进程发送失败后把任务重新扔回队列另一种是主动的启动一个独立的重试扫描进程定时扫描next_retry_time已经到了且status0的任务。我推荐第二种因为第一种方式在消息量大、多个消费进程并发的情况下容易打乱退避间隔导致重试过于密集。具体实现时在更新任务状态时使用条件更新确保并发环境下不会把同一个任务重复发送$affected DB::table(notification_task) -where(id, $taskId) -where(status, 0) -update([ status 1, updated_at now(), ]); // affected 1 表示当前进程抢到了这个任务 // affected 0 表示任务已经被其他进程处理了直接跳过这个条件更新是并发消费场景下的关键代码不加这个限制两个消费进程同时取出同一个任务ID时就会重复发送短信用户会收到两条一模一样的验证码。4.4 Redis队列的异常场景兜底很多人会问Redis挂了怎么办消息通知系统对Redis的依赖度有多高我的答案是Redis只是加速器不是系统的核心存储。系统设计的核心保障在MySQLRedis队列挂了任务还在DB里躺着只是暂时没人去消费而已。只要Redis恢复定时扫描进程会自动把堆积的任务补入队列。考虑到有些团队连Redis都没有或者不想引入Redis运维成本这套方案的队列也可以换成MySQL队列表。把任务ID插入队列表消费进程每次按FIFO取一条并加锁更新状态效果类似但吞吐量会下降。我自己在实际项目中两种模式都跑过通知量低于每秒几百条的MySQL队列表完全够用量再大一点或者后续可能扩展的建议直接上Redis。这套方案里我默认用Redis但队列接口要设计成可替换的。5. WebSocket与站内信通道实时通知的实现细节短信和邮件是传统渠道WebSocket和站内信是系统内部实时通知的重要手段。相比短信和邮件这两个渠道最大的区别是它们不依赖第三方平台完全自己掌控但需要处理用户在线状态、连接管理、历史消息存储等问题。5.1 站内信先落库再通知站内信实现起来相对简单。本质就是把通知内容写入messages表用户在前端轮询或下拉时拉取未读消息。这套方案里我建议站内信的发送逻辑直接用Adapter封装成EmailChannel类似的MessagingChannel核心动作就是INSERT一条记录到user_message表。站内信有一个容易被忽视的需求已读状态管理。发送只是第一步用户是否已读、何时已读是运营同学非常关心的数据。所以user_message表里要设计read_status、read_at字段。在暗送侧可以把站内信的发送和已读状态完全暴露给运营后台做数据统计。站内信的模板渲染逻辑与短信、邮件一致复用同一个renderTemplate方法。唯一区别是内容格式通常是纯文本或Markdown前端再根据消息类型渲染成不同样式。5.2 WebSocket推送连接管理与在线离线判断WebSocket是四个渠道里实时性最强、但实现复杂度最高的。PHP生态做WebSocket主流方案是Workerman或Swoole。消息中心需要把通知推送给在线用户如果用户不在线则退回到站内信或下一次用户上线时再补推。我个人用的比较多的是Workerman的GatewayWorker方案。推送流程是消费进程拿到WebSocket任务后调用GatewayClient向指定用户ID连接推送数据。Gateway的架构天然支持多进程也能处理连接状态管理。?php namespace App\Notification\Channel; use GatewayClient\Gateway; class WebSocketChannel implements NotificationChannelInterface { public function __construct() { // 初始化GatewayClient配置Gateway服务器地址 Gateway::$registerAddress 127.0.0.1:1238; } public function getChannel(): string { return ws; } public function send(array $payload): ChannelResult { $userId $payload[receiver]; $message [ type notification, title $payload[params][title] ?? , content $payload[params][content] ?? , created_at date(Y-m-d H:i:s), ]; // isOnline方法判断用户是否有活跃连接 $isOnline Gateway::isUidOnline($userId); if ($isOnline) { Gateway::sendToUid($userId, json_encode($message, JSON_UNESCAPED_UNICODE)); return new ChannelResult(true, , []); } // 用户不在线返回失败并标记为不允许重试 // 上层逻辑检测到这种情况会把这条通知降级为站内信存储 return new ChannelResult(false, , [reason user_offline]); } public function supportRetry(): bool { return false; } }这里有一个方案上的关键点WebSocket实时推送和站内信之间是互补关系不是替代关系。最理想的通知链路是实时推送优先用户在线就推给TA用户不在线这条消息落地成站内信等用户下次登录后再拉取。所以WebSocketChannel发送失败时上层会捕获这个特定的失败原因然后自动把任务重新分配成站内信渠道而不是重试WebSocket。这个设计一开始就要想清楚。我见过一些团队只做WebSocket推送结果用户一锁屏、一断网就收不到通知也没有任何兜底导致大量消息丢失。实时推送和站内信配合才是完整的实时通知方案。6. 部署、定时任务与常见问题排查6.1 CLI脚本配置与常驻管理这套消息中心的PHP代码分两部分FPM环境下运行的API服务以及命令行下运行的CLI服务。API服务用于接收业务方提交的请求CLI服务用于消费队列和定时扫描重试。CLI部分的进程管理可以用Systemd或者Supervisor我建议用Supervisor配置简单自动重启能力也比裸写systemd unit要方便。一份基本的Supervisor配置[program:notification-worker] process_name%(program_name)s_%(process_num)02d commandphp /data/www/app/bin/notification_worker.php numprocs4 autostarttrue autorestarttrue userwww redirect_stderrtrue stdout_logfile/var/log/notification_worker.log注意numprocs的取值不要盲目开很多进程。多进程数量一般跟CPU核心数对齐即可。因为每个进程消费任务后执行的是I/O密集型的HTTP调用线程等待第三方响应时CPU基本是空闲的所以进程数可以适当多一些但要控制Redis连接数不要超限。我之前在一台4核的机器上跑8个worker短信峰值每秒能消费几十条对于多数中小业务已经足够了。6.2 定时任务与扫描进程除了消费进程还需要一个定时扫描进程负责两类任务一是捞起那些进入队列失败的任务二是处理失败重试。这个脚本通过crontab定时触发* * * * * php /data/www/app/bin/notification_scan.php --typerequeue /var/log/notification_scan.log 21 */5 * * * * php /data/www/app/bin/notification_scan.php --typeretry /var/log/notification_retry.log 21requeue类型的扫描逻辑是查找status0且created_at在1分钟之前的任务重新push到Redis队列。retry类型的扫描逻辑是查找status3且retry_count小于3且next_retry_time已到期的任务先重置状态为0再push到Redis队列。为什么requeue要设1分钟的延迟因为正常情况下任务submit后立刻就会进入队列消费进程基本秒级处理。如果在1分钟内还没被消费大概率是入队失败或者消费进程异常。这时候扫描进程再把任务捞出来避免了重复消费问题又保证了可靠性。这里有个细节值得强调扫描进程和消费进程的并发竞争。扫描进程把任务重新入队时必须保证任务当前状态确实是0或者3否则可能一条发送成功的任务被重复捞起重发。所以SQL里都带status条件并且更新时用条件更新确保只有匹配预期状态的任务才会被处理。6.3 常见问题与排查实战6.3.1 短信发送成功但没有收到这是一个高频问题。首先要确认短信服务商后台的发送记录看管道是否已发送到运营商。如果服务商显示已发送用户没收到可能是手机号被运营商拦截、短信内容含敏感词、或者用户手机信号问题。这种情况下我们这边能查的就是日志表里的providerMessageId拿这个到服务商后台可以精确查到发送链路。第二种可能更隐蔽短信签名或模板没有通过审核。很多服务商要求短信签名、模板先过审如果用了未过审的签名接口返回正常但实际上不会下发。我遇到过一次服务商接口返回success但短信实际没发出来最后查日志发现签名被服务商悄悄替换成了默认签名而默认签名没有报备被运营商拦截了。6.3.2 邮件进了垃圾箱邮件被判定为垃圾邮件从代码层面很难彻底解决但有几点可以显著降低概率发件域名要配置SPF、DKIM、DMARC记录邮件正文不要带大量链接和敏感词HTML邮件不要使用过大图片发送频率要控制。另外邮件内容的模板最好不要直接复制网上现成的营销模板很多模板自带容易被识别的营销特征。6.3.3 Redis连接数被打满消费进程多、Redis连接没有复用的时候很容易出现这个问题。排查思路先看Redis的client list是不是被业务进程的连接占满然后检查消费进程是否每次循环都重新创建连接PHP常驻进程里特别容易犯这个错误改成构造函数里一次性建立连接、进程生命周期内复用即可。6.3.4 任务状态卡在“发送中”我在2.1里提到过发送前会把status置为1。如果消费进程在调第三方接口时超时、然后进程崩溃status1的任务就会一直卡住。所以定时扫描里还需要一个专门的逻辑查找status1且updated_at超过5分钟的任务视为“假死任务”重置为0并重新入队。这个场景必须覆盖否则系统跑一段时间后会有大量任务卡死。6.4 常见问题速查表症状可能原因排查方法解决方式短信接口返回成功但用户收不到签名未过审/内容含敏感词查询服务商后台发送记录重新提交签名审核或修改内容邮件进垃圾箱域名SPF/DKIM未配置检查域名解析与邮件头配置邮件认证记录任务一直status1消费进程崩溃/第三方接口超时查看日志中是否有超时记录加假死任务扫描机制Redis连接数满进程频繁创建新连接Redis client list查看来源常驻进程复用连接同一通知重复发送多次并发消费没有条件更新查看日志表中同一任务多次发送加上状态条件更新机制模板参数替换出现脏数据占位符替换顺序问题检查日志中实际发送内容改用preg_replace_callback精确替换用户不在线WebSocket推送丢失没有做降级兜底检查通道失败原因WebSocket失败转站内信7. 这套方案的扩展方向与经验总结消息通知系统的核心价值不在技术难度而在架构合理性和数据可靠性。技术选型上PHP的一整套生态足够支撑多数业务场景不要因为追求所谓的高性能架构而过度设计。真正需要花心思的地方是任务状态怎么管理、并发消费怎么避免重复、失败之后怎么兜底、出了问题怎么快速定位。我在这套方案的演进过程中最大的体会是消息通知系统要做好的不是“发送”而是“承诺”。业务方把通知交给消息中心消息中心就要承诺尽量送达、承诺失败会重试、承诺出错有日志、承诺不会因为你某个环节抖动就把用户的通知丢掉。这份承诺的实现靠的不是某一两个花哨的功能而是数据库状态设计、条件更新、失败扫描这些看起来不起眼的细节。最后再分享一个实际项目里的经验上线消息中心后一定要在后台加一个“通知发送明细”的查询页面让运营和客服可以直接按手机号、按时间搜索某条通知的发送状态。这个功能看起来不起眼但它能帮你省掉大量“用户说没收到到底发没发”的扯皮时间。有日志、有状态、有追溯消息通知系统才算真正闭环。如果后续想继续扩展可以考虑把场景从“通知”放大到“用户触达”接入App Push比如极光、个推甚至在模板配置页里加A/B测试功能。架构上的设计是通用的通道Adapter再加一个AppPushChannel就能搞定。这套方案的成长空间远比一开始想象的大。
