ThinkPHP 事件订阅我挂了三个监听器,结果同一个订单触发了两遍:原来 `Event::trigger` 和 `Event::listen` 我混成了"俄罗斯套娃"
上周给社区商城接支付回调,想着用事件解耦,订单支付成功之后要干的事一大堆:改状态、发通知、加积分、推库存。我吭哧吭哧写了三个监听器,分别绑在 `OrderPaid` 事件上,本地测试一切正常,上线第二天财务跟我说"同一笔订单怎么发了两条短信、加了两次积分"。
我第一反应是支付平台重复回调,加了幂等锁,问题还在。翻日志才发现,同一个监听器执行了两次,而且时间戳差不到 10ms。最后定位到问题的时候我想给自己一巴掌——我在 `AppService` 的 `boot` 里用 `Event::listen(OrderPaid::class, [SendSms::class, 'handle'])` 注册了一遍,又在 `event.php` 配置文件里把 `SendSms` 写进了 `OrderPaid` 的 `listen` 数组。ThinkPHP 启动时两边都读了,同一个监听器被挂了两回钩子。
这事儿让我重新理了一遍 TP 事件的几种挂法,发现坑比想象中多:
1. 配置里写数组 vs 代码里动态绑,优先级和去重逻辑不一样
`config/event.php` 里的 `listen` 是框架启动时批量注册的,底层走 `Event::listenEvents()`。而你在某个服务里写 `Event::listen()` 是运行时追加。TP 不会帮你做"同一个类只挂一次"的去重,它只管按顺序往 `listeners` 数组里塞。所以同一个类被塞两次,触发时就会跑两遍。
我现在养成了一个习惯:纯配置驱动的事件,全写 `event.php`,不在代码里重复 `Event::listen()`;必须动态绑的(比如插件机制、按配置开关),才走代码注册,而且注册前先 `Event::hasListeners()` 扫一眼,或者干脆用订阅者模式收敛到一个类里管。
2. 事件订阅者(Subscriber)是个被低估的"收纳盒"
之前我三个监听器分散在三个文件,每个都要在配置里写一行。后来改成订阅者,一个 `OrderEventSubscriber` 类实现 `EventSubscriberInterface`,`subscribe` 方法里统一 `$event->listen('OrderPaid', [$this, 'onOrderPaid'])`,然后内部再调度具体的 SMS、积分、库存逻辑。好处是钩子集中在一处,不会出现"配置文件漏删、代码里多绑"的套娃问题,而且订阅者内部可以自己控制执行顺序、做短路返回。
有个细节:订阅者类要在 `event.php` 的 `subscribe` 数组里注册,不是 `listen` 数组。我第一次写的时候塞错了地方,订阅者死活不生效,断点打进去发现 `subscribe` 方法根本没被调用。框架启动流程里 `listen` 和 `subscribe` 是两拨解析逻辑,别混。
3. 调试事件执行:别只会 `dump`,用中间件思路埋钩子
事件这玩意儿异步感很强,出了问题不好跟。我现在本地调试会在 `AppService::boot` 里临时挂一个"探针":
`Event::listen('*', function ($event) { if (strpos(get_class($event), 'Order') !== false) { trace("FIRED: " . get_class($event) . " at " . microtime(true)); } });`
TP 的 `Event` 支持通配符 `*`,所有事件都会过这个回调。配合 `trace` 或者写临时日志,能一眼看出某个业务动作到底触发了哪些事件、顺序是什么、有没有重复。比你在每个监听器里打 `dump` 干净多了,上线前删掉这行就行。
另外 `Event::until()` 这个冷方法挺好用,它触发事件但只执行第一个返回非 null 的监听器,后面全部跳过。适合做"策略竞争"场景,比如多个支付渠道抢着一个订单处理,谁先认出这笔订单是谁家的,就直接返回,后面的不用跑。
4. 闭包监听器慎用,内存和调试都是坑
早期图省事写过 `Event::listen('OrderPaid', function ($order) { ... })`,当时爽了,后来线上偶尔报内存泄漏,排查发现是闭包引用了外部变量,事件对象又长期驻在内存里(某些长连接场景)。而且闭包监听器在日志里显示的是 `Closure`,堆栈跟踪根本看不出是哪个文件定义的,排查全靠猜。
现在我的规矩是:监听器必须是类方法,哪怕里面只有两行代码,也得有个具名类。调试时能直接定位到文件行号,代码审查也一眼能看出事件链路。
最后说个今天刚踩的:ThinkPHP 的 `Event::trigger()` 返回值是监听器返回值的数组,但如果你用了 `Event::until()`,返回的是单个值。我之前有个接口判断"是否发送成功",用 `trigger` 之后拿返回值做 `if`,结果拿到的是数组,永远为真,短信失败也显示成功。这种类型陷阱在弱类型语言里特别阴险,建议事件返回值统一封装成对象,别裸传 `true/false`。
你们事件都是怎么组织的?配置驱动还是订阅者模式?有没有被重复触发坑过?

