ThinkPHP 关联预加载我写了 `with` 却 N+1 照跑:原来是闭包里那个 `where` 把外键条件给"顶"掉了
昨晚盯着接口响应时间发呆,明明写了 `with(['category'])`,APM 里还是一片 N+1 的锯齿。翻了两小时源码,问题居然出在闭包查询的写法上——这坑踩得实在憋屈,记录一下。
错误写法:闭包里顺手写的 `where` 覆盖了外键绑定
当时想顺便过滤下分类状态,随手这么写了:
// 错误:闭包里的 where 把关联外键条件冲掉了
$posts = Post::with(['category' => function($query) {
$query->where('status', 1); // 这里少了 whereField 保留外键
}])->select();
结果生成的 SQL 变成了这样:
SELECT * FROM `category` WHERE `status` = 1 -- 外键条件 `id` IN (...) 没了!
框架没法用 IN 批量查,只能退化成每条主记录单独发一条关联查询。`with` 形同虚设,N+1 完美复现。
正确写法:用 `whereField` 或手动把外键加回去
ThinkPHP 的关联闭包里,外键条件是通过 `whereIn` 自动注入的。一旦你在闭包里调用 `where`,默认会重置查询条件(看 `BaseQuery::where` 的实现,`$query->where()` 是全新条件组)。
正确姿势有两种:
// 方法一:用 whereField 追加条件,保留框架注入的外键
$posts = Post::with(['category' => function($query) {
$query->whereField('status', 1); // 追加而非覆盖
}])->select();
// 方法二:显式接收外键值,手动拼接(适合复杂场景)
$posts = Post::with(['category' => function($query) {
$query->where('status', 1)
->whereColumn('category.id', 'post.category_id'); // 自己把关系续上
}])->select();
改完再抓包,干净的单条 IN 查询回来了:
SELECT * FROM `category` WHERE `status` = 1 AND `id` IN (3,7,12,19,...)
延伸:另一个变种坑——`field` 里漏了外键字段
顺带提个兄弟坑。有人习惯闭包里指定 `field` 减少回包:
// 错:field 没包含关联外键,框架连不上
$query->field('name,status');
关联查询返回后,框架要靠外键字段做映射匹配。你把 `category_id` 或 `id` 从 field 里抠掉,数据能查回来,但主记录的 `category` 属性永远是 null。正确做法是外键必须留在 field 里,或者用 `field(true)` 全量。
一点感受
这问题难查在于:代码不报错、数据能出来、就是慢。APM 看像 N+1,但明明写了 `with`,第一反应肯定是"框架 bug"或者"缓存没清"。结果真相是闭包查询的 API 语义理解偏差——`where` 是重置,`whereField` 才是追加,文档里就一行小字,不踩一次根本记不住。
现在团队代码规范里加了一条:关联预加载的闭包必须显式写注释,说明条件类型。笨办法,但省命。

