مقالات › EF Core و دیتابیس

بهینه‌سازی EF Core؛ ۱۲ تکنیک برای کوئری‌های سریع‌تر

Entity Framework Core را سریع کنید: AsNoTracking، Projection، حذف مشکل N+1، Split Query، به‌روزرسانی گروهی با ExecuteUpdate، کوئری کامپایل‌شده، Pooling، صفحه‌بندی و اندازه‌گیری با لاگ.

Entity Framework Core سریع است؛ به شرطی که درست استفاده شود. بیشتر شکایت‌های «EF Core کند است» از چند الگوی تکراری می‌آیند: بارگذاری داده‌ی بیش از حد، ردیابی موجودیت‌هایی که هرگز تغییر نمی‌کنند و ارسال صدها کوئری در جایی که یکی کافی بود. این دوازده تکنیک اکثر مشکلات کارایی واقعی را حل می‌کنند.

۰. اول اندازه بگیرید

هرگز کورکورانه بهینه‌سازی نکنید. SQL تولیدشده توسط EF Core و زمان اجرایش را لاگ کنید:

builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(connectionString);
    if (builder.Environment.IsDevelopment())
    {
        options.LogTo(Console.WriteLine, LogLevel.Information)
               .EnableSensitiveDataLogging();
    }
});

با query.ToQueryString() هم می‌توانید یک کوئری را بررسی کنید. سپس برای هر چیز کندی Execution Plan را در SQL Server ببینید.

۱. برای کوئری‌های فقط‌خواندنی از AsNoTracking استفاده کنید

var articles = await db.Articles
    .AsNoTracking()
    .Where(a => a.IsPublished)
    .ToListAsync();

ردیابی برای تشخیص تغییرات، از هر موجودیت یک Snapshot نگه می‌دارد. برای داده‌ای که فقط نمایش می‌دهید سربار محض است؛ اغلب ۲۰ تا ۴۰ درصد کندتر و با حافظه‌ی بسیار بیشتر. در Contextهای پرخواندن می‌توانید آن را پیش‌فرض کنید:

options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);

۲. فقط چیزی را که لازم دارید Project کنید

// ❌ همه‌ی ستون‌ها، حتی فیلدهای بزرگ Content، بارگذاری می‌شوند
var list = await db.Articles.ToListAsync();

// ✅ فقط ستون‌های لازم انتخاب می‌شوند
var list = await db.Articles
    .Select(a => new ArticleSummary(a.Id, a.Slug, a.Title, a.PublishedAt))
    .ToListAsync();

Projectionها به‌طور خودکار ردیابی نمی‌شوند، داده‌ی کمتری منتقل می‌کنند و به SQL Server اجازه می‌دهند از Covering Index استفاده کند. همین یک تغییر اغلب بیشترین بهبود را می‌دهد.

۳. مشکل N+1 را از بین ببرید

// ❌ یک کوئری برای وبلاگ‌ها + یک کوئری برای هر وبلاگ (N+1)
var blogs = await db.Blogs.ToListAsync();
foreach (var blog in blogs)
{
    var count = await db.Posts.CountAsync(p => p.BlogId == blog.Id);
}

// ✅ یک کوئری
var blogs = await db.Blogs
    .Select(b => new { b.Title, PostCount = b.Posts.Count })
    .ToListAsync();

N+1 پشت Lazy Loading هم پنهان می‌شود. آن را در APIها غیرفعال کنید و داده‌های مرتبط را صریح با Include یا Projection بارگذاری کنید.

۴. برای چند کالکشن از Split Query استفاده کنید

var orders = await db.Orders
    .Include(o => o.Items)
    .Include(o => o.Payments)
    .AsSplitQuery()
    .ToListAsync();

Include کردن دو کالکشن در یک کوئری، ردیف‌ها را ضرب می‌کند (آیتم‌ها × پرداخت‌ها)؛ پدیده‌ای به نام «Cartesian Explosion». AsSplitQuery() هر کالکشن را با کوئری خودش بارگذاری می‌کند. اندازه بگیرید: برای کالکشن‌های کوچک یک کوئری واحد ممکن است همچنان سریع‌تر باشد.

۵. فیلتر و صفحه‌بندی را در دیتابیس انجام دهید

// ❌ اول ToList()، بعد فیلتر در حافظه
var page = db.Products.ToList().Where(p => p.Price > 100).Skip(20).Take(20);

// ✅ همه‌چیز به SQL ترجمه می‌شود
var page = await db.Products
    .Where(p => p.Price > 100)
    .OrderBy(p => p.Id)
    .Skip(20).Take(20)
    .ToListAsync();

کوئری را تا تکمیل کامل IQueryable نگه دارید. برای صفحه‌بندی عمیق در جدول‌های بزرگ از صفحه‌بندی Keyset استفاده کنید:

var next = await db.Products
    .Where(p => p.Id > lastSeenId)
    .OrderBy(p => p.Id)
    .Take(20)
    .ToListAsync();

۶. به‌روزرسانی و حذف گروهی بدون بارگذاری

// ✅ یک دستور UPDATE، بدون بارگذاری موجودیت
await db.Articles
    .Where(a => a.PublishedAt < cutoff)
    .ExecuteUpdateAsync(s => s
        .SetProperty(a => a.IsArchived, true)
        .SetProperty(a => a.UpdatedAt, DateTime.UtcNow));

await db.Sessions.Where(s => s.ExpiresAt < DateTime.UtcNow).ExecuteDeleteAsync();

ExecuteUpdate و ExecuteDelete (از EF Core 7) از ردیابی تغییرات عبور می‌کنند؛ توجه داشته باشید موجودیت‌های ردیابی‌شده در همان Context این تغییر را منعکس نمی‌کنند.

۷. درج‌ها را هوشمندانه دسته‌ای کنید

EF Core خودکار چند درج را در یک رفت‌وبرگشت دسته‌بندی می‌کند. SaveChanges را داخل حلقه صدا نزنید:

// ❌ ۱۰۰۰ رفت‌وبرگشت
foreach (var item in items) { db.Add(item); await db.SaveChangesAsync(); }

// ✅ دسته‌ای
db.AddRange(items);
await db.SaveChangesAsync();

برای صدها هزار ردیف از SqlBulkCopy یا یک کتابخانه‌ی Bulk استفاده کنید.

۸. چیزی را که کوئری می‌زنید ایندکس کنید

EF Core نمی‌تواند کمبود ایندکس را جبران کند. برای ستون‌هایی که زیاد فیلتر یا مرتب می‌شوند ایندکس بسازید:

modelBuilder.Entity<Article>()
    .HasIndex(a => new { a.IsPublished, a.PublishedAt });

modelBuilder.Entity<Article>()
    .HasIndex(a => a.Slug).IsUnique();

از شرط‌های غیرقابل ترجمه یا Non-SARGable هم پرهیز کنید (a.Title.ToLower().Contains(x) روی جدول بزرگ باعث Scan می‌شود؛ از Collation مناسب یا Full-Text Search استفاده کنید).

۹. کوئری کامپایل‌شده برای مسیرهای داغ

برای کوئری‌هایی که هزاران بار در ثانیه اجرا می‌شوند، هزینه‌ی ترجمه‌ی Expression Tree را حذف کنید:

private static readonly Func<AppDbContext, string, Task<Article?>> GetBySlug =
    EF.CompileAsyncQuery((AppDbContext db, string slug) =>
        db.Articles.AsNoTracking().FirstOrDefault(a => a.Slug == slug));

var article = await GetBySlug(db, slug);

EF Core خودش Plan کوئری‌ها را کش می‌کند؛ پس فقط برای نقاط داغِ اثبات‌شده از این روش استفاده کنید.

۱۰. Pooling برای DbContext

builder.Services.AddDbContextPool<AppDbContext>(o => o.UseSqlServer(connectionString), poolSize: 128);

Pooling نمونه‌های Context را دوباره استفاده می‌کند و هزینه‌ی تخصیص و راه‌اندازی را در APIهای پرترافیک کم می‌کند. State مخصوص درخواست را در فیلدهای Context پول‌شده نگه ندارید.

۱۱. همه‌جا از async و لغو استفاده کنید

var items = await db.Products.Where(...).ToListAsync(cancellationToken);

I/O ناهمگام در زمان کار SQL Server، Threadها را آزاد می‌کند و لغو، کار درخواست‌هایی را که کلاینت رهایشان کرده متوقف می‌کند.

۱۲. داده‌های بزرگ را جریانی بارگذاری کنید

await foreach (var row in db.AuditLogs.AsNoTracking().AsAsyncEnumerable().WithCancellation(ct))
{
    await writer.WriteLineAsync(row.ToCsv());
}

جریان‌سازی از بارگذاری هم‌زمان میلیون‌ها ردیف در حافظه جلوگیری می‌کند؛ ایده‌آل برای خروجی گرفتن.

کِی سراغ SQL خام برویم

وقتی LINQ بهترین ابزار نیست، EF Core به‌خوبی با SQL خام کار می‌کند:

var stats = await db.Database
    .SqlQuery<DailyStat>($"SELECT CAST(CreatedAt AS date) AS Day, COUNT(*) AS Count FROM Orders GROUP BY CAST(CreatedAt AS date)")
    .ToListAsync();

گزارش‌های پیچیده، توابع پنجره‌ای و کوئری‌های به‌شدت بهینه‌شده در SQL (یا Dapper) کاملاً قابل قبول‌اند؛ هدف کد سریع و قابل نگهداری است، نه خلوص LINQ.

مرجع سریع

علامت راه‌حل احتمالی
لیست‌های کند، مصرف حافظه‌ی بالا AsNoTracking، Projection
صدها کوئری مشابه در لاگ رفع N+1 با Projection یا Include
نتایج عظیم با ردیف‌های تکراری AsSplitQuery
به‌روزرسانی کند تعداد زیادی ردیف ExecuteUpdate / ExecuteDelete
Index Scan در Execution Plan اضافه یا اصلاح ایندکس، شرط‌های SARGable
CPU بالا در کوئری‌های بسیار پرتکرار کوئری کامپایل‌شده، Context Pooling

جمع‌بندی

اول اندازه بگیرید، بعد اصول پربازده را اعمال کنید: بدون ردیابی برای خواندن، Projection، حذف N+1، فیلتر و صفحه‌بندی در SQL، عملیات گروهی بدون بارگذاری و ایندکس مناسب. کوئری کامپایل‌شده، Pooling و SQL خام را برای مسیرهای داغِ اثبات‌شده نگه دارید. با این عادت‌ها EF Core به‌راحتی سیستم‌های پرترافیک Production را اداره می‌کند.

پرسش‌های متداول

آیا Dapper همیشه از EF Core سریع‌تر است؟

در خواندن‌های ساده، وقتی EF Core با AsNoTracking و Projection استفاده شود، تفاوت کم است. Dapper برای SQLهای دست‌تنظیم می‌درخشد و خیلی از پروژه‌ها از هر دو استفاده می‌کنند.

آیا Include همیشه مشکل کارایی ایجاد می‌کند؟

نه. Include کردن یک ارجاع یا یک کالکشن کوچک مشکلی ندارد. مشکلات با کالکشن‌های تودرتوی زیاد شروع می‌شوند؛ آن‌وقت از Projection یا Split Query استفاده کنید.

آیا ردیابی تغییرات را سراسری غیرفعال کنم؟

در سرویس‌های پرخواندن، پیش‌فرض کردن NoTracking و استفاده از AsTracking() برای به‌روزرسانی‌ها رویکرد تمیزی است.