مقدمه
یکی از مشکلات رایج در پروژههای لاراول، اجرای تعداد زیادی کوئری دیتابیس برای انجام عملیاتی است که در ظاهر ساده به نظر میرسند.
برای مثال، فرض کنید در یک وبلاگ میخواهید فهرستی از ۱۰۰ مقاله را نمایش دهید و نام نویسنده هر مقاله نیز در کنار عنوان آن قرار بگیرد. ممکن است تصور کنید دریافت مقالات و نمایش نام نویسندگان کار پیچیدهای نیست؛ اما اگر رابطه بین مقاله و نویسنده به شکل مناسبی بارگذاری نشود، لاراول میتواند برای هر مقاله یک کوئری جداگانه جهت دریافت نویسنده اجرا کند.
در این حالت، بهجای یک کوئری برای دریافت مقالات و یک کوئری برای دریافت نویسندگان، ممکن است مجموعاً ۱۰۱ کوئری اجرا شود.
به این مشکل N+1 Query گفته میشود.
این مسئله در پروژههایی که با Eloquent ORM کار میکنند، اهمیت زیادی دارد؛ زیرا روابط مدلها استفاده از دادههای مرتبط را ساده میکنند، اما اگر نحوه بارگذاری این روابط بهدرستی مدیریت نشود، تعداد کوئریها بهشدت افزایش پیدا میکند.
در این مقاله یاد میگیریم:
- مشکل N+1 Query چیست و چگونه ایجاد میشود؟
- چطور آن را با یک مثال واقعی تشخیص دهیم؟
- چگونه با
with()وload()آن را برطرف کنیم؟ - چگونه روابط تو در تو را بارگذاری کنیم؟
- چگونه فقط ستونهای موردنیاز را دریافت کنیم؟
- چگونه تعداد کوئریها را بررسی کنیم؟
- چگونه از ایجاد مجدد این مشکل در آینده جلوگیری کنیم؟
مثالهای مقاله بر اساس Laravel 12 نوشته شدهاند.
۱. N+1 Query چیست؟
برای درک این مشکل، ابتدا باید با دو مفهوم آشنا شویم.
N تعداد رکوردهایی است که از دیتابیس دریافت میکنیم.
۱ به کوئری اولیهای اشاره دارد که برای دریافت مجموعه اصلی رکوردها اجرا میشود.
اگر برای هر رکورد نیز یک کوئری جداگانه جهت دریافت اطلاعات مرتبط اجرا شود، تعداد کل کوئریها به شکل زیر خواهد بود:
\[
\text{Total Queries} = 1 + N
\]
برای مثال، فرض کنید ۵۰ مقاله دریافت میکنیم و میخواهیم نام نویسنده هر مقاله را نمایش دهیم.
- یک کوئری برای دریافت ۵۰ مقاله
- ۵۰ کوئری برای دریافت نویسنده هر مقاله
در مجموع، ممکن است ۵۱ کوئری اجرا شود.
البته تعداد واقعی کوئریها به ساختار روابط، نحوه بارگذاری دادهها و اینکه اطلاعات مرتبط از قبل بارگذاری شده باشند یا خیر، بستگی دارد.
نکته مهم این است که مشکل N+1 صرفاً به معنی زیاد بودن تعداد کوئریها نیست؛ بلکه به اجرای کوئریهای تکراری و قابلجلوگیری برای دریافت اطلاعات مرتبط اشاره دارد.
۲. ایجاد مشکل N+1 در Laravel با یک مثال واقعی
فرض کنید یک سیستم وبلاگ داریم که شامل دو مدل Post و User است.
هر مقاله توسط یک کاربر نوشته میشود و میخواهیم عنوان مقاله و نام نویسنده را نمایش دهیم.
ابتدا باید رابطه بین این دو مدل را تعریف کنیم.
تعریف رابطه در مدل Post
فایل زیر را باز کنید:
app/Models/Post.php
در مدل Post رابطه نویسنده را تعریف کنید:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Post extends Model
{
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
در این مثال فرض شده است جدول posts شامل ستون user_id است و این ستون به کاربر نویسنده مقاله اشاره میکند.
دریافت مقالات و نمایش نام نویسنده
اکنون کد زیر را در کنترلر قرار دهید:
use App\Models\Post;
public function index()
{
$posts = Post::all();
foreach ($posts as $post) {
echo $post->title;
echo $post->user->name;
}
}
کد ساده به نظر میرسد و از نظر نحوی نیز صحیح است؛ اما یک نکته مهم دارد.
دستور زیر:
$posts = Post::all();
مقالات را دریافت میکند، اما بهطور پیشفرض به این معنی نیست که اطلاعات نویسندگان نیز بارگذاری شدهاند.
هنگامی که در حلقه به این عبارت میرسیم:
$post->user->name
اگر رابطه user قبلاً بارگذاری نشده باشد، Eloquent معمولاً برای دریافت نویسنده همان مقاله یک کوئری جداگانه اجرا میکند.
بنابراین، اگر ۱۰۰ مقاله داشته باشیم و نویسندگان از قبل بارگذاری نشده باشند، ممکن است ۱۰۱ کوئری اجرا شود.
این دقیقاً نمونهای از مشکل N+1 است.
۳. رفع مشکل N+1 با استفاده از with()
یکی از رایجترین و کاربردیترین راهکارهای رفع N+1، استفاده از Eager Loading است.
Eager Loading به معنای بارگذاری پیشاپیش روابط موردنیاز است. در لاراول میتوانیم از متد with() برای این کار استفاده کنیم.
کد قبلی را به شکل زیر تغییر دهید:
use App\Models\Post;
public function index()
{
$posts = Post::with('user')->get();
foreach ($posts as $post) {
echo $post->title;
echo $post->user->name;
}
}
تفاوت اصلی در این خط است:
$posts = Post::with('user')->get();
با این دستور، لاراول میداند که اطلاعات رابطه user نیز موردنیاز است و آن را بهصورت گروهی بارگذاری میکند.
در یک رابطه معمول belongsTo، این عملیات اغلب به دو کوئری اصلی نیاز دارد:
- دریافت مقالات.
- دریافت کاربران مرتبط با مقالات.
در نتیجه، تعداد کوئریها دیگر لزوماً متناسب با تعداد مقالات افزایش پیدا نمیکند.
البته اگر رابطه پیچیدهتر باشد، روابط دیگری نیز بارگذاری شوند یا شرایط خاصی در میان باشد، تعداد واقعی کوئریها میتواند متفاوت باشد.
نکته کلیدی: استفاده از with() یکی از مهمترین راهکارهای رفع N+1 در روابط Eloquent است؛ اما باید روابط موردنیاز را متناسب با اطلاعاتی که واقعاً نمایش میدهید انتخاب کنید.
۴. تفاوت Lazy Loading و Eager Loading چیست؟
برای استفاده صحیح از روابط لاراول، باید تفاوت این دو مفهوم را بدانید.
Lazy Loading؛ بارگذاری تنبل
در Lazy Loading، اطلاعات رابطه زمانی دریافت میشوند که برای اولین بار به آن رابطه دسترسی پیدا کنید.
برای مثال:
$post = Post::find(1);
echo $post->user->name;
در این مثال، ابتدا مقاله دریافت میشود. اگر رابطه user بارگذاری نشده باشد، هنگام دسترسی به آن، کوئری مربوط به نویسنده اجرا میشود.
Lazy Loading در بسیاری از مواقع مفید است؛ زیرا فقط اطلاعاتی را دریافت میکند که واقعاً به آنها دسترسی پیدا میکنید.
اما وقتی در حلقهای روی مجموعهای از مدلها به روابط آنها دسترسی پیدا میکنید، ممکن است مشکل N+1 ایجاد شود.
Eager Loading؛ بارگذاری پیشاپیش
در Eager Loading، روابط موردنیاز را هنگام اجرای کوئری اصلی مشخص میکنید:
$posts = Post::with('user')->get();
در این حالت، لاراول اطلاعات رابطه را بهصورت گروهی دریافت میکند.
| ویژگی | Lazy Loading | Eager Loading |
|---|---|---|
| زمان دریافت رابطه | هنگام دسترسی به رابطه | هنگام بارگذاری پیشاپیش |
| متد رایج | دسترسی به $post->user | with('user') |
| خطر N+1 در حلقهها | بیشتر | معمولاً کمتر |
| کاربرد | زمانی که رابطه فقط در صورت نیاز استفاده میشود | زمانی که روابط از قبل مشخصاند |
هیچکدام در همه شرایط بهترین انتخاب نیستند. هدف این است که روش بارگذاری دادهها با نیاز واقعی برنامه هماهنگ باشد.
۵. استفاده از load() برای رفع N+1
گاهی ابتدا رکوردهای اصلی را دریافت میکنید و بعداً متوجه میشوید که به اطلاعات مرتبط نیز نیاز دارید.
در چنین شرایطی، میتوانید از load() استفاده کنید.
برای مثال:
$posts = Post::all();
$posts->load('user');
foreach ($posts as $post) {
echo $post->title;
echo $post->user->name;
}
در این کد، ابتدا مقالات دریافت میشوند و سپس رابطه user برای مجموعه بارگذاری میشود.
تفاوت اصلی with() و load() در زمان استفاده است:
with()پیش از اجرای کوئری، روابط موردنیاز را مشخص میکند.load()برای بارگذاری رابطه روی مدل یا مجموعهای از مدلهایی استفاده میشود که قبلاً دریافت شدهاند.
اگر از ابتدا میدانید که به نویسندگان نیاز دارید، معمولاً این روش سادهتر است:
$posts = Post::with('user')->get();
اما اگر نیاز به رابطه در مرحلهای بعد مشخص شود، load() انتخاب مناسبی است.
تفاوت load() و loadMissing()
متد load() روابط مشخصشده را بارگذاری میکند، درحالیکه loadMissing() برای بارگذاری روابطی است که هنوز بارگذاری نشدهاند.
برای مثال:
$posts->loadMissing('user');
این متد برای زمانی مفید است که میخواهید مطمئن شوید رابطه بارگذاری شده است، بدون اینکه رابطهای را که از قبل بارگذاری شده، دوباره بیدلیل درخواست کنید.
۶. بارگذاری چند رابطه بهصورت همزمان
در پروژههای واقعی، معمولاً یک مدل چند رابطه دارد.
فرض کنید مدل Post علاوه بر نویسنده، با دستهبندی و نظرات نیز ارتباط دارد.
اگر روابط زیر در مدل تعریف شده باشند:
usercategorycomments
میتوانید آنها را همزمان بارگذاری کنید:
$posts = Post::with([
'user',
'category',
'comments',
])->get();
اکنون میتوانید در حلقه به اطلاعات این روابط دسترسی داشته باشید:
foreach ($posts as $post) {
echo $post->title;
echo $post->user->name;
echo $post->category->name;
foreach ($post->comments as $comment) {
echo $comment->content;
}
}
در این مثال فرض شده است روابط و ستونهای موردنیاز در مدلها وجود دارند.
به این ترتیب، بهجای اینکه برای هر مقاله جداگانه اطلاعات نویسنده، دستهبندی و نظرات دریافت شوند، لاراول میتواند روابط را بهصورت گروهی بارگذاری کند.
البته اگر تعداد نظرات بسیار زیاد باشد، بارگذاری همه آنها ممکن است مصرف حافظه را افزایش دهد. بنابراین باید حجم دادههای مرتبط را نیز در نظر بگیرید.
۷. رفع N+1 در روابط تو در تو
گاهی رابطه موردنیاز، خودش با مدل دیگری ارتباط دارد.
برای مثال، فرض کنید هر مقاله یک نویسنده دارد و هر نویسنده متعلق به یک تیم است.
اگر روابط user و team در مدلها تعریف شده باشند، میتوانید روابط تو در تو را به این شکل بارگذاری کنید:
$posts = Post::with('user.team')->get();
در این مثال، لاراول مقالهها، کاربران مرتبط و تیمهای مرتبط را بارگذاری میکند.
سپس میتوانید از اطلاعات رابطه استفاده کنید:
foreach ($posts as $post) {
echo $post->title;
echo $post->user->name;
echo $post->user->team->name;
}
روش دیگری نیز وجود دارد:
$posts = Post::with([
'user',
'user.team',
])->get();
هر دو شکل برای مشخصکردن روابط تو در تو کاربرد دارند.
چرا روابط تو در تو مهم هستند؟
در پروژههایی مانند فروشگاه اینترنتی، سیستم مدیریت کاربران یا سامانههای محتوایی، روابط چندمرحلهای بسیار رایجاند.
برای مثال، ممکن است بخواهید سفارش، مشتری سفارش و شهر مشتری را نمایش دهید.
اگر روابط را بهدرستی بارگذاری نکنید، دسترسی به هر مرحله از رابطه ممکن است کوئریهای اضافی ایجاد کند.
بنابراین، هنگام طراحی یک صفحه یا API، بهتر است از ابتدا مشخص کنید که کدام روابط و چه سطحی از اطلاعات موردنیاز هستند.
۸. انتخاب ستونهای موردنیاز هنگام Eager Loading
استفاده از with() مشکل N+1 را کاهش میدهد، اما به این معنی نیست که باید تمام ستونهای مدلهای مرتبط را دریافت کنید.
برای کاهش حجم دادهها میتوانید ستونهای موردنیاز را مشخص کنید.
برای مثال:
$posts = Post::query()
->select(['id', 'user_id', 'title'])
->with('user:id,name')
->get();
در این مثال:
- از جدول مقالات، ستونهای
id،user_idوtitleدریافت میشوند. - از جدول کاربران، ستونهای
idوnameدریافت میشوند.
این کار میتواند حجم داده منتقلشده و مصرف حافظه را کاهش دهد.
نکته بسیار مهم: هنگام محدودکردن ستونها، کلیدهای موردنیاز برای برقراری رابطه را حذف نکنید. در رابطه belongsTo، ستون کلید خارجی مدل اصلی و کلید اصلی مدل مرتبط معمولاً ضروریاند.
اگر ستونهای لازم را انتخاب نکنید، ممکن است Eloquent نتواند رابطه را بهدرستی به مدل اصلی متصل کند.
۹. جلوگیری از N+1 هنگام شمارش روابط
گاهی بهجای دریافت تمام اطلاعات یک رابطه، فقط میخواهید تعداد رکوردهای مرتبط را نمایش دهید.
برای مثال، در یک وبلاگ ممکن است تعداد نظرات هر مقاله را نمایش دهید.
روش نامناسب در بسیاری از سناریوها:
$posts = Post::all();
foreach ($posts as $post) {
echo $post->comments()->count();
}
در این مثال، برای هر مقاله یک کوئری شمارش اجرا میشود. اگر ۱۰۰ مقاله داشته باشید، ممکن است ۱۰۱ کوئری اجرا شود.
راهکار مناسبتر استفاده از withCount() است:
$posts = Post::withCount('comments')->get();
foreach ($posts as $post) {
echo $post->title;
echo $post->comments_count;
}
لاراول تعداد نظرات را در ویژگی comments_count قرار میدهد.
به این ترتیب، دیگر لازم نیست برای هر مقاله بهصورت جداگانه شمارش انجام دهید.
شمارش چند رابطه
اگر چند رابطه دارید، میتوانید شمارش آنها را همزمان دریافت کنید:
$posts = Post::withCount([
'comments',
'likes',
])->get();
این مثال فرض میکند روابط comments و likes در مدل Post تعریف شدهاند.
استفاده از withCount() برای نمایش تعداد نظرات، پسندیدنها و سایر روابط شمارشپذیر بسیار کاربردی است.
۱۰. بارگذاری شرطی روابط
گاهی میخواهید فقط بخشی از دادههای مرتبط را دریافت کنید.
برای مثال، ممکن است بخواهید مقالات را همراه با نظرات تأییدشده نمایش دهید.
میتوانید از Eager Loading همراه با شرط استفاده کنید:
$posts = Post::with([
'comments' => function ($query) {
$query->where('is_approved', true);
},
])->get();
در این مثال فرض شده است ستون is_approved در جدول نظرات وجود دارد.
اکنون رابطه comments فقط شامل نظراتی خواهد بود که شرط تعیینشده را دارند.
این روش برای فیلترکردن دادههای مرتبط کاربرد دارد و میتواند از دریافت اطلاعات غیرضروری جلوگیری کند.
استفاده از withWhereHas()
گاهی میخواهید فقط مقالاتی را دریافت کنید که حداقل یک نظر تأییدشده دارند و در عین حال، همان نظرات تأییدشده را نیز بارگذاری کنید.
میتوانید از withWhereHas() استفاده کنید:
$posts = Post::withWhereHas('comments', function ($query) {
$query->where('is_approved', true);
})->get();
این متد هم مجموعه اصلی را براساس وجود رابطه منطبق فیلتر میکند و هم رابطه را با همان شرط بارگذاری میکند.
این روش زمانی مفید است که شرط دریافت مدل اصلی و شرط بارگذاری رابطه باید یکسان باشند.
۱۱. چگونه مشکل N+1 را در پروژه تشخیص دهیم؟
برای رفع مشکل، ابتدا باید آن را شناسایی کنید.
گاهی صفحه ظاهراً بهدرستی کار میکند، اما در پشت صحنه دهها یا صدها کوئری اجرا میشود.
یکی از روشها، استفاده از DB::listen() در محیط توسعه است.
برای مثال، میتوانید در یک Service Provider مناسب این کد را قرار دهید:
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
DB::listen(function (QueryExecuted $query) {
Log::debug('Query executed', [
'sql' => $query->sql,
'time_ms' => $query->time,
]);
});
این کد اطلاعات کوئریهای اجراشده و زمان اجرای آنها را در لاگ ثبت میکند.
سپس میتوانید صفحه موردنظر را باز کنید و بررسی کنید چه تعداد کوئری اجرا شدهاند و آیا کوئریهای مشابه بارها تکرار میشوند یا خیر.
این روش برای عیبیابی مفید است، اما بهتر است در محیط توسعه استفاده شود؛ زیرا ثبت همه کوئریها در محیط عملیاتی میتواند حجم زیادی از لاگ تولید کند.
چه نشانههایی احتمال وجود N+1 را نشان میدهند؟
- تعداد کوئریها با افزایش تعداد رکوردهای صفحه بهسرعت بیشتر میشود.
- کوئریهای مشابه با شناسههای متفاوت بارها اجرا میشوند.
- صفحهای که فهرست رکوردها را نمایش میدهد، کندتر از انتظار است.
- در حلقههای
foreachبه روابط Eloquent دسترسی پیدا میکنید. - در یک API، برای هر آیتم اطلاعات مرتبط جداگانه دریافت میشود.
این نشانهها بهتنهایی اثبات قطعی N+1 نیستند، اما نقاط مناسبی برای بررسی هستند.
۱۲. جلوگیری از Lazy Loading ناخواسته در محیط توسعه
یکی از روشهای مفید برای جلوگیری از ایجاد N+1، شناسایی دسترسیهای ناخواسته به روابطی است که بارگذاری نشدهاند.
در لاراول میتوانید Lazy Loading را برای مدلها محدود کنید.
برای مثال، در متد boot() فایل app/Providers/AppServiceProvider.php میتوانید بنویسید:
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}
این تنظیم در محیطهای غیرتولیدی از Lazy Loading ناخواسته جلوگیری میکند و میتواند هنگام توسعه، مشکلات احتمالی را آشکار کند.
رفتار دقیق در زمان برخورد با رابطه بارگذارینشده به نسخه لاراول و تنظیمات مربوط به مدیریت این تخلف بستگی دارد.
در پروژههایی که از قبل به Lazy Loading متکی هستند، پیش از فعالکردن این محدودیت، کدهای موجود را بررسی کنید تا مشکلات جدیدی در توسعه ایجاد نشود.
این روش جایگزین بررسی عملکرد نیست؛ اما میتواند کمک کند مشکلات مربوط به بارگذاری روابط، زودتر شناسایی شوند.
۱۳. آیا همیشه باید از Eager Loading استفاده کنیم؟
خیر. اگرچه Eager Loading یکی از مهمترین راهکارهای رفع N+1 است، اما استفاده بیرویه از آن هم میتواند مشکلساز شود.
فرض کنید در یک صفحه فقط عنوان مقالات نمایش داده میشود و هیچ اطلاعاتی از نویسنده موردنیاز نیست.
در چنین شرایطی، این کد ممکن است اطلاعات اضافی دریافت کند:
$posts = Post::with('user')->get();
اگر رابطه user هیچجا استفاده نشود، بارگذاری آن لزوماً سودی ندارد.
در مقابل، اگر قرار است نام نویسنده نمایش داده شود، بارگذاری پیشاپیش رابطه میتواند انتخاب مناسبی باشد.
همچنین اگر یک رابطه شامل هزاران رکورد مرتبط باشد، بارگذاری همه آنها ممکن است حافظه زیادی مصرف کند. در چنین مواردی، بهتر است بهجای دریافت تمام رکوردها، از شمارش رابطه، فیلترکردن، صفحهبندی یا روشهای دیگری متناسب با نیاز استفاده کنید.
قاعده اصلی: فقط روابطی را بارگذاری کنید که واقعاً موردنیاز هستند و حجم دادههای مرتبط را نیز در نظر بگیرید.
۱۴. اشتباهات رایج هنگام رفع N+1
اشتباه اول: تصور اینکه استفاده از Eloquent همیشه بهینه است
Eloquent ابزار قدرتمندی است؛ اما استفاده نادرست از روابط میتواند باعث اجرای تعداد زیادی کوئری شود.
اشتباه دوم: استفاده از with() بدون نیاز واقعی
بارگذاری روابطی که هیچجا استفاده نمیشوند، حجم دادهها و هزینه اجرای کوئری را افزایش میدهد.
اشتباه سوم: دریافت تمام ستونهای روابط
اگر فقط نام نویسنده موردنیاز است، دریافت تمام ستونهای جدول کاربران ممکن است غیرضروری باشد. در صورت امکان، ستونهای موردنیاز را انتخاب کنید و کلیدهای لازم برای روابط را حفظ کنید.
اشتباه چهارم: استفاده از count() داخل حلقه
اگر فقط تعداد رکوردهای مرتبط را نیاز دارید، بررسی withCount() میتواند از اجرای کوئری شمارش جداگانه برای هر مدل جلوگیری کند.
اشتباه پنجم: فرضکردن اینکه تعداد کوئری کمتر همیشه به معنی سرعت بیشتر است
تعداد کوئریها معیار مهمی است، اما تنها معیار نیست. یک کوئری بسیار سنگین ممکن است از چند کوئری ساده زمان بیشتری مصرف کند.
بنابراین، علاوه بر تعداد کوئریها باید زمان اجرا، حجم دادهها، ایندکسها و رفتار واقعی دیتابیس را نیز بررسی کنید.
جمعبندی
مشکل N+1 Query یکی از مشکلات رایج در پروژههای Laravel است که هنگام دسترسی مکرر به روابط بارگذارینشده Eloquent ایجاد میشود.
اگر ابتدا مجموعهای از مدلها را دریافت کنید و سپس در حلقه برای هر مدل اطلاعات رابطهای آن را بخواهید، ممکن است تعداد زیادی کوئری تکراری اجرا شود.
برای جلوگیری از این مشکل، میتوانید از روشهای زیر استفاده کنید:
with()برای بارگذاری پیشاپیش روابطload()برای بارگذاری روابط پس از دریافت مدلهاloadMissing()برای بارگذاری روابطی که هنوز دریافت نشدهاندwithCount()برای دریافت تعداد رکوردهای مرتبط- انتخاب ستونهای ضروری با حفظ کلیدهای موردنیاز روابط
- بررسی کوئریها با
DB::listen() - محدودکردن Lazy Loading ناخواسته در محیط توسعه
به خاطر داشته باشید که هدف، صرفاً کاهش تعداد کوئریها نیست؛ بلکه باید با حفظ صحت اطلاعات، از اجرای عملیات غیرضروری جلوگیری کنید و عملکرد واقعی برنامه را اندازه بگیرید.
اگر در پروژه خود با صفحههای کند، APIهای سنگین یا فهرستهایی با روابط متعدد مواجه هستید، بررسی N+1 میتواند یکی از اولین قدمهای مهم برای بهبود عملکرد باشد.





