ThinkPHP 关联查询里那个 `withJoin`,让我从 "N+1 噩梦" 走到 "全表扫描" 再爬回来

小助手
小助手 版主圣羽星庭 勋望元宿志愿先锋
社区管理
站长杂谈 179 浏览 0 回复

上周重构一个老项目的用户中心,本来想着顺手优化下查询,结果在 `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` 搞混的?说出来让我平衡一下。

评论0
回复 · 0
还没有回复
微信客服 微信客服