ThinkPHP 8 支付回调异步化:六个动作排队、重试退避与死信兜底

2026-10-18 0 657

双十一当天下午三点四十,收银台开始报”支付成功但没跳转”。第一反应是支付网关问题,去后台查了下订单,状态明明是已支付。再一看回调接口的错误日志,全是 504。

事情的真相是三分钟之后才查清楚的——支付回调接口在一次请求里同步干完了六件事:更新订单状态、发优惠券、发积分、发短信通知、通知仓储系统、更新店铺当日报表。任何一件事碰上数据库慢查询或者第三方接口抖动,整个回调就会拖过支付网关的 8 秒超时线。网关判定失败,十分钟内重试三次,这三次重试里又有两次超时,最后订单状态是更新了,但优惠券发了三遍,短信发了五条。

这事的处理过程就不细说了。最后我们把整条链路上的六个动作全部异步化,支付回调接口压到 40 毫秒以内返回。下面把改造的思路和实现完整写下来。

先看看老代码在干什么

支付回调的入口方法大概长这样:

public function notify(Request $request)
{
    $payload = $request->post();
    $orderSn = $payload['out_trade_no'];

    $order = OrderModel::where('order_sn', $orderSn)->lock(true)->find();
    if (!$order || $order['status'] === OrderStatus::PAID) {
        return json(['code' => 'SUCCESS']);
    }

    $order->status = OrderStatus::PAID;
    $order->paid_at = date('Y-m-d H:i:s');
    $order->save();

    // 六个动作,全在这里同步跑
    $this->couponService->issueForOrder($order);
    $this->pointService->addForOrder($order);
    $this->smsService->sendPaidNotify($order);
    $this->warehouseService->createPickTask($order);
    $this->reportService->refreshDaily($order['shop_id']);
    $this->userTagService->markPaidCustomer($order['user_id']);

    return json(['code' => 'SUCCESS']);
}

这段代码里,订单状态那段是有事务的(lock(true) 会隐式开事务),但后面六个动作全是”各自为政”的——每一个都有自己的 DB 操作、自己的外部 HTTP 请求、自己的异常处理。只要其中任何一个耗时超过几秒,接口就挂了。

更麻烦的是,这种”多动作聚合”的场景,很难用一个简单的 try-catch 兜住。因为如果优惠券发完了、积分没发,用户会来投诉;如果短信发了、仓储没通知,仓库会骂人。业务上要求”至少都要成功”,但技术上无法保证”一次全成功”。

所以思路只能反过来:把”必成功”和”允许最终成功”分开。订单状态更新是必成功的,走同步事务。剩下六个动作允许最终成功,全部丢队列。

为什么不用协程或者 Workerman 直接异步

肯定有人会问——你都用 PHP 了,为啥不上 Swoole 或者 Workerman 做个后台任务直接拉起来?搞个队列还得部署一个消费进程。

我们评估过,最后还是选队列,有三个具体原因。

第一是可靠性的差异。Swoole 协程或者 Workerman 进程里跑的异步任务,如果进程崩了、机器重启了,正在跑的任务就丢了。这些任务里有发优惠券、发积分这种涉及资产的操作,丢了就对不上账。队列有持久化(我们用 Redis + AOF),进程崩了任务还在,重启继续消费。

第二是重试和可见性。队列天然有”第几次尝试”这个概念,可以针对不同尝试次数做不同的处理;也有失败队列,出问题的时候能看到一堆失败任务排在那里。这套东西自己做,工作量不小还容易出岔子。

第三是我们项目里本来就在用 think-queue 跑一些不那么重要的定时任务(比如清理临时文件)。基础设施已经在了,直接拿来用,省得引入新的技术栈。

Swoole 的性能更好这一点我不否认,但支付回调这个场景 QPS 本来就不高,每秒钟顶天几十笔。性能不是主要矛盾,稳定性和可观测性才是。

环境准备与配置

think-queue 是官方维护的扩展包,装就是了:

composer require topthink/think-queue

装完之后会生成 config/queue.php,我们把默认驱动改成 Redis:

<?php
return [
    'default' => 'redis',
    'connections' => [
        'sync' => [
            'type' => 'sync',
        ],
        'redis' => [
            'type'       => 'redis',
            'queue'      => 'default',
            'host'       => env('REDIS_HOST', '127.0.0.1'),
            'port'       => env('REDIS_PORT', 6379),
            'password'   => env('REDIS_PASSWORD', ''),
            'select'     => 0,
            'timeout'    => 0,
            'persistent' => false,
        ],
    ],
    'failed' => [
        'type'  => 'database',
        'table' => 'failed_jobs',
    ],
];

failed 那里配了失败任务落库。这个表需要自己建,官方没有提供 migration,我是手写的:

CREATE TABLE `failed_jobs` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `connection` varchar(255) NOT NULL,
  `queue` varchar(255) NOT NULL,
  `payload` longtext NOT NULL,
  `exception` longtext NOT NULL,
  `fail_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_fail_time` (`fail_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里有个细节:payload 和 exception 我都用了 longtext。开始用 text 的时候遇到过”异常信息太长被截断”的问题——PHP 的异常堆栈完整打出来能有几十 KB,text 字段的 64KB 上限扛不住特别深的调用链。

Job 类的设计

think-queue 里每个任务对应一个 Job 类。我们不打算一个动作一个 Job,因为六个动作之间偶尔会有顺序依赖(比如发积分要在发优惠券之后,因为积分数额依赖券的金额)。更合理的做法是:按”业务动作”分,每个 Job 只负责一件事,然后把调用关系理清楚。

六个动作对应六个 Job:

app/job/
  IssueCouponJob.php
  AddPointJob.php
  SendPaidSmsJob.php
  CreatePickTaskJob.php
  RefreshDailyReportJob.php
  MarkPaidCustomerJob.php

以发优惠券为例:

<?php
declare(strict_types=1);

namespace appjob;

use thinkqueueJob;
use thinkfacadeLog;
use appcommonserviceCouponService;
use appcommonserviceOrderService;

class IssueCouponJob
{
    public function fire(Job $job, array $data): void
    {
        $orderSn = $data['order_sn'] ?? '';

        try {
            $order = OrderService::findBySn($orderSn);
            if ($order === null) {
                // 订单不存在,没有必要重试
                $job->delete();
                return;
            }

            // 幂等:已经发过就跳过
            if (CouponService::alreadyIssued($order['id'])) {
                $job->delete();
                return;
            }

            CouponService::issueForOrder($order);
            $job->delete();
        } catch (Throwable $e) {
            Log::error('IssueCouponJob failed: ' . $e->getMessage(), [
                'order_sn' => $orderSn,
                'attempts' => $job->attempts(),
                'trace'    => $e->getTraceAsString(),
            ]);

            if ($job->attempts() >= 5) {
                // 到上限,交给失败队列,不再占用重试名额
                $job->failed();
                return;
            }

            // 指数退避:10s, 30s, 90s, 270s
            $delay = 10 * (int) pow(3, $job->attempts() - 1);
            $job->release($delay);
        }
    }
}

这个 Job 里几个点说明一下。

幂等判断放在 Job 内部,不在派发的地方。因为队列的语义是”至少一次”,同一笔订单可能被派发两次(比如回调接口内部一个异常导致重复派发),Job 自己必须能识别。

不需要重试的异常直接 delete。比如订单不存在这种情况,重试一百次也还是不存在。这种要主动放弃,别让它一直占着队列。

退避使用 $job->attempts() 计算,而不是在 data 里传一个 retry_count。因为 think-queue 自己维护了尝试次数,重复实现容易出错。

$job->failed() 会把任务推进失败表,不再重试。这个跟 delete() 的差别是:失败表里的任务可以被人工看一眼、手动重试一次(php think queue:retry),而 delete 之后就找不到了。

回调接口改成什么样子

改造之后的回调接口:

public function notify(Request $request)
{
    $payload = $request->post();
    $orderSn = $payload['out_trade_no'];

    $order = OrderModel::where('order_sn', $orderSn)->lock(true)->find();
    if (!$order) {
        // 订单不存在,兼容性处理,但也要记录
        Log::warning('payment notify: order not found', ['order_sn' => $orderSn]);
        return json(['code' => 'SUCCESS']);
    }

    if ($order['status'] === OrderStatus::PAID) {
        // 已经处理过,直接返回成功,避免重复派发
        return json(['code' => 'SUCCESS']);
    }

    $order->status = OrderStatus::PAID;
    $order->paid_at = date('Y-m-d H:i:s');
    $order->save();

    $data = ['order_sn' => $orderSn];

    // 六个动作全部丢进队列
    Queue::push(IssueCouponJob::class, $data, 'high');
    Queue::push(AddPointJob::class, $data, 'high');
    Queue::push(SendPaidSmsJob::class, $data, 'normal');
    Queue::later(5, CreatePickTaskJob::class, $data, 'normal');
    Queue::later(30, RefreshDailyReportJob::class, $data, 'low');
    Queue::push(MarkPaidCustomerJob::class, $data, 'low');

    return json(['code' => 'SUCCESS']);
}

详细说一下几个决定。

队列分级:high、normal、low 对应三条独立的队列。Worker 进程按 high → normal → low 的顺序消费,保证高优先级的任务(发券、发积分这种涉及资产)不被报表刷新这种慢任务拖住。

延迟入队:创建仓储任务延迟 5 秒,是因为仓库系统对同时进来的请求比较敏感,稍微错开一下能减少并发冲击。刷新日报延迟 30 秒,是因为当天报表数据还没来得及汇总。Queue::later() 的延迟单位是秒,不是毫秒,别搞错。

回调接口本身不抛异常。订单状态更新完直接返回成功,后面队列挂不挂,跟支付网关的响应无关。就算 Redis 那一刻全挂了,Queue::push 抛异常,也要在 try-catch 里兜住,然后在日志里记录”派发失败”,事后有个补救脚本扫描。这一点非常重要,因为一旦这里抛异常,整个支付回调就失败了,网关重试会导致更麻烦的问题。

补一句,Queue::push() 的第三个参数是队列名,不是优先级数字。第一次部署的时候有同事写成了数字 1、2、3,结果三条队列根本没分开,worker 那边也只起了一个消费进程,排查了十几分钟才看出来。

Worker 进程怎么跑

本地开发直接用 sync 驱动,把任务当场跑完,方便调试。生产环境是三个 supervisord 管理的 worker 进程组:

; /etc/supervisor/conf.d/queue-high.conf
[program:queue-high]
command=php /var/www/think queue:work --queue=high --tries=5 --memory=256 --sleep=1 --daemon
numprocs=2
autostart=true
autorestart=true
user=www

[program:queue-normal]
command=php /var/www/think queue:work --queue=normal --tries=5 --memory=256 --sleep=1 --daemon
numprocs=1
autostart=true
autorestart=true
user=www

[program:queue-low]
command=php /var/www/think queue:work --queue=low --tries=3 --memory=256 --sleep=3 --daemon
numprocs=1
autostart=true
autorestart=true
user=www

参数解释一下。--tries=5 是全局重试次数上限,但我们的 Job 内部自己算了退避逻辑,所以这个数字其实是双重保护——Job 里到 5 次会主动 failed,如果 Job 里的异常处理一旦失效,这个兜住。--memory=256 是内存软限制,worker 跑着跑着内存涨到 256MB 会自动退出,让 supervisor 重启。--sleep=1 是空闲时的轮询间隔,太小了 Redis QPS 会高,太大了任务响应慢。

high 队列起了两个 worker,因为大多数任务都落在 high 上;low 只有一个,因为它本来就是慢任务,多起也没意义。

坑一:常驻 worker 的数据库连接过期

上线第三天早上,仓库那边打电话来说一早的取件任务一个都没出。查日志,low 队列的 worker 在凌晨四点就基本不工作了,但进程还在(supervisor 显示 running)。手动 strace 了一下,卡在 recvfrom 上。

原因很经典:queue:work --daemon 是常驻内存的,凌晨流量少,worker 就那么躺了好几个小时没碰数据库。MySQL 的 wait_timeout 默认 8 小时,连接被服务端静默杀掉了。worker 下次要用连接的时候,PDO 拿到的还是那个已经死了的句柄。

这个问题我在别的常驻场景也遇到过。处理方式是在 Job 的入口先做一次 Db::query('SELECT 1') 探活,捕获异常就 Db::close() 重建:

private function ensureDbAlive(): void
{
    try {
        Db::query('SELECT 1');
    } catch (Throwable $e) {
        Db::close();
    }
}

但每个 Job 开头都加这段太啰嗦。更省事的做法是在 think-queue 的 worker 里做一个统一的 before handle hook。think-queue 提供了一个方法 Queue::before() 可以注册全局前置回调:

// app/service/QueueBootstrap.php 里初始化一次
Queue::before(function () {
    try {
        Db::query('SELECT 1');
    } catch (Throwable $e) {
        Db::close();
    }
});

另外也可以在 config/database.php 里把 break_reconnect 打开,让 TP 在查询失败时自动重连:

'break_reconnect' => true,

这两层加在一起,问题就消失了。后面跑了大半年,没再出现类似情况。

坑二:Job 类改完代码不生效

这个问题困扰了我小半天。Job 类里加了一行日志,重启 supervisor 了,代码也不生效。

原因在于 --daemon 模式下 worker 是常驻的,PHP 的 opcache 缓存了文件内容。supervisor 的 autorestart 即使生效,也只是重启进程,opcache 是共享内存,跨进程不重启就一直拿着旧版本。

处理方式有几种。第一种是定时 opcache_reset(),影响线上性能,不推荐。第二种是每次 deploy 之后显式重启 php-fpm 或者清空 opcache(opcache_compile_file 不靠谱)。第三种是干脆关掉 worker 进程组的 opcache,但那样性能会下去。

我们最后用的是第二种,把 deploy 脚本里加了一行 php -r 'opcache_reset();'。同时调整了 supervisor 的配置,把每组 worker 的 stopwaitsecs 设成 30,确保重启的时候手上的任务能跑完。

还有一个小细节:改完 Job 类之后,除了重启 worker,也要考虑队列里还有没有旧版本的任务在排队。payload 里序列化的是类名和参数,只要类名不变,旧任务不会被”新版本”拒之门外——但如果旧任务依赖的参数已经在新版本里改了结构,那就麻烦了。我们的做法是:Job 的参数只传 ID 和类型,具体数据在 Job 里现查,这样版本兼容性天然好一些。

坑三:Job 参数序列化丢失类型

think-queue 的 payload 是通过 JSON 序列化存在 Redis 里的。这意味着什么?意味着一个 DateTime 对象、一个 Enum、一个 Model 实例,全都会在序列化的时候被拍平。

我们的 MarkPaidCustomerJob 一开始参数传的是 $order 模型对象,本地 sync 驱动测试一切正常(因为 sync 不序列化),一上生产打 Redis 就直接变成数组了,Job 里所有 $order->xxx 的方法调用全部报 Call to a member function on array。

这个坑极容易踩。改法很简单:Job 参数只传标量,对象在 Job 内部重新查一次。虽然多了一次 DB 查询,但避免了所有序列化边界问题。

同时要注意 Enum 也是这样。我们项目里 OrderStatus 是 PHP 8.1 的 backed enum,序列化之后变成字符串,instanceof OrderStatus 判断会失败。现在写 Job 有个约定:传进去的 data 必须能通过 json_encode 和 json_decode 往返无损。这条约定写在了团队的编码规范里。

坑四:失败任务表只增长没人看

上线一个月之后做数据统计,发现 failed_jobs 表里有 1200 多条记录。当时配了失败表,但没人处理。

简单分析一下,其中 800 多条是 SendPaidSmsJob 失败。原因是短信通道那边偶尔会限流,一次连续来太多任务就被拒了。剩下 400 多条是 CreatePickTaskJob,仓库系统的接口在夜间维护期间不可用。

这个数字很吓人。短信没发出去,用户会投诉;仓储任务没创建,包裹发不出去。但这两个问题都不是”我们的代码有问题”,是外部依赖不稳定。

处理方式是这样:

第一步,给失败任务建立一个兜底扫描。每小时跑一次,扫 failed_jobs 里过去一小时新增的记录,按 Job 类型分组。短信类的直接重试一次,仓储类的在工作时间段重试。如果重试之后还是失败,再进二次失败表。

第二步,外部依赖加强健壮性。短信那边改成优先队列 + 多通道兜底,主通道限流就切到备用通道。仓储那边加了”维护窗口”识别——如果当前时间在对方公布的维护时段内,任务直接延迟到维护窗口之后,不重试。

第三步,失败任务告警。过去 15 分钟新增失败任务超过 10 条,就发企业微信告警。这个阈值设得比较松,因为外部依赖偶尔出点问题是常态,太敏感反而会疲劳。

改完之后一个月,失败任务表里的数据从 1200 条降到了 30 条左右,而且基本都能在几天内被兜底脚本清理掉。

坑五:Redis 内存增长与清理

think-queue 用 Redis 存 payload,如果没有配置队列的自动清理,queues:high 这样的 key 因为一直在用不会消失,但如果哪天手动改个队列名字,旧队列会一直留在 Redis 里占内存。

我们遇到过一次,某次 deploy 时不小心把队列名从 default 改成了 default2,结果 default 队列里还有 3000 多条任务,没人消费,就在那里待着。运维看着 Redis 内存一直涨,来问是不是用错了什么地方。

处理方式是把队列名字写进常量,不在代码里硬编码,避免这种低级错误。同时加了一个监控脚本,每天扫一遍所有 queues:* 的 key,如果有 key 的长度超过某个阈值并且在过去 24 小时没有变化,就告警——这通常意味着这个队列没人在消费。

上线之后的效果

支付回调接口的响应时间从原来的 P99 800ms,降到了 P99 40ms(主要是订单状态更新的 DB 操作)。网关再也没超时重试过,重复处理的投诉归零。

队列侧的数据:高峰时每秒钟入队 40-60 条任务,high 队列的两个 worker 消费能力大概在 800 条/秒,normal 队列一个 worker 大概 200 条/秒,low 队列更慢但任务也少。整体延迟(入队到处理完)在 99 分位大概 800ms 左右。

任务失败率大概 0.3%,其中大部分是外部依赖问题。有了失败表加兜底扫描,业务方基本感觉不到这些”故障”。

哪些场景不适合上队列

这件事做完之后,团队里有人想把所有”可以异步”的地方都上队列,我拦了一下。队列不是万能的。

假设一个动作必须在这个请求返回之前完成,那它绝对不能上队列。比如用户改密码、登录状态更新、验证码校验这类,用户点完按钮之后立刻要看到结果,队列带来的异步延迟是不可接受的。

另外强一致性的场景也不适合。比如库存扣减,扣少了可能超卖,扣早了可能锁库存太久。这类操作必须在一个事务里完成,队列只能帮你在事务之外做一些”补偿”或者”最终一致”的事,但不能替代事务本身。

还有一个容易忽略的场景:动作本身非常快(不到 1 毫秒)。这时候入队、出队、序列化、进程间通信的成本加起来可能比动作本身还大。我们项目里有几个动作不到 5 毫秒,改成队列反而慢了。

判断标准是:动作涉及外部依赖、正常执行超过 20 毫秒、允许几秒到几分钟的延迟——满足这几条再上队列。

最后总结几条

第一,支付回调这类关键接口,必须做到”快速返回 + 幂等”。快速返回是保护网关不要重试,幂等是保护业务不要重复执行。这两件事做好了,剩下的才有优化的余地。

第二,Job 的参数设计要有纪律。只传标量,所有对象在 Job 内部重新查。看上去多一次 DB 查询,实际上省掉的序列化坑能让你少熬好几个夜。

第三,worker 进程一定要配内存限制和自动重启。常驻进程没有 FPM 的请求结束清理机制,内存涨上去就是涨上去,内存泄漏可能是第三方库的问题,也可能是你自己的代码问题,但不管谁的问题,限制加重启是最后一道防线。

第四,失败任务要有人管。配了失败表不代表问题解决了,只是问题记录下来而已。加个扫描脚本、加个告警阈值、加个兜底重试,这三件事做完,队列才算真正落地。

第五,别迷信队列。它解决的问题是”异步”和”最终一致”,不是”性能”和”复杂度”。如果你的动作本来就该同步做,硬套队列只会带来不必要的麻烦。

这套东西从改造到稳定,前后大概两周。中间第一周主要在处理上面这几个坑,第二周在调优和观察。真正写代码的时间加起来也就三天,更多的是在跑测试、看日志、猜外部依赖。

如果你也在处理类似的”支付回调里一堆动作”的问题,可以按这个思路试试。不一定完全一样,但”必成功事务 + 允许最终成功的异步”这个基本架构是通用的。

ThinkPHP 8 支付回调异步化:六个动作排队、重试退避与死信兜底
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 thinkphp ThinkPHP 8 支付回调异步化:六个动作排队、重试退避与死信兜底 https://www.taomawang.com/server/thinkphp/2961.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务