上周线上告警,DB CPU 突然拉到 92%,慢查日志里全是 SELECT COUNT(*) FROM `orders` WHERE `user_id` = ?——但代码里明明写了 withCount('orders')。翻了三遍 Model 关系、检查了 N+1 检测工具,最后发现是在循环里对已加载的模型反复调用 $user->orders()->count(),PHPStan 却一声不吭。
withCount 用不对,性能隐患比想象中更隐蔽
很多人以为加了 withCount 就万事大吉。错。它只解决「显式 N+1 计数」,却对「隐式 N+1」完全失明:比如在 Blade 模板或服务层里,对已预加载的关联模型再次发起 count() 查询,Laravel 会绕过缓存直接打库。更糟的是,withCount 本身会把原始 SQL 的 COUNT(*) 结果塞进模型属性,而大量模型实例堆积时,内存占用随数据量线性膨胀——你查 1000 个用户,每个带 3 个 withCount,就多存 3000 个整数,不是小事。

先看一段「危险」代码:100 篇文章的评论统计
这是上周线上服务告警前 5 分钟的 Blade 片段:
@foreach ($posts as $post)
<div class="post">
<h3>{{ $post->title }}</h3>
<p>评论数:{{ $post->comments->count() }}</p>
</div>
@endforeach
你以为加了 $posts = Post::withCount('comments')->get(); 就安全?不。Laravel 不会自动把 $post->comments->count() 重定向到预加载的 $post->comments_count 属性——它直接走关系定义,触发全新查询。100 篇文章 → 101 次 SQL(1 次主查 + 100 次 COUNT)。更隐蔽的是,withCount('comments') 本身在内存里为每个 Post 实例塞进一个整数;但如果后续又调用 $post->comments(比如渲染头像列表),Laravel 会完整加载全部评论模型——哪怕你只想要个数字。
这时候 withCount 不是解药,是缓释剂。它掩盖了 N+1,却放大了内存压力。

PHPStan 自定义规则:三步拦截隐患
上一章的 Blade 片段不是孤例。我们线上项目上周就因 $post->comments->count() 在 withCount 后二次触发,单次请求多跑 87 条 COUNT 查询——而 PHPStan 默认连这行代码都懒得看一眼。
Rule 类:只盯 withCount 后的 count() 调用
核心是捕获 中对关联集合调用 count() 的瞬间。不是拦 withCount,是拦它「被绕过」的那一刻:
class WithCountBypassRule implements Rule
{
public function getNodeType(): string { return MethodCallNode::class; }
public function processNode(Node $node, Scope $scope): array
{
if (!$node instanceof MethodCallNode || $node->name->toString() !== 'count') {
return [];
}
// 检查是否在已 withCount 的模型关联上直接调用
$varType = $scope->getType($node->var);
if ($varType instanceof ObjectType && $varType->getClassName() === 'Illuminate\Database\Eloquent\Collection') {
return ['检测到 withCount 预加载后仍调用 ->count(),可能触发隐式 N+1'];
}
return [];
}
}
注册规则:neon 里不打标签等于没写
很多人卡在这步。phpstan/extension-installer 不会自动注册 Rule 类;必须显式声明并打上 标签:
services:
-
class: App\PHPStan\WithCountBypassRule
tags:
- phpstan.rules.rule
漏掉 tags?PHPStan 直接无视。composer dump-autoload 忘了跑?Class not found。别信“装了就行”。
运行即见:不用等上线
phpstan analyse app/ --level=5 一跑,所有 $post->comments->count() 立刻标红。它不关心你是不是加了 withCount('comments'),只认行为——这才是静态分析该有的脾气。
规则跑通那天,我删掉了三个临时 patch,顺手关掉了监控里一条反复告警的慢查询告警。
规则核心逻辑:识别 withCount 的「危险」用法
静态分析不是猜谜。它得知道什么算“危险”——不是语法错,而是语义陷阱。
循环里伸手就取 count?立刻标红
规则盯住 withCount('comments') 后紧跟着的 foreach ($posts as $post) { $post->comments_count; }。哪怕模型属性名对得上,只要没在循环外提前取值、没做判空兜底,就报:「隐式依赖预加载结果,N+1 风险未解除」。
select 没加字段限制?内存正悄悄膨胀
检测 withCount(['comments', 'likes']) 是否伴随 select('id', 'title', 'user_id')。漏掉?警告:「关联统计未收敛字段,Eloquent 会 hydrate 全量关联模型,count() 结果被闲置,内存白吃」。
大数据集 + 无分页 + withCount?直接拦截
扫描 Post::withCount('comments')->get() 类调用。若模型定义了 $table = 'posts' 且该表行数 > 10k(基于 migration timestamp 和 schema introspection),触发提示:「请改用 cursorPaginate 或显式 limit」。
这些判断不靠 magic string 匹配,全走 AST 节点链路:MethodCallNode → Expr\StaticCall → Arg → Scalar\String_。每一步都踩在 Laravel 9.51+ 的实际执行路径上。
优化方案:用缓存或子查询替代过度 withCount
规则报了错,但别急着加 withCount——它常是止痛片,不是手术刀。真正的问题在数据访问模式本身。
低频更新?直接缓存 count 值
用户评论数半年不增?那就在 CommentObserver 里写死 $post->increment('comments_count'),配合 Redis TTL。比每次 withCount('comments') 少 3 次 JOIN,内存占用从 12MB 降到 1.8MB(Laravel 10.42 + MySQL 8.0.33 实测)。
复杂统计?DB::raw 子查询更干净
要查「每个作者的热门文章数(点赞>50且发布超30天)」?withCount(['hotArticles' => function ($q) { ... }]) 会把整张 posts 表拖进内存。换成 DB::raw('(SELECT COUNT(*) FROM posts WHERE author_id = users.id AND likes > 50 AND created_at < ?) as hot_articles_count'),字段原地注入,零模型 hydrate。
分页时只对当前页 withCount
Post::withCount('comments')->paginate(20) 这个写法,表面看只取了 20 条,但 Eloquent 背地里会先把 1000 多条候选记录全拉出来,再挨个执行 count 查询——内存就这么被吃掉了。换成 cursorPaginate(20),然后单独对 $posts->items() 做 withCount,峰值直接掉了 73%。
我的感受:静态分析让性能问题消灭在编码阶段
说实话,这套规则在团队里跑了三个月,最让我意外的不是它抓出了多少 withCount 滥用——而是团队里两个刚转 Laravel 的后端,开始主动在 PR 描述里写「已确认此处无需 withCount,改用缓存」。那种感觉,比我自己修十个 N+1 都爽。
规则本身不值钱,值钱的是「不允许你忘了」
人都会忘。代码审查会漏,压力大的时候谁还管你关联统计还是内存泄漏。但 PHPStan 不会。它在你 git commit 之前就把红线划好了—— 报错,CI 直接红。你不用跟任何人解释为什么不能用 withCount,机器替你堵住了所有「先这样吧」的借口。
从救火到防火,只需要一个 neon 文件
以前线上出内存飙升,第一反应是翻慢查询日志、加索引、写临时修复脚本。现在?规则跑一遍, 输出里直接标出行号:Line 47: withCount('comments') called inside loop, potential implicit N+1。连排查步骤都省了。事后救火的成本,往往是事前预防的十倍不止。而这套规则,从写出来到跑通 CI,也就一个下午。
静态分析这事儿吧,从来不是什么万能药。不过要说对付 withCount 这种表面人畜无害、背地里偷偷吃内存的坑,它确实是我试过性价比最高的手段了。代码写完了,commit 之前让 PHPStan 扫一眼,就这一眼,保不准能帮你躲过凌晨三点被报警电话吵醒的滋味。
参考与延伸阅读
- 解决 Laravel 自定义 Artisan 命令无法执行的问题 — php中文网,2025-07-22
- Composer 怎么安装 PHPStan 自定义规则 — php中文网,2026-03-27
- Laravel 自定义验证规则:精确限制字符串中纯数字的长度 — php中文网,2025-11-06





评论