给 Eloquent 的 whereHas 加个 where in 的优化
原文地址:www.jianshu.com/p/ec2a93755e10
用 Laravel 很久了,whereHas 简直是连表大杀器,本来需要写大量 SQL 的查询用 whereHas 都可以很快的实现。不过在一些场景里,遇到了严重的性能问题。
我们有个A表,大约是百万级数据,与之关联的有个B表,大约万级数据。在做关联查询的时候我们自然使用 A::whereHas('b', function(){...})
。
后来发现了许多慢查询,仔细一看发现,Laravel 的 whereHas
在生成 SQL 的时候会使用 select * from A where exists ( select * from b where ... )
。当我们的左表远远大于右表时,A 表就成了性能瓶颈。
最直接的方法当然是拆成两条 SQL,但是嫌麻烦,还得一条条优化。再加上我们很多 SQL 都是靠各种工具生成,所以改起来也挺麻烦。
于是就考虑加了个 whereHasIn
的方法,接口参数跟 whereHas
一致,只不过在生成 SQL 的时候会生成 select * from A where A.id in (select id from B)
。这样就不需要改什么 SQL 了,只要在调用 A::whereHas()
的地方加两个字符变成 A::whereHasIn()
就搞定了。在实际中,我们这条查询的耗时从几秒一下降低到了20毫秒。
下面是一个实现的 demo,暂时只支持 一对多的情况。如果大家有什么更好的想法,一起讨论讨论。
<?php
use Illuminate\Database\Eloquent\Relations;
abstract class AbstractModel
{
/**
* whereHas 的 where in 实现
*
* @param \Illuminate\Database\Eloquent\Builder $builder
* @param string $relationName
* @param callable $callable
* @return Builder
*
* @throws Exception
*/
public function scopeWhereHasIn($builder, $relationName, callable $callable)
{
$relationNames = explode('.', $relationName);
$nextRelation = implode('.', array_slice($relationNames, 1));
$method = $relationNames[0];
/** @var Relations\BelongsTo|Relations\HasOne $relation */
$relation = Relations\Relation::noConstraints(function () use ($method) {
return $this->$method();
});
/** @var Builder $in */
$in = $relation->getQuery()->whereHasIn($nextRelation, $callable);
if ($relation instanceof Relations\BelongsTo) {
return $builder->whereIn($relation->getForeignKey(), $in->select($relation->getOwnerKey()));
} elseif ($relation instanceof Relations\HasOne) {
return $builder->whereIn($this->getKeyName(), $in->select($relation->getForeignKeyName()));
}
throw new Exception(__METHOD__ . " 不支持 " . get_class($relation));
}
}
补充一:评论区 @overtrue 提供了用 macro 的方式,更优雅,支持场景更好。可以直接拿去用。感谢 overtrue!
补充二:社区大佬 @Jiangqh 已经封装成了扩展包,大家可直接拿去用 [扩展包] Laravel-wherehasin 提升 ORM 关联关系查询性能
补充三:帖子写了两年了,没想到还有这么多人在看在点赞。看来大家对这种小工具还是挺需要的,打算拉个群,如果你对各种奇技淫巧感兴趣,可以也一起玩。我的微信:Um1seVpVSnNZV05y(Base64Decode * 2)。还有个快死了的电报群:t.me/BetterCodeGroup
本作品采用《CC 协议》,转载必须注明作者和本文链接
高认可度评论:
可以考虑使用 macro 特性来增加该方法:
你可以将上面这段代码写到 AppServiceProvider 中
这样你就不需要修改原有的模型类了,也不需要创建 Model 基类。
这个原始的SQL语句还真没注意过
@rshu 都是坑出来的 ?
可以向官方提
PR
虽然知道有这个性能问题,但是一直没有去改造……感谢分享! :stuck_out_tongue_winking_eye: :+1:
报错了 SQLSTATE[42S22]: Column not found: 1054 Unknown column 'has_in' in 'where clause' (SQL: select * from
tags
wherehas_in
= tagsname)改成
join
会不会更好点?@linzi 看报错是 tag 没有加这个方法。这个函数我是加在 baseModel 里的。
@luffyzhao 这种场景下,性能都差不太多。对 laravel 封装的 join 用的不多,改的时候也没想太多。
@linzi 咦,这个 tagsname 是个 relation 么?
@huiren 是的 用whereHas可以 用whereHasIn 就报错啦
把这个方法Macro到Builder更好用觉得,不用到处添加trait,不过还没试试是否可行。
@ALMAS Laravel 提供了 scope 功能来做这个事,其实也相当于定制版的 macro 了 :joy:
@linzi 执行过程可以参考 \Illuminate\Database\Eloquent\Builder::__call ,优先找 scope 方法,找不到再找 Query。
所以这个错误应该是 scope 没命中上。
方便的话可以把更详细的错误私信给我
这个文件是添加到哪里呢 ,楼主能写详细点吗
@huiren
可以考虑使用 macro 特性来增加该方法:
你可以将上面这段代码写到 AppServiceProvider 中
这样你就不需要修改原有的模型类了,也不需要创建 Model 基类。
@linzi 参考我的回复
return Doctor::whereHas('user',function($query){
$query->select('id');
})->toSql();
直接 select id 会比你们那个快吗@huiren @overtrue
@overtrue 赞
@linzi 感觉上区别不大,这个问题的核心是 where exists 会扫左表。跟右表怎么处理关系不大。不过你可以试试。
没有看懂scopewhereHasIn这个函数,以及它要怎么调用?
@阳光的小mi 不好意思最近比较忙没空上来。
scope 是 eloquent 提供的一个类似于 plugin 的功能。能把方法集成到 builder 里。
直接 $query->whereHasIn(...) 就行了。
具体的 scope 的问题,可以详细看一下文档~
@huiren 感谢回复,问题已解决。
@阳光的小mi 请问你是怎么解决的,能把你的whereHasIn部分代码发出来吗?
@liyq 在你要调用的模型类里面添加下面这个scope方法,直接用就行
没有理解 $build这三个参数数在使用 whereHas();的时候传的 第二个参数是个字符串 指的是什么?
@overtrue $builder, $relationName, callable $callable 这三个值怎样传递 一直报参数缺少
@阳光的小mi Builder $builder, $relationName, callable $callable方法里的三个参数分别可以说明下吗,特别是第一个builder
{"errCode":40000,"errMsg":"App\Model\AssociateUser::scopeWhereHasIn 不支持 Illuminate\Database\Eloquent\Relations\BelongsTo"}
关于如何使用?
关于方法的参数说明?
@mingzheng
@阳光的小mi 感谢回复,确实是可以。目前还不支持belongsTo,hasOne的对应关联关系调用是吗·
@mingzheng 支持啊 哥 belongsTo hasOne这些类你要在头文件引用 要不你 instanceof不到就扔出异常啦
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasMany;
还有人吗,请教下注册macro方式如何使用,注册后跟whereHas一样调用时会报少参数
而把$builder传进去又会报另一个错

调用方式和注册方式

我写了一个trait,在模型里引入后这样调用的,报错会生成 一个不存在的字段 has_id = ? ,请问是这样调用的嘛?
大概搞定了,使用macro方式来扩展需要改动代码,调用方式也改变了,除了关系名与回调函数,还需要传query对象、model对象
macro注册方式

具体的实现代码
class WhereHasIn
{
protected $builder, $relationName, $callable;
// $in = $relation->getQuery()->whereHasIn($relation->getQuery(),$relation->getModel(),$nextRelation, $callable);
//调用闭包函数,并将关联模型的构造查询对象传入
call_user_func($callable,$in);
if ($relation instanceof Relations\BelongsTo) {
return $builder->whereIn($relation->getForeignKey(), $in->select($relation->getOwnerKey()));
} elseif ($relation instanceof Relations\HasOne) {
return $builder->whereIn($model->getKeyName(), $in->select($relation->getForeignKeyName()));
}
}
调用方式与whereHas一样
您好,拜读了您的《给 Eloquent 的 whereHas 加个 where in 的优化》的文章,对我很有启发,但是实现时出现一个问题。我有两张表,A 和 B,当我在 A 表的 Model 中加了 whereHasIn 方法后,使用 A::whereHasIn ('b',function (...)) 时,出现了 Column not found: 1054 Unknown column 'has_in' in 'where clause' 错误,此时 SQL 是,select * from A where b_id in (select id from B where has_in ) 。
然后我就在 B 表的 Model 中也加入了 whereHasIn 方法,此时错误是 Call to undefined method Illuminate\Database\Query\Builder,能请教一下是什么原因吗?或者您有什么 Demo 可供我参考,感谢百忙之后查看我的私信,期待您的回复!
这里错了吧, 发出来之前希望测试一下正确性
您好,写法暂时不支持belongsToMany吗?
我总结了一下:
A::whereHas('b', function(){...}, '>', 0) 将生成count比较 where (select count(*) from b where ... ) > 0