سه راه حل برای رفع مشکل N+1  در لاراول با مثال

سه راه حل برای رفع مشکل N+1 در لاراول با مثال

چگونه می توان با یک پروژه لاراول که دارای مشکل N+1 کوئری است، برخورد کرد. 

لاراول روابط Eloquent را فراهم کرده است که به ما امکان می دهد از یک مدل به مدل های مرتبط، دسترسی پیدا كنیم. اما اگر مراقب نباشیم، دچار یک مشکل N+1 شدن، بسیار آسان است. به این معنی که، اگر هر بار به رابطه ای که eager load نشده بود، دسترسی پیدا کنیم، یک کوئری اضافی را اجرا کرده ایم. 

در مقاله اول، مروری بر مشکل N+1 داشتیم و در این جا قصد داریم شیوه ی بر طرف کردن آن را بررسی کنیم. 

چگونه مشکل N+1 در لاراول را برطرف کنیم؟

در زندگی واقعی و توسعه وب، به طور تقریبی، راه حل یکسان است: واکشی عناصر به شیوه ی دسته ای، در صورتی که نیاز باشد. از آن جایی که قبل از شروع پخت و پز، همه ی آن چه را که در خانه دارید، می توانید در یک دسته قرار دهید و یک جا آن ها را آماده کنید، پس اگر لازم شد در کد هم می توانید مدل های وابسته به هم را در یک زمان، بارگیری کنید. در نتیجه، برنامه شما همه ی مواردی را که نیاز دارید، به یک باره واکشی می کند. 

خوش بختانه، لاراول با Eloquent خود، ابزار eager loading را در اختیار ما قرار داده است تا ما بتوانیم به سادگی جدول مورد علاقه ی شما را بارگذاری کنیم.

در ادامه ی این بخش با بیان مثالی، به بررسی روش های حل مشکل جستجوی N+1 در لاراول، خواهیم پرداخت. 

طراحی یک مثال 

فرض کنید یک صفحه ی کاربران در برنامه ی خود داریم که برخی اطلاعات آن ها نمایش داده می شود، از جمله تاریخ آخرین ورود به برنامه. این صفحه ی به ظاهر ساده، پیچیدگی های جالبی دارد. 

در این برنامه ما در جدول logins زمان ورود به سیستم کاربر را ردیابی می کنیم. شمای جدول های users و logins به صورت زیر خواهد بود: 

Schema::create('users', function (Blueprint $table) {
    $table->increments('id');
    $table->string('name');
    $table->string('email');
    $table->timestamps();
});
Schema::create('logins', function (Blueprint $table) {
    $table->increments('id');
    $table->integer('user_id');
    $table->string('ip_address');
    $table->timestamp('created_at');
});

مدل های مربوط به آن ها نیز به همراه روابط شان آورده شده است: 

class User extends Model

{
    public function logins()
    {
        return $this->hasMany(Login::class);
    }

}
class Login extends Model

{
    public function user()
    {
        return $this->belongsTo(User::class);
    }
}

حالا به نظر شما چطور این جدول را ایجاد کنیم؟ چطور تاریخ آخرین ورود را نمایش دهیم؟ آسان ترین جواب در کد زیر است: 

@foreach ($users as $user)
    <tr>
        <td>{{ $user->name }}</td>
        <td>{{ $user->email }}</td>
        <td>
            @if ($lastLogin = $user->logins()->latest()->first())
                {{ $lastLogin->created_at->format('M j, Y \a\t g:i a') }}
            @else
                Never
            @endif
        </td>
    </tr>
@endforeach

بسیار خوب. در حال حاضر ما یک مشکل N+1 به وجود آوردیم. به ازای نمایش هر کاربر یک کوئری اضافه برای دریافت زمان آخرین ورود، اجرا می شود. اگر صفحه ی ما 50 کاربر را نمایش دهد در مجموع 51 کوئری اجرا خواهد شد. 

راه حل اول: Eager Loading

Eager load فرایندی است که به موجب آن یک کوئری برای یک نوع موجودیت، نهادهای مرتبط را نیز به عنوان بخشی از درخواست بارگیری می کند. ما می توانیم در لاراول، داده های مدل مربوط به آن را با استفاده از with(…)، بارگیری کنیم.

لاراول مشکل N+1 را با استفاده از رابطه ها حل می کند. Eager Loading در لاراول واقعا آسان است. تمام کاری که شما باید انجام دهید این است که به مدل خود بگویید تا یک رابطه ی خاص را بارگذاری کند. برای این منظور تمام رکوردهای زمان ورود را با eager loading دریافت می کنیم. 

@foreach ($users as $user)
    <tr>
        <td>{{ $user->name }}</td>
        <td>{{ $user->email }}</td>
        <td>
            @if ($user->logins->isNotEmpty())
                {{ $user->logins->sortByDesc('created_at')->first()->created_at->format('M j, Y \a\t g:i a') }}
            @else
                Never
            @endif
        </td>
    </tr>
@endforeach

 این راه حل فقط نیاز به دو کوئری دیتابیس دارد. یکی برای دریافت اطلاعات users و دیگری برای دریافت اطلاعات logins است. به نظر می رسد به موفقیت رسیدیم.

اما این همه ی ماجرا نیست. اینجاست که استفاده ی بیش از حد از حافظه، به مشکل تبدیل می شود. درست است که ما از مشکل N+1 پرهیز کردیم اما در واقع یک مشکل بزرگ تر برای حافظه ایجاد کردیم. فرض کنید که 50 کاربر داشته باشیم و هر کدام 250 بار وارد شده باشند. ما برای نمایش زمان آخرین ورود برای هر کاربر، 12500 رکورد ورود، بارگذاری کردیم. این کار در واقع فقط حافظه را مصرف نمی کند، بلکه به محاسبات اضافی نیز احتیاج دارد، زیرا هر رکورد باید به عنوان یک Eloquent Model معرفی شود. 

البته این یک نمونه ی کوچک است. امکان دارد در موقعیت هایی قرار گیریم که نیاز شود میلیون ها رکورد بارگذاری کنیم.

توجه داشته باشید که Eager loading، راه حل مشکل جستجوی N+1 است، اما باید مطمئن شوید که هنگام حلقه زدن از راه یک شیء، کوئری های بی مورد اجرا نخواهند شد. 

راه حل دوم: Caching

شاید فکر کنید که cache کردن آخرین ورود، راه حل خوبی به نظر برسد. با اضافه کردن شناسه ی آخرین ورود از جدول logins به جدول users این کار را انجام می دهیم.

Schema::create('users', function (Blueprint $table) {
    $table->integer('last_login_id');
});

زمانی که کاربر به سیستم وارد می شود، یک رکورد جدید به جدول logins اضافه و کلید last_login_id در جدول users به روز رسانی می شود. می توانیم یک relationship با نام lastLogin در مدل user ایجاد کنیم و روی آن eager-load بزنیم. 

$users = User::with('lastLogin')->get();

این راه حل معتبریست اما می توانیم بهتر از این عمل کنیم. 

راه حل سوم: Subquery ها

یک روش دیگر برای حل مشکل جستجوی N+1 وجود دارد و آن استفاده از Subquery هاست. Subquery ها به ما اجازه می دهند تا ستون های اضافی را به طور مستقیم در کوئری های اصلی دیتابیس (در مثال ما، کوئری دریافت کاربران) select کنیم. لاراول، Subquery ها را با متد selectSub پشتیبانی می کند. در ابتدا پیاده سازی زیر را مشاهده کنید:

$lastLogin = Login::select('created_at')
    ->whereColumn('user_id', 'users.id')
    ->latest()
    ->limit(1)
    ->getQuery();
$users = User::select('users.*')
    ->selectSub($lastLogin, 'last_login_at')
    ->get();@foreach ($users as $user)
    <tr>
        <td>{{ $user->name }}</td>
        <td>{{ $user->email }}</td>
        <td>
            @if ($user->last_login_at)
     {{ Carbon\Carbon::parse($user->last_login_at)->format('M j, Y \a\t g:i a') }}
            @else
                Never
            @endif
        </td>
    </tr>
@endforeach

در این مثال ما در واقع هنوز یک relationship را load نکردیم. کاری که ما انجام دادیم در واقع استفاده از یک subquery برای گرفتن زمان آخرین ورود هرکاربر، به عنوان یک attribute است. بیایید یک نگاهی به کوئری دیتابیسی که اجرا شده است بیندازیم. 

SELECT 
    `users` .*,
    (
    SELECT `created_at` FROM `logins` 
        WHERE `user_id` = `users` . `id` 
        ORDER BY `created_at` DESC 
        LIMIT 1
    )as `last_login_at`
FROM `users`

استفاده از Subquery در این راه حل به ما اجازه می دهد که همه ی اطلاعاتی که برای کاربرانمان احتیاج داریم در یک کوئری دریافت کنیم. این تکنیک بردهای عملکردی زیادی را فراهم می کند، چرا که می توانیم هر دو بهینه سازی کوئری های دیتابیس و میزان استفاده از حافظه را به حداقل برسانیم. همچنین از استفاده از cache نیز خودداری کرده ایم. 

بهینه کردن Subquery با Macro

در این بخش قصد داریم تا با استفاده از Macro، Subquery را بهینه کنیم. برای این کار یک متد جدید به نام addSubSelect به query builder اضافه می کنیم. قطعه کد زیر را به AppServiceProvider اضافه می کنیم. 

use Illuminate\Database\Query\Builder;Builder::macro('addSubSelect', function ($column, $query) {
    if (is_null($this->columns)) {
        $this->select($this->from.'.*');
    }
    return $this->selectSub($query->limit(1), $column);
});

لازم است بدانید که به جای آن می توانیم از یک پکیج کوچک استفاده کنیم. با استفاده از دستور زیر می توانیم این پکیج را نصب کنیم:

سپس کد خود را به صورت زیر بازنویسی کنیم:

$users = User::addSubSelect('last_login_at', Login::select('created_at')
    ->whereColumn('user_id', 'users.id')
    ->latest()
)->get();

انتقال Subquery به یک Scope

قبل از اینکه قدم بعدی را برداریم بیایید subquery جدیدمان را به یک scope در مدل User منتقل کنیم:

public function scopeWithLastLoginDate($query)

{
    $query->addSubSelect('last_login_at', Login::select('created_at')
        ->whereColumn('user_id', 'users.id')
        ->latest()
    );
}
$users = User::withLastLoginDate()->get();

این کار، Controller ها را سبک تر می کند و همچنین امکان استفاده مجدد از این کوئری ها را فراهم می آورد. 

بسیار خوب، تا این جای کار سعی کردیم 3 راه حل برای حل مشکل جستجوی N+1 در لاراول را با شما در میان بگذاریم. در مقاله ی بعدی، به ادامه ی همین مطالب پرداخته و راه های دیگر رفع آن را بررسی خواهیم کرد. 

منابع 

 

https://medium.com/doctolib/understanding-and-fixing-n-1-query-30623109fe89

https://medium.com/swlh/solving-n-1-query-without-creating-memory-issue-in-laravel-d02d77c5fccc 

https://reinink.ca/articles/dynamic-relationships-in-laravel-using-subqueries

https://pociot.dev/1-finding-n1-queries-in-laravel#eager-loading

https://beyondco.de/docs/laravel-query-detector/installation

https://beyondco.de/docs/laravel-query-detector/installation 

https://www.codecheef.org/article/laravel-and-n-1-problem-how-to-fix-n1-problem

https://www.sitepoint.com/silver-bullet-n1-problem/

نظرات
اگر login نکردی برامون ایمیلت رو بنویس: