双十一当天下午三点四十,收银台开始报”支付成功但没跳转”。第一反应是支付网关问题,去后台查了下订单,状态明明是已支付。再一看回调接口的错误日志,全是 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 的请求结束清理机制,内存涨上去就是涨上去,内存泄漏可能是第三方库的问题,也可能是你自己的代码问题,但不管谁的问题,限制加重启是最后一道防线。
第四,失败任务要有人管。配了失败表不代表问题解决了,只是问题记录下来而已。加个扫描脚本、加个告警阈值、加个兜底重试,这三件事做完,队列才算真正落地。
第五,别迷信队列。它解决的问题是”异步”和”最终一致”,不是”性能”和”复杂度”。如果你的动作本来就该同步做,硬套队列只会带来不必要的麻烦。
这套东西从改造到稳定,前后大概两周。中间第一周主要在处理上面这几个坑,第二周在调优和观察。真正写代码的时间加起来也就三天,更多的是在跑测试、看日志、猜外部依赖。
如果你也在处理类似的”支付回调里一堆动作”的问题,可以按这个思路试试。不一定完全一样,但”必成功事务 + 允许最终成功的异步”这个基本架构是通用的。

