انتقال للمقال

مشكلة N+1 وحلها باستخدام Eager Loading

السلام عليكم ورحمة الله وبركاته

وقت القراءة: ≈ 10 دقائق

المقدمة

اليوم سنتحدث عن مشكلة شائعة في قواعد البيانات تعرف بمشكلة الـ N+1
وسنعرف ما هي وكيفية حلها باستخدام تقنيات مثل الـ Eager Loading
سأستخدم Laravel و Eloquent ORM كمثال عملي لتوضيح هذه المشكلة وكيفية التعامل معها
ولكن بالطبع يمكنك تنفيذ نفس الحلول باقي اللغات وليس فقط Laravel

ولا تقلق فأنا في كل مرة اقوم بكتابة كود عملي كمثال في Laravel سأتبعه بشرح المشكلة والحل بمنظور الـ SQL
لتعرف وتفهم ماذا يحدث خلف الكواليس وكيف تكون شكل الـ Queries التى تنفذ
ويمكنك تطبيق الحل بلغة التى تريدها طالما فهمت وعرفت المشكلة والحل من منظر الـ SQL

مشكلة الـ N+1 قد تؤثر بشكل كبير على أداء تطبيقك ووقت تحميل الصفحات واحضار البيانات
وقد تجعل موقعك بطيئًا جدًا خاصة عندما تتعامل مع كميات كبيرة من البيانات

تجهيز بيئة الشرح

لنفترض أن لديك جدولين في قاعدة البيانات:

  • جدول خاص بالموظفين Employees
  • جدول خاص بالمهام الخاصة بكل موظف Tasks

وكل موظف يمكن أن يكون له عدة مهام أي أنها ربطة بسيطة بين جدولين بـ One-to-Many كل موظف يقابله عدة مهام

لنكتب الـ model الخاص بكل من الموظفين والمهام:

class Employee extends Model
{
    protected $fillable = ['name', 'email', 'position'];

    public function tasks()
    {
        return $this->hasMany(Task::class);
    }
}

class Task extends Model
{
    protected $fillable = ['title', 'description', 'employee_id'];

    public function employee()
    {
        return $this->belongsTo(Employee::class);
    }
}

هنا لدينا model للموظف Employee وبداخله ستجد دالة tasks
والتي تقوم بإرجاع جميع المهام المرتبطة بالموظف

ثم لدينا model للمهام Task وبداخله دالة employee التي تعيد الموظف المرتبط بهذه المهمة
هكذا نكون مثلنا One-to-Many بين الموظفين والمهام

ما هو الـ Lazy Loading

قبل أن نتحدث عن المشكلة، دعنا نرى أمثلة بسيطة على كيفية عمل Lazy Loading

لو أردنا احضار موظف واحد فقط، هل سأحصل على مهامه تلقائياً أيضاً ؟

بمعنى لو احضرنا employee واحد فقط من الـ Database بهذا الشكل:

$employee = Employee::find(1);

هل سيتم احضار الـ tasks الخاصة بالموظف ؟

الإجابة: لا، لن يتم احضار المهام

عندما تستخدم Employee::find(1) فأنت فقط تحضر بيانات الموظف من جدول employees
أما المهام فهي في جدول منفصل يسمى tasks ولن يتم احضارها إلا عندما تطلبها وتستدعيها بنفسك
حتى لو كان الـ employee يحتوى على relationship مع الـ tasks فلن يتم احضارها تلقائيًا

متى يتم احضار المهام ؟

إذا أردنا احضار مهام هذا الموظف، يجب أن نستدعيها بشكل منفصل:

$employee = Employee::find(1);
$tasks = $employee->tasks()->get(); // هنا فقط سيتم احضار المهام

هنا نقوم باحضار موظف واحد باستخدام Employee::find(1);
ثم نستدعي $employee->tasks()->get(); لاحضار مهامه

كم عدد الـ queries التي سوف تُنفذ ؟

SELECT * FROM employees WHERE id = 1;

SELECT * FROM tasks WHERE employee_id = 1;

كما ترى هنا لدينا query واحدة لاحضار الموظف
و query أخرى منفصلة لاحضار مهامه
هذا طبيعي ولا يوجد مشكلة هنا

سؤال آخر مهم: ماذا لو استدعينا دالة tasks عدة مرات ؟

$employee = Employee::find(1);
$tasks1 = $employee->tasks()->get();
$tasks2 = $employee->tasks()->get();
$tasks3 = $employee->tasks()->get();

ما رأيك ؟ كم query ستُنفذ هنا ؟

للأسف، كل استدعاء لدالة tasks() سيؤدي إلى تنفيذ query جديدة لاحضار المهام الخاصة بالموظف
لأن tasks() مع القوسين تعني أننا نريد إنشاء query جديد في كل مرة

SELECT * FROM employees WHERE id = 1;

SELECT * FROM tasks WHERE employee_id = 1;
SELECT * FROM tasks WHERE employee_id = 1;
SELECT * FROM tasks WHERE employee_id = 1;

فكما ترى هنا قمنا بتكرار نفس الـ query ثلاث مرات

كيف نحل هذه المشكلة ؟

هنا لحل هذه المشكلة لدينا مفهوم يدعى Lazy Loading
ويمكننا القول أن الـ Lazy Loading تعني احضار البيانات عند الحاجة فقط
أي لحظة ما المطور يريد البيانات يتم احضارها له ثم تخزينها لرجوع لها دون الحاجة إلى تنفيذ query مرة أخرى

دعنا نرى مثال توضيحي:

$employee = Employee::find(1);

$tasks1 = $employee->tasks; // Query -> Database -> Load data inside employee
$tasks2 = $employee->tasks; // No Query, Get the loaded data
$tasks3 = $employee->tasks; // No Query, Get the loaded data

لاحظ الفرق المهم هنا:

  • $employee->tasks() مع القوسين تعني أننا نقوم بعمل query جديدة في كل مرة
  • $employee->tasks بدون أقواس تعني أننا نقوم بعمل Lazy Loading

ماذا يحدث خلف الكواليس بالضبط ؟

عندما نستدعي $employee->tasks لأول مرة:

  1. تقوم Laravel بالبحث عن دالة تدعى tasks داخل الـ Model الذي يدعى Employee
  2. في حال وجودها تقوم Laravel بتنفيذ الـ query لاحضار المهام من الـ Database
  3. ثم يتم حفظ النتيجة داخل $employee

عندما نستدعي $employee->tasks مرة أخرى:

  1. تتحقق Laravel هل البيانات موجودة مسبقاً في الـ $employee
  2. لو كانت موجودة، تقوم بإرجاعها مباشرة دون تنفيذ أي query جديدة
  3. لو لم تكن موجودة، تقوم بتنفيذ الـ query لاحضارها وتعيد تخزينها داخل الـ $employee

كم عدد الـ queries في المثال السابق ؟

SELECT * FROM employees WHERE id = 1;

SELECT * FROM tasks WHERE employee_id = 1;

ستلاحظ أنه تم تنفيذ فقط query واحدة للمهام، رغم أننا استدعينا $employee->tasks ثلاث مرات
وهذا بفضل فكرة الـ Lazy Loading التي تخزن البيانات بعد أول استدعاء

لماذا يُسمى Lazy Loading ؟

أظنك استنتجت الأمر بالفعل، تم تسميته Lazy Loading لأنه يقوم فقط بإحضار البيانات عند الحاجة الفعلية لها بمعنى عندما قمت باستدعاء $employee->tasks لأول مرة فقط تم تنفيذ query لاحضار المهام
لكن لو لم تقم باستدعاء $employee->tasks من الأساس فلن يتم تنفيذ أي query لاحضار المهام

وهذا منطقي جدًا ومفيد للغاية، لأنه تخيل معي أن الـ Employee لديه أكثر من Relationship مع جداول أخرى
فمثلًا، إذا كان لدينا Relation مع Task واخرى مع Department و اخرى مع Profile

هل بمجرد تنفيذ Employee::find(1) سيتم احضار كل هذه البيانات المرتبطة به من جداول أخرى ؟ حتى ولو لم أطلبها أو احتاجها ؟
بالتالي من المنطقي ألا يتم احضار أي بيانات مرتبطة إلا عندما أطلبها وأحتاجها
بالتالي لا يتم تنفيذ أي query لإحضار الـ Tasks أو الـ Department أو الـ Profile إلا عندما أطلبها

تذكر أن استدعاء $employee->tasks() مع القوسين تعني أننا نريد إنشاء query جديد في كل مرة ولن يتم تخزين النتائج داخل $employee
أما استدعاء $employee->tasks بدون أقواس هنا سيتم تطبيق مفهوم الـ Lazy Loading وسيتم تخزين النتائج داخل $employee بعد أول استدعاء


في Laravel يمكنك أيضًا عمل Lazy Loading بشكل مسبق عن طريق استدعاء دالة تدعى load

$employee = Employee::find(1);

$employee->load('tasks'); // Query -> Database -> Load data inside employee

$tasks1 = $employee->tasks; // No Query, Get the loaded data
$tasks2 = $employee->tasks; // No Query, Get the loaded data
$tasks3 = $employee->tasks; // No Query, Get the loaded data

لاحظ وجود دالة تدعى load وكما يوحى اسمها تقوم بعمل Lazy Loading مسبقًا قبل أن تستدعي $employee->tasks
فكرة عمل load بشكل مسبق للبيانات تسمى Eager Loading وسنتحدث عنها لاحقًا وهو ما يهمنا في هذا المقال

ما هي مشكلة الـ N+1 ؟

مشكلة الـ N+1 هي مشكلة في الأداء تحدث عندما يقوم التطبيق بتنفيذ عدد كبير من الـ Query غير الضرورية والمكررة على الـ Database
لنعطي مثالًا توضيحيًا، الآن إذا أردت عرض قائمة بـ 5 موظفين مع المهام الخاصة بهم:

$employees = Employee::take(5)->get();

$tasksForEachEmployee = collect();
foreach ($employees as $employee) {
    $tasks = $employee->tasks()->get();
    $tasksForEachEmployee->push(['employee_id' => $employee->id, 'tasks' => $tasks]);
}
DisplayService::showTasks($tasksForEachEmployee);

لدينا كود بسيط لغرض الشرح لك أكثر بالطبع
هنا لدينا Employee::take(5)->get(); وهى query بسيطة تقوم باحضار بيانات 5 موظفين فقط
ولدينا متغير يدعى $tasksForEachEmployee وهو عبارة عن Collection سنستخدمه لتخزين المهام الخاصة بكل موظف ثم لدينا الـ foreach التي تمر على كل موظف
ومع كل موظف تمر عليه تقوم باستدعاء $employee->tasks()->get(); وهذا يعد أيضًا query
ثم نقوم بإضافة المهام الخاصة بكل موظف إلى المتغير $tasksForEachEmployee

ثم في النهاية نقوم بعرض المهام الخاصة بكل موظف باستخدام DisplayService::showTasks($tasksForEachEmployee); أو أي كان ما نريد تنفيذه

حسنًا أين المشكلة ؟

أولًا ستلاحظ أننا هنا Employee::take(5)->get(); نقوم باحضار بيانات 5 موظفين فقط وهذا يعد query واحدة بسيطة
ثم داخل الـ foreach نستدعى المهام الخاص بكل موظف $employee->tasks()->get(); وهذا يعد query منفصلة تذهب لاحضار البيانات من الـ database
بمعنى أن هذه الـ foreach ستنفذ 5 مرات بحسب عدد الموظفين وكل مرة ستنفذ query لاحضار المهام الخاصة بهذا الموظف

يمعنى لو تخيلنا كم query سيتم تنفيذها في النهاية:

SELECT * FROM employees LIMIT 5;

SELECT * FROM tasks WHERE employee_id = 1;
SELECT * FROM tasks WHERE employee_id = 2;
SELECT * FROM tasks WHERE employee_id = 3;
SELECT * FROM tasks WHERE employee_id = 4;
SELECT * FROM tasks WHERE employee_id = 5;

إذا نظرت لعدد الـ queries التى تم تنفيذها سترى ستلاحظ الآتي: لدينا query واحدة لاحضار الموظفين SELECT * FROM employees LIMIT 5;
ثم نقوم بتنفيذ query لكل موظف من الـ 5 موظفين أي query لكل موظف لاحضار المهام الخاصة به
ستلاحظ أن عدد الـ queries هو 6 (1 + 5)

ماذا لو أردنا احضار بيانات لـ 10 موظفين ؟

سيكون لدينا query واحدة لاحضار الموظفين
وثم نقوم بتنفيذ query لكل موظف من الـ 10 لاحضار المهام الخاصة بهم
ستلاحظ أن عدد الـ queries هو 11 (1 + 10)

بالتالي لو افترضنا أن لديك عدد N من الموظفين
فنحتاج إلى query واحدة لاحضار الموظفين و N من الـ query لاحضار المهام الخاصة بكل موظف
وبالتالي سيكون العدد الكلي للـ queries هو N + 1
وهذا هو سبب تسميتها بمشكلة الـ N+1

وهذه مشكلة شائعة ومشهورة خصوصًا عندما تتعامل ORM دون فهم ومعرفة كافية لما يجري وراء الكواليس
عليك أن تملك أساسيات وفهم جيد للـ SQL لتفهم ما يحدث خلف الكواليس
ولتعرف كيف تتعامل مع أي ORM بشكل صحيح وتعرف كيف تتجنب أي مشاكل قد تواجهها

حل مشكلة الـ N+1 باستخدام Eager Loading

قد تقول لي الموضوع بسيط فقط بدلًا من استخدم $employee->tasks()->get(); داخل الـ foreach استخدم $employee->tasks مباشرة
سأقول لك إن هذا لن يحدث فرقًا، هل تعرف لماذا ؟

لأنه برغم من استخدام $employee->tasks سيتم عمل Lazy Loading الذي سيقوم بتنفيذ query واحدة على الاقل
وطالما أننا داخل الـ foreach ونقوم بتنفيذ $employee->tasks، سيتم تنفيذ query لكل موظف بشكل منفصل
بالتالي لن يتغير شيء وسيظل لدينا نفس المشكلة
الـ Lazy Loading مفيد لمنع تكرار الـ queries على نفس الموظف لكننا هنا نتعامل مع عدة موظفين وكل موظف نقوم بعمل query منفصلة لاحضار مهامه

إذًا ما الحل ؟

حسنًا بعد أن فهمنا المشكلة، دعنا نتحدث عن الحل
الحل الأساسي والأكثر شيوعًا لمشكلة الـ N+1 هو استخدام الـ Eager Loading
وهو كما قلنا عمل Lazy Loading مسبقًا

كما فعلنا سابقًا واستدعينا load لاحضار المهام مع الموظف
نستطيع استدعاء load بعد احضار الموظفين مباشرة لاحضار المهام الخاصة بهم دفعة واحدة

$employees = Employee::take(5)->get();

$employees->load('tasks'); // Eager load tasks

$tasksForEachEmployee = collect();
foreach ($employees as $employee) {
    $tasks = $employee->tasks; // No Query, Get the loaded data
    $tasksForEachEmployee->push(['employee_id' => $employee->id, 'tasks' => $tasks]);
}
DisplayService::showTasks($tasksForEachEmployee);

هنا الفرق الوحيد هو إضافة $employees->load('tasks'); قبل الـ foreach وبعد احضار الموظفين
لكي نقوم بعمل Eager Loading لكل المهام الخاصة بالموظفين دفعة واحدة في query واحدة

الآن دعنا نرى ما هي الـ queries التي ستُنفذ:

SELECT * FROM employees LIMIT 5;

SELECT * FROM tasks WHERE employee_id IN (1, 2, 3, 4, 5);

لاحظ الجمال، لاحظ كيف تم عمل query واحدة فقط لاحضار مجموعة من المهام لعدة موظفين في آن واحد
عن طريق فكرة بسيطة وهى WHERE employee_id IN (1, 2, 3, 4, 5)
هكذا مهما كان عدد الموظفين، سيتم احضار جميع المهام الخاصة بهم في query واحدة فقط

لاحظ أننا هكذا قللنا عدد الـ queries من N+1 إلى 2 فقط

وبالطبع Laravel ذكية بما يكفي لأنها بعد ما تحضر المهام الخاصة بالموظفين تقوم بتخزين المهام داخل كل موظف
بالتالي كل موظف يكون بداخلخ المهام الخاصة به

على أي حالى دالة load تستخدم فقط لعمل Eager Loading للـ Collection أو للـ Resource بعد احضاره من قاعدة البيانات
لكن لو أردت عمل load للبيانات أثناء احضار بيانات الموظفين في آن واحد يمكنك استخدام with:

$employees = Employee::with('tasks')->take(5)->get();

هكذا قمنا باحضار الموظفين وعمل Eager Loading للمهام الخاصة بهم في نفس الوقت

بالطبع إن نظرنا إلى الـ queries التي تم تنفيذها:

SELECT * FROM employees LIMIT 5;

SELECT * FROM tasks WHERE employee_id IN (1, 2, 3, 4, 5);

ستجدها كما هى فقط تم تنفيذ query واحدة لاحضار الموظفين
و query واحدة لاحضار المهام الخاصة بهم دفعة واحدة

بالتالي دالة with تقوم بعمل Eager Loading عن طريق الـ ORM الخاص بـ Laravel
ودالة load تقوم بعمل Eager Loading للـ Collection أو للـ Resource بعد احضاره من قاعدة البيانات

وكلاهما يحلان مشكلة الـ N+1
تذكر اننا خفضنا عدد الـ queries من N+1 إلى 2 فقط مهما كان عدد الموظفين N

خاتمة

مشكلة الـ N+1 هى مشكلة شائعة تواجه المطورين عندما يتعاملون مع الـ Database
خصوصًا عندما يتعاملون مع وسيط ORM مثل Eloquent في Laravel

خصوصًا وللاسف معظم الأشخاص الذي يستخدمون ORM يعتمدون عليه بشكل أعمى ولا يلتفتون لكيف يقوم بتنفيذ الـ queries خلف الكواليس
لذا من المهم أن يكون الشخص حتى وإن كان يستخدم ORM أن يكون يملك قدر جيد من المعرفة والأساسيات الخاصة بالـ SQL

لكي يفهم الـ Queries التي يتم تنفيذها خلف الكواليس، وكيفية تأثيرها على أداء التطبيق
والمشاكل التى قد تحدث مثل مشكلة الـ N+1 وكيفية تجنبها وتحسين أداء التطبيق

في هذه المقالة القصيرة على غير عادتي، حاولت أن أبسط الموضوع وأشرحه بأبسط شكل ممكن

شرحنا ما هو الـ Lazy Loading وكيف يعمل في Laravel مع أمثلة
ثم تحدثنا عن مشكلة الـ N+1 وكيفية تأثيرها على أداء التطبيق
ثم رأينا شكل الـ Queries في الـ SQL التي يتم تنفيذها في حالة وجود مشكلة الـ N+1
ثم ناقشنا في الحل وهو استخدام الـ Eager Loading
وشرحنا الفرق بين الـ Lazy Loading و الـ Eager Loading

بحيث أن الـ Lazy Loading هو احضار البيانات عند الحاجة فقط
بينما الـ Eager Loading هو احضار البيانات المرتبطة مسبقًا
بالتالي يمكنك أن تتخيل أن الـ Eager Loading هو Lazy Loading لكن بشكل مسبق

على أي حال، أتمنى أن تكون قد استفدت من هذه المقالة