سه راه حل دیگر برای حل مشکل N+1  و راه جلوگیری از بروز  مشکل N+1 در لاراول

سه راه حل دیگر برای حل مشکل N+1 و راه جلوگیری از بروز مشکل N+1 در لاراول

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

راه حل چهارم: رابطه های پویا با Subquery ها 

استفاده از subquery برای دریافت زمان آخرین ورود کاربر به نظر عالی می رسد، اما اگر بخواهیم اطلاعات بیشتری از آخرین ورود را داشته باشیم چه؟ برای مثال بخواهیم آدرس IP ورود را نمایش دهیم. چگونه باید این کار را انجام دهیم؟

یک گزینه این است که یک subquery دیگر ایجاد کنیم:

public function scopeWithLastLoginIpAddress($query)

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

به یقین این کار موثر خواهد بود. اما آیا بهتر نیست برای این اطلاعات از طریق یک مدل به login دسترسی داشته باشیم؟ 

اینجاست که وارد روابط پویا می شویم. با تعریف یک رابطه ی belongs-to به نام lastLogin شروع می کنیم. به طور معمول برای کار کردن یک رابطه ی belongs-to، جدول ما نیاز به یک ستون به عنوان کلید خارجی دارد که در مثال ما، این ستون، last_login_id در جدول users است. با این حال، از آن جا که ما در حال تلاش برای جلوگیری از تغییر ساختار و ذخیره ی داده ها در جدول users هستیم، در عوض از یک subquery برای انتخاب کلید خارجی استفاده خواهیم کرد. در حالی که Eloquent نمی داند این یک ستون واقعی نیست. کدهای مربوط به این پیاده سازی به صورت زیر خواهد بود. 

public function lastLogin()
{
    return $this->belongsTo(Login::class);
}

public function scopeWithLastLogin($query)
{
    $query->addSubSelect('last_login_id', Login::select('id')
        ->whereColumn('user_id', 'users.id')
        ->latest()
         ->take(1)
    )->with('lastLogin');
}

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

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

select
    "users".*,
    (
    select "id" from "logins"
        where "user_id" = "users"."id"
        order by "created_at" desc
        limit 1
    ) as "last_login_id"
from "users"

کوئری اول: این کوئری دقیقا مشابه کوئری ای بود که قبلا دیده بودیم، به استثنای انتخاب تاریخ آخرین ورود که به جای آن شناسه ی آخرین ورود را انتخاب کردیم.

SELECT * FROM `logins` WHERE `logins`.`id` in (3, 4, 5)

کوئری دوم: این کوئری را لاراول زمانی که ما با استفاده از with('lastLogin')، eager-load انجام دادیم، به طور اتوماتیک اجرا می کند. Subquery ما در واقع اجازه داد تا فقط آخرین ورود ها را برای هر کاربر انتخاب کنیم. 

 راه حل پنجم: رابطه ی پویای Lazy Loading

نکته ای که باید در این تکنیک در نظر داشته باشیم این است که نمی توانیم روابط پویا را lazy-load کنیم. به این دلیل که scope به صورت پیش فرض اضافه نمی شود. 

class User extends Model

{
    protected static function boot()
    {
        parent::boot();

        static::addGlobalScope(function ($query) {
            $query->withLastLogin();
        });
    }

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

در صورتی که به lazy-load علاقه داشته باشید می توانید global scope را به مدل کاربر اضافه کنید. البته بهتر است این کار را نکنید و روی تمام relationship های پویای خود در صورت نیاز از eager-load استفاده کنید.

بررسی استفاده از has-one 

در این بخش می خواهیم به بررسی استفاده از has-one بپردازیم. می خواهیم بدانیم آیا با یک رابطه ی has-one می توانیم از تمام مشکل ها جلوگیری کنیم یا خیر؟ جواب خیر است، بیایید بررسی کنیم:

ابتدا رابطه ی has-one را با کد زیر به مدل کاربر اضافه می کنیم:

public function lastLogin(){    return $this->hasOne(Login::class)->latest();}

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

SELECT * FROM `logins` WHERE `logins`.`user_id` in (1, 2, 3, 4) ORDER BY `created_at` DESC

در واقع login های کاربر با user_id به صورت eager-load به دست می آید، اما هیچ فیلتر یا محدودیتی وجود ندارد. یعنی این که فقط آخرین login را بارگیری نمی کند، بلکه هر رکورد login را برای همه کاربرها، بارگیری می کند. پس ما دوباره به مشکل 12500 رکورد ورود به سیستم،که قبلا دیدیم بر می گردیم. 

حال یک محدودیت ایجاد می کنیم:

public function lastLogin()

{
    return $this->hasOne(Login::class)->latest()->take(1);
}

 به نظر می رسد کار می کند، کوئری اجرا شده را بررسی می کنیم:

select * from "logins"
where "logins"."user_id" in (1, 2, 3...99, 100)
order by "created_at" desclimit 1

لاراول، رابطه ها را با یک کوئری دیتابیس eager-load می کند، ولی ما یک محدودیت limit(1) را به کوئری اضافه کردیم. یعنی این که فقط یک رکورد برای کل کاربرها پس خواهیم گرفت. این رکورد ورود آخرین کاربری است که به سیستم وارد شده است. رابطه ی lastLogin برای کاربرهای دیگر، null  خواهد بود.  

چگونه می توان از مشکل N+1 در لاراول جلوگیری کرد؟

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

دانستن این که چگونه این مشکل را برطرف کنید بسیار عالی است. اما دانستن چگونگی جلوگیری از ورود آن ها به کد شما، بهتر است! 

به این منظور کتاب خانه های بسیاری وجود دارد که به شما کمک می کند هنگام توسعه ی برنامه ی وب خود، جستجوهای N+1 را در حین کار دریافت کنید. ابزارهای جالبی نیز در دسترس است: 

  • Database Machine، یک ORM برای PHP، مکانیزم eager loading را پیاده سازی می کند. 
  • laravel-query-detector، یک بسته laravel (در PHP)، N+1 نمایش داده شده را در محیط توسعه ی شما تشخیص می دهد. 

معرفی Laravel N+1 Query Detector

این Package را می توانید از طریق composer نصب کنید: 

بعد از نصب آن (برنامه شما در حالت اشکال زدایی یا debug باشد)، تنها کاری که باید انجام دهید مرور برنامه است. اگرN+1 Query Detector، رابطه ی مدل را که eager load نشده است، پیدا کند و بیش از یک بار فراخوانی شده باشد، یک هشدار به شما نشان می دهد و می گوید که چگونه آن را برطرف کنید.

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

این Package به شما کمک می کند تا با کاهش تعداد درخواست های اجرا شده، عملکرد برنامه ی خود را افزایش دهید. این بسته به صورت real-time، درخواست های شما را کنترل می کند، در حالی که شما برنامه ی خود را توسعه می دهید، به شما اطلاع می دهد که چه زمانی باید eager loading را اضافه کنید. 

جمع بندی 

اکنون که دانستید مشکل N+1 کوئری در لاراول چیست، چگونه آن را برطرف کرده و چگونه می توان آن را تشخیص داد، امیدواریم که دیگر با دست خالی از اتاق زیر شیروانی برنگردید! 

آیا از روش هایی که در این مقاله صحبت کردیم، استفاده کرده اید؟ شما برای حل مشکل 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 نکردی برامون ایمیلت رو بنویس: