上周线上告警:MySQL 连接数在凌晨三点突然飙到 298,DBA 抓包发现全是 idle in transaction 状态。查日志,没报错;看队列,消费正常;最后翻到 App\Models\Order::created 监听器里一行 Mail::to($user)->send(new OrderConfirmed($order)) —— 邮件驱动配置错了,但异常被 Laravel 事件分发器吞掉,PDO 连接没释放,事务也没回滚。

模型事件监听器:数据库泄漏的隐形推手

很多人以为「事务悬挂」只发生在手动 DB::transaction() 里。其实 static::creating()static::saved() 这些模型事件监听器更危险:它们天然运行在主事务上下文中,一旦抛出未捕获异常,Laravel 不会自动 rollback,PDO 连接也不会 close —— 尤其当监听器里调用外部服务(邮件、HTTP、日志)失败时。

典型场景就三个:
• 邮件发送失败(SMTP 配置错 / 网络抖动)
• 日志写入异常(磁盘满 / Monolog handler 崩了)
• 第三方 SDK 抛出 RuntimeException(比如 Stripe Webhook 验证失败)

Laravel model event listener causing database connection lea

手动排查 vs 静态分析:为何选择PHPStan

靠日志翻三天?靠 Xdebug 单步进 17 个监听器?上回那个 Mail::send() 问题,我们花了 6 小时才定位到邮件驱动配置错——而异常根本没进日志,PDO 连接就卡在 状态里。

手动查漏,本质是用时间赌运气

你得确保每个监听器都加了 try/catch;得确认 DB::transaction() 外层没被 Laravel 事件分发器绕过;还得祈祷第三方 SDK 的 Http::post() 不被 Event::dispatch() 吞掉。现实是:90% 的监听器连 Log::error() 都没写。

PHPStan 的 AST 扫描,不依赖运行时

它直接解析 App\Models\User 和模型类里的 static::created()static::updated() 调用点,识别所有未包裹在 try 块中的外部调用(Mail::send()Http::post()Log::info())。不是猜,是看代码树的结构。

自定义规则 App\PHPStan\Rules\ModelEventCallWithoutTryCatchRule 会报出:
App\Models\User::saved() calls Mail::to() without surrounding try-catch — possible connection leak on exception

静态分析不会漏掉那个没写测试的 Order::updated(),也不在乎你是不是关了 APP_DEBUG

PHPStan static analysis scanning Laravel code

编写PHPStan自定义规则:三步搞定

那问题理清楚了,总得动手修吧。写一条 PHPStan 自定义规则,说到底就三步:先拿到 AST,再遍历监听器节点,最后检查 try/catch 有没有漏掉。别一听「抽象语法树」就觉得高大上——你把代码拆成一棵结构树,它就是个带坐标的地图,我们只需找到地图上那几个关键地标,剩下的事就好办了。

先说环境:PHPStan 1.10+ 是必须的,Laravel 9 或 10 都行,11 也兼容(虽然我还没在生产上跑过 11)。自定义规则是一个实现 PHPStan\Rules\Rule 接口的类,核心方法就一个 getNodeType(): stringprocessNode(Node $node, Scope $scope): array

第一步:从 AST 里捞出模型事件监听器

模型事件监听器长什么样?User::creating(function ($user) { ... }) 或者类里定义的 static::creating() 方法。我们关心的是后者——那些直接定义在 Model 类中的静态方法。它们在 AST 里对应 ClassMethod 节点,方法名匹配 creatingcreatedsavingsavedupdatingupdateddeletingdeleted 这八个之一。

class ModelEventCallWithoutTryCatchRule implements \PHPStan\Rules\Rule
{
    public function getNodeType(): string
    {
        return \PhpParser\Node\Stmt\ClassMethod::class;
    }

    public function processNode(Node $node, Scope $scope): array
    {
        // 只处理 Model 类里的静态方法
        if (!$node->isStatic()) {
            return [];
        }

        $methodName = $node->name->toString();
        $modelEventMethods = [
            'creating', 'created', 'saving', 'saved',
            'updating', 'updated', 'deleting', 'deleted'
        ];

        if (!in_array($methodName, $modelEventMethods, true)) {
            return [];
        }

        // 确认当前类继承自 Model
        $classReflection = $scope->getClassReflection();
        if (!$classReflection->isSubclassOf(\Illuminate\Database\Eloquent\Model::class)) {
            return [];
        }

        // 方法体拿到了,下一步检查异常处理
        $statements = $node->getStmts();
        // ... 后续逻辑
    }
}

这段代码干了三件事:限制节点类型为类方法,只取静态方法,只取那八个事件名,结果确认类继承自 Model。少一步都会有误报。

第二步:检查异常处理是否完整

拿到方法体后,我们要找的是所有「外部调用」——那些可能抛出异常且没有被 try/catch 包裹的语句。什么是外部调用?Mail::to()Http::post()Log::info()dispatch(new Job),甚至 Cache::put() 在 Redis 连不上时也会崩。

最简单粗暴的方式:遍历方法体内的所有 StaticCallMethodCall 节点,检查它们是否被某个 TryCatch 包裹。PHPStan 的 Scope 对象提供了 isInTryCatch() 方法,但注意:它只能判断当前节点是否直接位于 try 块内。嵌套的情况,比如 try 块里又调了另一个方法,那个方法里的调用是看不到的——但那是另一个规则该管的事。

public function processNode(Node $node, Scope $scope): array
{
    // ... 上一步的筛选逻辑 ...

    $errors = [];
    $statements = $node->getStmts();

    if ($statements === null) {
        return []; // 抽象方法或接口方法
    }

    $this->checkStatementsForUncaughtCalls($statements, $scope, $errors);

    return $errors;
}

private function checkStatementsForUncaughtCalls(
    array $statements, 
    Scope $scope, 
    array &$errors
): void {
    foreach ($statements as $stmt) {
        if ($stmt instanceof \PhpParser\Node\Stmt\TryCatch) {
            // try 块内部是安全的,不检查
            continue;
        }

        if ($stmt instanceof \PhpParser\Node\Expr\StaticCall
            || $stmt instanceof \PhpParser\Node\Expr\MethodCall) {
            
            $calleeName = $this->resolveCalleeName($stmt);
            if ($this->isExternalServiceCall($calleeName)) {
                $errors[] = \PHPStan\Rules\RuleErrorBuilder::message(
                    sprintf(
                        'Model event listener %s() calls %s without try-catch — possible connection leak on exception',
                        $scope->getFunctionName(),
                        $calleeName
                    )
                )->line($stmt->getLine())
                 ->build();
            }
        }

        // 递归检查子语句(比如 if/foreach 里的调用)
        foreach ($stmt->getSubNodeNames() as $subNodeName) {
            $subNode = $stmt->$subNodeName;
            if (is_array($subNode)) {
                $this->checkStatementsForUncaughtCalls($subNode, $scope, $errors);
            }
        }
    }
}

这里有个细节:resolveCalleeName() 需要你自己实现,从 AST 节点里提取出完整的调用字符串。比如 Mail::to() 的 StaticCall 节点,class 部分是 Name 节点(值为 Mail),name 部分是 Identifier(值为 to)。拼起来就是 Mail::to。MethodCall 类似,对应 $this->httpClient->post() 这种。

第三步:标记风险并报告

第二步里已经通过 生成了错误。但光是报错不够,我们还想告诉开发者「这可能导致连接泄漏」。PHPStan 的错误级别可以调:RuleErrorBuilder::message() 之后链式调用 ->tip() 可以加建议。

$errors[] = \PHPStan\Rules\RuleErrorBuilder::message(
    sprintf(
        'Model event %s::%s() calls %s without try-catch — possible DB connection leak',
        $scope->getClassReflection()->getName(),
        $scope->getFunctionName(),
        $calleeName
    )
)->line($stmt->getLine())
 ->tip('Wrap the call in try-catch and close the connection on failure: try { %s } catch (\Throwable $e) { DB::disconnect(); Log::error($e); throw $e; }', $calleeName)
 ->build();

注意

自定义规则需基于 PHPStan 1.10+,且 Laravel 版本需 >= 8.x。老项目如果还在用 PHP 7.4,跑不了。另外记得在 rules 数组里注册你的规则类,否则写了等于没写。

注册完了跑一遍:vendor/bin/phpstan analyse --level=max app/Models。如果之前哪个 static::created() 里直接调了 Http::post() 没包 try,立马会飘红。

这个规则不是银弹。它抓不到运行时动态注册的闭包监听器(比如 Event::listen('eloquent.created: App\Models\User', function ...)),也抓不到 $dispatchesEvents 属性里配的函数。但没关系——80% 的泄漏发生在显式定义的模型事件方法里,逮住这些就够把事故率砍掉大半。

实话实说,写这个规则花了大概三小时,调试 AST 节点类型占了两个小时。但上线当天就在一个用了六年的项目里抓出四个未捕获调用——三个发邮件,一个写日志。那四个监听器如果同时出错,记录库连接池能直接被打满。

实战:在Laravel项目中运行检测

规则写好了,总不能躺在单元测试的 fixture 目录里吃灰。真正要让它干活,得把它塞进 PHPStan 的加载链里,然后对着真实项目跑一遍。这一步比写规则本身更容易翻车——不是规则不生效,就是路径配错导致分析器压根没扫描到你的模型文件。

首先确认 PHPStan 版本。如果你还在用 0.12.x,建议先升到 1.10+。老版本的自定义规则接口是 PHPStan\Rules\Rule,但参数签名在 1.x 改过两次,直接复制网上的示例大概率报 TypeError。升级完了跑一下 phpstan --version,版本号对得上再往下走。

安装自定义规则包不需要额外 composer 包——规则类直接放在 app/PHPStan 或项目根目录下的 rules/ 都行。我习惯丢在 ,命名空间用 App\PHPStan\Rules,然后在 autoload 里加上 PSR-4 映射:

"autoload": {
    "psr-4": {
        "App\\": "app/",
        "App\\PHPStan\\": "app/PHPStan/"
    }
}

刷新一下,不然 PHPStan 找不到你的类。

关键的配置在 。如果你项目里还没有这个文件,从 Laravel 官方提供的 一份改改。在 rules 数组里注册你的检测类:

rules:
    - App\PHPStan\Rules\ModelEventCallWithoutTryCatchRule

然后把扫描路径指到模型目录:

这里有个坑:如果你用了 Laravel 的 IDE helper 生成的 _ide_helper.php,或者安装了 ,记得在 scanFiles 里把它排除掉,否则 PHPStan 会在那个文件里分析出一堆不存在的模型方法,干扰结果。

跑起来:

vendor/bin/phpstan analyse --configuration=phpstan.neon --memory-limit=1G

第一次跑的时候,输出可能让你崩溃。别慌,Laravel 的 Facade 和魔术方法会让 PHPStan 报一堆 ——这不代表你的代码有问题,只是 PHPStan 不认识 User::where() 这种动态调用。解决方案是在 里加载 Laravel 的扩展包:

Larastan 会提供 Facade 和 Eloquent 的桩文件,把误报压到合理范围。如果你不想额外引入 Larastan,也可以自己写 stub 文件,但工作量不亚于再写一条规则。

终端刷出来的检测结果其实是这样的——拿一个真实项目跑完,输出差不多是:

  Line   App\Models\Order.php
  42     Model event App\Models\Order::created() calls Http::post() without try-catch
         — possible DB connection leak. Wrap the call in try-catch and close
         the connection on failure.

  Line   App\Models\Invoice.php
  78     Model event App\Models\Invoice::updated() calls Log::channel('slack')->info()
         without try-catch — possible DB connection leak.

第一个泄漏点在 Order::created() 里直接调了 Http::post() 通知第三方支付网关。那哥们当初写的时候觉得“发个请求而已,不会挂的”——结果第三方网关超时,PDO 连接没释放,队列进程占满连接池,整条支付链路僵死。第二个是往 Slack 发通知,Log facade 底层走 UDP 按理不会阻塞,但 Log::channel('slack') 实际上是同步 HTTP POST,一样有连接泄漏风险。

你可能会问:这些地方加个 try-catchDB::disconnect() 塞进 catch 块不就行了?对,但问题是没人记得。而且很多项目里模型事件是后期加的,代码评审时根本没人盯着这个点。自动化规则的价值就在这儿——它替你记住那些你不想记的事。

只有一个场景这条规则无能为力:Event::listen('eloquent.created: App\Models\User', function ($user) { ... }) 这种动态注册的闭包监听器。闭包在运行时才加载,AST 分析看不到。我的做法是在项目里加一个脚本文档,要求所有闭包监听器必须委托到显式的 Listener 类,然后给那个类加上 @phpstan-require-try-catch 注解,再写一条补充规则去扫注解。但那是后话了,先把 80% 的显式泄漏按住,剩下的手动审计也不至于手忙脚乱。

上线这条规则那天下午,CI 流水线直接红了三个 PR。开发者骂了两句,然后默默给模型事件套上了 try-catch。没人再提连接池被打满的事——因为没再发生过。

修复建议:从源头杜绝泄漏

规则能扫出来,但改不改还是看人。我见过最离谱的修复方式是:在 catch 块里写一行 DB::disconnect(),然后继续抛异常——连接是释放了,事务还在半空中挂着。

try/finally 比 try/catch 更安全

模型事件监听器里如果开了事务,或者隐式持有了连接,推荐用 try/finally 而不是 try/catch。因为 finally 块无论是否抛出异常都会执行,能确保 DB::disconnect()DB::rollBack() 一定被调用。写起来也不复杂:

// 别这样
try {
    $order->update(['status' => 'paid']);
    Http::post('https://gateway.example.com/notify', $payload);
} catch (\Throwable $e) {
    DB::disconnect();
    throw $e;
}

// 这样更稳妥
try {
    DB::beginTransaction();
    $order->update(['status' => 'paid']);
    Http::post('https://gateway.example.com/notify', $payload);
    DB::commit();
} finally {
    DB::rollBack();
    DB::disconnect();
}

注意 finally 里的 DB::rollBack() 在事务已提交时不会报错,Laravel 的 DB::transaction() 会忽略多余的 rollBack 调用。当然,如果你用了 DB::transaction() 闭包写法,这些细节已经被框架处理了——但模型事件里直接调用 DB::beginTransaction() 的场景依然常见。

事务内操作尽量委托给队列

那个支付网关超时的例子,本质问题不是没 try-catch,而是在事务里做了同步 HTTP 请求。模型事件的触发时机往往在框架的事务保护层内(比如 Model::save() 可能嵌套在外部事务里),你在监听器里调用外部 API,等于把整个事务的提交时间交给了第三方。

解决办法:监听器里只做数据整理,然后 dispatch(new NotifyGatewayJob($order))。队列任务失败会自动重试,不会把数据库连接拖死。PHPStan 规则可以再加一条:检测模型事件监听器里是否有 Http::Mail::Log::channel('slack') 等 IO 调用,强制要求走队列。

对于闭包监听器,用文档约定兜底

前文说了 AST 扫不到闭包。我自己的做法是在 App\Providers\EventServiceProvider 的注释里写一段 @see ClosureListenerPolicy 指引,然后在项目 README 里加一条醒目的注意事项。开发者看到就会知道:闭包监听器必须显式声明 @phpstan-require-try-catch,否则 CI 不会放过你。这条补充规则大概 30 行 PHPStan 代码就能搞定——用 PhpParser\Node\Stmt\ClassMethod 扫注解,命中 @phpstan-require-try-catch 的方法再检查内部是否有 try-catch 结构。

自动化规则能卡住八成显式泄漏,剩下的两成,靠文档规范和代码评审慢慢磨。从那以后,连接池被打满的告警再也没响过。