本文目录导读:

在 PHP 中实现“最大努力通知”(Best-Effort Notification)方案,核心思想是尽最大努力将消息推送给接收方,但不保证 100% 成功,这种方案通常用于支付回调、异步任务通知或系统间通信。
针对 PHP 技术栈,我为你梳理了一套分层、稳健的实现方案,包括核心流程、代码示例以及降级策略。
核心设计理念
最大努力通知机制通常有两种模式:
- 定时扫描型(推荐):本地消息表 + 定时任务扫描重试。
- 即时重试型:发送失败后,立即在当前请求中循环重试(不推荐,容易阻塞)。
核心原则:
- 异步化:同步请求进来后,立即返回“已受理”,后台异步推送。
- 持久化:必须将待推送的消息存储到数据库(防止进程崩溃丢失)。
- 重试退避:失败后,间隔时间指数级增长(如 1分钟,5分钟,1小时)。
- 人工兜底:达到最大重试次数后,转为待人工处理或告警。
技术方案架构图
[业务模块]
| 1. 写入通知表 (status=待发送)
v
[消息通知表 (MySQL)]
^ 2. 定时扫描 (CRONTAB / Swoole)
|
[通知服务 (PHP CLI脚本)]
| 3. 调用第三方 API (HTTP)
| 4. 失败则更新 retry_count, next_time
v
[第三方系统/支付平台]
具体实现步骤(PHP 代码示例)
数据库表设计
这是整个方案的基石,用于记录通知状态。
CREATE TABLE `notification` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `business_type` varchar(32) NOT NULL COMMENT '业务类型 (如 payment_success)', `payload` json NOT NULL COMMENT '通知的具体数据 (JSON格式)', `notify_url` varchar(255) NOT NULL COMMENT '目标回调地址', `status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0待发送 1成功 -1失败(终极)', `retry_count` tinyint(3) NOT NULL DEFAULT '0' COMMENT '已重试次数', `max_retry` tinyint(3) NOT NULL DEFAULT '10' COMMENT '最大重试次数', `next_retry_time` datetime NOT NULL COMMENT '下次重试时间', `last_error` varchar(500) 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_time` (`status`, `next_retry_time`) -- 核心索引,扫描用 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='最大努力通知表';
核心服务类:发送与重试逻辑
<?php
declare(strict_types=1);
namespace App\Services\Notification;
use GuzzleHttp\Client;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class BestEffortNotifyService
{
private Client $httpClient;
public function __construct()
{
// 设置较短的超时时间,快速失败以便重试
$this->httpClient = new Client([
'timeout' => 10, // 请求超时
'connect_timeout' => 5, // 连接超时
]);
}
/**
* 步骤1: 创建通知记录(业务代码调用)
*/
public function createNotification(string $type, array $data, string $url): int
{
return DB::table('notification')->insertGetId([
'business_type' => $type,
'payload' => json_encode($data, JSON_UNESCAPED_UNICODE),
'notify_url' => $url,
'status' => 0,
'next_retry_time' => now(), // 立即发送
]);
}
/**
* 步骤2: 发送通知(供定时任务调用)
* 只处理 状态为0 且 next_retry_time <= now() 的数据
*/
public function processPendingNotifications(int $limit = 100): void
{
// 使用乐观锁类似机制,防止多进程并发重复发送
$rows = DB::table('notification')
->where('status', 0)
->where('next_retry_time', '<=', now())
->orderBy('id', 'asc')
->limit($limit)
->lockForUpdate() // 如果是数据库队列场景
->get();
foreach ($rows as $row) {
$this->send($row);
}
}
/**
* 单条发送逻辑
*/
protected function send(object $row): void
{
try {
// 1. 发送 HTTP 请求
$response = $this->httpClient->post($row->notify_url, [
'json' => json_decode($row->payload, true), // 发送JSON
'headers' => [
'X-Event-Type' => $row->business_type,
'X-Signature' => $this->generateSignature($row->payload) // 防篡改签名
]
]);
$statusCode = $response->getStatusCode();
// 2. 判断成功条件:通常2xx且返回内容包含特定标识(如 'success')
if ($statusCode >= 200 && $statusCode < 300) {
$body = (string)$response->getBody();
if (str_contains($body, 'success')) {
// 标记成功
DB::table('notification')->where('id', $row->id)->update(['status' => 1]);
Log::channel('notify')->info("通知成功: {$row->id}");
return;
}
}
// 3. 如果没返回 success,视为失败,进入重试逻辑
$this->markForRetry($row, "HTTP {$statusCode} or Invalid Response: " . substr($body ?? '', 0, 200));
} catch (\Exception $e) {
// 网络超时、连接失败等
$this->markForRetry($row, $e->getMessage());
}
}
/**
* 计算下次重试时间(指数退避算法)
*/
protected function markForRetry(object $row, string $error): void
{
$newRetryCount = $row->retry_count + 1;
// 超过最大重试次数,标记为终态,人工介入
if ($newRetryCount >= $row->max_retry) {
DB::table('notification')
->where('id', $row->id)
->update([
'status' => -1,
'last_error' => $error,
'updated_at' => now()
]);
Log::channel('notify')->error("通知彻底失败,等待人工处理: {$row->id}, 错误: {$error}");
// TODO: 触发告警短信/邮件
return;
}
// 指数退避:1次后 = 5分钟,2次后 = 25分钟,3次后 = 125分钟...
$delayMinutes = pow(5, $newRetryCount);
$nextTime = now()->addMinutes($delayMinutes);
DB::table('notification')
->where('id', $row->id)
->update([
'retry_count' => $newRetryCount,
'next_retry_time' => $nextTime,
'last_error' => substr($error, 0, 500),
'updated_at' => now()
]);
Log::channel('notify')->warning("通知失败,第{$newRetryCount}次重试,下次时间: {$nextTime},错误: {$error}");
}
/**
* 简单的签名验证(接收方需要配合)
*/
protected function generateSignature(string $payload): string
{
// 伪代码:需要和接收方协商KEY
$secretKey = config('app.notify_secret');
return hash_hmac('sha256', $payload, $secretKey);
}
}
如何调用(任务调度)
Linux Crontab 定时执行(最常用)
每次 PHP 进程执行完后退出,不占常驻内存。
# 每分钟执行一次,处理待发送的通知 * * * * * /usr/bin/php /var/www/html/artisan notification:process >> /dev/null 2>&1
对应 Laravel 命令:
// app/Console/Commands/ProcessNotification.php
class ProcessNotification extends Command
{
protected $signature = 'notification:process';
public function handle(BestEffortNotifyService $service)
{
$service->processPendingNotifications(200); // 每次处理200条
}
}
Swoole/Workerman 常驻内存(高性能)
如果使用 Swoole,可以设置 timer_tick(5000) 每5秒扫描一次,杜绝延迟。
高级优化与注意事项
防止“惊群”与幂等性
- 并发控制:如果启用了多个 PHP-FPM 进程同时跑 Cron,必须加 锁(如 Redis 分布式锁)或者使用
SELECT ... FOR UPDATE SKIP LOCKED(MySQL 8.0+)来避免重复发送。 - 接收方幂等:在 payload 里增加
notification_id字段,要求接收方根据该字段判断是否已处理,防止重复消费。
定时任务的频率设置
建议分两级:
- 紧急级:支付类(每 10秒 或 30秒 扫描一次)。
- 普通级:其他通知(每 1分钟 或 5分钟一次)。
最终一致性的人工兜底
对于 status = -1 的记录,后台管理界面需要开发一个“重发”按钮,让运营或客服可以手动干预。
性能考虑
- 定时任务批量拉取时,尽量限制
LIMIT,不要一次全取出。 - 如果通知量极大(百万级),不要频繁更新某一行,可采用 增量落库,比如直接 UPDATE ... WHERE retry_count = XX。
PHP 实现最大努力通知,关键在于“分离”:
- 写入与发送分离:业务请求立即落库,后台异步处理。
- 处理时间分离:通过
next_retry_time控制重试节奏。
这个方案足以应对绝大多数中小型业务场景,不需要引入 Kafka 或 RocketMQ 等重型中间件(除非你需要 MQ 的消息回溯和削峰特性),是 PHP 项目中最可靠、成本最低的落地方案之一。