N+1 Query در EF Core چیست و چطور آن را حل کنیم؟

N+1 Query در EF Core چیست و چطور آن را حل کنیم؟

N+1 Query در EF Core چیست و چگونه API شما را کند می‌کند؟

گاهی یک API روی سیستم توسعه‌دهنده سریع اجرا می‌شود، اما وقتی وارد محیط واقعی می‌شود، ناگهان زمان پاسخ‌دهی بالا می‌رود. CPU و RAM هم مشکل خاصی ندارند، اما دیتابیس مدام در حال دریافت Query است.

در بسیاری از پروژه‌های ASP.NET Core، یکی از دلایل این مشکل چیزی به نام N+1 Query Problem است.

این مشکل معمولاً در نگاه اول دیده نمی‌شود، اما می‌تواند عملکرد API را به شکل قابل توجهی کاهش دهد.

در این مقاله بررسی می‌کنیم N+1 Query چیست، چرا در EF Core اتفاق می‌افتد و چطور می‌توان آن را حل کرد.


N+1 Query چیست؟

فرض کنید می‌خواهیم لیست سفارش‌ها را همراه با نام مشتری نمایش دهیم.

مدل‌ها:

public class Order
{
    public int Id { get; set; }

    public decimal TotalPrice { get; set; }

    public int CustomerId { get; set; }

    public Customer Customer { get; set; }
}

public class Customer
{
    public int Id { get; set; }

    public string Name { get; set; }
}

حالا این کد را در نظر بگیرید:

var orders = await _context.Orders.ToListAsync();

foreach (var order in orders)
{
    Console.WriteLine(order.Customer.Name);
}

در ظاهر کد کاملاً ساده است.

اما ممکن است اتفاق زیر بیفتد:

Query 1:
SELECT * FROM Orders

Query 2:
SELECT * FROM Customers WHERE Id = 1

Query 3:
SELECT * FROM Customers WHERE Id = 2

Query 4:
SELECT * FROM Customers WHERE Id = 3

...

اگر 100 سفارش داشته باشیم:

1 Query برای Orders
100 Query برای Customer

مجموع: 101 Query

به همین دلیل به آن می‌گویند:

N + 1

یعنی:

1 Query اصلی
+
N Query اضافی

چرا این مشکل در EF Core اتفاق می‌افتد؟

معمولاً این مشکل به دلیل بارگذاری داده‌های مرتبط (Related Data) به شکل نامناسب رخ می‌دهد.

به‌خصوص اگر از Lazy Loading استفاده کنید.

مثلاً:

order.Customer.Name

ممکن است باعث شود EF Core برای هر رکورد یک Query جداگانه اجرا کند.

در تعداد کم داده شاید مشکلی ایجاد نشود.

اما در محیط واقعی:

100 Order = 101 Query

1000 Order = 1001 Query

و این یعنی:

  • تعداد زیاد Round Trip به دیتابیس
  • افزایش زمان پاسخ API
  • افزایش مصرف منابع Database

چگونه N+1 Query را پیدا کنیم؟

یکی از بهترین راه‌ها، مشاهده Queryهای تولیدشده توسط EF Core است.

در Program.cs:

builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(connectionString)
           .LogTo(Console.WriteLine);
});

اگر تعداد زیادی Query مشابه مشاهده کردید:

SELECT * FROM Customers WHERE Id = ...
SELECT * FROM Customers WHERE Id = ...
SELECT * FROM Customers WHERE Id = ...

احتمالاً با N+1 مواجه شده‌اید.


راه‌حل اول: استفاده از Include

ساده‌ترین راه استفاده از Include است.

var orders = await _context.Orders
    .Include(x => x.Customer)
    .ToListAsync();

حالا EF Core داده‌ها را در یک Query دریافت می‌کند.

مثلاً:

SELECT
    o.Id,
    o.TotalPrice,
    c.Name
FROM Orders o
LEFT JOIN Customers c
ON o.CustomerId = c.Id

حالا به جای:

101 Query

فقط:

1 Query

اجرا می‌شود.


آیا Include همیشه بهترین انتخاب است؟

نه.

گاهی Include داده‌های بیشتری از نیاز برنامه برمی‌گرداند.

فرض کنید فقط نام مشتری نیاز است.

این:

.Include(x => x.Customer)

کل اطلاعات Customer را بارگذاری می‌کند.


راه‌حل بهتر: Projection با Select

معمولاً برای APIها استفاده از Projection بهتر است.

var orders = await _context.Orders
    .Select(x => new OrderDto
    {
        Id = x.Id,
        TotalPrice = x.TotalPrice,
        CustomerName = x.Customer.Name
    })
    .ToListAsync();

مزیت‌ها:

  • داده کمتر
  • سرعت بیشتر
  • مصرف حافظه کمتر
  • Query بهینه‌تر

مثال واقعی

فرض کنید این Query را داریم:

var products = await _context.Products
    .Select(x => new ProductDto
    {
        Id = x.Id,
        Name = x.Name,
        Category = x.Category.Name
    })
    .AsNoTracking()
    .ToListAsync();

این روش معمولاً بهتر از این است:

_context.Products
    .Include(x => x.Category)

چون فقط اطلاعات مورد نیاز را دریافت می‌کنیم.


نقش AsNoTracking در Performance

اگر داده فقط خواندنی است:

var products = await _context.Products
    .AsNoTracking()
    .ToListAsync();

EF Core دیگر تغییرات Entityها را Track نمی‌کند.

این باعث می‌شود:

  • مصرف حافظه کمتر شود
  • سرعت Query افزایش پیدا کند

برای APIهای Read-Only معمولاً استفاده از AsNoTracking() پیشنهاد می‌شود.


Lazy Loading؛ دوست یا دشمن؟

Lazy Loading در بعضی سناریوها مفید است، اما اگر بدون دقت استفاده شود، می‌تواند منبع اصلی N+1 باشد.

مثلاً:

order.Customer.Name

ممکن است بدون اینکه متوجه شوید Query جدید اجرا کند.

به همین دلیل در بسیاری از پروژه‌های بزرگ، استفاده زیاد از Lazy Loading پیشنهاد نمی‌شود.


چگونه N+1 Query را در پروژه‌های واقعی جلوگیری کنیم؟

چند قانون ساده:

✅ برای APIها از Projection استفاده کنید.

✅ فقط داده مورد نیاز را دریافت کنید.

✅ از AsNoTracking() برای Queryهای خواندنی استفاده کنید.

✅ Queryهای EF Core را Log کنید.

✅ مراقب Lazy Loading باشید.

✅ قبل از انتشار، Queryهای سنگین را بررسی کنید.


یک مثال قبل و بعد

قبل:

Orders: 500
Customers: 500

Total Queries: 501

بعد از استفاده از Projection:

Orders + CustomerName

Total Queries: 1

همین تغییر ساده می‌تواند زمان پاسخ API را از چند ثانیه به چند صد میلی‌ثانیه کاهش دهد.


جمع‌بندی

N+1 Query یکی از رایج‌ترین مشکلات Performance در EF Core است.

این مشکل معمولاً زمانی رخ می‌دهد که داده‌های مرتبط به شکل نامناسب بارگذاری شوند و به جای یک Query، ده‌ها یا صدها Query اجرا شود.

راه‌حل‌های اصلی:

  • استفاده از Include
  • استفاده از Select Projection
  • استفاده از AsNoTracking
  • بررسی Queryها و Logging

در بسیاری از APIهای واقعی، استفاده از Projection به همراه AsNoTracking یکی از بهترین راه‌ها برای بهینه‌سازی عملکرد EF Core است.


سوال برای شما

آیا تا به حال در پروژه‌های ASP.NET Core با مشکل N+1 Query روبه‌رو شده‌اید؟

چطور آن را پیدا و حل کردید؟

دیدگاه‌ خود را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

پیمایش به بالا