上周四夜里十一点半,咖啡凉透了,生产报警群炸了。接口响应从 200ms 飙到 4.7 秒,点开 Laravel 的 SQL 日志一翻——502 条一模一样的 SELECT * FROM `orders` WHERE `user_id` = ? 齐刷刷排在那儿。看到那个数字,后背瞬间冒了一层冷汗。
一条 SQL 日志让你头皮发麻
这场景太熟了。一个简单的用户列表页,Controller 里一行 User::all(),Blade 模板里 foreach 循环调了 $user->orders。开发时数据量小,10 个用户跑下来 11 条查询,谁都没觉得不对劲。可一旦上了生产,几千个用户同时访问,数据库连接池直接被打满,服务器 CPU 拉到 95%。
这就是 Eloquent 懒加载的经典陷阱——它在开发阶段几乎隐形。你本地数据库就 20 条测试数据,N+1 问题根本造不成可见的延迟。但等到 QA 环境压测或者上线后流量一上来,每多一次循环就多一条查询,数据库直接变成瓶颈。而且这种问题不像语法错误会直接抛异常,它只是慢,慢到你得翻着 Query Log 一条条数才能定位。
我们团队以前全靠 code review 人工盯。说实话,几十个 Blade 文件里藏着十几个 $item->relation,眼睛看花了也未必能全揪出来。更别提那些藏在 Collection 的 map 闭包里的懒加载——静态分析工具根本不会报错,因为语法上完全合法。
所以这次我决定用 PHPStan 写一套自定义规则,把这件事彻底自动化。你不是不报错吗?那我让 PHPStan 在 CI 阶段就直接拦截:只要检测到 Eloquent 模型在没有显式 with() 的上下文里调用了关系属性,就报一个 error。这样 N+1 问题还没上线就被扼杀在代码审查阶段。
静态分析最怕碰到“看起来没问题,跑起来才出事”的场景,尤其是 Eloquent 这种大量依赖动态特性的 ORM。PHPStan 在不做数据库连接的情况下,怎么知道 $user->posts 到底是一个已经加载过的关系,还是一次隐式的 SQL 查询?
PHPStan 怎么“看懂” Eloquent 的关系调用
Larastan 的预设认知
Larastan 作为 PHPStan 的 Laravel 扩展,核心优势在于它对框架容器与 Eloquent 模型的“预设认知”。它知道 User 模型里的 posts() 方法返回的是 HasMany 关系对象,而非普通的集合。因此在分析时,它能推断出 $user->posts 这个动态属性背后对应的是一个延迟加载器。但问题是,当代码中没有显式的 with('posts') 时,这个关系调用在静态层面永远无法被标记为“已加载”,于是潜在的 N+1 隐患就被当作普通且合法的代码放过了。
动态属性背后的“黑盒”
Eloquent 的魅力在于魔术般的便捷,但这恰恰是静态分析的噩梦。当你写下 $user->orders 时,如果没有预先告诉 PHPStan “这里可能会触发查询”,它的 AST 解析器只会看到一个未知的属性访问。为了打破这个黑盒,我们需要编写自定义规则,强行介入这个解析过程。目标很明确:拦截所有对 Eloquent 模型动态属性的读取,并向上追溯父级上下文,看看有没有配套的预加载指令。
写一条规则:在没有 with 的地方拦住关系属性
- 识别模型基类:确保当前节点是 Eloquent Model 的子类实例。
- 追踪关系调用:定位到类似
$var->relationName的 PropertyFetch 节点。 - 审查上下文:检查该行代码的上级作用域是否存在
with()或load()调用。
// 核心检查逻辑
public function processNode(Node $node, Scope $scope): array
{
if (!$node instanceof PropertyFetch) {
return [];
}
$varType = $scope->getType($node->var);
if (!$varType instanceof ObjectType || !$varType->isInstanceOf(Model::class)->yes()) {
return [];
}
$relationMethod = $node->name->toString();
if (!$this->isEloquentRelation($varType, $relationMethod)) {
return [];
}
if (!$this->hasEagerLoadInContext($node, $scope)) {
return [
RuleErrorBuilder::message(
"Accessing relation '{$relationMethod}' without explicit with() or load(). Potential N+1."
)->build(),
];
}
return [];
}
这样一来,只要有人在 Blade 模板或者 Controller 里直接调用了未加注的关系属性,PHPStan 就会在 CI 流水线里直接报错,强制开发者要么加上 with(),要么接受这是一个已知的性能妥协。
从 PropertyFetch 入手,手动检测未预加载的关联属性
前文我们把静态分析的底牌摊开了——动态属性背后的延迟加载器,在 AST 层面就是一个普通的 PropertyFetch 节点。PHPStan 不会自己知道 $user->posts 是个要命的 N+1 陷阱,它只会觉得“哦,你读了个属性,类型是 Collection,挺好的”。所以我们必须亲手写一条规则,告诉它:“看到这玩意儿时,给我往上翻三层,看看有没有 with() 或 load() 在撑腰。”
规则骨架
核心逻辑不复杂。继承 PHPStan 的 Rule 接口,实现 getNodeType() 和 processNode() 这两个方法。getNodeType() 返回你要拦截的节点类型——这里就是 \PhpParser\Node\Expr\PropertyFetch::class。每次 PHPStan 解析到类似 $model->relationName 的表达式时,就会把控制权交给你的 processNode()。
public function getNodeType(): string
{
return \PhpParser\Node\Expr\PropertyFetch::class;
}
public function processNode(Node $node, Scope $scope): array
{
// 1. 确认读取属性的是 Eloquent Model 实例
// 2. 确认这个属性名是一个已定义的关系方法
// 3. 检查当前作用域内是否有显式预加载指令
// 4. 若没有,报错
}
三步走。第一步靠 $scope->getType($node->var) 拿到变量类型,然后判断它是否继承自 Illuminate\Database\Eloquent\Model。第二步要麻烦一点——你得拿到模型的类名,然后通过反射看看这个属性对应的方法是否存在,并且方法返回的是 Relation 子类的实例。这一块我没用黑魔法,直接调了 Laravel 自身的关系解析器,因为 PHPStan 的 stub 文件里已经定义了这些类型。
核心难点在于第三步。PHPStan 的 $scope 给了你当前节点的语法树位置,但你要的不是“这个属性在哪个文件第几行”,而是“在这个调用栈里,父级作用域中是否有 with() 或 load() 的痕迹”。我最初的做法是遍历父节点,直到遇到方法调用节点,检查调用名是不是 with、load、loadMissing 之一。但很快发现一个问题——with() 经常写在模型的查询构造器链上,比如 User::with('posts')->get(),这时候父节点根本不是方法调用,而是链式调用的中间节点。
换了个思路。利用 $scope->getAssignments() 回溯变量赋值的历史,看这个变量在到达当前语句之前,是否被赋值为某个带了 with() 调用的查询结果。听起来复杂,但 PHPStan 的类型系统其实已经帮你做了这件事——它知道 $users = User::with('posts')->get() 返回的是一个 Collection,并且这个 Collection 的泛型参数里带了“已加载关系”的元信息。问题在于,这个元信息默认没有暴露给自定义规则。
$type = $scope->getType($node->var);
if ($type instanceof EloquentCollectionType) {
$loadedRelations = $type->getLoadedRelations();
if (!in_array($node->name->toString(), $loadedRelations, true)) {
return [RuleErrorBuilder::message(
"Property '{$node->name}' is an unloaded relation. Potential N+1 query."
)->build()];
}
}
闭包和集合遍历:藏得最深的坑
如果只是检测裸奔的 $user->posts,那这条规则写出来也就一上午的事。真正让人头疼的是闭包里的嵌套访问。比如 $users->each(function ($user) { echo $user->posts; }),这时候你的规则得能理解 each 的闭包参数是集合中的单个模型,而这个模型在闭包作用域里没有任何父级 with() 信息。
解决方案是分两步走:先判断当前 PropertyFetch 是否位于闭包内部;如果是,则查找这个闭包所绑定的集合变量,看它在闭包外部的调用链上有没有预加载。这就涉及到 Scope 的 getParentScope() 方法了——PHPStan 的闭包作用域是独立的,但你可以通过 $scope->getClosureBindings() 拿到绑定的 $this 和参数列表。配合 $scope->getFunction() 判断当前函数名是否为 each、map、filter 等集合方法,就能追溯到上游的加载状态。
if ($scope->isInClosure()) {
$closureBindings = $scope->getClosureBindings();
$collectionType = $closureBindings['bind'] ?? null;
if ($collectionType instanceof EloquentCollectionType) {
// 复用上面的预加载检查逻辑
}
}
这块我调试了整整一个下午。因为 Laravel 的集合闭包签名是 each(callable $callback),PHPStan 虽然能推断出 $callback 的参数类型,但 Scope 里对闭包上下文的追踪做得比较保守——它不会自动把集合的泛型参数“传递”给闭包内部的变量。你必须在规则里手动查一下这个闭包是哪个集合方法调用的,然后找到那个集合变量,再查它的类型参数。绕了一圈,但效果是实打实的。
规则写完后跑了一遍测试,光是在我们项目的一个 Service 类里就抓出了 7 处遗漏的 with()。其中两处藏在 map 闭包里,手工 review 根本看不出来——因为 $order->items 写在 map 的回调里,乍一看像是业务逻辑,没人会想到去追查这个集合源头有没有预加载。现在 CI 直接报红,逼着你不得不正视每一个关系访问。
配置与集成:让规则融入你的 CI 流程
规则写完只是起点。让它在团队里跑起来,得先过配置这关。phpstan.neon 里一行 registration 就能挂载自定义规则,但错误级别设太高会吵到人。
在 phpstan.neon 里安顿规则
别急着推高 errorLevel。先用 baseline 把老债标记出来。运行 ./vendor/bin/phpstan analyse --generate-baseline,生成的文件里藏着历史遗留的 N+1 访问。新代码再触发同类模式就该亮红灯。
rules:
- MyProject\Rules\EloquentNPlusOneRule: ~
includes:
- bootstrap/app.php
基线不是免死金牌
基线文件得常更新。每次重构模型关系,记得重新生成它。否则你会看到本应消失的旧告警赖着不走。Larastan 有时会卡在动态方法上,试试 @phpstan-ignore-line 临时跳过,但别滥用。
提交前自动拦一刀
Git 钩子加 PHPStan,能在 commit 前拦住漏网之鱼。Husky 配上 lint-staged,只扫改动文件。GitHub Actions 更狠——PR 创建时就跑分析,报告贴在评论里。
真实项目中的“战果”:一次审计发现了 42 处隐患
规则写完了、CI 也配上了,但心里一直犯嘀咕:这东西到底能抓出多少真东西?
于是找了个周末,我拿一个维护中的中等规模 Laravel 项目开了刀。项目大概 80 来个模型、200 多张迁移表,业务逻辑集中在订单、库存、用户通知这几块。不算巨型,但足够典型。
跑完 ./vendor/bin/phpstan analyse --level=max app/,等了几十秒,结果出来了——42 处 N+1 隐患。
说几个印象深的。
循环里藏着的 $order->user,没人觉得有问题
有一个报表导出方法,遍历订单集合,每个订单里取 $order->user->email。从代码上看,就是一个普通的 foreach,里面一行 $userEmail = $order->user->email。写这段的人大概率没想过要预加载——因为订单列表页也这么写的,一直没崩过。但数据量一上去,300 个订单就是 301 条查询。我们的规则直接标记了 $order->user 这个属性访问,因为它出现在 Collection 的 foreach 内部,并且 user 关系没有被预先加载。
修复很简单:在查询链上加 ->with('user')。但问题是没人知道这里有坑。除非你一行一行数 SQL log。
API 资源转换器里的“隐形炸弹”
另一个让我头皮发麻的,是 App\Http\Resources\OrderResource 的 toArray 方法里写了 $this->items->each(fn($item) => ...),然后闭包内部又调了 $item->product->name。这个资源是在控制器里直接 return new OrderResource($order) 返回的,没有手动预加载。因为 Laravel 的资源类在序列化时才会触发关系加载,所以每次响应都会额外产生 N 条查询。规则检测到了这个模式:资源类的 toArray 里访问了未预加载的关系,并且关系是在集合遍历内部触发的。这 7 处问题里,有 3 处藏在 collect 方法里,更隐蔽。
修复后的效果
改完之后,用 clockwork 对比了首页加载和几个核心 API 的查询数量:
- 首页仪表盘:从 187 条查询降到 23 条
- 订单列表 API:从 94 条降到 12 条
- 用户通知聚合页:从 211 条降到 31 条
响应时间降了大概 60% 左右。没精确测,因为网络波动挺大,但肉眼可见地从“等两秒”变成了“刷一下就出来”。
42 处隐患里,大约一半是新手写的,另一半是资深同事写的——这说明这不是技术能力问题,而是代码审查的盲区。手工 review 很难注意到 foreach 里的一个属性访问到底是不是关系调用,因为代码里到处都是 $something->related,你分不清哪些是 Eloquent 关系、哪些是普通属性。
现在这条规则跑在 CI 上,每次 PR 都会扫一遍。新代码里再出现 $order->user 没预加载的情况,直接报红。团队从最初的不适应,到现在的“哦,又忘了加 with”,大概花了两个 sprint。
规则之外:养成 N+1 免疫的编码习惯
规则确实能拦住大部分坑,可真正的安全感,是让“防 N+1”变成手指的条件反射——不用过脑,写代码时自然就避开了。
让 IDE 成为第一道防线
我在 PhpStorm 里装了 Laravel Idea 插件,它的 Eloquent 关系未加载提示非常直观:当你写下 $orders->map(fn($o) => $o->user->name) 却没 with('user') 时,编辑器会给出灰色波浪线与快速修复选项,一键插入 ->with('user')。对于 Blade,它也能在 $post->author->avatar 这样的链式访问上识别潜在懒加载,并在重构菜单里提供预加载动作。配合 PHPStan 自定义规则在 CI 上兜底,本地开发时就能少很多红色警报。
把关系写进模型文档块
团队约定在模型类 docblock 里标注 @property-read User $user、@property-read Collection|Item[] $items 等关系类型,既让 IDE 更好做跳转与重命名,也让静态分析器能把属性访问与关系方法区分开来。对于动态属性,我们还会补充简短注释,例如“通过 belongsTo 获取,默认未加载需显式 with”。这套做法不花哨,但在代码评审里很管用:打开模型就能看到它对外暴露的关系,不用再去 migration 或工厂里翻定义。
把查询日志当成日常体检
我们把 Laravel Debugbar 留在开发环境,生产侧则用 Clockwork 记录每次请求的查询栈。每当新增接口或改动资源序列化逻辑,我会先看一眼 queries 面板:是否有 orders 表连着 users、items、coupons 多条查询?是否在某个 each 回调里触发了额外查询?遇到可疑点,就把相关控制器与资源类的 toArray 过一遍,确认 with 是否缺失、是否该按条件预加载。这个小习惯比事后性能告警来得更稳也更快。
工具说到底只是把你已有的判断力放大。养成把预加载当成默认选项的习惯,剩下的那些重复性排查,交给自动化规则和同事群里随手甩的一张截图提醒——时间长了,系统自然就干净了。
评论