Poor whereHas performance in Laravel
Asked Answered
J

7

30

I want to apply a where condition to relation. Here's what I do:

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

It generates the following query:

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

Which executes in 70 seconds. If I manually rewrite query like this:

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

It executes in 0.4 seconds. Why where exists is the default behavior if it's so slow? Is there a way to generate the correct where in query with query builder or do I need to inject raw SQL? Maybe I'm doing something wrong altogether?

replays table has 4M rows, players has 40M rows, all relevant columns are indexed, dataset doesn't fit into MySQL server memory.

Update: found that the correct query can be generated as:

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

Still have a question why exists performs so poorly and why it is the default behavior

Jettiejettison answered 17/10, 2017 at 8:16 Comment(3)
I suggest dont SELECT * .. Try to SELECT specific attribute instead of all.Quantity
I need to select all of them for my case. And even selecting only id column improves query performance by less then 1% so it's negligibleJettiejettison
https://mcmap.net/q/73918/-difference-between-exists-and-in-in-sql will help you understand the difference.Rosariarosario
T
6

whereHas performance is poor on tables without index, put index on it and be happy!

    Schema::table('category_product', function (Blueprint $table) {
        $table->index(['category_id', 'product_id']);
    });
Turgeon answered 2/11, 2020 at 2:4 Comment(1)
This really helped me solve an issue which has been bugging me for days. I didn't realize that I need to add indexes for intermediate tables. Now my queries are fast!!Saarinen
H
4

Try this:

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

That's all. Happy Eloquent Life!

Hezekiah answered 28/6, 2020 at 22:8 Comment(1)
i think whereIn has limit right? for some cases, i tried on millions row, it produced very long queryChlortetracycline
H
4

The reason for laravel has(whereHas) sometimes slowly is that implemented with where exists syntax.

For example:

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

The 'exists' syntax is a loop to the external table, and then queries the internal table (subQuery) every time.

However, there will be performance problems when the users table has a large amount of data, because above sql select * from 'users' where exists... unable to use index.

It can use where in instead of where exists here without damaging the structure.

// 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`)

This will greatly improve performance!

I recommend you try this package hasin, in the above example, you can use the hasin instead of the has.

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

The hasin just only use where in syntax instead of where exists compared with the framework has, but everywhere else is the same, such as parameters and call mode even the code implementation, and can be used safely.

Harbert answered 27/2, 2021 at 3:54 Comment(2)
Thanks for hasin package, it helped me a lot !!Forensic
Please don't consider this answer as the absolute truth. A lot depends on the amount of rows in the subquery, the indexes,... More nuanced information here: #25756612Showplace
A
2

This is related to the mysql not to the laravel. You can perform the same thing you wanted from the above with the both options, joins and the subqueries. Subqueries are generally much slower than joins.

Subqueries are:

  • less complicated
  • elegant
  • easier to understand
  • easier to write
  • logic separation

and the above facts are why ORMs like eloquent are using suquries. but there are slower! Especially when you have many rows in the database.

Join version of your query is something like this :

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

but now you must change select and add group by and be careful on many other things, but why is this so it is beyond that answer. New query would be :

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;

So that are the reasons why join in more complicated.

You can write raw query in laravel, but eloquent support for join queries are not well supported, also there are no much packages that can help you with that, this one is for example : https://github.com/fico7489/laravel-eloquent-join

Alvinalvina answered 28/11, 2019 at 16:26 Comment(3)
I think that the main reason to the first query is so slower is by indexes to replay_id field, because it's requesting only the IDs and after it making the where has clausuleCarollcarolle
It could be, but whereHas can really be much slower than join....Alvinalvina
I really agree that whereHas() is slower than a snail especially if you're working on a relationship with intermediate table. It's advisable to simply use joins if you'll be dealing with a lot of records. If you're are worried that your code would have non-eloquent query, you can encapsulate this one query into its own class + use DB query builder then its okay.Chanteuse
T
2

WhereHas() query is really as slow as lazy turtle, so I created and still using a trait that I glue to any laravel model which required a simple join requests. This trait make a scope function whereJoin(). You can just pass there a joined model class name, where clause params and enjoy. This trait take care of table names and related details in query. Well, it's for my personal use and ofc feel free to modify this monstruosity.

<?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;
    }
}

You can use it like this (Model Goods in example have a dedicated extended data model GoodsData with hasOne relationship between them):

$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();

Resulting SQL query will be like this

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

In your case there should be like this

$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);

To use it more efficiently, you can go like this:

// 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 I dont run any automated tests for this so be careful in use. It works just fine in my case, but there may be unexpected behaviour in yours.

Tussore answered 18/12, 2020 at 14:13 Comment(0)
S
0

I think performance does not depend on whereHas only it depends on how many records you have selected

Plus try to optimize your mysql server

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

and also Optimize your php server

and if you have faster query why don't you use raw query object from 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']
); 
Sapro answered 17/10, 2017 at 8:41 Comment(3)
both queries select exactly 100 rows because of limit clause. whereHas does it in 70 seconds and whereIn in 0.4 seconds. Optimizations are irrelevant to the question because they will reduce both queries execution time.Jettiejettison
then maybe you can use raw query mentioned aboveSapro
query in the real application is way more complex than that with lots of conditions and I really need query builder for it. Building it from lots of raw string parts will turn my code into a spaghetti.Jettiejettison
P
0

You can use left join

$replies = Replay::orderBy('replays.id')
            ->leftJoin('players', function ($join) {
                $join->on('replays.id', '=', 'players.replay_id');
            })
            ->take(100)
            ->get();
Peekaboo answered 28/11, 2019 at 18:26 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.