كيف تتجنب مشكل N+1 في Laravel باستخدام التحميل النّشط Eager Loading


عمر الوريكات

مقدمة

قبل أن ندخل في صلب الموضوع، دعني أطلعك على بعض مشاكل تصميم البرمجيات التي قد تواجهك. قبل بضعة أيام اشتكى أحد العملاء من بطء كبير جدًا في تحميل بعض صفحات موقعه. لذلك قرّرت أن أتفحّص تلك الصفحة وقد صدمني ما وجدته. فقد أظهر قسم الاستعلامات(query section) بأنّه تمّ تنفيذ أكثر من 16500 استعلام على تلك الصفحة لوحدها!
وجدت الشيفرة البرمجية المسبّبة لتلك المشكلة.

laravel3.png

لقد كانت ثلاث حلقات من نوع foreach تستعلم عن صفةattribute معينة وعن الصّفات المتعلقة بها. كانت هذه الحلقات تعمل بشكل جيّد حتى أصبح هناك ما يقارب من 5500 عُنصر في قاعدة البيانات. إليك ما كان يحصل:

$main_object = MainObject::all();

foreach($main_object as $object) {
    echo $object->some_property;

    foreach($object->related_object as $related) {
        echo $related->some_property;
        echo $related->another_property;
    }

    foreach($object->another_related as $another) {
        echo $another->some_property;
        echo $another->another_property;
    }
}

إذا كانت $main_object = MainObject::all(); تقوم بإرجاع 5500 نتيجة، فإنّ أوّل حلقة foreach سوف تُنفَّذ 5500 مرة وكذلك الحال بالنّسبة للحلقتين الثانية والثالثة. عند استعمال ما يُسمى بمخطّط الكائن العلائقيّة ORM ، يقوم أغلب المطورين بكتابة استعلامات قواعد بيانات غير فعّالة، ويُصعّب استخدام الـORM مُهمّة اكتشاف هذه الأخطاء. تُسمّى هذه المشكلة بمشكلة N+1 وأعتقد بأنّ المُطوّر السّابق لم ينتبه لذلك. ولنتفادى هذه المشكلة سنقوم باستعمال التحميل النّشط(eager loading).

ما هو التحميل النّشط(eager loading)؟

يمكنك القول بأنّ التّحميل النّشط هو طريقة لفعل كلّ شيء عند الطّلب. وهو أيضًا عكس التحميل الخامل(lazy loading) بحيث يتمّ تنفيذ المهام عند الحاجة إليها. يساعد التحميل النّشط على تفادي الوقوع في مشاكل الأداء. حتى تتضح الصورة بشكل أفضل تخيّل معي الحالة التّالية:

Figure1_stores_model.png

يوجد لدينا نموذج علاقة كائنيّة مُعزّزة enhanced entity-relationship model يحتوي على ثلاث وحدات/كائنات مترابطة. يمكننا قراءة هذا النموذج كالتالي: كل عضو(member) يمكنه أن يملك أكثر من متجر(store) ولكن المتجر الواحد ينتمي إلى عضو واحد فقط. كل متجر يمكن أن يحتوي على أكثر من مُنتَج(product) ولكن المنتَج الواحد ينتمي إلى متجر واحد فقط.
الخطوة التّالية هي إنشاء نماذج eloquent لهذه الوحدات/الكائنات.
عضو(member):

<?php namespace App;

use Illuminate\Database\Eloquent\Model;

class Member extends Model {

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = ['username', 'email', 'first_name', 'last_name'];

    public function stores() {
        return $this->hasMany('App\\Store');
    }
}

متجر(store):

<?php namespace App;

use Illuminate\Database\Eloquent\Model;

class Store extends Model {

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = ['name', 'slug', 'site', 'member_id'];

    public function  member() {
        return $this->belongsTo('App\\Member');
    }

    public function products() {
        return $this->hasMany('App\\Product');
    }
}

مُنتَج(product):

<?php namespace App;

use Illuminate\Database\Eloquent\Model;

class Product extends Model {

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = ['name', 'short_desc', 'long_desc', 'price', 'store_id', 'member_id'];

    public function  store() {
        return $this->belongsTo('App\\Store');
    }
}

تخيّل بأنّك تقوم ببناء تطبيق ما وأنّ هذا التطبيق يسمح للمستخدمين بإنشاء متجر خاص بهم. بكل تأكيد وكما هو الحال في جميع المتاجر، فسيكون لدى المستخدمين القدرة على إنشاء أكثر من مُنتج. وما نريده أيضًا هو إنشاء صفحة لعرض جميع المتاجر وأفضل المنتجات الموجودة في كلّ متجر. شيء يشبه التالي:

Figure2_storelist_mockup.png

قد ينتهي بك المطاف بشيء كهذا في المتحكم(controller) الخاص بك:

<?php namespace App\Http\Controllers;

use App\Repositories\StoreRepository;

class StoresController extends Controller {

    protected $stores;

    function __construct(StoreRepository $stores) {
        $this->stores = $stores;
    }


    public function index() {
        $stores = $this->stores->all();

        return \View::make('stores.index')->with('stores', $stores);
    }
}

في الـView تريد أن تظهر تلك البيانات:

@foreach($stores as $store)
    <h1>{{ $store->name }}</h1>
    <span>Owner: {{ $store->member->first_name . ' ' . $store->member->last_name }}</span><br>

    <h2>Products:</h2>
    @foreach($store->products as $product)
        <h3>{{$product->name}}</h3>
        <span>{{$product->short_desc}}</span><br/><br/>
        <span>Price: {{$product->price}}</span>
        <br/>
        <?php Debugbar::info('Product displayed'); ?>
    @endforeach
    <br/>
    ========================
    <br/>
@endforeach

وتكون النتيجة كالتالي:

Figure3_n_plus_one_problem.png

في هذا المثال، قُمت بإعطاء قاعدة البيانات 5 أعضاء و3 متاجر و4 منتجات. الاستعلام الأول هو لجلب جميع المتاجر من قاعدة البيانات وهذا هو الجزء “+1” من المشكلة N+1. وفي هذا المثال تحديدًا، فإنّ الحرف “N” يُمثّل عدد المتاجر التي سيقوم الاستعلام الأول بإرجاعها، وسيتم تنفيذ استعلام select *from بنفس العدد على جداول المنتجات والأعضاء. ولأنّ هناك 3 متاجر، فهذا يعني بأنّنا سنقوم بإجراء 3 استعلامات على جدول الأعضاء و3 استعلامات أخرى على جدول المنتجات، وبالتالي يصبح عدد الاستعلامات التي تمّ تنفيذها بالكامل هو 3+3+1.
تخيّل معي الآن ماذا كان سيحصل لو كان هناك 5 آلاف أو 10 آلاف متجر؟ سيكون هناك من 10 آلاف إلى 20 ألف استعلام في كلّ مرّة يزور أحدهم تلك الصفحة. وماذا لو كان هناك 10 آلاف أو 100 ألف زائر خلال 24 ساعة فقط؟ سيكون ذلك بمثابة كابوسٍ مرعبٍ بلا شك. يظهر الآن وبكلّ وضوح بأنّ هذه الطّريقة غير مجدية وقاتلة للأداء(performance killer). لن يهم أي قاعدة بيانات تستخدم أو قوّة الخادوم فستكون هناك دائمًا نقطة ما لن يستطيع العتاد القوي التعامل معها. يمكنك تحسين الأداء عن طريق التخزين المؤقت(caching) للاستعلامات؛ باستخدام Redis على سبيل المثال. سيعمل ذلك جيّدًا ولكن بشكل مؤقّت. ستقوم بتلك الطّريقة فقط بتأخير النّهاية الحتميّة التي ستكلّفك الكثير من المال والوقت وقد تخسر أيضًا الكثير من المستخدمين.
وهنا يأتي دور التّحميل النّشط. فاستعمال التحميل النّشط في Laravel يُعدّ أمرًا في غاية السهولة. فعن طريق استخدام تابع with تقوم بتحديد العلاقات التي تريد أن يتمّ تحميلها تحميلاً نشطًا:

$stores = Store::with('member','products')->get();

باستعمال التحميل النّشط، قمنا بتقليص عدد الاستعلامات من 7 إلى 3 فقط، كما توضح الصورة التالية:

Figure4_n_plus_one_problem_eager.png

وحتى لو كان هناك 10 آلاف مُدخل للمتجر، فإنّ عدد الاستعلامات لن يتغير وسيبقى 3 فقط. وكما ترى، فإنّ استخدام التّحميل النّشط بالشّكل المناسب قد يُحسّن من الأداء بصورة كبيرة. وحتى نرى تحسّنًا فعليًا، فإنّه يجب أيضًا أن يكون هناك مؤشّر/فهرس(index) على حقول المُعرّفات(id fields) في جداول الأعضاء والمنتجات. فمع وجود الكثير من السجلات(records) فإنّ تنفيذ in( ‘1’, ‘2’, … ) على حقول غير مفهرسة(non-indexed) قد يأخذ بعض الوقت.

بعد هذه المقدمة السّريعة عن التحميل النّشط، لنرى كيف يمكننا استعمال العلاقات(relations) مع المستودعات(repositories).

تمديد فئة المستودع

سأريك الآن طريقة واحدة لكيفية استعمال العلاقات (relations) في فئات المستودع. هذا مثال لنتيجة نهائية:

function __construct(StoreRepository $stores) {
    $this->stores = $stores;
}


public function index() {
    $stores = $this->stores->with('member', 'products')->all();

    ....
}

فكما ترى في الأعلى، هناك تابع with ليمكننا من سلسلة نموذج العلاقات. هذا التابع سيكون شبيها بالتّابع with الخاصة بمُنشئ الاستعلامات في Laravel(Laravel’s Query Builder).

public function with($relations) {
    if (is_string($relations)) $relations = func_get_args();

    $this->with = $relations;

    return $this;
}

نحتاج الآن لربط كل علاقة(relation) إلى النموذج:

protected function eagerLoadRelations() {
    if(!is_null($this->with)) {
        foreach ($this->with as $relation) {
            $this->model->with($relation);
        }
    }

    return $this;
}

ما تبّقى علينا الآن هو تحديث تابع المستودع all() (وأي توابع أخرى تريدها) لتستخدم التحميل النّشط:

public function all($columns = array('*')) {
    $this->applyCriteria();
    $this->newQuery()->eagerLoadRelations();
    return $this->model->get($columns);
}

وكما ذكرت مسبقًا، يمكنك إضافة علاقات متعدّدة داخل وسيلة with(). وهذا مثال على StoresController:

<?php namespace App\Http\Controllers;

use App\Repositories\StoreRepository;

class StoresController extends Controller {

    protected $stores;

    function __construct(StoreRepository $stores) {
        $this->stores = $stores;
    }

    public function index() {
        $stores = $this->stores->with('member', 'products')->all();

        return \View::make('stores.index')->with('stores', $stores);
    }
}

يمكنك في الـView أن تعرض أيّ بيانات بالطريقة التي تريدها. لأغراض التّجريب سيكون التالي كافيًا:

@foreach($stores as $store)
    <h1>{{ $store->name }}</h1>
    <span>Owner: {{ $store->member->first_name . ' ' . $store->member->last_name }}</span><br>

    <h2>Products:</h2>
    @foreach($store->products as $product)
        <h3>{{$product->name}}</h3>
        <span>{{$product->short_desc}}</span><br/><br/>
        <span>Price: {{$product->price}}</span>
        <br/>
        <?php Debugbar::info('Product displayed'); ?>
    @endforeach
    <br/>
    ========================
    <br/>
@endforeach

يوجد الآن 3 استعلامات فقط كما هو متوقع:

Figure5_eager_loading.png

خاتمة

كما رأيت، يمكنك باستعمال التحميل النّشط أن تُحسّن من أداء التطبيق الخاص بك. ولكن ضع في الحسبان بأنّه عندما يكبر تطبيقك، فإنّ التحميل النّشط لوحده لن يكون كافيًا للحصول على أفضل النتائج.

ترجمة -وبتصرّف- للمقال Using Repository Pattern In Laravel 5 – Eloquent Relations And Eager Loading لصاحبه Mirza Pasic





تفاعل الأعضاء


لا توجد أيّة تعليقات بعد



يجب أن تكون عضوًا لدينا لتتمكّن من التعليق

انشاء حساب جديد

يستغرق التسجيل بضع ثوان فقط


سجّل حسابًا جديدًا

تسجيل الدخول

تملك حسابا مسجّلا بالفعل؟


سجّل دخولك الآن