Laravel 的 whereHas 表现不佳

Posted

技术标签:

【中文标题】Laravel 的 whereHas 表现不佳【英文标题】:Poor whereHas performance in Laravel 【发布时间】:2018-03-28 20:58:43 【问题描述】:

我想将where 条件应用于关系。这是我的工作:

Replay::whereHas('players', function ($query) 
    $query->where('battletag_name', 'test');
)->limit(100);

它会生成以下查询:

select * from `replays` 
where exists (
    select * from `players` 
    where `replays`.`id` = `players`.`replay_id` 
      and `battletag_name` = 'test') 
order by `id` asc 
limit 100;

在 70 秒内执行。如果我像这样手动重写查询:

select * from `replays` 
where id in (
    select replay_id from `players` 
    where `battletag_name` = 'test') 
order by `id` asc 
limit 100;

它在 0.4 秒内执行。如果where exists 这么慢,为什么它是默认行为?有没有办法使用查询生成器生成正确的where in 查询,还是我需要注入原始 SQL?也许我做错了什么?

replays 表有 4M 行,players 有 40M 行,所有相关列都被索引,数据集不适合 mysql 服务器内存。

更新:发现正确的查询可以生成为:

Replay::whereIn('id', function ($query) 
    $query->select('replay_id')->from('players')->where('battletag_name', 'test');
)->limit(100);

还有一个问题,为什么exists 表现如此糟糕,为什么它是默认行为

【问题讨论】:

我建议不要选择 * .. 尝试选择特定属性而不是全部。 我需要为我的案例选择所有这些。即使只选择id 列,查询性能也会提高不到 1%,因此可以忽略不计 ***.com/a/24932/916000 将帮助您了解差异。 【参考方案1】:

我认为性能不取决于 whereHas 仅取决于您选择了多少条记录

另外尝试优化你的mysql服务器

https://dev.mysql.com/doc/refman/5.7/en/optimize-overview.html

同时优化你的 php 服务器

如果你有更快的查询,为什么不使用来自 larval 的原始查询对象

$replay = DB::select('select * from replays where id in (
select replay_id from players where battletag_name = ?) 
order by id asc limit 100', ['test']
); 

【讨论】:

由于limit 子句,两个查询都恰好选择了 100 行。 whereHas 在 70 秒内完成,而 whereIn 在 0.4 秒内完成。优化与问题无关,因为它们会减少两个查询的执行时间。 那么也许你可以使用上面提到的原始查询 实际应用程序中的查询比有很多条件的查询要复杂得多,我真的需要查询构建器。从大量原始字符串部分构建它会将我的代码变成意大利面条。【参考方案2】:

这与mysql有关,与laravel无关。您可以使用 joinssubqueries 这两个选项执行上述操作。 子查询通常比连接慢得多。

子查询是:

不那么复杂 优雅 更容易理解 更容易编写 逻辑分离

以上事实就是为什么像 eloquent 这样的 ORM 使用 suquries。 但速度较慢!尤其是当数据库中有很多行时。

您的查询的加入版本是这样的:

select * from `replays`
join `players` on `replays`.`id` = `players`.`replay_id` 
and `battletag_name` = 'test'
order by `id` asc 
limit 100;

但是现在您必须更改 select 和 add group by 并在许多其他事情上小心,但为什么会这样,所以它超出了这个答案。新查询将是:

select replays.* from `replays`
join `players` on `replays`.`id` = `players`.`replay_id` 
and `battletag_name` = 'test'
order by `id` asc 
group by replays.id
limit 100;

这就是为什么加入更复杂的原因。

您可以在 laravel 中编写原始查询,但对连接查询的 eloquent 支持没有得到很好的支持,也没有多少包可以帮助您,例如:https://github.com/fico7489/laravel-eloquent-join

【讨论】:

我认为第一个查询如此慢的主要原因是索引到replay_id 字段,因为它只请求ID,并且在它使where has clausule 之后 可能是这样,但是 whereHas 确实比 join 慢得多...... 我真的同意whereHas() 比蜗牛慢,尤其是在您处理与中间表的关系时。如果您要处理大量记录,建议简单地使用联接。如果您担心您的代码会有非 eloquent 查询,您可以将这个查询封装到它自己的类中 + 使用 DB 查询生成器就可以了。【参考方案3】:

你可以使用左连接

$replies = Replay::orderBy('replays.id')
            ->leftJoin('players', function ($join) 
                $join->on('replays.id', '=', 'players.replay_id');
            )
            ->take(100)
            ->get();

【讨论】:

【参考方案4】:

试试这个:

mpyw/eloquent-has-by-non-dependent-subquery: Convert has() and whereHas() constraints to non-dependent subqueries. mpyw/eloquent-has-by-join: Convert has() and whereHas() constraints to join() ones for single-result relations.
Replay::hasByNonDependentSubquery('players', function ($query) 
    $query->where('battletag_name', 'test');
)->limit(100);

就是这样。祝你生活愉快!

【讨论】:

【参考方案5】:

whereHas 在没有索引的表上性能很差,把索引放在上面就开心了!

    Schema::table('category_product', function (Blueprint $table) 
        $table->index(['category_id', 'product_id']);
    );

【讨论】:

【参考方案6】:

WhereHas() 查询真的和懒惰的乌龟一样慢,所以我创建并仍在使用我粘合到任何需要简单连接请求的 laravel 模型的特征。这个特征产生了一个scope function whereJoin()。您可以只传递一个连接模型类名称,where 子句参数并享受。此特征负责查询中的表名和相关详细信息。好吧,它仅供我个人使用,并且可以随意修改这个怪物。

<?php
namespace App\Traits;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\JoinClause;

/** @mixin Model */
trait ModelJoinTrait

    /**
     * @param string|\Countable|array $on
     * @param $column
     * @param $whereOperator
     * @param $value
     * @param Model $exemplar
     * @return array
     */
    function _modelJoinTraitJoinPreset($on, $column, $whereOperator, $value, $exemplar)
        $foreignTable = $exemplar->getTable();
        $foreignId = $exemplar->getKeyName();

        $localTable = $this->getTable();
        $localId = $this->getKeyName();

        //set up default join and condition parameters
        $joinOn =[
            'local' => $localTable.'.'.$localId,
            'foreign'=> $foreignTable.'.'.$foreignId,
            'operator' => '=',
            'type'=>'inner',
            'alias'=>'_joint_id',
            'column'=>$column,
            'where_operator'=>$whereOperator,
            'value'=>$value
        ];

        //config join parameters based on input
        if(is_string($on))
            //if $on is string it treated as foreign key column name for join clause
            $joinOn['foreign'] = $foreignTable.'.'.$on;
         elseif (is_countable($on))
            //if $is array or collection there can be join parameters
            if(isset($on['local']) && $on['local'])
                $joinOn['local'] = $localTable.'.'.$on['local'];
            if(isset($on['foreign']) && $on['foreign'])
                $joinOn['foreign'] = $localTable.'.'.$on['foreign'];
            if(isset($on['operator']) && $on['operator'])
                $joinOn['operator'] = $on['operator'];
            if(isset($on['alias']) && $on['alias'])
                $joinOn['alias'] = $on['alias'];
        

        //define join type
        $joinTypeArray = ['inner', 'left', 'right', 'cross'];
        if(is_countable($on) && isset($on['type']) && in_array($on['type'], $joinTypeArray))
            $joinOn = $on['type'];
        return $joinOn;
    

    /**
     * @param Model $exemplar
     * @param string|array|\Countable $joinedColumns
     * @param string|array|\Countable $ownColumns
     * @param string $jointIdAlias
     * @return array
     */
    function _modelJoinTraitSetColumns($exemplar, $joinedColumns, $ownColumns, $jointIdAlias = '_joint_id')
    

        $foreignTable = $exemplar->getTable();
        $foreignId = $exemplar->getKeyName();

        $localTable = $this->getTable();
        $localId = $this->getKeyName();

        if(is_string($joinedColumns))
            $foreignColumn = ["$foreignTable.$joinedColumns"];
        else if(is_countable($joinedColumns)) 
            $foreignColumn = array_map(function ($el) use ($foreignTable) 
                return "$foreignTable.$el";
            , $joinedColumns);
         else 
            $foreignColumn = ["$foreignTable.*"];
        

        if(is_string($ownColumns))
            $ownColumns = ["$localTable.$ownColumns"];
        elseif(is_countable($ownColumns)) 
            $ownColumns = array_map(function ($el) use ($localTable) 
                return "$localTable.$el";
            , $ownColumns);
          else 
            $ownColumns = ["$localTable.*"];
        


        $columns = array_merge($foreignColumn, $ownColumns);
        if($foreignId == $localId)
            $columns = array_merge(["$foreignTable.$foreignId as $jointIdAlias"], $columns);
        
        return $columns;
    


    /**
     * @param Builder $query
     * @param string|array|\Countable $on
     * @param Model $exemplar
     */
    function _modelJoinTraitJoinPerform($query, $on, $exemplar)
        $funcTable = ['left'=>'leftJoin', 'right'=>'rightJoin', 'cross'=>'crossJoin', 'inner'=>'join'];
        $query->$funcTable[$on['type']]($exemplar->getTable(),
            function(JoinClause $join) use ($exemplar, $on)
                $this->_modelJoinTraitJoinCallback($join, $on);
            
        );
    
    function _modelJoinTraitJoinCallback(JoinClause $join, $on)
        $query = $this->_modelJoinTraitJoinOn($join, $on);

        $column = $on['column'];
        $operator = $on['where_operator'];
        $value = $on['value'];

        if(is_string($column))
            $query->where($column, $operator, $value);
        else if(is_callable($column))
            $query->where($column);
    
    /**
     * @param JoinClause $join
     * @param array|\Countable $on
     * @return JoinClause
     */
    function _modelJoinTraitJoinOn(JoinClause $join, $on)
        //execute join query on given parameters
        return $join->on($on['local'], $on['operator'], $on['foreign']);
    


    /**
     * A scope function used on Eloquent models for inner join of another model. After connecting trait in target class
     * just use it as ModelClass::query()->whereJoin(...). This query function forces a select() function with
     * parameters $joinedColumns and $ownColumns for preventing overwrite primary key on resulting model.
     * Columns of base and joined models with same name will be overwritten by base model
     *
     * @param Builder $query Query given by Eloquent mechanism. It's not exists in
     * ModelClass::query()->whereJoin(...) function.
     * @param string $class Fully-qualified class name of joined model. Should be descendant of
     * Illuminate\Database\Eloquent\Model class.
     * @param string|array|\Countable $on Parameter that have join parameters. If it is string, it should be foreign
     * key in $class model. If it's an array or Eloquent collection, it can have five elements: 'local' - local key
     * in base model, 'foreign' - foreign key in joined $class model (default values - names of respective primary keys),
     * 'operator' = comparison operator ('=' by default), 'type' - 'inner', 'left', 'right' and 'cross'
     * ('inner' by default) and 'alias' - alias for primary key from joined model if key name is same with key name in
     * base model (by default '_joint_id')
     * @param Closure|string $column Default Eloquent model::where(...) parameter that will be applied to joined model.
     * @param null $operator Default Eloquent model::where(...) parameter that will be applied to joined model.
     * @param null $value Default Eloquent model::where(...) parameter that will be applied to joined model.
     * @param string[] $joinedColumns Columns from joined model that will be joined to resulting model
     * @param string[] $ownColumns Columns from base model that will be included in resulting model
     * @return Builder
     * @throws \Exception
     */
    public function scopeWhereJoin($query, $class, $on, $column, $operator = null, $value=null,
                                   $joinedColumns=['*'], $ownColumns=['*'])

        //try to get a fake model of class to get table name and primary key name
        /** @var Model $exemplar */
        try 
            $exemplar = new $class;
         catch (\Exception $ex)
            throw new \Exception("Cannot take out data of '$class'");
        

        //preset join parameters and conditions
        $joinOnArray = $this->_modelJoinTraitJoinPreset($on, $column, $operator, $value, $exemplar);

        //set joined and base model columns
        $selectedColumns = $this->_modelJoinTraitSetColumns($exemplar, $joinedColumns, $ownColumns, $joinOnArray['alias']);
        $query->select($selectedColumns);

        //perform join with set parameters;
        $this->_modelJoinTraitJoinPerform($query, $joinOnArray, $exemplar);
        return $query;
    


您可以这样使用它(示例中的模型商品有一个专用的扩展数据模型 GoodsData 与它们之间的 hasOne 关系):

$q = Goods::query();

$q->whereJoin(GoodsData::class, 'goods_id', 
    function ($q)     //where clause callback
        $q->where('recommend', 1);
    
);

//same as previous exmple
$q->whereJoin(GoodsData::class, 'goods_id', 
    'recommend', 1);   //where clause params


// there we have sorted columns from GoodsData model
$q->whereJoin(GoodsData::class, 'goods_id', 
    'recommend', 1, null, //where clause params
    ['recommend', 'discount']); //selected columns

//and there - sorted columns from Goods model
$q->whereJoin(GoodsData::class, 'goods_id', 
    'recommend', '=', 1,                           //where clause params
    ['id', 'recommend'], ['id', 'name', 'price']); //selected columns from
                                                   //joined and base model

//a bit more complex example but still same. Table names is resolved 
//by trait from relevant models
$joinData = [
    'type'=>'inner'          //  inner join `goods_data` on
    'local'=>'id',           //      `goods`.`id`
    'operator'=>'='          //      =
    'foreign'=>'goods_id',   //      `goods_data`.`goods_id`
];
$q->whereJoin(GoodsData::class, $joinData, 
    'recommend', '=', 1,                           //where clause params
    ['id', 'recommend'], ['id', 'name', 'price']); //selected columns

return $q->get();

生成的 SQL 查询将是这样的

select 
    `goods_data`.`id` as `_joint_id`, `goods_data`.`id`, `goods_data`.`recommend`, 
    `goods`.`id`, `goods`.`name`, `goods`.`price` from `goods` 
inner join 
    `goods_data` 
on 
    `goods`.`id` = `goods_data`.`goods_id` 
and
    -- If callback used then this block will be a nested where clause 
    -- enclosed in parenthesis
    (`recommend` = ? )
    -- If used scalar parameters result will be like this
    `recommend` = ? 
    -- so if you have complex queries use a callback for convenience

你的情况应该是这样的

$q = Replay::query();
$q->whereJoin(Player::class, 'replay_id', 'battletag_name', 'test');
//or
$q->whereJoin(Player::class, 'replay_id', 
    function ($q)     
        $q->where('battletag_name', 'test');
    
);
$q->limit(100);

为了更有效地使用它,你可以这样:

// Goods.php
class Goods extends Model 
    use ModelJoinTrait;
    // 
    public function scopeWhereData($query, $column, $operator = null, 
        $value = null, $joinedColumns = ['*'], $ownColumns = ['*'])
    
        return $query->whereJoin(
            GoodsData::class, 'goods_id', 
            $column, $operator, $value, 
            $joinedColumns, $ownColumns);
    


// -------
// any.php

$query = Goods::whereData('goods_data_column', 1)->get();

PS 我没有为此运行任何自动化测试,所以使用时要小心。在我的情况下它工作得很好,但在你的情况下可能会有意想不到的行为。

【讨论】:

【参考方案7】:

laravel has(whereHas) 有时慢的原因是用 where exists 语法实现的。

例如:

// User hasMany Post
Users::has('posts')->get();
// Sql: select * from `users` where exists (select * from `posts` where `users`.`id`=`posts`.`user_id`)

'exists'语法是循环到外部表,然后每次查询内部表(subQuery)。

但是当users表有大量数据时会出现性能问题,因为上面的sqlselect * from 'users' where exists...无法使用索引。

这里可以用where in代替where exists而不破坏结构。

// select * from `users` where exists (select * from `posts` where `users`.`id`=`posts`.`user_id`)
// =>
// select * from `users` where `id` in (select `posts`.`user_id` from `posts`)

这将大大提高性能!

我建议你试试这个包hasin,在上面的例子中,你可以使用hasin而不是has

// User hasMany Post
Users::hasin('posts')->get();
// Sql: select * from `users` where `id` in (select `posts`.`user_id` from `posts`)

hasin 与框架 has 相比,只是使用了 where in 语法而不是 where exists,但其他地方都相同,例如 参数调用方式连代码实现,都可以放心使用。

【讨论】:

以上是关于Laravel 的 whereHas 表现不佳的主要内容,如果未能解决你的问题,请参考以下文章

Laravel - whereHas 查询没有返回正确的记录

在 Laravel 5 中合并 'with' 和 'whereHas'

Laravel - 使用“whereHas”获取嵌套关系

Laravel:具有 whereHas 和多对多关系的全局范围

Laravel Eloquent multiple whereHas on relationship where column 'order' 高于前一个 whereHas

Laravel 多个 whereHas 关系标准