ThinkPHP 关联查询里那个 `withJoin`,让我从 "N+1 噩梦" 走到 "全表扫描" 再爬回来
上周重构一个老项目的用户中心,本来想着顺手优化下查询,结果在 `withJoin` 和 `with` 之间来回横跳,性能曲线跟过山车似的。记录一下这段糗事,给同样迷糊的兄弟提个醒。
第一回合:N+1 的"经典复刻"
最开始代码长这样,看着人畜无害:
// 错误写法:以为用了关联就万事大吉
$userList = UserModel::select();
foreach ($userList as $user) {
// 每次循环都触发一次查询,100 个用户就是 101 条 SQL
echo $user->profile->phone;
}
本地就几条测试数据,丝滑得很。一上预发,接口直接 4 秒开外。监控一拉,SQL 执行了 200 多次——我当场就想给两个月前的自己一巴掌。这不就是教科书级的 N+1 么,居然还能栽。
第二回合:矫枉过正的"全表暴击"
赶紧改,想起文档里提过 `withJoin` 可以用 JOIN 一次性搞定,兴冲冲往上怼:
// 错误写法:withJoin 乱用,条件写错位置
$userList = UserModel::withJoin([
'profile' => function($query) {
// 致命:把过滤条件写在闭包里,但 withJoin 的闭包只控制字段
$query->where('status', 1);
}
])->select();
跑是跑起来了,速度确实快了,但数据明显不对。仔细一查,`profile` 表被 LEFT JOIN 之后,因为 `status=1` 的条件实际变成了 JOIN 的 ON 条件的一部分,导致很多用户直接消失了——他们的 profile 记录 status 是 0 或者 NULL。
更隐蔽的是,如果 `profile` 是一对多,这玩意儿还会自动给你去重或者聚合,跟 `with` 的预加载逻辑完全两码事。我当时盯着结果集数了半天人头,差点怀疑人生。
第三回合:摸清脾气后的"对症下药"
翻源码、啃文档、又跑了十几组对比,才算理清楚这俩的适用场景。现在我的写法分两种:
场景 A:一对一关联,且确实需要 JOIN 后的字段做筛选排序——用 `withJoin`,但条件位置要对:
// 正确写法:过滤条件放主查询 where,withJoin 只控制关联字段
$userList = UserModel::withJoin([
'profile' => ['phone', 'address'] // 只取需要的字段,别 *
])
->where('profile.status', 1) // 条件在这里!
->select();
场景 B:一对多,或者只是附带读取关联数据、不做筛选——老老实实 `with` 预加载:
// 正确写法:with 预加载,两条 SQL 解决问题
$userList = UserModel::with([
'orders' => function($query) {
$query->where('pay_status', 1)->limit(5); // 这里可以安心写条件
}
])->select();
几个血泪细节
1. `withJoin` 目前只支持一对一(或者说文档建议只用于一对一),强行一对多会踩聚合坑;
2. `withJoin` 的字段指定是数组格式 `['field1', 'field2']`,跟 `with` 里用 `field()` 不一样,混用会报错;
3. 用了 `withJoin` 后,关联数据是直接拍平在主模型上的,`$user->profile_phone` 这种访问方式要注意字段别名冲突;
4. 最坑的是 IDE 不会提示你写错,运行也不报错,就是数据 silently wrong,排查全靠瞪眼。
现在项目里我定了条土规矩:先写 `with`,性能不够再评估要不要换 `withJoin`,换之前必须跑 `debug('sql')` 对比执行计划。宁可多两行代码,也不想再半夜被告警吵醒。
你们有没有被 TP 的关联查询坑过别的姿势?比如 `bindAttr` 绑定覆盖、或者 `hasWhere` 和 `whereExists` 搞混的?说出来让我平衡一下。

