钩子明明注册了,队列任务却像进了黑洞:一次 ThinkPHP6 事件监听"静默失败"的排查实录
上周给项目加积分系统,需求很简单:用户下单成功后,监听器里扣积分、写日志、发通知。我照着文档三步走——`Event::trigger('OrderPaid')`、监听器里实现 `handle()`、配置文件里绑定。本地 Postman 一测,完美。推了测试环境,前端同事说"没问题啊,订单状态变了"。
直到运营后台对账,发现三天积分流水差了四百多条。我当场冷汗:事件没报错,订单正常走完,但积分就是没加上。
先说结论:ThinkPHP6 的事件监听在异步队列模式下,监听器类里的异常会被静默吞掉,既不进日志,也不影响主流程。我本地没配队列驱动,走的是同步;测试环境用了 Redis 队列,`handle()` 里一个未定义变量直接让任务进了死信,但页面照样返回"成功"。
排查过程极其反人类。我先确认了事件触发点:
Event::trigger('OrderPaid', $order);
然后翻 `config/event.php`,绑定没问题。接着在监听器里打 `trace_log()`,测试环境毛都没有。这时候我怀疑是队列没消费,去 Horizon 一看(我们用的 Laravel 那套队列封装),任务列表空空如也。
真正破案的线索是 Redis 的 `queues:default:failed` 键。手动 `LRANGE` 一翻,全是我的事件类,失败原因 `ErrorException: Undefined variable: userLevel`。回代码一看,`handle()` 里引用了 `$this->userLevel`,但实际注入的是 `$order`,我复制了另一个监听器的模板忘了改。
本地为啥没事?因为 `QUEUE_DRIVER=sync`,同步模式里异常会直接抛到控制器,我一眼就能看到。测试环境切了 Redis,框架把任务丢给队列进程后就不管了,消费者那边报错只写 failed 表/队列,默认还不通知。
现在我的监听器开头必加这三行,成了肌肉记忆:
public function handle($order)
{
try {
// 业务逻辑
} catch (\Throwable $e) {
\think\facade\Log::error('积分事件处理失败: ' . $e->getMessage(), ['order_id' => $order->id]);
// 可选:重试或告警
throw $e; // 让队列知道这单失败了,别直接消失
}
}
另外给队列消费者加了 `--tries=3 --timeout=30`,失败任务进数据库方便审计。最讽刺的是,这个"静默"特性在官方文档里就一句话带过,藏在"队列任务"章节而不是"事件系统"章节。我翻了四十分钟才找到。
还有个衍生坑:如果监听器里用了模型关联懒加载,队列进程和 Web 进程的 DB 连接配置可能不一样。我同事后来踩了 `strict` 模式差异,本地宽松、队列严格,同一条 `save()` 语句两种结果。建议队列消费者单独配一套 `.env`,或者至少把 `database.php` 里容易漂移的参数显式写死。
现在每次写事件监听,我先问自己三个问题:同步还是异步?异常往哪流?队列失败谁看得见?这三个想不清,再简单的业务逻辑也能变成对账噩梦。