PHP 最大努力通知方案

wen PHP项目 4

本文目录导读:

PHP 最大努力通知方案

  1. 核心设计理念
  2. 技术方案架构图
  3. 具体实现步骤(PHP 代码示例)
  4. 如何调用(任务调度)
  5. 高级优化与注意事项

在 PHP 中实现“最大努力通知”(Best-Effort Notification)方案,核心思想是尽最大努力将消息推送给接收方,但不保证 100% 成功,这种方案通常用于支付回调、异步任务通知或系统间通信。

针对 PHP 技术栈,我为你梳理了一套分层、稳健的实现方案,包括核心流程、代码示例以及降级策略。


核心设计理念

最大努力通知机制通常有两种模式:

  1. 定时扫描型(推荐):本地消息表 + 定时任务扫描重试。
  2. 即时重试型:发送失败后,立即在当前请求中循环重试(不推荐,容易阻塞)。

核心原则

  • 异步化:同步请求进来后,立即返回“已受理”,后台异步推送。
  • 持久化:必须将待推送的消息存储到数据库(防止进程崩溃丢失)。
  • 重试退避:失败后,间隔时间指数级增长(如 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 实现最大努力通知,关键在于“分离”

  1. 写入与发送分离:业务请求立即落库,后台异步处理。
  2. 处理时间分离:通过 next_retry_time 控制重试节奏。

这个方案足以应对绝大多数中小型业务场景,不需要引入 Kafka 或 RocketMQ 等重型中间件(除非你需要 MQ 的消息回溯和削峰特性),是 PHP 项目中最可靠、成本最低的落地方案之一。

抱歉,评论功能暂时关闭!